@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,186 @@
1
+ /** Public supplier city metadata, independent of inventory and account sessions. */
2
+ export const CTRIP_CITY_CATALOG_URL = 'https://m.ctrip.com/restapi/soa2/17909/SearchBoxRecommend';
3
+ export const CTRIP_CITY_SUGGEST_URL = 'https://flights.ctrip.com/international/search/api/poi/search';
4
+ export const CTRIP_HOTEL_CITY_URL = 'https://m.ctrip.com/restapi/soa2/34951/getHotelKeywords';
5
+ const normalized = (s) => s.normalize('NFKC').trim().replace(/^中国(?=香港|澳门)/, '').toLowerCase();
6
+ const text = (v) => typeof v === 'string' ? v.trim() : '';
7
+ const code = (v) => /^[a-z]{3}$/i.test(text(v)) ? text(v).toUpperCase() : undefined;
8
+ const object = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
9
+ export function parseCtripCityCatalog(value) {
10
+ const root = object(value);
11
+ if (object(root.ResponseStatus).Ack !== 'Success' || object(root.responseHead).errorCode !== '0' || !Array.isArray(root.recommendGroupList))
12
+ throw new Error('unrecognized city catalog');
13
+ const records = [];
14
+ let visited = 0;
15
+ const walk = (v, depth = 0) => {
16
+ if (++visited > 20_000 || depth > 12)
17
+ throw new Error('city catalog bounds');
18
+ if (Array.isArray(v)) {
19
+ for (const item of v)
20
+ walk(item, depth + 1);
21
+ return;
22
+ }
23
+ const r = object(v), c = code(r.code), name = text(r.name);
24
+ if (c && name && Number(r.id) > 0) {
25
+ records.push({ name, code: c, aliases: [name, text(r.eName), text(r.nameEn), text(r.namePy), text(r.specName)].filter(Boolean), context: text(r.specName) || undefined, requiresQualification: r.sameSpecCity === 1 });
26
+ if (Array.isArray(r.airportlst))
27
+ for (const item of r.airportlst) {
28
+ const a = object(item), ac = code(a.code), an = text(a.name);
29
+ if (ac && an)
30
+ records.push({ name: an, code: ac, aliases: [an], context: name, airport: true });
31
+ }
32
+ }
33
+ for (const [key, child] of Object.entries(r))
34
+ if (key !== 'airportlst' && child && typeof child === 'object')
35
+ walk(child, depth + 1);
36
+ };
37
+ walk(root.recommendGroupList);
38
+ return records;
39
+ }
40
+ export function parseCtripCitySuggestions(value) {
41
+ const root = object(value);
42
+ if (!Array.isArray(root.Data))
43
+ throw new Error('unrecognized city suggestions');
44
+ const records = [];
45
+ let visited = 0;
46
+ const walk = (items, depth = 0) => {
47
+ if (depth > 5)
48
+ throw new Error('city suggestion bounds');
49
+ for (const value of items) {
50
+ if (++visited > 2_000)
51
+ throw new Error('city suggestion bounds');
52
+ const r = object(value), c = code(r.Code), name = text(r.Name);
53
+ if (c && name && [3, 4, 5].includes(Number(r.Type)))
54
+ records.push({
55
+ name, code: c, aliases: [name, text(r.EName), text(r.NameAlias), text(r.ShortSpell)].filter(Boolean),
56
+ context: [text(r.Country), text(r.Province)].filter(Boolean).join(' / ') || undefined, airport: Number(r.Type) === 3,
57
+ });
58
+ if (Array.isArray(r.Datas))
59
+ walk(r.Datas, depth + 1);
60
+ // Nearby cities must not be substituted for the requested destination.
61
+ }
62
+ };
63
+ walk(root.Data);
64
+ return records;
65
+ }
66
+ export function matchCtripCity(query, records) {
67
+ const input = normalized(query);
68
+ const matches = records.filter(r => r.code.toLowerCase() === input || r.aliases.some(a => normalized(a) === input));
69
+ const unique = [...new Map(matches.map(r => [`${r.code}:${r.context ?? ''}`, r])).values()];
70
+ // A city and its airport may share one code. An explicitly typed code remains exact.
71
+ const codes = new Set(unique.map(r => r.code));
72
+ if (codes.size === 1 && /^[a-z]{3}$/i.test(query.trim()))
73
+ return { ok: true, city: unique[0] };
74
+ if (unique.length === 1 && (!unique[0].requiresQualification || normalized(unique[0].context ?? '') === input))
75
+ return { ok: true, city: unique[0] };
76
+ return { ok: false, reason: unique.length ? 'ambiguous' : 'not-found', candidates: unique.slice(0, 8) };
77
+ }
78
+ async function readMetadata(url, init, fetcher) {
79
+ const response = await fetcher(url, { ...init, signal: AbortSignal.timeout(8_000), redirect: 'error' });
80
+ if (!response.ok || !response.body)
81
+ throw new Error('city metadata unavailable');
82
+ const reader = response.body.getReader(), chunks = [];
83
+ let size = 0;
84
+ try {
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done)
88
+ break;
89
+ size += value.byteLength;
90
+ if (size > 512_000)
91
+ throw new Error('city metadata bounds');
92
+ chunks.push(value);
93
+ }
94
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
95
+ }
96
+ finally {
97
+ await reader.cancel().catch(() => { });
98
+ }
99
+ }
100
+ let catalog;
101
+ const catalogBody = {
102
+ head: { cver: '3', cid: '', extension: [{ name: 'source', value: 'ONLINE' }, { name: 'sotpGroup', value: 'CTrip' }, { name: 'sotpLocale', value: 'zh-CN' }] },
103
+ locale: 'zh-CN', departureCity: '', dataType: 0,
104
+ };
105
+ export async function resolveCtripFlightCity(query, fetcher = fetch) {
106
+ if (!query.trim() || query.length > 120)
107
+ return { ok: false, reason: 'not-found', candidates: [] };
108
+ if (fetcher === fetch && process.env.GOTRY_SESSION_LIVE === '0')
109
+ return { ok: false, reason: 'unavailable', candidates: [] };
110
+ try {
111
+ const load = () => readMetadata(CTRIP_CITY_CATALOG_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(catalogBody) }, fetcher).then(parseCtripCityCatalog);
112
+ let records;
113
+ if (fetcher !== fetch)
114
+ records = await load();
115
+ else {
116
+ if (!catalog || Date.now() - catalog.at > 3_600_000)
117
+ catalog = { at: Date.now(), promise: load() };
118
+ try {
119
+ records = await catalog.promise;
120
+ }
121
+ catch {
122
+ catalog = undefined;
123
+ records = [];
124
+ }
125
+ }
126
+ const matched = matchCtripCity(query, records);
127
+ if (matched.ok || matched.reason === 'ambiguous')
128
+ return matched;
129
+ const url = new URL(CTRIP_CITY_SUGGEST_URL);
130
+ url.searchParams.set('key', query.trim().toUpperCase());
131
+ url.searchParams.set('filterAirport', 'false');
132
+ return matchCtripCity(query, parseCtripCitySuggestions(await readMetadata(url.href, {}, fetcher)));
133
+ }
134
+ catch {
135
+ return { ok: false, reason: 'unavailable', candidates: [] };
136
+ }
137
+ }
138
+ export function cityResolutionHint(query, result) {
139
+ if (result.reason === 'ambiguous')
140
+ return `城市「${query}」有多个候选,请确认城市或机场码:${result.candidates.map(c => `${c.name} ${c.code}${c.context ? `(${c.context})` : ''}`).join(';')}`;
141
+ return `unresolved 城市「${query}」:${result.reason === 'unavailable' ? '携程城市解析服务暂不可用' : '供应商城市候选未精确匹配'};请确认城市/机场名称,或提供 fromCityCode/toCityCode。城市解析失败不能证明该航线无库存或不受支持。`;
142
+ }
143
+ export function parseCtripHotelCities(value) {
144
+ const root = object(value), data = object(root.data), keywords = object(data.mainKeywordList).keywords;
145
+ if (object(root.ResponseStatus).Ack !== 'Success' || data.htlSpiderActionErrorCode || !Array.isArray(keywords) || keywords.length > 1_000)
146
+ throw new Error('hotel city metadata unavailable');
147
+ const records = [];
148
+ for (const item of keywords) {
149
+ const r = object(item), k = object(object(r.keyword).keywordContentInfo), region = object(object(r.controlInfo).regionInfo);
150
+ const display = object(region.displayCityModel), basic = object(region.basicCityModel), id = String(basic.cityId ?? '');
151
+ if (k.tripType !== 'CT' || !/^[1-9]\d{0,9}$/.test(id))
152
+ continue;
153
+ if (k.keywordCode !== undefined && String(k.keywordCode) !== id)
154
+ throw new Error('hotel city ID conflict');
155
+ const titles = Array.isArray(k.displayTexts) ? k.displayTexts.map(object).filter(t => t.key === 'MAIN_TITLE').map(t => text(t.value)) : [];
156
+ const name = text(display.cityName) || text(display.destinationName);
157
+ if (!name)
158
+ continue;
159
+ records.push({ id, name, aliases: [name, text(display.destinationName), text(display.destinationNameEN), ...titles].filter(Boolean),
160
+ countries: [text(display.countryName), text(display.countryNameEN)].filter(Boolean),
161
+ context: [text(display.countryName), text(display.provinceName)].filter(Boolean).join(' / ') });
162
+ }
163
+ return records;
164
+ }
165
+ export async function resolveCtripHotelCity(query, country, fetcher = fetch) {
166
+ if (!query.trim() || query.length > 120 || (country?.length ?? 0) > 120)
167
+ return { ok: false, reason: 'not-found', candidates: [] };
168
+ if (fetcher === fetch && process.env.GOTRY_SESSION_LIVE === '0')
169
+ return { ok: false, reason: 'unavailable', candidates: [] };
170
+ try {
171
+ const value = await readMetadata(CTRIP_HOTEL_CITY_URL, { method: 'POST', headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ head: { platform: 'PC', bu: 'HBU', group: 'ctrip', locale: 'zh-CN' }, queryInfo: { keyword: query.trim(), actionType: 'destination' } }) }, fetcher);
173
+ const input = normalized(query);
174
+ const candidates = parseCtripHotelCities(value).filter(c => (!country || c.countries.some(a => normalized(a) === normalized(country))) && c.aliases.some(a => normalized(a) === input));
175
+ const unique = [...new Map(candidates.map(c => [c.id, c])).values()];
176
+ return unique.length === 1 ? { ok: true, city: unique[0] } : { ok: false, reason: unique.length ? 'ambiguous' : 'not-found', candidates: unique.slice(0, 8) };
177
+ }
178
+ catch {
179
+ return { ok: false, reason: 'unavailable', candidates: [] };
180
+ }
181
+ }
182
+ export function hotelResolutionHint(query, result) {
183
+ if (result.reason === 'ambiguous')
184
+ return `城市「${query}」有多个候选,请确认国家或城市:${result.candidates.map(c => `${c.name}(${c.context},cityId=${c.id})`).join(';')}`;
185
+ return `城市「${query}」${result.reason === 'unavailable' ? '的携程酒店城市解析服务暂不可用' : '未精确匹配供应商城市候选'};可提供 country 消歧,或携程酒店 list 页 city= 数字作为 cityId。城市解析失败不能证明目的地不受支持。`;
186
+ }
@@ -19,12 +19,22 @@
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
  import { randomUUID } from 'node:crypto';
24
31
  import { existsSync } from 'node:fs';
25
32
  import { homedir } from 'node:os';
26
33
  import { join } from 'node:path';
27
34
  import { createServer } from 'node:http';
35
+ import { assertOrganizationId } from '../../src/tenant-state/ids.js';
36
+ /** 缺省组织 = 今天的单车道:不传组织的调用方(桌面 loopback 桥、既有测试)都落在这里 */
37
+ export const BRIDGE_DEFAULT_ORGANIZATION = 'local';
28
38
  /** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
29
39
  export const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795];
30
40
  export const BRIDGE_PROTOCOL = 'session-bridge.v1';
@@ -82,7 +92,10 @@ export const DEFAULT_EXTENSION_WAIT_MS = 6_000;
82
92
  export const MAX_RESULT_BODY_BYTES = 8 * 1024 * 1024;
83
93
  /** 扩展客户端标识形状(每 SW 生命周期一个 crypto.randomUUID);导出供事件上行端点(批次 B)同链校验 */
84
94
  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;
85
- function jobMatches(entry, capabilities, origin, clientId) {
95
+ function jobMatches(entry, capabilities, origin, clientId, organization) {
96
+ // 组织严格相等先于一切:站点/origin/clientId 全部吻合的另一个组织的取活者,也永远领不到这个作业
97
+ if (entry.organization !== organization)
98
+ return false;
86
99
  if (entry.preferredOrigin && entry.preferredOrigin !== origin)
87
100
  return false;
88
101
  if (entry.preferredClientId && entry.preferredClientId !== clientId)
@@ -121,6 +134,16 @@ const NAVIGATION_JOB_KINDS = new Set(['search', 'open-login']);
121
134
  export function isNavigationJobKind(kind) {
122
135
  return NAVIGATION_JOB_KINDS.has(kind);
123
136
  }
137
+ /** 组织 id 是否合法(assertOrganizationId 的布尔形式;非法组织一律在碰任何状态之前被拒) */
138
+ export function isBridgeOrganization(value) {
139
+ try {
140
+ assertOrganizationId(value);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
124
147
  export function createBridgeJobQueue(opts = {}) {
125
148
  const extensionOrigins = new Set(opts.extensionOrigins ?? EXTENSION_ORIGINS);
126
149
  const now = opts.now ?? Date.now;
@@ -128,9 +151,13 @@ export function createBridgeJobQueue(opts = {}) {
128
151
  const queue = [];
129
152
  const inFlight = new Map();
130
153
  const parked = [];
131
- let lastSeenAt = 0;
154
+ // 心跳时刻按组织分开记:一个组织的扩展在线,不代表另一个组织的扩展在线
155
+ const lastSeenAt = new Map();
132
156
  let closed = false;
133
- const extensionConnected = () => lastSeenAt > 0 && now() - lastSeenAt < EXTENSION_CONNECTED_WINDOW_MS;
157
+ const extensionConnected = (organization = BRIDGE_DEFAULT_ORGANIZATION) => {
158
+ const seen = lastSeenAt.get(assertOrganizationId(organization)) ?? 0;
159
+ return seen > 0 && now() - seen < EXTENSION_CONNECTED_WINDOW_MS;
160
+ };
134
161
  /**
135
162
  * 账本写兜底:持久面故障不得打断桥协议主路径(write-before-submit 的 INSERT 除外,
136
163
  * 它在 submit 里直接决定提交成败)。stderr 留痕,作业照常流转。
@@ -146,48 +173,69 @@ export function createBridgeJobQueue(opts = {}) {
146
173
  }
147
174
  };
148
175
  /** 领走首个 capability 匹配的 job(移入 inFlight——回包按 jobId 路由,两处都可找到) */
149
- function takeMatching(capabilities, origin, clientId) {
150
- const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId));
176
+ function takeMatching(capabilities, origin, clientId, organization) {
177
+ const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId, organization));
151
178
  if (idx < 0)
152
179
  return null;
153
180
  const [next] = queue.splice(idx, 1);
154
181
  next.claimedOrigin = origin;
155
182
  next.claimedClientId = clientId;
156
183
  inFlight.set(next.job.jobId, next);
157
- record('markClaimed', () => store.markClaimed(next.job.jobId, { origin, clientId }));
184
+ record('markClaimed', () => store.markClaimed(next.organization, next.job.jobId, { origin, clientId }));
158
185
  if (isNavigationJobKind(next.job.kind))
159
- record('noteSiteHit', () => store.noteSiteHit(next.job.site));
186
+ record('noteSiteHit', () => store.noteSiteHit(next.organization, next.job.site));
160
187
  return next;
161
188
  }
162
189
  /** 扩展回包:queued 与 inFlight 两处都可寻址(job 执行失败 = result.ok:false,桥自身不吞) */
163
- function resolveJob(jobId, parsed) {
190
+ function resolveJob(organization, jobId, parsed) {
164
191
  const inFlightHit = inFlight.get(jobId);
165
192
  if (inFlightHit) {
193
+ // 第二道防线:回包处理已把别的组织的 jobId 按「未知作业」挡在外面,这里再不认一次
194
+ if (inFlightHit.organization !== organization)
195
+ return;
166
196
  inFlight.delete(jobId);
167
197
  if (inFlightHit.timer)
168
198
  clearTimeout(inFlightHit.timer);
169
- record('markResolved', () => store.markResolved(jobId, parsed));
199
+ record('markResolved', () => store.markResolved(inFlightHit.organization, jobId, parsed));
170
200
  record('client-login-site', () => {
171
201
  const names = Array.isArray(parsed.names) ? parsed.names : [];
172
202
  if (parsed.ok === true && inFlightHit.job.kind === 'cookie-names' && names.length > 0 && inFlightHit.claimedClientId) {
173
- store.recordClientLoginSite(inFlightHit.claimedClientId, inFlightHit.job.site);
203
+ store.recordClientLoginSite(inFlightHit.organization, inFlightHit.claimedClientId, inFlightHit.job.site);
174
204
  }
175
205
  });
176
206
  inFlightHit.resolve({ ok: true, result: parsed, origin: inFlightHit.claimedOrigin, clientId: inFlightHit.claimedClientId });
177
207
  return;
178
208
  }
179
- const idx = queue.findIndex((q) => q.job.jobId === jobId);
209
+ const idx = queue.findIndex((q) => q.job.jobId === jobId && q.organization === organization);
180
210
  if (idx < 0)
181
211
  return;
182
212
  const [entry] = queue.splice(idx, 1);
183
213
  if (entry.timer)
184
214
  clearTimeout(entry.timer);
185
- record('markResolved', () => store.markResolved(jobId, parsed));
215
+ record('markResolved', () => store.markResolved(entry.organization, jobId, parsed));
186
216
  entry.resolve({ ok: true, result: parsed });
187
217
  }
218
+ /**
219
+ * 这个 jobId 属于另一个组织吗(内存里的排队/在飞条目,或账本里的行)?回包处理用它在**一切写入之前**
220
+ * 把别的组织的回包挡掉。读不了账本就证明不了「它不属于别的组织」,宁可当作属于(不落孤儿行)。
221
+ */
222
+ function belongsToAnotherOrganization(jobId, organization) {
223
+ const held = inFlight.get(jobId) ?? queue.find((q) => q.job.jobId === jobId);
224
+ if (held)
225
+ return held.organization !== organization;
226
+ if (!store)
227
+ return false;
228
+ try {
229
+ const owner = store.organizationOfJob(jobId);
230
+ return owner !== null && owner !== organization;
231
+ }
232
+ catch {
233
+ return true;
234
+ }
235
+ }
188
236
  /** 新 job 入队时即时派发给 capability 匹配的 parked 取活者(否则要白等一个 20s 长轮询周期) */
189
237
  function dispatchToParked(entry) {
190
- const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId));
238
+ const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId, p.organization));
191
239
  if (i < 0)
192
240
  return false;
193
241
  const [p] = parked.splice(i, 1);
@@ -195,20 +243,24 @@ export function createBridgeJobQueue(opts = {}) {
195
243
  entry.claimedOrigin = p.origin;
196
244
  entry.claimedClientId = p.clientId;
197
245
  inFlight.set(entry.job.jobId, entry);
198
- record('markClaimed', () => store.markClaimed(entry.job.jobId, { origin: p.origin, clientId: p.clientId }));
246
+ record('markClaimed', () => store.markClaimed(entry.organization, entry.job.jobId, { origin: p.origin, clientId: p.clientId }));
199
247
  if (isNavigationJobKind(entry.job.kind))
200
- record('noteSiteHit', () => store.noteSiteHit(entry.job.site));
248
+ record('noteSiteHit', () => store.noteSiteHit(entry.organization, entry.job.site));
201
249
  p.res.statusCode = 200;
202
250
  p.res.setHeader('content-type', 'application/json');
203
251
  p.res.end(JSON.stringify({ job: entry.job }));
204
252
  return true;
205
253
  }
206
- const stats = () => ({
207
- queued: queue.length,
208
- inFlight: inFlight.size,
209
- parked: parked.length,
210
- lastSeenMsAgo: lastSeenAt > 0 ? now() - lastSeenAt : null,
211
- });
254
+ const stats = (organization = BRIDGE_DEFAULT_ORGANIZATION) => {
255
+ const org = assertOrganizationId(organization);
256
+ const seen = lastSeenAt.get(org) ?? 0;
257
+ return {
258
+ queued: queue.filter((q) => q.organization === org).length,
259
+ inFlight: [...inFlight.values()].filter((q) => q.organization === org).length,
260
+ parked: parked.filter((p) => p.organization === org).length,
261
+ lastSeenMsAgo: seen > 0 ? now() - seen : null,
262
+ };
263
+ };
212
264
  /**
213
265
  * 结算器(submit 与 recoverOnBoot 复活条目共用):内存三态清理 + 账本终态落笔。
214
266
  * 幂等护栏(settled 标记)防 waitTimer 兜底在 resolved 之后二次结算把账本写花。
@@ -233,19 +285,19 @@ export function createBridgeJobQueue(opts = {}) {
233
285
  if (store && !outcome.ok) {
234
286
  if (outcome.reason === 'timeout') {
235
287
  if (wasClaimed) {
236
- record('markUnresolved', () => store.markUnresolved(entry.job.jobId, { reason: 'timeout' }));
288
+ record('markUnresolved', () => store.markUnresolved(entry.organization, entry.job.jobId, { reason: 'timeout' }));
237
289
  if (isNavigationJobKind(entry.job.kind))
238
- record('applyCooldown', () => store.applyCooldown(entry.job.site));
290
+ record('applyCooldown', () => store.applyCooldown(entry.organization, entry.job.site));
239
291
  }
240
292
  else {
241
- record('markVoid', () => store.markVoid(entry.job.jobId));
293
+ record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
242
294
  }
243
295
  }
244
296
  else if (outcome.reason === 'extension-not-connected') {
245
- record('markVoid', () => store.markVoid(entry.job.jobId));
297
+ record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
246
298
  }
247
299
  else if (!wasClaimed) {
248
- record('markVoid', () => store.markVoid(entry.job.jobId));
300
+ record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
249
301
  }
250
302
  }
251
303
  deliver?.(outcome);
@@ -272,6 +324,11 @@ export function createBridgeJobQueue(opts = {}) {
272
324
  return summary;
273
325
  }
274
326
  for (const row of pending) {
327
+ // 行里的组织必须是合法 id:被改坏的行还原不出所属组织,留在原地不派发(fail-closed),绝不猜成 local
328
+ if (!isBridgeOrganization(row.organization)) {
329
+ process.stderr.write(`[bridge] 账本恢复跳过组织非法的作业 ${row.id}\n`);
330
+ continue;
331
+ }
275
332
  const expired = row.timeout_at !== null && row.timeout_at <= now();
276
333
  let job = null;
277
334
  try {
@@ -284,6 +341,7 @@ export function createBridgeJobQueue(opts = {}) {
284
341
  if (!expired && job) {
285
342
  const entry = {
286
343
  job,
344
+ organization: row.organization,
287
345
  resolve: null,
288
346
  timer: null,
289
347
  claimedOrigin: row.claimed_origin ?? undefined,
@@ -297,9 +355,9 @@ export function createBridgeJobQueue(opts = {}) {
297
355
  summary.rehydratedInFlight++;
298
356
  }
299
357
  else {
300
- record('markUnresolved', () => store.markUnresolved(row.id, { reason: 'timeout' }));
358
+ record('markUnresolved', () => store.markUnresolved(row.organization, row.id, { reason: 'timeout' }));
301
359
  if (isNavigationJobKind(row.kind))
302
- record('applyCooldown', () => store.applyCooldown(row.site));
360
+ record('applyCooldown', () => store.applyCooldown(row.organization, row.site));
303
361
  summary.settledUnresolved++;
304
362
  }
305
363
  }
@@ -307,6 +365,7 @@ export function createBridgeJobQueue(opts = {}) {
307
365
  if (!expired && job) {
308
366
  const entry = {
309
367
  job,
368
+ organization: row.organization,
310
369
  resolve: null,
311
370
  timer: null,
312
371
  };
@@ -319,7 +378,7 @@ export function createBridgeJobQueue(opts = {}) {
319
378
  summary.requeued++;
320
379
  }
321
380
  else {
322
- record('markVoid', () => store.markVoid(row.id));
381
+ record('markVoid', () => store.markVoid(row.organization, row.id));
323
382
  summary.voidedStale++;
324
383
  }
325
384
  }
@@ -331,8 +390,19 @@ export function createBridgeJobQueue(opts = {}) {
331
390
  * 为挂载形态(查询式 results——gotry-backend 内核只支持精确路由,jobId 走 query)。
332
391
  * Origin 白名单校验内置于两种形态(远程形态同样强制:网页跨域请求必带邪恶 Origin)。
333
392
  */
334
- function makeHandler(prefix, resultsViaQuery, statusExtra) {
335
- return (req, res) => {
393
+ function makeHandler(prefix, resultsViaQuery, mounted, statusExtra) {
394
+ return (req, res, caller) => {
395
+ // 组织只来自挂载宿主在鉴权之后传入的 caller(loopback 桥恒为 local),**从不读请求体或请求头**;
396
+ // 非法组织先于一切 403,不碰任何状态
397
+ // 传了 caller 就必须带合法组织(缺字段/null 也算非法,绝不悄悄回落成 local);没传 caller = 单车道
398
+ const requested = mounted ? (caller === undefined ? BRIDGE_DEFAULT_ORGANIZATION : caller.organization) : BRIDGE_DEFAULT_ORGANIZATION;
399
+ if (!isBridgeOrganization(requested)) {
400
+ res.statusCode = 403;
401
+ res.setHeader('content-type', 'application/json');
402
+ res.end(JSON.stringify({ ok: false, error: 'organization-invalid' }));
403
+ return;
404
+ }
405
+ const organization = requested;
336
406
  if (closed) {
337
407
  res.statusCode = 503;
338
408
  res.end();
@@ -354,7 +424,7 @@ export function createBridgeJobQueue(opts = {}) {
354
424
  // 一切携带白名单扩展 Origin 的请求都刷新 lastSeen,否则 45s 后误判扩展掉线;
355
425
  // 无 Origin 的诊断 GET(/health /status)不记心跳,curl 探活不能伪造「扩展在线」
356
426
  if (originTrusted) {
357
- lastSeenAt = now();
427
+ lastSeenAt.set(organization, now());
358
428
  }
359
429
  const finish = (code, body) => {
360
430
  res.statusCode = code;
@@ -367,8 +437,8 @@ export function createBridgeJobQueue(opts = {}) {
367
437
  }
368
438
  if (req.method === 'GET' && path === '/status') {
369
439
  finish(200, {
370
- ok: true, protocol: BRIDGE_PROTOCOL, ...stats(),
371
- extensionConnected: extensionConnected(),
440
+ ok: true, protocol: BRIDGE_PROTOCOL, ...stats(organization),
441
+ extensionConnected: extensionConnected(organization),
372
442
  ...(statusExtra ? statusExtra() : {}),
373
443
  });
374
444
  return;
@@ -393,16 +463,16 @@ export function createBridgeJobQueue(opts = {}) {
393
463
  })();
394
464
  // 客户端注册落账(store 形态):轮询即刷新 origin/capabilities/last_seen(bridge_clients upsert)
395
465
  if (pollerInfo.clientId) {
396
- record('upsertClient', () => store.upsertClient(pollerInfo.clientId, origin, [...(pollerInfo.capabilities ?? [])]));
466
+ record('upsertClient', () => store.upsertClient(organization, pollerInfo.clientId, origin, [...(pollerInfo.capabilities ?? [])]));
397
467
  }
398
- const next = takeMatching(pollerInfo.capabilities, origin, pollerInfo.clientId);
468
+ const next = takeMatching(pollerInfo.capabilities, origin, pollerInfo.clientId, organization);
399
469
  if (next) {
400
470
  finish(200, { job: next.job });
401
471
  return;
402
472
  }
403
473
  // 长轮询:hold ≤ JOBS_LONG_POLL_MS(必须 < MV3 SW 30s 存活窗口,每次响应都续命);
404
474
  // 新 job 提交时即时唤醒 parked 取活者(见 dispatchToParked)
405
- const poller = { res, timer: null, capabilities: pollerInfo.capabilities, origin: origin, clientId: pollerInfo.clientId };
475
+ const poller = { res, timer: null, capabilities: pollerInfo.capabilities, origin: origin, clientId: pollerInfo.clientId, organization };
406
476
  const parkTimer = setTimeout(() => {
407
477
  const i = parked.findIndex((p) => p.res === res);
408
478
  if (i >= 0)
@@ -446,6 +516,13 @@ export function createBridgeJobQueue(opts = {}) {
446
516
  finish(400, { ok: false, error: '回包不是 JSON' });
447
517
  return;
448
518
  }
519
+ // 组织边界先于一切(含下面的 Origin/客户端校验):jobId 在内存或账本里属于另一个组织,
520
+ // 就答得与未知作业一模一样(404 unknown-job),但**什么都不写**——不落孤儿行、不动在飞作业
521
+ // (它自己的合法回包照常闭环),也不泄露「这个 id 存在」。
522
+ if (belongsToAnotherOrganization(jobId, organization)) {
523
+ finish(404, { ok: false, error: 'unknown-job' });
524
+ return;
525
+ }
449
526
  const assigned = inFlight.get(jobId);
450
527
  if (assigned?.claimedOrigin && assigned.claimedOrigin !== origin) {
451
528
  finish(403, { ok: false, error: '作业由另一扩展 Origin 领取' });
@@ -458,14 +535,14 @@ export function createBridgeJobQueue(opts = {}) {
458
535
  // 未知 jobId(批次 A):不再 200 静默丢弃——404 明示,孤儿回包内容落账
459
536
  // (status='unresolved',result_json 保留)供对账。扩展 postResult 本就有
460
537
  // catch 且不检查状态码(extension/background.js),协议兼容零改动。
461
- const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId);
538
+ const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId && q.organization === organization);
462
539
  if (!known) {
463
- record('orphan-result', () => store.recordOrphanResult(jobId, parsed));
540
+ record('orphan-result', () => store.recordOrphanResult(organization, jobId, parsed));
464
541
  finish(404, { ok: false, error: 'unknown-job' });
465
542
  return;
466
543
  }
467
544
  finish(200, { ok: true });
468
- resolveJob(jobId, parsed);
545
+ resolveJob(organization, jobId, parsed);
469
546
  });
470
547
  return;
471
548
  }
@@ -476,14 +553,31 @@ export function createBridgeJobQueue(opts = {}) {
476
553
  port: 0,
477
554
  extensionConnected,
478
555
  stats,
479
- handleRequest: makeHandler('', false),
480
- handleMountedRequest: makeHandler('/v1/session/bridge', true),
556
+ forOrganization(organization) {
557
+ const bound = assertOrganizationId(organization);
558
+ return {
559
+ get port() { return queueHandle.port; },
560
+ extensionConnected: () => extensionConnected(bound),
561
+ // 先展开调用方的选项、再盖上绑定的组织:下游传什么 organization 都改不了提交去向
562
+ submit: (job, submitOpts) => queueHandle.submit(job, { ...submitOpts, organization: bound }),
563
+ // 句柄只是视图,共享队列的生命周期归创建它的宿主
564
+ close: () => Promise.resolve(),
565
+ };
566
+ },
567
+ handleRequest: makeHandler('', false, false),
568
+ handleMountedRequest: makeHandler('/v1/session/bridge', true, true),
481
569
  ...(store ? { recoverOnBoot } : {}),
482
570
  submit(job, submitOpts = {}) {
483
571
  const extensionWaitMs = submitOpts.extensionWaitMs ?? DEFAULT_EXTENSION_WAIT_MS;
484
572
  const timeoutMs = submitOpts.timeoutMs ?? job.timeoutMs ?? 30_000;
485
573
  const full = { jobId: randomUUID(), ...job };
486
574
  return new Promise((resolve) => {
575
+ // 组织只能是合法 id:非法的在碰账本/队列之前就失败(fail-closed)
576
+ const organization = submitOpts.organization === undefined ? BRIDGE_DEFAULT_ORGANIZATION : submitOpts.organization;
577
+ if (!isBridgeOrganization(organization)) {
578
+ resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定的组织标识无效' });
579
+ return;
580
+ }
487
581
  if (closed) {
488
582
  resolve({ ok: false, reason: 'bridge-unavailable', summary: '扩展桥已关闭' });
489
583
  return;
@@ -500,20 +594,27 @@ export function createBridgeJobQueue(opts = {}) {
500
594
  // 绝不派发无账作业。桌面纯内存形态此步整体跳过,行为零变化。
501
595
  if (store) {
502
596
  try {
503
- store.insertQueuedJob(full, now() + timeoutMs);
597
+ store.insertQueuedJob(organization, full, now() + timeoutMs);
504
598
  }
505
599
  catch (e) {
506
600
  resolve({ ok: false, reason: 'bridge-unavailable', summary: `桥账本写入失败,作业不入队:${e instanceof Error ? e.message : String(e)}` });
507
601
  return;
508
602
  }
509
603
  if (isNavigationJobKind(full.kind))
510
- record('noteSiteHit', () => store.noteSiteHit(full.site));
604
+ record('noteSiteHit', () => store.noteSiteHit(organization, full.site));
511
605
  }
512
- const entry = { job: full, resolve: null, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId };
606
+ const entry = { job: full, organization, resolve: null, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId };
513
607
  const settle = armSettle(entry, resolve);
514
608
  entry.resolve = settle;
515
609
  entry.timer = setTimeout(() => {
516
- settle({ ok: false, reason: 'timeout', summary: `扩展未在 ${timeoutMs}ms 内回包(标签页无嗅探命中或扩展已停用)` });
610
+ const stage = entry.claimedOrigin === undefined ? 'queued' : 'claimed';
611
+ const task = full.kind === 'cookie-names' ? '登录态检查' : full.kind === 'search' ? '检索' : '登录页打开';
612
+ settle({
613
+ ok: false, reason: 'timeout', jobKind: full.kind, stage,
614
+ summary: stage === 'queued'
615
+ ? `扩展${task}等待领取超时(${timeoutMs}ms);尚未执行,请检查扩展是否支持当前站点或正在处理其他作业`
616
+ : `扩展${task}已领取但未在 ${timeoutMs}ms 内回传;请检查扩展运行错误与桥连接`,
617
+ });
517
618
  }, timeoutMs);
518
619
  if (!dispatchToParked(entry))
519
620
  queue.push(entry);
@@ -521,7 +622,7 @@ export function createBridgeJobQueue(opts = {}) {
521
622
  let waited = 0;
522
623
  const waitTimer = setInterval(() => {
523
624
  waited += 250;
524
- if (queueHandle.extensionConnected()) {
625
+ if (extensionConnected(organization)) {
525
626
  clearInterval(waitTimer);
526
627
  return;
527
628
  }