@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -11,9 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 观察器可注入(测试/未来多供应商扩展);缺省实现 = CDP 只读观察
|
|
13
13
|
* (openSession,ReadGuard 照常生效)。
|
|
14
|
+
*
|
|
15
|
+
* AaaS(docs/rfc/aaas-rfc.md §4.8,威胁 S-19)——缺省观察器读的是宿主机上**同一个** Chrome 和它里面**同一份**供应商登录,
|
|
16
|
+
* 所以它天生是单组织的:只有持有该登录的组织(hostBrowserOrganization,缺省 local)的调用方能用它。
|
|
17
|
+
* - callerOrganization(req):宿主在 bearer 之后解出调用方组织(null/非法/抛错 → 403 organization-unresolved);
|
|
18
|
+
* 组织从不取自请求体或请求头;缺省 = 单组织形态,所有调用方都是 local;
|
|
19
|
+
* - 缺省(宿主 Chrome)观察器遇到别的组织 → 403,不碰浏览器;声明了 callerOrganization 却没说宿主浏览器属于谁 →
|
|
20
|
+
* 构造即失败(多组织部署必须点名);
|
|
21
|
+
* - 注入的 observe 是组织感知的:收到解出的组织,自己负责把浏览器绑到该组织(云端多租户浏览器需要新 ADR,
|
|
22
|
+
* 见 RFC §4.8 的入场条件——供应商登录只在员工浏览器里,云端无法替租户持有)。
|
|
14
23
|
*/
|
|
15
24
|
import { mkdirSync } from 'node:fs';
|
|
16
25
|
import { openSession } from '../../../capabilities/session/transport.js';
|
|
26
|
+
import { BRIDGE_DEFAULT_ORGANIZATION, isBridgeOrganization } from '../../../capabilities/session/extension-bridge.js';
|
|
17
27
|
function sendJson(res, status, body) {
|
|
18
28
|
res.statusCode = status;
|
|
19
29
|
res.setHeader('content-type', 'application/json');
|
|
@@ -66,6 +76,15 @@ export async function defaultObserve(req, evidenceDir) {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
export function startBookingExecutorModule(options) {
|
|
79
|
+
if (options.hostBrowserOrganization !== undefined && !isBridgeOrganization(options.hostBrowserOrganization)) {
|
|
80
|
+
throw new Error('booking_executor_host_browser_organization_invalid');
|
|
81
|
+
}
|
|
82
|
+
// A deployment that tells us it serves several organizations must also say whose login the host browser holds: with the
|
|
83
|
+
// default observer and no answer, every organization would ride one organization's supplier login.
|
|
84
|
+
if (options.callerOrganization && !options.observe && options.hostBrowserOrganization === undefined) {
|
|
85
|
+
throw new Error('booking_executor_host_browser_organization_required');
|
|
86
|
+
}
|
|
87
|
+
const hostBrowserOrganization = options.hostBrowserOrganization ?? BRIDGE_DEFAULT_ORGANIZATION;
|
|
69
88
|
const authorized = (req, res) => {
|
|
70
89
|
const key = options.apiKey();
|
|
71
90
|
const auth = String(req.headers.authorization ?? '');
|
|
@@ -79,16 +98,39 @@ export function startBookingExecutorModule(options) {
|
|
|
79
98
|
}
|
|
80
99
|
return true;
|
|
81
100
|
};
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
const hostBrowserObserve = options.hostBrowserObserve ?? (async (req) => defaultObserve(req, '/tmp/booking-evidence'));
|
|
102
|
+
/** 解出本次请求的组织:无注入点 = local;解不出/非法 → 403 并返回 null(请求不碰任何观察器) */
|
|
103
|
+
const resolveOrganization = (req, res) => {
|
|
104
|
+
if (!options.callerOrganization)
|
|
105
|
+
return BRIDGE_DEFAULT_ORGANIZATION;
|
|
106
|
+
let resolved;
|
|
107
|
+
try {
|
|
108
|
+
resolved = options.callerOrganization(req);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
resolved = null;
|
|
112
|
+
}
|
|
113
|
+
if (resolved === null || !isBridgeOrganization(resolved)) {
|
|
114
|
+
sendJson(res, 403, { ok: false, error: 'organization-unresolved' });
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return resolved;
|
|
118
|
+
};
|
|
119
|
+
async function handleObserve(req, res, organization) {
|
|
120
|
+
let decoded;
|
|
85
121
|
try {
|
|
86
|
-
|
|
122
|
+
decoded = JSON.parse(await readBody(req));
|
|
87
123
|
}
|
|
88
124
|
catch {
|
|
89
125
|
sendJson(res, 400, { ok: false, error: 'body 不是 JSON' });
|
|
90
126
|
return;
|
|
91
127
|
}
|
|
128
|
+
// `null`, a number, a string or an array is JSON too; reading a field from one would throw outside any handler.
|
|
129
|
+
if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'body 不是 JSON 对象' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const parsed = decoded;
|
|
92
134
|
const supplier = (parsed.supplier ?? '').trim();
|
|
93
135
|
if (supplier !== 'dida-portal') {
|
|
94
136
|
sendJson(res, 400, { ok: false, error: `未知供应商通道 ${supplier || '(空)'}` });
|
|
@@ -99,8 +141,18 @@ export function startBookingExecutorModule(options) {
|
|
|
99
141
|
sendJson(res, 400, { ok: false, error: 'entry URL 必须落在 https://portal.dida.com/ 域内' });
|
|
100
142
|
return;
|
|
101
143
|
}
|
|
144
|
+
// The request handed to an observer is built from the validated fields and the host-resolved organization only:
|
|
145
|
+
// whatever organization the body names (`parsed.organization`) is never read.
|
|
146
|
+
// In the single-organization form the request has the shape it always had; the organization is added only where a host
|
|
147
|
+
// resolves one.
|
|
148
|
+
const observation = options.callerOrganization ? { supplier, entryUrl, organization } : { supplier, entryUrl };
|
|
149
|
+
if (!options.observe && organization !== hostBrowserOrganization) {
|
|
150
|
+
// The host browser holds one organization's supplier login; another organization's caller must not ride it.
|
|
151
|
+
sendJson(res, 403, { ok: false, error: 'executor-organization-mismatch' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
102
154
|
try {
|
|
103
|
-
const result = await observe
|
|
155
|
+
const result = await (options.observe ?? hostBrowserObserve)(observation);
|
|
104
156
|
sendJson(res, 200, result);
|
|
105
157
|
}
|
|
106
158
|
catch (e) {
|
|
@@ -116,7 +168,10 @@ export function startBookingExecutorModule(options) {
|
|
|
116
168
|
handle: (req, res) => {
|
|
117
169
|
if (!authorized(req, res))
|
|
118
170
|
return;
|
|
119
|
-
|
|
171
|
+
const organization = resolveOrganization(req, res);
|
|
172
|
+
if (organization === null)
|
|
173
|
+
return;
|
|
174
|
+
void handleObserve(req, res, organization);
|
|
120
175
|
},
|
|
121
176
|
},
|
|
122
177
|
],
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* POST /v1/session/bridge/events → 扩展 workspace 事件批量上行(批次 B,2026-10-01;
|
|
22
22
|
* body ≤256KB,逐条落 bridge_events,响应 {accepted,rejected})
|
|
23
23
|
*
|
|
24
|
+
* AaaS A2(#674,S-5)组织维度(两个可选注入点,缺省 = 今天的单车道 `local`,行为逐字不变):
|
|
25
|
+
* - callerOrganization(req):宿主在 bearer 之后解出本次请求的组织(null/非法 → 403
|
|
26
|
+
* organization-unresolved);桥端点、事件落账、节律读判定、检索/登录提交都按它走,
|
|
27
|
+
* 提交经 queue.forOrganization(org),下游代码没有办法把作业提交去别的组织;
|
|
28
|
+
* - admission(req, organization):配额闸(每组织/每租户的桥作业速率由部署组合接入,A3),
|
|
29
|
+
* 拒绝 → 429 + retry-after,release 在 finally 里执行。
|
|
30
|
+
*
|
|
24
31
|
* 红线继承:登录在供应商官网由人完成;challenged 即停;节律闸在 sessionDidaSearch
|
|
25
32
|
* 内;本模块再加单飞锁(同供应商串行,防并发打站点)。桥端点在 bearer 之上再强制
|
|
26
33
|
* Origin ∈ 扩展白名单(网页跨域请求必带邪恶 Origin,双保险)。
|
|
@@ -31,9 +38,9 @@
|
|
|
31
38
|
* latencyMs/error 摘要,脱敏)。与桥账本(bridge_jobs,PR #601)互补——日志管
|
|
32
39
|
* 「看」,账本管「恢复」,verdict 不写 bridge_jobs。
|
|
33
40
|
*/
|
|
34
|
-
import { sessionDidaSearch } from '../../../capabilities/session-search.js';
|
|
41
|
+
import { resolveTransportMode, sessionDidaSearch } from '../../../capabilities/session-search.js';
|
|
35
42
|
import { DIDA_LOGIN_COOKIE_NAMES, DIDA_SITE_DOMAIN } from '../../../capabilities/session/adapters/dida-portal.js';
|
|
36
|
-
import { CLIENT_ID_RE, createBridgeJobQueue, EXTENSION_ORIGINS } from '../../../capabilities/session/extension-bridge.js';
|
|
43
|
+
import { BRIDGE_DEFAULT_ORGANIZATION, CLIENT_ID_RE, createBridgeJobQueue, EXTENSION_ORIGINS, isBridgeOrganization } from '../../../capabilities/session/extension-bridge.js';
|
|
37
44
|
import { openBridgeLedgerStore } from '../../../capabilities/session/bridge-ledger.js';
|
|
38
45
|
import { extensionCookieNames, extensionOpenLogin, classifyBridgeFailure } from '../../../capabilities/session/extension-channel.js';
|
|
39
46
|
/** 事件上行端点路径(批次 B;导出供 extension-tests §38 与扩展侧 background.js 防漂移对账) */
|
|
@@ -258,7 +265,26 @@ async function withLock(key, fn) {
|
|
|
258
265
|
locks.delete(key);
|
|
259
266
|
}
|
|
260
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* 桥面扩展 Origin 白名单的运维覆盖:unpacked 安装的扩展 ID 随装载绝对路径变化
|
|
270
|
+
* (SHA256(path) 前 32 位映射 a-p),把员工本机的安装挡在 403(hotel-be#32143 验收
|
|
271
|
+
* 实测;hotel-be#34173 UAT 复验发现 10-01 cherry-pick 未进 main,10-17 例行发版回退)。
|
|
272
|
+
* 默认仍收 unpacked dev + 商店双通道;env 以逗号追加合法 origin。
|
|
273
|
+
*/
|
|
274
|
+
function bridgeExtensionOrigins() {
|
|
275
|
+
const raw = process.env.GOTRY_SESSION_BRIDGE_EXTENSION_ORIGINS;
|
|
276
|
+
if (!raw)
|
|
277
|
+
return undefined;
|
|
278
|
+
const extra = [...new Set(raw.split(',')
|
|
279
|
+
.map((s) => s.trim())
|
|
280
|
+
.filter((s) => /^chrome-extension:\/\/[a-p]{32}$/.test(s)))];
|
|
281
|
+
return extra.length ? [...EXTENSION_ORIGINS, ...extra] : undefined;
|
|
282
|
+
}
|
|
261
283
|
export function startSessionSearchModule(options) {
|
|
284
|
+
// AaaS A2: the cdp transport drives the host's own Chrome whoever asks, so a second organization could ride it. The
|
|
285
|
+
// organization-scoped form therefore runs on the extension bridge only (the single lane keeps the cdp opt-in).
|
|
286
|
+
if (options.callerOrganization && resolveTransportMode() !== 'extension')
|
|
287
|
+
throw new Error('session_search_organization_requires_extension_transport');
|
|
262
288
|
const search = options.search ?? sessionDidaSearch;
|
|
263
289
|
// verdict 结构化日志通道(#272):缺省 stderr(服务运行时既见面),测试注入收集器
|
|
264
290
|
const verdictEmit = options.verdictLog ?? ((line) => { process.stderr.write(`${line}\n`); });
|
|
@@ -283,10 +309,10 @@ export function startSessionSearchModule(options) {
|
|
|
283
309
|
// 账本开不了不打断服务:退回纯内存(重启丢在飞的旧缺陷面),stderr 留痕
|
|
284
310
|
process.stderr.write(`[session-search] 桥账本打开失败,退回纯内存队列: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
285
311
|
}
|
|
286
|
-
queue = createBridgeJobQueue(ledger ? { store: ledger } : {});
|
|
312
|
+
queue = createBridgeJobQueue(ledger ? { store: ledger, extensionOrigins: bridgeExtensionOrigins() } : { extensionOrigins: bridgeExtensionOrigins() });
|
|
287
313
|
}
|
|
288
314
|
else {
|
|
289
|
-
queue = createBridgeJobQueue();
|
|
315
|
+
queue = createBridgeJobQueue({ extensionOrigins: bridgeExtensionOrigins() });
|
|
290
316
|
}
|
|
291
317
|
// 开机恢复(gotry-backend 会话模块启动路径):queued→重排;claimed 已超时→
|
|
292
318
|
// unresolved+节律记账;claimed 未超时→复活 inFlight(重启不再丢在飞作业)。
|
|
@@ -307,6 +333,66 @@ export function startSessionSearchModule(options) {
|
|
|
307
333
|
}
|
|
308
334
|
return true;
|
|
309
335
|
};
|
|
336
|
+
/** 解出本次请求的组织:无注入点 = 单车道 local;解不出/非法 → 403 并返回 null(请求不进桥) */
|
|
337
|
+
const resolveOrganization = (req, res) => {
|
|
338
|
+
if (!options.callerOrganization)
|
|
339
|
+
return BRIDGE_DEFAULT_ORGANIZATION;
|
|
340
|
+
let resolved;
|
|
341
|
+
try {
|
|
342
|
+
resolved = options.callerOrganization(req);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
resolved = null;
|
|
346
|
+
}
|
|
347
|
+
if (resolved === null || !isBridgeOrganization(resolved)) {
|
|
348
|
+
sendJson(res, 403, { ok: false, error: 'organization-unresolved' });
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return resolved;
|
|
352
|
+
};
|
|
353
|
+
/** bearer → 组织 → 业务:每个路由先后过这两道,组织只解一次 */
|
|
354
|
+
const withOrganization = (req, res, run) => {
|
|
355
|
+
if (!authorized(req, res))
|
|
356
|
+
return;
|
|
357
|
+
const organization = resolveOrganization(req, res);
|
|
358
|
+
if (organization === null)
|
|
359
|
+
return;
|
|
360
|
+
run(organization);
|
|
361
|
+
};
|
|
362
|
+
// 单车道(无注入点)下沿用今天的调用形状:队列本体 + 无参 stats/extensionConnected(既有假队列也只实现这些)
|
|
363
|
+
const scoped = options.callerOrganization !== undefined;
|
|
364
|
+
const bridgeFor = (organization) => (scoped ? queue.forOrganization(organization) : queue);
|
|
365
|
+
const connectedOf = (organization) => (scoped ? queue.extensionConnected(organization) : queue.extensionConnected());
|
|
366
|
+
const statsOf = (organization) => (scoped ? queue.stats(organization) : queue.stats());
|
|
367
|
+
const bridgeCaller = (organization) => (scoped ? { organization } : undefined);
|
|
368
|
+
/** 配额闸:放行返回 release 钩子(可无);拒绝已写好 429/503 响应,返回 null */
|
|
369
|
+
const admit = (req, res, organization) => {
|
|
370
|
+
if (!options.admission)
|
|
371
|
+
return {};
|
|
372
|
+
let decision;
|
|
373
|
+
try {
|
|
374
|
+
decision = options.admission(req, organization);
|
|
375
|
+
// a return that is not a decision is an admission that could not be made: the same answer as a throw, never a crash
|
|
376
|
+
if (typeof decision !== 'object' || decision === null || typeof decision.ok !== 'boolean')
|
|
377
|
+
throw new Error('admission_decision_malformed');
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
sendJson(res, 503, { ok: false, error: 'admission-unavailable' });
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
if (decision.ok)
|
|
384
|
+
return typeof decision.release === 'function' ? { release: decision.release } : {};
|
|
385
|
+
const retryAfter = Number.isFinite(decision.retryAfterSeconds) ? Math.max(1, Math.ceil(decision.retryAfterSeconds)) : 1;
|
|
386
|
+
res.setHeader('retry-after', String(retryAfter));
|
|
387
|
+
sendJson(res, 429, { ok: false, verdict: 'quota_exceeded', error: typeof decision.code === 'string' && decision.code.length > 0 ? decision.code : 'quota_exceeded' });
|
|
388
|
+
return null;
|
|
389
|
+
};
|
|
390
|
+
const releaseAdmission = (admitted) => {
|
|
391
|
+
try {
|
|
392
|
+
admitted.release?.();
|
|
393
|
+
}
|
|
394
|
+
catch { /* 释放额度的失败不反噬已写出的响应 */ }
|
|
395
|
+
};
|
|
310
396
|
/**
|
|
311
397
|
* 扩展 workspace 事件批量上行(批次 B,2026-10-01):bearer(路由前置 authorized)之上
|
|
312
398
|
* 再强制 Origin ∈ 扩展白名单——与 /jobs 同链(同一 EXTENSION_ORIGINS 常量,网页跨域
|
|
@@ -318,7 +404,7 @@ export function startSessionSearchModule(options) {
|
|
|
318
404
|
* fail-closed 503:事件无处落账,不假成功。
|
|
319
405
|
*/
|
|
320
406
|
const extensionOrigins = new Set(EXTENSION_ORIGINS);
|
|
321
|
-
async function handleBridgeEvents(req, res) {
|
|
407
|
+
async function handleBridgeEvents(req, res, organization) {
|
|
322
408
|
const origin = req.headers.origin;
|
|
323
409
|
if (typeof origin !== 'string' || !extensionOrigins.has(origin)) {
|
|
324
410
|
sendJson(res, 403, { ok: false, error: 'origin 不在桥白名单' });
|
|
@@ -366,7 +452,7 @@ export function startSessionSearchModule(options) {
|
|
|
366
452
|
continue;
|
|
367
453
|
}
|
|
368
454
|
try {
|
|
369
|
-
if (ledger.insertEvent(input))
|
|
455
|
+
if (ledger.insertEvent(organization, input))
|
|
370
456
|
accepted += 1;
|
|
371
457
|
else
|
|
372
458
|
rejected += 1;
|
|
@@ -378,36 +464,36 @@ export function startSessionSearchModule(options) {
|
|
|
378
464
|
}
|
|
379
465
|
sendJson(res, 200, { ok: true, accepted, rejected });
|
|
380
466
|
}
|
|
381
|
-
async function handleStatus(res) {
|
|
467
|
+
async function handleStatus(res, organization) {
|
|
382
468
|
const startedAt = Date.now();
|
|
383
469
|
const checkedAt = new Date().toISOString();
|
|
384
|
-
if (!
|
|
470
|
+
if (!connectedOf(organization)) {
|
|
385
471
|
sendJson(res, 200, {
|
|
386
472
|
ok: true,
|
|
387
473
|
suppliers: [{ supplier: 'dida-portal', loggedIn: false, reason: 'extension-not-connected', detail: '会话执行环境=管理员浏览器扩展(Stai Travel Bridge);扩展未连接桥——在管理员浏览器安装扩展并配置本服务桥地址' }],
|
|
388
|
-
bridge:
|
|
474
|
+
bridge: statsOf(organization),
|
|
389
475
|
checkedAt,
|
|
390
476
|
});
|
|
391
477
|
verdictLog({ surface: 'status', supplier: 'dida-portal', verdict: 'needs-extension', latencyMs: Date.now() - startedAt });
|
|
392
478
|
return;
|
|
393
479
|
}
|
|
394
|
-
const login = await extensionCookieNames({ site: 'dida-portal', domain: DIDA_SITE_DOMAIN, ticketNames: DIDA_LOGIN_COOKIE_NAMES, timeoutMs: 20_000 },
|
|
480
|
+
const login = await extensionCookieNames({ site: 'dida-portal', domain: DIDA_SITE_DOMAIN, ticketNames: DIDA_LOGIN_COOKIE_NAMES, timeoutMs: 20_000 }, bridgeFor(organization));
|
|
395
481
|
if (!login.ok) {
|
|
396
482
|
const verdict = classifyBridgeFailure(login.kind);
|
|
397
|
-
sendJson(res, 200, { ok: true, suppliers: [{ supplier: 'dida-portal', loggedIn: false, reason: verdict, detail: login.summary }], bridge:
|
|
483
|
+
sendJson(res, 200, { ok: true, suppliers: [{ supplier: 'dida-portal', loggedIn: false, reason: verdict, detail: login.summary }], bridge: statsOf(organization), checkedAt });
|
|
398
484
|
verdictLog({ surface: 'status', supplier: 'dida-portal', verdict, latencyMs: Date.now() - startedAt, error: login.summary });
|
|
399
485
|
return;
|
|
400
486
|
}
|
|
401
487
|
sendJson(res, 200, {
|
|
402
488
|
ok: true,
|
|
403
489
|
suppliers: [{ supplier: 'dida-portal', loggedIn: login.tickets.length > 0, tickets: login.tickets, ...(login.tickets.length === 0 ? { reason: 'needs-login' } : {}) }],
|
|
404
|
-
bridge:
|
|
490
|
+
bridge: statsOf(organization),
|
|
405
491
|
checkedAt,
|
|
406
492
|
});
|
|
407
493
|
// tickets 只有 cookie 名级(值零过手),verdict 行也只落登录态结论本身
|
|
408
494
|
verdictLog({ surface: 'status', supplier: 'dida-portal', verdict: login.tickets.length > 0 ? 'logged-in' : 'needs-login', latencyMs: Date.now() - startedAt });
|
|
409
495
|
}
|
|
410
|
-
async function handleSearch(req, res) {
|
|
496
|
+
async function handleSearch(req, res, organization) {
|
|
411
497
|
const startedAt = Date.now();
|
|
412
498
|
const parsed = await readAndParseObjectBody(req);
|
|
413
499
|
if ('code' in parsed) {
|
|
@@ -431,7 +517,7 @@ export function startSessionSearchModule(options) {
|
|
|
431
517
|
}
|
|
432
518
|
// 挂载路径节律读判定(批次 A):账本口径跨重启(超时结算/上一次提交都在库里),
|
|
433
519
|
// 与 sessionDidaSearch 的进程内节律闸双闸取严——内存闸挡本进程,账本闸挡重启前打过的站点。
|
|
434
|
-
const pacing = ledger?.pacingOf(supplier);
|
|
520
|
+
const pacing = ledger?.pacingOf(organization, supplier);
|
|
435
521
|
if (pacing && Date.now() < pacing.cooldownUntil) {
|
|
436
522
|
const retryAfterSec = Math.max(1, Math.ceil((pacing.cooldownUntil - Date.now()) / 1_000));
|
|
437
523
|
res.setHeader('retry-after', String(retryAfterSec));
|
|
@@ -443,9 +529,15 @@ export function startSessionSearchModule(options) {
|
|
|
443
529
|
verdictLog({ surface: 'search', supplier, verdict: 'cooldown', latencyMs: Date.now() - startedAt, error: `bridge ledger cooldown retry-after ${retryAfterSec}s` });
|
|
444
530
|
return;
|
|
445
531
|
}
|
|
532
|
+
// 配额闸:只有真要动桥的请求才占额度——上面的形状校验与账本冷却已拦下的不算
|
|
533
|
+
const admitted = admit(req, res, organization);
|
|
534
|
+
if (admitted === null)
|
|
535
|
+
return;
|
|
446
536
|
try {
|
|
447
537
|
const q = queryCheck.query;
|
|
448
|
-
|
|
538
|
+
// 单飞锁按 组织+站点:一个组织的慢检索不排队另一个组织(单车道 local 的锁键仍是 supplier 本身)
|
|
539
|
+
const lockKey = organization === BRIDGE_DEFAULT_ORGANIZATION ? supplier : `${organization}/${supplier}`;
|
|
540
|
+
const result = await withLock(lockKey, () => search({
|
|
449
541
|
entryUrl: q?.entryUrl,
|
|
450
542
|
timeoutMs: q?.timeoutMs,
|
|
451
543
|
// 查询态(城市+日期)交给能力层 → 扩展驱动门户目的地搜索
|
|
@@ -453,7 +545,9 @@ export function startSessionSearchModule(options) {
|
|
|
453
545
|
? { query: { city: q.city, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults, children: q.children } }
|
|
454
546
|
: {}),
|
|
455
547
|
auditPath: options.auditPath,
|
|
456
|
-
bridge:
|
|
548
|
+
bridge: bridgeFor(organization),
|
|
549
|
+
// the in-process pacing gate is keyed like the lock: one organization's searches never pace another's
|
|
550
|
+
pacingKey: lockKey,
|
|
457
551
|
}));
|
|
458
552
|
if (result.verdict === 'cooldown') {
|
|
459
553
|
res.setHeader('retry-after', '30');
|
|
@@ -489,8 +583,11 @@ export function startSessionSearchModule(options) {
|
|
|
489
583
|
sendJson(res, 500, { ok: false, error: `会话检索异常: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}` });
|
|
490
584
|
verdictLog({ surface: 'search', supplier, verdict: 'error', latencyMs: Date.now() - startedAt, error: e instanceof Error ? e.message : String(e) });
|
|
491
585
|
}
|
|
586
|
+
finally {
|
|
587
|
+
releaseAdmission(admitted);
|
|
588
|
+
}
|
|
492
589
|
}
|
|
493
|
-
async function handleLoginOpen(req, res) {
|
|
590
|
+
async function handleLoginOpen(req, res, organization) {
|
|
494
591
|
const parsed = await readAndParseObjectBody(req);
|
|
495
592
|
if ('code' in parsed) {
|
|
496
593
|
sendJson(res, 400, { ok: false, error: validationMessage(parsed.code) });
|
|
@@ -516,35 +613,35 @@ export function startSessionSearchModule(options) {
|
|
|
516
613
|
sendJson(res, 400, { ok: false, error: '登录入口必须落在 https://portal.dida.com/ 域内(fail-closed)' });
|
|
517
614
|
return;
|
|
518
615
|
}
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
521
|
-
const verdict = classifyBridgeFailure(outcome.kind);
|
|
522
|
-
sendJson(res, verdict === 'needs-extension' ? 503 : 502, { ok: false, verdict, error: outcome.summary });
|
|
616
|
+
const admitted = admit(req, res, organization);
|
|
617
|
+
if (admitted === null)
|
|
523
618
|
return;
|
|
619
|
+
try {
|
|
620
|
+
const outcome = await extensionOpenLogin({ site: supplier, url, timeoutMs: 20_000 }, bridgeFor(organization));
|
|
621
|
+
if (!outcome.ok) {
|
|
622
|
+
const verdict = classifyBridgeFailure(outcome.kind);
|
|
623
|
+
sendJson(res, verdict === 'needs-extension' ? 503 : 502, { ok: false, verdict, error: outcome.summary });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
sendJson(res, 200, { ok: true, opened: true, url, note: '登录页已在会话所属浏览器置前台打开(验证码人过);gotry 永不经手密码/cookie 值' });
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
releaseAdmission(admitted);
|
|
524
630
|
}
|
|
525
|
-
sendJson(res, 200, { ok: true, opened: true, url, note: '登录页已在会话所属浏览器置前台打开(验证码人过);gotry 永不经手密码/cookie 值' });
|
|
526
631
|
}
|
|
527
632
|
return {
|
|
528
633
|
name: 'session-search',
|
|
529
634
|
routes: [
|
|
530
|
-
{ method: 'GET', path: '/v1/session/status', handle: (req, res) => {
|
|
531
|
-
|
|
532
|
-
{ method: 'POST', path: '/v1/session/
|
|
533
|
-
|
|
534
|
-
{ method: '
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
{ method: '
|
|
538
|
-
queue.handleMountedRequest(req, res); } },
|
|
539
|
-
{ method: 'GET', path: '/v1/session/bridge/status', handle: (req, res) => { if (authorized(req, res))
|
|
540
|
-
queue.handleMountedRequest(req, res); } },
|
|
541
|
-
{ method: 'POST', path: '/v1/session/bridge/jobs', handle: (req, res) => { if (authorized(req, res))
|
|
542
|
-
queue.handleMountedRequest(req, res); } },
|
|
543
|
-
{ method: 'POST', path: '/v1/session/bridge/results', handle: (req, res) => { if (authorized(req, res))
|
|
544
|
-
queue.handleMountedRequest(req, res); } },
|
|
635
|
+
{ method: 'GET', path: '/v1/session/status', handle: (req, res) => withOrganization(req, res, (organization) => { void handleStatus(res, organization); }) },
|
|
636
|
+
{ method: 'POST', path: '/v1/session/search', handle: (req, res) => withOrganization(req, res, (organization) => { void handleSearch(req, res, organization); }) },
|
|
637
|
+
{ method: 'POST', path: '/v1/session/login/open', handle: (req, res) => withOrganization(req, res, (organization) => { void handleLoginOpen(req, res, organization); }) },
|
|
638
|
+
// 桥协议端点:远程扩展经 bearer 鉴权连入;Origin 白名单在队列处理器内再校验一道;组织由宿主解出后传入队列
|
|
639
|
+
{ method: 'GET', path: '/v1/session/bridge/health', handle: (req, res) => withOrganization(req, res, (organization) => queue.handleMountedRequest(req, res, bridgeCaller(organization))) },
|
|
640
|
+
{ method: 'GET', path: '/v1/session/bridge/status', handle: (req, res) => withOrganization(req, res, (organization) => queue.handleMountedRequest(req, res, bridgeCaller(organization))) },
|
|
641
|
+
{ method: 'POST', path: '/v1/session/bridge/jobs', handle: (req, res) => withOrganization(req, res, (organization) => queue.handleMountedRequest(req, res, bridgeCaller(organization))) },
|
|
642
|
+
{ method: 'POST', path: '/v1/session/bridge/results', handle: (req, res) => withOrganization(req, res, (organization) => queue.handleMountedRequest(req, res, bridgeCaller(organization))) },
|
|
545
643
|
// 事件上行(批次 B):bearer + Origin 白名单双校验与 /jobs 同链,处理器内落 bridge_events
|
|
546
|
-
{ method: 'POST', path: BRIDGE_EVENTS_PATH, handle: (req, res) => {
|
|
547
|
-
void handleBridgeEvents(req, res); } },
|
|
644
|
+
{ method: 'POST', path: BRIDGE_EVENTS_PATH, handle: (req, res) => withOrganization(req, res, (organization) => { void handleBridgeEvents(req, res, organization); }) },
|
|
548
645
|
],
|
|
549
646
|
close: () => queue.close().then(() => { ledger?.close(); }),
|
|
550
647
|
};
|