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 99d91d5..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; } @@ -2392,12 +2394,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 +2915,85 @@ function normalizeSecretRef(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +type HostSecretRef = EnvSecretRefBinding; + +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 isSamePluginSecretRef(left: PluginSecretRef | undefined, right: PluginSecretRef | undefined): boolean { + return Boolean(left && right && getPluginSecretRefKey(left) === getPluginSecretRefKey(right)); +} + +function getCompanyPluginSecretScope(companyId: string, configKey: 'githubTokenRefs' | 'paperclipBoardApiTokenRefs') { + return { + companyId, + configPath: `${configKey}.${companyId}` + }; +} + +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 { if (typeof value !== 'string') { return undefined; @@ -2923,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 { @@ -2940,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; @@ -5709,10 +5793,14 @@ async function clearExternalCompanyPaperclipBoardApiTokenFallback( async function shouldSeedExternalPaperclipBoardTokenFallback( ctx: PluginSetupContext, companyId: string, - secretRef: string + secretRef: PluginSecretRef ): Promise { try { - return !(await ctx.secrets.resolve(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, @@ -5731,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; @@ -15976,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) @@ -15990,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) { @@ -16012,7 +16100,7 @@ function getConfiguredGithubTokenRef( settings: Pick | null | undefined, config: GitHubSyncConfig, companyId?: string -): string | undefined { +): PluginSecretRef | undefined { return getConfiguredGithubTokenSource(settings, config, companyId).secretRef; } @@ -16039,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( @@ -16170,12 +16258,16 @@ async function resolvePaperclipApiAuthTokens( } try { - const token = (await ctx.secrets.resolve(secretRef)).trim(); + const token = (await resolvePluginSecret( + ctx, + secretRef, + getCompanyPluginSecretScope(companyId, 'paperclipBoardApiTokenRefs') + )).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 +16301,19 @@ async function resolveGithubToken( const configuredTokenSource = getConfiguredGithubTokenSource(settings, config, options.companyId); if (configuredTokenSource.secretRef) { try { - const token = (await ctx.secrets.resolve(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; } 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, @@ -24155,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 : {}; @@ -24179,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); @@ -24260,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) { @@ -24276,7 +24373,11 @@ const plugin = definePlugin({ } try { - const resolvedToken = (await ctx.secrets.resolve(githubTokenRef)).trim(); + const resolvedToken = (await resolvePluginSecret( + ctx, + githubTokenRef, + getCompanyPluginSecretScope(companyId, 'githubTokenRefs') + )).trim(); if (resolvedToken) { return { secretResolvable: true, @@ -24284,7 +24385,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..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,6 +15446,194 @@ test('resolveGithubToken trims secret-backed GitHub tokens before returning them ); }); +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; + }; + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + '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, 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'; + }; + + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_structured_secret_ref_token' + ); +}); + +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; + }; + + const harness = createTestHarness({ + manifest, + config: { + githubTokenRefs: { + 'company-1': TEST_GITHUB_SECRET_ID + } + } + }); + await plugin.definition.setup(harness.ctx); + + harness.ctx.secrets.resolve = async function (secretRef) { + assert.equal(arguments.length, 1); + assert.equal(secretRef, TEST_GITHUB_SECRET_ID); + return 'ghp_legacy_uuid_token'; + }; + + assert.equal( + await testing.resolveGithubToken?.(harness.ctx, { companyId: 'company-1' }), + 'ghp_legacy_uuid_token' + ); +}); + +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; + }; + 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) { + 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_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 () => { + 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 + } + }, + githubTokensByCompanyId: { + 'company-1': 'ghp_company_fallback_token' + } + } + }); + await plugin.definition.setup(harness.ctx); + + 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? }.'); + }; + + 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); @@ -27902,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';