From b1a747ca4089c2362c9970d80761ba6ba00ed8d6 Mon Sep 17 00:00:00 2001 From: Stein Gran Date: Fri, 24 Jul 2026 03:59:36 +0200 Subject: [PATCH] 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);