Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<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<string>;
return await resolveSecret(toHostSecretRef(secretRef));
Comment on lines +2933 to +2934
}

function normalizeGitHubLowercaseString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
Expand Down Expand Up @@ -5712,7 +5736,7 @@ async function shouldSeedExternalPaperclipBoardTokenFallback(
secretRef: string
): Promise<boolean> {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -24276,15 +24300,15 @@ const plugin = definePlugin({
}

try {
const resolvedToken = (await ctx.secrets.resolve(githubTokenRef)).trim();
const resolvedToken = (await resolvePluginSecret(ctx, githubTokenRef)).trim();
if (resolvedToken) {
return {
secretResolvable: true,
fallbackStored: false
};
}
} catch (error) {
if (!isPluginSecretReferenceDisabledError(error)) {
if (!isPluginSecretReferenceUnavailableError(error)) {
throw error;
}

Expand Down
71 changes: 71 additions & 0 deletions tests/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
};
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<string>;
};
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);
Expand Down