From b1a747ca4089c2362c9970d80761ba6ba00ed8d6 Mon Sep 17 00:00:00 2001 From: Stein Gran Date: Fri, 24 Jul 2026 03:59:36 +0200 Subject: [PATCH 1/3] fix: resolve UUID secret refs with host contract --- src/worker.ts | 42 ++++++++++++++++++++------ tests/plugin.spec.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 99d91d5..00cb2de 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2392,12 +2392,15 @@ function getErrorMessage(error: unknown): string { return String(error); } -function isPluginSecretReferenceDisabledError(error: unknown): boolean { +function isPluginSecretReferenceUnavailableError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); return ( message.includes('plugin secret reference') && message.includes('disabled') - ) || message.includes('company-scoped plugin config lands'); + ) || message.includes('company-scoped plugin config lands') || ( + message.includes('invalid secret reference') + && message.includes('secret_ref') + ); } function getErrorCause(error: unknown): unknown { @@ -2910,6 +2913,27 @@ function normalizeSecretRef(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +const SECRET_REF_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type HostSecretRef = { + type: 'secret_ref'; + secretId: string; +}; + +function toHostSecretRef(secretRef: string): string | HostSecretRef { + return SECRET_REF_UUID_PATTERN.test(secretRef) + ? { type: 'secret_ref', secretId: secretRef } + : secretRef; +} + +async function resolvePluginSecret(ctx: PluginSetupContext, secretRef: string): Promise { + // The published SDK still types this as a string, while newer Paperclip hosts + // require UUID-backed plugin refs in the structured secret_ref form. Keep + // non-UUID legacy references unchanged so older hosts retain their contract. + const resolveSecret = ctx.secrets.resolve as unknown as (ref: string | HostSecretRef) => Promise; + return await resolveSecret(toHostSecretRef(secretRef)); +} + function normalizeGitHubLowercaseString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -5712,7 +5736,7 @@ async function shouldSeedExternalPaperclipBoardTokenFallback( secretRef: string ): Promise { try { - return !(await ctx.secrets.resolve(secretRef)).trim(); + return !(await resolvePluginSecret(ctx, secretRef)).trim(); } catch (error) { ctx.logger.warn('Unable to resolve the saved Paperclip board API token while checking worker fallback necessity.', { companyId, @@ -16170,12 +16194,12 @@ async function resolvePaperclipApiAuthTokens( } try { - const token = (await ctx.secrets.resolve(secretRef)).trim(); + const token = (await resolvePluginSecret(ctx, secretRef)).trim(); if (token) { tokensByCompanyId.set(companyId, token); } } catch (error) { - if (fallbackToken && isPluginSecretReferenceDisabledError(error)) { + if (fallbackToken && isPluginSecretReferenceUnavailableError(error)) { ctx.logger.warn('GitHub Sync is using a worker-local Paperclip board token fallback because plugin secret refs are unavailable in this host.', { companyId, secretRef, @@ -16209,14 +16233,14 @@ async function resolveGithubToken( const configuredTokenSource = getConfiguredGithubTokenSource(settings, config, options.companyId); if (configuredTokenSource.secretRef) { try { - const token = (await ctx.secrets.resolve(configuredTokenSource.secretRef)).trim(); + const token = (await resolvePluginSecret(ctx, configuredTokenSource.secretRef)).trim(); if (token) { return token; } return configuredTokenSource.fallbackToken ?? ''; } catch (error) { - if (configuredTokenSource.fallbackToken && isPluginSecretReferenceDisabledError(error)) { + if (configuredTokenSource.fallbackToken && isPluginSecretReferenceUnavailableError(error)) { ctx.logger.warn('GitHub Sync is using a worker-local company token fallback because plugin secret refs are unavailable in this host.', { companyId: normalizeCompanyId(options.companyId), secretRef: configuredTokenSource.secretRef, @@ -24276,7 +24300,7 @@ const plugin = definePlugin({ } try { - const resolvedToken = (await ctx.secrets.resolve(githubTokenRef)).trim(); + const resolvedToken = (await resolvePluginSecret(ctx, githubTokenRef)).trim(); if (resolvedToken) { return { secretResolvable: true, @@ -24284,7 +24308,7 @@ const plugin = definePlugin({ }; } } catch (error) { - if (!isPluginSecretReferenceDisabledError(error)) { + if (!isPluginSecretReferenceUnavailableError(error)) { throw error; } diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 15346f8..7920645 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -15409,6 +15409,77 @@ test('resolveGithubToken trims secret-backed GitHub tokens before returning them ); }); +test('resolveGithubToken passes UUID-backed refs to structured-secret-ref hosts', async () => { + const workerModule = await importFreshWorkerModule(); + const testing = workerModule.__testing as typeof workerModule.__testing & { + resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; + }; + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': TEST_GITHUB_SECRET_ID + } + } + }); + await plugin.definition.setup(harness.ctx); + + let resolvedSecretRef: unknown; + harness.ctx.secrets.resolve = async (secretRef) => { + resolvedSecretRef = secretRef; + return 'ghp_structured_secret_ref_token'; + }; + + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_structured_secret_ref_token' + ); + assert.deepEqual(resolvedSecretRef, { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + }); +}); + +test('resolveGithubToken uses only the configured company fallback for the documented invalid-secret-ref host error', async () => { + const workerModule = await importFreshWorkerModule(); + const testing = workerModule.__testing as typeof workerModule.__testing & { + resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; + }; + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': TEST_GITHUB_SECRET_ID + }, + githubTokensByCompanyId: { + 'company-1': 'ghp_company_fallback_token' + } + } + }); + await plugin.definition.setup(harness.ctx); + + harness.ctx.secrets.resolve = async (secretRef) => { + assert.deepEqual(secretRef, { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + }); + throw new Error('Invalid secret reference for plugin: secret UUID. Use { type: "secret_ref", secretId, version? }.'); + }; + + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_company_fallback_token' + ); + + harness.ctx.secrets.resolve = async () => { + throw new Error('Secret provider access denied'); + }; + await assert.rejects( + testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + /Secret provider access denied/ + ); +}); + test('settings.registration reports company-specific board access without resolving the saved secret', async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup(harness.ctx); From 4f8a497e1851a28b774d05144bac246f85173f19 Mon Sep 17 00:00:00 2001 From: Stein Gran Date: Tue, 28 Jul 2026 08:20:54 +0200 Subject: [PATCH 2/3] fix: address secret resolver review feedback --- src/worker.ts | 8 +++--- tests/plugin.spec.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 00cb2de..a3ff1b4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2913,7 +2913,7 @@ function normalizeSecretRef(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } -const SECRET_REF_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SECRET_REF_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type HostSecretRef = { type: 'secret_ref'; @@ -2930,8 +2930,10 @@ async function resolvePluginSecret(ctx: PluginSetupContext, secretRef: string): // The published SDK still types this as a string, while newer Paperclip hosts // require UUID-backed plugin refs in the structured secret_ref form. Keep // non-UUID legacy references unchanged so older hosts retain their contract. - const resolveSecret = ctx.secrets.resolve as unknown as (ref: string | HostSecretRef) => Promise; - return await resolveSecret(toHostSecretRef(secretRef)); + const secrets = ctx.secrets as unknown as { + resolve(ref: string | HostSecretRef): Promise; + }; + return await secrets.resolve(toHostSecretRef(secretRef)); } function normalizeGitHubLowercaseString(value: unknown): string | undefined { diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 7920645..8275d75 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -15440,6 +15440,70 @@ test('resolveGithubToken passes UUID-backed refs to structured-secret-ref hosts' }); }); +test('resolveGithubToken passes UUIDv6, UUIDv7, and UUIDv8 refs to structured-secret-ref hosts', async () => { + const workerModule = await importFreshWorkerModule(); + const testing = workerModule.__testing as typeof workerModule.__testing & { + resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; + }; + + for (const secretId of [ + '00000000-0000-6000-8000-000000000001', + '00000000-0000-7000-8000-000000000001', + '00000000-0000-8000-8000-000000000001' + ]) { + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': secretId + } + } + }); + await plugin.definition.setup(harness.ctx); + + let resolvedSecretRef: unknown; + harness.ctx.secrets.resolve = async (secretRef) => { + resolvedSecretRef = secretRef; + return 'ghp_structured_secret_ref_token'; + }; + + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }); + assert.deepEqual(resolvedSecretRef, { type: 'secret_ref', secretId }); + } +}); + +test('resolveGithubToken preserves the host secret resolver receiver', async () => { + const workerModule = await importFreshWorkerModule(); + const testing = workerModule.__testing as typeof workerModule.__testing & { + resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; + }; + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': TEST_GITHUB_SECRET_ID + } + } + }); + await plugin.definition.setup(harness.ctx); + + const secrets = harness.ctx.secrets as typeof harness.ctx.secrets & { receiver?: unknown }; + secrets.receiver = secrets; + secrets.resolve = async function (secretRef) { + assert.equal(this.receiver, this); + assert.deepEqual(secretRef, { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + }); + return 'ghp_bound_secret_resolver_token'; + }; + + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_bound_secret_resolver_token' + ); +}); + test('resolveGithubToken uses only the configured company fallback for the documented invalid-secret-ref host error', async () => { const workerModule = await importFreshWorkerModule(); const testing = workerModule.__testing as typeof workerModule.__testing & { From 3ef0dfde146e0f0ef830d29d1f479be1e15f3920 Mon Sep 17 00:00:00 2001 From: Stein Gran Date: Sat, 1 Aug 2026 01:03:45 +0200 Subject: [PATCH 3/3] fix: support scoped structured secret refs --- package-lock.json | 18 ++-- pnpm-lock.yaml | 16 ++-- src/manifest.ts | 28 ++++++- src/ui/index.tsx | 13 +-- src/ui/plugin-config.ts | 51 +++++++++-- src/worker.ts | 169 ++++++++++++++++++++++++++----------- tests/plugin.spec.ts | 182 ++++++++++++++++++++++++++++++---------- 7 files changed, 354 insertions(+), 123 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5766dd4..14282f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paperclip-github-plugin", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paperclip-github-plugin", - "version": "0.16.0", + "version": "0.16.1", "license": "Apache-2.0", "dependencies": { "@octokit/rest": "^22.0.1", @@ -627,12 +627,12 @@ } }, "node_modules/@paperclipai/plugin-sdk": { - "version": "2026.707.0", - "resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.707.0.tgz", - "integrity": "sha512-5pS4nG+l8k7GBvIHiuvPjWsiVX9KYG1WBD/c/DJGPBRRAuUj3Sd/PiaukAV6g/Ax94CnPS799hQLq40i09FKVg==", + "version": "2026.722.0", + "resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz", + "integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==", "license": "MIT", "dependencies": { - "@paperclipai/shared": "2026.707.0", + "@paperclipai/shared": "2026.722.0", "zod": "^3.24.2" }, "bin": { @@ -648,9 +648,9 @@ } }, "node_modules/@paperclipai/shared": { - "version": "2026.707.0", - "resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.707.0.tgz", - "integrity": "sha512-CI1D+jBVKdBlCqhFi+GfaK+ADZ1E1PLxAhStzmDkHORTmDnloZZMQ2JbnbPcJGdlIJTTMxAEFVrhgGzwh7jsbw==", + "version": "2026.722.0", + "resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz", + "integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==", "license": "MIT", "dependencies": { "zod": "^3.24.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eb9304..a2f77a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 22.0.1 '@paperclipai/plugin-sdk': specifier: ^2026.626.0 - version: 2026.707.0(react@19.2.7) + version: 2026.722.0(react@19.2.7) react: specifier: ^19.2.7 version: 19.2.7 @@ -259,8 +259,8 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@paperclipai/plugin-sdk@2026.707.0': - resolution: {integrity: sha512-5pS4nG+l8k7GBvIHiuvPjWsiVX9KYG1WBD/c/DJGPBRRAuUj3Sd/PiaukAV6g/Ax94CnPS799hQLq40i09FKVg==} + '@paperclipai/plugin-sdk@2026.722.0': + resolution: {integrity: sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==} hasBin: true peerDependencies: react: '>=18' @@ -268,8 +268,8 @@ packages: react: optional: true - '@paperclipai/shared@2026.707.0': - resolution: {integrity: sha512-CI1D+jBVKdBlCqhFi+GfaK+ADZ1E1PLxAhStzmDkHORTmDnloZZMQ2JbnbPcJGdlIJTTMxAEFVrhgGzwh7jsbw==} + '@paperclipai/shared@2026.722.0': + resolution: {integrity: sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==} '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -833,14 +833,14 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@paperclipai/plugin-sdk@2026.707.0(react@19.2.7)': + '@paperclipai/plugin-sdk@2026.722.0(react@19.2.7)': dependencies: - '@paperclipai/shared': 2026.707.0 + '@paperclipai/shared': 2026.722.0 zod: 3.25.76 optionalDependencies: react: 19.2.7 - '@paperclipai/shared@2026.707.0': + '@paperclipai/shared@2026.722.0': dependencies: zod: 3.25.76 diff --git a/src/manifest.ts b/src/manifest.ts index a7586f6..7c55113 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -58,14 +58,38 @@ export const manifest: PaperclipPluginManifestV1 = { type: 'object', title: 'GitHub Token Secrets', additionalProperties: { - type: 'string' + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + type: { const: 'secret_ref' }, + secretId: { type: 'string' }, + version: { oneOf: [{ const: 'latest' }, { type: 'integer', minimum: 1 }] } + }, + required: ['type', 'secretId'], + additionalProperties: false + } + ] } }, paperclipBoardApiTokenRefs: { type: 'object', title: 'Paperclip Board Token Secrets', additionalProperties: { - type: 'string' + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + type: { const: 'secret_ref' }, + secretId: { type: 'string' }, + version: { oneOf: [{ const: 'latest' }, { type: 'integer', minimum: 1 }] } + }, + required: ['type', 'secretId'], + additionalProperties: false + } + ] } }, paperclipApiBaseUrl: { diff --git a/src/ui/index.tsx b/src/ui/index.tsx index 300646d..d636376 100644 --- a/src/ui/index.tsx +++ b/src/ui/index.tsx @@ -18,6 +18,7 @@ import { normalizeCompanyAssigneeOptionsResponse, type GitHubSyncAssigneeOption import { buildPaperclipUrl, fetchJson, fetchPaperclipHealth, resolveCliAuthPollUrl } from './http.ts'; import { resolveInstalledGitHubSyncPluginId, resolvePluginSettingsHref } from './plugin-installation.ts'; import { + createPluginConfigSecretRef, mergePluginConfig, type GitHubSyncPluginConfig, normalizePaperclipApiBaseUrl, @@ -12021,16 +12022,17 @@ export function GitHubSyncSettingsPage(): React.JSX.Element { const secretName = `github_sync_${companyId.replace(/[^a-z0-9]+/gi, '_').toLowerCase()}`; const secret = await resolveOrCreateCompanySecret(companyId, secretName, trimmedToken); + const secretRef = createPluginConfigSecretRef(secret.id); await patchPluginConfig(pluginId, { githubTokenRefs: { - [companyId]: secret.id + [companyId]: secretRef } }); await saveRegistration({ companyId, githubTokenRefs: { - [companyId]: secret.id + [companyId]: secretRef }, githubTokenLogin: validation.login }); @@ -12039,7 +12041,7 @@ export function GitHubSyncSettingsPage(): React.JSX.Element { try { await ensureGitHubTokenAvailable({ companyId, - githubTokenRef: secret.id, + githubTokenRef: secretRef, token: trimmedToken }); } catch (error) { @@ -12128,15 +12130,16 @@ export function GitHubSyncSettingsPage(): React.JSX.Element { const boardIdentity = await fetchBoardAccessIdentity(boardApiToken); const secretName = `paperclip_board_api_${companyId.replace(/[^a-z0-9]+/gi, '_').toLowerCase()}`; const secret = await resolveOrCreateCompanySecret(companyId, secretName, boardApiToken); + const secretRef = createPluginConfigSecretRef(secret.id); await patchPluginConfig(pluginId, { paperclipBoardApiTokenRefs: { - [companyId]: secret.id + [companyId]: secretRef } }); await updateBoardAccess({ companyId, - paperclipBoardApiTokenRef: secret.id, + paperclipBoardApiTokenRef: secretRef, paperclipBoardAccess: { authorization: { bearer: boardApiToken diff --git a/src/ui/plugin-config.ts b/src/ui/plugin-config.ts index 6ca941f..5a31282 100644 --- a/src/ui/plugin-config.ts +++ b/src/ui/plugin-config.ts @@ -1,5 +1,12 @@ -export type PluginConfigBoardTokenRefs = Record; -export type PluginConfigGitHubTokenRefs = Record; +export interface PluginConfigSecretRef { + type: 'secret_ref'; + secretId: string; + version?: number | 'latest'; +} + +export type PluginConfigSecretReference = string | PluginConfigSecretRef; +export type PluginConfigBoardTokenRefs = Record; +export type PluginConfigGitHubTokenRefs = Record; export interface GitHubSyncPluginConfig extends Record { githubTokenRefs?: PluginConfigGitHubTokenRefs; @@ -11,6 +18,38 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +export function createPluginConfigSecretRef(secretId: string): PluginConfigSecretRef { + return { type: 'secret_ref', secretId }; +} + +export function normalizePluginConfigSecretRef(value: unknown): PluginConfigSecretReference | undefined { + const stringRef = normalizeOptionalString(value); + if (stringRef) { + return stringRef; + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const secretId = normalizeOptionalString(record.secretId); + if (record.type !== 'secret_ref' || !secretId) { + return undefined; + } + + const version = record.version === 'latest' + ? 'latest' as const + : typeof record.version === 'number' && Number.isSafeInteger(record.version) && record.version > 0 + ? record.version + : undefined; + return { + type: 'secret_ref', + secretId, + ...(version ? { version } : {}) + }; +} + export function normalizePaperclipApiBaseUrl(value: unknown): string | undefined { const normalizedValue = normalizeOptionalString(value); if (!normalizedValue) { @@ -32,12 +71,12 @@ export function normalizePluginConfigBoardTokenRefs(value: unknown): PluginConfi const entries = Object.entries(value as Record) .map(([companyId, secretRef]) => { const normalizedCompanyId = normalizeOptionalString(companyId); - const normalizedSecretRef = normalizeOptionalString(secretRef); + const normalizedSecretRef = normalizePluginConfigSecretRef(secretRef); return normalizedCompanyId && normalizedSecretRef ? [normalizedCompanyId, normalizedSecretRef] as const : null; }) - .filter((entry): entry is readonly [string, string] => Boolean(entry)); + .filter((entry): entry is readonly [string, PluginConfigSecretReference] => Boolean(entry)); if (entries.length === 0) { return undefined; @@ -54,12 +93,12 @@ export function normalizePluginConfigGitHubTokenRefs(value: unknown): PluginConf const entries = Object.entries(value as Record) .map(([companyId, secretRef]) => { const normalizedCompanyId = normalizeOptionalString(companyId); - const normalizedSecretRef = normalizeOptionalString(secretRef); + const normalizedSecretRef = normalizePluginConfigSecretRef(secretRef); return normalizedCompanyId && normalizedSecretRef ? [normalizedCompanyId, normalizedSecretRef] as const : null; }) - .filter((entry): entry is readonly [string, string] => Boolean(entry)); + .filter((entry): entry is readonly [string, PluginConfigSecretReference] => Boolean(entry)); if (entries.length === 0) { return undefined; diff --git a/src/worker.ts b/src/worker.ts index a3ff1b4..ae80017 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -10,6 +10,7 @@ import { definePlugin, startWorkerRpcHost, type Agent, + type EnvSecretRefBinding, type Issue, type IssueComment, type PluginApiRequestInput, @@ -186,9 +187,10 @@ type PaperclipIssueUpdatePatchWithLabels = Parameters; -type GitHubTokenRefs = Record; +type PluginSecretRef = string | HostSecretRef; +type GitHubTokenRefs = Record; type GitHubTokensByCompanyId = Record; -type PaperclipBoardApiTokenRefs = Record; +type PaperclipBoardApiTokenRefs = Record; type PaperclipBoardApiTokensByCompanyId = Record; type GitHubTokenLoginByCompanyId = Record; type PaperclipBoardAccessIdentityByCompanyId = Record; @@ -587,7 +589,7 @@ interface GitHubSyncSettings { paperclipApiBaseUrlByCompanyId?: PaperclipApiBaseUrlByCompanyId; githubTokenRefs?: GitHubTokenRefs; githubTokenLoginByCompanyId?: GitHubTokenLoginByCompanyId; - githubTokenRef?: string; + githubTokenRef?: PluginSecretRef; githubTokenLogin?: string; paperclipBoardApiTokenRefs?: PaperclipBoardApiTokenRefs; paperclipBoardAccessIdentityByCompanyId?: PaperclipBoardAccessIdentityByCompanyId; @@ -599,7 +601,7 @@ interface GitHubSyncSettings { interface GitHubSyncConfig { githubTokenRefs?: GitHubTokenRefs; - githubTokenRef?: string; + githubTokenRef?: PluginSecretRef; githubToken?: string; githubTokensByCompanyId?: GitHubTokensByCompanyId; paperclipBoardApiTokenRefs?: PaperclipBoardApiTokenRefs; @@ -646,7 +648,7 @@ type ScheduleFrequencyMinutesByCompanyId = Record; type PaperclipApiBaseUrlByCompanyId = Record; interface ResolvedGitHubTokenSource { - secretRef?: string; + secretRef?: PluginSecretRef; token?: string; fallbackToken?: string; } @@ -2913,27 +2915,83 @@ function normalizeSecretRef(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } -const SECRET_REF_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +type HostSecretRef = EnvSecretRefBinding; -type HostSecretRef = { - type: 'secret_ref'; - secretId: string; -}; +function normalizePluginSecretRef(value: unknown): PluginSecretRef | undefined { + const stringRef = normalizeSecretRef(value); + if (stringRef) { + return stringRef; + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const secretId = normalizeSecretRef(record.secretId); + if (record.type !== 'secret_ref' || !secretId) { + return undefined; + } + + const version = record.version === 'latest' + ? 'latest' as const + : typeof record.version === 'number' && Number.isSafeInteger(record.version) && record.version > 0 + ? record.version + : undefined; + return { + type: 'secret_ref', + secretId, + ...(version ? { version } : {}) + }; +} + +function isLegacySecretResolverObjectRejection(error: unknown): boolean { + const message = getErrorMessage(error).toLowerCase(); + return message.includes('invalid secret reference') && message.includes('[object object]'); +} + +function getPluginSecretRefKey(secretRef: PluginSecretRef): string { + return typeof secretRef === 'string' + ? `string:${secretRef}` + : `secret_ref:${secretRef.secretId}:${secretRef.version ?? ''}`; +} -function toHostSecretRef(secretRef: string): string | HostSecretRef { - return SECRET_REF_UUID_PATTERN.test(secretRef) - ? { type: 'secret_ref', secretId: secretRef } - : secretRef; +function isSamePluginSecretRef(left: PluginSecretRef | undefined, right: PluginSecretRef | undefined): boolean { + return Boolean(left && right && getPluginSecretRefKey(left) === getPluginSecretRefKey(right)); } -async function resolvePluginSecret(ctx: PluginSetupContext, secretRef: string): Promise { - // The published SDK still types this as a string, while newer Paperclip hosts - // require UUID-backed plugin refs in the structured secret_ref form. Keep - // non-UUID legacy references unchanged so older hosts retain their contract. - const secrets = ctx.secrets as unknown as { - resolve(ref: string | HostSecretRef): Promise; +function getCompanyPluginSecretScope(companyId: string, configKey: 'githubTokenRefs' | 'paperclipBoardApiTokenRefs') { + return { + companyId, + configPath: `${configKey}.${companyId}` }; - return await secrets.resolve(toHostSecretRef(secretRef)); +} + +async function resolvePluginSecret( + ctx: PluginSetupContext, + secretRef: PluginSecretRef, + scope?: { companyId?: string; configPath?: string } +): Promise { + const secrets = ctx.secrets; + + // Raw refs intentionally retain the 2026.626.0 bridge contract. Structured + // refs are trusted plugin-config bindings and must carry their host scope. + if (typeof secretRef === 'string') { + return await secrets.resolve.call(secrets, secretRef); + } + + try { + return await secrets.resolve.call(secrets, secretRef, scope); + } catch (error) { + // Paperclip 2026.626.0 rejects structured refs before resolution. Retry + // only that exact bridge-shape rejection with its UUID string contract; + // permissions, provider, and all unrelated failures remain fail-closed. + if (!isLegacySecretResolverObjectRejection(error)) { + throw error; + } + + return await secrets.resolve.call(secrets, secretRef.secretId); + } } function normalizeGitHubLowercaseString(value: unknown): string | undefined { @@ -2949,8 +3007,8 @@ function normalizeGitHubUserLogin(value: unknown): string | undefined { return normalizeGitHubLowercaseString(value); } -function normalizeGitHubTokenRef(value: unknown): string | undefined { - return normalizeSecretRef(value); +function normalizeGitHubTokenRef(value: unknown): PluginSecretRef | undefined { + return normalizePluginSecretRef(value); } function normalizeGitHubTokenRefs(value: unknown): GitHubTokenRefs | undefined { @@ -2966,7 +3024,7 @@ function normalizeGitHubTokenRefs(value: unknown): GitHubTokenRefs | undefined { ? [normalizedCompanyId, normalizedSecretRef] as const : null; }) - .filter((entry): entry is readonly [string, string] => entry !== null); + .filter((entry): entry is readonly [string, PluginSecretRef] => entry !== null); if (entries.length === 0) { return undefined; @@ -5735,10 +5793,14 @@ async function clearExternalCompanyPaperclipBoardApiTokenFallback( async function shouldSeedExternalPaperclipBoardTokenFallback( ctx: PluginSetupContext, companyId: string, - secretRef: string + secretRef: PluginSecretRef ): Promise { try { - return !(await resolvePluginSecret(ctx, secretRef)).trim(); + return !(await resolvePluginSecret( + ctx, + secretRef, + getCompanyPluginSecretScope(companyId, 'paperclipBoardApiTokenRefs') + )).trim(); } catch (error) { ctx.logger.warn('Unable to resolve the saved Paperclip board API token while checking worker fallback necessity.', { companyId, @@ -5757,12 +5819,12 @@ function normalizePaperclipBoardApiTokenRefs(value: unknown): PaperclipBoardApiT const entries = Object.entries(value as Record) .map(([companyId, secretRef]) => { const normalizedCompanyId = normalizeCompanyId(companyId); - const normalizedSecretRef = normalizeSecretRef(secretRef); + const normalizedSecretRef = normalizePluginSecretRef(secretRef); return normalizedCompanyId && normalizedSecretRef ? [normalizedCompanyId, normalizedSecretRef] as const : null; }) - .filter((entry): entry is readonly [string, string] => entry !== null); + .filter((entry): entry is readonly [string, PluginSecretRef] => entry !== null); if (entries.length === 0) { return undefined; @@ -16002,8 +16064,8 @@ function getConfiguredGithubTokenSource( hasAnyScopedValue(settings?.githubTokenRefs) || hasAnyScopedValue(config.githubTokenRefs); const secretRef = normalizedCompanyId - ? normalizeSecretRef(config.githubTokenRefs?.[normalizedCompanyId]) - ?? normalizeSecretRef(settings?.githubTokenRefs?.[normalizedCompanyId]) + ? normalizePluginSecretRef(config.githubTokenRefs?.[normalizedCompanyId]) + ?? normalizePluginSecretRef(settings?.githubTokenRefs?.[normalizedCompanyId]) ?? (!hasScopedGitHubTokenRefs ? normalizeGitHubTokenRef(config.githubTokenRef) ?? normalizeGitHubTokenRef(settings?.githubTokenRef) @@ -16016,8 +16078,8 @@ function getConfiguredGithubTokenSource( ...Object.values(settings?.githubTokenRefs ?? {}) ] .map((value) => normalizeGitHubTokenRef(value)) - .filter((value): value is string => Boolean(value)); - const uniqueRefs = [...new Set(configuredRefs)]; + .filter((value): value is PluginSecretRef => Boolean(value)); + const uniqueRefs = [...new Map(configuredRefs.map((value) => [getPluginSecretRefKey(value), value])).values()]; return uniqueRefs.length === 1 ? uniqueRefs[0] : undefined; })(); if (secretRef) { @@ -16038,7 +16100,7 @@ function getConfiguredGithubTokenRef( settings: Pick | null | undefined, config: GitHubSyncConfig, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { return getConfiguredGithubTokenSource(settings, config, companyId).secretRef; } @@ -16065,45 +16127,45 @@ function hasConfiguredGithubToken( function getSavedGitHubTokenRef( settings: Pick | null | undefined, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { if (!companyId) { return undefined; } - return normalizeSecretRef(settings?.githubTokenRefs?.[companyId]); + return normalizePluginSecretRef(settings?.githubTokenRefs?.[companyId]); } function getConfiguredGitHubTokenRef( config: Pick | null | undefined, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { if (!companyId) { return undefined; } - return normalizeSecretRef(config?.githubTokenRefs?.[companyId]); + return normalizePluginSecretRef(config?.githubTokenRefs?.[companyId]); } function getSavedPaperclipBoardApiTokenRef( settings: Pick | null | undefined, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { if (!companyId) { return undefined; } - return normalizeSecretRef(settings?.paperclipBoardApiTokenRefs?.[companyId]); + return normalizePluginSecretRef(settings?.paperclipBoardApiTokenRefs?.[companyId]); } function getConfiguredPaperclipBoardApiTokenRef( config: Pick | null | undefined, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { if (!companyId) { return undefined; } - return normalizeSecretRef(config?.paperclipBoardApiTokenRefs?.[companyId]); + return normalizePluginSecretRef(config?.paperclipBoardApiTokenRefs?.[companyId]); } function hasConfiguredPaperclipBoardAccess( @@ -16196,7 +16258,11 @@ async function resolvePaperclipApiAuthTokens( } try { - const token = (await resolvePluginSecret(ctx, secretRef)).trim(); + const token = (await resolvePluginSecret( + ctx, + secretRef, + getCompanyPluginSecretScope(companyId, 'paperclipBoardApiTokenRefs') + )).trim(); if (token) { tokensByCompanyId.set(companyId, token); } @@ -16235,7 +16301,12 @@ async function resolveGithubToken( const configuredTokenSource = getConfiguredGithubTokenSource(settings, config, options.companyId); if (configuredTokenSource.secretRef) { try { - const token = (await resolvePluginSecret(ctx, configuredTokenSource.secretRef)).trim(); + const companyId = normalizeCompanyId(options.companyId); + const token = (await resolvePluginSecret( + ctx, + configuredTokenSource.secretRef, + companyId ? getCompanyPluginSecretScope(companyId, 'githubTokenRefs') : undefined + )).trim(); if (token) { return token; } @@ -24181,7 +24252,7 @@ const plugin = definePlugin({ throw new Error('A company id is required to update Paperclip board access.'); } - const nextSecretRef = normalizeSecretRef(record.paperclipBoardApiTokenRef); + const nextSecretRef = normalizePluginSecretRef(record.paperclipBoardApiTokenRef); const boardAccessRecord = record.paperclipBoardAccess && typeof record.paperclipBoardAccess === 'object' ? record.paperclipBoardAccess as Record : {}; @@ -24205,7 +24276,7 @@ const plugin = definePlugin({ if (nextBoardApiToken) { const configuredSecretRef = getConfiguredPaperclipBoardApiTokenRef(trustedConfig, companyId); if ( - configuredSecretRef !== nextSecretRef + !isSamePluginSecretRef(configuredSecretRef, nextSecretRef) || await shouldSeedExternalPaperclipBoardTokenFallback(ctx, companyId, nextSecretRef) ) { await writeExternalCompanyPaperclipBoardApiTokenFallback(ctx, companyId, nextBoardApiToken); @@ -24286,7 +24357,7 @@ const plugin = definePlugin({ ctx.actions.register('settings.ensureGitHubTokenAvailable', async (input, actionContext) => { const record = normalizeActionRecord(input, actionContext); const companyId = normalizeCompanyId(record.companyId); - const githubTokenRef = normalizeSecretRef(record.githubTokenRef); + const githubTokenRef = normalizePluginSecretRef(record.githubTokenRef); const token = normalizeGitHubToken(record.token); if (!companyId) { @@ -24302,7 +24373,11 @@ const plugin = definePlugin({ } try { - const resolvedToken = (await resolvePluginSecret(ctx, githubTokenRef)).trim(); + const resolvedToken = (await resolvePluginSecret( + ctx, + githubTokenRef, + getCompanyPluginSecretScope(companyId, 'githubTokenRefs') + )).trim(); if (resolvedToken) { return { secretResolvable: true, diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 8275d75..f3a03d4 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -6033,6 +6033,43 @@ test('mergePluginConfig preserves existing config while merging token and board assert.equal(result.customFlag, true); }); +test('normalizePluginConfig preserves company-scoped structured secret bindings', () => { + assert.deepEqual( + normalizePluginConfig({ + githubTokenRefs: { + 'company-1': { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID, + version: 'latest' + } + }, + paperclipBoardApiTokenRefs: { + 'company-1': { + type: 'secret_ref', + secretId: '00000000-0000-4000-8000-000000000002', + version: 2 + } + } + }), + { + githubTokenRefs: { + 'company-1': { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID, + version: 'latest' + } + }, + paperclipBoardApiTokenRefs: { + 'company-1': { + type: 'secret_ref', + secretId: '00000000-0000-4000-8000-000000000002', + version: 2 + } + } + } + ); +}); + test('patchPluginConfig retries without plugin secret refs when the host rejects secret refs', async () => { const uiModule = await importFreshUiModule() as { patchPluginConfig?: unknown; @@ -15409,7 +15446,7 @@ test('resolveGithubToken trims secret-backed GitHub tokens before returning them ); }); -test('resolveGithubToken passes UUID-backed refs to structured-secret-ref hosts', async () => { +test('resolveGithubToken forwards structured company bindings and scope to the new host bridge', async () => { const workerModule = await importFreshWorkerModule(); const testing = workerModule.__testing as typeof workerModule.__testing & { resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; @@ -15418,15 +15455,28 @@ test('resolveGithubToken passes UUID-backed refs to structured-secret-ref hosts' manifest, config: { githubTokenRefs: { - 'company-1': TEST_GITHUB_SECRET_ID + 'company-1': { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + } } } }); await plugin.definition.setup(harness.ctx); - let resolvedSecretRef: unknown; - harness.ctx.secrets.resolve = async (secretRef) => { - resolvedSecretRef = secretRef; + const secrets = harness.ctx.secrets as typeof harness.ctx.secrets & { receiver?: unknown }; + secrets.receiver = secrets; + secrets.resolve = async function (secretRef, options) { + assert.equal(this.receiver, this); + assert.equal(arguments.length, 2); + assert.deepEqual(secretRef, { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + }); + assert.deepEqual(options, { + companyId: 'company-1', + configPath: 'githubTokenRefs.company-1' + }); return 'ghp_structured_secret_ref_token'; }; @@ -15434,45 +15484,37 @@ test('resolveGithubToken passes UUID-backed refs to structured-secret-ref hosts' await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), 'ghp_structured_secret_ref_token' ); - assert.deepEqual(resolvedSecretRef, { - type: 'secret_ref', - secretId: TEST_GITHUB_SECRET_ID - }); }); -test('resolveGithubToken passes UUIDv6, UUIDv7, and UUIDv8 refs to structured-secret-ref hosts', async () => { +test('resolveGithubToken retains UUID-string resolution on the 2026.626.0 host bridge', async () => { const workerModule = await importFreshWorkerModule(); const testing = workerModule.__testing as typeof workerModule.__testing & { resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; }; - for (const secretId of [ - '00000000-0000-6000-8000-000000000001', - '00000000-0000-7000-8000-000000000001', - '00000000-0000-8000-8000-000000000001' - ]) { - const harness = createTestHarness({ - manifest, - config: { - githubTokenRefs: { - 'company-1': secretId - } + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': TEST_GITHUB_SECRET_ID } - }); - await plugin.definition.setup(harness.ctx); + } + }); + await plugin.definition.setup(harness.ctx); - let resolvedSecretRef: unknown; - harness.ctx.secrets.resolve = async (secretRef) => { - resolvedSecretRef = secretRef; - return 'ghp_structured_secret_ref_token'; - }; + harness.ctx.secrets.resolve = async function (secretRef) { + assert.equal(arguments.length, 1); + assert.equal(secretRef, TEST_GITHUB_SECRET_ID); + return 'ghp_legacy_uuid_token'; + }; - await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }); - assert.deepEqual(resolvedSecretRef, { type: 'secret_ref', secretId }); - } + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_legacy_uuid_token' + ); }); -test('resolveGithubToken preserves the host secret resolver receiver', async () => { +test('resolveGithubToken retries only the baseline structured-ref bridge rejection', async () => { const workerModule = await importFreshWorkerModule(); const testing = workerModule.__testing as typeof workerModule.__testing & { resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; @@ -15481,27 +15523,68 @@ test('resolveGithubToken preserves the host secret resolver receiver', async () manifest, config: { githubTokenRefs: { - 'company-1': TEST_GITHUB_SECRET_ID + 'company-1': { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + } } } }); await plugin.definition.setup(harness.ctx); - const secrets = harness.ctx.secrets as typeof harness.ctx.secrets & { receiver?: unknown }; - secrets.receiver = secrets; - secrets.resolve = async function (secretRef) { - assert.equal(this.receiver, this); - assert.deepEqual(secretRef, { - type: 'secret_ref', - secretId: TEST_GITHUB_SECRET_ID - }); - return 'ghp_bound_secret_resolver_token'; + let callCount = 0; + harness.ctx.secrets.resolve = async function (secretRef) { + callCount += 1; + if (callCount === 1) { + assert.equal(arguments.length, 2); + assert.deepEqual(secretRef, { type: 'secret_ref', secretId: TEST_GITHUB_SECRET_ID }); + throw new Error('Invalid secret reference: [object Object]'); + } + + assert.equal(arguments.length, 1); + assert.equal(secretRef, TEST_GITHUB_SECRET_ID); + return 'ghp_legacy_bridge_token'; }; assert.equal( await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), - 'ghp_bound_secret_resolver_token' + 'ghp_legacy_bridge_token' ); + assert.equal(callCount, 2); +}); + +test('resolveGithubToken fails closed for unrelated structured-ref host errors', async () => { + const workerModule = await importFreshWorkerModule(); + const testing = workerModule.__testing as typeof workerModule.__testing & { + resolveGithubToken?: (ctx: unknown, options?: { companyId?: string }) => Promise; + }; + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': { type: 'secret_ref', secretId: TEST_GITHUB_SECRET_ID } + } + } + }); + await plugin.definition.setup(harness.ctx); + + let callCount = 0; + harness.ctx.secrets.resolve = async function (secretRef, options) { + callCount += 1; + assert.equal(arguments.length, 2); + assert.deepEqual(secretRef, { type: 'secret_ref', secretId: TEST_GITHUB_SECRET_ID }); + assert.deepEqual(options, { + companyId: 'company-1', + configPath: 'githubTokenRefs.company-1' + }); + throw new Error('Secret provider access denied'); + }; + + await assert.rejects( + testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + /Secret provider access denied/ + ); + assert.equal(callCount, 1); }); test('resolveGithubToken uses only the configured company fallback for the documented invalid-secret-ref host error', async () => { @@ -15513,7 +15596,10 @@ test('resolveGithubToken uses only the configured company fallback for the docum manifest, config: { githubTokenRefs: { - 'company-1': TEST_GITHUB_SECRET_ID + 'company-1': { + type: 'secret_ref', + secretId: TEST_GITHUB_SECRET_ID + } }, githubTokensByCompanyId: { 'company-1': 'ghp_company_fallback_token' @@ -15522,11 +15608,15 @@ test('resolveGithubToken uses only the configured company fallback for the docum }); await plugin.definition.setup(harness.ctx); - harness.ctx.secrets.resolve = async (secretRef) => { + harness.ctx.secrets.resolve = async (secretRef, options) => { assert.deepEqual(secretRef, { type: 'secret_ref', secretId: TEST_GITHUB_SECRET_ID }); + assert.deepEqual(options, { + companyId: 'company-1', + configPath: 'githubTokenRefs.company-1' + }); throw new Error('Invalid secret reference for plugin: secret UUID. Use { type: "secret_ref", secretId, version? }.'); }; @@ -28037,7 +28127,7 @@ test('sync.runNow falls back to the saved githubTokenRef when config has not pro const harness = createTestHarness({ manifest }); await plugin.definition.setup(harness.ctx); - let resolvedSecretRef: string | null = null; + let resolvedSecretRef: unknown = null; harness.ctx.secrets.resolve = async (secretRef) => { resolvedSecretRef = secretRef; return 'github-token';