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:
Dotta committed 2026-07-02 03:54:31 +00:00
commit 377ebefd03
98 files changed
+7199 -481

No files matched your search

+3
View File
@@ -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",
+14
View File
@@ -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:
+41 -4
View File
@@ -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.
+24
View File
@@ -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
View File
@@ -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
}
]
}
+34 -3
View File
@@ -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
)`,
),
}),
);
+6
View File
@@ -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,
+2
View File
@@ -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";
+2
View File
@@ -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),
+14
View File
@@ -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),
+17 -1
View File
@@ -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,
+2
View File
@@ -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,
+5
View File
@@ -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`,
+12 -1
View File
@@ -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;
+13
View File
@@ -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,
+1
View File
@@ -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;
+5
View File
@@ -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,
+1
View File
@@ -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;
+2
View File
@@ -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 {
+65 -2
View File
@@ -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;
+12
View File
@@ -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({
+122 -5
View File
@@ -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

+5 -2
View File
@@ -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({
+105
View File
@@ -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);
+10
View File
@@ -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;
+3
View File
@@ -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,
});
+122
View File
@@ -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({
+326
View File
@@ -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);
+115 -11
View File
@@ -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,
+62
View File
@@ -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({
+1
View File
@@ -1177,6 +1177,7 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect):
originId: routine.originId,
variables: routine.variables ?? [],
env: routine.env ?? null,
responsibleUserId: routine.responsibleUserId ?? null,
};
}
+26
View File
@@ -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,
File diff suppressed because it is too large. Load diff
+76
View File
@@ -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",
+11
View File
@@ -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;
+128
View File
@@ -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");
});
});
+186 -57
View File
@@ -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&apos;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,
+1
View File
@@ -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,
+1
View File
@@ -176,6 +176,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
+1
View File
@@ -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
View File
@@ -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">
+9
View File
@@ -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",
+2
View File
@@ -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,
+1
View File
@@ -24,6 +24,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
+1
View File
@@ -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,
+1
View File
@@ -46,6 +46,7 @@ describe("issueDetailBreadcrumb", () => {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
+1
View File
@@ -31,6 +31,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
+1
View File
@@ -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,
+4
View File
@@ -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) =>
+1
View File
@@ -48,6 +48,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
+1
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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,
+316 -29
View File
@@ -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({
+90 -11
View File
@@ -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
View File
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>
);
}
+181
View File
@@ -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>
);
}
+20
View File
@@ -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`;
}
+31
View File
@@ -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,
+78 -1
View File
@@ -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>
);
},
};