diff --git a/packages/cli/src/bridge-errors.ts b/packages/cli/src/bridge-errors.ts index d749750..dfd311a 100644 --- a/packages/cli/src/bridge-errors.ts +++ b/packages/cli/src/bridge-errors.ts @@ -18,6 +18,16 @@ export function mapBridgeError(err: unknown, io: Io): number { } const kind = classifyBridgeError(err); const msg = err instanceof Error ? err.message : String(err); + // The server speaks to its library callers ("pass { domain: '' }"). + // A CLI user has no object to pass — name the flag instead. Verbs that can + // prove the gap up front (read, dom) refuse before connecting; this catches + // the rest, e.g. `fpx session` on a multi-domain profile. + if (msg.includes('declared multiple domains')) { + io.err( + `${msg.replace(/ — pass \{ domain.*$/, '')} — pick one with --storage-domain `, + ); + return EXIT.USAGE; + } const hints: Record = { bridge_down: 'is Chrome running with the Transporter extension installed?', timeout: 'is a tab open on the declared domain and signed in?', diff --git a/packages/cli/src/profiles.ts b/packages/cli/src/profiles.ts index 540072b..8758a37 100644 --- a/packages/cli/src/profiles.ts +++ b/packages/cli/src/profiles.ts @@ -53,6 +53,35 @@ export function emptyProfile(domains: string[]): Profile { const isStringArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0); +const isRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +const hasStrings = (e: Record, keys: readonly string[]): boolean => + keys.every((k) => typeof e[k] === 'string' && (e[k] as string).length > 0); + +const optionalString = (v: unknown): boolean => + v === undefined || (typeof v === 'string' && v.length > 0); + +/** + * Per-element shape checks for the object arrays. These arrays are hand-edited + * into profiles.json (only --capture-header and --dom-selector have flags), so + * `Array.isArray` alone let a typo through here and surface much later as a + * ProtocolError from the extension's hello validator, mid-pair. + * + * Deliberately structural: required fields present and string-typed. The + * protocol's format rules (origin scheme, key/selector character sets, JSON + * pointer syntax) stay in @fetchproxy/protocol's validator — duplicating them + * here would give two definitions of valid, and they would drift. + */ +const ELEMENT_SHAPE: Record) => boolean> = { + captureHeaders: (e) => hasStrings(e, ['host', 'headerName']) && optionalString(e.path), + indexedDb: (e) => + hasStrings(e, ['origin', 'database', 'store']) && isStringArray(e.keys) && e.keys.length > 0, + localStoragePointers: (e) => hasStrings(e, ['outputKey', 'storageKey', 'jsonPointer']), + sessionStoragePointers: (e) => hasStrings(e, ['outputKey', 'storageKey', 'jsonPointer']), + domSelectors: (e) => hasStrings(e, ['name', 'selector']) && optionalString(e.attribute), +}; + function validateProfile(name: string, raw: unknown): Profile { const fail = (field: string): never => { throw new UsageError( @@ -69,7 +98,12 @@ function validateProfile(name: string, raw: unknown): Profile { for (const k of [ 'captureHeaders', 'indexedDb', 'localStoragePointers', 'sessionStoragePointers', 'domSelectors', ] as const) { - if (p[k] !== undefined && !Array.isArray(p[k])) fail(k); + if (p[k] === undefined) continue; + if (!Array.isArray(p[k])) fail(k); + const shapeOk = ELEMENT_SHAPE[k]!; + (p[k] as unknown[]).forEach((entry, i) => { + if (!isRecord(entry) || !shapeOk(entry)) fail(`${k}[${i}]`); + }); } if (p.download !== undefined && typeof p.download !== 'boolean') fail('download'); return { ...emptyProfile(p.domains as string[]), ...(p as Partial) } as Profile; diff --git a/packages/cli/src/storage-scope.ts b/packages/cli/src/storage-scope.ts new file mode 100644 index 0000000..cc3251c --- /dev/null +++ b/packages/cli/src/storage-scope.ts @@ -0,0 +1,20 @@ +import type { Profile } from './profiles.js'; +import { UsageError } from './output.js'; + +/** + * Storage reads (cookies / localStorage / sessionStorage / indexedDb / DOM) + * target exactly ONE declared domain. With several declared and none picked, + * the server refuses — but its message names the library option + * (`pass { domain: '' }`), which is not something a CLI user can + * pass. Refuse here instead, before connecting, naming the flag that fixes it. + * + * `fpx pair` does the same check against `--domain`; only the flag differs. + */ +export function requireStorageDomain(profile: Profile, storageDomain: string | undefined): void { + if (storageDomain !== undefined || profile.domains.length <= 1) return; + throw new UsageError( + `profile declares multiple domains (${profile.domains.join(', ')}) — ` + + 'storage reads target one of them: pick it with --storage-domain ', + 'add --storage-subdomain too if the signed-in tab is on a subdomain.', + ); +} diff --git a/packages/cli/src/verbs/dom.ts b/packages/cli/src/verbs/dom.ts index 533b085..771a5ba 100644 --- a/packages/cli/src/verbs/dom.ts +++ b/packages/cli/src/verbs/dom.ts @@ -1,6 +1,7 @@ import type { Command } from '../args.js'; import type { Profile } from '../profiles.js'; import { serverOptsFor } from '../server-opts.js'; +import { requireStorageDomain } from '../storage-scope.js'; import { EXIT, UsageError, printJson, type Io } from '../output.js'; import { mapBridgeError } from '../bridge-errors.js'; import { defaultServerFactory, pairCodePrinter, type VerbServerFactory } from './fetch.js'; @@ -32,6 +33,7 @@ export async function runDom( ): Promise { // Validate scope narrowing BEFORE connecting — usage errors must not // cost a bridge round-trip (and must not trigger pairing). + requireStorageDomain(profile, cmd.storageDomain); const names = narrowDomNames(cmd.names, profile.domSelectors.map((d) => d.name)); const server = makeServer({ diff --git a/packages/cli/src/verbs/read.ts b/packages/cli/src/verbs/read.ts index 86cb1e7..d37b640 100644 --- a/packages/cli/src/verbs/read.ts +++ b/packages/cli/src/verbs/read.ts @@ -1,6 +1,7 @@ import type { Command } from '../args.js'; import type { Profile } from '../profiles.js'; import { serverOptsFor } from '../server-opts.js'; +import { requireStorageDomain } from '../storage-scope.js'; import { EXIT, UsageError, printJson, type Io } from '../output.js'; import { mapBridgeError } from '../bridge-errors.js'; import { defaultServerFactory, pairCodePrinter, type VerbServerFactory } from './fetch.js'; @@ -50,6 +51,7 @@ export async function runRead( ): Promise { // Validate scope narrowing BEFORE connecting — usage errors must not // cost a bridge round-trip (and must not trigger pairing). + requireStorageDomain(profile, cmd.storageDomain); const scope = { domain: cmd.storageDomain, subdomain: cmd.storageSubdomain }; let keys: string[] = []; if (cmd.bucket === 'indexedDb') { diff --git a/packages/cli/tests/dom.test.ts b/packages/cli/tests/dom.test.ts index b363a3b..e47c97c 100644 --- a/packages/cli/tests/dom.test.ts +++ b/packages/cli/tests/dom.test.ts @@ -28,6 +28,14 @@ const PROFILE = { }; describe('runDom', () => { + it('multi-domain profile without --storage-domain → UsageError naming the flag', async () => { + const server = stubServer(); + await expect(runDom({ kind: 'dom', profile: 'r', names: [] }, + { ...PROFILE, domains: ['resy.com', 'resy.io'] }, memIo(), () => server)) + .rejects.toThrow(/--storage-domain/); + expect(server.listen).not.toHaveBeenCalled(); + }); + it('narrows to requested declared names and reads via readDom', async () => { const server = stubServer(); const code = await runDom( diff --git a/packages/cli/tests/profiles.test.ts b/packages/cli/tests/profiles.test.ts index 550f78f..25b4da7 100644 --- a/packages/cli/tests/profiles.test.ts +++ b/packages/cli/tests/profiles.test.ts @@ -36,6 +36,50 @@ describe('profiles', () => { expect(() => loadProfiles(home)).toThrow(/bad.*domains/); }); + it('rejects a malformed element inside an object array, naming its index', () => { + saveProfiles({ + bad: { + ...emptyProfile(['x.com']), + indexedDb: [ + { origin: 'https://x.com', database: 'db', store: 's', keys: ['k'] }, + { database: 'db', store: 's', keys: ['k'] }, + ], + } as never, + }, home); + expect(() => loadProfiles(home)).toThrow(UsageError); + // The index matters: with several scopes declared, "invalid indexedDb" + // alone leaves the user hunting for which entry is wrong. + expect(() => loadProfiles(home)).toThrow(/indexedDb\[1\]/); + }); + + it('rejects a non-object element, an empty key list, and a bad optional field', () => { + const cases: Array<[string, unknown]> = [ + ['captureHeaders', ['not-an-object']], + ['captureHeaders', [{ host: 'x.com', headerName: 'x-tok', path: 7 }]], + ['indexedDb', [{ origin: 'https://x.com', database: 'db', store: 's', keys: [] }]], + ['localStoragePointers', [{ outputKey: 'o', storageKey: 's' }]], + ['sessionStoragePointers', [{ outputKey: 'o', storageKey: 's', jsonPointer: 3 }]], + ['domSelectors', [{ name: 'n', selector: '#a', attribute: '' }]], + ]; + for (const [field, value] of cases) { + saveProfiles({ bad: { ...emptyProfile(['x.com']), [field]: value } as never }, home); + expect(() => loadProfiles(home), field).toThrow(new RegExp(`${field}\\[0\\]`)); + } + }); + + it('accepts well-formed elements, including the optional fields', () => { + saveProfiles({ + ok: { + ...emptyProfile(['x.com']), + captureHeaders: [{ host: 'x.com', headerName: 'x-tok', path: '/api/*' }], + indexedDb: [{ origin: 'https://x.com', database: 'db', store: 's', keys: ['k'] }], + localStoragePointers: [{ outputKey: 'o', storageKey: 'auth', jsonPointer: '/token' }], + domSelectors: [{ name: 'csrf', selector: 'meta[name=csrf]', attribute: 'content' }], + } as never, + }, home); + expect(loadProfiles(home).ok.domSelectors[0].attribute).toBe('content'); + }); + it('getProfile throws a UsageError listing known profiles', () => { saveProfiles({ trip: emptyProfile(['tripadvisor.com']) }, home); expect(() => getProfile('nope', home)).toThrow(/known profiles: trip/); diff --git a/packages/cli/tests/read.test.ts b/packages/cli/tests/read.test.ts index 01f90c4..c37daf2 100644 --- a/packages/cli/tests/read.test.ts +++ b/packages/cli/tests/read.test.ts @@ -48,6 +48,27 @@ describe('runRead', () => { PROFILE, memIo(), () => stubServer())).rejects.toThrow(/profile declare/); }); + it('multi-domain profile without --storage-domain → UsageError naming the flag', async () => { + const multi = { ...PROFILE, domains: ['resy.com', 'resy.io'] }; + const server = stubServer(); + await expect(runRead({ kind: 'read', profile: 'r', bucket: 'cookies', keys: [] }, + multi, memIo(), () => server)).rejects.toThrow(/--storage-domain/); + // Refused before connecting: a usage error must not cost a bridge + // round-trip, and must not trigger pairing. + expect(server.listen).not.toHaveBeenCalled(); + }); + + it('multi-domain profile WITH --storage-domain proceeds', async () => { + const multi = { ...PROFILE, domains: ['resy.com', 'resy.io'] }; + const server = stubServer(); + const code = await runRead( + { kind: 'read', profile: 'r', bucket: 'cookies', keys: [], storageDomain: 'resy.io' }, + multi, memIo(), () => server); + expect(code).toBe(EXIT.OK); + expect(server.readCookies).toHaveBeenCalledWith( + { keys: ['a', 'b'], domain: 'resy.io', subdomain: undefined }); + }); + it('empty declared bucket → UsageError with the declare flag', async () => { await expect(runRead({ kind: 'read', profile: 'r', bucket: 'sessionStorage', keys: [] }, PROFILE, memIo(), () => stubServer())).rejects.toThrow(/--session-storage/); diff --git a/packages/cli/tests/session.test.ts b/packages/cli/tests/session.test.ts index 5bd88d6..ce59be2 100644 --- a/packages/cli/tests/session.test.ts +++ b/packages/cli/tests/session.test.ts @@ -42,6 +42,27 @@ describe('runSession', () => { expect(io.errs.join('\n')).toMatch(/waiting: trigger a request/); }); + it("rewrites the server's multi-domain error to name --storage-domain", async () => { + const io = memIo(); + // `fpx session` can't pre-check like read/dom do: a captureHeaders-only + // profile never does a domain-scoped read, so a multi-domain profile with + // no --storage-domain is legitimate there. The server's message is what + // reaches the user, and it speaks to library callers. + const boot = vi.fn(async () => { + throw new Error( + 'FetchproxyServer: this MCP declared multiple domains ["honeybook.com", "hbportal.co"]' + + " — pass { domain: '' } on every per-call request", + ); + }); + const code = await runSession( + { kind: 'session', profile: 'hb', storageDomain: undefined, storageSubdomain: undefined }, + { ...emptyProfile(['honeybook.com', 'hbportal.co']), cookies: ['a'] }, io, boot as never); + expect(code).toBe(EXIT.USAGE); + expect(io.errs[0]).toContain('--storage-domain'); + expect(io.errs[0]).not.toContain('pass { domain'); + expect(io.errs[0]).toContain('["honeybook.com", "hbportal.co"]'); + }); + it('bridge failure → exit 2', async () => { const io = memIo(); const boot = vi.fn(async () => { throw new Error('extension gone'); });