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
10 changes: 10 additions & 0 deletions packages/cli/src/bridge-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<one of them>' }").
// 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 <domain>`,
);
return EXIT.USAGE;
}
const hints: Record<string, string> = {
bridge_down: 'is Chrome running with the Transporter extension installed?',
timeout: 'is a tab open on the declared domain and signed in?',
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v);

const hasStrings = (e: Record<string, unknown>, 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<string, (e: Record<string, unknown>) => 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(
Expand All @@ -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<Profile>) } as Profile;
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/storage-scope.ts
Original file line number Diff line number Diff line change
@@ -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: '<one of them>' }`), 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 <domain>',
'add --storage-subdomain <sub> too if the signed-in tab is on a subdomain.',
);
}
2 changes: 2 additions & 0 deletions packages/cli/src/verbs/dom.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +33,7 @@ export async function runDom(
): Promise<number> {
// 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({
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/verbs/read.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -50,6 +51,7 @@ export async function runRead(
): Promise<number> {
// 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') {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/tests/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/tests/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/tests/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<one of them>' } 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'); });
Expand Down