mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Merge secrets unified UX + table redesign chain (PAP-12267 branch tip 84507ce60) into PAP-12349 branch off current master
Co-Authored-By: Paperclip <noreply@paperclip.ing> # Conflicts: # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
commit
377ebefd03
98 files changed
+7199
-481
No files matched your search
@@ -46,6 +46,9 @@ function secret(partial: Partial<CompanySecret>): CompanySecret {
|
||||
return {
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "agent_agent-12_anthropic_api_key",
|
||||
name: "agent_agent-12_anthropic_api_key",
|
||||
provider: "local_encrypted",
|
||||
|
||||
@@ -174,11 +174,21 @@ up separately when you need full instance disaster recovery.
|
||||
|
||||
Paperclip stores secret metadata and versions in:
|
||||
|
||||
- `user_secret_definitions`
|
||||
- `user_secret_declarations`
|
||||
- `company_secrets`
|
||||
- `company_secret_versions`
|
||||
- `company_secret_bindings`
|
||||
- `secret_access_events`
|
||||
|
||||
Company secrets use `company_secrets.scope = 'company'` and are bound directly
|
||||
through `company_secret_bindings`. User-specific secrets reuse the same provider
|
||||
and version storage, but each value is a `company_secrets.scope = 'user'` row
|
||||
with `owner_user_id` and `user_secret_definition_id` set. Definitions describe
|
||||
the reusable company-level slot, declarations record where `user_secret_ref`
|
||||
bindings are required, and the concrete value is selected later for the
|
||||
responsible user.
|
||||
|
||||
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
|
||||
|
||||
For local/default installs, the active provider is `local_encrypted`:
|
||||
@@ -188,6 +198,10 @@ For local/default installs, the active provider is `local_encrypted`:
|
||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
||||
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
||||
- User-scoped values use the same local encrypted provider path. Database
|
||||
backups preserve definitions, declarations, owner metadata, version metadata,
|
||||
and access events, but restored user-scoped values are decryptable only when
|
||||
the matching local master key is restored with the database.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Operational contract for the hosted `aws_secrets_manager` secret provider used b
|
||||
|
||||
## Scope
|
||||
|
||||
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
|
||||
- Hosted provider for Paperclip-managed company and user-scoped secrets when Paperclip Cloud runs on AWS.
|
||||
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
|
||||
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
|
||||
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
|
||||
@@ -120,6 +120,25 @@ Tag set for Paperclip-managed secrets:
|
||||
- `paperclip:secret-key=<secret key>`
|
||||
- `paperclip:environment=<environment tag>`
|
||||
|
||||
When the user-secret service creates Paperclip-managed AWS values, keep them
|
||||
under the same deployment/company namespace. The secret-key segment should be
|
||||
non-sensitive and collision-safe for the user-secret value, normally derived
|
||||
from the definition key plus an opaque credential-owner subject. Do not put
|
||||
emails, personal names, OAuth scopes, ticket identifiers, or plaintext
|
||||
credential material in AWS secret names, descriptions, or tags.
|
||||
|
||||
For operator-owned external references, prefer a separate approved prefix such
|
||||
as:
|
||||
|
||||
```text
|
||||
paperclip-ext/<environment>/<company-id>/user-secrets/<definition-key>/<opaque-owner-id>
|
||||
```
|
||||
|
||||
Paperclip stores the external ref and provider version as metadata. Treat those
|
||||
fields as secret-adjacent: they must be redacted from comments, activity logs,
|
||||
run transcripts, issue documents, and broad board views unless a route is
|
||||
explicitly designed to show sanitized provider metadata.
|
||||
|
||||
## IAM And KMS Assumptions
|
||||
|
||||
Launch posture:
|
||||
@@ -223,6 +242,14 @@ If selected external secrets use customer-managed KMS keys, also grant
|
||||
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
|
||||
remote import.
|
||||
|
||||
The same rule applies to user-scoped external refs. Paperclip derives the
|
||||
responsible user and credential owner before resolution, but AWS IAM still
|
||||
decides whether the runtime role can read the selected ARN/path. If the runtime
|
||||
role can read a broad external prefix, that access is broader than Paperclip's
|
||||
metadata policy. Use dedicated provider vaults, accounts, Regions, prefixes,
|
||||
KMS keys, or runtime roles when provider-side isolation must match a
|
||||
user-secret boundary.
|
||||
|
||||
Safe scoping guidance:
|
||||
|
||||
- Prefer one Paperclip runtime role per environment/account.
|
||||
@@ -288,16 +315,24 @@ Guidance:
|
||||
What must survive:
|
||||
|
||||
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
|
||||
- User-secret definitions, declarations, `company_secrets.scope = 'user'`
|
||||
value rows, owner user ids, responsible-user snapshots, access-event
|
||||
metadata, and provider version refs.
|
||||
- AWS Secrets Manager namespace under the configured deployment prefix.
|
||||
- Any operator-owned external AWS prefixes linked by user-scoped external refs.
|
||||
- The configured KMS key and its decrypt permissions.
|
||||
|
||||
Restore checklist:
|
||||
|
||||
1. Restore Paperclip database metadata.
|
||||
2. Confirm the same AWS Secrets Manager namespace still exists.
|
||||
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
|
||||
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
|
||||
5. Run the live smoke below or a targeted runtime secret resolution test.
|
||||
3. Confirm any linked user-scoped external prefixes still exist.
|
||||
4. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored
|
||||
managed prefix and approved external prefixes.
|
||||
5. Confirm the role still has decrypt access to the CMKs referenced by
|
||||
`PAPERCLIP_SECRETS_AWS_KMS_KEY_ID` and by any external user-secret values.
|
||||
6. Run the live smoke below or a targeted runtime secret resolution test for
|
||||
both a company secret and a user-secret value.
|
||||
|
||||
## Provider Outage Runbook
|
||||
|
||||
@@ -342,6 +377,8 @@ Response steps:
|
||||
|
||||
1. Stop or pause affected Paperclip runs.
|
||||
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
|
||||
For user-scoped incidents, include `credentialOwnerUserId`,
|
||||
`responsibleUserId`, and `userSecretDefinitionId` in the review.
|
||||
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
|
||||
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
|
||||
Region, deployment prefix, and approved external prefixes.
|
||||
|
||||
@@ -399,6 +399,30 @@ end at injection: the agent process can read, log, or forward the value, so
|
||||
treat any secret bound to an agent as exposed to that agent. See the custody
|
||||
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
|
||||
|
||||
User-specific env bindings use a definition key instead of a concrete
|
||||
`secretId`. The concrete value is resolved for the run's responsible user:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"GITHUB_TOKEN": {
|
||||
"type": "user_secret_ref",
|
||||
"key": "github_api_token",
|
||||
"version": "latest",
|
||||
"required": true,
|
||||
"allowMissingOverride": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`required` defaults to `true` and `allowMissingOverride` defaults to `false`.
|
||||
Missing required user-secret values must fail closed before adapter dispatch.
|
||||
Optional missing values omit the environment variable; they must not inject an
|
||||
empty string or another user's value. Paperclip records value-free access
|
||||
events with `secretScope`, `responsibleUserId`, `credentialOwnerUserId`, and
|
||||
`userSecretDefinitionId`.
|
||||
|
||||
## Portability
|
||||
|
||||
Company export/import APIs represent agent and project environment requirements
|
||||
|
||||
+118
-5
@@ -51,6 +51,114 @@ Project env applies to every issue run in that project. When a project env key
|
||||
matches an agent env key, the project value wins before Paperclip injects its
|
||||
own `PAPERCLIP_*` runtime variables.
|
||||
|
||||
## User-Specific Secrets
|
||||
|
||||
User-specific secrets let a shared agent or project declare a slot such as
|
||||
`github_api_token`, then resolve the value owned by the run's responsible user
|
||||
at dispatch time. The environment binding stores only the definition key:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"GITHUB_TOKEN": {
|
||||
"type": "user_secret_ref",
|
||||
"key": "github_api_token",
|
||||
"required": true,
|
||||
"allowMissingOverride": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Paperclip stores the feature as value-free metadata plus the user's own secret
|
||||
value record:
|
||||
|
||||
- `user_secret_definitions`: company-level metadata for the reusable slot.
|
||||
- `user_secret_declarations`: target/config-path declarations for
|
||||
`user_secret_ref` bindings, including required/optional policy.
|
||||
- `company_secrets` rows with `scope = "user"`, `owner_user_id`, and
|
||||
`user_secret_definition_id`: the current user's actual value record.
|
||||
- `company_secret_versions`: encrypted or provider-backed version metadata for
|
||||
the value record.
|
||||
|
||||
Board/admin operators manage definitions and coverage. Individual users manage
|
||||
their own values. Board/admin coverage views must stay metadata-only: they can
|
||||
show missing, configured, inactive, provider, and vault status, but not
|
||||
plaintext values, raw external refs, provider credentials, or provider error
|
||||
payloads.
|
||||
|
||||
Required user-secret refs fail closed when the responsible user is missing,
|
||||
the definition is missing, or the responsible user has no active value.
|
||||
Optional refs may omit the env var. They must not inject blank credentials or
|
||||
fall back to another user's value.
|
||||
|
||||
### Vault Placement
|
||||
|
||||
User-scoped values can use the same provider families as company secrets:
|
||||
|
||||
- `local_encrypted`: the default local path. It is appropriate for local
|
||||
trusted installs and small self-hosted deployments. The same master key
|
||||
protects both company and user-scoped values.
|
||||
- `aws_secrets_manager`: the hosted/provider-vault path. Use it when the
|
||||
deployment already relies on AWS Secrets Manager, KMS, CloudTrail, and
|
||||
infrastructure IAM for custody.
|
||||
- Dedicated provider vaults: optional. Use a dedicated vault only when you need
|
||||
a separate AWS account, Region, KMS key, prefix, retention posture, or
|
||||
import boundary for user-owned values. Do not create one vault per user by
|
||||
default; prefer one vault per environment or compliance boundary.
|
||||
|
||||
A user-secret definition can carry provider and vault defaults. A user's value
|
||||
may also carry provider/vault metadata when the implementation path supports
|
||||
overrides. In both cases, provider vault config contains non-secret routing
|
||||
metadata only. Provider credentials still come from deployment infrastructure
|
||||
identity, not from Paperclip secrets.
|
||||
|
||||
### External Reference Naming
|
||||
|
||||
External vault references are secret-adjacent metadata. Treat paths, ARNs,
|
||||
versions, aliases, and tags as operationally sensitive even though they are not
|
||||
plaintext secret values.
|
||||
|
||||
Recommended external ref shape for operator-owned AWS paths:
|
||||
|
||||
```text
|
||||
paperclip-ext/{environment}/{company-id}/user-secrets/{definition-key}/{opaque-owner-id}
|
||||
```
|
||||
|
||||
Guidance:
|
||||
|
||||
- Use the user-secret definition key for the credential type, such as
|
||||
`github_api_token`.
|
||||
- Use an opaque stable user id or one-way mapped subject id for the owner
|
||||
segment. Avoid emails, personal names, customer names, OAuth scopes, or
|
||||
ticket identifiers in provider paths.
|
||||
- Keep provider metadata value-free. Safe metadata examples are provider id,
|
||||
vault id, Region, KMS key id or alias, tag counts, and fingerprint hashes.
|
||||
Do not store raw AWS descriptions, full tag maps, token scopes, provider
|
||||
error bodies, or anything copied from the secret value.
|
||||
- Keep Paperclip-managed AWS values under the Paperclip managed namespace.
|
||||
External refs under that namespace are blocked by guardrails; use the
|
||||
Paperclip-managed flow when Paperclip should create and rotate the value.
|
||||
|
||||
### IAM Caveats
|
||||
|
||||
Paperclip enforces company scoping, responsible-user derivation, declaration
|
||||
policy, current-user value APIs, redaction, and access-event metadata. It does
|
||||
not replace the IAM policy of an external vault.
|
||||
|
||||
For AWS Secrets Manager, the Paperclip runtime role needs `GetSecretValue` and
|
||||
any required KMS decrypt permission for every user-scoped value it may resolve.
|
||||
If you link user-specific external refs outside the Paperclip managed prefix,
|
||||
scope those permissions to the approved external prefixes and KMS keys. AWS
|
||||
tag/name filters help operators search, but they are not a reliable permission
|
||||
boundary for resolution.
|
||||
|
||||
If stronger provider-side isolation is required, split user-secret workloads by
|
||||
provider vault, AWS account, Region, prefix, or runtime role before linking the
|
||||
refs. Paperclip can prevent an agent from choosing another credential owner,
|
||||
but a runtime role with broad external vault read permissions can still read
|
||||
what IAM allows if another code path is introduced outside Paperclip.
|
||||
|
||||
## Default Provider: `local_encrypted`
|
||||
|
||||
Secrets are encrypted with a local master key stored at:
|
||||
@@ -319,13 +427,18 @@ Each provider family has a different backup story:
|
||||
- `local_encrypted`: back up the local master key file and the Paperclip
|
||||
database together. Either alone is not enough to restore the encrypted
|
||||
values, and the vault row only records the path and acknowledgement, not the
|
||||
key bytes.
|
||||
key bytes. This includes user-scoped values: the database holds
|
||||
`user_secret_definitions`, `user_secret_declarations`,
|
||||
`company_secrets.scope = "user"` rows, version metadata, and owner ids; the
|
||||
key file is required to decrypt their local material.
|
||||
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
|
||||
(vault id, region, prefix, KMS key id, default flag, bindings, version
|
||||
pointers). The actual secret values live in AWS Secrets Manager under the
|
||||
configured prefix; restore by pointing the same Paperclip company at the
|
||||
same AWS namespace and confirming the runtime role still has
|
||||
`GetSecretValue` plus KMS decrypt. The full restore checklist lives in
|
||||
pointers, user-secret definitions/declarations, owner ids, and access-event
|
||||
metadata). The actual secret values live in AWS Secrets Manager under the
|
||||
configured prefix or operator-owned external refs; restore by pointing the
|
||||
same Paperclip company at the same AWS namespace and confirming the runtime
|
||||
role still has `GetSecretValue` plus KMS decrypt for both managed and linked
|
||||
user-scoped values. The full restore checklist lives in
|
||||
`doc/SECRETS-AWS-PROVIDER.md`.
|
||||
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
|
||||
draft vault config exists in Paperclip. Database backups capture it. There
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
CREATE TABLE IF NOT EXISTS "user_secret_definitions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"provider" text DEFAULT 'local_encrypted' NOT NULL,
|
||||
"managed_mode" text DEFAULT 'paperclip_managed' NOT NULL,
|
||||
"provider_config_id" uuid,
|
||||
"provider_metadata" jsonb,
|
||||
"usage_guidance" text,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"updated_by_agent_id" uuid,
|
||||
"updated_by_user_id" text,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "user_secret_declarations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"user_secret_definition_id" uuid NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"config_path" text NOT NULL,
|
||||
"env_key" text NOT NULL,
|
||||
"version_selector" text DEFAULT 'latest' NOT NULL,
|
||||
"required" boolean DEFAULT true NOT NULL,
|
||||
"allow_missing_override" boolean DEFAULT false NOT NULL,
|
||||
"label" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "scope" text DEFAULT 'company' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "owner_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid;
|
||||
--> statement-breakpoint
|
||||
UPDATE "company_secrets"
|
||||
SET "scope" = 'company'
|
||||
WHERE "scope" IS NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ALTER COLUMN "secret_id" DROP NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "secret_scope" text DEFAULT 'company' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_owner_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_type" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routine_revisions" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk') THEN
|
||||
ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk" FOREIGN KEY ("provider_config_id") REFERENCES "public"."company_secret_provider_configs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_updated_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_declarations_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk') THEN
|
||||
ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_user_secret_definition_id_user_secret_definitions_id_fk') THEN
|
||||
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_scope_shape_check') THEN
|
||||
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_scope_shape_check" CHECK (
|
||||
("scope" = 'company' AND "owner_user_id" IS NULL AND "user_secret_definition_id" IS NULL)
|
||||
OR
|
||||
("scope" = 'user' AND "owner_user_id" IS NOT NULL AND "user_secret_definition_id" IS NOT NULL)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "company_secrets_company_name_uq";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "company_secrets_company_key_uq";
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_status_idx" ON "user_secret_definitions" USING btree ("company_id","status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_provider_idx" ON "user_secret_definitions" USING btree ("company_id","provider");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_definitions_provider_config_idx" ON "user_secret_definitions" USING btree ("provider_config_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_definitions_company_key_uq" ON "user_secret_definitions" USING btree ("company_id","key") WHERE "user_secret_definitions"."deleted_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_idx" ON "user_secret_declarations" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_declarations_definition_idx" ON "user_secret_declarations" USING btree ("user_secret_definition_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_declarations_target_idx" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_required_idx" ON "user_secret_declarations" USING btree ("company_id","required");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_declarations_target_path_uq" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id","config_path");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_secret_declarations_required_override_idx" ON "user_secret_declarations" USING btree ("company_id","allow_missing_override") WHERE "user_secret_declarations"."allow_missing_override" = true;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secrets_company_scope_idx" ON "company_secrets" USING btree ("company_id","scope");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secrets_company_owner_idx" ON "company_secrets" USING btree ("company_id","owner_user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_idx" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_name_uq" ON "company_secrets" USING btree ("company_id","name") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_uq" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id") WHERE "company_secrets"."scope" = 'user' AND "company_secrets"."deleted_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_user_definition_created_idx" ON "secret_access_events" USING btree ("user_secret_definition_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_company_credential_owner_idx" ON "secret_access_events" USING btree ("company_id","credential_owner_user_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issues_company_responsible_user_idx" ON "issues" USING btree ("company_id","responsible_user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_responsible_user_idx" ON "heartbeat_runs" USING btree ("company_id","responsible_user_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routines_company_responsible_user_idx" ON "routines" USING btree ("company_id","responsible_user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routine_revisions_company_responsible_user_idx" ON "routine_revisions" USING btree ("company_id","responsible_user_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routine_runs_company_responsible_user_idx" ON "routine_runs" USING btree ("company_id","responsible_user_id","created_at");
|
||||
@@ -890,6 +890,13 @@
|
||||
"when": 1782526400000,
|
||||
"tag": "0126_issue_comment_derived_attribution",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 127,
|
||||
"version": "7",
|
||||
"when": 1782923938661,
|
||||
"tag": "0127_user_specific_secrets",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
import { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
|
||||
export const companySecrets = pgTable(
|
||||
"company_secrets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
scope: text("scope").notNull().default("company"),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }),
|
||||
key: text("key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
provider: text("provider").notNull().default("local_encrypted"),
|
||||
@@ -28,9 +33,35 @@ export const companySecrets = pgTable(
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("company_secrets_company_idx").on(table.companyId),
|
||||
companyScopeIdx: index("company_secrets_company_scope_idx").on(table.companyId, table.scope),
|
||||
companyOwnerIdx: index("company_secrets_company_owner_idx").on(table.companyId, table.ownerUserId),
|
||||
userDefinitionOwnerIdx: index("company_secrets_user_definition_owner_idx").on(
|
||||
table.companyId,
|
||||
table.userSecretDefinitionId,
|
||||
table.ownerUserId,
|
||||
),
|
||||
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
||||
providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId),
|
||||
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
|
||||
companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
|
||||
companyNameUq: uniqueIndex("company_secrets_company_name_uq")
|
||||
.on(table.companyId, table.name)
|
||||
.where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`),
|
||||
companyKeyUq: uniqueIndex("company_secrets_company_key_uq")
|
||||
.on(table.companyId, table.key)
|
||||
.where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`),
|
||||
userDefinitionOwnerUq: uniqueIndex("company_secrets_user_definition_owner_uq")
|
||||
.on(table.companyId, table.userSecretDefinitionId, table.ownerUserId)
|
||||
.where(sql`${table.scope} = 'user' and ${table.deletedAt} is null`),
|
||||
scopeShapeCheck: check(
|
||||
"company_secrets_scope_shape_check",
|
||||
sql`(
|
||||
${table.scope} = 'company'
|
||||
and ${table.ownerUserId} is null
|
||||
and ${table.userSecretDefinitionId} is null
|
||||
) or (
|
||||
${table.scope} = 'user'
|
||||
and ${table.ownerUserId} is not null
|
||||
and ${table.userSecretDefinitionId} is not null
|
||||
)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -12,6 +12,7 @@ export const heartbeatRuns = pgTable(
|
||||
invocationSource: text("invocation_source").notNull().default("on_demand"),
|
||||
triggerDetail: text("trigger_detail"),
|
||||
status: text("status").notNull().default("queued"),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
@@ -63,6 +64,11 @@ export const heartbeatRuns = pgTable(
|
||||
table.agentId,
|
||||
table.startedAt,
|
||||
),
|
||||
companyResponsibleUserIdx: index("heartbeat_runs_company_responsible_user_idx").on(
|
||||
table.companyId,
|
||||
table.responsibleUserId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on(
|
||||
table.companyId,
|
||||
table.livenessState,
|
||||
|
||||
@@ -84,9 +84,11 @@ export { approvals } from "./approvals.js";
|
||||
export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
export { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
export { userSecretDeclarations } from "./user_secret_declarations.js";
|
||||
export { secretAccessEvents } from "./secret_access_events.js";
|
||||
export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
|
||||
@@ -41,6 +41,7 @@ export const issues = pgTable(
|
||||
executionLockedAt: timestamp("execution_locked_at", { withTimezone: true }),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
issueNumber: integer("issue_number"),
|
||||
identifier: text("identifier"),
|
||||
originKind: text("origin_kind").notNull().default("manual"),
|
||||
@@ -82,6 +83,7 @@ export const issues = pgTable(
|
||||
table.assigneeUserId,
|
||||
table.status,
|
||||
),
|
||||
responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
|
||||
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
||||
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
||||
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
|
||||
@@ -42,6 +42,7 @@ export const routines = pgTable(
|
||||
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
lastTriggeredAt: timestamp("last_triggered_at", { withTimezone: true }),
|
||||
@@ -53,6 +54,7 @@ export const routines = pgTable(
|
||||
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
|
||||
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
|
||||
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
|
||||
companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
|
||||
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
}),
|
||||
);
|
||||
@@ -75,6 +77,7 @@ export const routineRevisions = pgTable(
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -87,6 +90,11 @@ export const routineRevisions = pgTable(
|
||||
table.routineId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyResponsibleUserIdx: index("routine_revisions_company_responsible_user_idx").on(
|
||||
table.companyId,
|
||||
table.responsibleUserId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -136,6 +144,7 @@ export const routineRuns = pgTable(
|
||||
status: text("status").notNull().default("received"),
|
||||
triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
triggerPayload: jsonb("trigger_payload").$type<Record<string, unknown>>(),
|
||||
dispatchFingerprint: text("dispatch_fingerprint"),
|
||||
@@ -149,6 +158,11 @@ export const routineRuns = pgTable(
|
||||
(table) => ({
|
||||
companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt),
|
||||
routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId),
|
||||
companyResponsibleUserIdx: index("routine_runs_company_responsible_user_idx").on(
|
||||
table.companyId,
|
||||
table.responsibleUserId,
|
||||
table.createdAt,
|
||||
),
|
||||
triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt),
|
||||
dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint),
|
||||
linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId),
|
||||
|
||||
@@ -4,15 +4,22 @@ import { companySecrets } from "./company_secrets.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
|
||||
export const secretAccessEvents = pgTable(
|
||||
"secret_access_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||
secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||
userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }),
|
||||
secretScope: text("secret_scope").notNull().default("company"),
|
||||
version: integer("version"),
|
||||
provider: text("provider").notNull(),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
credentialOwnerUserId: text("credential_owner_user_id"),
|
||||
credentialSubjectType: text("credential_subject_type"),
|
||||
credentialSubjectId: text("credential_subject_id"),
|
||||
actorType: text("actor_type").notNull(),
|
||||
actorId: text("actor_id"),
|
||||
consumerType: text("consumer_type").notNull(),
|
||||
@@ -28,6 +35,15 @@ export const secretAccessEvents = pgTable(
|
||||
(table) => ({
|
||||
companyCreatedIdx: index("secret_access_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
secretCreatedIdx: index("secret_access_events_secret_created_idx").on(table.secretId, table.createdAt),
|
||||
userDefinitionCreatedIdx: index("secret_access_events_user_definition_created_idx").on(
|
||||
table.userSecretDefinitionId,
|
||||
table.createdAt,
|
||||
),
|
||||
credentialOwnerIdx: index("secret_access_events_company_credential_owner_idx").on(
|
||||
table.companyId,
|
||||
table.credentialOwnerUserId,
|
||||
table.createdAt,
|
||||
),
|
||||
consumerIdx: index("secret_access_events_consumer_idx").on(table.companyId, table.consumerType, table.consumerId),
|
||||
runIdx: index("secret_access_events_run_idx").on(table.heartbeatRunId),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
|
||||
export const userSecretDeclarations = pgTable(
|
||||
"user_secret_declarations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
userSecretDefinitionId: uuid("user_secret_definition_id")
|
||||
.notNull()
|
||||
.references(() => userSecretDefinitions.id, { onDelete: "cascade" }),
|
||||
targetType: text("target_type").notNull(),
|
||||
targetId: text("target_id").notNull(),
|
||||
configPath: text("config_path").notNull(),
|
||||
envKey: text("env_key").notNull(),
|
||||
versionSelector: text("version_selector").notNull().default("latest"),
|
||||
required: boolean("required").notNull().default(true),
|
||||
allowMissingOverride: boolean("allow_missing_override").notNull().default(false),
|
||||
label: text("label"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("user_secret_declarations_company_idx").on(table.companyId),
|
||||
definitionIdx: index("user_secret_declarations_definition_idx").on(table.userSecretDefinitionId),
|
||||
targetIdx: index("user_secret_declarations_target_idx").on(table.companyId, table.targetType, table.targetId),
|
||||
companyRequiredIdx: index("user_secret_declarations_company_required_idx").on(table.companyId, table.required),
|
||||
targetPathUq: uniqueIndex("user_secret_declarations_target_path_uq").on(
|
||||
table.companyId,
|
||||
table.targetType,
|
||||
table.targetId,
|
||||
table.configPath,
|
||||
),
|
||||
requiredOverrideCheck: index("user_secret_declarations_required_override_idx")
|
||||
.on(table.companyId, table.allowMissingOverride)
|
||||
.where(sql`${table.allowMissingOverride} = true`),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
|
||||
export const userSecretDefinitions = pgTable(
|
||||
"user_secret_definitions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
key: text("key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").notNull().default("active"),
|
||||
provider: text("provider").notNull().default("local_encrypted"),
|
||||
managedMode: text("managed_mode").notNull().default("paperclip_managed"),
|
||||
providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }),
|
||||
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
|
||||
usageGuidance: text("usage_guidance"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyStatusIdx: index("user_secret_definitions_company_status_idx").on(table.companyId, table.status),
|
||||
companyProviderIdx: index("user_secret_definitions_company_provider_idx").on(table.companyId, table.provider),
|
||||
providerConfigIdx: index("user_secret_definitions_provider_config_idx").on(table.providerConfigId),
|
||||
companyKeyUq: uniqueIndex("user_secret_definitions_company_key_uq")
|
||||
.on(table.companyId, table.key)
|
||||
.where(sql`${table.deletedAt} is null`),
|
||||
}),
|
||||
);
|
||||
@@ -573,6 +573,7 @@ function paperclipIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
parentIssueId: null,
|
||||
title: declaration.title,
|
||||
description: declaration.description ?? null,
|
||||
responsibleUserId: null,
|
||||
assigneeAgentId,
|
||||
priority: declaration.priority ?? "medium",
|
||||
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
|
||||
@@ -1574,6 +1575,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
responsibleUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
originKind,
|
||||
|
||||
@@ -20,6 +20,11 @@ export const API = {
|
||||
goals: `${API_PREFIX}/goals`,
|
||||
approvals: `${API_PREFIX}/approvals`,
|
||||
secrets: `${API_PREFIX}/secrets`,
|
||||
userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`,
|
||||
userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`,
|
||||
userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`,
|
||||
myUserSecrets: `${API_PREFIX}/companies/:companyId/me/user-secrets`,
|
||||
myUserSecret: `${API_PREFIX}/companies/:companyId/me/user-secrets/:secretId`,
|
||||
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
|
||||
secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`,
|
||||
costs: `${API_PREFIX}/costs`,
|
||||
|
||||
@@ -618,6 +618,9 @@ export type SecretProviderConfigHealthStatus =
|
||||
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
|
||||
export type SecretStatus = (typeof SECRET_STATUSES)[number];
|
||||
|
||||
export const SECRET_SCOPES = ["company", "user"] as const;
|
||||
export type SecretScope = (typeof SECRET_SCOPES)[number];
|
||||
|
||||
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
|
||||
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
|
||||
|
||||
@@ -642,7 +645,15 @@ export const SECRET_BINDING_TARGET_TYPES = [
|
||||
] as const;
|
||||
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
|
||||
|
||||
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
|
||||
export const SECRET_ACCESS_OUTCOMES = [
|
||||
"success",
|
||||
"failure",
|
||||
"missing",
|
||||
"inactive",
|
||||
"not_allowed",
|
||||
"optional_omitted",
|
||||
"provider_error",
|
||||
] as const;
|
||||
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
|
||||
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
|
||||
@@ -183,6 +183,7 @@ export {
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
|
||||
SECRET_SCOPES,
|
||||
STORAGE_PROVIDERS,
|
||||
BILLING_TYPES,
|
||||
FINANCE_EVENT_KINDS,
|
||||
@@ -320,6 +321,7 @@ export {
|
||||
type SecretProvider,
|
||||
type SecretProviderConfigStatus,
|
||||
type SecretProviderConfigHealthStatus,
|
||||
type SecretScope,
|
||||
type StorageProvider,
|
||||
type BillingType,
|
||||
type FinanceEventKind,
|
||||
@@ -812,8 +814,12 @@ export type {
|
||||
EnvBinding,
|
||||
EnvPlainBinding,
|
||||
EnvSecretRefBinding,
|
||||
EnvUserSecretRefBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
UserSecretDefinition,
|
||||
UserSecretDeclaration,
|
||||
UserSecretCoverageSummary,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigPayload,
|
||||
SecretProviderConfigHealthDetails,
|
||||
@@ -1280,9 +1286,16 @@ export {
|
||||
type AddApprovalComment,
|
||||
envBindingPlainSchema,
|
||||
envBindingSecretRefSchema,
|
||||
envBindingUserSecretRefSchema,
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createUserSecretDefinitionSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
createUserSecretValueSchema,
|
||||
updateUserSecretValueSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
createUserSecretDeclarationSchema,
|
||||
createSecretProviderConfigSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface HeartbeatRun {
|
||||
invocationSource: HeartbeatInvocationSource;
|
||||
triggerDetail: WakeupTriggerDetail | null;
|
||||
status: HeartbeatRunStatus;
|
||||
responsibleUserId: string | null;
|
||||
startedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
error: string | null;
|
||||
|
||||
@@ -407,9 +407,13 @@ export type {
|
||||
SecretVersionSelector,
|
||||
EnvPlainBinding,
|
||||
EnvSecretRefBinding,
|
||||
EnvUserSecretRefBinding,
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
UserSecretDefinition,
|
||||
UserSecretDeclaration,
|
||||
UserSecretCoverageSummary,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigPayload,
|
||||
SecretProviderConfigHealthDetails,
|
||||
@@ -433,6 +437,7 @@ export type {
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretScope,
|
||||
SecretProviderDescriptor,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
|
||||
@@ -550,6 +550,7 @@ export interface Issue {
|
||||
executionLockedAt: Date | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
issueNumber: number | null;
|
||||
identifier: string | null;
|
||||
originKind?: IssueOriginKind;
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface Routine {
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
lastTriggeredAt: Date | null;
|
||||
@@ -126,6 +127,7 @@ export interface RoutineRevisionSnapshotRoutineV1 {
|
||||
originId?: string | null;
|
||||
variables: RoutineVariable[];
|
||||
env: RoutineEnvConfig | null;
|
||||
responsibleUserId: string | null;
|
||||
}
|
||||
|
||||
export interface RoutineRevisionSnapshotTriggerV1 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretScope,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
} from "../constants.js";
|
||||
@@ -16,6 +17,7 @@ export type {
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretScope,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
};
|
||||
@@ -33,14 +35,25 @@ export interface EnvSecretRefBinding {
|
||||
version?: SecretVersionSelector;
|
||||
}
|
||||
|
||||
export interface EnvUserSecretRefBinding {
|
||||
type: "user_secret_ref";
|
||||
key: string;
|
||||
version?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
allowMissingOverride?: boolean;
|
||||
}
|
||||
|
||||
// Backward-compatible: legacy plaintext string values are still accepted.
|
||||
export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding;
|
||||
export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding | EnvUserSecretRefBinding;
|
||||
|
||||
export type AgentEnvConfig = Record<string, EnvBinding>;
|
||||
|
||||
export interface CompanySecret {
|
||||
id: string;
|
||||
companyId: string;
|
||||
scope: SecretScope;
|
||||
ownerUserId: string | null;
|
||||
userSecretDefinitionId: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
@@ -61,6 +74,50 @@ export interface CompanySecret {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserSecretDefinition {
|
||||
id: string;
|
||||
companyId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: SecretStatus;
|
||||
provider: SecretProvider;
|
||||
managedMode: SecretManagedMode;
|
||||
providerConfigId: string | null;
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
usageGuidance: string | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
deletedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserSecretDeclaration {
|
||||
id: string;
|
||||
companyId: string;
|
||||
userSecretDefinitionId: string;
|
||||
targetType: SecretBindingTargetType;
|
||||
targetId: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
required: boolean;
|
||||
allowMissingOverride: boolean;
|
||||
label: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserSecretCoverageSummary {
|
||||
definitionId: string;
|
||||
configuredCount: number;
|
||||
missingCount: number;
|
||||
inactiveCount: number;
|
||||
}
|
||||
|
||||
export interface SecretProviderDescriptor {
|
||||
id: SecretProvider;
|
||||
label: string;
|
||||
@@ -216,9 +273,15 @@ export interface CompanySecretUsageBinding extends CompanySecretBinding {
|
||||
export interface SecretAccessEvent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
secretId: string | null;
|
||||
userSecretDefinitionId: string | null;
|
||||
secretScope: SecretScope;
|
||||
version: number | null;
|
||||
provider: SecretProvider;
|
||||
responsibleUserId: string | null;
|
||||
credentialOwnerUserId: string | null;
|
||||
credentialSubjectType: string | null;
|
||||
credentialSubjectId: string | null;
|
||||
actorType: "agent" | "user" | "system" | "plugin";
|
||||
actorId: string | null;
|
||||
consumerType: SecretBindingTargetType;
|
||||
|
||||
@@ -463,9 +463,16 @@ export {
|
||||
export {
|
||||
envBindingPlainSchema,
|
||||
envBindingSecretRefSchema,
|
||||
envBindingUserSecretRefSchema,
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createUserSecretDefinitionSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
createUserSecretValueSchema,
|
||||
updateUserSecretValueSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
createUserSecretDeclarationSchema,
|
||||
createSecretProviderConfigSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
@@ -483,6 +490,11 @@ export {
|
||||
updateSecretSchema,
|
||||
type CreateSecretBinding,
|
||||
type CreateSecret,
|
||||
type CreateUserSecretDefinition,
|
||||
type UpdateUserSecretDefinition,
|
||||
type CreateUserSecretValue,
|
||||
type UpdateUserSecretValue,
|
||||
type CreateUserSecretDeclaration,
|
||||
type CreateSecretProviderConfig,
|
||||
type UpdateSecretProviderConfig,
|
||||
type SecretProviderConfigDiscoveryPreview,
|
||||
|
||||
@@ -96,6 +96,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES),
|
||||
variables: z.array(routineVariableSchema),
|
||||
env: envConfigSchema.nullable().default(null),
|
||||
responsibleUserId: z.string().nullable().default(null),
|
||||
}).strict();
|
||||
|
||||
export const routineRevisionSnapshotTriggerV1Schema = z.object({
|
||||
|
||||
@@ -1,15 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createUserSecretDefinitionSchema,
|
||||
createUserSecretDeclarationSchema,
|
||||
createUserSecretValueSchema,
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
envBindingUserSecretRefSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
rotateSecretSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
secretProviderConfigPayloadSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
} from "./secret.js";
|
||||
|
||||
describe("secret validators", () => {
|
||||
it("defaults user secret refs to required and no missing override", () => {
|
||||
expect(
|
||||
envBindingUserSecretRefSchema.parse({
|
||||
type: "user_secret_ref",
|
||||
key: "github_api_token",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "user_secret_ref",
|
||||
key: "github_api_token",
|
||||
required: true,
|
||||
allowMissingOverride: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("validates user secret declarations and current-user value payloads", () => {
|
||||
expect(
|
||||
createUserSecretDeclarationSchema.parse({
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
definitionKey: "github_api_token",
|
||||
}),
|
||||
).toMatchObject({
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
allowMissingOverride: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
createUserSecretValueSchema.parse({
|
||||
value: "secret-value",
|
||||
}),
|
||||
).toThrow(/definitionId or definitionKey/);
|
||||
});
|
||||
|
||||
it("does not allow user secret definition keys to be renamed through updates", () => {
|
||||
expect(
|
||||
updateUserSecretDefinitionSchema.parse({
|
||||
key: "renamed_key",
|
||||
name: "Renamed",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not allow user secret definitions to be created as deleted", () => {
|
||||
expect(() =>
|
||||
createUserSecretDefinitionSchema.parse({
|
||||
key: "github_api_token",
|
||||
name: "GitHub API token",
|
||||
status: "deleted",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("requires secret rotation payloads to include rotation input", () => {
|
||||
expect(() => rotateSecretSchema.parse({})).toThrow(/requires value, externalRef/);
|
||||
expect(() => rotateUserSecretValueSchema.parse({})).toThrow(/requires value, externalRef/);
|
||||
expect(rotateUserSecretValueSchema.parse({ value: "new-secret" })).toEqual({
|
||||
value: "new-secret",
|
||||
});
|
||||
expect(rotateUserSecretValueSchema.parse({ providerVersionRef: "version-2" })).toEqual({
|
||||
providerVersionRef: "version-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects externalRef on managed secrets", () => {
|
||||
expect(() =>
|
||||
createSecretSchema.parse({
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
SECRET_STATUSES,
|
||||
} from "../constants.js";
|
||||
|
||||
const secretKeySchema = z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/);
|
||||
const secretVersionSelectorSchema = z.union([z.literal("latest"), z.number().int().positive()]);
|
||||
const creatableSecretStatusSchema = z.enum(["active", "disabled", "archived"]);
|
||||
|
||||
export const envBindingPlainSchema = z.object({
|
||||
type: z.literal("plain"),
|
||||
value: z.string(),
|
||||
@@ -15,7 +19,15 @@ export const envBindingPlainSchema = z.object({
|
||||
export const envBindingSecretRefSchema = z.object({
|
||||
type: z.literal("secret_ref"),
|
||||
secretId: z.string().uuid(),
|
||||
version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
|
||||
version: secretVersionSelectorSchema.optional(),
|
||||
});
|
||||
|
||||
export const envBindingUserSecretRefSchema = z.object({
|
||||
type: z.literal("user_secret_ref"),
|
||||
key: secretKeySchema,
|
||||
version: secretVersionSelectorSchema.optional(),
|
||||
required: z.boolean().optional().default(true),
|
||||
allowMissingOverride: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// Backward-compatible union that accepts legacy inline values.
|
||||
@@ -23,13 +35,14 @@ export const envBindingSchema = z.union([
|
||||
z.string(),
|
||||
envBindingPlainSchema,
|
||||
envBindingSecretRefSchema,
|
||||
envBindingUserSecretRefSchema,
|
||||
]);
|
||||
|
||||
export const envConfigSchema = z.record(z.string(), envBindingSchema);
|
||||
|
||||
export const createSecretSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||
key: secretKeySchema.optional(),
|
||||
provider: z.enum(SECRET_PROVIDERS).optional(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||
@@ -67,18 +80,41 @@ export const createSecretSchema = z.object({
|
||||
|
||||
export type CreateSecret = z.infer<typeof createSecretSchema>;
|
||||
|
||||
function requireSecretRotationInput(
|
||||
value: {
|
||||
value?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
if (
|
||||
!value.value?.trim() &&
|
||||
!value.externalRef?.trim() &&
|
||||
value.providerVersionRef === undefined &&
|
||||
value.providerConfigId === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["value"],
|
||||
message: "Secret rotation requires value, externalRef, providerVersionRef, or providerConfigId",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const rotateSecretSchema = z.object({
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
}).superRefine(requireSecretRotationInput);
|
||||
|
||||
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
|
||||
|
||||
export const updateSecretSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||
key: secretKeySchema.optional(),
|
||||
status: z.enum(SECRET_STATUSES).optional(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
@@ -96,13 +132,94 @@ export const secretBindingTargetSchema = z.object({
|
||||
|
||||
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
|
||||
secretId: z.string().uuid(),
|
||||
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
|
||||
versionSelector: secretVersionSelectorSchema.default("latest"),
|
||||
required: z.boolean().default(true),
|
||||
label: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
|
||||
|
||||
export const createUserSecretDefinitionSchema = z.object({
|
||||
key: secretKeySchema,
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().trim().max(500).optional().nullable(),
|
||||
status: creatableSecretStatusSchema.optional(),
|
||||
provider: z.enum(SECRET_PROVIDERS).optional(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
usageGuidance: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateUserSecretDefinition = z.infer<typeof createUserSecretDefinitionSchema>;
|
||||
|
||||
export const updateUserSecretDefinitionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
description: z.string().trim().max(500).optional().nullable(),
|
||||
status: z.enum(SECRET_STATUSES).optional(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
usageGuidance: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateUserSecretDefinition = z.infer<typeof updateUserSecretDefinitionSchema>;
|
||||
|
||||
export const createUserSecretValueSchema = z.object({
|
||||
definitionKey: secretKeySchema.optional(),
|
||||
definitionId: z.string().uuid().optional(),
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.definitionKey && !value.definitionId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["definitionId"],
|
||||
message: "User secret value requires definitionId or definitionKey",
|
||||
});
|
||||
}
|
||||
if (!value.value?.trim() && !value.externalRef?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["value"],
|
||||
message: "User secret value requires value or externalRef",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateUserSecretValue = z.infer<typeof createUserSecretValueSchema>;
|
||||
|
||||
export const updateUserSecretValueSchema = z.object({
|
||||
status: z.enum(SECRET_STATUSES).optional(),
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateUserSecretValue = z.infer<typeof updateUserSecretValueSchema>;
|
||||
|
||||
export const rotateUserSecretValueSchema = z.object({
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
}).superRefine(requireSecretRotationInput);
|
||||
|
||||
export type RotateUserSecretValue = z.infer<typeof rotateUserSecretValueSchema>;
|
||||
|
||||
export const createUserSecretDeclarationSchema = secretBindingTargetSchema.extend({
|
||||
definitionKey: secretKeySchema,
|
||||
envKey: z.string().trim().min(1),
|
||||
versionSelector: secretVersionSelectorSchema.default("latest"),
|
||||
required: z.boolean().default(true),
|
||||
allowMissingOverride: z.boolean().default(false),
|
||||
label: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateUserSecretDeclaration = z.infer<typeof createUserSecretDeclarationSchema>;
|
||||
|
||||
const safeShortText = z.string().trim().min(1).max(160);
|
||||
const optionalSafeShortText = safeShortText.optional().nullable();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -28,6 +28,7 @@ import {
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const execFileAsync = promisify(execFile);
|
||||
let seedGraphSequence = 0;
|
||||
|
||||
type TestGraph = {
|
||||
companyId: string;
|
||||
@@ -63,6 +64,8 @@ async function seedGraph(db: Db, input: {
|
||||
projectSourceType?: string;
|
||||
targetProjectSourceType?: string;
|
||||
}): Promise<TestGraph> {
|
||||
seedGraphSequence += 1;
|
||||
const prefixSuffix = seedGraphSequence.toString(36).toUpperCase().padStart(4, "0");
|
||||
const suffix = crypto.randomUUID().slice(0, 8);
|
||||
const companyId = crypto.randomUUID();
|
||||
const otherCompanyId = crypto.randomUUID();
|
||||
@@ -79,8 +82,8 @@ async function seedGraph(db: Db, input: {
|
||||
const otherIssueId = crypto.randomUUID();
|
||||
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: `Company ${suffix}`, issuePrefix: `F${suffix.slice(0, 4).toUpperCase()}` },
|
||||
{ id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${suffix.slice(0, 4).toUpperCase()}` },
|
||||
{ id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` },
|
||||
{ id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` },
|
||||
]);
|
||||
await db.insert(goals).values([
|
||||
{ id: goalId, companyId, title: "Goal", level: "company", status: "active" },
|
||||
|
||||
@@ -283,6 +283,72 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks required missing user secrets before runtime env resolution", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn();
|
||||
const resolveEnvBindings = vi.fn();
|
||||
const collectMissingRuntimeBindings = vi.fn(async (_companyId, _env, context) =>
|
||||
context.consumerType === "agent"
|
||||
? [
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
userSecretDefinitionId: "definition-1",
|
||||
userSecretDefinitionKey: "github_token",
|
||||
userSecretDefinitionName: "GitHub token",
|
||||
responsibleUserId: context.responsibleUserId,
|
||||
errorCode: "user_secret_missing",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
await expect(resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
heartbeatRunId: "run-1",
|
||||
responsibleUserId: "user-1",
|
||||
executionRunConfig: {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "user_secret_ref", key: "github_token", required: true },
|
||||
},
|
||||
},
|
||||
projectEnv: null,
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime,
|
||||
resolveEnvBindings,
|
||||
collectMissingRuntimeBindings,
|
||||
} as any,
|
||||
})).rejects.toMatchObject({
|
||||
code: "configuration_incomplete",
|
||||
resultJson: {
|
||||
configurationIncomplete: {
|
||||
reason: "secret_binding_missing",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
missingBindings: [
|
||||
expect.objectContaining({
|
||||
bindingType: "user_secret_ref",
|
||||
userSecretDefinitionKey: "github_token",
|
||||
responsibleUserId: "user-1",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({
|
||||
responsibleUserId: "user-1",
|
||||
});
|
||||
expect(resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects inline sensitive env values for low-trust runs", async () => {
|
||||
await expect(resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
|
||||
@@ -2292,6 +2292,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(environments);
|
||||
await db.delete(instanceSettings);
|
||||
@@ -2378,6 +2379,57 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits responsible user for agent-created child issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const parent = await svc.create(companyId, {
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByUserId: responsibleUserId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "running",
|
||||
responsibleUserId,
|
||||
contextSnapshot: { issueId: parent.id },
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parent.id,
|
||||
title: "Agent-created child",
|
||||
createdByAgentId: agentId,
|
||||
actorRunId: runId,
|
||||
});
|
||||
|
||||
expect(parent.responsibleUserId).toBe(responsibleUserId);
|
||||
expect(child.responsibleUserId).toBe(responsibleUserId);
|
||||
});
|
||||
|
||||
it("does not stamp the assignee default environment onto new issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -712,6 +712,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
id: issues.id,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!));
|
||||
@@ -719,6 +720,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
id: run.linkedIssueId,
|
||||
assigneeAgentId: agentId,
|
||||
createdByUserId: userId,
|
||||
responsibleUserId: userId,
|
||||
});
|
||||
|
||||
const inboxIssues = await issueSvc.list(companyId, {
|
||||
@@ -729,6 +731,45 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||
});
|
||||
|
||||
it("uses the routine revision responsible-user snapshot for automatic runs", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const responsibleUserId = randomUUID();
|
||||
const driftUserId = randomUUID();
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "snapshotted owner routine",
|
||||
description: null,
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{ userId: responsibleUserId },
|
||||
);
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ responsibleUserId: driftUserId, updatedAt: new Date() })
|
||||
.where(eq(routines.id, routine.id));
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "schedule" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.responsibleUserId).toBe(responsibleUserId);
|
||||
const [createdIssue] = await db
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!));
|
||||
expect(createdIssue?.responsibleUserId).toBe(responsibleUserId);
|
||||
});
|
||||
|
||||
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
|
||||
let wakeupResolved = false;
|
||||
const { routine, svc } = await seedFixture({
|
||||
|
||||
@@ -19,8 +19,19 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
listUserSecretDefinitions: vi.fn(),
|
||||
createUserSecretDefinition: vi.fn(),
|
||||
updateUserSecretDefinition: vi.fn(),
|
||||
removeUserSecretDefinition: vi.fn(),
|
||||
getUserSecretDefinitionCoverage: vi.fn(),
|
||||
listCurrentUserSecretValues: vi.fn(),
|
||||
createCurrentUserSecretValue: vi.fn(),
|
||||
updateCurrentUserSecretValue: vi.fn(),
|
||||
rotateCurrentUserSecretValue: vi.fn(),
|
||||
removeCurrentUserSecretValue: vi.fn(),
|
||||
previewRemoteImport: vi.fn(),
|
||||
importRemoteSecrets: vi.fn(),
|
||||
}));
|
||||
@@ -95,6 +106,100 @@ describe("secret routes", () => {
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restricts user secret definition management to company admins", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
||||
})).post("/api/companies/company-1/user-secret-definitions").send({
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates current-user secret values for the authenticated user only", async () => {
|
||||
mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
scope: "user",
|
||||
ownerUserId: "user-1",
|
||||
userSecretDefinitionId: "definition-1",
|
||||
provider: "local_encrypted",
|
||||
latestVersion: 1,
|
||||
});
|
||||
|
||||
const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({
|
||||
definitionKey: "github_token",
|
||||
value: "secret-value",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"user-1",
|
||||
{
|
||||
definitionKey: "github_token",
|
||||
definitionId: undefined,
|
||||
value: "secret-value",
|
||||
externalRef: undefined,
|
||||
providerVersionRef: undefined,
|
||||
providerConfigId: undefined,
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
it("rejects current-user secret values without a concrete user identity", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
})).post("/api/companies/company-1/me/user-secrets").send({
|
||||
definitionKey: "github_token",
|
||||
value: "secret-value",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({ error: "User identity required for user-specific secrets" });
|
||||
expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects empty current-user secret rotation payloads", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/me/user-secrets/secret-1/rotate")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/requires value, externalRef/);
|
||||
expect(mockSecretService.rotateCurrentUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides user-scoped secrets from legacy company secret mutation routes", async () => {
|
||||
mockSecretService.getById.mockResolvedValue({
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
scope: "user",
|
||||
ownerUserId: "user-2",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({
|
||||
value: "new-secret-value",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockSecretService.rotate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provider vault routes for non-board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
@@ -47,9 +49,11 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agents);
|
||||
@@ -342,6 +346,56 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(resolved.manifest[0]?.bindingId).toBe(binding!.id);
|
||||
});
|
||||
|
||||
it("denies user secret resolution outside the low-trust declaration allowlist", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const env = {
|
||||
GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
|
||||
};
|
||||
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
||||
await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
definitionKey: "github_token",
|
||||
value: "user-one-secret",
|
||||
});
|
||||
const [declaration] = await db
|
||||
.select()
|
||||
.from(userSecretDeclarations)
|
||||
.where(eq(userSecretDeclarations.userSecretDefinitionId, definition.id));
|
||||
expect(declaration?.id).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
responsibleUserId: "user-1",
|
||||
allowedBindingIds: ["11111111-1111-4111-8111-111111111111"],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 422,
|
||||
details: { code: "binding_not_allowed" },
|
||||
});
|
||||
|
||||
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
responsibleUserId: "user-1",
|
||||
allowedBindingIds: [declaration!.id],
|
||||
});
|
||||
expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
|
||||
expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id);
|
||||
});
|
||||
|
||||
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
@@ -387,6 +441,264 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
|
||||
});
|
||||
|
||||
it("resolves user secret refs through responsible-user values and records owner metadata", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
await seedCompanyMember(companyId, "user-2", "member");
|
||||
const svc = secretService(db);
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const env = {
|
||||
GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
|
||||
};
|
||||
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
||||
const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
definitionKey: "github_token",
|
||||
value: "user-one-secret",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
await expect(
|
||||
svc.collectMissingRuntimeBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
bindingType: "user_secret_ref",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: "github_token",
|
||||
responsibleUserId: "user-2",
|
||||
errorCode: "user_secret_missing",
|
||||
}),
|
||||
]);
|
||||
|
||||
const optionalEnv = {
|
||||
OPTIONAL_GITHUB_TOKEN: {
|
||||
type: "user_secret_ref" as const,
|
||||
key: "github_token",
|
||||
version: "latest" as const,
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv);
|
||||
await expect(
|
||||
svc.collectMissingRuntimeBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
actorType: "agent",
|
||||
actorId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
env: {},
|
||||
manifest: [],
|
||||
});
|
||||
|
||||
await db
|
||||
.update(userSecretDefinitions)
|
||||
.set({ status: "disabled" })
|
||||
.where(eq(userSecretDefinitions.id, definition.id));
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
actorType: "agent",
|
||||
actorId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
env: {},
|
||||
manifest: [],
|
||||
});
|
||||
await db
|
||||
.update(userSecretDefinitions)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(eq(userSecretDefinitions.id, definition.id));
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
actorType: "agent",
|
||||
actorId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
env: {},
|
||||
manifest: [],
|
||||
});
|
||||
await db
|
||||
.update(userSecretDefinitions)
|
||||
.set({ status: "active", deletedAt: null })
|
||||
.where(eq(userSecretDefinitions.id, definition.id));
|
||||
|
||||
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
responsibleUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
|
||||
expect(resolved.manifest[0]).toMatchObject({
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
secretId: userOneSecret.id,
|
||||
secretKey: userOneSecret.key,
|
||||
outcome: "success",
|
||||
});
|
||||
expect((await svc.list(companyId)).map((secret) => secret.id)).not.toContain(userOneSecret.id);
|
||||
await expect(
|
||||
svc.resolveSecretValue(companyId, userOneSecret.id, "latest", {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
}),
|
||||
).rejects.toThrow(/User-scoped secrets/i);
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(secretAccessEvents)
|
||||
.where(eq(secretAccessEvents.secretId, userOneSecret.id));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
companyId,
|
||||
secretId: userOneSecret.id,
|
||||
userSecretDefinitionId: definition.id,
|
||||
secretScope: "user",
|
||||
responsibleUserId: "user-1",
|
||||
credentialOwnerUserId: "user-1",
|
||||
credentialSubjectType: "user",
|
||||
credentialSubjectId: "user-1",
|
||||
outcome: "success",
|
||||
});
|
||||
expect(JSON.stringify(events)).not.toContain("user-one-secret");
|
||||
});
|
||||
|
||||
it("treats nullable user-secret value patches as non-rotation updates", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const secret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
definitionKey: "github_token",
|
||||
value: "user-one-secret",
|
||||
});
|
||||
|
||||
const updated = await svc.updateCurrentUserSecretValue(companyId, "user-1", secret.id, {
|
||||
value: null,
|
||||
externalRef: null,
|
||||
providerVersionRef: null,
|
||||
providerConfigId: null,
|
||||
});
|
||||
|
||||
expect(updated.latestVersion).toBe(secret.latestVersion);
|
||||
expect(updated.status).toBe(secret.status);
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, secret.id));
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(versions[0]).toMatchObject({ version: secret.latestVersion, status: "current" });
|
||||
expect(versions[0]?.material).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports missing adapter-config user secret refs before runtime resolution", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "hermes_api_key",
|
||||
name: "Hermes API key",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const adapterConfig = {
|
||||
apiBaseUrl: "http://127.0.0.1:9119/api",
|
||||
apiKey: { type: "user_secret_ref" as const, key: "hermes_api_key", version: "latest" as const },
|
||||
};
|
||||
await svc.syncUserSecretDeclarationsForTarget(companyId, {
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
}, [
|
||||
{
|
||||
definitionKey: "hermes_api_key",
|
||||
configPath: "apiKey",
|
||||
envKey: "apiKey",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
svc.collectMissingAdapterConfigRuntimeBindings(
|
||||
companyId,
|
||||
adapterConfig,
|
||||
"hermes_gateway",
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
responsibleUserId: "user-1",
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
bindingType: "user_secret_ref",
|
||||
configPath: "apiKey",
|
||||
envKey: "apiKey",
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: "hermes_api_key",
|
||||
responsibleUserId: "user-1",
|
||||
errorCode: "user_secret_missing",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
svc.collectMissingAdapterConfigRuntimeBindings(
|
||||
companyId,
|
||||
{
|
||||
...adapterConfig,
|
||||
apiKey: {
|
||||
type: "user_secret_ref" as const,
|
||||
key: "hermes_api_key",
|
||||
version: "latest" as const,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
"hermes_gateway",
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
responsibleUserId: "user-1",
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("records stable redacted failure codes for routine env secret resolution", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -54,6 +54,7 @@ function sanitizeValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeValue);
|
||||
if (isSecretRefBinding(value)) return value;
|
||||
if (isUserSecretRefBinding(value)) return value;
|
||||
if (isPlainBinding(value)) return { type: "plain", value: sanitizeValue(value.value) };
|
||||
if (!isPlainObject(value)) return value;
|
||||
return sanitizeRecord(value);
|
||||
@@ -64,6 +65,11 @@ function isSecretRefBinding(value: unknown): value is { type: "secret_ref"; secr
|
||||
return value.type === "secret_ref" && typeof value.secretId === "string";
|
||||
}
|
||||
|
||||
function isUserSecretRefBinding(value: unknown): value is { type: "user_secret_ref"; key: string; version?: unknown } {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return value.type === "user_secret_ref" && typeof value.key === "string";
|
||||
}
|
||||
|
||||
function isPlainBinding(value: unknown): value is { type: "plain"; value: unknown } {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return value.type === "plain" && "value" in value;
|
||||
@@ -101,6 +107,10 @@ export function sanitizeRecord(record: Record<string, unknown>): Record<string,
|
||||
redacted[key] = sanitizeValue(value);
|
||||
continue;
|
||||
}
|
||||
if (isUserSecretRefBinding(value)) {
|
||||
redacted[key] = sanitizeValue(value);
|
||||
continue;
|
||||
}
|
||||
if (isPlainBinding(value)) {
|
||||
redacted[key] = { type: "plain", value: REDACTED_EVENT_VALUE };
|
||||
continue;
|
||||
|
||||
@@ -5229,6 +5229,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
watchdogActorRunId: actor.runId,
|
||||
});
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
@@ -5397,6 +5398,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
watchdogActorRunId: actor.runId,
|
||||
@@ -5562,6 +5564,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
createSecretSchema,
|
||||
updateSecretSchema,
|
||||
rotateSecretSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
createUserSecretDefinitionSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
createUserSecretValueSchema,
|
||||
updateUserSecretValueSchema,
|
||||
// Approval
|
||||
createApprovalSchema,
|
||||
resolveApprovalSchema,
|
||||
@@ -613,6 +618,16 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
||||
"DELETE /api/secret-provider-configs/{id}",
|
||||
"POST /api/secret-provider-configs/{id}/default",
|
||||
"POST /api/secret-provider-configs/{id}/health",
|
||||
"GET /api/companies/{companyId}/user-secret-definitions",
|
||||
"POST /api/companies/{companyId}/user-secret-definitions",
|
||||
"PATCH /api/companies/{companyId}/user-secret-definitions/{definitionId}",
|
||||
"DELETE /api/companies/{companyId}/user-secret-definitions/{definitionId}",
|
||||
"GET /api/companies/{companyId}/user-secret-definitions/{definitionId}/coverage",
|
||||
"GET /api/companies/{companyId}/me/user-secrets",
|
||||
"POST /api/companies/{companyId}/me/user-secrets",
|
||||
"PATCH /api/companies/{companyId}/me/user-secrets/{secretId}",
|
||||
"POST /api/companies/{companyId}/me/user-secrets/{secretId}/rotate",
|
||||
"DELETE /api/companies/{companyId}/me/user-secrets/{secretId}",
|
||||
"POST /api/companies/{companyId}/secrets/remote-import",
|
||||
"POST /api/companies/{companyId}/secrets/remote-import/preview",
|
||||
"GET /api/secrets/{id}/usage",
|
||||
@@ -673,6 +688,8 @@ const CREATED_OPERATIONS = new Set([
|
||||
"POST /api/companies/{companyId}/routines",
|
||||
"POST /api/routines/{id}/triggers",
|
||||
"POST /api/companies/{companyId}/secrets",
|
||||
"POST /api/companies/{companyId}/user-secret-definitions",
|
||||
"POST /api/companies/{companyId}/me/user-secrets",
|
||||
"POST /api/companies/{companyId}/skills",
|
||||
"POST /api/companies/{companyId}/skills/import",
|
||||
"POST /api/join-requests/{requestId}/claim-api-key",
|
||||
@@ -2180,6 +2197,111 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions",
|
||||
tags: ["secrets"],
|
||||
summary: "List user secret definitions",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions",
|
||||
tags: ["secrets"],
|
||||
summary: "Create a user secret definition",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createUserSecretDefinitionSchema),
|
||||
},
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}",
|
||||
tags: ["secrets"],
|
||||
summary: "Update a user secret definition",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), definitionId: z.string() }),
|
||||
body: jsonBody(updateUserSecretDefinitionSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}",
|
||||
tags: ["secrets"],
|
||||
summary: "Delete a user secret definition",
|
||||
request: { params: z.object({ companyId: z.string(), definitionId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}/coverage",
|
||||
tags: ["secrets"],
|
||||
summary: "Get user secret definition coverage",
|
||||
request: { params: z.object({ companyId: z.string(), definitionId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/me/user-secrets",
|
||||
tags: ["secrets"],
|
||||
summary: "List my user secret values",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/me/user-secrets",
|
||||
tags: ["secrets"],
|
||||
summary: "Create my user secret value",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createUserSecretValueSchema),
|
||||
},
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/me/user-secrets/{secretId}",
|
||||
tags: ["secrets"],
|
||||
summary: "Update my user secret value",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), secretId: z.string() }),
|
||||
body: jsonBody(updateUserSecretValueSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/me/user-secrets/{secretId}/rotate",
|
||||
tags: ["secrets"],
|
||||
summary: "Rotate my user secret value",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), secretId: z.string() }),
|
||||
body: jsonBody(rotateUserSecretValueSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/companies/{companyId}/me/user-secrets/{secretId}",
|
||||
tags: ["secrets"],
|
||||
summary: "Delete my user secret value",
|
||||
request: { params: z.object({ companyId: z.string(), secretId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
// ─── Approvals ───────────────────────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
|
||||
@@ -3,17 +3,44 @@ import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
createUserSecretDefinitionSchema,
|
||||
createUserSecretValueSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
rotateSecretSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
updateSecretSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
updateUserSecretValueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { logActivity, secretService } from "../services/index.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
|
||||
function assertSecretDefinitionAdmin(req: Parameters<typeof assertBoard>[0], companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
|
||||
if (membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole))) {
|
||||
return;
|
||||
}
|
||||
throw forbidden("Company admin access required");
|
||||
}
|
||||
|
||||
function currentUserId(req: Parameters<typeof assertBoard>[0]) {
|
||||
assertBoard(req);
|
||||
if (req.actor.userId) return req.actor.userId;
|
||||
throw unauthorized("User identity required for user-specific secrets");
|
||||
}
|
||||
|
||||
function isCompanyScopedSecret(secret: { scope?: string | null }) {
|
||||
return (secret.scope ?? "company") === "company";
|
||||
}
|
||||
|
||||
export function secretRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -269,6 +296,285 @@ export function secretRoutes(db: Db) {
|
||||
res.json(secrets);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/user-secret-definitions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertSecretDefinitionAdmin(req, companyId);
|
||||
res.json(await svc.listUserSecretDefinitions(companyId));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/user-secret-definitions",
|
||||
validate(createUserSecretDefinitionSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertSecretDefinitionAdmin(req, companyId);
|
||||
|
||||
const created = await svc.createUserSecretDefinition(
|
||||
companyId,
|
||||
{
|
||||
key: req.body.key,
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
status: req.body.status,
|
||||
provider: req.body.provider ?? defaultProvider,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
managedMode: req.body.managedMode,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
usageGuidance: req.body.usageGuidance,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "user_secret_definition.created",
|
||||
entityType: "user_secret_definition",
|
||||
entityId: created.id,
|
||||
details: { key: created.key, provider: created.provider },
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/user-secret-definitions/:definitionId",
|
||||
validate(updateUserSecretDefinitionSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const definitionId = req.params.definitionId as string;
|
||||
assertSecretDefinitionAdmin(req, companyId);
|
||||
|
||||
const updated = await svc.updateUserSecretDefinition(
|
||||
companyId,
|
||||
definitionId,
|
||||
{
|
||||
key: req.body.key,
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
status: req.body.status,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
usageGuidance: req.body.usageGuidance,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "User secret definition not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "user_secret_definition.updated",
|
||||
entityType: "user_secret_definition",
|
||||
entityId: updated.id,
|
||||
details: { key: updated.key, status: updated.status },
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/user-secret-definitions/:definitionId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const definitionId = req.params.definitionId as string;
|
||||
assertSecretDefinitionAdmin(req, companyId);
|
||||
|
||||
const removed = await svc.removeUserSecretDefinition(
|
||||
companyId,
|
||||
definitionId,
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "User secret definition not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "user_secret_definition.deleted",
|
||||
entityType: "user_secret_definition",
|
||||
entityId: removed.id,
|
||||
details: { key: removed.key },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/user-secret-definitions/:definitionId/coverage", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const definitionId = req.params.definitionId as string;
|
||||
assertSecretDefinitionAdmin(req, companyId);
|
||||
res.json(await svc.getUserSecretDefinitionCoverage(companyId, definitionId));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/me/user-secrets", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listCurrentUserSecretValues(companyId, currentUserId(req)));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/me/user-secrets",
|
||||
validate(createUserSecretValueSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const ownerUserId = currentUserId(req);
|
||||
const created = await svc.createCurrentUserSecretValue(
|
||||
companyId,
|
||||
ownerUserId,
|
||||
{
|
||||
definitionKey: req.body.definitionKey,
|
||||
definitionId: req.body.definitionId,
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
},
|
||||
{ userId: ownerUserId, agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "user_secret_value.created",
|
||||
entityType: "secret",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
userSecretDefinitionId: created.userSecretDefinitionId,
|
||||
ownerUserId: created.ownerUserId,
|
||||
provider: created.provider,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/me/user-secrets/:secretId",
|
||||
validate(updateUserSecretValueSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const secretId = req.params.secretId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const ownerUserId = currentUserId(req);
|
||||
const updated = await svc.updateCurrentUserSecretValue(
|
||||
companyId,
|
||||
ownerUserId,
|
||||
secretId,
|
||||
{
|
||||
status: req.body.status,
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
},
|
||||
{ userId: ownerUserId, agentId: null },
|
||||
);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "User secret value not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "user_secret_value.updated",
|
||||
entityType: "secret",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
userSecretDefinitionId: updated.userSecretDefinitionId,
|
||||
ownerUserId: updated.ownerUserId,
|
||||
status: updated.status,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/me/user-secrets/:secretId/rotate",
|
||||
validate(rotateUserSecretValueSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const secretId = req.params.secretId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const ownerUserId = currentUserId(req);
|
||||
const rotated = await svc.rotateCurrentUserSecretValue(
|
||||
companyId,
|
||||
ownerUserId,
|
||||
secretId,
|
||||
{
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
},
|
||||
{ userId: ownerUserId, agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "user_secret_value.rotated",
|
||||
entityType: "secret",
|
||||
entityId: rotated.id,
|
||||
details: {
|
||||
userSecretDefinitionId: rotated.userSecretDefinitionId,
|
||||
ownerUserId: rotated.ownerUserId,
|
||||
version: rotated.latestVersion,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(rotated);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/me/user-secrets/:secretId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const secretId = req.params.secretId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const ownerUserId = currentUserId(req);
|
||||
const removed = await svc.removeCurrentUserSecretValue(companyId, ownerUserId, secretId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "User secret value not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "user_secret_value.deleted",
|
||||
entityType: "secret",
|
||||
entityId: removed.id,
|
||||
details: {
|
||||
userSecretDefinitionId: removed.userSecretDefinitionId,
|
||||
ownerUserId: removed.ownerUserId,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -383,6 +689,10 @@ export function secretRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
if (!isCompanyScopedSecret(existing)) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
@@ -421,6 +731,10 @@ export function secretRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
if (!isCompanyScopedSecret(existing)) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
@@ -463,6 +777,10 @@ export function secretRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
if (!isCompanyScopedSecret(existing)) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
|
||||
res.json({ secretId: existing.id, bindings });
|
||||
@@ -476,6 +794,10 @@ export function secretRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
if (!isCompanyScopedSecret(existing)) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const events = await svc.listAccessEvents(existing.companyId, existing.id);
|
||||
res.json(events);
|
||||
@@ -489,6 +811,10 @@ export function secretRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
if (!isCompanyScopedSecret(existing)) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const removed = await svc.remove(id);
|
||||
|
||||
@@ -18,6 +18,20 @@ interface AgentSecretBindingSyncService {
|
||||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
envValue: unknown,
|
||||
) => Promise<unknown>;
|
||||
syncUserSecretDeclarationsForTarget?: (
|
||||
companyId: string,
|
||||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
refs: Array<{
|
||||
definitionKey: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
allowMissingOverride?: boolean;
|
||||
label?: string | null;
|
||||
}>,
|
||||
options?: { replaceAll?: boolean },
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -67,6 +81,60 @@ function collectSecretRefs(adapterConfig: unknown): Array<{
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUserSecretRefs(adapterConfig: unknown): Array<{
|
||||
definitionKey: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
allowMissingOverride?: boolean;
|
||||
}> {
|
||||
const config = asRecord(adapterConfig);
|
||||
if (!config) return [];
|
||||
const refs: Array<{
|
||||
definitionKey: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
allowMissingOverride?: boolean;
|
||||
}> = [];
|
||||
|
||||
const envValue = asRecord(config.env);
|
||||
for (const [key, rawBinding] of Object.entries(envValue ?? {})) {
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = parsed.data;
|
||||
if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
|
||||
refs.push({
|
||||
definitionKey: binding.key,
|
||||
configPath: `env.${key}`,
|
||||
envKey: key,
|
||||
versionSelector: binding.version ?? "latest",
|
||||
required: binding.required ?? true,
|
||||
allowMissingOverride: binding.allowMissingOverride ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(config)) {
|
||||
if (key === "env") continue;
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = parsed.data;
|
||||
if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
|
||||
refs.push({
|
||||
definitionKey: binding.key,
|
||||
configPath: key,
|
||||
envKey: key,
|
||||
versionSelector: binding.version ?? "latest",
|
||||
required: binding.required ?? true,
|
||||
allowMissingOverride: binding.allowMissingOverride ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export async function syncAgentAdapterEnvBindings(input: {
|
||||
secretsSvc: AgentSecretBindingSyncService;
|
||||
companyId: string;
|
||||
@@ -80,6 +148,12 @@ export async function syncAgentAdapterEnvBindings(input: {
|
||||
collectSecretRefs(input.adapterConfig),
|
||||
{ replaceAll: true },
|
||||
);
|
||||
await input.secretsSvc.syncUserSecretDeclarationsForTarget?.(
|
||||
input.companyId,
|
||||
{ targetType: "agent", targetId: input.agentId },
|
||||
collectUserSecretRefs(input.adapterConfig),
|
||||
{ replaceAll: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const envValue = asRecord(asRecord(input.adapterConfig)?.env);
|
||||
|
||||
@@ -433,9 +433,19 @@ type RuntimeConfigSecretResolver = Pick<
|
||||
>;
|
||||
|
||||
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
|
||||
if (missing.bindingType === "user_secret_ref") {
|
||||
const definitionLabel =
|
||||
missing.userSecretDefinitionName
|
||||
? `"${missing.userSecretDefinitionName}"`
|
||||
: missing.userSecretDefinitionKey
|
||||
? `"${missing.userSecretDefinitionKey}"`
|
||||
: "declared user secret";
|
||||
const ownerLabel = missing.responsibleUserId ? ` for responsible user ${missing.responsibleUserId}` : "";
|
||||
return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
const secretLabel = missing.secretName
|
||||
? `"${missing.secretName}"`
|
||||
: missing.secretId;
|
||||
: missing.secretId ?? "unknown";
|
||||
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
|
||||
@@ -514,6 +524,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
adapterType?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
environmentId?: string | null;
|
||||
environmentEnv?: unknown;
|
||||
projectId?: string | null;
|
||||
@@ -580,7 +591,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
environmentEnv,
|
||||
{ consumerType: "environment", consumerId: input.environmentId },
|
||||
{
|
||||
consumerType: "environment",
|
||||
consumerId: input.environmentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -589,7 +604,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
parseObject(executionRunConfig.env),
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") {
|
||||
@@ -598,7 +617,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
input.adapterType ?? null,
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -608,7 +631,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
projectEnv,
|
||||
{ consumerType: "project", consumerId: input.projectId },
|
||||
{
|
||||
consumerType: "project",
|
||||
consumerId: input.projectId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -617,7 +644,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
routineEnv,
|
||||
{ consumerType: "routine", consumerId: input.routineId },
|
||||
{
|
||||
consumerType: "routine",
|
||||
consumerId: input.routineId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -673,6 +704,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
consumerId: input.environmentId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
@@ -689,6 +721,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
consumerId: input.agentId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
@@ -715,6 +748,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
consumerId: input.projectId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
@@ -741,6 +775,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
consumerId: input.routineId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
@@ -4561,6 +4596,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
parentId: issues.parentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
originRunId: issues.originRunId,
|
||||
@@ -4576,13 +4614,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null,
|
||||
) {
|
||||
if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) {
|
||||
return { routineId: null, env: null };
|
||||
return { routineId: null, env: null, responsibleUserId: null };
|
||||
}
|
||||
|
||||
const routineRun = issueContext.originRunId
|
||||
? await db
|
||||
.select({
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
responsibleUserId: routineRuns.responsibleUserId,
|
||||
})
|
||||
.from(routineRuns)
|
||||
.where(
|
||||
@@ -4599,6 +4638,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const revision = await db
|
||||
.select({
|
||||
snapshot: routineRevisions.snapshot,
|
||||
responsibleUserId: routineRevisions.responsibleUserId,
|
||||
})
|
||||
.from(routineRevisions)
|
||||
.where(
|
||||
@@ -4611,16 +4651,48 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined;
|
||||
if (snapshot?.version === 1) {
|
||||
return { routineId: issueContext.originId, env: snapshot.routine.env ?? null };
|
||||
return {
|
||||
routineId: issueContext.originId,
|
||||
env: snapshot.routine.env ?? null,
|
||||
responsibleUserId: revision?.responsibleUserId ?? snapshot.routine.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const routine = await db
|
||||
.select({ env: routines.env })
|
||||
.select({ env: routines.env, responsibleUserId: routines.responsibleUserId })
|
||||
.from(routines)
|
||||
.where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return { routineId: issueContext.originId, env: routine?.env ?? null };
|
||||
return {
|
||||
routineId: issueContext.originId,
|
||||
env: routine?.env ?? null,
|
||||
responsibleUserId: routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveResponsibleUserIdForRun(input: {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
|
||||
routineEnvContext: Awaited<ReturnType<typeof getRoutineEnvForExecutionIssue>>;
|
||||
}) {
|
||||
const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId);
|
||||
if (contextResponsibleUserId) return contextResponsibleUserId;
|
||||
if (input.run.responsibleUserId) return input.run.responsibleUserId;
|
||||
if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId;
|
||||
if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId;
|
||||
if (input.issueContext?.parentId) {
|
||||
const parent = await db
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueContext.parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return parent?.responsibleUserId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRuntimeState(agentId: string) {
|
||||
@@ -8364,10 +8436,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
|
||||
const claimedAt = new Date();
|
||||
const responsibleUserId = await resolveResponsibleUserIdForRun({
|
||||
run,
|
||||
contextSnapshot: context,
|
||||
issueContext: issueId ? await getIssueExecutionContext(run.companyId, issueId) : null,
|
||||
routineEnvContext: { routineId: null, env: null, responsibleUserId: null },
|
||||
});
|
||||
const claimed = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "running",
|
||||
responsibleUserId,
|
||||
startedAt: run.startedAt ?? claimedAt,
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
@@ -9458,6 +9537,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
delete context.acceptedPlanWakeRouting;
|
||||
}
|
||||
const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext);
|
||||
const responsibleUserId = await resolveResponsibleUserIdForRun({
|
||||
run,
|
||||
contextSnapshot: context,
|
||||
issueContext,
|
||||
routineEnvContext,
|
||||
});
|
||||
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({ responsibleUserId, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
run = { ...run, responsibleUserId };
|
||||
}
|
||||
if (responsibleUserId && issueContext && !issueContext.responsibleUserId) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ responsibleUserId, updatedAt: new Date() })
|
||||
.where(and(eq(issues.companyId, agent.companyId), eq(issues.id, issueContext.id), isNull(issues.responsibleUserId)));
|
||||
issueContext = { ...issueContext, responsibleUserId };
|
||||
}
|
||||
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
@@ -9766,6 +9865,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
|
||||
projectId: projectContext?.id ?? null,
|
||||
routineId: routineEnvContext.routineId,
|
||||
responsibleUserId,
|
||||
executionRunConfig,
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
routineEnv: routineEnvContext.env,
|
||||
@@ -12155,6 +12255,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
|
||||
enrichedContextSnapshot.projectId = projectId;
|
||||
}
|
||||
const queuedResponsibleUserId =
|
||||
readNonEmptyString(enrichedContextSnapshot.responsibleUserId) ??
|
||||
(opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null);
|
||||
|
||||
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
|
||||
issueId,
|
||||
@@ -12741,6 +12844,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
responsibleUserId: queuedResponsibleUserId,
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
@@ -12806,7 +12910,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
Boolean(sameScopeRunningRun) &&
|
||||
!sameScopeQueuedRun &&
|
||||
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId });
|
||||
|
||||
const rawCoalescedTarget =
|
||||
sameScopeQueuedRun ??
|
||||
sameScopeScheduledRetryRun ??
|
||||
@@ -12915,6 +13018,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
responsibleUserId: queuedResponsibleUserId,
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
routineRuns,
|
||||
executionWorkspaces,
|
||||
issueApprovals,
|
||||
issueAttachments,
|
||||
@@ -144,6 +145,55 @@ function readStringFromRecord(record: unknown, key: string) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function resolveResponsibleUserIdForIssueCreate(
|
||||
reader: DbReader,
|
||||
companyId: string,
|
||||
input: {
|
||||
explicitResponsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
parentId?: string | null;
|
||||
originKind?: string | null;
|
||||
originRunId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
},
|
||||
) {
|
||||
const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId");
|
||||
if (explicitResponsibleUserId) return explicitResponsibleUserId;
|
||||
|
||||
if (input.originKind === "routine_execution" && input.originRunId) {
|
||||
const routineRun = await reader
|
||||
.select({ responsibleUserId: routineRuns.responsibleUserId })
|
||||
.from(routineRuns)
|
||||
.where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
|
||||
}
|
||||
|
||||
if (input.actorRunId) {
|
||||
const actorRun = await reader
|
||||
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
|
||||
}
|
||||
|
||||
if (input.parentId) {
|
||||
const parent = await reader
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (parent?.responsibleUserId) return parent.responsibleUserId;
|
||||
if (parent?.createdByUserId) return parent.createdByUserId;
|
||||
}
|
||||
|
||||
return input.createdByUserId ?? null;
|
||||
}
|
||||
|
||||
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
|
||||
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
|
||||
) {
|
||||
@@ -412,6 +462,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||
inheritExecutionWorkspaceFromIssueId?: string | null;
|
||||
watchdog?: { agentId: string; instructions?: string | null } | null;
|
||||
watchdogActorRunId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
type IssueChildCreateInput = IssueCreateInput & {
|
||||
acceptanceCriteria?: string[];
|
||||
@@ -2137,6 +2188,7 @@ const issueListSelect = {
|
||||
executionLockedAt: issues.executionLockedAt,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
issueNumber: issues.issueNumber,
|
||||
identifier: issues.identifier,
|
||||
originKind: issues.originKind,
|
||||
@@ -5180,6 +5232,7 @@ export function issueService(db: Db) {
|
||||
inheritExecutionWorkspaceFromIssueId,
|
||||
watchdog,
|
||||
watchdogActorRunId,
|
||||
actorRunId,
|
||||
...issueData
|
||||
} = data;
|
||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||
@@ -5325,9 +5378,18 @@ export function issueService(db: Db) {
|
||||
|
||||
const issueNumber = company.issueCounter;
|
||||
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
||||
const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, {
|
||||
explicitResponsibleUserId: issueData.responsibleUserId ?? null,
|
||||
createdByUserId: issueData.createdByUserId ?? null,
|
||||
parentId: issueData.parentId ?? null,
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
originRunId: issueData.originRunId ?? null,
|
||||
actorRunId: actorRunId ?? null,
|
||||
});
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
responsibleUserId,
|
||||
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
goalId: resolveIssueGoalId({
|
||||
|
||||
@@ -1177,6 +1177,7 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect):
|
||||
originId: routine.originId,
|
||||
variables: routine.variables ?? [],
|
||||
env: routine.env ?? null,
|
||||
responsibleUserId: routine.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -452,6 +452,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna
|
||||
catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"],
|
||||
variables: routine.variables ?? [],
|
||||
env: routine.env ?? null,
|
||||
responsibleUserId: routine.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -819,6 +820,7 @@ export function routineService(
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
responsibleUserId: snapshot.routine.responsibleUserId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
@@ -1136,6 +1138,7 @@ export function routineService(
|
||||
completedAt: triggeredAt,
|
||||
linkedIssueId: null,
|
||||
routineRevisionId: input.routine.latestRevisionId,
|
||||
responsibleUserId: input.routine.responsibleUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
await updateRoutineTouchedState({
|
||||
@@ -1485,6 +1488,26 @@ export function routineService(
|
||||
|
||||
const triggeredAt = new Date();
|
||||
const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null;
|
||||
const latestRevisionResponsibleUserId = input.routine.latestRevisionId
|
||||
? await txDb
|
||||
.select({
|
||||
responsibleUserId: routineRevisions.responsibleUserId,
|
||||
snapshot: routineRevisions.snapshot,
|
||||
})
|
||||
.from(routineRevisions)
|
||||
.where(and(
|
||||
eq(routineRevisions.companyId, input.routine.companyId),
|
||||
eq(routineRevisions.routineId, input.routine.id),
|
||||
eq(routineRevisions.id, input.routine.latestRevisionId),
|
||||
))
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
const snapshot = row?.snapshot as RoutineRevisionSnapshotV1 | undefined;
|
||||
return row?.responsibleUserId ?? snapshot?.routine.responsibleUserId ?? null;
|
||||
})
|
||||
: null;
|
||||
const responsibleUserId =
|
||||
manualRunnerUserId ?? latestRevisionResponsibleUserId ?? input.routine.responsibleUserId ?? null;
|
||||
const [createdRun] = await txDb
|
||||
.insert(routineRuns)
|
||||
.values({
|
||||
@@ -1498,6 +1521,7 @@ export function routineService(
|
||||
triggerPayload,
|
||||
dispatchFingerprint,
|
||||
routineRevisionId: input.routine.latestRevisionId,
|
||||
responsibleUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -1551,6 +1575,7 @@ export function routineService(
|
||||
assigneeAgentId,
|
||||
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
|
||||
createdByUserId: manualRunnerUserId,
|
||||
responsibleUserId,
|
||||
originKind: issueOriginKind,
|
||||
originId: issueOriginId,
|
||||
originRunId: createdRun.id,
|
||||
@@ -1860,6 +1885,7 @@ export function routineService(
|
||||
catchUpPolicy: input.catchUpPolicy,
|
||||
variables,
|
||||
env,
|
||||
responsibleUserId: actor.userId ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
|
||||
+1076
-12
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,8 @@ import type {
|
||||
SecretProviderConfigHealthResponse,
|
||||
SecretProviderDescriptor,
|
||||
SecretStatus,
|
||||
UserSecretCoverageSummary,
|
||||
UserSecretDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -20,6 +22,43 @@ export interface SecretUsageResponse {
|
||||
bindings: CompanySecretUsageBinding[];
|
||||
}
|
||||
|
||||
/** One "My secrets" row: a company definition paired with the current user's own value (if set). */
|
||||
export interface MyUserSecretEntry {
|
||||
definition: UserSecretDefinition;
|
||||
secret: CompanySecret | null;
|
||||
}
|
||||
|
||||
export interface CreateUserSecretDefinitionInput {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: Exclude<SecretStatus, "deleted">;
|
||||
provider?: SecretProvider;
|
||||
managedMode?: SecretManagedMode;
|
||||
providerConfigId?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
usageGuidance?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserSecretDefinitionInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: SecretStatus;
|
||||
providerConfigId?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
usageGuidance?: string | null;
|
||||
}
|
||||
|
||||
/** Owner-supplied value for a user secret. Either `value` (managed) or `externalRef`. */
|
||||
export interface UpsertMyUserSecretInput {
|
||||
definitionId?: string;
|
||||
definitionKey?: string;
|
||||
value?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSecretInput {
|
||||
name: string;
|
||||
key?: string;
|
||||
@@ -147,6 +186,43 @@ export const secretsApi = {
|
||||
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
||||
usage: (id: string) => api.get<SecretUsageResponse>(`/secrets/${id}/usage`),
|
||||
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
|
||||
|
||||
// --- User-specific secrets ---------------------------------------------
|
||||
// Admin: shared definitions each member fills in with their own value.
|
||||
listUserSecretDefinitions: (companyId: string) =>
|
||||
api.get<UserSecretDefinition[]>(`/companies/${companyId}/user-secret-definitions`),
|
||||
createUserSecretDefinition: (companyId: string, data: CreateUserSecretDefinitionInput) =>
|
||||
api.post<UserSecretDefinition>(`/companies/${companyId}/user-secret-definitions`, data),
|
||||
updateUserSecretDefinition: (
|
||||
companyId: string,
|
||||
definitionId: string,
|
||||
data: UpdateUserSecretDefinitionInput,
|
||||
) =>
|
||||
api.patch<UserSecretDefinition>(
|
||||
`/companies/${companyId}/user-secret-definitions/${definitionId}`,
|
||||
data,
|
||||
),
|
||||
removeUserSecretDefinition: (companyId: string, definitionId: string) =>
|
||||
api.delete<{ ok: true }>(`/companies/${companyId}/user-secret-definitions/${definitionId}`),
|
||||
userSecretDefinitionCoverage: (companyId: string, definitionId: string) =>
|
||||
api.get<UserSecretCoverageSummary>(
|
||||
`/companies/${companyId}/user-secret-definitions/${definitionId}/coverage`,
|
||||
),
|
||||
|
||||
// Current user ("My secrets"): each definition paired with my own value.
|
||||
listMyUserSecrets: (companyId: string) =>
|
||||
api.get<MyUserSecretEntry[]>(`/companies/${companyId}/me/user-secrets`),
|
||||
createMyUserSecret: (companyId: string, data: UpsertMyUserSecretInput) =>
|
||||
api.post<CompanySecret>(`/companies/${companyId}/me/user-secrets`, data),
|
||||
updateMyUserSecret: (
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
data: Partial<UpsertMyUserSecretInput> & { status?: SecretStatus },
|
||||
) => api.patch<CompanySecret>(`/companies/${companyId}/me/user-secrets/${secretId}`, data),
|
||||
rotateMyUserSecret: (companyId: string, secretId: string, data: UpsertMyUserSecretInput) =>
|
||||
api.post<CompanySecret>(`/companies/${companyId}/me/user-secrets/${secretId}/rotate`, data),
|
||||
removeMyUserSecret: (companyId: string, secretId: string) =>
|
||||
api.delete<{ ok: true }>(`/companies/${companyId}/me/user-secrets/${secretId}`),
|
||||
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
|
||||
api.post<RemoteSecretImportPreviewResult>(
|
||||
`/companies/${companyId}/secrets/remote-import/preview`,
|
||||
|
||||
@@ -38,6 +38,7 @@ function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
responsibleUserId: null,
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
status: "succeeded",
|
||||
|
||||
@@ -217,6 +217,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
// User-secret definitions power the "User secret" env binding source. Requires
|
||||
// secret-admin; non-admins simply get the free-text key fallback in the editor.
|
||||
const { data: userSecretDefinitions = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.secrets.userDefinitions(selectedCompanyId)
|
||||
: ["user-secret-definitions", "none"],
|
||||
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
retry: false,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
@@ -1358,6 +1368,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
)
|
||||
}
|
||||
secrets={availableSecrets}
|
||||
userSecretDefinitions={userSecretDefinitions}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { CompanySecret, EnvBinding, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EnvVarEditor } from "./EnvVarEditor";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const definition: UserSecretDefinition = {
|
||||
id: "def-1",
|
||||
companyId: "c1",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
description: null,
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(props: Partial<React.ComponentProps<typeof EnvVarEditor>>) {
|
||||
root = createRoot(container);
|
||||
return act(() => {
|
||||
root.render(
|
||||
<EnvVarEditor
|
||||
value={props.value ?? {}}
|
||||
secrets={props.secrets ?? []}
|
||||
userSecretDefinitions={props.userSecretDefinitions}
|
||||
onCreateSecret={props.onCreateSecret ?? (vi.fn() as never)}
|
||||
onChange={props.onChange ?? vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("EnvVarEditor user secret binding", () => {
|
||||
it("renders an existing user_secret_ref as a User secret row with the definition and requirement", async () => {
|
||||
const value: Record<string, EnvBinding> = {
|
||||
GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true },
|
||||
};
|
||||
await render({ value, userSecretDefinitions: [definition] });
|
||||
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="KEY"]');
|
||||
expect(keyInput?.value).toBe("GH_TOKEN");
|
||||
// Radix Select triggers render the selected label as text.
|
||||
expect(container.textContent).toContain("User secret");
|
||||
expect(container.textContent).toContain("Personal GitHub token");
|
||||
expect(container.textContent).toContain("Required");
|
||||
});
|
||||
|
||||
it("offers the User secret source option and the company-secret rename", async () => {
|
||||
await render({ userSecretDefinitions: [definition] });
|
||||
// The trailing empty row shows the source select defaulting to Plain.
|
||||
const modeTrigger = container.querySelector('[aria-label="Binding mode"]');
|
||||
expect(modeTrigger?.textContent).toContain("Plain");
|
||||
// Helper copy documents both company and user secret sources.
|
||||
expect(container.textContent).toContain("Company secret");
|
||||
expect(container.textContent).toContain("User secret");
|
||||
});
|
||||
|
||||
it("keeps working for company secrets when no user definitions are provided", async () => {
|
||||
const secret: CompanySecret = {
|
||||
id: "sec-1",
|
||||
companyId: "c1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "api_key",
|
||||
name: "API key",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 2,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const value: Record<string, EnvBinding> = {
|
||||
API_KEY: { type: "secret_ref", secretId: "sec-1", version: "latest" },
|
||||
};
|
||||
await render({ value, secrets: [secret] });
|
||||
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="KEY"]');
|
||||
expect(keyInput?.value).toBe("API_KEY");
|
||||
expect(container.textContent).toContain("API key");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { AlertCircle, KeyRound, X } from "lucide-react";
|
||||
import type {
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
SecretVersionSelector,
|
||||
UserSecretDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertCircle, KeyRound, UserRound, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
Select,
|
||||
@@ -30,60 +35,78 @@ function envKeyFromSecretName(name: string): string {
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
type RowSource = "plain" | "secret" | "user_secret";
|
||||
|
||||
type Row = {
|
||||
key: string;
|
||||
source: "plain" | "secret";
|
||||
source: RowSource;
|
||||
plainValue: string;
|
||||
secretId: string;
|
||||
version: SecretVersionSelector;
|
||||
/** For user_secret rows: the user-secret definition key this env var resolves from. */
|
||||
userSecretKey: string;
|
||||
/** For user_secret rows: whether a run must fail if the responsible user has not set a value. */
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
function emptyRow(): Row {
|
||||
return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" };
|
||||
return {
|
||||
key: "",
|
||||
source: "plain",
|
||||
plainValue: "",
|
||||
secretId: "",
|
||||
version: "latest",
|
||||
userSecretKey: "",
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") {
|
||||
return [emptyRow()];
|
||||
}
|
||||
const entries = Object.entries(rec).map(([key, binding]) => {
|
||||
const entries = Object.entries(rec).map(([key, binding]): Row => {
|
||||
if (typeof binding === "string") {
|
||||
return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const };
|
||||
return { ...emptyRow(), key, source: "plain", plainValue: binding };
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
const record = binding as { secretId?: unknown; version?: unknown };
|
||||
const version: SecretVersionSelector = typeof record.version === "number"
|
||||
? record.version
|
||||
: "latest";
|
||||
return {
|
||||
key,
|
||||
source: "secret" as const,
|
||||
plainValue: "",
|
||||
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
||||
version,
|
||||
};
|
||||
if (typeof binding === "object" && binding !== null && "type" in binding) {
|
||||
const type = (binding as { type?: unknown }).type;
|
||||
if (type === "secret_ref") {
|
||||
const record = binding as { secretId?: unknown; version?: unknown };
|
||||
const version: SecretVersionSelector =
|
||||
typeof record.version === "number" ? record.version : "latest";
|
||||
return {
|
||||
...emptyRow(),
|
||||
key,
|
||||
source: "secret",
|
||||
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
||||
version,
|
||||
};
|
||||
}
|
||||
if (type === "user_secret_ref") {
|
||||
const record = binding as { key?: unknown; version?: unknown; required?: unknown };
|
||||
const version: SecretVersionSelector =
|
||||
typeof record.version === "number" ? record.version : "latest";
|
||||
return {
|
||||
...emptyRow(),
|
||||
key,
|
||||
source: "user_secret",
|
||||
userSecretKey: typeof record.key === "string" ? record.key : "",
|
||||
version,
|
||||
required: record.required !== false,
|
||||
};
|
||||
}
|
||||
if (type === "plain") {
|
||||
const record = binding as { value?: unknown };
|
||||
return {
|
||||
...emptyRow(),
|
||||
key,
|
||||
source: "plain",
|
||||
plainValue: typeof record.value === "string" ? record.value : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "plain"
|
||||
) {
|
||||
const record = binding as { value?: unknown };
|
||||
return {
|
||||
key,
|
||||
source: "plain" as const,
|
||||
plainValue: typeof record.value === "string" ? record.value : "",
|
||||
secretId: "",
|
||||
version: "latest" as const,
|
||||
};
|
||||
}
|
||||
return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const };
|
||||
return { ...emptyRow(), key, source: "plain" };
|
||||
});
|
||||
return [...entries, emptyRow()];
|
||||
}
|
||||
@@ -91,12 +114,19 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
export function EnvVarEditor({
|
||||
value,
|
||||
secrets,
|
||||
userSecretDefinitions,
|
||||
onCreateSecret,
|
||||
onChange,
|
||||
recentlyUsedSecrets,
|
||||
}: {
|
||||
value: Record<string, EnvBinding>;
|
||||
secrets: CompanySecret[];
|
||||
/**
|
||||
* Optional company user-secret definitions. When present, the "User secret"
|
||||
* source becomes a picker over these definitions; otherwise the user types
|
||||
* the definition key directly. Absent for non-admin contexts.
|
||||
*/
|
||||
userSecretDefinitions?: UserSecretDefinition[];
|
||||
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
|
||||
onChange: (env: Record<string, EnvBinding> | undefined) => void;
|
||||
/**
|
||||
@@ -109,6 +139,7 @@ export function EnvVarEditor({
|
||||
const [sealError, setSealError] = useState<string | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
const emittingRef = useRef(false);
|
||||
const userSecretsEnabled = (userSecretDefinitions?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (emittingRef.current) {
|
||||
@@ -133,6 +164,18 @@ export function EnvVarEditor({
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
} else if (row.source === "user_secret") {
|
||||
const definitionKey = row.userSecretKey.trim();
|
||||
if (definitionKey) {
|
||||
rec[key] = {
|
||||
type: "user_secret_ref",
|
||||
key: definitionKey,
|
||||
version: row.version,
|
||||
required: row.required,
|
||||
};
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
@@ -145,11 +188,8 @@ export function EnvVarEditor({
|
||||
const withPatch: Row[] = rows.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, ...patch, version: patch.version ?? row.version } : row,
|
||||
);
|
||||
if (
|
||||
withPatch[withPatch.length - 1].key ||
|
||||
withPatch[withPatch.length - 1].plainValue ||
|
||||
withPatch[withPatch.length - 1].secretId
|
||||
) {
|
||||
const last = withPatch[withPatch.length - 1];
|
||||
if (last.key || last.plainValue || last.secretId || last.userSecretKey) {
|
||||
withPatch.push(emptyRow());
|
||||
}
|
||||
setRows(withPatch);
|
||||
@@ -158,12 +198,8 @@ export function EnvVarEditor({
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.filter((_, rowIndex) => rowIndex !== index);
|
||||
if (
|
||||
next.length === 0 ||
|
||||
next[next.length - 1].key ||
|
||||
next[next.length - 1].plainValue ||
|
||||
next[next.length - 1].secretId
|
||||
) {
|
||||
const last = next[next.length - 1];
|
||||
if (next.length === 0 || last.key || last.plainValue || last.secretId || last.userSecretKey) {
|
||||
next.push(emptyRow());
|
||||
}
|
||||
setRows(next);
|
||||
@@ -175,7 +211,13 @@ export function EnvVarEditor({
|
||||
const next = rows.map((row) => ({ ...row }));
|
||||
const trailing = next[next.length - 1];
|
||||
let target: Row;
|
||||
if (trailing && !trailing.key && !trailing.plainValue && !trailing.secretId) {
|
||||
if (
|
||||
trailing &&
|
||||
!trailing.key &&
|
||||
!trailing.plainValue &&
|
||||
!trailing.secretId &&
|
||||
!trailing.userSecretKey
|
||||
) {
|
||||
target = trailing;
|
||||
} else {
|
||||
target = emptyRow();
|
||||
@@ -226,7 +268,8 @@ export function EnvVarEditor({
|
||||
index === rows.length - 1 &&
|
||||
!row.key &&
|
||||
!row.plainValue &&
|
||||
!row.secretId;
|
||||
!row.secretId &&
|
||||
!row.userSecretKey;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<input
|
||||
@@ -239,8 +282,10 @@ export function EnvVarEditor({
|
||||
value={row.source}
|
||||
onValueChange={(next) =>
|
||||
updateRow(index, {
|
||||
source: next === "secret" ? "secret" : "plain",
|
||||
...(next === "plain" ? { secretId: "" } : {}),
|
||||
source: next as RowSource,
|
||||
...(next === "plain" ? { secretId: "", userSecretKey: "" } : {}),
|
||||
...(next === "secret" ? { userSecretKey: "" } : {}),
|
||||
...(next === "user_secret" ? { secretId: "" } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -249,7 +294,8 @@ export function EnvVarEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="plain">Plain</SelectItem>
|
||||
<SelectItem value="secret">Secret</SelectItem>
|
||||
<SelectItem value="secret">Company secret</SelectItem>
|
||||
<SelectItem value="user_secret">User secret</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{row.source === "secret" ? (
|
||||
@@ -325,6 +371,75 @@ export function EnvVarEditor({
|
||||
New
|
||||
</button>
|
||||
</>
|
||||
) : row.source === "user_secret" ? (
|
||||
<>
|
||||
{userSecretsEnabled ? (
|
||||
<Select
|
||||
value={row.userSecretKey || SECRET_UNSET}
|
||||
onValueChange={(next) => {
|
||||
const definitionKey = next === SECRET_UNSET ? "" : next;
|
||||
const definition = userSecretDefinitions?.find((d) => d.key === definitionKey);
|
||||
updateRow(index, {
|
||||
userSecretKey: definitionKey,
|
||||
...(definition && !row.key.trim()
|
||||
? { key: envKeyFromSecretName(definition.key) }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="User secret"
|
||||
className={cn(
|
||||
selectTriggerClass,
|
||||
"flex-[3]",
|
||||
row.userSecretKey &&
|
||||
!userSecretDefinitions?.some((d) => d.key === row.userSecretKey) &&
|
||||
"border-destructive text-destructive",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Select user secret..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.userSecretKey &&
|
||||
!userSecretDefinitions?.some((d) => d.key === row.userSecretKey) ? (
|
||||
<SelectItem value={row.userSecretKey}>
|
||||
Unknown ({row.userSecretKey})
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{(userSecretDefinitions ?? []).map((definition) => (
|
||||
<SelectItem key={definition.id} value={definition.key}>
|
||||
{definition.name}
|
||||
{definition.status !== "active" ? ` (${definition.status})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="user-secret key"
|
||||
aria-label="User secret key"
|
||||
value={row.userSecretKey}
|
||||
onChange={(event) => updateRow(index, { userSecretKey: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={row.required ? "required" : "optional"}
|
||||
onValueChange={(next) => updateRow(index, { required: next === "required" })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(selectTriggerClass, "flex-[1]")}
|
||||
aria-label="Requirement"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="required">Required</SelectItem>
|
||||
<SelectItem value="optional">Optional</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="w-[34px] shrink-0" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
@@ -416,9 +531,23 @@ export function EnvVarEditor({
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const userRows = rows.filter((row) => row.source === "user_secret" && row.userSecretKey);
|
||||
if (!userRows.length) return null;
|
||||
return (
|
||||
<p className="text-[11px] text-muted-foreground inline-flex items-start gap-1">
|
||||
<UserRound className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
User secrets resolve to the value set by the user responsible for the run. Required
|
||||
bindings fail the run until that user sets their value under Secrets → My secrets.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Secret to resolve a stored
|
||||
value at run start. PAPERCLIP_* variables are injected automatically.
|
||||
Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Company secret
|
||||
to resolve a shared stored value, or User secret to resolve each user's own value at run
|
||||
start. PAPERCLIP_* variables are injected automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -238,6 +238,7 @@ function createIssue(): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
issueNumber: 807,
|
||||
|
||||
@@ -34,6 +34,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -23,6 +23,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -178,6 +178,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -93,6 +93,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -92,6 +92,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 81,
|
||||
|
||||
@@ -176,6 +176,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -45,6 +45,7 @@ function createIssue(index: number, status: IssueStatus): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: index === 1 ? "agent-1" : null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: index,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -1354,6 +1355,12 @@ export function NewIssueDialog() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{effectiveCompanyId ? (
|
||||
<div className="px-4 pb-2">
|
||||
<MissingUserSecretsBanner companyId={effectiveCompanyId} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
|
||||
@@ -256,6 +256,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const { data: userSecretDefinitions = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.secrets.userDefinitions(selectedCompanyId)
|
||||
: ["user-secret-definitions", "none"],
|
||||
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
retry: false,
|
||||
});
|
||||
const createSecret = useMutation({
|
||||
mutationFn: (input: { name: string; value: string }) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
||||
@@ -626,6 +634,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
<EnvVarEditor
|
||||
value={project.env ?? {}}
|
||||
secrets={availableSecrets}
|
||||
userSecretDefinitions={userSecretDefinitions}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
|
||||
@@ -89,6 +89,7 @@ function snapshotV1(overrides?: Partial<RoutineRevisionSnapshotV1["routine"]>):
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Daily standup digest",
|
||||
description: "Summarize standup notes",
|
||||
assigneeAgentId: null,
|
||||
@@ -130,6 +131,7 @@ function createRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Daily standup digest",
|
||||
description: "Summarize standup notes",
|
||||
assigneeAgentId: null,
|
||||
@@ -346,6 +348,9 @@ describe("RoutineHistoryTab", () => {
|
||||
{
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "gh_token",
|
||||
name: "github-bot",
|
||||
provider: "local_encrypted",
|
||||
@@ -408,6 +413,9 @@ describe("RoutineHistoryTab", () => {
|
||||
{
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "old_token",
|
||||
name: "old-token",
|
||||
provider: "local_encrypted",
|
||||
@@ -429,6 +437,9 @@ describe("RoutineHistoryTab", () => {
|
||||
{
|
||||
id: "secret-2",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "new_token",
|
||||
name: "new-token",
|
||||
provider: "local_encrypted",
|
||||
|
||||
@@ -133,6 +133,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
||||
id,
|
||||
companyId: "company-1",
|
||||
agentId,
|
||||
responsibleUserId: null,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status,
|
||||
@@ -190,6 +191,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeIssue(id: string, parentId: string | null = null): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("issueDetailBreadcrumb", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -31,6 +31,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -450,6 +450,7 @@ describe("optimistic issue comments", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
@@ -520,6 +521,7 @@ describe("optimistic issue comments", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
@@ -694,6 +696,7 @@ describe("optimistic issue comments", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
@@ -736,6 +739,7 @@ describe("optimistic issue comments", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-2",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -245,6 +245,10 @@ export const queryKeys = {
|
||||
providerConfigs: (companyId: string) => ["secret-provider-configs", companyId] as const,
|
||||
usage: (secretId: string) => ["secrets", "usage", secretId] as const,
|
||||
accessEvents: (secretId: string) => ["secrets", "access-events", secretId] as const,
|
||||
userDefinitions: (companyId: string) => ["user-secret-definitions", companyId] as const,
|
||||
userDefinitionCoverage: (companyId: string, definitionId: string) =>
|
||||
["user-secret-definitions", companyId, definitionId, "coverage"] as const,
|
||||
myUserSecrets: (companyId: string) => ["my-user-secrets", companyId] as const,
|
||||
},
|
||||
companySearch: {
|
||||
search: (companyId: string, q: string, scope: string, limit: number, offset: number) =>
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ function createRoutine(overrides: Partial<RoutineListItem> = {}): RoutineListIte
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Routine title",
|
||||
description: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
|
||||
@@ -158,6 +158,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 904,
|
||||
@@ -398,6 +399,7 @@ describe("FailedRunInboxRow", () => {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
responsibleUserId: null,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status: "failed",
|
||||
|
||||
@@ -253,6 +253,7 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Routine title",
|
||||
description: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
@@ -293,6 +294,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1000,
|
||||
|
||||
@@ -5,10 +5,13 @@ import { flushSync } from "react-dom";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanySecret,
|
||||
CompanySecretProviderConfig,
|
||||
RemoteSecretImportPreviewResult,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
SecretProviderDescriptor,
|
||||
UserSecretCoverageSummary,
|
||||
UserSecretDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
||||
@@ -37,6 +40,16 @@ const mockSecretsApi = vi.hoisted(() => ({
|
||||
remove: vi.fn(),
|
||||
usage: vi.fn(),
|
||||
accessEvents: vi.fn(),
|
||||
listUserSecretDefinitions: vi.fn(),
|
||||
createUserSecretDefinition: vi.fn(),
|
||||
updateUserSecretDefinition: vi.fn(),
|
||||
removeUserSecretDefinition: vi.fn(),
|
||||
userSecretDefinitionCoverage: vi.fn(),
|
||||
listMyUserSecrets: vi.fn(),
|
||||
createMyUserSecret: vi.fn(),
|
||||
updateMyUserSecret: vi.fn(),
|
||||
rotateMyUserSecret: vi.fn(),
|
||||
removeMyUserSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
@@ -210,12 +223,78 @@ function makeRemoteImportPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompanySecret(overrides: Partial<CompanySecret> = {}): CompanySecret {
|
||||
return {
|
||||
id: "secret-openai",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "openai_api_key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
referenceCount: 2,
|
||||
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserSecretDefinition(overrides: Partial<UserSecretDefinition> = {}): UserSecretDefinition {
|
||||
return {
|
||||
id: "def-github",
|
||||
companyId: "company-1",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
description: "Used when the responsible user's own repos must be reached.",
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: "Create a fine-grained PAT with repo read access.",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const userSecretCoverage: UserSecretCoverageSummary = {
|
||||
definitionId: "def-github",
|
||||
configuredCount: 3,
|
||||
missingCount: 2,
|
||||
inactiveCount: 0,
|
||||
};
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function openAwsVaultDialog() {
|
||||
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.includes("Provider vaults"),
|
||||
@@ -258,6 +337,9 @@ describe("Secrets page layout", () => {
|
||||
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
|
||||
mockSecretsApi.remoteImportPreview.mockResolvedValue(makeRemoteImportPreview());
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
||||
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -435,31 +517,8 @@ describe("Secrets page layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens reference details from the secrets table count", async () => {
|
||||
mockSecretsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-1",
|
||||
key: "openai_api_key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
referenceCount: 2,
|
||||
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
it("keeps references reachable from the compact secrets row and detail drawer", async () => {
|
||||
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
||||
mockSecretsApi.usage.mockResolvedValue({
|
||||
secretId: "secret-openai",
|
||||
bindings: [
|
||||
@@ -504,17 +563,27 @@ describe("Secrets page layout", () => {
|
||||
await flushReact();
|
||||
|
||||
const referencesButton = container.querySelector(
|
||||
'button[aria-label="View references for OPENAI_API_KEY"]',
|
||||
'button[aria-label="Actions for OPENAI_API_KEY"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(referencesButton?.textContent).toBe("2");
|
||||
expect(referencesButton).not.toBeNull();
|
||||
|
||||
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
||||
(row) => row.textContent?.includes("OPENAI_API_KEY"),
|
||||
) as HTMLElement | undefined;
|
||||
await act(async () => {
|
||||
referencesButton?.click();
|
||||
companyRow?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const viewUsageButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("View in Usage"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
viewUsageButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
|
||||
expect(document.body.textContent).toContain("Secret references");
|
||||
expect(document.body.textContent).toContain("CodexCoder");
|
||||
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
|
||||
|
||||
@@ -523,6 +592,224 @@ describe("Secrets page layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges company secrets and each-user definitions into the Secrets list", async () => {
|
||||
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("OPENAI_API_KEY");
|
||||
expect(container.textContent).toContain("Personal GitHub token");
|
||||
expect(container.textContent).toContain("Company");
|
||||
expect(container.textContent).toContain("Each user");
|
||||
expect(container.textContent).toContain("3/5 set");
|
||||
expect(container.textContent).not.toContain("User secret definitions");
|
||||
|
||||
const listContainer = container.querySelector('[data-testid="secrets-list-container"]');
|
||||
const tableView = container.querySelector('[data-testid="secrets-table-view"]');
|
||||
const cardView = container.querySelector('[data-testid="secrets-card-view"]');
|
||||
expect(listContainer?.className).toContain("@container");
|
||||
expect(tableView?.className).toContain("@min-[40rem]:block");
|
||||
expect(tableView?.className).not.toContain("md:block");
|
||||
expect(tableView?.querySelector("[role='row']")?.className).toContain("minmax(12rem,2.4fr)");
|
||||
expect(cardView?.className).toContain("@min-[40rem]:hidden");
|
||||
expect(cardView?.className).not.toContain("md:hidden");
|
||||
|
||||
expect(mockSecretsApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(mockSecretsApi.listUserSecretDefinitions).toHaveBeenCalledWith("company-1");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an each-user secret from the unified New secret dialog", async () => {
|
||||
const definition = makeUserSecretDefinition({ name: "Personal GitHub token" });
|
||||
mockSecretsApi.createUserSecretDefinition.mockResolvedValueOnce(definition);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("New secret"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
newSecretButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const eachUserButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Each user",
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
eachUserButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
eachUserButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
eachUserButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
||||
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
||||
const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement;
|
||||
expect(document.getElementById("new-secret-provider")).toBeNull();
|
||||
expect(document.getElementById("new-secret-vault")).toBeNull();
|
||||
expect(document.getElementById("new-secret-value")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(nameInput, "Personal GitHub token");
|
||||
setTextareaValue(usageGuidance, "Create a fine-grained PAT with repo read access.");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(keyInput.value).toBe("PERSONAL_GITHUB_TOKEN");
|
||||
|
||||
const createButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Create user-provided secret"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
createButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith("company-1", {
|
||||
name: "Personal GitHub token",
|
||||
description: null,
|
||||
usageGuidance: "Create a fine-grained PAT with repo read access.",
|
||||
key: "PERSONAL_GITHUB_TOKEN",
|
||||
status: "active",
|
||||
});
|
||||
expect(mockSecretsApi.create).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the New secret dialog when provider queries fail", async () => {
|
||||
mockSecretsApi.providers.mockRejectedValueOnce(new ApiError("Providers unavailable", 403, null));
|
||||
mockSecretsApi.providerConfigs.mockRejectedValueOnce(new ApiError("Provider vaults unavailable", 403, null));
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("New secret"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
newSecretButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Create secret");
|
||||
expect(document.body.textContent).toContain("Select a provider.");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the each-user detail sheet with coverage and set-my-value actions", async () => {
|
||||
const definition = makeUserSecretDefinition();
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]);
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
||||
(row) => row.textContent?.includes("Personal GitHub token"),
|
||||
) as HTMLElement | undefined;
|
||||
await act(async () => {
|
||||
definitionRow?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Personal GitHub token");
|
||||
expect(document.body.textContent).toContain("Details");
|
||||
expect(document.body.textContent).toContain("Coverage");
|
||||
expect(document.body.textContent).toContain("Usage");
|
||||
expect(document.body.textContent).toContain("Access events");
|
||||
|
||||
const viewCoverageButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("View in Coverage"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
viewCoverageButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("3 of 5 set");
|
||||
expect(document.body.textContent).toContain("Secret values are never shown here");
|
||||
|
||||
const setValueButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Set my value"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
setValueButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Set your value");
|
||||
expect(document.body.textContent).toContain("PERSONAL_GH_TOKEN");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the new secret value textarea width-constrained for long tokens", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import {
|
||||
findCreateProviderReplacement,
|
||||
getAwsManagedPathPreview,
|
||||
getCreateProviderBlockReason,
|
||||
getDefaultProviderConfigId,
|
||||
@@ -19,6 +20,37 @@ const awsProvider: SecretProviderDescriptor = {
|
||||
configured: true,
|
||||
};
|
||||
|
||||
const localProvider: SecretProviderDescriptor = {
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
};
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<CompanySecretProviderConfig> & Pick<CompanySecretProviderConfig, "id" | "provider">,
|
||||
): CompanySecretProviderConfig {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
displayName: overrides.id,
|
||||
status: "ready",
|
||||
isDefault: false,
|
||||
config: {},
|
||||
healthStatus: null,
|
||||
healthCheckedAt: null,
|
||||
healthMessage: null,
|
||||
healthDetails: null,
|
||||
disabledAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Secrets page provider helpers", () => {
|
||||
it("previews the derived AWS managed path from provider health details", () => {
|
||||
const health: SecretProviderHealthResponse = {
|
||||
@@ -52,7 +84,9 @@ describe("Secrets page provider helpers", () => {
|
||||
"managed",
|
||||
null,
|
||||
),
|
||||
).toBe("AWS Secrets Manager is not configured in this deployment.");
|
||||
).toBe(
|
||||
"Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => {
|
||||
@@ -74,27 +108,72 @@ describe("Secrets page provider helpers", () => {
|
||||
health,
|
||||
),
|
||||
).toBe(
|
||||
"AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
|
||||
"Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an unconfigured AWS deployment default when a ready AWS provider vault is selected", () => {
|
||||
expect(
|
||||
getCreateProviderBlockReason(
|
||||
{ ...awsProvider, configured: false },
|
||||
"external",
|
||||
null,
|
||||
providerConfig({
|
||||
id: "aws-prod",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("names the selected provider vault block before deployment-default AWS config", () => {
|
||||
expect(
|
||||
getCreateProviderBlockReason(
|
||||
{ ...awsProvider, configured: false },
|
||||
"external",
|
||||
null,
|
||||
providerConfig({
|
||||
id: "aws-disabled",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS disabled",
|
||||
status: "disabled",
|
||||
}),
|
||||
),
|
||||
).toBe("This provider vault is disabled.");
|
||||
});
|
||||
|
||||
it("blocks provider modes the backend does not support", () => {
|
||||
expect(
|
||||
getCreateProviderBlockReason(
|
||||
{
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
},
|
||||
localProvider,
|
||||
"external",
|
||||
null,
|
||||
),
|
||||
).toBe("Local encrypted (default) does not support linked external references.");
|
||||
});
|
||||
|
||||
it("switching to external mode prefers AWS instead of staying on local encrypted when a ready AWS vault exists", () => {
|
||||
expect(
|
||||
findCreateProviderReplacement({
|
||||
providers: [localProvider, { ...awsProvider, configured: false }],
|
||||
providerConfigs: [
|
||||
providerConfig({
|
||||
id: "aws-prod",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
}),
|
||||
],
|
||||
currentProvider: "local_encrypted",
|
||||
mode: "external",
|
||||
health: null,
|
||||
})?.id,
|
||||
).toBe("aws_secrets_manager");
|
||||
});
|
||||
|
||||
it("chooses the ready default provider vault for a provider", () => {
|
||||
expect(
|
||||
getDefaultProviderConfigId(
|
||||
|
||||
+1537
-337
File diff suppressed because it is too large.
Load diff
@@ -286,6 +286,9 @@ describe("ImportFromVaultDialog", () => {
|
||||
{
|
||||
id: "secret-existing",
|
||||
companyId: "company-1",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "openai_api_key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "aws_secrets_manager",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MissingUserSecretsBanner } from "./MissingUserSecretsBanner";
|
||||
import type { MyUserSecretEntry } from "../../api/secrets";
|
||||
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
listMyUserSecrets: vi.fn(),
|
||||
createMyUserSecret: vi.fn(),
|
||||
rotateMyUserSecret: vi.fn(),
|
||||
}));
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../api/secrets", () => ({ secretsApi: mockSecretsApi }));
|
||||
vi.mock("../../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: mockPushToast }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function definition(overrides: Partial<UserSecretDefinition> = {}): UserSecretDefinition {
|
||||
return {
|
||||
id: "def-1",
|
||||
companyId: "c1",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
description: "Used for private repo access",
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function secret(): CompanySecret {
|
||||
return {
|
||||
id: "sec-1",
|
||||
companyId: "c1",
|
||||
scope: "user",
|
||||
ownerUserId: "u1",
|
||||
userSecretDefinitionId: "def-1",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
root = createRoot(container);
|
||||
return act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<MissingUserSecretsBanner companyId="c1" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("MissingUserSecretsBanner", () => {
|
||||
it("warns about an active user secret with no value and offers to set it", async () => {
|
||||
const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }];
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
|
||||
|
||||
await render();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Personal GitHub token");
|
||||
expect(container.textContent).toContain("PERSONAL_GH_TOKEN");
|
||||
expect(container.textContent).toContain("Set value");
|
||||
expect(container.textContent?.toLowerCase()).toContain("will fail");
|
||||
});
|
||||
|
||||
it("renders nothing when every active secret already has a value", async () => {
|
||||
const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: secret() }];
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
|
||||
|
||||
await render();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("ignores disabled definitions", async () => {
|
||||
const entries: MyUserSecretEntry[] = [
|
||||
{ definition: definition({ status: "disabled" }), secret: null },
|
||||
];
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
|
||||
|
||||
await render();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("opens the set-value dialog when Set value is clicked", async () => {
|
||||
const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }];
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
|
||||
|
||||
await render();
|
||||
await flushReact();
|
||||
|
||||
const setButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Set value"),
|
||||
);
|
||||
expect(setButton).toBeTruthy();
|
||||
await act(() => setButton!.click());
|
||||
await flushReact();
|
||||
|
||||
// Dialog content renders in a portal on document.body.
|
||||
expect(document.body.textContent).toContain("Set your value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { secretsApi, type MyUserSecretEntry } from "../../api/secrets";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
|
||||
|
||||
/**
|
||||
* Warning surface for user secrets the current user has not yet set. Renders
|
||||
* nothing when there is nothing missing, so it is safe to embed on task
|
||||
* creation / run and issue-failure surfaces. Lets the user satisfy a missing
|
||||
* required secret inline via the shared value dialog.
|
||||
*
|
||||
* Pass `definitionKeys` to scope the warning to a specific set (e.g. the user
|
||||
* secrets a blocked run reported as missing); omit it to warn about every
|
||||
* active definition the user has not set.
|
||||
*/
|
||||
export function MissingUserSecretsBanner({
|
||||
companyId,
|
||||
definitionKeys,
|
||||
title = "Set your user secrets",
|
||||
secretsPath,
|
||||
className,
|
||||
}: {
|
||||
companyId: string;
|
||||
definitionKeys?: string[];
|
||||
title?: string;
|
||||
/** Optional route to the Secrets → My secrets tab for the "Manage all" link. */
|
||||
secretsPath?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [dialogFor, setDialogFor] = useState<MyUserSecretEntry | null>(null);
|
||||
|
||||
const mySecretsQuery = useQuery({
|
||||
queryKey: queryKeys.secrets.myUserSecrets(companyId),
|
||||
queryFn: () => secretsApi.listMyUserSecrets(companyId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const keyFilter = definitionKeys ? new Set(definitionKeys) : null;
|
||||
const missing = (mySecretsQuery.data ?? []).filter(
|
||||
(entry) =>
|
||||
entry.definition.status === "active" &&
|
||||
!entry.secret &&
|
||||
(!keyFilter || keyFilter.has(entry.definition.key)),
|
||||
);
|
||||
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-xs text-amber-800 dark:text-amber-200" +
|
||||
(className ? ` ${className}` : "")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="mt-0.5 text-amber-700/90 dark:text-amber-300/90">
|
||||
{missing.length} user secret{missing.length === 1 ? "" : "s"} you are responsible for
|
||||
{missing.length === 1 ? " has" : " have"} no value yet. Runs that require
|
||||
{missing.length === 1 ? " it" : " them"} will fail until you set your value.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{missing.map((entry) => (
|
||||
<li
|
||||
key={entry.definition.id}
|
||||
className="flex items-center justify-between gap-2 rounded border border-amber-500/30 bg-background/40 px-2 py-1"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">{entry.definition.name}</span>{" "}
|
||||
<code className="text-[11px] text-muted-foreground">{entry.definition.key}</code>
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setDialogFor(entry)}>
|
||||
Set value
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{secretsPath ? (
|
||||
<Link
|
||||
to={secretsPath}
|
||||
className="mt-2 inline-block text-[11px] font-medium underline underline-offset-2"
|
||||
>
|
||||
Manage all my secrets
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SetMyUserSecretDialog
|
||||
companyId={companyId}
|
||||
definition={dialogFor?.definition ?? null}
|
||||
existingSecret={dialogFor?.secret ?? null}
|
||||
open={dialogFor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDialogFor(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CompanySecret } from "@paperclipai/shared";
|
||||
import { AlertCircle, KeyRound, Trash2, UserRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { secretsApi, type MyUserSecretEntry } from "../../api/secrets";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastActions } from "../../context/ToastContext";
|
||||
import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
|
||||
import {
|
||||
myValueLabel,
|
||||
myValueState,
|
||||
myValueTone,
|
||||
} from "./my-value-state";
|
||||
|
||||
/**
|
||||
* Secrets → My secrets tab. Lists every company user-secret definition paired
|
||||
* with the current user's own value state, and lets the user set / update /
|
||||
* clear their value. This is the owner-facing counterpart to the admin
|
||||
* "User secret definitions" tab.
|
||||
*/
|
||||
export function MyUserSecretsTab({ companyId }: { companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [dialogFor, setDialogFor] = useState<MyUserSecretEntry | null>(null);
|
||||
|
||||
const mySecretsQuery = useQuery({
|
||||
queryKey: queryKeys.secrets.myUserSecrets(companyId),
|
||||
queryFn: () => secretsApi.listMyUserSecrets(companyId),
|
||||
});
|
||||
const entries = mySecretsQuery.data ?? [];
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: (secret: CompanySecret) => secretsApi.removeMyUserSecret(companyId, secret.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) });
|
||||
pushToast({ title: "Value cleared", tone: "info" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not clear value",
|
||||
body: err instanceof Error ? err.message : undefined,
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const missingCount = entries.filter(
|
||||
(entry) => entry.definition.status === "active" && !entry.secret,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-500/30 bg-violet-500/5 px-4 py-3 text-xs text-violet-800 dark:text-violet-200">
|
||||
<UserRound className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
These are credentials only you provide. Each value is yours alone — used when you are the
|
||||
user responsible for a run — and is never shown back to anyone, including admins.
|
||||
{missingCount > 0 ? (
|
||||
<span className="font-medium">
|
||||
{" "}
|
||||
{missingCount} required secret{missingCount === 1 ? " still needs" : "s still need"} your
|
||||
value.
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{mySecretsQuery.isError ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" /> Failed to load your secrets:{" "}
|
||||
{(mySecretsQuery.error as Error).message}
|
||||
<Button variant="ghost" size="sm" onClick={() => mySecretsQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : entries.length === 0 && !mySecretsQuery.isPending ? (
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
message="No user secrets are defined for this company yet. An admin defines which credentials each member supplies."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<MyUserSecretRow
|
||||
key={entry.definition.id}
|
||||
entry={entry}
|
||||
onSet={() => setDialogFor(entry)}
|
||||
onClear={() => entry.secret && clear.mutate(entry.secret)}
|
||||
clearing={clear.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SetMyUserSecretDialog
|
||||
companyId={companyId}
|
||||
definition={dialogFor?.definition ?? null}
|
||||
existingSecret={dialogFor?.secret ?? null}
|
||||
open={dialogFor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDialogFor(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyUserSecretRow({
|
||||
entry,
|
||||
onSet,
|
||||
onClear,
|
||||
clearing,
|
||||
}: {
|
||||
entry: MyUserSecretEntry;
|
||||
onSet: () => void;
|
||||
onClear: () => void;
|
||||
clearing: boolean;
|
||||
}) {
|
||||
const { definition, secret } = entry;
|
||||
const state = myValueState(definition, secret);
|
||||
const disabledDefinition = definition.status !== "active";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-md border p-3",
|
||||
state === "not_set" && !disabledDefinition
|
||||
? "border-amber-500/40 bg-amber-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{definition.name}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{definition.key}
|
||||
</code>
|
||||
{disabledDefinition ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{definition.status}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{definition.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{definition.description}</p>
|
||||
) : null}
|
||||
{definition.usageGuidance ? (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">{definition.usageGuidance}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="outline" className={cn("text-[11px]", myValueTone(state))}>
|
||||
{myValueLabel(state)}
|
||||
</Badge>
|
||||
{!disabledDefinition ? (
|
||||
<Button size="sm" variant={secret ? "outline" : "default"} onClick={onSet}>
|
||||
{secret ? "Update" : "Set value"}
|
||||
</Button>
|
||||
) : null}
|
||||
{secret ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={onClear}
|
||||
disabled={clearing}
|
||||
title="Clear my value"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { secretsApi } from "../../api/secrets";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { useToastActions } from "../../context/ToastContext";
|
||||
import { UserSecretChip } from "./user-secret-presentation";
|
||||
|
||||
/**
|
||||
* Shared "set my value" dialog for a user-secret definition. Used both from the
|
||||
* Secrets → My secrets tab and from the missing-required-secret warning surfaces
|
||||
* (task run / issue failure), so a user can satisfy a required secret from either
|
||||
* place with identical behavior.
|
||||
*/
|
||||
export function SetMyUserSecretDialog({
|
||||
companyId,
|
||||
definition,
|
||||
existingSecret,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
definition: UserSecretDefinition | null;
|
||||
existingSecret?: CompanySecret | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved?: (secret: CompanySecret) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [value, setValue] = useState("");
|
||||
const [externalRef, setExternalRef] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isExternal = definition?.managedMode === "external_reference";
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue("");
|
||||
setExternalRef("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open, definition?.id]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!definition) throw new Error("No definition selected");
|
||||
const payload = isExternal
|
||||
? { externalRef: externalRef.trim() }
|
||||
: { value: value.trim() };
|
||||
if (existingSecret) {
|
||||
// A stored value already exists → rotate it in place.
|
||||
return secretsApi.rotateMyUserSecret(companyId, existingSecret.id, payload);
|
||||
}
|
||||
return secretsApi.createMyUserSecret(companyId, {
|
||||
definitionId: definition.id,
|
||||
definitionKey: definition.key,
|
||||
...payload,
|
||||
});
|
||||
},
|
||||
onSuccess: (secret) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
|
||||
pushToast({
|
||||
title: existingSecret ? "Value updated" : "Value saved",
|
||||
body: definition?.name,
|
||||
tone: "success",
|
||||
});
|
||||
onSaved?.(secret);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to save value",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const canSave = isExternal ? externalRef.trim().length > 0 : value.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{existingSecret ? "Update your value" : "Set your value"}
|
||||
<UserSecretChip />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{definition ? (
|
||||
<>
|
||||
This value is yours only. It is used when you are the user responsible for a run that
|
||||
needs <span className="font-mono">{definition.key}</span>.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{definition ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||
<div className="font-medium text-foreground">{definition.name}</div>
|
||||
{definition.description ? (
|
||||
<p className="mt-1 text-muted-foreground">{definition.description}</p>
|
||||
) : null}
|
||||
{definition.usageGuidance ? (
|
||||
<p className="mt-1 text-muted-foreground">{definition.usageGuidance}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isExternal ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">External reference</label>
|
||||
<Input
|
||||
value={externalRef}
|
||||
onChange={(event) => setExternalRef(event.target.value)}
|
||||
placeholder="provider reference or ARN"
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Points at your own credential in the configured provider. Paperclip stores the
|
||||
reference, not the value.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Your value</label>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="Paste your token or credential"
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Stored encrypted. Never shown back to anyone, including admins.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={!canSave || save.isPending}>
|
||||
{save.isPending ? "Saving…" : existingSecret ? "Update value" : "Save value"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { SecretStatus, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import { AlertCircle, Pencil, Plus, Trash2, UserRound, Users } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { secretsApi } from "../../api/secrets";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastActions } from "../../context/ToastContext";
|
||||
import {
|
||||
coverageSummaryLabel,
|
||||
secretStatusTone,
|
||||
UserSecretChip,
|
||||
} from "./user-secret-presentation";
|
||||
|
||||
function keyFromName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
interface DefinitionForm {
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
usageGuidance: string;
|
||||
status: SecretStatus;
|
||||
}
|
||||
|
||||
const emptyForm: DefinitionForm = {
|
||||
name: "",
|
||||
key: "",
|
||||
description: "",
|
||||
usageGuidance: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
/**
|
||||
* Secrets → User secret definitions tab (admin). Defines the shared credentials
|
||||
* that each member fills in with their own value. Coverage is shown as counts
|
||||
* only — never values — per the UX terminology decisions.
|
||||
*/
|
||||
export function UserSecretDefinitionsTab({ companyId }: { companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<UserSecretDefinition | null>(null);
|
||||
const [form, setForm] = useState<DefinitionForm>(emptyForm);
|
||||
const [keyDirty, setKeyDirty] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserSecretDefinition | null>(null);
|
||||
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: queryKeys.secrets.userDefinitions(companyId),
|
||||
queryFn: () => secretsApi.listUserSecretDefinitions(companyId),
|
||||
});
|
||||
const definitions = definitionsQuery.data ?? [];
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setKeyDirty(false);
|
||||
setError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(definition: UserSecretDefinition) {
|
||||
setEditing(definition);
|
||||
setForm({
|
||||
name: definition.name,
|
||||
key: definition.key,
|
||||
description: definition.description ?? "",
|
||||
usageGuidance: definition.usageGuidance ?? "",
|
||||
status: definition.status,
|
||||
});
|
||||
setKeyDirty(true);
|
||||
setError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sharedPayload = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
usageGuidance: form.usageGuidance.trim() || null,
|
||||
};
|
||||
if (editing) {
|
||||
return secretsApi.updateUserSecretDefinition(companyId, editing.id, {
|
||||
...sharedPayload,
|
||||
status: form.status,
|
||||
});
|
||||
}
|
||||
return secretsApi.createUserSecretDefinition(companyId, {
|
||||
...sharedPayload,
|
||||
key: form.key.trim(),
|
||||
status: form.status === "deleted" ? "active" : form.status,
|
||||
});
|
||||
},
|
||||
onSuccess: (definition) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
|
||||
pushToast({
|
||||
title: editing ? "Definition updated" : "Definition created",
|
||||
body: definition.name,
|
||||
tone: "success",
|
||||
});
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(err instanceof ApiError || err instanceof Error ? err.message : "Failed to save"),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (definition: UserSecretDefinition) =>
|
||||
secretsApi.removeUserSecretDefinition(companyId, definition.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
|
||||
pushToast({ title: "Definition removed", tone: "info" });
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not remove definition",
|
||||
body: err instanceof Error ? err.message : undefined,
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const canSave = form.name.trim().length > 0 && form.key.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-500/30 bg-violet-500/5 px-4 py-3 text-xs text-violet-800 dark:text-violet-200">
|
||||
<UserRound className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
Define credentials that <span className="font-medium">each member supplies for
|
||||
themselves</span>. You set the shape here; every user enters their own value under My
|
||||
secrets. Coverage shows how many members have set a value — never the values themselves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> New user secret
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{definitionsQuery.isError ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" /> Failed to load definitions:{" "}
|
||||
{(definitionsQuery.error as Error).message}
|
||||
<Button variant="ghost" size="sm" onClick={() => definitionsQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : definitions.length === 0 && !definitionsQuery.isPending ? (
|
||||
<EmptyState
|
||||
icon={UserRound}
|
||||
message="No user secret definitions yet. Create one to require each member to supply their own credential."
|
||||
action="New user secret"
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{definitions.map((definition) => (
|
||||
<li
|
||||
key={definition.id}
|
||||
className="flex items-start gap-3 rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{definition.name}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{definition.key}
|
||||
</code>
|
||||
<UserSecretChip />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[11px]", secretStatusTone(definition.status))}
|
||||
>
|
||||
{definition.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{definition.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{definition.description}</p>
|
||||
) : null}
|
||||
<CoverageBadge companyId={companyId} definitionId={definition.id} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => openEdit(definition)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(definition)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create / edit dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{editing ? "Edit user secret" : "New user secret"}
|
||||
<UserSecretChip />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Members supply their own value for this credential. No value is entered here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: keyDirty ? current.key : keyFromName(name),
|
||||
}));
|
||||
}}
|
||||
placeholder="Personal GitHub token"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Key</label>
|
||||
<Input
|
||||
value={form.key}
|
||||
onChange={(event) => {
|
||||
setKeyDirty(true);
|
||||
setForm((current) => ({ ...current, key: event.target.value }));
|
||||
}}
|
||||
placeholder="PERSONAL_GH_TOKEN"
|
||||
className="font-mono text-sm"
|
||||
disabled={Boolean(editing)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Stable identifier referenced by env bindings. {editing ? "Cannot be changed." : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Description</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
placeholder="What this credential is for"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Usage guidance <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.usageGuidance}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, usageGuidance: event.target.value }))
|
||||
}
|
||||
placeholder="Tell members how to create their token, required scopes, etc."
|
||||
className="min-h-[70px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Status</label>
|
||||
<Select
|
||||
value={form.status}
|
||||
onValueChange={(status) =>
|
||||
setForm((current) => ({ ...current, status: status as SecretStatus }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={!canSave || save.isPending}>
|
||||
{save.isPending ? "Saving…" : editing ? "Save changes" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove user secret?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the definition <span className="font-mono">{deleteTarget?.key}</span> for
|
||||
the whole company. Existing member values become unreferenced. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)} disabled={remove.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && remove.mutate(deleteTarget)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
{remove.isPending ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageBadge({
|
||||
companyId,
|
||||
definitionId,
|
||||
}: {
|
||||
companyId: string;
|
||||
definitionId: string;
|
||||
}) {
|
||||
const coverageQuery = useQuery({
|
||||
queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
|
||||
queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const summary = coverageQuery.data;
|
||||
const missing = summary ? summary.missingCount : 0;
|
||||
return (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
Coverage: {coverageSummaryLabel(summary)}
|
||||
{summary && missing > 0 ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">· {missing} not set</span>
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import { type MyValueState, myValueLabel, myValueTone } from "./user-secret-presentation";
|
||||
|
||||
export type { MyValueState };
|
||||
export { myValueLabel, myValueTone };
|
||||
|
||||
/**
|
||||
* Derive the current user's value state for a definition:
|
||||
* - "set": an active value exists
|
||||
* - "inactive": a value exists but is disabled/archived
|
||||
* - "not_set": no value stored yet
|
||||
*/
|
||||
export function myValueState(
|
||||
_definition: UserSecretDefinition,
|
||||
secret: CompanySecret | null | undefined,
|
||||
): MyValueState {
|
||||
if (!secret) return "not_set";
|
||||
if (secret.status === "active") return "set";
|
||||
return "inactive";
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
coverageSummaryLabel,
|
||||
myValueLabel,
|
||||
secretStatusTone,
|
||||
} from "./user-secret-presentation";
|
||||
import { myValueState } from "./my-value-state";
|
||||
|
||||
function makeSecret(status: CompanySecret["status"]): CompanySecret {
|
||||
return {
|
||||
id: "sec-1",
|
||||
companyId: "c1",
|
||||
scope: "user",
|
||||
ownerUserId: "u1",
|
||||
userSecretDefinitionId: "def-1",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GH token",
|
||||
provider: "local_encrypted",
|
||||
status,
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const definition = { id: "def-1", key: "PERSONAL_GH_TOKEN" } as UserSecretDefinition;
|
||||
|
||||
describe("coverageSummaryLabel", () => {
|
||||
it("shows counts only, never values", () => {
|
||||
expect(
|
||||
coverageSummaryLabel({
|
||||
definitionId: "def-1",
|
||||
configuredCount: 5,
|
||||
missingCount: 2,
|
||||
inactiveCount: 0,
|
||||
}),
|
||||
).toBe("5 of 7 set");
|
||||
});
|
||||
|
||||
it("counts inactive members in the total", () => {
|
||||
expect(
|
||||
coverageSummaryLabel({
|
||||
definitionId: "def-1",
|
||||
configuredCount: 3,
|
||||
missingCount: 1,
|
||||
inactiveCount: 1,
|
||||
}),
|
||||
).toBe("3 of 5 set");
|
||||
});
|
||||
|
||||
it("renders a dash when coverage is unknown", () => {
|
||||
expect(coverageSummaryLabel(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("myValueState", () => {
|
||||
it("is not_set when the user has no value", () => {
|
||||
expect(myValueState(definition, null)).toBe("not_set");
|
||||
expect(myValueLabel(myValueState(definition, null))).toBe("Not set");
|
||||
});
|
||||
|
||||
it("is set when an active value exists", () => {
|
||||
expect(myValueState(definition, makeSecret("active"))).toBe("set");
|
||||
});
|
||||
|
||||
it("is inactive when the value is disabled", () => {
|
||||
expect(myValueState(definition, makeSecret("disabled"))).toBe("inactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStatusTone", () => {
|
||||
it("uses emerald for active and muted for disabled", () => {
|
||||
expect(secretStatusTone("active")).toContain("emerald");
|
||||
expect(secretStatusTone("disabled")).toContain("muted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { SecretStatus, UserSecretCoverageSummary } from "@paperclipai/shared";
|
||||
import { UserRound } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/**
|
||||
* User secrets are visually distinct from company secrets via a violet accent
|
||||
* (company secrets use neutral/emerald tones). This keeps the two Secrets tabs
|
||||
* unmistakable at a glance, per the Phase 2 UX direction.
|
||||
*/
|
||||
export const USER_SECRET_ACCENT_TEXT = "text-violet-700 dark:text-violet-300";
|
||||
export const USER_SECRET_ACCENT_BORDER = "border-violet-500/30";
|
||||
export const USER_SECRET_ACCENT_BG = "bg-violet-500/10";
|
||||
|
||||
/** Small pill used to mark user-scoped rows and headers. */
|
||||
export function UserSecretChip({ className, label = "User secret" }: { className?: string; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
USER_SECRET_ACCENT_BORDER,
|
||||
USER_SECRET_ACCENT_BG,
|
||||
USER_SECRET_ACCENT_TEXT,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tone for a secret/definition status badge. */
|
||||
export function secretStatusTone(status: SecretStatus): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
case "disabled":
|
||||
return "border-muted bg-muted text-muted-foreground";
|
||||
case "archived":
|
||||
return "border-border bg-muted/60 text-muted-foreground";
|
||||
default:
|
||||
return "border-border bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/** Tone for "my value" state: set (emerald), not set (amber), inactive (muted). */
|
||||
export type MyValueState = "set" | "not_set" | "inactive";
|
||||
|
||||
export function myValueTone(state: MyValueState): string {
|
||||
switch (state) {
|
||||
case "set":
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
case "not_set":
|
||||
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
case "inactive":
|
||||
return "border-muted bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export function myValueLabel(state: MyValueState): string {
|
||||
switch (state) {
|
||||
case "set":
|
||||
return "Value set";
|
||||
case "not_set":
|
||||
return "Not set";
|
||||
case "inactive":
|
||||
return "Disabled";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage is surfaced as counts only, never values, per the UX terminology
|
||||
* decisions. E.g. "5 of 7 members set".
|
||||
*/
|
||||
export function coverageSummaryLabel(summary: UserSecretCoverageSummary | undefined): string {
|
||||
if (!summary) return "—";
|
||||
const total = summary.configuredCount + summary.missingCount + summary.inactiveCount;
|
||||
return `${summary.configuredCount} of ${total} set`;
|
||||
}
|
||||
@@ -712,6 +712,7 @@ export function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
priority: "high",
|
||||
assigneeAgentId: "agent-codex",
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: "run-storybook",
|
||||
executionRunId: "run-storybook",
|
||||
executionAgentNameKey: "codexcoder",
|
||||
@@ -1452,6 +1453,9 @@ export const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "openai_api_key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
@@ -1473,6 +1477,9 @@ export const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-aws-prod",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "prod_aws_deploy",
|
||||
name: "PROD_AWS_DEPLOY_KEY",
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -1494,6 +1501,9 @@ export const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-github",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "github_app_pem",
|
||||
name: "GITHUB_APP_PEM",
|
||||
provider: "local_encrypted",
|
||||
@@ -1515,6 +1525,9 @@ export const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-stripe-archived",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "stripe_legacy",
|
||||
name: "STRIPE_LEGACY",
|
||||
provider: "vault",
|
||||
@@ -1582,6 +1595,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
|
||||
id: "evt-1",
|
||||
companyId: "company-storybook",
|
||||
secretId: "secret-openai",
|
||||
userSecretDefinitionId: null,
|
||||
secretScope: "company",
|
||||
responsibleUserId: null,
|
||||
credentialOwnerUserId: null,
|
||||
credentialSubjectType: null,
|
||||
credentialSubjectId: null,
|
||||
version: 3,
|
||||
provider: "local_encrypted",
|
||||
actorType: "agent",
|
||||
@@ -1600,6 +1619,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
|
||||
id: "evt-2",
|
||||
companyId: "company-storybook",
|
||||
secretId: "secret-openai",
|
||||
userSecretDefinitionId: null,
|
||||
secretScope: "company",
|
||||
responsibleUserId: null,
|
||||
credentialOwnerUserId: null,
|
||||
credentialSubjectType: null,
|
||||
credentialSubjectId: null,
|
||||
version: 3,
|
||||
provider: "local_encrypted",
|
||||
actorType: "system",
|
||||
@@ -1618,6 +1643,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
|
||||
id: "evt-3",
|
||||
companyId: "company-storybook",
|
||||
secretId: "secret-openai",
|
||||
userSecretDefinitionId: null,
|
||||
secretScope: "company",
|
||||
responsibleUserId: null,
|
||||
credentialOwnerUserId: null,
|
||||
credentialSubjectType: null,
|
||||
credentialSubjectId: null,
|
||||
version: null,
|
||||
provider: "local_encrypted",
|
||||
actorType: "agent",
|
||||
|
||||
@@ -254,6 +254,9 @@ const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: COMPANY_ID,
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "openai-api-key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
@@ -275,6 +278,9 @@ const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-ops-webhook",
|
||||
companyId: COMPANY_ID,
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "ops-webhook-token",
|
||||
name: "OPS_WEBHOOK_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
|
||||
@@ -96,6 +96,7 @@ function makeHeartbeatRun(overrides: Partial<HeartbeatRun>): HeartbeatRun {
|
||||
id: "run-fixture",
|
||||
companyId,
|
||||
agentId: "agent-codex",
|
||||
responsibleUserId: null,
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
status: "succeeded",
|
||||
|
||||
@@ -238,6 +238,7 @@ function makeIntegratedIssue(): Issue {
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -202,6 +202,9 @@ const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "openai-api-key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
@@ -223,6 +226,9 @@ const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-github",
|
||||
companyId: "company-storybook",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: "github-token",
|
||||
name: "GITHUB_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
|
||||
@@ -78,6 +78,7 @@ const routine: RoutineDetailType = {
|
||||
projectId: storybookProjects[0]?.id ?? null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Send the weekly digest to {{customer_name}}",
|
||||
description:
|
||||
"Compile last week's shipped work and email a digest to {{customer_name}} by {{deadline}}.\n\nKeep it to five bullets.",
|
||||
|
||||
@@ -149,6 +149,7 @@ function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 {
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Nightly digest",
|
||||
description: "Summarize agent activity each night.",
|
||||
assigneeAgentId: null,
|
||||
@@ -170,6 +171,7 @@ function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Ro
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
responsibleUserId: null,
|
||||
title: "Nightly digest",
|
||||
description: "Summarize agent activity each night.",
|
||||
assigneeAgentId: null,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, KeyRound } from "lucide-react";
|
||||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import type {
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
UserSecretCoverageSummary,
|
||||
UserSecretDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { Secrets } from "@/pages/Secrets";
|
||||
import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker";
|
||||
import { EnvVarEditor } from "@/components/EnvVarEditor";
|
||||
@@ -14,6 +19,64 @@ import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
|
||||
const storybookUserSecretDefinitions: UserSecretDefinition[] = [
|
||||
{
|
||||
id: "def-storybook-github",
|
||||
companyId: COMPANY_ID,
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
description: "Used when the responsible user's own repos must be reached.",
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: "Create a fine-grained PAT with repo read access.",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-board",
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "def-storybook-openai",
|
||||
companyId: COMPANY_ID,
|
||||
key: "USER_OPENAI_API_KEY",
|
||||
name: "User OpenAI API key",
|
||||
description: "Each member bills agent experiments to their own account.",
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: "Create a project-scoped OpenAI key.",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-board",
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-06-03T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const storybookUserSecretCoverage: Record<string, UserSecretCoverageSummary> = {
|
||||
"def-storybook-github": {
|
||||
definitionId: "def-storybook-github",
|
||||
configuredCount: 5,
|
||||
missingCount: 2,
|
||||
inactiveCount: 0,
|
||||
},
|
||||
"def-storybook-openai": {
|
||||
definitionId: "def-storybook-openai",
|
||||
configuredCount: 7,
|
||||
missingCount: 0,
|
||||
inactiveCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Seed localStorage before CompanyContext mounts so its `useState` initializer reads the right id.
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID);
|
||||
@@ -23,6 +86,20 @@ function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
// Seed query caches synchronously so children hydrate from cache on first render.
|
||||
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.secrets.userDefinitions(COMPANY_ID),
|
||||
storybookUserSecretDefinitions,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.secrets.myUserSecrets(COMPANY_ID),
|
||||
storybookUserSecretDefinitions.map((definition) => ({ definition, secret: null })),
|
||||
);
|
||||
for (const [definitionId, summary] of Object.entries(storybookUserSecretCoverage)) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.secrets.userDefinitionCoverage(COMPANY_ID, definitionId),
|
||||
summary,
|
||||
);
|
||||
}
|
||||
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
UserSecretCoverageSummary,
|
||||
UserSecretDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MyUserSecretsTab } from "@/pages/secrets/MyUserSecretsTab";
|
||||
import { UserSecretDefinitionsTab } from "@/pages/secrets/UserSecretDefinitionsTab";
|
||||
import { MissingUserSecretsBanner } from "@/pages/secrets/MissingUserSecretsBanner";
|
||||
import { EnvVarEditor } from "@/components/EnvVarEditor";
|
||||
import type { MyUserSecretEntry } from "@/api/secrets";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID);
|
||||
}
|
||||
|
||||
function makeDefinition(overrides: Partial<UserSecretDefinition>): UserSecretDefinition {
|
||||
return {
|
||||
id: "def-x",
|
||||
companyId: COMPANY_ID,
|
||||
key: "USER_SECRET",
|
||||
name: "User secret",
|
||||
description: null,
|
||||
status: "active",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
usageGuidance: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeValue(definitionId: string): CompanySecret {
|
||||
return {
|
||||
id: `sec-${definitionId}`,
|
||||
companyId: COMPANY_ID,
|
||||
scope: "user",
|
||||
ownerUserId: "user-me",
|
||||
userSecretDefinitionId: definitionId,
|
||||
key: "USER_SECRET",
|
||||
name: "User secret",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-me",
|
||||
createdAt: new Date("2026-06-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
const ghToken = makeDefinition({
|
||||
id: "def-gh",
|
||||
key: "PERSONAL_GH_TOKEN",
|
||||
name: "Personal GitHub token",
|
||||
description: "Used when the responsible user's own repos must be reached.",
|
||||
usageGuidance: "Create a fine-grained PAT with repo:read scope.",
|
||||
});
|
||||
const openai = makeDefinition({
|
||||
id: "def-openai",
|
||||
key: "OPENAI_API_KEY",
|
||||
name: "OpenAI API key",
|
||||
description: "Each member bills to their own OpenAI account.",
|
||||
});
|
||||
const slack = makeDefinition({
|
||||
id: "def-slack",
|
||||
key: "SLACK_USER_TOKEN",
|
||||
name: "Slack user token",
|
||||
status: "disabled",
|
||||
});
|
||||
|
||||
const definitions: UserSecretDefinition[] = [ghToken, openai, slack];
|
||||
|
||||
const coverage: Record<string, UserSecretCoverageSummary> = {
|
||||
"def-gh": { definitionId: "def-gh", configuredCount: 5, missingCount: 2, inactiveCount: 0 },
|
||||
"def-openai": { definitionId: "def-openai", configuredCount: 7, missingCount: 0, inactiveCount: 0 },
|
||||
"def-slack": { definitionId: "def-slack", configuredCount: 1, missingCount: 5, inactiveCount: 1 },
|
||||
};
|
||||
|
||||
const myEntries: MyUserSecretEntry[] = [
|
||||
{ definition: ghToken, secret: null },
|
||||
{ definition: openai, secret: makeValue("def-openai") },
|
||||
{ definition: slack, secret: null },
|
||||
];
|
||||
|
||||
function SeedFixtures({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(queryKeys.secrets.userDefinitions(COMPANY_ID), definitions);
|
||||
queryClient.setQueryData(queryKeys.secrets.myUserSecrets(COMPANY_ID), myEntries);
|
||||
for (const [definitionId, summary] of Object.entries(coverage)) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.secrets.userDefinitionCoverage(COMPANY_ID, definitionId),
|
||||
summary,
|
||||
);
|
||||
}
|
||||
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
useEffect(() => {
|
||||
if (selectedCompanyId !== COMPANY_ID) setSelectedCompanyId(COMPANY_ID);
|
||||
}, [selectedCompanyId, setSelectedCompanyId]);
|
||||
if (selectedCompanyId !== COMPANY_ID) return null;
|
||||
|
||||
return <MemoryRouter>{children}</MemoryRouter>;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border-b border-border p-6 last:border-b-0">
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Product/User secrets",
|
||||
parameters: { layout: "fullscreen", a11y: { test: "off" } },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const AdminDefinitions: Story = {
|
||||
render: () => (
|
||||
<SeedFixtures>
|
||||
<Section title="User secret definitions (admin)">
|
||||
<div className="h-[520px]">
|
||||
<UserSecretDefinitionsTab companyId={COMPANY_ID} />
|
||||
</div>
|
||||
</Section>
|
||||
</SeedFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
export const MySecrets: Story = {
|
||||
render: () => (
|
||||
<SeedFixtures>
|
||||
<Section title="My secrets (owner)">
|
||||
<div className="h-[520px]">
|
||||
<MyUserSecretsTab companyId={COMPANY_ID} />
|
||||
</div>
|
||||
</Section>
|
||||
</SeedFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
export const MissingWarning: Story = {
|
||||
render: () => (
|
||||
<SeedFixtures>
|
||||
<Section title="Missing user-secret warning (task creation / run)">
|
||||
<div className="max-w-xl">
|
||||
<MissingUserSecretsBanner
|
||||
companyId={COMPANY_ID}
|
||||
secretsPath="/company/secrets"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</SeedFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
export const EnvPicker: Story = {
|
||||
render: () => {
|
||||
const value: Record<string, EnvBinding> = {
|
||||
GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true },
|
||||
OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", required: false },
|
||||
};
|
||||
return (
|
||||
<SeedFixtures>
|
||||
<Section title="Env binding picker — User secret source">
|
||||
<div className="max-w-2xl">
|
||||
<EnvVarEditor
|
||||
value={value}
|
||||
secrets={[]}
|
||||
userSecretDefinitions={definitions}
|
||||
onCreateSecret={async () => {
|
||||
throw new Error("noop");
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</SeedFixtures>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user