@danceiny/gotry 0.2.0-rc.32 → 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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -19,6 +19,13 @@
19
19
  * POST /results/:jobId → 善果/失败回传(≤8MB;batchSearch ~550KB)
20
20
  * 语义:桥不存在即整个扩展车道不存在——fail-closed 不变量在调用方(session-search 的 needs-extension)。
21
21
  * 审计:job 提交/回传写 GuardAuditEntry 同款 JSONL(kind:'extension-session-job',channel 层负责)。
22
+ *
23
+ * AaaS A2(#674,威胁 S-5)组织维度:作业、取活者(parked/长轮询)、在飞作业、心跳时刻、节律、
24
+ * 事件都属于恰好一个组织。作业匹配要求组织严格相等(先于 origin/clientId/站点 capability 判定);
25
+ * 另一个组织的回包与「未知作业」一模一样地 404 且**不落任何行**,在飞作业纹丝不动。
26
+ * 取活者的组织只由挂载宿主在鉴权之后经 `handleMountedRequest(req, res, { organization })`
27
+ * 传入,**从不读请求体或请求头**;不传 = 缺省 `local`(桌面 loopback 桥与既有调用方行为逐字不变)。
28
+ * 发给扩展的 ExtensionJob 线上形状不变:组织只活在队列内部条目与账本列里。
22
29
  */
23
30
 
24
31
  import { randomUUID } from 'node:crypto'
@@ -26,6 +33,10 @@ import { existsSync } from 'node:fs'
26
33
  import { homedir } from 'node:os'
27
34
  import { join } from 'node:path'
28
35
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
36
+ import { assertOrganizationId } from '../../src/tenant-state/ids.ts'
37
+
38
+ /** 缺省组织 = 今天的单车道:不传组织的调用方(桌面 loopback 桥、既有测试)都落在这里 */
39
+ export const BRIDGE_DEFAULT_ORGANIZATION = 'local'
29
40
 
30
41
  /** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
31
42
  export const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795] as const
@@ -136,7 +147,7 @@ export type SubmitOutcome =
136
147
  | { ok: false; reason: 'bridge-unavailable' | 'extension-not-connected' | 'timeout'; summary: string; jobKind?: ExtensionJobKind; stage?: 'queued' | 'claimed' }
137
148
 
138
149
  export interface SessionJobHandle {
139
- submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number; preferredOrigin?: string; preferredClientId?: string }): Promise<SubmitOutcome>
150
+ submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number; preferredOrigin?: string; preferredClientId?: string; organization?: string }): Promise<SubmitOutcome>
140
151
  extensionConnected(): boolean
141
152
  port: number
142
153
  close(): Promise<void>
@@ -160,6 +171,8 @@ export interface SessionBridgeOptions {
160
171
 
161
172
  interface QueuedJob {
162
173
  job: ExtensionJob
174
+ /** 作业所属组织(队列内部属性,不进发给扩展的 ExtensionJob) */
175
+ organization: string
163
176
  resolve: (outcome: SubmitOutcome) => void
164
177
  timer: ReturnType<typeof setTimeout> | null
165
178
  preferredOrigin?: string
@@ -183,12 +196,16 @@ interface ParkedPoller {
183
196
  capabilities: Set<string> | null
184
197
  origin: string
185
198
  clientId?: string
199
+ /** 取活者所属组织:由挂载宿主鉴权后传入,从不取自轮询体/请求头 */
200
+ organization: string
186
201
  }
187
202
 
188
203
  /** 扩展客户端标识形状(每 SW 生命周期一个 crypto.randomUUID);导出供事件上行端点(批次 B)同链校验 */
189
204
  export const CLIENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
190
205
 
191
- function jobMatches(entry: QueuedJob, capabilities: Set<string> | null, origin: string, clientId?: string): boolean {
206
+ function jobMatches(entry: QueuedJob, capabilities: Set<string> | null, origin: string, clientId: string | undefined, organization: string): boolean {
207
+ // 组织严格相等先于一切:站点/origin/clientId 全部吻合的另一个组织的取活者,也永远领不到这个作业
208
+ if (entry.organization !== organization) return false
192
209
  if (entry.preferredOrigin && entry.preferredOrigin !== origin) return false
193
210
  if (entry.preferredClientId && entry.preferredClientId !== clientId) return false
194
211
  return capabilities == null || capabilities.size === 0 || capabilities.has(entry.job.site)
@@ -231,11 +248,24 @@ export interface BridgeQueueStats {
231
248
  * 浏览器客户端即执行环境,服务器只做编排/队列)
232
249
  */
233
250
  export interface BridgeJobQueue extends SessionJobHandle {
234
- stats(): BridgeQueueStats
235
- /** 完整桥协议(loopback 独立桥形态;/results/:jobId 路径式) */
251
+ /** 某组织的队列快照(缺省 `local`);别的组织的作业/取活者/心跳一概不计 */
252
+ stats(organization?: string): BridgeQueueStats
253
+ /** 某组织的扩展是否在线(缺省 `local`):心跳按组织分开记,一个组织的扩展在线不代表另一个组织在线 */
254
+ extensionConnected(organization?: string): boolean
255
+ /**
256
+ * 绑定到一个组织的提交句柄:`submit` 一律注入该组织,调用方传的 `organization` 会被覆盖,
257
+ * 所以下游代码(会话检索、登录入口)没有任何办法把作业提交到别的组织。组织非法 = 抛 InvalidTenantIdError。
258
+ * 句柄是视图:`close()` 不关闭共享队列。
259
+ */
260
+ forOrganization(organization: string): SessionJobHandle
261
+ /** 完整桥协议(loopback 独立桥形态;/results/:jobId 路径式;组织恒为 `local`) */
236
262
  handleRequest(req: IncomingMessage, res: ServerResponse): void
237
- /** 挂载形态(/v1/session/bridge 前缀;/results?jobId= 查询式;bearer 鉴权由宿主模块前置) */
238
- handleMountedRequest(req: IncomingMessage, res: ServerResponse): void
263
+ /**
264
+ * 挂载形态(/v1/session/bridge 前缀;/results?jobId= 查询式;bearer 鉴权由宿主模块前置)。
265
+ * `caller.organization` 是宿主在鉴权之后解出的组织(缺省 `local`);非法组织 → 403 organization-invalid,
266
+ * 先于其它一切处理。组织**从不**读请求体或请求头。
267
+ */
268
+ handleMountedRequest(req: IncomingMessage, res: ServerResponse, caller?: { organization: string }): void
239
269
  /**
240
270
  * 开机恢复(批次 A;仅 store 注入形态存在,纯内存无此方法):
241
271
  * queued→重排 / claimed 已超时→unresolved+节律记账 / claimed 未超时→复活 inFlight。
@@ -265,28 +295,31 @@ export interface BridgeJobQueueOptions {
265
295
  */
266
296
  export interface BridgeJobStore {
267
297
  /** write-before-submit:入队前先落 queued;**抛错 = 提交失败,调用方不得入队派发** */
268
- insertQueuedJob(job: ExtensionJob, timeoutAt: number): void
298
+ insertQueuedJob(organization: string, job: ExtensionJob, timeoutAt: number): void
269
299
  /** 领取(/jobs 派发与 dispatchToParked 两处):claimed + 领取者,attempts 递增 */
270
- markClaimed(jobId: string, claim: { origin: string; clientId?: string }): void
271
- markResolved(jobId: string, result: ExtensionJobResult): void
300
+ markClaimed(organization: string, jobId: string, claim: { origin: string; clientId?: string }): void
301
+ markResolved(organization: string, jobId: string, result: ExtensionJobResult): void
272
302
  /** 超时/对账结算:曾被领取却未拿到善果终态(带回包则由 recordOrphanResult 落 result) */
273
- markUnresolved(jobId: string, note?: { reason: string }): void
303
+ markUnresolved(organization: string, jobId: string, note?: { reason: string }): void
274
304
  /** 从未被领取的终态作废(零站点花费) */
275
- markVoid(jobId: string): void
276
- /** 孤儿回包落库(未知 jobId 的回包内容以 unresolved 保留,供对账) */
277
- recordOrphanResult(jobId: string, result: ExtensionJobResult): void
305
+ markVoid(organization: string, jobId: string): void
306
+ /** 孤儿回包落库(未知 jobId 的回包内容以 unresolved 保留,供对账);jobId 已属别的组织 = 抛错零写入 */
307
+ recordOrphanResult(organization: string, jobId: string, result: ExtensionJobResult): void
278
308
  /** bridge_clients upsert(/jobs 轮询体 clientId + capabilities 到达即刷新) */
279
- upsertClient(clientId: string, origin: string, capabilities: readonly string[]): void
309
+ upsertClient(organization: string, clientId: string, origin: string, capabilities: readonly string[]): void
280
310
  /** cookie-names 命中票据名(名字级)→ 记客户端在该站点持有登录态 */
281
- recordClientLoginSite(clientId: string, site: string): void
282
- /** 站点导航类作业提交/领取:记命中时刻并推冷却 */
283
- noteSiteHit(site: string): void
311
+ recordClientLoginSite(organization: string, clientId: string, site: string): void
312
+ /** 站点导航类作业提交/领取:记命中时刻并推冷却(按 组织+站点) */
313
+ noteSiteHit(organization: string, site: string): void
284
314
  /** 超时结算冷却(claimed 超时:站点可能已被打) */
285
- applyCooldown(site: string): void
286
- pacingOf(site: string): { lastHitAt: number; cooldownUntil: number } | null
287
- /** recoverOnBoot 输入:未终态(queued/claimed)作业投影 */
315
+ applyCooldown(organization: string, site: string): void
316
+ pacingOf(organization: string, site: string): { lastHitAt: number; cooldownUntil: number } | null
317
+ /** 该 jobId 在账本里属于哪个组织(无此行 = null):回包处理用它认出「别的组织的作业」 */
318
+ organizationOfJob(jobId: string): string | null
319
+ /** recoverOnBoot 输入:全部组织的未终态(queued/claimed)作业投影,每行带所属组织 */
288
320
  listPendingJobs(): Array<{
289
321
  id: string
322
+ organization: string
290
323
  kind: string
291
324
  site: string
292
325
  payload_json: string
@@ -316,6 +349,16 @@ export function isNavigationJobKind(kind: ExtensionJobKind): boolean {
316
349
  return NAVIGATION_JOB_KINDS.has(kind)
317
350
  }
318
351
 
352
+ /** 组织 id 是否合法(assertOrganizationId 的布尔形式;非法组织一律在碰任何状态之前被拒) */
353
+ export function isBridgeOrganization(value: unknown): value is string {
354
+ try {
355
+ assertOrganizationId(value)
356
+ return true
357
+ } catch {
358
+ return false
359
+ }
360
+ }
361
+
319
362
  export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJobQueue {
320
363
  const extensionOrigins = new Set(opts.extensionOrigins ?? EXTENSION_ORIGINS)
321
364
  const now = opts.now ?? Date.now
@@ -323,9 +366,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
323
366
  const queue: QueuedJob[] = []
324
367
  const inFlight = new Map<string, QueuedJob>()
325
368
  const parked: ParkedPoller[] = []
326
- let lastSeenAt = 0
369
+ // 心跳时刻按组织分开记:一个组织的扩展在线,不代表另一个组织的扩展在线
370
+ const lastSeenAt = new Map<string, number>()
327
371
  let closed = false
328
- const extensionConnected = (): boolean => lastSeenAt > 0 && now() - lastSeenAt < EXTENSION_CONNECTED_WINDOW_MS
372
+ const extensionConnected = (organization: string = BRIDGE_DEFAULT_ORGANIZATION): boolean => {
373
+ const seen = lastSeenAt.get(assertOrganizationId(organization)) ?? 0
374
+ return seen > 0 && now() - seen < EXTENSION_CONNECTED_WINDOW_MS
375
+ }
329
376
 
330
377
  /**
331
378
  * 账本写兜底:持久面故障不得打断桥协议主路径(write-before-submit 的 INSERT 除外,
@@ -341,65 +388,87 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
341
388
  }
342
389
 
343
390
  /** 领走首个 capability 匹配的 job(移入 inFlight——回包按 jobId 路由,两处都可找到) */
344
- function takeMatching(capabilities: Set<string> | null, origin: string, clientId?: string): QueuedJob | null {
345
- const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId))
391
+ function takeMatching(capabilities: Set<string> | null, origin: string, clientId: string | undefined, organization: string): QueuedJob | null {
392
+ const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId, organization))
346
393
  if (idx < 0) return null
347
394
  const [next] = queue.splice(idx, 1)
348
395
  next.claimedOrigin = origin
349
396
  next.claimedClientId = clientId
350
397
  inFlight.set(next.job.jobId, next)
351
- record('markClaimed', () => store!.markClaimed(next.job.jobId, { origin, clientId }))
352
- if (isNavigationJobKind(next.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(next.job.site))
398
+ record('markClaimed', () => store!.markClaimed(next.organization, next.job.jobId, { origin, clientId }))
399
+ if (isNavigationJobKind(next.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(next.organization, next.job.site))
353
400
  return next
354
401
  }
355
402
 
356
403
  /** 扩展回包:queued 与 inFlight 两处都可寻址(job 执行失败 = result.ok:false,桥自身不吞) */
357
- function resolveJob(jobId: string, parsed: ExtensionJobResult): void {
404
+ function resolveJob(organization: string, jobId: string, parsed: ExtensionJobResult): void {
358
405
  const inFlightHit = inFlight.get(jobId)
359
406
  if (inFlightHit) {
407
+ // 第二道防线:回包处理已把别的组织的 jobId 按「未知作业」挡在外面,这里再不认一次
408
+ if (inFlightHit.organization !== organization) return
360
409
  inFlight.delete(jobId)
361
410
  if (inFlightHit.timer) clearTimeout(inFlightHit.timer)
362
- record('markResolved', () => store!.markResolved(jobId, parsed))
411
+ record('markResolved', () => store!.markResolved(inFlightHit.organization, jobId, parsed))
363
412
  record('client-login-site', () => {
364
413
  const names = Array.isArray(parsed.names) ? parsed.names : []
365
414
  if (parsed.ok === true && inFlightHit.job.kind === 'cookie-names' && names.length > 0 && inFlightHit.claimedClientId) {
366
- store!.recordClientLoginSite(inFlightHit.claimedClientId, inFlightHit.job.site)
415
+ store!.recordClientLoginSite(inFlightHit.organization, inFlightHit.claimedClientId, inFlightHit.job.site)
367
416
  }
368
417
  })
369
418
  inFlightHit.resolve({ ok: true, result: parsed, origin: inFlightHit.claimedOrigin, clientId: inFlightHit.claimedClientId })
370
419
  return
371
420
  }
372
- const idx = queue.findIndex((q) => q.job.jobId === jobId)
421
+ const idx = queue.findIndex((q) => q.job.jobId === jobId && q.organization === organization)
373
422
  if (idx < 0) return
374
423
  const [entry] = queue.splice(idx, 1)
375
424
  if (entry.timer) clearTimeout(entry.timer)
376
- record('markResolved', () => store!.markResolved(jobId, parsed))
425
+ record('markResolved', () => store!.markResolved(entry.organization, jobId, parsed))
377
426
  entry.resolve({ ok: true, result: parsed })
378
427
  }
379
428
 
429
+ /**
430
+ * 这个 jobId 属于另一个组织吗(内存里的排队/在飞条目,或账本里的行)?回包处理用它在**一切写入之前**
431
+ * 把别的组织的回包挡掉。读不了账本就证明不了「它不属于别的组织」,宁可当作属于(不落孤儿行)。
432
+ */
433
+ function belongsToAnotherOrganization(jobId: string, organization: string): boolean {
434
+ const held = inFlight.get(jobId) ?? queue.find((q) => q.job.jobId === jobId)
435
+ if (held) return held.organization !== organization
436
+ if (!store) return false
437
+ try {
438
+ const owner = store.organizationOfJob(jobId)
439
+ return owner !== null && owner !== organization
440
+ } catch {
441
+ return true
442
+ }
443
+ }
444
+
380
445
  /** 新 job 入队时即时派发给 capability 匹配的 parked 取活者(否则要白等一个 20s 长轮询周期) */
381
446
  function dispatchToParked(entry: QueuedJob): boolean {
382
- const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId))
447
+ const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId, p.organization))
383
448
  if (i < 0) return false
384
449
  const [p] = parked.splice(i, 1)
385
450
  clearTimeout(p.timer)
386
451
  entry.claimedOrigin = p.origin
387
452
  entry.claimedClientId = p.clientId
388
453
  inFlight.set(entry.job.jobId, entry)
389
- record('markClaimed', () => store!.markClaimed(entry.job.jobId, { origin: p.origin, clientId: p.clientId }))
390
- if (isNavigationJobKind(entry.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(entry.job.site))
454
+ record('markClaimed', () => store!.markClaimed(entry.organization, entry.job.jobId, { origin: p.origin, clientId: p.clientId }))
455
+ if (isNavigationJobKind(entry.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(entry.organization, entry.job.site))
391
456
  p.res.statusCode = 200
392
457
  p.res.setHeader('content-type', 'application/json')
393
458
  p.res.end(JSON.stringify({ job: entry.job }))
394
459
  return true
395
460
  }
396
461
 
397
- const stats = (): BridgeQueueStats => ({
398
- queued: queue.length,
399
- inFlight: inFlight.size,
400
- parked: parked.length,
401
- lastSeenMsAgo: lastSeenAt > 0 ? now() - lastSeenAt : null,
402
- })
462
+ const stats = (organization: string = BRIDGE_DEFAULT_ORGANIZATION): BridgeQueueStats => {
463
+ const org = assertOrganizationId(organization)
464
+ const seen = lastSeenAt.get(org) ?? 0
465
+ return {
466
+ queued: queue.filter((q) => q.organization === org).length,
467
+ inFlight: [...inFlight.values()].filter((q) => q.organization === org).length,
468
+ parked: parked.filter((p) => p.organization === org).length,
469
+ lastSeenMsAgo: seen > 0 ? now() - seen : null,
470
+ }
471
+ }
403
472
 
404
473
  /**
405
474
  * 结算器(submit 与 recoverOnBoot 复活条目共用):内存三态清理 + 账本终态落笔。
@@ -422,15 +491,15 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
422
491
  if (store && !outcome.ok) {
423
492
  if (outcome.reason === 'timeout') {
424
493
  if (wasClaimed) {
425
- record('markUnresolved', () => store!.markUnresolved(entry.job.jobId, { reason: 'timeout' }))
426
- if (isNavigationJobKind(entry.job.kind)) record('applyCooldown', () => store!.applyCooldown(entry.job.site))
494
+ record('markUnresolved', () => store!.markUnresolved(entry.organization, entry.job.jobId, { reason: 'timeout' }))
495
+ if (isNavigationJobKind(entry.job.kind)) record('applyCooldown', () => store!.applyCooldown(entry.organization, entry.job.site))
427
496
  } else {
428
- record('markVoid', () => store!.markVoid(entry.job.jobId))
497
+ record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
429
498
  }
430
499
  } else if (outcome.reason === 'extension-not-connected') {
431
- record('markVoid', () => store!.markVoid(entry.job.jobId))
500
+ record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
432
501
  } else if (!wasClaimed) {
433
- record('markVoid', () => store!.markVoid(entry.job.jobId))
502
+ record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
434
503
  }
435
504
  }
436
505
  deliver?.(outcome)
@@ -456,6 +525,11 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
456
525
  return summary
457
526
  }
458
527
  for (const row of pending) {
528
+ // 行里的组织必须是合法 id:被改坏的行还原不出所属组织,留在原地不派发(fail-closed),绝不猜成 local
529
+ if (!isBridgeOrganization(row.organization)) {
530
+ process.stderr.write(`[bridge] 账本恢复跳过组织非法的作业 ${row.id}\n`)
531
+ continue
532
+ }
459
533
  const expired = row.timeout_at !== null && row.timeout_at <= now()
460
534
  let job: ExtensionJob | null = null
461
535
  try {
@@ -466,6 +540,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
466
540
  if (!expired && job) {
467
541
  const entry: QueuedJob = {
468
542
  job,
543
+ organization: row.organization,
469
544
  resolve: null as unknown as (o: SubmitOutcome) => void,
470
545
  timer: null,
471
546
  claimedOrigin: row.claimed_origin ?? undefined,
@@ -478,14 +553,15 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
478
553
  inFlight.set(row.id, entry)
479
554
  summary.rehydratedInFlight++
480
555
  } else {
481
- record('markUnresolved', () => store!.markUnresolved(row.id, { reason: 'timeout' }))
482
- if (isNavigationJobKind(row.kind as ExtensionJobKind)) record('applyCooldown', () => store!.applyCooldown(row.site))
556
+ record('markUnresolved', () => store!.markUnresolved(row.organization, row.id, { reason: 'timeout' }))
557
+ if (isNavigationJobKind(row.kind as ExtensionJobKind)) record('applyCooldown', () => store!.applyCooldown(row.organization, row.site))
483
558
  summary.settledUnresolved++
484
559
  }
485
560
  } else {
486
561
  if (!expired && job) {
487
562
  const entry: QueuedJob = {
488
563
  job,
564
+ organization: row.organization,
489
565
  resolve: null as unknown as (o: SubmitOutcome) => void,
490
566
  timer: null,
491
567
  }
@@ -496,7 +572,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
496
572
  if (!dispatchToParked(entry)) queue.push(entry)
497
573
  summary.requeued++
498
574
  } else {
499
- record('markVoid', () => store!.markVoid(row.id))
575
+ record('markVoid', () => store!.markVoid(row.organization, row.id))
500
576
  summary.voidedStale++
501
577
  }
502
578
  }
@@ -509,8 +585,19 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
509
585
  * 为挂载形态(查询式 results——gotry-backend 内核只支持精确路由,jobId 走 query)。
510
586
  * Origin 白名单校验内置于两种形态(远程形态同样强制:网页跨域请求必带邪恶 Origin)。
511
587
  */
512
- function makeHandler(prefix: string, resultsViaQuery: boolean, statusExtra?: () => Record<string, unknown>) {
513
- return (req: IncomingMessage, res: ServerResponse): void => {
588
+ function makeHandler(prefix: string, resultsViaQuery: boolean, mounted: boolean, statusExtra?: () => Record<string, unknown>) {
589
+ return (req: IncomingMessage, res: ServerResponse, caller?: { organization: string }): void => {
590
+ // 组织只来自挂载宿主在鉴权之后传入的 caller(loopback 桥恒为 local),**从不读请求体或请求头**;
591
+ // 非法组织先于一切 403,不碰任何状态
592
+ // 传了 caller 就必须带合法组织(缺字段/null 也算非法,绝不悄悄回落成 local);没传 caller = 单车道
593
+ const requested: unknown = mounted ? (caller === undefined ? BRIDGE_DEFAULT_ORGANIZATION : caller.organization) : BRIDGE_DEFAULT_ORGANIZATION
594
+ if (!isBridgeOrganization(requested)) {
595
+ res.statusCode = 403
596
+ res.setHeader('content-type', 'application/json')
597
+ res.end(JSON.stringify({ ok: false, error: 'organization-invalid' }))
598
+ return
599
+ }
600
+ const organization = requested
514
601
  if (closed) { res.statusCode = 503; res.end(); return }
515
602
  const urlPath = (req.url ?? '').split('?')[0]
516
603
  const path = prefix && urlPath.startsWith(prefix) ? urlPath.slice(prefix.length) : urlPath
@@ -528,7 +615,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
528
615
  // 一切携带白名单扩展 Origin 的请求都刷新 lastSeen,否则 45s 后误判扩展掉线;
529
616
  // 无 Origin 的诊断 GET(/health /status)不记心跳,curl 探活不能伪造「扩展在线」
530
617
  if (originTrusted) {
531
- lastSeenAt = now()
618
+ lastSeenAt.set(organization, now())
532
619
  }
533
620
  const finish = (code: number, body: unknown): void => {
534
621
  res.statusCode = code
@@ -541,8 +628,8 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
541
628
  }
542
629
  if (req.method === 'GET' && path === '/status') {
543
630
  finish(200, {
544
- ok: true, protocol: BRIDGE_PROTOCOL, ...stats(),
545
- extensionConnected: extensionConnected(),
631
+ ok: true, protocol: BRIDGE_PROTOCOL, ...stats(organization),
632
+ extensionConnected: extensionConnected(organization),
546
633
  ...(statusExtra ? statusExtra() : {}),
547
634
  })
548
635
  return
@@ -565,13 +652,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
565
652
  })()
566
653
  // 客户端注册落账(store 形态):轮询即刷新 origin/capabilities/last_seen(bridge_clients upsert)
567
654
  if (pollerInfo.clientId) {
568
- record('upsertClient', () => store!.upsertClient(pollerInfo.clientId!, origin as string, [...(pollerInfo.capabilities ?? [])]))
655
+ record('upsertClient', () => store!.upsertClient(organization, pollerInfo.clientId!, origin as string, [...(pollerInfo.capabilities ?? [])]))
569
656
  }
570
- const next = takeMatching(pollerInfo.capabilities, origin as string, pollerInfo.clientId)
657
+ const next = takeMatching(pollerInfo.capabilities, origin as string, pollerInfo.clientId, organization)
571
658
  if (next) { finish(200, { job: next.job }); return }
572
659
  // 长轮询:hold ≤ JOBS_LONG_POLL_MS(必须 < MV3 SW 30s 存活窗口,每次响应都续命);
573
660
  // 新 job 提交时即时唤醒 parked 取活者(见 dispatchToParked)
574
- const poller: ParkedPoller = { res, timer: null as unknown as ReturnType<typeof setTimeout>, capabilities: pollerInfo.capabilities, origin: origin as string, clientId: pollerInfo.clientId }
661
+ const poller: ParkedPoller = { res, timer: null as unknown as ReturnType<typeof setTimeout>, capabilities: pollerInfo.capabilities, origin: origin as string, clientId: pollerInfo.clientId, organization }
575
662
  const parkTimer = setTimeout(() => {
576
663
  const i = parked.findIndex((p) => p.res === res)
577
664
  if (i >= 0) parked.splice(i, 1)
@@ -605,6 +692,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
605
692
  finish(400, { ok: false, error: '回包不是 JSON' })
606
693
  return
607
694
  }
695
+ // 组织边界先于一切(含下面的 Origin/客户端校验):jobId 在内存或账本里属于另一个组织,
696
+ // 就答得与未知作业一模一样(404 unknown-job),但**什么都不写**——不落孤儿行、不动在飞作业
697
+ // (它自己的合法回包照常闭环),也不泄露「这个 id 存在」。
698
+ if (belongsToAnotherOrganization(jobId, organization)) {
699
+ finish(404, { ok: false, error: 'unknown-job' })
700
+ return
701
+ }
608
702
  const assigned = inFlight.get(jobId)
609
703
  if (assigned?.claimedOrigin && assigned.claimedOrigin !== origin) {
610
704
  finish(403, { ok: false, error: '作业由另一扩展 Origin 领取' })
@@ -617,14 +711,14 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
617
711
  // 未知 jobId(批次 A):不再 200 静默丢弃——404 明示,孤儿回包内容落账
618
712
  // (status='unresolved',result_json 保留)供对账。扩展 postResult 本就有
619
713
  // catch 且不检查状态码(extension/background.js),协议兼容零改动。
620
- const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId)
714
+ const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId && q.organization === organization)
621
715
  if (!known) {
622
- record('orphan-result', () => store!.recordOrphanResult(jobId, parsed))
716
+ record('orphan-result', () => store!.recordOrphanResult(organization, jobId, parsed))
623
717
  finish(404, { ok: false, error: 'unknown-job' })
624
718
  return
625
719
  }
626
720
  finish(200, { ok: true })
627
- resolveJob(jobId, parsed)
721
+ resolveJob(organization, jobId, parsed)
628
722
  })
629
723
  return
630
724
  }
@@ -636,14 +730,31 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
636
730
  port: 0,
637
731
  extensionConnected,
638
732
  stats,
639
- handleRequest: makeHandler('', false),
640
- handleMountedRequest: makeHandler('/v1/session/bridge', true),
733
+ forOrganization(organization) {
734
+ const bound = assertOrganizationId(organization)
735
+ return {
736
+ get port() { return queueHandle.port },
737
+ extensionConnected: () => extensionConnected(bound),
738
+ // 先展开调用方的选项、再盖上绑定的组织:下游传什么 organization 都改不了提交去向
739
+ submit: (job, submitOpts) => queueHandle.submit(job, { ...submitOpts, organization: bound }),
740
+ // 句柄只是视图,共享队列的生命周期归创建它的宿主
741
+ close: () => Promise.resolve(),
742
+ }
743
+ },
744
+ handleRequest: makeHandler('', false, false),
745
+ handleMountedRequest: makeHandler('/v1/session/bridge', true, true),
641
746
  ...(store ? { recoverOnBoot } : {}),
642
747
  submit(job, submitOpts = {}) {
643
748
  const extensionWaitMs = submitOpts.extensionWaitMs ?? DEFAULT_EXTENSION_WAIT_MS
644
749
  const timeoutMs = submitOpts.timeoutMs ?? job.timeoutMs ?? 30_000
645
750
  const full: ExtensionJob = { jobId: randomUUID(), ...job }
646
751
  return new Promise<SubmitOutcome>((resolve) => {
752
+ // 组织只能是合法 id:非法的在碰账本/队列之前就失败(fail-closed)
753
+ const organization: unknown = submitOpts.organization === undefined ? BRIDGE_DEFAULT_ORGANIZATION : submitOpts.organization
754
+ if (!isBridgeOrganization(organization)) {
755
+ resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定的组织标识无效' })
756
+ return
757
+ }
647
758
  if (closed) { resolve({ ok: false, reason: 'bridge-unavailable', summary: '扩展桥已关闭' }); return }
648
759
  if (submitOpts.preferredOrigin && !extensionOrigins.has(submitOpts.preferredOrigin)) {
649
760
  resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定扩展 Origin 不在桥白名单' })
@@ -657,14 +768,14 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
657
768
  // 绝不派发无账作业。桌面纯内存形态此步整体跳过,行为零变化。
658
769
  if (store) {
659
770
  try {
660
- store.insertQueuedJob(full, now() + timeoutMs)
771
+ store.insertQueuedJob(organization, full, now() + timeoutMs)
661
772
  } catch (e) {
662
773
  resolve({ ok: false, reason: 'bridge-unavailable', summary: `桥账本写入失败,作业不入队:${e instanceof Error ? e.message : String(e)}` })
663
774
  return
664
775
  }
665
- if (isNavigationJobKind(full.kind)) record('noteSiteHit', () => store!.noteSiteHit(full.site))
776
+ if (isNavigationJobKind(full.kind)) record('noteSiteHit', () => store!.noteSiteHit(organization, full.site))
666
777
  }
667
- const entry: QueuedJob = { job: full, resolve: null as unknown as (o: SubmitOutcome) => void, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId }
778
+ const entry: QueuedJob = { job: full, organization, resolve: null as unknown as (o: SubmitOutcome) => void, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId }
668
779
  const settle = armSettle(entry, resolve)
669
780
  entry.resolve = settle
670
781
  entry.timer = setTimeout(() => {
@@ -682,7 +793,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
682
793
  let waited = 0
683
794
  const waitTimer = setInterval(() => {
684
795
  waited += 250
685
- if (queueHandle.extensionConnected()) { clearInterval(waitTimer); return }
796
+ if (extensionConnected(organization)) { clearInterval(waitTimer); return }
686
797
  if (waited >= extensionWaitMs) {
687
798
  clearInterval(waitTimer)
688
799
  settle({