@yixinshuke/node-auth 0.0.0-stage → 0.1.0

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/src/jwt.ts ADDED
@@ -0,0 +1,346 @@
1
+ import {
2
+ randomUUID,
3
+ sign as cryptoSign,
4
+ verify as cryptoVerify,
5
+ createPrivateKey,
6
+ createPublicKey,
7
+ type KeyObject,
8
+ } from "node:crypto";
9
+ import { authConfig, keyVersionOf } from "./config.js";
10
+
11
+ /**
12
+ * 极简 RS256 JWT(零依赖,`node:crypto` 实现)—— 只服务本项目的登录态。
13
+ *
14
+ * 设计:`docs/操作员多商户登录设计.md` §5、`docs/JWT双轨鉴权设计.md` §2
15
+ * - **无状态**:服务端不存 access token,只验签 + 验过期;撤销能力由「落库的 refresh token」承担;
16
+ * - **不含门店/商户权限明细**:payload 只带当前租户 `mid`,权限每次请求现查(防 token 里的旧权限);
17
+ * - **RS256 非对称**:签发用私钥、验签用公钥(密钥由宿主 `configureAuth` 注入)。
18
+ * 只有「登录方」持有私钥,**验签方即便被攻破也无法伪造令牌**——这是选用非对称的唯一理由。
19
+ *
20
+ * 失败策略(**fail-closed**):未配置公钥时无法验签 → 所有令牌判定无效,绝不放行。
21
+ */
22
+
23
+ /** access / pre 票据的载荷 */
24
+ export interface TokenPayload {
25
+ /** 账号编号(I_账号编号) */
26
+ sub: number;
27
+ /** 当前租户ID(pre 票据为 0):商户后台=商户ID,代理商后台=代理商ID */
28
+ mid: number;
29
+ /** 操作员档案ID(pre 票据为 0) */
30
+ oid: number;
31
+ /** 是否管理员(快照,仅用于 UI 显隐;鉴权以库里为准) */
32
+ adm: 0 | 1;
33
+ /** access = 正式令牌;pre = 预登录票据(只能用于「选择商户」) */
34
+ typ: "access" | "pre";
35
+ iat: number;
36
+ exp: number;
37
+ jti: string;
38
+ /**
39
+ * 调用域(partner / merchant):由签发方写入并随令牌签名,属冗余记录;
40
+ * 权威 dom 由 `verifyTokenDom` 按「命中哪把公钥」给出(签发方无法伪造域)。
41
+ */
42
+ dom?: string;
43
+ /**
44
+ * 宿主自定义声明(如 `I_用户Id` / `I_代理Id` / `I_商户Id`):原样透传,
45
+ * 供鉴权中间件 `forceWriteFields` 按同名 key 拷回请求体。本包不约束其名称/数量。
46
+ */
47
+ [k: string]: unknown;
48
+ }
49
+
50
+ /** 多域验签结果:payload + 命中公钥的调用域 dom */
51
+ export interface VerifiedToken {
52
+ /** 令牌载荷(标准 5 字段 + 宿主自定义声明) */
53
+ payload: TokenPayload;
54
+ /** 调用域:命中公钥标注的 dom(如 `"partner"` / `"merchant"`),由验签方确定、不可伪造 */
55
+ dom: string;
56
+ }
57
+
58
+ /** 签发入参(iat/exp/jti 由签发函数补);除标准 5 字段外,可附带任意宿主自定义声明(会被写入 payload 顶层) */
59
+ export type TokenInput = Pick<TokenPayload, "sub" | "mid" | "oid" | "adm" | "typ"> & {
60
+ [k: string]: unknown;
61
+ };
62
+
63
+ const JWT_ALG = "RS256";
64
+
65
+ let cachedPriv: KeyObject | null = null;
66
+ let cachedPrivVer = -1;
67
+ /** 公钥缓存:按 pem + keyVersion 失效;多域各自一把 */
68
+ let cachedPubs: Array<{ pem: string; key: KeyObject | null; ver: number }> = [];
69
+ let warnedNoPub = false;
70
+
71
+ /**
72
+ * 私钥(签发用)—— 未配置直接抛错:无法签发时静默降级等于放行伪造令牌。
73
+ * 缓存按 `keyVersionOf()` 失效:运行期重新注入密钥后立即生效,不会沿用旧 KeyObject。
74
+ */
75
+ function privKey(): KeyObject {
76
+ const ver = keyVersionOf();
77
+ if (cachedPriv && cachedPrivVer === ver) return cachedPriv;
78
+ if (!authConfig.privateKey) {
79
+ throw new Error("[jwt] 未配置 RS256 私钥(JWT_PRIVATE_KEY),无法签发令牌");
80
+ }
81
+ cachedPriv = createPrivateKey(authConfig.privateKey);
82
+ cachedPrivVer = ver;
83
+ return cachedPriv;
84
+ }
85
+
86
+ /** 取验签公钥(按 pem + keyVersion 缓存;解析失败返回 null,调用方按「该域无法验签」跳过) */
87
+ function pubFor(pem: string): KeyObject | null {
88
+ const ver = keyVersionOf();
89
+ const hit = cachedPubs.find((c) => c.pem === pem && c.ver === ver);
90
+ if (hit) return hit.key;
91
+ let k: KeyObject | null = null;
92
+ try {
93
+ k = createPublicKey(pem);
94
+ } catch {
95
+ k = null;
96
+ }
97
+ cachedPubs.push({ pem, key: k, ver });
98
+ return k;
99
+ }
100
+
101
+ /**
102
+ * 内部验签:逐把公钥试签,命中即返回 `{ payload, dom }`(dom 取命中公钥标注的调用域)。
103
+ * 失败(结构错 / alg 非 RS256 / 全部公钥都不匹配 / 过期 / 缺标准字段)一律返回 null(fail-closed)。
104
+ */
105
+ function verifyInternal(token: unknown): VerifiedToken | null {
106
+ try {
107
+ const parts = String(token ?? "").trim().split(".");
108
+ if (parts.length !== 3) return null;
109
+ const [h, b, s] = parts;
110
+
111
+ // 先验 header 的算法声明:不是 RS256 一律拒绝(禁止 none / HS256 降级)
112
+ const header = JSON.parse(Buffer.from(h, "base64url").toString("utf8")) as { alg?: string };
113
+ if (header?.alg !== JWT_ALG) return null;
114
+
115
+ const entries = authConfig.verifyEntries;
116
+ if (!entries.length) {
117
+ if (!warnedNoPub) {
118
+ warnedNoPub = true;
119
+ console.warn(
120
+ "[jwt] 未配置 RS256 公钥(JWT_PUBLIC_KEY / publicKeys):无法验签,所有令牌将判定为未登录(fail-closed)",
121
+ );
122
+ }
123
+ return null;
124
+ }
125
+
126
+ for (const entry of entries) {
127
+ const pub = pubFor(entry.pem);
128
+ if (!pub) continue; // 该域公钥解析失败 → 跳过试下一把
129
+ const okSig = cryptoVerify("sha256", Buffer.from(`${h}.${b}`), pub, Buffer.from(s, "base64url"));
130
+ if (!okSig) continue;
131
+ const payload = JSON.parse(Buffer.from(b, "base64url").toString("utf8")) as TokenPayload;
132
+ if (!payload || typeof payload.sub !== "number" || typeof payload.mid !== "number") return null;
133
+ if (Number(payload.exp) * 1000 <= Date.now()) return null;
134
+ return { payload, dom: entry.dom };
135
+ }
136
+ return null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** 签发令牌(ttlMs 有效期毫秒) */
143
+ export function signToken(input: TokenInput, ttlMs: number): string {
144
+ const now = Math.floor(Date.now() / 1000);
145
+ // ttl <= 0(或非法)→ 立即过期:调用方显式传 0 就是「签发即失效」,不做隐式下限兜底
146
+ const ttlSec = Number.isFinite(ttlMs) ? Math.floor(ttlMs / 1000) : 0;
147
+ const payload: TokenPayload = {
148
+ ...input,
149
+ iat: now,
150
+ exp: now + Math.max(0, ttlSec),
151
+ jti: randomUUID(),
152
+ };
153
+ const header = Buffer.from(JSON.stringify({ alg: JWT_ALG, typ: "JWT" })).toString("base64url");
154
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
155
+ const signing = Buffer.from(`${header}.${body}`);
156
+ const sig = cryptoSign("sha256", signing, privKey()).toString("base64url");
157
+ return `${header}.${body}.${sig}`;
158
+ }
159
+
160
+ /**
161
+ * 校验令牌:失败一律返回 `null`(调用方决定是否 401)。
162
+ * 覆盖:结构、**alg 必须是 RS256**(防 alg 混淆攻击,如降级到 HS256 / none)、签名、过期。
163
+ */
164
+ /**
165
+ * 验签(旧版兼容,只返回 payload):单/多密钥均可验,dom 被忽略。
166
+ * 仍维持 fail-closed:无公钥 / 全部不匹配 / 过期 → null。
167
+ */
168
+ export function verifyToken(token: unknown): TokenPayload | null {
169
+ return verifyInternal(token)?.payload ?? null;
170
+ }
171
+
172
+ /**
173
+ * 多域验签(新增):返回 `payload` + 命中公钥的 `dom`(调用域)。
174
+ * 下游据此区分调用方身份:`dom === "partner"` 走伙伴域(前缀匹配 6 段级别链),
175
+ * `dom === "merchant"` 走商户域(单商户),`dom === "service"` 为机器签名通道。
176
+ */
177
+ export function verifyTokenDom(token: unknown): VerifiedToken | null {
178
+ return verifyInternal(token);
179
+ }
180
+
181
+ /** 读取 JWT 的 exp(绝对 Unix 秒,不验签);非法令牌返回 -1。供前端/调试面板判断 access 剩余有效时间。 */
182
+ export function accessTokenExp(token: string): number {
183
+ try {
184
+ const p = JSON.parse(Buffer.from(String(token).split(".")[1] ?? "", "base64url").toString("utf8"));
185
+ return typeof p?.exp === "number" ? p.exp : -1;
186
+ } catch {
187
+ return -1;
188
+ }
189
+ }
190
+
191
+ /** 从 Authorization 头取值(兼容 `Bearer xxx` 与裸令牌) */
192
+ export function bearerOf(header: unknown): string {
193
+ const s = String(header ?? "").trim();
194
+ return s.startsWith("Bearer ") ? s.slice(7).trim() : s;
195
+ }
196
+
197
+ /**
198
+ * 访问令牌 Cookie 名(与 Bearer 头并存:地址栏 GET 走 Cookie,API/脚本走 Bearer 头)。
199
+ * HttpOnly:JS 读不到,防 XSS 偷 token;SameSite=Lax:允许同站顶层 GET 导航携带
200
+ * (调试面板页 `GET /api/<module>` 因此可用),但挡掉跨站 POST(CSRF)。
201
+ * **Path 锁定 `/api`**:登录态只属于人用通道,`/open`、`/callback`(机器签名通道)、静态页与
202
+ * `/_sys` 观测端点都不会自动带上它——同一份报文里只存在一套身份,避免混着两套导致语义混乱。
203
+ */
204
+ export const ACCESS_TOKEN_COOKIE = "accessToken";
205
+
206
+ /** 手写解析 Cookie 头(避免引入 @fastify/cookie 依赖;本项目未注册该插件) */
207
+ export function parseCookies(header: unknown): Record<string, string> {
208
+ const out: Record<string, string> = {};
209
+ const s = String(header ?? "").trim();
210
+ if (!s) return out;
211
+ for (const part of s.split(";")) {
212
+ const idx = part.indexOf("=");
213
+ if (idx < 0) continue;
214
+ const k = part.slice(0, idx).trim();
215
+ const v = part.slice(idx + 1).trim();
216
+ if (k) out[k] = decodeURIComponent(v);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** 是否 https 请求(决定是否加 Secure;本地 http 不加,否则 Cookie 不会被浏览器发送) */
222
+ function isSecureReq(req: any): boolean {
223
+ if (String(req?.protocol ?? "").toLowerCase() === "https") return true;
224
+ return String(req?.headers?.["x-forwarded-proto"] ?? "").toLowerCase() === "https";
225
+ }
226
+
227
+ function cookieValue(token: string, maxAgeSec: number, secure: boolean): string {
228
+ const parts = [
229
+ `${ACCESS_TOKEN_COOKIE}=${encodeURIComponent(token)}`,
230
+ // Path 锁 `/api`:登录态只在人用通道自动携带(面板页 GET /api/<module> 靠它),
231
+ // /open、/callback(机器签名通道)与静态页一律不带——机器通道的身份只有 appId 签名一套。
232
+ "Path=/api",
233
+ "HttpOnly",
234
+ "SameSite=Lax",
235
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`,
236
+ ];
237
+ if (secure) parts.push("Secure");
238
+ return parts.join("; ");
239
+ }
240
+
241
+ /**
242
+ * 种一个 Set-Cookie。
243
+ * ⚠️ 关键:Fastify 5 的 `reply.header("Set-Cookie", value)` 对同一名字是**追加**语义
244
+ * (传数组则把数组每个元素都追加进内部 set-cookie 列表,而非覆盖或一次性设多个)。
245
+ * 因此每到一个 Cookie 就调用一次标量 `reply.header("Set-Cookie", cookieStr)` 即可,
246
+ * 多个 Cookie 各自追加、互不覆盖。切勿先 `getHeader` 读回再数组合并重设——那会重复追加、造成重复 Set-Cookie。
247
+ */
248
+ function appendSetCookie(reply: any, value: string): void {
249
+ if (!reply || typeof reply.header !== "function") return;
250
+ reply.header("Set-Cookie", value);
251
+ }
252
+
253
+ /** 在响应上种访问令牌 Cookie(供地址栏 GET 自动携带,被鉴权中间件识别) */
254
+ export function setAuthCookie(reply: any, token: string): void {
255
+ const secure = isSecureReq(reply?.request);
256
+ appendSetCookie(reply, cookieValue(token, authConfig.accessTtlMs / 1000, secure));
257
+ }
258
+
259
+ /** 清除访问令牌 Cookie(登出时) */
260
+ export function clearAuthCookie(reply: any): void {
261
+ const secure = isSecureReq(reply?.request);
262
+ appendSetCookie(reply, cookieValue("", 0, secure));
263
+ }
264
+
265
+ /**
266
+ * 刷新令牌 Cookie 名(默认 `refreshToken`,可由 `setTokenCookieNames` 按服务覆盖,如 1dl→`dlrefreshToken`)。
267
+ * 仅认证模块 `/api/auth` 自动携带(见下方 Path 锁定)——其余 /api/* 业务模块不会带,降低 refreshToken 暴露面。
268
+ */
269
+ export let REFRESH_TOKEN_COOKIE = "refreshToken";
270
+
271
+ /**
272
+ * 刷新 Cookie 值:**Path 锁定整个认证模块 `/api/auth`**(刷新命令 `续期` 是其中一员之一),
273
+ * 浏览器在该模块下所有请求(登录 / 选代理商 / 切换 / 续期 / 登出 等)自动带上 refreshToken,
274
+ * 但 `/api/partner`、`/api/account` 等业务模块绝不自动发送。HttpOnly:JS 读不到,防 XSS 偷 refreshToken;
275
+ * SameSite=Lax:挡跨站 POST(CSRF);Secure:https 下才下发(本地 http 调试不加,否则 Cookie 不被发送)。
276
+ * Max-Age = refreshTtlMs(刷新令牌长期有效,与落库 TTL 对齐)。
277
+ */
278
+ function refreshCookieValue(token: string, maxAgeSec: number, secure: boolean): string {
279
+ const parts = [
280
+ `${REFRESH_TOKEN_COOKIE}=${encodeURIComponent(token)}`,
281
+ "Path=/api/auth",
282
+ "HttpOnly",
283
+ "SameSite=Lax",
284
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`,
285
+ ];
286
+ if (secure) parts.push("Secure");
287
+ return parts.join("; ");
288
+ }
289
+
290
+ /** 种刷新令牌 Cookie(仅专用刷新路由自动携带;与 access 的 setAuthCookie 对称) */
291
+ export function setRefreshCookie(reply: any, token: string): void {
292
+ const secure = isSecureReq(reply?.request);
293
+ appendSetCookie(reply, refreshCookieValue(token, authConfig.refreshTtlMs / 1000, secure));
294
+ }
295
+
296
+ /** 清除刷新令牌 Cookie(登出 / 切换设备时) */
297
+ export function clearRefreshCookie(reply: any): void {
298
+ const secure = isSecureReq(reply?.request);
299
+ appendSetCookie(reply, refreshCookieValue("", 0, secure));
300
+ }
301
+
302
+ // ── 设备令牌 Cookie(仅 /api/auth 自动携带)────────────────────────────────
303
+
304
+ /**
305
+ * 设备令牌 Cookie 名(默认 `deviceToken`,可由 `setTokenCookieNames` 按服务覆盖,如 1dl→`dldeviceToken`)。Path 锁定整个认证模块 `/api/auth`:
306
+ * 浏览器在该模块下所有请求(登录 / 选代理商 / 切换 / 续期 / 我的设备 / 踢设备 / 登出)自动带上,
307
+ * 业务模块 `/api/partner` 等绝不自动发送,降低设备令牌暴露面。HttpOnly:JS 读不到,防 XSS 偷设备令牌;
308
+ * SameSite=Lax:挡跨站 POST;Secure:https 下才下发(本地 http 调试不加)。
309
+ * ⚠️ 与 refreshToken 同 Path:二者都只在认证链路使用。设备令牌由服务端在登录/选代理商时生成并种入,
310
+ * 前端不持有明文(不再经响应体下发),仅依赖浏览器自动携带。
311
+ */
312
+ export let DEVICE_TOKEN_COOKIE = "deviceToken";
313
+
314
+ /**
315
+ * 按宿主服务覆盖 refresh / device 的 Cookie 名(默认 `refreshToken` / `deviceToken`)。
316
+ * 由 `registerAuthHook` 在启动时注入,使同源多服务(如 1dl→`dl*`、2sh→`sh*`)可使用不同 Cookie 名,避免跨服务串号。
317
+ * 未注入的服务保持默认,对未配置的 3pay/4hy/5footpos 等无影响。
318
+ */
319
+ export function setTokenCookieNames(refreshName?: string, deviceName?: string): void {
320
+ if (refreshName) REFRESH_TOKEN_COOKIE = refreshName;
321
+ if (deviceName) DEVICE_TOKEN_COOKIE = deviceName;
322
+ }
323
+
324
+ function deviceCookieValue(token: string, maxAgeSec: number, secure: boolean): string {
325
+ const parts = [
326
+ `${DEVICE_TOKEN_COOKIE}=${encodeURIComponent(token)}`,
327
+ "Path=/api/auth",
328
+ "HttpOnly",
329
+ "SameSite=Lax",
330
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`,
331
+ ];
332
+ if (secure) parts.push("Secure");
333
+ return parts.join("; ");
334
+ }
335
+
336
+ /** 种设备令牌 Cookie(随登录/选代理商/切换/续期响应下发;仅 /api/auth 模块自动携带) */
337
+ export function setDeviceCookie(reply: any, token: string): void {
338
+ const secure = isSecureReq(reply?.request);
339
+ appendSetCookie(reply, deviceCookieValue(token, authConfig.refreshTtlMs / 1000, secure));
340
+ }
341
+
342
+ /** 清除设备令牌 Cookie(登出 / 切换设备时) */
343
+ export function clearDeviceCookie(reply: any): void {
344
+ const secure = isSecureReq(reply?.request);
345
+ appendSetCookie(reply, deviceCookieValue("", 0, secure));
346
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 开放通道调用方 secret + 命令白/黑名单解析的**通用核心**(纯算法 + 缓存,不绑定任何业务库/表/列)。
3
+ *
4
+ * ## 为什么放 node-auth(而不是 5 个业务服务各复制一份)
5
+ * 解密 / 缓存 / 命令放通这套算法**完全相同**,单点维护避免「一起改容易不同步」。
6
+ *
7
+ * ## 通用组件的边界(重要)
8
+ * 本文件**不出现任何业务字段**(如 b_开放应用 / a_开放应用 / C_AppId / C_Secret / J_白名单)。
9
+ * 「查哪张表、哪些列、启用/删除标志」一律由调用方经 `query` 回调注入——
10
+ * 因为别人用 node-auth 时,表名/列名可能是任意样子,组件无法、也不该预设。
11
+ *
12
+ * ## 数据来源:只有一条路(DB,禁止旁路)
13
+ * secret **唯一**来源是「本域开放应用表」(C_Secret 密文,AES 解密)。
14
+ * 命令白/黑名单来自同表的 J_白名单 / J_黑名单(JSON 数组,NULL=不限制)。
15
+ * 不提供环境变量 / 密库等旁路兜底——避免多人协作时各服务接不同来源、行为不一致。
16
+ * query 查不到或解密失败,统一返回 null → 调用方验签拒绝(fail-closed,全失败即拒绝)。
17
+ *
18
+ * ## 用法
19
+ * const resolve = createOpenSecretSource({
20
+ * name: "b_开放应用",
21
+ * query: async (appId) => {
22
+ * const db = getShAuthDb();
23
+ * if (!db) return null;
24
+ * const r = await db.from("b_开放应用")
25
+ * .where({ C_AppId: appId, I_删除: 0, I_启用: 1 })
26
+ * .first(["C_Secret", "J_白名单", "J_黑名单"]);
27
+ * const row = r?.row as any;
28
+ * if (!row || !row.C_Secret) return null;
29
+ * return {
30
+ * secret: row.C_Secret,
31
+ * whiteList: asStringArray(row.J_白名单),
32
+ * blackList: asStringArray(row.J_黑名单),
33
+ * };
34
+ * },
35
+ * });
36
+ * resolve(appId) -> OpenSecretBundle | null // 含 secret 明文 + 白/黑名单,供验签与命令放通
37
+ *
38
+ * 解密降级(数据格式容错,非「多来源」):未注入密钥时若拿到密文则**原样返回**(兼容库内明文残留),不抛错;
39
+ * 命中后即写入进程内缓存(默认 60s),避免每请求查库。白/黑名单随 secret 一并缓存。
40
+ */
41
+
42
+ /**
43
+ * 开放应用解析结果(secret 密文 + 命令白/黑名单)。
44
+ *
45
+ * `query` 回调从本域表取出的是库内 **C_Secret 密文** 与 **J_白名单/J_黑名单(JSON)**;
46
+ * 组件负责把 C_Secret 解密成明文、把 JSON 解析成字符串数组,最终交给验签与命令放通使用。
47
+ */
48
+ export interface OpenSecretBundle {
49
+ /** 库内 C_Secret 密文(组件会按密钥就绪情况自动解密为明文);为空视为未知调用方 */
50
+ secret: string;
51
+ /** 命令白名单(cmd 前缀数组)。空/NULL 表示不限制(全部允许) */
52
+ whiteList?: string[] | null;
53
+ /** 命令黑名单(cmd 前缀数组)。空/NULL 表示无黑名单 */
54
+ blackList?: string[] | null;
55
+ }
56
+
57
+ /**
58
+ * 把库里读出的「命令列表」字段归一化成 `string[] | null`。
59
+ * 兼容三种来源:JSON 数组(MySQL JSON 列原生)、JSON 字符串、旧式逗号串;空/NULL 返回 null。
60
+ * 由各服务 openSecret.ts 的 `query` 回调调用,避免 6 份接线各写一遍解析。
61
+ */
62
+ export function asStringArray(v: unknown): string[] | null {
63
+ if (v == null) return null;
64
+ if (Array.isArray(v)) return v.map((x) => String(x));
65
+ if (typeof v === "string") {
66
+ const s = v.trim();
67
+ if (!s) return null;
68
+ try {
69
+ const p = JSON.parse(s);
70
+ return Array.isArray(p) ? p.map((x) => String(x)) : [String(p)];
71
+ } catch {
72
+ // 兼容旧式逗号分隔串
73
+ const arr = s.split(",").map((x) => x.trim()).filter(Boolean);
74
+ return arr.length ? arr : null;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ const buckets = new Map<string, Map<string, { bundle: OpenSecretBundle; ts: number }>>();
81
+
82
+ function bucketOf(name: string): Map<string, { bundle: OpenSecretBundle; ts: number }> {
83
+ let b = buckets.get(name);
84
+ if (!b) {
85
+ b = new Map();
86
+ buckets.set(name, b);
87
+ }
88
+ return b;
89
+ }
90
+
91
+ export interface OpenSecretSourceOptions {
92
+ /** 标识:用于日志与缓存命名空间(建议传业务表名以便排查),可空。仅作标识,不参与查询。 */
93
+ name?: string;
94
+ /**
95
+ * 查库回调:传入 appId,返回 **OpenSecretBundle**(含 secret 密文 + 白/黑名单)或 null。
96
+ * 业务层在此决定表名 / 列名 / 启用删除标志(组件不预设)。
97
+ * 抛错被吞(视为 null),不让异常泄漏给调用方。
98
+ */
99
+ query: (appId: string) => Promise<OpenSecretBundle | null>;
100
+ /** 缓存 TTL(毫秒),默认 60000 */
101
+ ttlMs?: number;
102
+ }
103
+
104
+ export function createOpenSecretSource(
105
+ opts: OpenSecretSourceOptions,
106
+ ): (appId: string) => Promise<OpenSecretBundle | null> {
107
+ const ttl = opts.ttlMs ?? 60_000;
108
+ const bucket = bucketOf(opts.name ?? "open-secret");
109
+ const label = opts.name ?? "open-secret";
110
+
111
+ return async (appId: string): Promise<OpenSecretBundle | null> => {
112
+ const id = String(appId ?? "").trim();
113
+ if (!id) return null;
114
+ const now = Date.now();
115
+ const hit = bucket.get(id);
116
+ if (hit && now - hit.ts < ttl) return hit.bundle;
117
+
118
+ try {
119
+ const bundle = await opts.query(id);
120
+ if (bundle && bundle.secret && String(bundle.secret).trim()) {
121
+ // 密钥已由 shardorm C4(crypto:"encrypt" 列)在读路径透明解密,此处取到的即为明文
122
+ const secret = bundle.secret;
123
+ const b: OpenSecretBundle = {
124
+ secret: String(secret).trim(),
125
+ whiteList: bundle.whiteList ?? null,
126
+ blackList: bundle.blackList ?? null,
127
+ };
128
+ bucket.set(id, { bundle: b, ts: now });
129
+ return b;
130
+ }
131
+ } catch (e) {
132
+ // 查询失败:告警但不阻断(让验签走到 null → 拒绝),避免异常泄漏给调用方
133
+ console.warn(`[open] 查询 ${label} 失败(${id}):`, (e as Error)?.message ?? String(e));
134
+ }
135
+ return null;
136
+ };
137
+ }
@@ -0,0 +1,68 @@
1
+ /** 跨服务权限协议。这里只处理目录、集合与 HTTP,不预设业务表或身份字段。 */
2
+ export interface PermissionFeature {
3
+ code: string;
4
+ name: string;
5
+ group: string;
6
+ scope: "merchant" | "store";
7
+ requires: string[];
8
+ }
9
+ export interface PermissionCatalog {
10
+ system: string;
11
+ name: string;
12
+ version: string;
13
+ features: PermissionFeature[];
14
+ }
15
+ export interface PermissionRule {
16
+ scope: "商户" | "全部门店" | "分组" | "门店";
17
+ target: number | null;
18
+ system: string;
19
+ features: string[];
20
+ }
21
+ export function effectivePermissions(rules: PermissionRule[], storeId: number | null, ancestors: number[]): string[] {
22
+ const result = new Set<string>();
23
+ for (const rule of rules) {
24
+ const matches = storeId === null ? rule.scope === "商户" :
25
+ rule.scope === "全部门店" || rule.scope === "门店" && rule.target === storeId ||
26
+ rule.scope === "分组" && rule.target !== null && ancestors.includes(rule.target);
27
+ if (matches) for (const feature of rule.features) result.add(`${rule.system}:${feature}`);
28
+ }
29
+ return [...result].sort();
30
+ }
31
+
32
+ /** 包装模块注册,无需业务代码复制路由器或绕过 schema / 文档管线。 */
33
+ export function guardCommandRouter<T extends { register: (...args: any[]) => any }>(
34
+ router: T,
35
+ guard: (command: string, raw: any, ctx: any, signal: AbortSignal | undefined, run: () => Promise<any>) => Promise<any>,
36
+ ): T {
37
+ return new Proxy(router, {
38
+ get(target, property) {
39
+ if (property === "register") return (command: string, options: unknown, handler: (...args: any[]) => any) =>
40
+ target.register(command, options, (raw: unknown, ctx: unknown, signal?: AbortSignal) =>
41
+ guard(command, raw, ctx, signal, () => Promise.resolve(handler(raw, ctx, signal))));
42
+ const value = Reflect.get(target, property);
43
+ return typeof value === "function" ? value.bind(target) : value;
44
+ },
45
+ });
46
+ }
47
+
48
+ /** 转发用户的 Bearer;远端自行验签与解析身份,不接受调用方伪造账号。 */
49
+ export async function callPermissionApi<T>(url: string, authorization: unknown, cmd: string, payload: unknown, signal?: AbortSignal): Promise<T> {
50
+ if (typeof authorization !== "string" || !/^Bearer\s+\S+$/i.test(authorization)) {
51
+ throw Object.assign(new Error("缺少有效登录凭据"), { statusCode: 401 });
52
+ }
53
+ let response: Response;
54
+ try {
55
+ response = await fetch(url, {
56
+ method: "POST", headers: { "content-type": "application/json", authorization },
57
+ body: JSON.stringify({ cmd, payload }),
58
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000),
59
+ });
60
+ } catch {
61
+ throw Object.assign(new Error("权限服务暂不可用,请稍后重试"), { statusCode: 503 });
62
+ }
63
+ if (!response.ok) throw Object.assign(new Error("权限服务请求失败"), { statusCode: 503 });
64
+ const body = await response.json() as { result?: string; data: T; error?: string };
65
+ if (body.result !== "成功") throw Object.assign(new Error(body.error || "权限校验未通过"), { statusCode: body.result === "未登录" ? 401 : 403 });
66
+ return body.data;
67
+ }
68
+