Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe('ConnectorSetupService — install', () => {

it('asks for the sign-in when an OAuth connector has its app keys', async () => {
const { service, ctx } = build();
const out: any = await service.install(ctx, { adapter: 'etsy', settings: { ETSY_CLIENT_ID: 'ks' } });
const out: any = await service.install(ctx, { adapter: 'etsy', settings: { ETSY_CLIENT_ID: 'a1b2c3d4e5f6g7h8i9j0k1l2' } });
expect(out.body.status).toBe('needs_input'); // the shared secret still has to be entered on the page
expect(out.body.whatTheUserDoes).toMatch(/enter Shared secret, then sign in to .+ and approve\./);
expect(out.body.finishSetupUrl).toBeDefined();
Expand All @@ -138,6 +138,14 @@ describe('ConnectorSetupService — install', () => {
expect(JSON.stringify(out.body)).toContain('an hour');
});

it('refuses a setting that does not match its pattern, with what to check', async () => {
const { service, ctx, adapters } = build();
const out: any = await service.install(ctx, { adapter: 'etsy', settings: { ETSY_CLIENT_ID: 'abc123:secret' } });
expect(out.isError).toBe(true);
expect(out.body.error).toMatch(/Keystring.*does not look right.*24 lowercase/);
expect(adapters.importAdapter).not.toHaveBeenCalled();
});

it('respects the trial limit', async () => {
const { service, ctx, licenseGuard, adapters } = build();
licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).'));
Expand Down
22 changes: 21 additions & 1 deletion packages/backend/src/adapters/connector-setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,17 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
},
};
}
if (value !== undefined && value !== null && String(value).trim() !== '') settings[name] = String(value).trim();
const text = value !== undefined && value !== null ? String(value).trim() : '';
if (text && d.pattern && !matchesPattern(d.pattern, text)) {
return {
isError: true,
body: {
error: `'${d.label}' does not look right. ${d.patternMessage ?? d.help ?? ''}`.trim(),
hint: 'Ask the user to check the value, or install without it: they can enter it on the page linked in the answer.',
},
};
}
if (text) settings[name] = text;
}

if (!this.takeInstallSlot(ctx.userId)) {
Expand Down Expand Up @@ -401,3 +411,13 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
});
}
}

/** A broken pattern in an adapter never blocks an install. */
function matchesPattern(pattern: string, value: string): boolean {
try {
return new RegExp(pattern).test(value);
} catch {
return true;
}
}

5 changes: 4 additions & 1 deletion packages/backend/src/adapters/env-var-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ export interface EnvVarMeta {
/** Where to find the value, in a sentence. */
help?: string;
example?: string;
/** Regular expression the value must match (validated in the form). */
/** Regular expression the value must match (validated in the form and in the chat). */
pattern?: string;
/** What to tell the user when the value does not match `pattern`. */
patternMessage?: string;
/** Page of the provider where the value is created or shown. */
link?: string;
/** Rarely needed: shown collapsed (e.g. a refresh token the authorization fills in). */
Expand All @@ -33,6 +35,7 @@ export interface EnvVarDescriptor extends Required<Pick<EnvVarMeta, 'label' | 'k
help?: string;
example?: string;
pattern?: string;
patternMessage?: string;
link?: string;
advanced?: boolean;
}
Expand Down
10 changes: 7 additions & 3 deletions packages/backend/src/adapters/intl/etsy.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
"label": "Keystring",
"kind": "credential",
"help": "Etsy developers → Your apps → your app: the Keystring.",
"link": "https://www.etsy.com/developers/your-apps"
"link": "https://www.etsy.com/developers/your-apps",
"pattern": "^[a-z0-9]{20,30}$",
"patternMessage": "The Keystring is 24 lowercase letters and digits, nothing else (no spaces, no colon)."
},
"ETSY_CLIENT_SECRET": {
"label": "Shared secret",
"help": "Same page, the Shared secret next to the Keystring.",
"link": "https://www.etsy.com/developers/your-apps"
"help": "Same page, the Shared secret next to the Keystring. Paste it on its own: not the Keystring, not \"keystring:secret\".",
"link": "https://www.etsy.com/developers/your-apps",
"pattern": "^[a-z0-9]{8,32}$",
"patternMessage": "Paste the Shared secret alone: lowercase letters and digits, without the Keystring and without a colon."
},
"ETSY_REFRESH_TOKEN": {
"label": "Refresh token",
Expand Down
14 changes: 12 additions & 2 deletions packages/frontend/src/app/connectors/setup/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,17 @@ function SetupContent() {
.then(async (c) => {
if (c?.setupStatus === 'ready') {
const test = await connectors.test(existingId, token).catch(() => null);
setResult({ connectorId: existingId, status: (test as any)?.message });
// The sign-in can succeed with wrong app keys (Etsy's token exchange
// does not check the shared secret), so only a call to the API tells.
// A refusal sends the user back to the keys, not to "is ready".
if (test && test.ok === false && test.kind === 'auth_failed') {
productEvents.track('setup_verify_failed', token, { adapterSlug: slug, kind: 'after_authorization' });
setVerifyFailed({ ok: false, kind: 'auth_failed', message: test.message } as VerifyResult);
setError('');
setPhase('form');
return;
}
setResult({ connectorId: existingId, status: test?.message });
setPhase('done');
productEvents.track('setup_completed', token, { adapterSlug: slug, kind: 'oauth_browser' });
} else {
Expand Down Expand Up @@ -143,7 +153,7 @@ function SetupContent() {
if (f.required && !f.advanced && !v && !storedSecrets.includes(f.name)) errs[f.name] = 'Required';
else if (v && f.pattern) {
try {
if (!new RegExp(f.pattern).test(v)) errs[f.name] = f.example ? `Looks wrong. Example: ${f.example}` : 'Looks wrong';
if (!new RegExp(f.pattern).test(v)) errs[f.name] = f.patternMessage ?? (f.example ? `Looks wrong. Example: ${f.example}` : 'Looks wrong');
} catch {
/* a broken pattern never blocks the form */
}
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ export interface EnvVarDescriptor {
help?: string;
example?: string;
pattern?: string;
/** Shown when the value does not match `pattern`. */
patternMessage?: string;
link?: string;
advanced?: boolean;
}
Expand Down
34 changes: 28 additions & 6 deletions packages/frontend/tests/e2e/connector-guided-setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ETSY = {
setupKind: 'oauth_browser',
envVars: [
{ name: 'ETSY_CLIENT_ID', required: true, label: 'Keystring', kind: 'credential', secret: false },
{ name: 'ETSY_CLIENT_SECRET', required: true, label: 'Shared secret', kind: 'credential', secret: true },
{ name: 'ETSY_CLIENT_SECRET', required: true, label: 'Shared secret', kind: 'credential', secret: true, pattern: '^[a-z0-9]{8,32}$', patternMessage: 'Paste the Shared secret alone: lowercase letters and digits, without the Keystring and without a colon.' },
{ name: 'ETSY_REFRESH_TOKEN', required: false, label: 'Refresh token', kind: 'credential', secret: true, advanced: true },
],
};
Expand All @@ -42,7 +42,7 @@ interface Calls {
authorize: any[];
}

async function setup(page: Page, opts: { verify?: any; connector?: any } = {}): Promise<Calls> {
async function setup(page: Page, opts: { verify?: any; connector?: any; test?: any } = {}): Promise<Calls> {
const calls: Calls = { verify: [], imports: [], envVars: [], authorize: [] };
await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }]);
await page.addInitScript((user) => {
Expand Down Expand Up @@ -73,7 +73,7 @@ async function setup(page: Page, opts: { verify?: any; connector?: any } = {}):
calls.envVars.push(req.postDataJSON());
return json({});
}
if (url.includes('/api/connectors/c9/test')) return json({ ok: true, message: 'Connected' });
if (url.includes('/api/connectors/c9/test')) return json(opts.test ?? { ok: true, message: 'Connected' });
if (url.includes('/api/connectors/c9')) return json(opts.connector ?? { id: 'c9', setupStatus: 'ready', envVars: {}, maskedEnvVars: [] });
if (url.includes('/api/product-events')) return json({ ok: true });
if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' });
Expand Down Expand Up @@ -128,11 +128,11 @@ test('OAuth: saves the app keys and goes straight to the sign-in, with the way b
await expect(page.getByLabel('Keystring')).toHaveAttribute('autocomplete', 'off');
await expect(page.getByLabel('Shared secret')).toHaveAttribute('autocomplete', 'new-password');
await expect(page.getByLabel('Shared secret')).toHaveAttribute('data-1p-ignore', 'true');
await page.getByLabel('Keystring').fill('ks');
await page.getByLabel('Shared secret').fill('ss');
await page.getByLabel('Keystring').fill('a1b2c3d4e5f6g7h8i9j0k1l2');
await page.getByLabel('Shared secret').fill('s3cr3t0abc');
await page.getByRole('button', { name: 'Save and sign in to Etsy' }).click();
await page.waitForURL('https://www.etsy.com/oauth/connect?state=s1');
expect(calls.imports[0].credentials).toMatchObject({ ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' });
expect(calls.imports[0].credentials).toMatchObject({ ETSY_CLIENT_ID: 'a1b2c3d4e5f6g7h8i9j0k1l2', ETSY_CLIENT_SECRET: 's3cr3t0abc' });
expect(calls.authorize).toEqual([{ returnTo: '/connectors/setup/etsy?connector=c9&step=done' }]);
});

Expand All @@ -143,6 +143,28 @@ test('OAuth: back from the provider, shows the connector ready', async ({ page }
await expect(page.getByRole('link', { name: 'Back to Claude' })).toBeVisible();
});

test('OAuth: a sign-in that worked with wrong app keys goes back to the keys, not to "ready"', async ({ page }) => {
// Etsy's token exchange does not check the shared secret; the first API
// call does. Seen in production: users told "is ready", then every call 403.
await setup(page, {
test: { ok: false, kind: 'auth_failed', httpStatus: 403, message: 'Invalid API key: should be in the format keystring:shared_secret.' },
});
await page.goto('/connectors/setup/etsy?connector=c9&step=done&from=claude');
await expect(page.getByText('Etsy Open API v3 did not accept these credentials.')).toBeVisible();
await expect(page.getByText('should be in the format keystring:shared_secret')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Etsy Open API v3 is ready' })).toHaveCount(0);
await expect(page.getByLabel('Shared secret')).toBeVisible();
});

test('a value that does not match the pattern says what to check', async ({ page }) => {
await setup(page);
await page.goto('/connectors/setup/etsy');
await page.getByLabel('Keystring').fill('a1b2c3d4e5f6g7h8i9j0k1l2');
await page.getByLabel('Shared secret').fill('a1b2c3d4e5f6g7h8i9j0k1l2:abc');
await page.getByRole('button', { name: 'Save and sign in to Etsy' }).click();
await expect(page.getByText('Paste the Shared secret alone')).toBeVisible();
});

test('finishing an existing connector keeps a stored secret left empty', async ({ page }) => {
const calls = await setup(page, {
connector: { id: 'c9', setupStatus: 'needs_input', envVars: { LEXWARE_API_KEY: '' }, maskedEnvVars: ['LEXWARE_API_KEY'] },
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate-adapters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export function validateAdapter(adapter, file, region) {
const meta = adapter.envVarMeta;
const declared = [...(adapter.requiredEnvVars || []), ...(Array.isArray(adapter.optionalEnvVars) ? adapter.optionalEnvVars : [])];
const KINDS = new Set(['address', 'credential', 'setting']);
const FIELDS = new Set(['label', 'kind', 'secret', 'help', 'example', 'pattern', 'link', 'advanced']);
const FIELDS = new Set(['label', 'kind', 'secret', 'help', 'example', 'pattern', 'patternMessage', 'link', 'advanced']);
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
errors.push(error('env-meta-shape', 'envVarMeta', 'envVarMeta must map a variable name to its description', 'Use { "MY_VAR": { "label": "…", "help": "…" } }.', 'adapter-fields'));
} else {
Expand Down
Loading