@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,1264 @@
1
+ /**
2
+ * Bridge organization dimension (AaaS A2, docs/rfc/aaas-rfc.md §4.4 and §8.3; threats S-5, S-9, S-11).
3
+ *
4
+ * Offline and deterministic: isolated mkdtemp state roots, loopback HTTP servers on temporary ports, a fake
5
+ * extension that is just `fetch`, no network, no real supplier, no credentials, and the founder's runtime
6
+ * state root is never opened. A bridge job, claim, result, event, pacing entry and client registration belong
7
+ * to exactly one organization; the organization comes from the mounting host (the seam), never from a body,
8
+ * a header or a payload.
9
+ *
10
+ * L ledger: the organization column and keys, in-place migration of a legacy file, store-level scoping,
11
+ * validation before the database is touched
12
+ * Q queue: matching requires organization equality (queued and parked, identical site / origin / client
13
+ * id), a foreign result answers like an unknown job and writes nothing, heartbeats and stats per
14
+ * organization, the caller organization is never read from the request, recovery keeps organizations
15
+ * M session-search module: `callerOrganization` and `admission` hooks, per-organization pacing, lock and
16
+ * events, end to end through the backend kernel
17
+ * S schema scan: no bridge column can hold a cookie value (threat S-9)
18
+ * D the default organization `local` keeps today's single lane
19
+ *
20
+ * Run: cd ts && npx tsx scripts/tenant-bridge-org-tests.ts
21
+ */
22
+ import assert from 'node:assert/strict';
23
+ import { randomUUID } from 'node:crypto';
24
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
25
+ import { createServer } from 'node:http';
26
+ import { tmpdir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ import { setTimeout as sleep } from 'node:timers/promises';
29
+ import Database from 'better-sqlite3';
30
+ import { BRIDGE_DEFAULT_ORGANIZATION, EXTENSION_ORIGIN, createBridgeJobQueue, } from '../capabilities/session/extension-bridge.js';
31
+ import { bridgeLedgerDbPath, openBridgeLedgerStore } from '../capabilities/session/bridge-ledger.js';
32
+ import { closeBackend, createBackendServer } from '../src/backend/kernel.js';
33
+ import { startSessionSearchModule } from '../src/backend/modules/session-search.js';
34
+ import { __resetRateLimiterForTest } from '../capabilities/session-search.js';
35
+ import { InvalidTenantIdError } from '../src/tenant-state/errors.js';
36
+ const ACME = 'acme';
37
+ const RIVAL = 'rival';
38
+ const LOCAL = BRIDGE_DEFAULT_ORGANIZATION;
39
+ const BASE = '/v1/session/bridge';
40
+ const SITE = 'dida-portal';
41
+ const BAD_ORGANIZATIONS = ['', 'ACME', 'Acme', '../acme', 'acme/rival', 'a'.repeat(64), '-acme', 'acme\n', 'ac me', 'acme\u0000', 42, null, {}];
42
+ let passed = 0;
43
+ async function check(label, assertion) {
44
+ await assertion();
45
+ passed += 1;
46
+ console.log(` ok - ${label}`);
47
+ }
48
+ const roots = [];
49
+ function tmpRoot(label) {
50
+ const root = mkdtempSync(join(tmpdir(), `gotry-bridge-org-${label}-`));
51
+ roots.push(root);
52
+ return root;
53
+ }
54
+ const searchJob = (site = SITE) => ({ kind: 'search', site, url: 'https://portal.dida.com/find' });
55
+ // ---- a store that records which methods the queue calls, for "refused before any store call" ----------------
56
+ const WRITE_METHODS = new Set(['insertQueuedJob', 'markClaimed', 'markResolved', 'markUnresolved', 'markVoid', 'recordOrphanResult', 'upsertClient', 'recordClientLoginSite', 'noteSiteHit', 'applyCooldown']);
57
+ class SpyStore {
58
+ calls = [];
59
+ inner;
60
+ constructor(inner) {
61
+ this.inner = inner;
62
+ }
63
+ log(method, organization) {
64
+ this.calls.push(organization === undefined ? method : `${method}:${organization}`);
65
+ }
66
+ writes() {
67
+ return this.calls.filter((call) => WRITE_METHODS.has(call.split(':')[0]));
68
+ }
69
+ insertQueuedJob(organization, job, timeoutAt) { this.log('insertQueuedJob', organization); this.inner.insertQueuedJob(organization, job, timeoutAt); }
70
+ markClaimed(organization, jobId, claim) { this.log('markClaimed', organization); this.inner.markClaimed(organization, jobId, claim); }
71
+ markResolved(organization, jobId, result) { this.log('markResolved', organization); this.inner.markResolved(organization, jobId, result); }
72
+ markUnresolved(organization, jobId, note) { this.log('markUnresolved', organization); this.inner.markUnresolved(organization, jobId, note); }
73
+ markVoid(organization, jobId) { this.log('markVoid', organization); this.inner.markVoid(organization, jobId); }
74
+ recordOrphanResult(organization, jobId, result) { this.log('recordOrphanResult', organization); this.inner.recordOrphanResult(organization, jobId, result); }
75
+ upsertClient(organization, clientId, origin, capabilities) { this.log('upsertClient', organization); this.inner.upsertClient(organization, clientId, origin, capabilities); }
76
+ recordClientLoginSite(organization, clientId, site) { this.log('recordClientLoginSite', organization); this.inner.recordClientLoginSite(organization, clientId, site); }
77
+ noteSiteHit(organization, site) { this.log('noteSiteHit', organization); this.inner.noteSiteHit(organization, site); }
78
+ applyCooldown(organization, site) { this.log('applyCooldown', organization); this.inner.applyCooldown(organization, site); }
79
+ pacingOf(organization, site) { this.log('pacingOf', organization); return this.inner.pacingOf(organization, site); }
80
+ organizationOfJob(jobId) { this.log('organizationOfJob'); return this.inner.organizationOfJob(jobId); }
81
+ listPendingJobs() { this.log('listPendingJobs'); return this.inner.listPendingJobs(); }
82
+ }
83
+ async function fixture(label) {
84
+ const root = tmpRoot(label);
85
+ const store = openBridgeLedgerStore(root);
86
+ const spy = new SpyStore(store);
87
+ const queue = createBridgeJobQueue({ store: spy });
88
+ const servers = [];
89
+ return {
90
+ root,
91
+ store,
92
+ spy,
93
+ queue,
94
+ async serve(organization) {
95
+ let current = organization;
96
+ const server = createServer((req, res) => {
97
+ queue.handleMountedRequest(req, res, current === undefined ? undefined : { organization: current });
98
+ });
99
+ server.on('connection', (socket) => socket.unref());
100
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
101
+ server.unref();
102
+ servers.push(server);
103
+ return { port: server.address().port, as: (next) => { current = next; } };
104
+ },
105
+ async done() {
106
+ for (const server of servers) {
107
+ server.closeAllConnections?.();
108
+ server.close();
109
+ }
110
+ await queue.close();
111
+ store.close();
112
+ },
113
+ };
114
+ }
115
+ function headersFor(extra = {}, origin = EXTENSION_ORIGIN) {
116
+ return { 'content-type': 'application/json', ...(origin === null ? {} : { origin }), ...extra };
117
+ }
118
+ /** The fake extension's long poll. Parked polls only end when the test aborts them or a job arrives. */
119
+ async function poll(face, body = {}, opts = {}) {
120
+ const response = await fetch(`http://127.0.0.1:${face.port}${BASE}/jobs`, {
121
+ method: 'POST',
122
+ headers: headersFor(opts.headers),
123
+ body: JSON.stringify({ extensionVersion: 'tenant-bridge-org-tests', ...body }),
124
+ ...(opts.signal ? { signal: opts.signal } : {}),
125
+ });
126
+ assert.equal(response.status, 200);
127
+ return (await response.json());
128
+ }
129
+ async function postResult(face, jobId, result, clientId) {
130
+ const response = await fetch(`http://127.0.0.1:${face.port}${BASE}/results?jobId=${encodeURIComponent(jobId)}`, {
131
+ method: 'POST',
132
+ headers: headersFor(clientId ? { 'x-gotry-client-id': clientId } : {}),
133
+ body: JSON.stringify(result),
134
+ });
135
+ return { status: response.status, body: await response.json() };
136
+ }
137
+ /** Any request that carries the trusted extension Origin refreshes that organization's heartbeat. */
138
+ async function heartbeat(face) {
139
+ const response = await fetch(`http://127.0.0.1:${face.port}${BASE}/status`, { headers: headersFor({}, EXTENSION_ORIGIN) });
140
+ assert.equal(response.status, 200);
141
+ return (await response.json());
142
+ }
143
+ async function until(condition, label, timeoutMs = 5_000) {
144
+ const deadline = Date.now() + timeoutMs;
145
+ while (Date.now() < deadline) {
146
+ if (condition())
147
+ return;
148
+ await sleep(10);
149
+ }
150
+ throw new Error(`timed out waiting for: ${label}`);
151
+ }
152
+ /** Resolves `pending` when the promise has not settled within `ms`: the way to assert "nothing was delivered". */
153
+ async function staysPending(promise, ms = 150) {
154
+ return (await Promise.race([promise.then(() => false, () => false), sleep(ms).then(() => true)]));
155
+ }
156
+ function jobRows(root) {
157
+ const raw = new Database(bridgeLedgerDbPath(root), { readonly: true });
158
+ try {
159
+ return raw.prepare('SELECT * FROM bridge_jobs ORDER BY created_at, id').all();
160
+ }
161
+ finally {
162
+ raw.close();
163
+ }
164
+ }
165
+ const LEGACY_DDL = `
166
+ CREATE TABLE bridge_jobs (id TEXT PRIMARY KEY, kind TEXT NOT NULL, site TEXT NOT NULL, payload_json TEXT NOT NULL,
167
+ status TEXT NOT NULL CHECK (status IN ('queued','claimed','resolved','unresolved','void')), claimed_by_client TEXT,
168
+ claimed_origin TEXT, claimed_at INTEGER, timeout_at INTEGER, result_json TEXT, attempts INTEGER NOT NULL DEFAULT 0,
169
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
170
+ CREATE INDEX bridge_jobs_status ON bridge_jobs(status);
171
+ CREATE TABLE bridge_clients (client_id TEXT PRIMARY KEY, origin TEXT NOT NULL, capabilities_json TEXT NOT NULL DEFAULT '[]',
172
+ last_seen_at INTEGER NOT NULL, login_sites_json TEXT NOT NULL DEFAULT '[]');
173
+ CREATE TABLE bridge_pacing (site TEXT PRIMARY KEY, last_hit_at INTEGER NOT NULL, cooldown_until INTEGER NOT NULL DEFAULT 0);
174
+ CREATE TABLE bridge_events (seq INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, client_id TEXT NOT NULL, kind TEXT NOT NULL,
175
+ site TEXT, subject TEXT NOT NULL, payload_json TEXT NOT NULL, idem_key TEXT);
176
+ CREATE UNIQUE INDEX bridge_events_idem_key ON bridge_events(idem_key) WHERE idem_key IS NOT NULL;
177
+ `;
178
+ async function main() {
179
+ console.log('§95 bridge organization dimension (offline)');
180
+ /* ---------- L: ledger ---------- */
181
+ await check('L1 every bridge table carries organization (default local); keys include it; no leftover single-column idem index', () => {
182
+ const root = tmpRoot('l1');
183
+ openBridgeLedgerStore(root).close();
184
+ const raw = new Database(bridgeLedgerDbPath(root), { readonly: true });
185
+ try {
186
+ const info = (table) => raw.prepare(`PRAGMA table_info(${table})`).all();
187
+ for (const table of ['bridge_jobs', 'bridge_clients', 'bridge_pacing', 'bridge_events']) {
188
+ const column = info(table).find((c) => c.name === 'organization');
189
+ assert.ok(column, `${table} has an organization column`);
190
+ assert.equal(column.notnull, 1, `${table}.organization is NOT NULL`);
191
+ assert.equal(column.dflt_value, "'local'", `${table}.organization defaults to local`);
192
+ }
193
+ const primaryKey = (table) => info(table).filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
194
+ assert.deepEqual(primaryKey('bridge_clients'), ['organization', 'client_id']);
195
+ assert.deepEqual(primaryKey('bridge_pacing'), ['organization', 'site']);
196
+ assert.deepEqual(primaryKey('bridge_jobs'), ['id'], 'a job id stays globally unique');
197
+ const indexes = raw.prepare('PRAGMA index_list(bridge_events)').all();
198
+ assert.ok(!indexes.some((i) => i.name === 'bridge_events_idem_key'), 'the single-column idem index is gone');
199
+ const idem = indexes.find((i) => i.name === 'bridge_events_org_idem_key');
200
+ assert.ok(idem && idem.unique === 1 && idem.partial === 1, 'a partial unique index on (organization, idem_key)');
201
+ assert.deepEqual(raw.prepare('PRAGMA index_info(bridge_events_org_idem_key)').all().map((c) => c.name), ['organization', 'idem_key']);
202
+ }
203
+ finally {
204
+ raw.close();
205
+ }
206
+ });
207
+ await check('L2 two organizations never share pacing, client rows, event idempotency keys or event lists', () => {
208
+ const root = tmpRoot('l2');
209
+ const store = openBridgeLedgerStore(root);
210
+ try {
211
+ store.noteSiteHit(ACME, SITE);
212
+ assert.ok(store.pacingOf(ACME, SITE));
213
+ assert.equal(store.pacingOf(RIVAL, SITE), null, 'a hit by acme is not a hit by rival');
214
+ assert.equal(store.pacingOf(LOCAL, SITE), null);
215
+ store.applyCooldown(RIVAL, 'train-12306');
216
+ assert.deepEqual(store.listPacing(ACME).map((r) => r.site), [SITE]);
217
+ assert.deepEqual(store.listPacing(RIVAL).map((r) => r.site), ['train-12306']);
218
+ const client = randomUUID();
219
+ store.upsertClient(ACME, client, 'chrome-extension://aaaa', ['dida-portal']);
220
+ store.upsertClient(RIVAL, client, 'chrome-extension://bbbb', ['train-12306']);
221
+ assert.equal(store.getClient(ACME, client)?.origin, 'chrome-extension://aaaa', 'the same client id is two rows');
222
+ assert.equal(store.getClient(RIVAL, client)?.origin, 'chrome-extension://bbbb');
223
+ assert.equal(store.getClient(LOCAL, client), null);
224
+ store.recordClientLoginSite(ACME, client, SITE);
225
+ assert.deepEqual(JSON.parse(store.getClient(ACME, client).login_sites_json), [SITE]);
226
+ assert.deepEqual(JSON.parse(store.getClient(RIVAL, client).login_sites_json), [], 'a login site noted for acme is not noted for rival');
227
+ const event = { client_id: client, kind: 'workspace', site: null, subject: 's', payload_json: '{}', idem_key: 'same-key' };
228
+ assert.equal(store.insertEvent(ACME, event), true);
229
+ assert.equal(store.insertEvent(RIVAL, event), true, 'another organization may reuse an idempotency key');
230
+ assert.equal(store.insertEvent(ACME, event), false, 'one organization cannot');
231
+ assert.equal(store.listEvents(ACME).length, 1);
232
+ assert.equal(store.listEvents(RIVAL).length, 1);
233
+ assert.equal(store.listEvents(LOCAL).length, 0);
234
+ assert.ok(store.listEvents(ACME).every((row) => row.organization === ACME));
235
+ }
236
+ finally {
237
+ store.close();
238
+ }
239
+ });
240
+ await check('L3 a job id is scoped to its organization: foreign updates change nothing, foreign reads see nothing, a foreign orphan write is refused', () => {
241
+ const root = tmpRoot('l3');
242
+ const store = openBridgeLedgerStore(root);
243
+ try {
244
+ const job = { jobId: 'job-acme-1', kind: 'search', site: SITE, url: 'https://portal.dida.com/find' };
245
+ store.insertQueuedJob(ACME, job, Date.now() + 60_000);
246
+ assert.equal(store.organizationOfJob(job.jobId), ACME);
247
+ assert.equal(store.organizationOfJob('no-such-job'), null);
248
+ store.markClaimed(RIVAL, job.jobId, { origin: EXTENSION_ORIGIN });
249
+ store.markResolved(RIVAL, job.jobId, { ok: true });
250
+ store.markUnresolved(RIVAL, job.jobId, { reason: 'forged' });
251
+ store.markVoid(RIVAL, job.jobId);
252
+ const untouched = store.getJob(job.jobId);
253
+ assert.equal(untouched.status, 'queued', 'no foreign update took effect');
254
+ assert.equal(untouched.attempts, 0);
255
+ assert.equal(untouched.result_json, null);
256
+ assert.equal(untouched.organization, ACME);
257
+ assert.equal(store.getJobForOrganization(RIVAL, job.jobId), null, 'a foreign read sees nothing');
258
+ assert.equal(store.getJobForOrganization(ACME, job.jobId)?.id, job.jobId);
259
+ assert.throws(() => store.recordOrphanResult(RIVAL, job.jobId, { ok: true, kind: 'search' }), /organization-mismatch/);
260
+ assert.equal(store.getJob(job.jobId)?.result_json, null, 'the refused orphan wrote nothing');
261
+ store.recordOrphanResult(ACME, job.jobId, { ok: true, kind: 'search' });
262
+ assert.equal(store.getJob(job.jobId)?.status, 'unresolved', 'the owner can still reconcile its own job');
263
+ // a genuinely unknown id becomes an orphan row of the caller, and only of the caller
264
+ store.recordOrphanResult(RIVAL, 'job-unknown-1', { ok: true, kind: 'search' });
265
+ assert.equal(store.getJob('job-unknown-1')?.organization, RIVAL);
266
+ assert.equal(store.getJobForOrganization(ACME, 'job-unknown-1'), null);
267
+ }
268
+ finally {
269
+ store.close();
270
+ }
271
+ });
272
+ await check('L4 every store method validates the organization before the database is touched (a closed database still reports InvalidTenantIdError)', () => {
273
+ const root = tmpRoot('l4');
274
+ const store = openBridgeLedgerStore(root);
275
+ store.close();
276
+ const job = { jobId: 'x', kind: 'search', site: SITE };
277
+ for (const bad of BAD_ORGANIZATIONS) {
278
+ const organization = bad;
279
+ const calls = [
280
+ () => store.insertQueuedJob(organization, job, 1),
281
+ () => store.markClaimed(organization, 'x', { origin: EXTENSION_ORIGIN }),
282
+ () => store.markResolved(organization, 'x', { ok: true }),
283
+ () => store.markUnresolved(organization, 'x'),
284
+ () => store.markVoid(organization, 'x'),
285
+ () => store.recordOrphanResult(organization, 'x', { ok: true }),
286
+ () => store.upsertClient(organization, randomUUID(), 'o', []),
287
+ () => store.recordClientLoginSite(organization, 'c', 's'),
288
+ () => store.noteSiteHit(organization, 's'),
289
+ () => store.applyCooldown(organization, 's'),
290
+ () => store.pacingOf(organization, 's'),
291
+ () => store.insertEvent(organization, { client_id: 'c', kind: 'k', site: null, subject: 's', payload_json: '{}', idem_key: null }),
292
+ () => store.listEvents(organization),
293
+ () => store.getJobForOrganization(organization, 'x'),
294
+ () => store.getClient(organization, 'c'),
295
+ () => store.listPacing(organization),
296
+ ];
297
+ for (const call of calls)
298
+ assert.throws(call, InvalidTenantIdError, `organization ${JSON.stringify(bad)} must be refused first`);
299
+ }
300
+ assert.throws(() => store.pacingOf(ACME, 's'), (error) => !(error instanceof InvalidTenantIdError), 'a valid organization on a closed database fails differently, so the check above is meaningful');
301
+ });
302
+ await check('L5 a legacy bridge.db (no organization anywhere) migrates in place: rows become local, keys are rebuilt, re-opening is idempotent', async () => {
303
+ const root = tmpRoot('l5');
304
+ const dbPath = bridgeLedgerDbPath(root);
305
+ mkdirSync(dirname(dbPath), { recursive: true });
306
+ const legacy = new Database(dbPath);
307
+ const now = Date.now();
308
+ legacy.exec(LEGACY_DDL);
309
+ const insertJob = legacy.prepare(`INSERT INTO bridge_jobs (id, kind, site, payload_json, status, claimed_by_client, claimed_origin, timeout_at, result_json, attempts, created_at, updated_at)
310
+ VALUES (@id, 'search', @site, @payload, @status, @client, @origin, @timeout, @result, @attempts, 1, 1)`);
311
+ const payload = (jobId, site) => JSON.stringify({ jobId, kind: 'search', site, url: 'https://portal.dida.com/find' });
312
+ insertJob.run({ id: 'legacy-queued', site: SITE, payload: payload('legacy-queued', SITE), status: 'queued', client: null, origin: null, timeout: now + 60_000, result: null, attempts: 0 });
313
+ insertJob.run({ id: 'legacy-claimed-dead', site: 'ctrip-hotel', payload: payload('legacy-claimed-dead', 'ctrip-hotel'), status: 'claimed', client: null, origin: EXTENSION_ORIGIN, timeout: now - 1_000, result: null, attempts: 1 });
314
+ insertJob.run({ id: 'legacy-resolved', site: SITE, payload: payload('legacy-resolved', SITE), status: 'resolved', client: null, origin: EXTENSION_ORIGIN, timeout: now, result: '{"ok":true}', attempts: 1 });
315
+ legacy.prepare(`INSERT INTO bridge_clients (client_id, origin, capabilities_json, last_seen_at, login_sites_json) VALUES ('legacy-client-1', ?, '["dida-portal"]', 5, '["dida-portal"]')`).run(EXTENSION_ORIGIN);
316
+ legacy.prepare(`INSERT INTO bridge_clients (client_id, origin, capabilities_json, last_seen_at, login_sites_json) VALUES ('legacy-client-2', ?, '[]', 6, '[]')`).run(EXTENSION_ORIGIN);
317
+ legacy.prepare(`INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES ('dida-portal', 100, 130000)`).run();
318
+ legacy.prepare(`INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES ('ctrip-hotel', 200, 230000)`).run();
319
+ const insertEvent = legacy.prepare(`INSERT INTO bridge_events (ts, client_id, kind, site, subject, payload_json, idem_key) VALUES (1, 'c', 'workspace', NULL, 's', '{}', ?)`);
320
+ insertEvent.run('ev-1');
321
+ insertEvent.run('ev-2');
322
+ insertEvent.run(null);
323
+ legacy.close();
324
+ // open the legacy file three times: the first pass migrates, the others must find nothing left to do
325
+ for (let pass = 0; pass < 3; pass += 1) {
326
+ const store = openBridgeLedgerStore(root);
327
+ try {
328
+ const raw = new Database(dbPath, { readonly: true });
329
+ try {
330
+ for (const table of ['bridge_jobs', 'bridge_clients', 'bridge_pacing', 'bridge_events']) {
331
+ const rows = raw.prepare(`SELECT organization FROM ${table}`).all();
332
+ assert.ok(rows.length > 0 && rows.every((r) => r.organization === 'local'), `${table}: every legacy row is local (pass ${pass})`);
333
+ }
334
+ assert.equal(raw.prepare('SELECT COUNT(*) AS n FROM bridge_jobs').get().n, 3);
335
+ assert.equal(raw.prepare('SELECT COUNT(*) AS n FROM bridge_clients').get().n, 2);
336
+ assert.equal(raw.prepare('SELECT COUNT(*) AS n FROM bridge_pacing').get().n, 2);
337
+ assert.equal(raw.prepare('SELECT COUNT(*) AS n FROM bridge_events').get().n, 3);
338
+ assert.deepEqual(raw.prepare(`SELECT name FROM sqlite_master WHERE name LIKE '%\\_\\_legacy' ESCAPE '\\'`).all(), [], 'no half-migrated table is left');
339
+ assert.deepEqual(raw.prepare('PRAGMA index_info(bridge_events_org_idem_key)').all().map((c) => c.name), ['organization', 'idem_key']);
340
+ const keyOf = (table) => raw.prepare(`PRAGMA table_info(${table})`).all().filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
341
+ assert.deepEqual(keyOf('bridge_clients'), ['organization', 'client_id'], 'the client key was rebuilt to include the organization');
342
+ assert.deepEqual(keyOf('bridge_pacing'), ['organization', 'site'], 'the pacing key was rebuilt to include the organization');
343
+ assert.ok(!raw.prepare(`PRAGMA index_list(bridge_events)`).all().some((i) => i.name === 'bridge_events_idem_key'), 'the legacy single-column idem index was dropped');
344
+ }
345
+ finally {
346
+ raw.close();
347
+ }
348
+ assert.equal(store.getJobForOrganization(LOCAL, 'legacy-resolved')?.result_json, '{"ok":true}');
349
+ assert.equal(store.getJobForOrganization(ACME, 'legacy-resolved'), null, 'legacy rows are not visible to another organization');
350
+ assert.deepEqual(store.pacingOf(LOCAL, SITE), { lastHitAt: 100, cooldownUntil: 130000 });
351
+ assert.equal(store.pacingOf(ACME, SITE), null);
352
+ assert.equal(store.getClient(LOCAL, 'legacy-client-1')?.login_sites_json, '["dida-portal"]');
353
+ assert.equal(store.getClient(ACME, 'legacy-client-1'), null);
354
+ const event = { client_id: 'c', kind: 'workspace', site: null, subject: 's', payload_json: '{}', idem_key: 'ev-1' };
355
+ assert.equal(store.insertEvent(LOCAL, event), false, 'a legacy idempotency key still deduplicates inside local');
356
+ }
357
+ finally {
358
+ store.close();
359
+ }
360
+ }
361
+ {
362
+ const store = openBridgeLedgerStore(root);
363
+ try {
364
+ const event = { client_id: 'c', kind: 'workspace', site: null, subject: 's', payload_json: '{}', idem_key: 'ev-1' };
365
+ assert.equal(store.insertEvent(ACME, event), true, 'the legacy key no longer blocks another organization');
366
+ assert.equal(store.insertEvent(ACME, event), false, 'and deduplicates inside it');
367
+ assert.equal(store.listEvents(ACME).length, 1);
368
+ assert.equal(store.listEvents(LOCAL).length, 3);
369
+ }
370
+ finally {
371
+ store.close();
372
+ }
373
+ }
374
+ // behavior on the migrated file: recovery restores local rows, a local poller can take them, an acme poller cannot
375
+ const store = openBridgeLedgerStore(root);
376
+ const queue = createBridgeJobQueue({ store });
377
+ const local = createServer((req, res) => queue.handleMountedRequest(req, res));
378
+ const acme = createServer((req, res) => queue.handleMountedRequest(req, res, { organization: ACME }));
379
+ await Promise.all([local, acme].map((s) => new Promise((resolve) => s.listen(0, '127.0.0.1', resolve))));
380
+ const faces = { local: { port: local.address().port, as: () => undefined }, acme: { port: acme.address().port, as: () => undefined } };
381
+ try {
382
+ const summary = queue.recoverOnBoot();
383
+ assert.deepEqual(summary, { requeued: 1, rehydratedInFlight: 0, settledUnresolved: 1, voidedStale: 0 });
384
+ assert.equal(queue.stats(LOCAL).queued, 1);
385
+ assert.equal(queue.stats(ACME).queued, 0);
386
+ const abort = new AbortController();
387
+ const acmePoll = poll(faces.acme, { capabilities: [SITE] }, { signal: abort.signal }).then(() => 'received', () => 'aborted');
388
+ await until(() => queue.stats(ACME).parked === 1, 'acme poller parked');
389
+ assert.equal(await staysPending(acmePoll), true, 'the acme poller never gets a migrated local job');
390
+ assert.equal((await poll(faces.local, { capabilities: [SITE] })).job?.jobId, 'legacy-queued');
391
+ abort.abort();
392
+ await acmePoll;
393
+ assert.ok(store.pacingOf(LOCAL, 'ctrip-hotel').cooldownUntil > Date.now(), 'the dead claim cooled the local site');
394
+ assert.equal(store.pacingOf(ACME, 'ctrip-hotel'), null);
395
+ }
396
+ finally {
397
+ local.closeAllConnections?.();
398
+ acme.closeAllConnections?.();
399
+ local.close();
400
+ acme.close();
401
+ await queue.close();
402
+ store.close();
403
+ }
404
+ });
405
+ /* ---------- Q: queue ---------- */
406
+ await check('L6 an older binary recreates the single-column idempotency index; the next open of the new code drops it again, so one organization\'s key never blocks another\'s', async () => {
407
+ const root = tmpRoot('l6');
408
+ openBridgeLedgerStore(root).close();
409
+ const dbPath = bridgeLedgerDbPath(root);
410
+ const raw = new Database(dbPath);
411
+ // what the older schema does at every open of the file
412
+ raw.exec('CREATE UNIQUE INDEX IF NOT EXISTS bridge_events_idem_key ON bridge_events(idem_key) WHERE idem_key IS NOT NULL');
413
+ raw.close();
414
+ const store = openBridgeLedgerStore(root);
415
+ try {
416
+ const check = new Database(dbPath, { readonly: true });
417
+ try {
418
+ const names = check.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map((row) => row.name);
419
+ assert.ok(!names.includes('bridge_events_idem_key'), `the old single-column index is gone: ${names.join(',')}`);
420
+ assert.ok(names.includes('bridge_events_org_idem_key'));
421
+ }
422
+ finally {
423
+ check.close();
424
+ }
425
+ const event = { client_id: randomUUID(), kind: 'workspace', site: null, subject: 's', payload_json: '{}', idem_key: 'shared-key' };
426
+ assert.equal(store.insertEvent(ACME, event), true);
427
+ assert.equal(store.insertEvent(RIVAL, event), true, 'the same key in another organization is its own event');
428
+ assert.equal(store.insertEvent(ACME, event), false, 'and still deduplicates inside one');
429
+ }
430
+ finally {
431
+ store.close();
432
+ }
433
+ });
434
+ await check('Q1 a queued job is never handed to a poller of another organization, even with the same site, origin and client id', async () => {
435
+ const f = await fixture('q1');
436
+ try {
437
+ const acme = await f.serve(ACME);
438
+ const rival = await f.serve(RIVAL);
439
+ const sharedClient = randomUUID();
440
+ await heartbeat(acme);
441
+ const pending = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
442
+ assert.equal(f.queue.stats(ACME).queued, 1);
443
+ const abort = new AbortController();
444
+ const rivalPoll = poll(rival, { clientId: sharedClient, capabilities: [SITE] }, { signal: abort.signal }).then(() => 'received', () => 'aborted');
445
+ await until(() => f.queue.stats(RIVAL).parked === 1, 'rival poller parked');
446
+ assert.equal(await staysPending(rivalPoll), true, 'the rival poller is still waiting');
447
+ assert.equal(f.queue.stats(ACME).queued, 1);
448
+ assert.equal(f.queue.stats(ACME).inFlight, 0);
449
+ assert.equal(f.queue.stats(RIVAL).inFlight, 0);
450
+ const got = await poll(acme, { clientId: sharedClient, capabilities: [SITE] });
451
+ assert.ok(got.job, 'the owner organization receives it');
452
+ assert.equal(f.store.getJobForOrganization(ACME, got.job.jobId)?.status, 'claimed');
453
+ assert.equal(f.store.getJobForOrganization(RIVAL, got.job.jobId), null);
454
+ abort.abort();
455
+ assert.equal(await rivalPoll, 'aborted');
456
+ assert.equal((await postResult(acme, got.job.jobId, { ok: true, kind: 'search', body: '{}' }, sharedClient)).status, 200);
457
+ assert.equal((await pending).ok, true);
458
+ }
459
+ finally {
460
+ await f.done();
461
+ }
462
+ });
463
+ await check('Q2 a parked poller of another organization is not woken by a new job, in either direction, while the same organization\'s parked poller is', async () => {
464
+ const f = await fixture('q2');
465
+ try {
466
+ const acme = await f.serve(ACME);
467
+ const rival = await f.serve(RIVAL);
468
+ // A: a rival poller is parked; acme submits a job it could technically run
469
+ const abortRival = new AbortController();
470
+ const rivalPoll = poll(rival, { capabilities: [SITE] }, { signal: abortRival.signal }).then(() => 'received', () => 'aborted');
471
+ await until(() => f.queue.stats(RIVAL).parked === 1, 'rival poller parked');
472
+ await heartbeat(acme);
473
+ const acmeJob = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
474
+ assert.equal(await staysPending(rivalPoll), true, 'acme\'s job did not wake the parked rival poller');
475
+ assert.equal(f.queue.stats(ACME).queued, 1, 'the job waits for an acme poller');
476
+ assert.equal(f.queue.stats(RIVAL).parked, 1);
477
+ assert.equal(f.queue.stats(RIVAL).inFlight, 0);
478
+ // B: the reverse, with a positive control. An acme poller is parked first, so it is first in line when a
479
+ // rival job arrives; the rival's own parked poller behind it must be the one that gets the job.
480
+ const abortAcme = new AbortController();
481
+ const acmePoll = poll(acme, { capabilities: ['train-12306'] }, { signal: abortAcme.signal }).then(() => 'received', () => 'aborted');
482
+ await until(() => f.queue.stats(ACME).parked === 1, 'acme poller parked');
483
+ const rivalOwn = poll(rival, { capabilities: ['train-12306'] }).then((r) => r.job?.site ?? 'none');
484
+ await until(() => f.queue.stats(RIVAL).parked === 2, 'second rival poller parked');
485
+ const rivalJob = f.queue.forOrganization(RIVAL).submit(searchJob('train-12306'), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
486
+ assert.equal(await rivalOwn, 'train-12306', 'the rival\'s own parked poller received its job');
487
+ assert.equal(await staysPending(acmePoll), true, 'the acme poller, parked earlier and first in line, did not');
488
+ assert.equal(f.queue.stats(ACME).parked, 1);
489
+ assert.equal(f.queue.stats(ACME).inFlight, 0);
490
+ assert.equal(f.queue.stats(RIVAL).inFlight, 1);
491
+ abortAcme.abort();
492
+ abortRival.abort();
493
+ await Promise.all([acmePoll, rivalPoll]);
494
+ void acmeJob;
495
+ void rivalJob;
496
+ }
497
+ finally {
498
+ await f.done();
499
+ }
500
+ });
501
+ await check('Q3 the default lane: a caller-less poller gets only local jobs, and local jobs go only to it', async () => {
502
+ const f = await fixture('q3');
503
+ try {
504
+ const local = await f.serve();
505
+ const acme = await f.serve(ACME);
506
+ await heartbeat(local);
507
+ await heartbeat(acme);
508
+ const localJob = f.queue.submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
509
+ const acmeJob = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
510
+ assert.equal(f.queue.stats(LOCAL).queued, 1);
511
+ assert.equal(f.queue.stats(ACME).queued, 1);
512
+ const gotLocal = await poll(local, { capabilities: [SITE] });
513
+ const gotAcme = await poll(acme, { capabilities: [SITE] });
514
+ assert.ok(gotLocal.job && gotAcme.job && gotLocal.job.jobId !== gotAcme.job.jobId);
515
+ assert.equal(f.store.getJob(gotLocal.job.jobId)?.organization, LOCAL);
516
+ assert.equal(f.store.getJob(gotAcme.job.jobId)?.organization, ACME);
517
+ await postResult(local, gotLocal.job.jobId, { ok: true, kind: 'search', body: '{}' });
518
+ await postResult(acme, gotAcme.job.jobId, { ok: true, kind: 'search', body: '{}' });
519
+ assert.equal((await localJob).ok && (await acmeJob).ok, true);
520
+ }
521
+ finally {
522
+ await f.done();
523
+ }
524
+ });
525
+ await check('Q4 a result for another organization\'s job is answered like an unknown job and writes nothing; the owner\'s job stays in flight and still resolves', async () => {
526
+ const f = await fixture('q4');
527
+ try {
528
+ const acme = await f.serve(ACME);
529
+ const rival = await f.serve(RIVAL);
530
+ const client = randomUUID();
531
+ await heartbeat(acme);
532
+ const pending = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
533
+ const got = await poll(acme, { clientId: client, capabilities: [SITE] });
534
+ const jobId = got.job.jobId;
535
+ const before = jobRows(f.root);
536
+ f.spy.calls.length = 0;
537
+ // even with the owner's client id header and the same Origin, the rival is just an unknown caller
538
+ const foreign = await postResult(rival, jobId, { ok: true, kind: 'search', body: 'FORGED-BY-RIVAL' }, client);
539
+ const unknown = await postResult(rival, randomUUID(), { ok: true, kind: 'search', body: 'x' });
540
+ assert.equal(foreign.status, 404);
541
+ assert.deepEqual(foreign.body, { ok: false, error: 'unknown-job' });
542
+ assert.equal(unknown.status, foreign.status, 'a foreign job id and an unknown job id are indistinguishable');
543
+ assert.deepEqual(unknown.body, foreign.body);
544
+ const writes = f.spy.writes();
545
+ assert.deepEqual(writes.filter((w) => !w.startsWith('recordOrphanResult:rival')), [], 'only the genuinely unknown id wrote anything, and only as rival');
546
+ assert.equal(writes.filter((w) => w.startsWith('recordOrphanResult')).length, 1, 'exactly one orphan write: the unknown id, not the foreign one');
547
+ const after = jobRows(f.root);
548
+ assert.equal(after.length, before.length + 1, 'one new row: the orphan of the unknown id');
549
+ const job = after.find((r) => r.id === jobId);
550
+ assert.deepEqual({ status: job.status, attempts: job.attempts, result: job.result_json, organization: job.organization }, { status: 'claimed', attempts: 1, result: null, organization: ACME });
551
+ assert.equal(f.queue.stats(ACME).inFlight, 1, 'the owner\'s job is still in flight');
552
+ assert.equal((await postResult(acme, jobId, { ok: true, kind: 'search', body: 'REAL' }, client)).status, 200, 'the owner\'s own result still resolves it');
553
+ const outcome = await pending;
554
+ assert.ok(outcome.ok && outcome.result.body === 'REAL');
555
+ assert.equal(f.store.getJob(jobId)?.status, 'resolved');
556
+ }
557
+ finally {
558
+ await f.done();
559
+ }
560
+ });
561
+ await check('Q5 a foreign result for a job that is still queued, or already resolved (ledger only), changes nothing and writes no orphan', async () => {
562
+ const f = await fixture('q5');
563
+ try {
564
+ const acme = await f.serve(ACME);
565
+ const rival = await f.serve(RIVAL);
566
+ await heartbeat(acme);
567
+ // queued, not yet claimed
568
+ const queued = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
569
+ const queuedId = jobRows(f.root)[0].id;
570
+ f.spy.calls.length = 0;
571
+ assert.equal((await postResult(rival, queuedId, { ok: true, kind: 'search', body: 'FORGED' })).status, 404);
572
+ assert.deepEqual(f.spy.writes(), []);
573
+ assert.equal(f.queue.stats(ACME).queued, 1);
574
+ assert.equal(f.store.getJob(queuedId)?.status, 'queued');
575
+ // resolved, then gone from memory: only the ledger knows whose it is
576
+ const got = await poll(acme, { capabilities: [SITE] });
577
+ assert.equal(got.job.jobId, queuedId);
578
+ await postResult(acme, queuedId, { ok: true, kind: 'search', body: 'REAL' });
579
+ assert.equal((await queued).ok, true);
580
+ const resolved = f.store.getJob(queuedId);
581
+ f.spy.calls.length = 0;
582
+ assert.equal((await postResult(rival, queuedId, { ok: true, kind: 'search', body: 'LATE-FORGED' })).status, 404);
583
+ assert.equal(f.spy.writes().length, 0, 'no orphan write for a resolved job of another organization');
584
+ assert.deepEqual(f.store.getJob(queuedId), resolved, 'the resolved row is byte for byte unchanged');
585
+ // the owner\'s own late duplicate still takes the legacy path (404 + an idempotent orphan call that changes nothing)
586
+ f.spy.calls.length = 0;
587
+ assert.equal((await postResult(acme, queuedId, { ok: true, kind: 'search', body: 'LATE-OWN' })).status, 404);
588
+ assert.deepEqual(f.spy.writes(), [`recordOrphanResult:${ACME}`]);
589
+ assert.deepEqual(f.store.getJob(queuedId), resolved);
590
+ }
591
+ finally {
592
+ await f.done();
593
+ }
594
+ });
595
+ await check('Q6 a genuinely unknown job id keeps today\'s reconciliation, filed under the caller\'s organization only', async () => {
596
+ const f = await fixture('q6');
597
+ try {
598
+ const rival = await f.serve(RIVAL);
599
+ const id = `orphan-${randomUUID()}`;
600
+ const response = await postResult(rival, id, { ok: true, kind: 'search', body: 'ORPHAN-EVIDENCE' });
601
+ assert.equal(response.status, 404);
602
+ const row = f.store.getJob(id);
603
+ assert.equal(row.organization, RIVAL);
604
+ assert.equal(row.status, 'unresolved');
605
+ assert.ok(row.result_json?.includes('ORPHAN-EVIDENCE'));
606
+ assert.equal(f.store.getJobForOrganization(ACME, id), null);
607
+ // and the id is now rival\'s: acme posting the same id is treated as foreign, not as a second orphan
608
+ f.spy.calls.length = 0;
609
+ const acme = await f.serve(ACME);
610
+ assert.equal((await postResult(acme, id, { ok: true, kind: 'search', body: 'ACME-OVERWRITE' })).status, 404);
611
+ assert.deepEqual(f.spy.writes(), []);
612
+ assert.ok(f.store.getJob(id)?.result_json?.includes('ORPHAN-EVIDENCE'));
613
+ }
614
+ finally {
615
+ await f.done();
616
+ }
617
+ });
618
+ await check('Q7 heartbeats, stats and /status are per organization', async () => {
619
+ const f = await fixture('q7');
620
+ try {
621
+ const acme = await f.serve(ACME);
622
+ const rival = await f.serve(RIVAL);
623
+ const local = await f.serve();
624
+ assert.equal(f.queue.extensionConnected(ACME), false);
625
+ await heartbeat(acme);
626
+ assert.equal(f.queue.extensionConnected(ACME), true);
627
+ assert.equal(f.queue.extensionConnected(RIVAL), false, 'acme\'s extension being online says nothing about rival\'s');
628
+ assert.equal(f.queue.extensionConnected(), false, 'nor about the default lane');
629
+ assert.equal(f.queue.extensionConnected(LOCAL), false);
630
+ assert.ok(f.queue.stats(ACME).lastSeenMsAgo !== null);
631
+ assert.equal(f.queue.stats(RIVAL).lastSeenMsAgo, null);
632
+ // a diagnostic GET without the extension Origin must not fake a heartbeat
633
+ await fetch(`http://127.0.0.1:${rival.port}${BASE}/status`);
634
+ assert.equal(f.queue.extensionConnected(RIVAL), false);
635
+ const pending = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
636
+ assert.equal(f.queue.stats(ACME).queued, 1);
637
+ assert.equal(f.queue.stats(RIVAL).queued, 0);
638
+ assert.equal(f.queue.stats().queued, 0, 'the default lane counts only its own queue');
639
+ const rivalStatus = await heartbeat(rival);
640
+ assert.equal(rivalStatus.queued, 0, 'rival\'s /status does not count acme\'s queue');
641
+ assert.equal(rivalStatus.extensionConnected, true, 'its own request refreshed its own heartbeat');
642
+ const localStatus = await (await fetch(`http://127.0.0.1:${local.port}${BASE}/status`)).json();
643
+ assert.equal(localStatus.queued, 0);
644
+ assert.equal(localStatus.extensionConnected, false);
645
+ void pending;
646
+ }
647
+ finally {
648
+ await f.done();
649
+ }
650
+ });
651
+ await check('Q8 the caller organization comes only from the host: bodies and headers that name another organization change nothing', async () => {
652
+ const f = await fixture('q8');
653
+ try {
654
+ const local = await f.serve();
655
+ const acme = await f.serve(ACME);
656
+ await heartbeat(acme);
657
+ await heartbeat(local);
658
+ const pending = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
659
+ const abort = new AbortController();
660
+ const forged = poll(local, { capabilities: [SITE], organization: ACME, tenant: ACME, ledgerTenant: 't-0123456789abcdef0123456789abcdef' }, {
661
+ signal: abort.signal,
662
+ headers: { 'x-gotry-organization': ACME, 'x-organization': ACME, 'x-tenant-id': ACME, 'x-forwarded-organization': ACME },
663
+ }).then(() => 'received', () => 'aborted');
664
+ await until(() => f.queue.stats(LOCAL).parked === 1, 'local poller parked');
665
+ assert.equal(f.queue.stats(ACME).parked, 0, 'forged fields did not move the poller into acme');
666
+ assert.equal(await staysPending(forged), true, 'and it did not receive acme\'s job');
667
+ // the other direction: an acme face stays acme whatever the body says
668
+ const abortAcme = new AbortController();
669
+ const acmePoll = poll(acme, { capabilities: ['train-12306'], organization: RIVAL }, { signal: abortAcme.signal, headers: { 'x-gotry-organization': RIVAL } }).then(() => 'received', () => 'aborted');
670
+ await until(() => f.queue.stats(ACME).parked === 1, 'acme poller parked');
671
+ assert.equal(f.queue.stats(RIVAL).parked, 0);
672
+ abort.abort();
673
+ abortAcme.abort();
674
+ await Promise.all([forged, acmePoll]);
675
+ void pending;
676
+ }
677
+ finally {
678
+ await f.done();
679
+ }
680
+ });
681
+ await check('Q9 an invalid caller organization is refused with 403 before any state, store call or heartbeat; an explicit null or missing field is not "local"', async () => {
682
+ const f = await fixture('q9');
683
+ try {
684
+ const face = await f.serve(ACME);
685
+ for (const bad of BAD_ORGANIZATIONS) {
686
+ face.as(bad);
687
+ for (const [method, path] of [['POST', 'jobs'], ['POST', 'results?jobId=x'], ['GET', 'status'], ['GET', 'health']]) {
688
+ const response = await fetch(`http://127.0.0.1:${face.port}${BASE}/${path}`, { method, headers: headersFor({}, EXTENSION_ORIGIN), ...(method === 'POST' ? { body: '{}' } : {}) });
689
+ assert.equal(response.status, 403, `${method} ${path} for ${JSON.stringify(bad)}`);
690
+ assert.deepEqual(await response.json(), { ok: false, error: 'organization-invalid' });
691
+ }
692
+ }
693
+ assert.deepEqual(f.spy.calls, [], 'the store was never called');
694
+ for (const org of [LOCAL, ACME, RIVAL]) {
695
+ assert.equal(f.queue.extensionConnected(org), false, 'no heartbeat was recorded for anyone');
696
+ assert.deepEqual(f.queue.stats(org), { queued: 0, inFlight: 0, parked: 0, lastSeenMsAgo: null });
697
+ }
698
+ assert.equal(jobRows(f.root).length, 0);
699
+ // a caller object without the field is invalid, not the default lane
700
+ const bare = createServer((req, res) => f.queue.handleMountedRequest(req, res, {}));
701
+ await new Promise((resolve) => bare.listen(0, '127.0.0.1', resolve));
702
+ try {
703
+ const response = await fetch(`http://127.0.0.1:${bare.address().port}${BASE}/status`);
704
+ assert.equal(response.status, 403);
705
+ }
706
+ finally {
707
+ bare.closeAllConnections?.();
708
+ bare.close();
709
+ }
710
+ }
711
+ finally {
712
+ await f.done();
713
+ }
714
+ });
715
+ await check('Q10 submit, forOrganization, stats and extensionConnected refuse a bad organization before any store call', async () => {
716
+ const f = await fixture('q10');
717
+ try {
718
+ for (const bad of BAD_ORGANIZATIONS) {
719
+ const outcome = await f.queue.submit(searchJob(), { organization: bad, timeoutMs: 200, extensionWaitMs: 0 });
720
+ assert.equal(outcome.ok, false);
721
+ if (!outcome.ok)
722
+ assert.equal(outcome.reason, 'bridge-unavailable');
723
+ assert.throws(() => f.queue.forOrganization(bad), InvalidTenantIdError);
724
+ assert.throws(() => f.queue.stats(bad), InvalidTenantIdError);
725
+ assert.throws(() => f.queue.extensionConnected(bad), InvalidTenantIdError);
726
+ }
727
+ assert.deepEqual(f.spy.calls, [], 'no store call happened');
728
+ assert.equal(jobRows(f.root).length, 0);
729
+ assert.deepEqual(f.queue.stats(), { queued: 0, inFlight: 0, parked: 0, lastSeenMsAgo: null });
730
+ }
731
+ finally {
732
+ await f.done();
733
+ }
734
+ });
735
+ await check('Q11 forOrganization cannot be redirected, and a payload cannot choose the organization either', async () => {
736
+ const f = await fixture('q11');
737
+ try {
738
+ const acme = await f.serve(ACME);
739
+ const rival = await f.serve(RIVAL);
740
+ await heartbeat(acme);
741
+ const bound = f.queue.forOrganization(ACME);
742
+ const redirected = bound.submit(searchJob(), { organization: RIVAL, timeoutMs: 10_000, extensionWaitMs: 30_000 });
743
+ assert.equal(f.queue.stats(ACME).queued, 1, 'the bound handle overrides the organization in the options');
744
+ assert.equal(f.queue.stats(RIVAL).queued, 0);
745
+ assert.equal(jobRows(f.root)[0].organization, ACME);
746
+ // a job whose payload claims to belong to rival is still acme's
747
+ const lying = f.queue.forOrganization(ACME).submit({ ...searchJob('train-12306'), organization: RIVAL }, { timeoutMs: 10_000, extensionWaitMs: 30_000 });
748
+ const lyingRow = jobRows(f.root).find((r) => r.site === 'train-12306');
749
+ assert.equal(lyingRow.organization, ACME, 'the column comes from the bound organization');
750
+ assert.ok(lyingRow.payload_json.includes(`"organization":"${RIVAL}"`), 'the lie is in the payload, exactly as the caller wrote it');
751
+ const abort = new AbortController();
752
+ const rivalPoll = poll(rival, { capabilities: ['train-12306', SITE] }, { signal: abort.signal }).then(() => 'received', () => 'aborted');
753
+ await until(() => f.queue.stats(RIVAL).parked === 1, 'rival parked');
754
+ assert.equal(await staysPending(rivalPoll), true, 'the payload\'s claim did not route the job to rival');
755
+ abort.abort();
756
+ await rivalPoll;
757
+ // closing the bound handle is closing a view: the shared queue keeps accepting work
758
+ await bound.close();
759
+ const later = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
760
+ assert.equal(f.queue.stats(ACME).queued, 3, 'the queue is still open after the view was closed');
761
+ void redirected;
762
+ void lying;
763
+ void later;
764
+ }
765
+ finally {
766
+ await f.done();
767
+ }
768
+ });
769
+ await check('Q12 recoverOnBoot restores each row under the organization of its column, never of its payload; pacing and results stay inside', async () => {
770
+ const f = await fixture('q12');
771
+ try {
772
+ const t0 = Date.now();
773
+ const live = randomUUID();
774
+ f.store.insertQueuedJob(ACME, { jobId: 'rec-acme-queued', kind: 'search', site: SITE, url: 'https://portal.dida.com/find' }, t0 + 60_000);
775
+ f.store.insertQueuedJob(RIVAL, { jobId: 'rec-rival-queued', kind: 'search', site: SITE, url: 'https://portal.dida.com/find' }, t0 + 60_000);
776
+ f.store.insertQueuedJob(ACME, { jobId: 'rec-acme-lying', kind: 'search', site: SITE, url: 'https://portal.dida.com/find', organization: RIVAL }, t0 + 60_000);
777
+ f.store.insertQueuedJob(ACME, { jobId: 'rec-acme-live', kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list' }, t0 + 60_000);
778
+ f.store.markClaimed(ACME, 'rec-acme-live', { origin: EXTENSION_ORIGIN, clientId: live });
779
+ f.store.insertQueuedJob(RIVAL, { jobId: 'rec-rival-dead', kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, t0 - 1_000);
780
+ f.store.markClaimed(RIVAL, 'rec-rival-dead', { origin: EXTENSION_ORIGIN });
781
+ const acme = await f.serve(ACME);
782
+ const rival = await f.serve(RIVAL);
783
+ const summary = f.queue.recoverOnBoot();
784
+ assert.deepEqual(summary, { requeued: 3, rehydratedInFlight: 1, settledUnresolved: 1, voidedStale: 0 });
785
+ assert.deepEqual([f.queue.stats(ACME).queued, f.queue.stats(ACME).inFlight], [2, 1]);
786
+ assert.deepEqual([f.queue.stats(RIVAL).queued, f.queue.stats(RIVAL).inFlight], [1, 0]);
787
+ assert.deepEqual([f.queue.stats(LOCAL).queued, f.queue.stats(LOCAL).inFlight], [0, 0]);
788
+ assert.ok(f.store.pacingOf(RIVAL, 'ctrip-hotel'), 'the dead claim cooled rival\'s site');
789
+ assert.equal(f.store.pacingOf(ACME, 'ctrip-hotel'), null);
790
+ assert.equal(f.store.pacingOf(LOCAL, 'ctrip-hotel'), null);
791
+ assert.equal((await poll(rival, { capabilities: [SITE] })).job?.jobId, 'rec-rival-queued', 'rival gets only its own queued job');
792
+ const first = await poll(acme, { capabilities: [SITE] });
793
+ const second = await poll(acme, { capabilities: [SITE] });
794
+ assert.deepEqual([first.job?.jobId, second.job?.jobId].sort(), ['rec-acme-lying', 'rec-acme-queued']);
795
+ assert.equal(f.store.pacingOf(RIVAL, SITE) !== null && f.store.pacingOf(ACME, SITE) !== null, true, 'each organization\'s claim cooled its own site');
796
+ // the revived claim: rival cannot resolve it (even with the live client id), acme can
797
+ assert.equal((await postResult(rival, 'rec-acme-live', { ok: true, kind: 'search', body: 'FORGED' }, live)).status, 404);
798
+ assert.equal(f.store.getJob('rec-acme-live')?.status, 'claimed');
799
+ assert.equal((await postResult(acme, 'rec-acme-live', { ok: true, kind: 'search', body: 'REAL' }, live)).status, 200);
800
+ assert.equal(f.store.getJob('rec-acme-live')?.status, 'resolved');
801
+ }
802
+ finally {
803
+ await f.done();
804
+ }
805
+ });
806
+ await check('Q13 a ledger row whose organization column is corrupt is skipped on recovery (fail closed), not guessed into local', async () => {
807
+ const f = await fixture('q13');
808
+ try {
809
+ f.store.insertQueuedJob(ACME, { jobId: 'rec-corrupt', kind: 'search', site: SITE, url: 'https://portal.dida.com/find' }, Date.now() + 60_000);
810
+ const raw = new Database(bridgeLedgerDbPath(f.root));
811
+ try {
812
+ raw.prepare(`UPDATE bridge_jobs SET organization='../evil' WHERE id='rec-corrupt'`).run();
813
+ }
814
+ finally {
815
+ raw.close();
816
+ }
817
+ const summary = f.queue.recoverOnBoot();
818
+ assert.deepEqual(summary, { requeued: 0, rehydratedInFlight: 0, settledUnresolved: 0, voidedStale: 0 });
819
+ for (const org of [LOCAL, ACME])
820
+ assert.equal(f.queue.stats(org).queued, 0);
821
+ }
822
+ finally {
823
+ await f.done();
824
+ }
825
+ });
826
+ await check('Q14 navigation pacing is per organization and site through the queue', async () => {
827
+ const f = await fixture('q14');
828
+ try {
829
+ const acme = await f.serve(ACME);
830
+ await heartbeat(acme);
831
+ const pending = f.queue.forOrganization(ACME).submit(searchJob(), { timeoutMs: 10_000, extensionWaitMs: 30_000 });
832
+ assert.ok(f.store.pacingOf(ACME, SITE), 'submitting a navigation job notes acme\'s hit');
833
+ assert.equal(f.store.pacingOf(RIVAL, SITE), null);
834
+ assert.equal(f.store.pacingOf(LOCAL, SITE), null);
835
+ const got = await poll(acme, { capabilities: [SITE] });
836
+ await postResult(acme, got.job.jobId, { ok: true, kind: 'search', body: '{}' });
837
+ await pending;
838
+ assert.deepEqual(f.store.listPacing(ACME).map((r) => r.organization), [ACME]);
839
+ assert.deepEqual(f.store.listPacing(RIVAL), []);
840
+ }
841
+ finally {
842
+ await f.done();
843
+ }
844
+ });
845
+ /* ---------- M: session-search module hooks ---------- */
846
+ const KEY = 'tenant-bridge-org-key';
847
+ const ORG_HEADER = 'x-test-resolved-organization';
848
+ async function harness(label, options = {}) {
849
+ const root = tmpRoot(label);
850
+ const h = {
851
+ root,
852
+ base: '',
853
+ searches: [],
854
+ admissions: [],
855
+ releases: 0,
856
+ decide: () => ({ ok: true, release: () => { h.releases += 1; } }),
857
+ searchImpl: async () => undefined,
858
+ async done() {
859
+ await closeBackend(handle, [mod]);
860
+ },
861
+ };
862
+ const mod = startSessionSearchModule({
863
+ apiKey: () => KEY,
864
+ stateRoot: root,
865
+ verdictLog: () => undefined,
866
+ search: async (opts) => {
867
+ h.searches.push({ query: opts.query, bridge: opts.bridge });
868
+ await h.searchImpl(opts.query);
869
+ return { ok: true, via: 'session-dida-portal', evidence: '[session:dida-portal@tenant-bridge-org-tests]', latencyMs: 1, verdict: 'hit', rates: [] };
870
+ },
871
+ ...(options.scoped === false ? {} : {
872
+ callerOrganization: (req) => {
873
+ const value = req.headers[ORG_HEADER];
874
+ return typeof value === 'string' ? value : null;
875
+ },
876
+ }),
877
+ ...(options.admission ? {
878
+ admission: (_req, organization) => {
879
+ h.admissions.push({ organization });
880
+ return h.decide(organization);
881
+ },
882
+ } : {}),
883
+ });
884
+ const handle = await createBackendServer({ modules: [mod], port: 0 });
885
+ h.base = `http://127.0.0.1:${handle.port}`;
886
+ return h;
887
+ }
888
+ const authed = (organization, extra = {}, origin = null) => ({
889
+ authorization: `Bearer ${KEY}`,
890
+ 'content-type': 'application/json',
891
+ ...(organization === undefined ? {} : { [ORG_HEADER]: organization }),
892
+ ...(origin === null ? {} : { origin }),
893
+ ...extra,
894
+ });
895
+ async function postSearch(h, organization, body = { supplier: SITE }) {
896
+ return fetch(`${h.base}/v1/session/search`, { method: 'POST', headers: authed(organization), body: JSON.stringify(body) });
897
+ }
898
+ await check('M1 callerOrganization: null, invalid or throwing resolution is 403 organization-unresolved on every route, after (never instead of) the bearer check', async () => {
899
+ const h = await harness('m1');
900
+ try {
901
+ const routes = [
902
+ ['GET', '/v1/session/status'], ['POST', '/v1/session/search', '{"supplier":"dida-portal"}'], ['POST', '/v1/session/login/open', '{"supplier":"dida-portal"}'],
903
+ ['GET', '/v1/session/bridge/health'], ['GET', '/v1/session/bridge/status'], ['POST', '/v1/session/bridge/jobs', '{}'],
904
+ ['POST', '/v1/session/bridge/results?jobId=x', '{}'], ['POST', '/v1/session/bridge/events', '{}'],
905
+ ];
906
+ for (const [method, path, body] of routes) {
907
+ for (const organization of [undefined, '', 'ACME', '../acme', 'a'.repeat(64)]) {
908
+ const response = await fetch(`${h.base}${path}`, { method, headers: authed(organization, {}, EXTENSION_ORIGIN), ...(body === undefined ? {} : { body }) });
909
+ assert.equal(response.status, 403, `${method} ${path} org=${JSON.stringify(organization)}`);
910
+ assert.deepEqual(await response.json(), { ok: false, error: 'organization-unresolved' });
911
+ }
912
+ const wrongBearer = await fetch(`${h.base}${path}`, { method, headers: { ...authed(ACME, {}, EXTENSION_ORIGIN), authorization: 'Bearer wrong' }, ...(body === undefined ? {} : { body }) });
913
+ assert.deepEqual(await wrongBearer.json(), { ok: false, error: 'forbidden' }, 'a bad bearer is answered by the bearer check, not the organization one');
914
+ }
915
+ assert.equal(h.searches.length, 0, 'no request reached the search');
916
+ assert.equal(jobRows(h.root).length, 0);
917
+ }
918
+ finally {
919
+ await h.done();
920
+ }
921
+ const thrower = startSessionSearchModule({ apiKey: () => KEY, callerOrganization: () => { throw new Error('resolver down'); } });
922
+ const server = await createBackendServer({ modules: [thrower], port: 0 });
923
+ try {
924
+ const response = await fetch(`http://127.0.0.1:${server.port}/v1/session/status`, { headers: authed() });
925
+ assert.equal(response.status, 403);
926
+ }
927
+ finally {
928
+ await closeBackend(server, [thrower]);
929
+ }
930
+ });
931
+ await check('M2 events: the organization the host resolved owns the rows; the same idempotency key is accepted once per organization', async () => {
932
+ const h = await harness('m2');
933
+ try {
934
+ const client = randomUUID();
935
+ const body = JSON.stringify({ clientId: client, events: [{ kind: 'workspace', subject: 's', payload_json: '{}', idem_key: 'k-1' }] });
936
+ const post = (organization) => fetch(`${h.base}/v1/session/bridge/events`, { method: 'POST', headers: authed(organization, {}, EXTENSION_ORIGIN), body }).then((r) => r.json());
937
+ assert.deepEqual(await post(ACME), { ok: true, accepted: 1, rejected: 0 });
938
+ assert.deepEqual(await post(RIVAL), { ok: true, accepted: 1, rejected: 0 }, 'rival\'s identical key is its own event');
939
+ assert.deepEqual(await post(ACME), { ok: true, accepted: 0, rejected: 1 }, 'a replay inside one organization is still deduplicated');
940
+ // a body field that names another organization is just an unknown field of an event
941
+ const forged = JSON.stringify({ clientId: client, organization: RIVAL, events: [{ kind: 'workspace', subject: 's', payload_json: '{}', idem_key: 'k-2', organization: RIVAL }] });
942
+ await fetch(`${h.base}/v1/session/bridge/events`, { method: 'POST', headers: authed(ACME, {}, EXTENSION_ORIGIN), body: forged });
943
+ const reader = openBridgeLedgerStore(h.root);
944
+ try {
945
+ assert.deepEqual(reader.listEvents(ACME).map((e) => e.idem_key), ['k-1', 'k-2']);
946
+ assert.deepEqual(reader.listEvents(RIVAL).map((e) => e.idem_key), ['k-1']);
947
+ assert.equal(reader.listEvents(LOCAL).length, 0);
948
+ }
949
+ finally {
950
+ reader.close();
951
+ }
952
+ }
953
+ finally {
954
+ await h.done();
955
+ }
956
+ });
957
+ await check('M3 search: the bridge handed to the search is bound to the caller\'s organization; the ledger cooldown and the single-flight lock are per organization', async () => {
958
+ const h = await harness('m3');
959
+ try {
960
+ // 1. the bridge is bound
961
+ const hit = await postSearch(h, ACME);
962
+ assert.equal(hit.status, 200);
963
+ const outcome = await h.searches[0].bridge.submit({ kind: 'cookie-names', site: SITE }, { timeoutMs: 200, extensionWaitMs: 0, organization: RIVAL });
964
+ assert.equal(outcome.ok, false);
965
+ assert.deepEqual(jobRows(h.root).map((r) => r.organization), [ACME], 'the job the search made is acme\'s, whatever the option said');
966
+ // 2. the ledger cooldown of one organization does not throttle another
967
+ const writer = openBridgeLedgerStore(h.root);
968
+ try {
969
+ writer.applyCooldown(ACME, SITE);
970
+ }
971
+ finally {
972
+ writer.close();
973
+ }
974
+ const hot = await postSearch(h, ACME);
975
+ assert.equal(hot.status, 429);
976
+ assert.ok(String(hot.headers.get('retry-after')).length > 0);
977
+ assert.equal((await postSearch(h, RIVAL)).status, 200, 'rival is not cooling down because acme is');
978
+ assert.equal((await postSearch(h, undefined)).status, 403, 'a request the host could not attribute never reaches the search');
979
+ // 3. a slow search of one organization does not queue another organization behind it
980
+ let release;
981
+ const gate = new Promise((resolve) => { release = resolve; });
982
+ h.searchImpl = async (query) => { if (query?.city === 'slow')
983
+ await gate; };
984
+ const slow = postSearch(h, 'orgslow', { supplier: SITE, query: { city: 'slow' } });
985
+ try {
986
+ await until(() => h.searches.some((s) => s.query?.city === 'slow'), 'the slow search started');
987
+ const fast = await Promise.race([postSearch(h, 'orgfast', { supplier: SITE, query: { city: 'fast' } }).then((r) => r.status), sleep(5_000).then(() => 'blocked')]);
988
+ assert.equal(fast, 200, 'orgfast was served while orgslow\'s search was still running');
989
+ }
990
+ finally {
991
+ release();
992
+ }
993
+ assert.equal((await slow).status, 200);
994
+ }
995
+ finally {
996
+ await h.done();
997
+ }
998
+ });
999
+ await check('M4 end to end through the module: a search of one organization is claimed only by that organization\'s extension, and a foreign result is a 404 that leaves the search waiting', async () => {
1000
+ const h = await harness('m4');
1001
+ try {
1002
+ const result = { value: null };
1003
+ const client = randomUUID();
1004
+ const pollAs = (organization, signal) => fetch(`${h.base}/v1/session/bridge/jobs`, { method: 'POST', headers: authed(organization, {}, EXTENSION_ORIGIN), body: JSON.stringify({ clientId: client, capabilities: [SITE] }), ...(signal ? { signal } : {}) }).then((r) => r.json());
1005
+ const resultAs = (organization, jobId, body) => fetch(`${h.base}/v1/session/bridge/results?jobId=${jobId}`, { method: 'POST', headers: authed(organization, { 'x-gotry-client-id': client }, EXTENSION_ORIGIN), body: JSON.stringify(body) });
1006
+ // submit through a search request: the fake search is told to enqueue a real job and wait for it
1007
+ h.searchImpl = async () => {
1008
+ const bridge = h.searches[h.searches.length - 1].bridge;
1009
+ const outcome = await bridge.submit({ kind: 'search', site: SITE, url: 'https://portal.dida.com/find' }, { timeoutMs: 8_000, extensionWaitMs: 30_000 });
1010
+ result.value = outcome.ok ? String(outcome.result.body) : outcome.reason;
1011
+ };
1012
+ const requestDone = postSearch(h, ACME).then((r) => r.status);
1013
+ await until(() => jobRows(h.root).some((r) => r.organization === ACME && r.status === 'queued'), 'acme job queued');
1014
+ const abort = new AbortController();
1015
+ const rivalPoll = pollAs(RIVAL, abort.signal).then(() => 'received', () => 'aborted');
1016
+ assert.equal(await staysPending(rivalPoll, 200), true, 'rival\'s extension is not given acme\'s job');
1017
+ const got = await pollAs(ACME);
1018
+ assert.ok(got.job, 'acme\'s extension is');
1019
+ const foreign = await resultAs(RIVAL, got.job.jobId, { ok: true, kind: 'search', body: 'FORGED' });
1020
+ assert.equal(foreign.status, 404);
1021
+ assert.deepEqual(await foreign.json(), { ok: false, error: 'unknown-job' });
1022
+ assert.equal(result.value, null, 'the search is still waiting');
1023
+ assert.equal((await resultAs(ACME, got.job.jobId, { ok: true, kind: 'search', body: 'REAL' })).status, 200);
1024
+ assert.equal(await requestDone, 200);
1025
+ assert.equal(result.value, 'REAL');
1026
+ abort.abort();
1027
+ await rivalPoll;
1028
+ const rows = jobRows(h.root);
1029
+ assert.deepEqual(rows.map((r) => [r.organization, r.status]), [[ACME, 'resolved']], 'no row exists for rival');
1030
+ }
1031
+ finally {
1032
+ await h.done();
1033
+ }
1034
+ });
1035
+ await check('M5 admission: a refusal is 429 + retry-after before any bridge work; releases run in finally; a throwing admission is 503; it is consulted only for requests that would reach the bridge', async () => {
1036
+ const h = await harness('m5', { admission: true });
1037
+ try {
1038
+ // refusal
1039
+ h.decide = () => ({ ok: false, retryAfterSeconds: 7.2, code: 'bridge_jobs_per_tenant' });
1040
+ const refused = await postSearch(h, ACME);
1041
+ assert.equal(refused.status, 429);
1042
+ assert.equal(refused.headers.get('retry-after'), '8');
1043
+ assert.deepEqual(await refused.json(), { ok: false, verdict: 'quota_exceeded', error: 'bridge_jobs_per_tenant' });
1044
+ assert.equal(h.searches.length, 0, 'the search never ran');
1045
+ assert.equal(jobRows(h.root).length, 0, 'no queue activity');
1046
+ const refusedLogin = await fetch(`${h.base}/v1/session/login/open`, { method: 'POST', headers: authed(ACME), body: JSON.stringify({ supplier: SITE }) });
1047
+ assert.equal(refusedLogin.status, 429);
1048
+ assert.equal(jobRows(h.root).length, 0, 'login/open queued nothing either');
1049
+ assert.equal(h.releases, 0, 'a refusal has nothing to release');
1050
+ // the retry-after floor and non-finite values
1051
+ h.decide = () => ({ ok: false, retryAfterSeconds: 0, code: 'floor' });
1052
+ assert.equal((await postSearch(h, ACME)).headers.get('retry-after'), '1');
1053
+ h.decide = () => ({ ok: false, retryAfterSeconds: Number.NaN, code: 'nan' });
1054
+ assert.equal((await postSearch(h, ACME)).headers.get('retry-after'), '1');
1055
+ // admitted: release runs once, also when the search throws
1056
+ h.admissions.length = 0;
1057
+ h.decide = () => ({ ok: true, release: () => { h.releases += 1; } });
1058
+ assert.equal((await postSearch(h, ACME)).status, 200);
1059
+ assert.equal(h.releases, 1);
1060
+ h.searchImpl = async () => { throw new Error('boom'); };
1061
+ assert.equal((await postSearch(h, ACME)).status, 500);
1062
+ assert.equal(h.releases, 2, 'released after a failing search too');
1063
+ h.searchImpl = async () => undefined;
1064
+ assert.deepEqual(h.admissions.map((a) => a.organization), [ACME, ACME], 'admission receives the resolved organization');
1065
+ // a throwing admission never lets the request through
1066
+ h.decide = () => { throw new Error('quota store down'); };
1067
+ const before = h.searches.length;
1068
+ assert.equal((await postSearch(h, ACME)).status, 503);
1069
+ assert.equal(h.searches.length, before);
1070
+ // not consulted for invalid bodies, and not for a ledger cooldown that already says no
1071
+ h.admissions.length = 0;
1072
+ h.decide = () => ({ ok: true });
1073
+ assert.equal((await postSearch(h, ACME, [])).status, 400);
1074
+ assert.equal((await postSearch(h, ACME, { supplier: 'unknown-supplier' })).status, 400);
1075
+ const writer = openBridgeLedgerStore(h.root);
1076
+ try {
1077
+ writer.applyCooldown(RIVAL, SITE);
1078
+ }
1079
+ finally {
1080
+ writer.close();
1081
+ }
1082
+ assert.equal((await postSearch(h, RIVAL)).status, 429);
1083
+ assert.deepEqual(h.admissions, [], 'nothing above would have reached the bridge, so the quota was not touched');
1084
+ }
1085
+ finally {
1086
+ await h.done();
1087
+ }
1088
+ });
1089
+ await check('M6 without the hooks the module is the single lane it always was: no resolver needed, rows land under local', async () => {
1090
+ const h = await harness('m6', { scoped: false });
1091
+ try {
1092
+ assert.equal((await postSearch(h, undefined)).status, 200);
1093
+ assert.equal((await postSearch(h, ACME)).status, 200, 'a header naming an organization is ignored when no resolver was configured');
1094
+ await h.searches[0].bridge.submit({ kind: 'cookie-names', site: SITE }, { timeoutMs: 200, extensionWaitMs: 0 });
1095
+ assert.deepEqual(jobRows(h.root).map((r) => r.organization), [LOCAL]);
1096
+ const eventPost = await fetch(`${h.base}/v1/session/bridge/events`, { method: 'POST', headers: authed(ACME, {}, EXTENSION_ORIGIN), body: JSON.stringify({ clientId: randomUUID(), events: [{ kind: 'workspace', subject: 's', payload_json: '{}' }] }) });
1097
+ assert.equal(eventPost.status, 200);
1098
+ const reader = openBridgeLedgerStore(h.root);
1099
+ try {
1100
+ assert.equal(reader.listEvents(LOCAL).length, 1);
1101
+ assert.equal(reader.listEvents(ACME).length, 0, 'the header did not move the event into acme');
1102
+ }
1103
+ finally {
1104
+ reader.close();
1105
+ }
1106
+ }
1107
+ finally {
1108
+ await h.done();
1109
+ }
1110
+ });
1111
+ await check('M7 the in-process pacing gate of the real search is per organization: two organizations back to back are both served, one organization twice is paced', async () => {
1112
+ __resetRateLimiterForTest();
1113
+ const mod = startSessionSearchModule({
1114
+ apiKey: () => KEY,
1115
+ stateRoot: tmpRoot('m7'),
1116
+ verdictLog: () => undefined,
1117
+ callerOrganization: (req) => { const value = req.headers[ORG_HEADER]; return typeof value === 'string' ? value : null; },
1118
+ });
1119
+ const handle = await createBackendServer({ modules: [mod], port: 0 });
1120
+ const base = `http://127.0.0.1:${handle.port}`;
1121
+ try {
1122
+ // the real search asks the organization's extension for login tickets; an extension with none ends it at once
1123
+ const search = async (organization) => {
1124
+ const client = randomUUID();
1125
+ const request = fetch(`${base}/v1/session/search`, { method: 'POST', headers: authed(organization), body: JSON.stringify({ supplier: SITE }) });
1126
+ const polled = await fetch(`${base}/v1/session/bridge/jobs`, { method: 'POST', headers: authed(organization, {}, EXTENSION_ORIGIN), body: JSON.stringify({ clientId: client, capabilities: [SITE] }) }).then((r) => r.json());
1127
+ assert.ok(polled.job, `${organization}'s extension was handed its job`);
1128
+ await fetch(`${base}/v1/session/bridge/results?jobId=${polled.job.jobId}`, { method: 'POST', headers: authed(organization, { 'x-gotry-client-id': client }, EXTENSION_ORIGIN), body: JSON.stringify({ ok: true, kind: 'cookie-names', names: [] }) });
1129
+ const response = await request;
1130
+ return { status: response.status, body: await response.json() };
1131
+ };
1132
+ const a1 = await search(ACME);
1133
+ assert.deepEqual([a1.status, a1.body.verdict], [200, 'needs-login'], 'acme was served');
1134
+ const r1 = await search(RIVAL);
1135
+ assert.deepEqual([r1.status, r1.body.verdict], [200, 'needs-login'], `rival is not paced by acme's call (${r1.body.error ?? ''})`);
1136
+ const a2 = await fetch(`${base}/v1/session/search`, { method: 'POST', headers: authed(ACME), body: JSON.stringify({ supplier: SITE }) });
1137
+ assert.equal(a2.status, 429, 'acme twice in a row is still paced');
1138
+ assert.match(String((await a2.json()).error), /last call \d+ms ago/);
1139
+ }
1140
+ finally {
1141
+ await closeBackend(handle, [mod]);
1142
+ __resetRateLimiterForTest();
1143
+ }
1144
+ });
1145
+ await check('M8 an admission that returns something that is not a decision is a 503, like a throw: it never crashes the process and never lets the request through', async () => {
1146
+ const h = await harness('m8', { admission: true });
1147
+ const rejections = [];
1148
+ const onRejection = (reason) => { rejections.push(reason); };
1149
+ process.on('unhandledRejection', onRejection);
1150
+ try {
1151
+ for (const malformed of [undefined, null, 'yes', 42, {}, { ok: 'true' }, { ok: 1 }]) {
1152
+ h.decide = () => malformed;
1153
+ const before = h.searches.length;
1154
+ // bounded: a handler that crashes would never answer, and that must read as a failed assertion, not a hang
1155
+ const response = await fetch(`${h.base}/v1/session/search`, { method: 'POST', headers: authed(ACME), body: JSON.stringify({ supplier: SITE }), signal: AbortSignal.timeout(3_000) });
1156
+ assert.equal(response.status, 503, `a decision of ${JSON.stringify(malformed)}`);
1157
+ assert.deepEqual(await response.json(), { ok: false, error: 'admission-unavailable' });
1158
+ assert.equal(h.searches.length, before, 'the search never ran');
1159
+ }
1160
+ // a refusal without a usable code still refuses, with a fixed one
1161
+ h.decide = () => ({ ok: false, retryAfterSeconds: 3 });
1162
+ const refused = await postSearch(h, ACME);
1163
+ assert.equal(refused.status, 429);
1164
+ assert.deepEqual(await refused.json(), { ok: false, verdict: 'quota_exceeded', error: 'quota_exceeded' });
1165
+ assert.equal(jobRows(h.root).length, 0, 'none of it reached the bridge');
1166
+ await sleep(50);
1167
+ assert.deepEqual(rejections, [], 'no rejection escaped');
1168
+ }
1169
+ finally {
1170
+ process.off('unhandledRejection', onRejection);
1171
+ await h.done();
1172
+ }
1173
+ });
1174
+ await check('M9 the organization-scoped module refuses the cdp transport at construction (it would drive the host\'s own Chrome for any organization); the single lane keeps it', async () => {
1175
+ const previous = process.env.GOTRY_SESSION_TRANSPORT;
1176
+ process.env.GOTRY_SESSION_TRANSPORT = 'cdp';
1177
+ try {
1178
+ assert.throws(() => startSessionSearchModule({ apiKey: () => KEY, callerOrganization: () => ACME }), /session_search_organization_requires_extension_transport/);
1179
+ const single = startSessionSearchModule({ apiKey: () => KEY });
1180
+ await single.close?.();
1181
+ }
1182
+ finally {
1183
+ if (previous === undefined)
1184
+ delete process.env.GOTRY_SESSION_TRANSPORT;
1185
+ else
1186
+ process.env.GOTRY_SESSION_TRANSPORT = previous;
1187
+ }
1188
+ const extension = startSessionSearchModule({ apiKey: () => KEY, callerOrganization: () => ACME });
1189
+ await extension.close?.();
1190
+ });
1191
+ /* ---------- S: schema scan ---------- */
1192
+ await check('S1 no bridge table can hold a cookie value, a credential or a session secret (threat S-9)', async () => {
1193
+ const f = await fixture('s1');
1194
+ try {
1195
+ const raw = new Database(bridgeLedgerDbPath(f.root), { readonly: true });
1196
+ try {
1197
+ const tables = raw.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all().map((t) => t.name).sort();
1198
+ assert.deepEqual(tables, ['bridge_clients', 'bridge_events', 'bridge_jobs', 'bridge_pacing']);
1199
+ const forbidden = /cookie|passw|secret|credential|bearer|api[_-]?key|token|session[_-]?(id|key)|^value$|^values$/i;
1200
+ for (const table of tables) {
1201
+ for (const column of raw.prepare(`PRAGMA table_info(${table})`).all()) {
1202
+ assert.ok(!forbidden.test(column.name), `${table}.${column.name} must not exist: the bridge stores names and control state only`);
1203
+ }
1204
+ }
1205
+ }
1206
+ finally {
1207
+ raw.close();
1208
+ }
1209
+ // the cookie-names path stores the names the extension reported, and nothing shaped like a value
1210
+ const acme = await f.serve(ACME);
1211
+ await heartbeat(acme);
1212
+ const pending = f.queue.forOrganization(ACME).submit({ kind: 'cookie-names', site: SITE }, { timeoutMs: 5_000, extensionWaitMs: 30_000 });
1213
+ const client = randomUUID();
1214
+ const got = await poll(acme, { clientId: client, capabilities: [SITE] });
1215
+ await postResult(acme, got.job.jobId, { ok: true, kind: 'cookie-names', names: ['dida_session_sig'] }, client);
1216
+ await pending;
1217
+ const row = f.store.getJob(got.job.jobId);
1218
+ assert.deepEqual(JSON.parse(row.result_json), { ok: true, kind: 'cookie-names', names: ['dida_session_sig'] });
1219
+ assert.deepEqual(JSON.parse(f.store.getClient(ACME, client).login_sites_json), [SITE]);
1220
+ }
1221
+ finally {
1222
+ await f.done();
1223
+ }
1224
+ });
1225
+ /* ---------- D: default lane ---------- */
1226
+ await check('D1 the default organization is local: loopback handler and plain submit keep the single lane, and the loopback handler ignores a caller', async () => {
1227
+ const root = tmpRoot('d1');
1228
+ const store = openBridgeLedgerStore(root);
1229
+ const queue = createBridgeJobQueue({ store });
1230
+ const server = createServer((req, res) => {
1231
+ ;
1232
+ queue.handleRequest(req, res, { organization: ACME });
1233
+ });
1234
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1235
+ server.unref();
1236
+ const port = server.address().port;
1237
+ try {
1238
+ assert.deepEqual(queue.stats(), queue.stats(LOCAL));
1239
+ const pending = queue.submit(searchJob(), { timeoutMs: 5_000, extensionWaitMs: 30_000 });
1240
+ const polled = await (await fetch(`http://127.0.0.1:${port}/jobs`, { method: 'POST', headers: headersFor(), body: JSON.stringify({ capabilities: [SITE] }) })).json();
1241
+ assert.ok(polled.job, 'the loopback poller got the local job even though a caller naming acme was passed');
1242
+ assert.equal(store.getJob(polled.job.jobId)?.organization, LOCAL);
1243
+ await fetch(`http://127.0.0.1:${port}/results/${polled.job.jobId}`, { method: 'POST', headers: headersFor(), body: JSON.stringify({ ok: true, kind: 'search', body: '{}' }) });
1244
+ assert.equal((await pending).ok, true);
1245
+ assert.equal(queue.extensionConnected(), true);
1246
+ assert.equal(queue.extensionConnected(LOCAL), true);
1247
+ assert.equal(queue.extensionConnected(ACME), false);
1248
+ }
1249
+ finally {
1250
+ server.closeAllConnections?.();
1251
+ server.close();
1252
+ await queue.close();
1253
+ store.close();
1254
+ }
1255
+ });
1256
+ for (const root of roots)
1257
+ rmSync(root, { recursive: true, force: true });
1258
+ assert.ok(roots.every((root) => !existsSync(root)), 'temporary state roots are cleaned up');
1259
+ console.log(`\n§95 bridge organization dimension: ${passed} checks green`);
1260
+ }
1261
+ main().catch((error) => {
1262
+ console.error('FAIL:', error);
1263
+ process.exit(1);
1264
+ });