@harrylabsj/kiwi-merchant-cloud 0.12.0 → 0.12.1
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/app/cloud/bootstrap.d.ts +2 -0
- package/app/cloud/bootstrap.js +92 -10
- package/app/cloud/bootstrap.js.map +1 -1
- package/app/cloud/connect-service.d.ts +144 -0
- package/app/cloud/connect-service.js +776 -0
- package/app/cloud/connect-service.js.map +1 -0
- package/app/cloud/file-listing-publication.d.ts +105 -0
- package/app/cloud/file-listing-publication.js +436 -0
- package/app/cloud/file-listing-publication.js.map +1 -0
- package/app/cloud/merchant-connect.d.ts +19 -13
- package/app/cloud/merchant-connect.js +43 -284
- package/app/cloud/merchant-connect.js.map +1 -1
- package/app/cloud-sample/products.json +3 -3
- package/app/http/merchant-management/api.d.ts +21 -1
- package/app/http/merchant-management/api.js +136 -1
- package/app/http/merchant-management/api.js.map +1 -1
- package/app/http/merchant-management/page.d.ts +4 -1
- package/app/http/merchant-management/page.js +113 -7
- package/app/http/merchant-management/page.js.map +1 -1
- package/build-manifest.json +98 -68
- package/node_modules/@hono/node-server/dist/early-hints.cjs +1 -1
- package/node_modules/@hono/node-server/dist/early-hints.mjs +1 -1
- package/node_modules/@hono/node-server/dist/index.cjs +41 -19
- package/node_modules/@hono/node-server/dist/index.mjs +42 -20
- package/node_modules/@hono/node-server/dist/serve-static.cjs +1 -0
- package/node_modules/@hono/node-server/dist/serve-static.d.cts +2 -1
- package/node_modules/@hono/node-server/dist/serve-static.d.mts +2 -1
- package/node_modules/@hono/node-server/dist/serve-static.mjs +1 -0
- package/node_modules/@hono/node-server/package.json +3 -2
- package/node_modules/hono/dist/cjs/adapter/bun/websocket.js +1 -1
- package/node_modules/hono/dist/cjs/middleware/language/language.js +1 -1
- package/node_modules/hono/dist/cjs/middleware/serve-static/index.js +1 -0
- package/node_modules/hono/dist/middleware/language/language.js +1 -1
- package/node_modules/hono/dist/middleware/serve-static/index.js +1 -0
- package/node_modules/hono/package.json +1 -1
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +2 -2
- package/runtime-package.json +2 -2
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 harrylabsj
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Catalog 连接服务内核(A2 直连 device enrollment)。
|
|
18
|
+
*
|
|
19
|
+
* 把 `merchant-connect.ts` 里既有的签名 device enrollment 协议
|
|
20
|
+
* (create → poll → bind → publish → activate,CatalogClient 承载)抽成
|
|
21
|
+
* 可供工作台/云端 bootstrap 调用的服务,CLI 变为本服务之上的薄适配。
|
|
22
|
+
* **协议步骤只有这一套**——本文件是从 CLI 实现原样搬移的状态机,不是重写。
|
|
23
|
+
*
|
|
24
|
+
* 与 CLI 的差异只在"驱动方式":
|
|
25
|
+
*
|
|
26
|
+
* - `begin()` 是**唯一**允许创建 enrollment 的入口(幂等:同 origin/公钥/
|
|
27
|
+
* Catalog/冻结卡直接复用),且永远不会被 GET 触发;
|
|
28
|
+
* - `reconcile()` 是单趟短执行(至多一次 poll,随后级联 authorized→bound→
|
|
29
|
+
* published 中已就绪的格),尊重 interval/slow_down 节流,不创建新会话、
|
|
30
|
+
* 不轮询已过期授权、**不输出配对码/授权链接/grant**;
|
|
31
|
+
* - 状态经 `getSummary()`(助手安全层:状态枚举/published/agentId/bindingId/
|
|
32
|
+
* bindingExpiresAt/可用性错误码,永无 user_code/device_code/grant)与
|
|
33
|
+
* `getPairing()`(仅商家本人层:user_code/verification_uri/expires_at)分层外露;
|
|
34
|
+
* - `options.beforePublish(binding)` 钩子在**绑定回执经 Catalog 验签之后、
|
|
35
|
+
* 发名片之前**调用(cloud bootstrap 用它拿已验证 merchantId 盖戳商品表并跑
|
|
36
|
+
* 真实 readiness);钩子失败保留 bound,不发布。
|
|
37
|
+
*
|
|
38
|
+
* 身份纪律(全部沿用 CLI 既有校验,不新增信任根):
|
|
39
|
+
*
|
|
40
|
+
* - 首次 Catalog 服务端回执里的 `catalog_agent_id` 是权威身份;只与"历史已
|
|
41
|
+
* 发布身份"(expected_catalog_agent_id)做迁移约束,**不与本地 cagt_* 假定比较**;
|
|
42
|
+
* - `merchantId` 只能来自 CatalogClient 验真的 binding claim(bind 时写入并
|
|
43
|
+
* 持久化 binding_expires_at 等已验证声明以便重启),authorized 轮询结果
|
|
44
|
+
* **绝不**足以盖戳商品;
|
|
45
|
+
* - 代次固定为配置值(当前 1),会话按 generation 选择,拒绝悄悄迁移;
|
|
46
|
+
* 绝不把 applicationId 字符串当权威证据(本服务根本不经过开通向导)。
|
|
47
|
+
*
|
|
48
|
+
* 持久化仍是 0600 `merchant-enrollments.json` 原子写;任何失败路径只改
|
|
49
|
+
* 目标会话的格,不删除其他会话。
|
|
50
|
+
*/
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
import { isIP } from "node:net";
|
|
53
|
+
import { loadOrCreateA2aSigningIdentity, toJwsSigningIdentity } from "../a2a/signing-key.js";
|
|
54
|
+
import { writeFileAtomic } from "../fs/atomic-write.js";
|
|
55
|
+
import { canonicalize } from "../negotiation/jcs.js";
|
|
56
|
+
import { validateAgentCard } from "../discovery/agent-card/validate.js";
|
|
57
|
+
import { isRedirectResponse, readJsonBody } from "../net/safe-http.js";
|
|
58
|
+
import { CatalogClient, normalizeOrigin, runtimePublicKey, } from "./catalog-client.js";
|
|
59
|
+
import { enrollmentStorePath, readEnrollmentStore, } from "./binding/enrollment-challenge.js";
|
|
60
|
+
export class MerchantConnectError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
constructor(code, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.code = code;
|
|
65
|
+
this.name = "MerchantConnectError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 公网入口 host 白名单口径(CLI parsePublicOrigin 与服务构造器共用,
|
|
70
|
+
* 两边边界一致,谁也不放宽):拒绝 localhost/私网 IP/链路本地域名/无点主机。
|
|
71
|
+
* 只判 host;协议与路径形态由各自调用点另行校验。
|
|
72
|
+
*/
|
|
73
|
+
export function isPublicHostAllowed(hostname) {
|
|
74
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
75
|
+
const ip = isIP(host);
|
|
76
|
+
const octets = ip === 4 ? host.split(".").map(Number) : [];
|
|
77
|
+
const privateIpv4 = ip === 4 && (octets[0] === 0 || octets[0] === 10 || octets[0] === 127 || octets[0] === 169 && octets[1] === 254 ||
|
|
78
|
+
octets[0] === 192 && octets[1] === 168 || octets[0] === 172 && (octets[1] ?? 0) >= 16 && (octets[1] ?? 0) <= 31 ||
|
|
79
|
+
octets[0] === 100 && (octets[1] ?? 0) >= 64 && (octets[1] ?? 0) <= 127 || (octets[0] ?? 0) >= 224);
|
|
80
|
+
const privateIpv6 = ip === 6 && (host === "::" || host === "::1" || host.startsWith("fc") ||
|
|
81
|
+
host.startsWith("fd") || host.startsWith("fe80:") || host.startsWith("::ffff:"));
|
|
82
|
+
return host !== "localhost" && !host.endsWith(".localhost") &&
|
|
83
|
+
!host.endsWith(".local") && !host.endsWith(".internal") && !host.endsWith(".lan") && !host.endsWith(".home") &&
|
|
84
|
+
host.includes(".") && !privateIpv4 && !privateIpv6;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 纯 resume 选择:只有同 origin + 同公钥指纹 + 可续状态 + 未过期才复用。
|
|
88
|
+
* (自 merchant-connect.ts 原样搬移,行为被 tests/merchant-connect-migration.test.ts 钉住。)
|
|
89
|
+
*
|
|
90
|
+
* 过期口径按状态区分:preparing/authorized 看授权窗口 expires_at;
|
|
91
|
+
* **bound/published 看已验真绑定的 binding_expires_at**——绑定已建立后
|
|
92
|
+
* 授权 grant 过期不影响续办(用户无需重新授权),反过来绑定过期必须拒绝。
|
|
93
|
+
*/
|
|
94
|
+
export function selectReusableEnrollment(sessions, input) {
|
|
95
|
+
return sessions.find((session) => {
|
|
96
|
+
const state = session;
|
|
97
|
+
if (state.runtime_origin !== input.runtimeOrigin || state.key_thumbprint !== input.keyThumbprint)
|
|
98
|
+
return false;
|
|
99
|
+
if (!["preparing", "authorized", "bound", "published"].includes(state.status))
|
|
100
|
+
return false;
|
|
101
|
+
if (state.status === "published")
|
|
102
|
+
return true;
|
|
103
|
+
const expiry = state.status === "bound" ? state.binding_expires_at : state.expires_at;
|
|
104
|
+
return Number.isFinite(Date.parse(expiry ?? "")) && Date.parse(expiry ?? "") > input.nowMs;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Keep the Catalog account's single-agent identity through key loss and address migration. */
|
|
108
|
+
export function priorPublishedCatalogAgentId(sessions) {
|
|
109
|
+
const ids = new Set(sessions
|
|
110
|
+
.map((session) => session)
|
|
111
|
+
.filter((session) => session.status === "published" && typeof session.catalog_agent_id === "string")
|
|
112
|
+
.map((session) => session.catalog_agent_id));
|
|
113
|
+
if (ids.size > 1)
|
|
114
|
+
throw new MerchantConnectError("AMBIGUOUS_MIGRATION", "本Runtime已有多个Catalog Agent历史绑定;请在Catalog先确认要迁移的商家身份。");
|
|
115
|
+
return ids.values().next().value;
|
|
116
|
+
}
|
|
117
|
+
function cardDigest(card) {
|
|
118
|
+
return `sha256:${createHash("sha256").update(canonicalize(card), "utf8").digest("hex")}`;
|
|
119
|
+
}
|
|
120
|
+
/** 缺省公开卡加载:与 CLI verifyPublicRuntimeReady 同口径(匿名读 + 签名声明核对)。 */
|
|
121
|
+
async function fetchPublicCard(origin, expectedEndpoint, identity, fetchImpl) {
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), 8_000);
|
|
124
|
+
const url = `${origin}/.well-known/agent-card.json`;
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetchImpl(url, { method: "GET", redirect: "manual", signal: controller.signal, headers: { accept: "application/json" } });
|
|
127
|
+
if (isRedirectResponse(response) || !response.ok) {
|
|
128
|
+
throw new MerchantConnectError("RUNTIME_UNREACHABLE", `暂时无法从外部读取商家服务的 Agent Card(HTTP ${response.status})。请确认服务已启动且公网入口可访问,然后重新运行 kiwi merchant connect。`);
|
|
129
|
+
}
|
|
130
|
+
const card = validateAgentCard(await readJsonBody(response, { signal: controller.signal }));
|
|
131
|
+
const urls = card.supportedInterfaces.map((entry) => entry.url);
|
|
132
|
+
if (typeof card.url !== "string" || new URL(card.url).origin !== origin || !urls.includes(expectedEndpoint)) {
|
|
133
|
+
throw new MerchantConnectError("RUNTIME_NOT_READY", "公网入口返回的 Agent Card 与当前 Runtime 地址不一致;未发布名片。请检查反向代理与服务配置后重试。");
|
|
134
|
+
}
|
|
135
|
+
const signatureScheme = card.securitySchemes?.["kiwi-signature"];
|
|
136
|
+
if (signatureScheme !== undefined && (signatureScheme.type !== "kiwi-http-message-signature" ||
|
|
137
|
+
signatureScheme.keyid !== identity.keyid ||
|
|
138
|
+
signatureScheme.publicKeyPem !== identity.publicKeyPem ||
|
|
139
|
+
signatureScheme.algorithm !== "ed25519")) {
|
|
140
|
+
throw new MerchantConnectError("RUNTIME_IDENTITY_MISMATCH", "公网服务的 A2A 签名声明与当前 Runtime 持久密钥不一致;未发布名片。请在实际承接请求的 Runtime 主机上运行连接命令。");
|
|
141
|
+
}
|
|
142
|
+
return card;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof MerchantConnectError)
|
|
146
|
+
throw err;
|
|
147
|
+
throw new MerchantConnectError("RUNTIME_UNREACHABLE", `暂时无法从外部连接你的商家服务(${err instanceof Error ? err.message : String(err)})。请确认服务已启动、TLS 证书有效且允许公网访问,然后重试。`);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
class ConnectionServiceImpl {
|
|
154
|
+
dataDir;
|
|
155
|
+
origin;
|
|
156
|
+
a2aEndpoint;
|
|
157
|
+
generation;
|
|
158
|
+
serviceEpoch;
|
|
159
|
+
client;
|
|
160
|
+
identityRaw;
|
|
161
|
+
identity;
|
|
162
|
+
keyThumbprint;
|
|
163
|
+
now;
|
|
164
|
+
loadPublicCard;
|
|
165
|
+
beforePublish;
|
|
166
|
+
/** begin/reconcile 串行(排队不交错)。 */
|
|
167
|
+
mutex = Promise.resolve();
|
|
168
|
+
/** poll 节流(内存态;重启后首趟允许立即 poll)。 */
|
|
169
|
+
nextPollAtMs = 0;
|
|
170
|
+
/**
|
|
171
|
+
* published 会话的"本次已验证"结论(fail-closed,getSummary 只读它):
|
|
172
|
+
* publicationVerified=false 表示本实例还没从 Catalog 核对过当前绑定/名片,
|
|
173
|
+
* 此时绝不允许把本地历史 published 记录当当前发布回执;
|
|
174
|
+
* availability 非 null 表示核对给出否定/出错结论(撤回、网络不可达等)。
|
|
175
|
+
*/
|
|
176
|
+
publicationVerified = false;
|
|
177
|
+
availability = null;
|
|
178
|
+
constructor(options) {
|
|
179
|
+
this.dataDir = options.dataDir;
|
|
180
|
+
this.generation = options.generation ?? 1;
|
|
181
|
+
this.serviceEpoch = options.serviceEpoch ?? 1;
|
|
182
|
+
const trimmed = String(options.publicOrigin ?? "").trim().replace(/\/+$/, "");
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = new URL(trimmed);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new MerchantConnectError("PUBLIC_ORIGIN_INVALID", "公网入口不是合法 URL;请设置 KIWI_A2A_PUBLIC_URL=https://你的域名");
|
|
189
|
+
}
|
|
190
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" ||
|
|
191
|
+
parsed.search !== "" || parsed.hash !== "" || (parsed.pathname !== "/" && parsed.pathname !== "")) {
|
|
192
|
+
throw new MerchantConnectError("PUBLIC_ORIGIN_INVALID", "公网入口不是合法 URL;请设置 KIWI_A2A_PUBLIC_URL=https://你的域名");
|
|
193
|
+
}
|
|
194
|
+
// 与 CLI parsePublicOrigin 同一白名单口径:生产构造必须提供公网可达 host;
|
|
195
|
+
// 只有显式 allowPrivateOrigin(测试/本地回环)才放宽。
|
|
196
|
+
if (options.allowPrivateOrigin !== true && !isPublicHostAllowed(parsed.hostname)) {
|
|
197
|
+
throw new MerchantConnectError("PUBLIC_ORIGIN_INVALID", "商家服务尚未配置可用的公网 HTTPS origin。请运行 `kiwi merchant setup-public` 查看入口配置指引;资料已保留,配置后重新运行连接命令。");
|
|
198
|
+
}
|
|
199
|
+
this.origin = normalizeOrigin(trimmed);
|
|
200
|
+
this.a2aEndpoint = `${this.origin}/a2a`;
|
|
201
|
+
this.client = new CatalogClient({ baseUrl: options.catalogUrl, ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}) });
|
|
202
|
+
this.identityRaw = loadOrCreateA2aSigningIdentity(this.dataDir, this.origin);
|
|
203
|
+
this.identity = { signingIdentity: toJwsSigningIdentity(this.identityRaw), keyId: this.identityRaw.keyid };
|
|
204
|
+
this.keyThumbprint = runtimePublicKey(this.identity).keyThumbprint;
|
|
205
|
+
this.now = options.now ?? (() => new Date());
|
|
206
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
207
|
+
this.loadPublicCard = options.loadPublicCard ??
|
|
208
|
+
(async () => await fetchPublicCard(this.origin, this.a2aEndpoint, {
|
|
209
|
+
keyid: this.identityRaw.keyid,
|
|
210
|
+
publicKeyPem: this.identityRaw.publicKeyPem,
|
|
211
|
+
}, fetchImpl));
|
|
212
|
+
this.beforePublish = options.beforePublish;
|
|
213
|
+
}
|
|
214
|
+
// ── 公开接口 ─────────────────────────────────────────────────────────
|
|
215
|
+
async begin() {
|
|
216
|
+
return await this.withLock(async () => {
|
|
217
|
+
const card = await this.loadPublicCard();
|
|
218
|
+
const currentDigest = cardDigest(card);
|
|
219
|
+
const store = readEnrollmentStore(this.dataDir);
|
|
220
|
+
const session = this.selectSession(store);
|
|
221
|
+
const catalogOrigin = this.client.catalogOrigin;
|
|
222
|
+
if (session !== undefined) {
|
|
223
|
+
if (session.catalog_origin !== catalogOrigin) {
|
|
224
|
+
throw new MerchantConnectError("CATALOG_MISMATCH", "待续办的接入任务属于另一个 Catalog;为防止许可串用,已停止。");
|
|
225
|
+
}
|
|
226
|
+
const frozenDigest = cardDigest(session.frozen_card);
|
|
227
|
+
if (typeof session.preview_digest !== "string" || frozenDigest !== session.preview_digest) {
|
|
228
|
+
throw new MerchantConnectError("STATE_INVALID", "持久接入任务缺少冻结的公开名片,已停止。");
|
|
229
|
+
}
|
|
230
|
+
if (currentDigest !== session.preview_digest) {
|
|
231
|
+
throw new MerchantConnectError(session.status === "published" ? "RUNTIME_CARD_CHANGED" : "PREVIEW_CHANGED", "公网服务当前的 Agent Card 与已批准预览不同;请核对变更并重新授权,Runtime不会静默更改已公开信息。");
|
|
232
|
+
}
|
|
233
|
+
if (session.status === "published") {
|
|
234
|
+
try {
|
|
235
|
+
await this.assertPublicationActive(session);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
// 撤回/暂停如实留痕(summary fail-closed 为未发布)并继续向上抛;
|
|
239
|
+
// begin 是显式动作,保持与旧 CLI 一致的抛错语义。
|
|
240
|
+
this.publicationVerified = false;
|
|
241
|
+
this.availability = err instanceof MerchantConnectError
|
|
242
|
+
? { code: err.code, detail: err.message }
|
|
243
|
+
: { code: "PUBLICATION_CHECK_FAILED", detail: String(err) };
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
this.publicationVerified = true;
|
|
247
|
+
this.availability = null;
|
|
248
|
+
const fresh = this.selectSession(readEnrollmentStore(this.dataDir));
|
|
249
|
+
return this.toSummary(fresh ?? session);
|
|
250
|
+
}
|
|
251
|
+
return this.toSummary(session);
|
|
252
|
+
}
|
|
253
|
+
// 无可用会话:只有这里允许创建 enrollment。但若同 origin+key 还存在
|
|
254
|
+
// **其他 Catalog** 的活会话,说明配置指向变了——明确拒绝而不是 silently
|
|
255
|
+
// 在新 Catalog 重建(防止许可串用/悄悄迁移)。
|
|
256
|
+
const foreign = this.findForeignCatalogSession(store);
|
|
257
|
+
if (foreign !== undefined) {
|
|
258
|
+
throw new MerchantConnectError("CATALOG_MISMATCH", "待续办的接入任务属于另一个 Catalog;为防止许可串用,已停止。");
|
|
259
|
+
}
|
|
260
|
+
const expectedCatalogAgentId = priorPublishedCatalogAgentId(store.sessions);
|
|
261
|
+
const enrollment = await this.client.createDeviceEnrollment({
|
|
262
|
+
runtimeOrigin: this.origin,
|
|
263
|
+
a2aEndpoint: this.a2aEndpoint,
|
|
264
|
+
generation: this.generation,
|
|
265
|
+
serviceEpoch: this.serviceEpoch,
|
|
266
|
+
publicPreview: card,
|
|
267
|
+
publicProfileRevision: 1,
|
|
268
|
+
}, this.identity);
|
|
269
|
+
const next = {
|
|
270
|
+
enrollment_id: enrollment.enrollmentId,
|
|
271
|
+
runtime_origin: this.origin,
|
|
272
|
+
key_thumbprint: this.keyThumbprint,
|
|
273
|
+
catalog_origin: catalogOrigin,
|
|
274
|
+
preview_digest: currentDigest,
|
|
275
|
+
frozen_card: card,
|
|
276
|
+
generation: this.generation,
|
|
277
|
+
...(expectedCatalogAgentId !== undefined ? { expected_catalog_agent_id: expectedCatalogAgentId } : {}),
|
|
278
|
+
expires_at: enrollment.expiresAt,
|
|
279
|
+
status: "preparing",
|
|
280
|
+
device_code: enrollment.deviceCode,
|
|
281
|
+
user_code: enrollment.userCode,
|
|
282
|
+
verification_uri: enrollment.verificationUri,
|
|
283
|
+
interval: enrollment.intervalSeconds,
|
|
284
|
+
};
|
|
285
|
+
// 同 origin+key 的旧 preparing 会话被本任务取代(标 canceled 留痕),
|
|
286
|
+
// 其余会话(含其他 origin/key、bound/published 历史)一律保留。
|
|
287
|
+
this.persist((fresh) => [
|
|
288
|
+
...fresh.sessions.map((item) => {
|
|
289
|
+
const old = item;
|
|
290
|
+
return old.enrollment_id !== next.enrollment_id &&
|
|
291
|
+
old.runtime_origin === this.origin && old.key_thumbprint === this.keyThumbprint &&
|
|
292
|
+
old.status === "preparing"
|
|
293
|
+
? { ...old, status: "canceled" }
|
|
294
|
+
: item;
|
|
295
|
+
}),
|
|
296
|
+
next,
|
|
297
|
+
]);
|
|
298
|
+
this.nextPollAtMs = 0;
|
|
299
|
+
this.publicationVerified = false;
|
|
300
|
+
this.availability = null;
|
|
301
|
+
return this.toSummary(next);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async reconcile() {
|
|
305
|
+
return await this.withLock(async () => {
|
|
306
|
+
const store = readEnrollmentStore(this.dataDir);
|
|
307
|
+
const session = this.selectSession(store);
|
|
308
|
+
if (session === undefined) {
|
|
309
|
+
// 过期授权显式标记:不再轮询,下一次 begin() 为同 origin+key 新建。
|
|
310
|
+
const stale = this.findStaleSession(store);
|
|
311
|
+
if (stale !== undefined) {
|
|
312
|
+
const expired = { ...stale, status: "expired" };
|
|
313
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === stale.enrollment_id ? expired : item));
|
|
314
|
+
return this.toSummary(expired);
|
|
315
|
+
}
|
|
316
|
+
return this.toSummary(null);
|
|
317
|
+
}
|
|
318
|
+
// 单趟级联:preparing →(poll)authorized →(bind)bound →(钩子+publish)
|
|
319
|
+
// published,每格已就绪就顺势走完;poll 节流保证至多一次出站 poll。
|
|
320
|
+
let current = session;
|
|
321
|
+
if (current.status === "preparing")
|
|
322
|
+
current = await this.stepPreparing(current);
|
|
323
|
+
if (current.status === "authorized")
|
|
324
|
+
current = await this.stepAuthorized(current);
|
|
325
|
+
if (current.status === "bound")
|
|
326
|
+
current = await this.stepBound(current);
|
|
327
|
+
if (current.status === "published") {
|
|
328
|
+
try {
|
|
329
|
+
await this.assertPublicationActive(current);
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
// 撤回/暂停/网络不可达一律 fail-closed:summary 报未发布(paused/error),
|
|
333
|
+
// 绝不自动恢复营业、不发任何写请求;下趟 reconcile 会重新核对。
|
|
334
|
+
this.publicationVerified = false;
|
|
335
|
+
this.availability = err instanceof MerchantConnectError
|
|
336
|
+
? { code: err.code, detail: err.message }
|
|
337
|
+
: { code: "PUBLICATION_CHECK_FAILED", detail: String(err) };
|
|
338
|
+
return this.toSummary(current);
|
|
339
|
+
}
|
|
340
|
+
this.publicationVerified = true;
|
|
341
|
+
this.availability = null;
|
|
342
|
+
}
|
|
343
|
+
return this.toSummary(current);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async getSummary() {
|
|
347
|
+
const store = readEnrollmentStore(this.dataDir);
|
|
348
|
+
const session = this.selectSession(store);
|
|
349
|
+
if (session !== undefined)
|
|
350
|
+
return this.toSummary(session);
|
|
351
|
+
const stale = this.findStaleSession(store);
|
|
352
|
+
return this.toSummary(stale === undefined ? null : { ...stale, status: "expired" });
|
|
353
|
+
}
|
|
354
|
+
getPairing() {
|
|
355
|
+
const store = readEnrollmentStore(this.dataDir);
|
|
356
|
+
const session = this.selectSession(store);
|
|
357
|
+
if (session === undefined || session.status !== "preparing")
|
|
358
|
+
return null;
|
|
359
|
+
return {
|
|
360
|
+
userCode: session.user_code,
|
|
361
|
+
verificationUri: session.verification_uri,
|
|
362
|
+
expiresAt: session.expires_at,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* 已验证绑定投影(bound 与 published 两条口径):
|
|
367
|
+
*
|
|
368
|
+
* - **bound(尚未公开发布)**:只认 bind 时验真并持久化的签名 claim,
|
|
369
|
+
* 且该 claim 未过期——绝不无条件延长旧凭据;
|
|
370
|
+
* - **published**:先从 Catalog 公开端点读取**当次重签**的绑定声明
|
|
371
|
+
* (Catalog 每次公开读都会重新签发 expires_at=now+ttl≤900s 的短期声明,
|
|
372
|
+
* 本地 binding_expires_at 只是首签 TTL、不是长期绑定的终点),逐字段核对
|
|
373
|
+
* merchant/origin/key/binding id/version/card revision 后,用新鲜声明的
|
|
374
|
+
* 期限刷新投影。历史 claim 过期绝不阻断只读续验,也不触发重建 enrollment。
|
|
375
|
+
*/
|
|
376
|
+
async getVerifiedBinding() {
|
|
377
|
+
const store = readEnrollmentStore(this.dataDir);
|
|
378
|
+
const session = this.selectSession(store);
|
|
379
|
+
if (session === undefined)
|
|
380
|
+
return null;
|
|
381
|
+
if (session.status === "bound") {
|
|
382
|
+
// 未发布:必须用本地已验证且未过期的签名 claim。
|
|
383
|
+
return this.verifiedFromSession(session);
|
|
384
|
+
}
|
|
385
|
+
if (session.status !== "published")
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
const active = await this.assertPublicationActive(session);
|
|
389
|
+
this.markPublishedVerified();
|
|
390
|
+
return this.verifiedFromPublication(session, active);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
this.publicationVerified = false;
|
|
394
|
+
this.availability = err instanceof MerchantConnectError
|
|
395
|
+
? { code: err.code, detail: err.message }
|
|
396
|
+
: { code: "PUBLICATION_CHECK_FAILED", detail: "Catalog publication could not be verified" };
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// ── 协议单步(自 CLI 状态机原样搬移,顺序与校验一致)──────────────────
|
|
401
|
+
/** begin/reconcile 后本趟已确认 published 健康(发布成功即视为已验证)。 */
|
|
402
|
+
markPublishedVerified() {
|
|
403
|
+
this.publicationVerified = true;
|
|
404
|
+
this.availability = null;
|
|
405
|
+
}
|
|
406
|
+
async stepPreparing(session) {
|
|
407
|
+
const nowMs = this.now().getTime();
|
|
408
|
+
if (!Number.isFinite(Date.parse(session.expires_at)) || Date.parse(session.expires_at) <= nowMs) {
|
|
409
|
+
// 授权已过期:不再轮询,显式标记;下一次 begin() 会为同 origin+key 新建。
|
|
410
|
+
const expired = { ...session, status: "expired" };
|
|
411
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? expired : item));
|
|
412
|
+
return expired;
|
|
413
|
+
}
|
|
414
|
+
if (nowMs < this.nextPollAtMs)
|
|
415
|
+
return session;
|
|
416
|
+
const poll = await this.client.pollDeviceEnrollment(session.device_code, this.identity);
|
|
417
|
+
if (poll.status !== "authorized") {
|
|
418
|
+
const intervalMs = poll.status === "slow_down"
|
|
419
|
+
? Math.max(session.interval * 1000 + 5_000, poll.intervalSeconds * 1000)
|
|
420
|
+
: Math.max(session.interval, poll.intervalSeconds) * 1000;
|
|
421
|
+
this.nextPollAtMs = nowMs + intervalMs;
|
|
422
|
+
const updated = { ...session, interval: Math.round(intervalMs / 1000) };
|
|
423
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? updated : item));
|
|
424
|
+
return updated;
|
|
425
|
+
}
|
|
426
|
+
this.verifyAuthorizedPoll(session, poll);
|
|
427
|
+
const authorized = {
|
|
428
|
+
...session,
|
|
429
|
+
status: "authorized",
|
|
430
|
+
catalog_agent_id: poll.catalogAgentId,
|
|
431
|
+
grant: poll.grant,
|
|
432
|
+
authorization_epoch: poll.authorizationEpoch,
|
|
433
|
+
expires_at: poll.expiresAt,
|
|
434
|
+
// poll.merchantId 是未验真断言,只存 offered_merchant_id 供 bind 入参
|
|
435
|
+
// 一致性校验(CatalogClient 会核对签名 claim 与它一致);
|
|
436
|
+
// 权威的 merchant_id 只在 bind 阶段由 Catalog 签名 claim 写入。
|
|
437
|
+
offered_merchant_id: poll.merchantId,
|
|
438
|
+
};
|
|
439
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? authorized : item));
|
|
440
|
+
return authorized;
|
|
441
|
+
}
|
|
442
|
+
verifyAuthorizedPoll(session, poll) {
|
|
443
|
+
if (poll.enrollmentId !== session.enrollment_id || poll.runtimeOrigin !== this.origin || poll.a2aEndpoint !== this.a2aEndpoint ||
|
|
444
|
+
poll.merchantId.trim() === "" ||
|
|
445
|
+
session.expected_catalog_agent_id !== undefined && poll.catalogAgentId !== session.expected_catalog_agent_id) {
|
|
446
|
+
throw new MerchantConnectError("AUTHORIZED_MATERIAL_MISMATCH", "Catalog 授权材料与当前 Runtime 不一致,已停止连接。");
|
|
447
|
+
}
|
|
448
|
+
if (poll.authorizationEpoch < 1 || poll.approvedCardDigest !== session.preview_digest ||
|
|
449
|
+
!poll.scopes.includes("runtime:bind") || !poll.scopes.includes("card:publish") ||
|
|
450
|
+
!poll.scopes.includes("heartbeat")) {
|
|
451
|
+
throw new MerchantConnectError("GRANT_SCOPE_INVALID", "Catalog 接入许可缺少有效授权代次或必要范围,已停止。");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async stepAuthorized(session) {
|
|
455
|
+
// 兼容旧 CLI 写入的会话(当时 merchant_id 存的是 poll 断言):优先
|
|
456
|
+
// offered_merchant_id,缺失时回落旧字段,语义相同(都只是 bind 入参)。
|
|
457
|
+
const offeredMerchantId = session.offered_merchant_id ?? session.merchant_id;
|
|
458
|
+
if (session.catalog_agent_id === undefined || session.grant === undefined ||
|
|
459
|
+
session.authorization_epoch === undefined || offeredMerchantId === undefined) {
|
|
460
|
+
throw new MerchantConnectError("STATE_INVALID", "本地授权状态缺少许可、授权代次、merchant_id或 catalog_agent_id");
|
|
461
|
+
}
|
|
462
|
+
const binding = await this.client.bindEnrollment({
|
|
463
|
+
enrollmentId: session.enrollment_id,
|
|
464
|
+
grant: session.grant,
|
|
465
|
+
catalogAgentId: session.catalog_agent_id,
|
|
466
|
+
merchantId: offeredMerchantId,
|
|
467
|
+
runtimeOrigin: this.origin,
|
|
468
|
+
a2aEndpoint: this.a2aEndpoint,
|
|
469
|
+
generation: session.generation ?? this.generation,
|
|
470
|
+
serviceEpoch: this.serviceEpoch,
|
|
471
|
+
authorizationEpoch: session.authorization_epoch,
|
|
472
|
+
}, this.identity);
|
|
473
|
+
if (binding.keyThumbprint !== this.keyThumbprint) {
|
|
474
|
+
throw new MerchantConnectError("BINDING_KEY_MISMATCH", "Catalog 绑定回执的密钥与本 Runtime 不一致");
|
|
475
|
+
}
|
|
476
|
+
const rawClaims = binding.bindingClaim["claims"];
|
|
477
|
+
const claimMerchantId = rawClaims !== null && typeof rawClaims === "object" && !Array.isArray(rawClaims)
|
|
478
|
+
? rawClaims["merchant_id"]
|
|
479
|
+
: undefined;
|
|
480
|
+
const claimExpiry = rawClaims !== null && typeof rawClaims === "object" && !Array.isArray(rawClaims)
|
|
481
|
+
? rawClaims["expires_at"]
|
|
482
|
+
: undefined;
|
|
483
|
+
if (typeof claimMerchantId !== "string" || claimMerchantId.trim() === "") {
|
|
484
|
+
throw new MerchantConnectError("BINDING_CLAIM_INVALID", "Catalog已签名绑定声明缺少merchant_id;名片保持未发布。");
|
|
485
|
+
}
|
|
486
|
+
if (typeof claimExpiry !== "string" || !Number.isFinite(Date.parse(claimExpiry))) {
|
|
487
|
+
throw new MerchantConnectError("BINDING_CLAIM_INVALID", "Catalog已签名绑定声明缺少有效expires_at;名片保持未发布。");
|
|
488
|
+
}
|
|
489
|
+
const bound = {
|
|
490
|
+
...session,
|
|
491
|
+
status: "bound",
|
|
492
|
+
binding_id: binding.bindingId,
|
|
493
|
+
binding_version: binding.bindingVersion,
|
|
494
|
+
merchant_id: claimMerchantId,
|
|
495
|
+
binding_expires_at: claimExpiry,
|
|
496
|
+
expected_card_revision: binding.activeCardRevision ?? 0,
|
|
497
|
+
};
|
|
498
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? bound : item));
|
|
499
|
+
// 级联:同一趟继续走 bound 格(发名片前仍要复核公开卡 + beforePublish 钩子)。
|
|
500
|
+
return await this.stepBound(bound);
|
|
501
|
+
}
|
|
502
|
+
async stepBound(session) {
|
|
503
|
+
if (session.catalog_agent_id === undefined || session.binding_id === undefined ||
|
|
504
|
+
session.binding_version === undefined || session.merchant_id === undefined ||
|
|
505
|
+
session.binding_expires_at === undefined) {
|
|
506
|
+
throw new MerchantConnectError("STATE_INVALID", "绑定状态缺少回执字段");
|
|
507
|
+
}
|
|
508
|
+
// 重启续办/长等待后重新验证:绑定授权已过期的绝不允许带过期绑定调
|
|
509
|
+
// beforePublish 钩子或发布名片;显式标记 expired,下一次 begin() 重开。
|
|
510
|
+
if (!Number.isFinite(Date.parse(session.binding_expires_at)) || Date.parse(session.binding_expires_at) <= this.now().getTime()) {
|
|
511
|
+
const expired = { ...session, status: "expired" };
|
|
512
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? expired : item));
|
|
513
|
+
return expired;
|
|
514
|
+
}
|
|
515
|
+
// 重启续办/长等待后重新验证:公开卡必须与冻结预览一致才允许发布。
|
|
516
|
+
const currentCard = await this.loadPublicCard();
|
|
517
|
+
if (canonicalize(currentCard) !== canonicalize(session.frozen_card)) {
|
|
518
|
+
throw new MerchantConnectError("PREVIEW_CHANGED", "公网服务的公开 Agent Card 已不同于商家批准的预览;未发布名片。请重新检查并授权新的公开信息。");
|
|
519
|
+
}
|
|
520
|
+
const catalogAgentId = session.catalog_agent_id;
|
|
521
|
+
const bindingId = session.binding_id;
|
|
522
|
+
const verified = {
|
|
523
|
+
agentId: catalogAgentId,
|
|
524
|
+
bindingId,
|
|
525
|
+
bindingVersion: session.binding_version,
|
|
526
|
+
merchantId: session.merchant_id,
|
|
527
|
+
keyThumbprint: this.keyThumbprint,
|
|
528
|
+
runtimeOrigin: this.origin,
|
|
529
|
+
a2aEndpoint: this.a2aEndpoint,
|
|
530
|
+
expiresAt: session.binding_expires_at,
|
|
531
|
+
cardRevision: session.card_revision ?? null,
|
|
532
|
+
};
|
|
533
|
+
if (this.beforePublish !== undefined)
|
|
534
|
+
await this.beforePublish(verified);
|
|
535
|
+
const expectedRevision = session.expected_card_revision ?? 0;
|
|
536
|
+
let cardRevision = session.card_revision;
|
|
537
|
+
if (cardRevision === undefined) {
|
|
538
|
+
const publication = await this.client.publishCard({
|
|
539
|
+
agentId: catalogAgentId,
|
|
540
|
+
bindingId,
|
|
541
|
+
generation: session.generation ?? this.generation,
|
|
542
|
+
expectedRevision,
|
|
543
|
+
agentCard: session.frozen_card,
|
|
544
|
+
a2aEndpoint: this.a2aEndpoint,
|
|
545
|
+
runtimeOrigin: this.origin,
|
|
546
|
+
}, this.identity);
|
|
547
|
+
if (publication.revision === null) {
|
|
548
|
+
throw new MerchantConnectError("PUBLICATION_RECEIPT_INVALID", "Catalog 未返回名片 revision;绑定已保留,重试可续办");
|
|
549
|
+
}
|
|
550
|
+
cardRevision = publication.revision;
|
|
551
|
+
const publishedCard = { ...session, card_revision: cardRevision };
|
|
552
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? publishedCard : item));
|
|
553
|
+
session = publishedCard;
|
|
554
|
+
}
|
|
555
|
+
const activated = await this.client.activateCard({
|
|
556
|
+
agentId: catalogAgentId,
|
|
557
|
+
bindingId,
|
|
558
|
+
cardRevision,
|
|
559
|
+
expectedRevision,
|
|
560
|
+
}, this.identity);
|
|
561
|
+
if (activated.revision === null) {
|
|
562
|
+
throw new MerchantConnectError("ACTIVATION_RECEIPT_INVALID", "Catalog 未确认名片激活;绑定已保留,重试可续办");
|
|
563
|
+
}
|
|
564
|
+
const published = { ...session, status: "published", card_revision: activated.revision };
|
|
565
|
+
// 同 catalog agent 的旧 published 会话由新绑定取代(标 replaced);其余一律保留。
|
|
566
|
+
this.persist((fresh) => fresh.sessions.map((item) => {
|
|
567
|
+
if (item.enrollment_id === session.enrollment_id)
|
|
568
|
+
return published;
|
|
569
|
+
const old = item;
|
|
570
|
+
return old.status === "published" && old.catalog_agent_id === published.catalog_agent_id &&
|
|
571
|
+
old.binding_id !== published.binding_id
|
|
572
|
+
? { ...old, status: "replaced" }
|
|
573
|
+
: item;
|
|
574
|
+
}));
|
|
575
|
+
this.markPublishedVerified();
|
|
576
|
+
return published;
|
|
577
|
+
}
|
|
578
|
+
// ── 内部 ─────────────────────────────────────────────────────────────
|
|
579
|
+
/**
|
|
580
|
+
* 选择当前(origin, key, catalog_origin, generation)可续的会话。
|
|
581
|
+
* published 永不过期;其余状态按各自过期口径(bound 看 binding_expires_at)。
|
|
582
|
+
* 逐条扫描而非取首条:第一条 catalog/generation 不匹配不得遮挡后面正确的会话;
|
|
583
|
+
* 任何路径都不得把别的 Catalog 的 device_code/grant 发给当前 Catalog。
|
|
584
|
+
*/
|
|
585
|
+
selectSession(store) {
|
|
586
|
+
const nowMs = this.now().getTime();
|
|
587
|
+
for (const item of store.sessions) {
|
|
588
|
+
const session = item;
|
|
589
|
+
if (session.runtime_origin !== this.origin || session.key_thumbprint !== this.keyThumbprint)
|
|
590
|
+
continue;
|
|
591
|
+
if (!["preparing", "authorized", "bound", "published"].includes(session.status))
|
|
592
|
+
continue;
|
|
593
|
+
if (session.status !== "published") {
|
|
594
|
+
// published 永不在本地按首签 claim 的 TTL 判死(Catalog 每次公开读都会
|
|
595
|
+
// 重签短期声明;长期绑定是否有效由 Catalog 复核把关),其余状态按各自
|
|
596
|
+
// 过期口径:bound 看验真绑定 binding_expires_at,preparing/authorized
|
|
597
|
+
// 看授权窗口 expires_at。
|
|
598
|
+
const expiry = session.status === "bound" ? session.binding_expires_at : session.expires_at;
|
|
599
|
+
if (!(Number.isFinite(Date.parse(expiry ?? "")) && Date.parse(expiry ?? "") > nowMs))
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (session.catalog_origin !== this.client.catalogOrigin)
|
|
603
|
+
continue;
|
|
604
|
+
if (session.generation !== undefined && session.generation !== this.generation)
|
|
605
|
+
continue;
|
|
606
|
+
return session;
|
|
607
|
+
}
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
/** 同 origin/key/catalog/generation、但授权窗口已过的 preparing/authorized 会话。 */
|
|
611
|
+
findStaleSession(store) {
|
|
612
|
+
const candidate = store.sessions.find((item) => {
|
|
613
|
+
const session = item;
|
|
614
|
+
return session.runtime_origin === this.origin && session.key_thumbprint === this.keyThumbprint &&
|
|
615
|
+
(session.generation ?? 1) === this.generation &&
|
|
616
|
+
session.catalog_origin === this.client.catalogOrigin &&
|
|
617
|
+
["preparing", "authorized"].includes(session.status) &&
|
|
618
|
+
(!Number.isFinite(Date.parse(session.expires_at)) || Date.parse(session.expires_at) <= this.now().getTime());
|
|
619
|
+
});
|
|
620
|
+
return candidate;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* 同 origin+key 可续、但属于**其他 Catalog** 的会话(begin 创建新 enrollment
|
|
624
|
+
* 前的显式守卫;selection 本身已按 catalog 过滤,这里只是给出明确拒绝)。
|
|
625
|
+
*/
|
|
626
|
+
findForeignCatalogSession(store) {
|
|
627
|
+
const nowMs = this.now().getTime();
|
|
628
|
+
for (const item of store.sessions) {
|
|
629
|
+
const session = item;
|
|
630
|
+
if (session.runtime_origin !== this.origin || session.key_thumbprint !== this.keyThumbprint)
|
|
631
|
+
continue;
|
|
632
|
+
if (!["preparing", "authorized", "bound", "published"].includes(session.status))
|
|
633
|
+
continue;
|
|
634
|
+
if (session.status !== "published") {
|
|
635
|
+
const expiry = session.status === "bound" ? session.binding_expires_at : session.expires_at;
|
|
636
|
+
if (!(Number.isFinite(Date.parse(expiry ?? "")) && Date.parse(expiry ?? "") > nowMs))
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (session.catalog_origin === this.client.catalogOrigin)
|
|
640
|
+
continue;
|
|
641
|
+
if (session.generation !== undefined && session.generation !== this.generation)
|
|
642
|
+
continue;
|
|
643
|
+
return session;
|
|
644
|
+
}
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* published 会话核对 Catalog **当次重签**的公开绑定声明;不符即
|
|
649
|
+
* PUBLICATION_NOT_ACTIVE(不自动恢复)。返回核对通过的公开文档,
|
|
650
|
+
* 其 expiresAt 是新鲜短期声明的期限(Catalog 每次公开读重签,
|
|
651
|
+
* expires_at=now+ttl≤900s),不是长期绑定的终止时间——长期绑定过期由
|
|
652
|
+
* Catalog 拒签(403)体现。
|
|
653
|
+
*/
|
|
654
|
+
async assertPublicationActive(session) {
|
|
655
|
+
if (session.catalog_agent_id === undefined || session.binding_id === undefined) {
|
|
656
|
+
throw new MerchantConnectError("STATE_INVALID", "已发布接入缺少 Catalog/绑定信息。");
|
|
657
|
+
}
|
|
658
|
+
const active = await this.client.fetchPublicBinding(session.catalog_agent_id);
|
|
659
|
+
if (active === null || active.bindingId !== session.binding_id ||
|
|
660
|
+
(session.binding_version !== undefined && active.bindingVersion !== session.binding_version) ||
|
|
661
|
+
active.keyThumbprint !== this.keyThumbprint ||
|
|
662
|
+
active.runtimeOrigin.replace(/\/+$/, "") !== this.origin || active.a2aEndpoint !== this.a2aEndpoint ||
|
|
663
|
+
active.cardRevision !== session.card_revision ||
|
|
664
|
+
!Number.isFinite(Date.parse(active.expiresAt)) || Date.parse(active.expiresAt) <= this.now().getTime() ||
|
|
665
|
+
!["ACTIVE", "active"].includes(active.publicationState)) {
|
|
666
|
+
throw new MerchantConnectError("PUBLICATION_NOT_ACTIVE", "Catalog 当前没有返回这条绑定和已激活名片;服务可能已暂停或撤回。请到Catalog恢复服务后再检查,Runtime不会自动恢复营业。");
|
|
667
|
+
}
|
|
668
|
+
if (session.merchant_id === undefined) {
|
|
669
|
+
const backfilled = { ...session, merchant_id: active.merchantId };
|
|
670
|
+
this.persist((fresh) => fresh.sessions.map((item) => item.enrollment_id === session.enrollment_id ? backfilled : item));
|
|
671
|
+
return active;
|
|
672
|
+
}
|
|
673
|
+
if (session.merchant_id !== active.merchantId) {
|
|
674
|
+
throw new MerchantConnectError("BINDING_MERCHANT_MISMATCH", "Catalog binding的merchant_id与本地Enrollment不一致,已停止。");
|
|
675
|
+
}
|
|
676
|
+
return active;
|
|
677
|
+
}
|
|
678
|
+
/** 用当次重签的公开声明构造已验证绑定投影(期限取新鲜声明)。 */
|
|
679
|
+
verifiedFromPublication(session, active) {
|
|
680
|
+
return {
|
|
681
|
+
agentId: session.catalog_agent_id,
|
|
682
|
+
bindingId: active.bindingId,
|
|
683
|
+
bindingVersion: active.bindingVersion,
|
|
684
|
+
merchantId: active.merchantId,
|
|
685
|
+
keyThumbprint: active.keyThumbprint,
|
|
686
|
+
runtimeOrigin: active.runtimeOrigin,
|
|
687
|
+
a2aEndpoint: active.a2aEndpoint,
|
|
688
|
+
expiresAt: active.expiresAt,
|
|
689
|
+
cardRevision: active.cardRevision,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
verifiedFromSession(session) {
|
|
693
|
+
if (session.catalog_agent_id === undefined || session.binding_id === undefined ||
|
|
694
|
+
session.binding_version === undefined || session.merchant_id === undefined ||
|
|
695
|
+
session.binding_expires_at === undefined)
|
|
696
|
+
return null;
|
|
697
|
+
if (!Number.isFinite(Date.parse(session.binding_expires_at)) || Date.parse(session.binding_expires_at) <= this.now().getTime()) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
agentId: session.catalog_agent_id,
|
|
702
|
+
bindingId: session.binding_id,
|
|
703
|
+
bindingVersion: session.binding_version,
|
|
704
|
+
merchantId: session.merchant_id,
|
|
705
|
+
keyThumbprint: session.key_thumbprint,
|
|
706
|
+
runtimeOrigin: session.runtime_origin,
|
|
707
|
+
a2aEndpoint: this.a2aEndpoint,
|
|
708
|
+
expiresAt: session.binding_expires_at,
|
|
709
|
+
cardRevision: session.card_revision ?? null,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* published 的 summary 视图是 fail-closed 的,仅限"本次已验证"的当前绑定/名片:
|
|
714
|
+
* 本实例尚未核对 → unknown/published=false;撤回暂停 → paused/published=false;
|
|
715
|
+
* 核对出错(含网络不可达、长期绑定过期被 Catalog 拒签)→ error/published=false。
|
|
716
|
+
* 绝不把读取本地历史记录当作当前发布回执,也绝不用首签声明的短期 TTL 在本地判死。
|
|
717
|
+
*/
|
|
718
|
+
toSummary(session) {
|
|
719
|
+
if (session === null) {
|
|
720
|
+
return { status: "idle", published: false, agentId: null, bindingId: null, bindingExpiresAt: null, cardRevision: null, code: null, detail: null };
|
|
721
|
+
}
|
|
722
|
+
if (session.status !== "published") {
|
|
723
|
+
return {
|
|
724
|
+
status: session.status === "preparing" ? "awaiting_confirmation" : session.status,
|
|
725
|
+
published: false,
|
|
726
|
+
agentId: session.catalog_agent_id ?? null,
|
|
727
|
+
bindingId: session.binding_id ?? null,
|
|
728
|
+
bindingExpiresAt: session.binding_expires_at ?? null,
|
|
729
|
+
cardRevision: session.card_revision ?? null,
|
|
730
|
+
code: null,
|
|
731
|
+
detail: null,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
// published 绝不在本地按首签 claim 的 TTL 判死:binding_expires_at 只是
|
|
735
|
+
// 首次绑定声明的短期期限(Catalog 每次公开读重签 expires_at=now+ttl≤900s
|
|
736
|
+
// 的新声明),长期绑定是否有效只由 Catalog 复核结论决定。
|
|
737
|
+
if (!this.publicationVerified || this.availability !== null) {
|
|
738
|
+
const paused = this.availability?.code === "PUBLICATION_NOT_ACTIVE";
|
|
739
|
+
return {
|
|
740
|
+
status: this.availability === null ? "unknown" : paused ? "paused" : "error",
|
|
741
|
+
published: false,
|
|
742
|
+
agentId: session.catalog_agent_id ?? null,
|
|
743
|
+
bindingId: session.binding_id ?? null,
|
|
744
|
+
bindingExpiresAt: session.binding_expires_at ?? null,
|
|
745
|
+
cardRevision: session.card_revision ?? null,
|
|
746
|
+
code: this.availability?.code ?? null,
|
|
747
|
+
detail: this.availability?.detail ?? "本实例尚未完成 Catalog 发布核对;published 暂报未确认。",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
status: "published", published: true,
|
|
752
|
+
agentId: session.catalog_agent_id ?? null,
|
|
753
|
+
bindingId: session.binding_id ?? null,
|
|
754
|
+
bindingExpiresAt: session.binding_expires_at ?? null,
|
|
755
|
+
cardRevision: session.card_revision ?? null,
|
|
756
|
+
code: null,
|
|
757
|
+
detail: null,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/** 0600 原子写;mutator 在**新鲜重读**的 store 上操作,保留 consumed 与其他会话。 */
|
|
761
|
+
persist(mutate) {
|
|
762
|
+
const fresh = readEnrollmentStore(this.dataDir);
|
|
763
|
+
const sessions = mutate(fresh);
|
|
764
|
+
writeFileAtomic(enrollmentStorePath(this.dataDir), `${JSON.stringify({ ...fresh, sessions })}\n`, { mode: 0o600 });
|
|
765
|
+
}
|
|
766
|
+
async withLock(fn) {
|
|
767
|
+
const result = this.mutex.then(fn);
|
|
768
|
+
this.mutex = result.then(() => undefined, () => undefined);
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** 构造连接服务(幂等工厂;同一 dataDir 可由 CLI 与服务实例先后使用)。 */
|
|
773
|
+
export function createMerchantConnectionService(options) {
|
|
774
|
+
return new ConnectionServiceImpl(options);
|
|
775
|
+
}
|
|
776
|
+
//# sourceMappingURL=connect-service.js.map
|