@oj-module/cli 0.2.43 → 0.2.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/devkit/README.md +7 -5
  2. package/devkit/SKILL.md +22 -2
  3. package/devkit/manual.md +5 -0
  4. package/devkit/wecom-daikaifa.md +392 -0
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +102 -39
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/manifest-routes-derive.test.ts +22 -0
  10. package/src/__tests__/site-tenant-header.test.ts +54 -0
  11. package/src/__tests__/templates-tenant-scope.test.ts +139 -0
  12. package/src/build.ts +9 -0
  13. package/src/init.ts +4 -1
  14. package/src/install.ts +2 -2
  15. package/src/market/bootstrap.ts +15 -0
  16. package/src/market/setup.ts +3 -3
  17. package/src/market/walkthrough.ts +35 -29
  18. package/src/site.ts +17 -1
  19. package/templates/config.yaml +72 -2
  20. package/templates/openapi.json +319 -0
  21. package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
  22. package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
  23. package/templates/src/_platform/api/_shared/respond.ts +11 -6
  24. package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
  25. package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
  26. package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
  27. package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
  28. package/templates/src/_platform/api/contacts/api.ts +4 -1
  29. package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
  30. package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
  31. package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
  32. package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
  33. package/templates/src/_platform/api/schema.yaml +28 -0
  34. package/templates/src/_platform/api/seed.sql +3 -0
  35. package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
  36. package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
  37. package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
  38. package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
  39. package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
  40. package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
  41. package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
  42. package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
  43. package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
  44. package/templates/src/auth/api/manifest.yaml +6 -0
  45. package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
  46. package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
  47. package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
  48. package/templates/src/auth/api/schema.yaml +48 -0
  49. package/templates/src/auth/api/wecom/admin/api.ts +121 -0
  50. package/templates/src/auth/api/wecom/api.ts +226 -0
  51. package/templates/src/auth/web/__client/api.schemas.ts +36 -0
  52. package/templates/src/auth/web/__client/api.ts +148 -1
  53. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
  54. package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
  55. package/templates/src/demo/api/schema.yaml +1 -0
  56. package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
  57. package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
  58. package/templates/src/home/api/schema.yaml +2 -0
  59. package/templates/src/login/web/auth-provider.ts +48 -0
  60. package/templates/src/login/web/entry.ts +21 -1
  61. package/templates/src/login/web/locales/en-US.json +11 -1
  62. package/templates/src/login/web/locales/zh-CN.json +11 -1
  63. package/templates/src/login/web/pages/login.tsx +154 -25
  64. package/templates/src/login/web/wecom.ts +51 -0
  65. package/templates/src/notification/api/_shared/notify.ts +9 -5
  66. package/templates/src/notification/api/api.ts +25 -0
  67. package/templates/src/notification/api/del/api.ts +5 -0
  68. package/templates/src/notification/api/manifest.yaml +3 -0
  69. package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
  70. package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
  71. package/templates/src/notification/api/schema.yaml +4 -0
  72. package/templates/src/personal-center/api/profile/api.ts +13 -0
  73. package/templates/src/personal-center/api/upload/api.ts +8 -1
  74. package/templates/src/system/web/entry.ts +8 -0
  75. package/templates/src/system/web/locales/en-US.json +33 -2
  76. package/templates/src/system/web/locales/zh-CN.json +33 -2
  77. package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
  78. package/templates/src/system/web/pages/users/index.tsx +1 -1
  79. package/templates/src/system/web/pages/wecom/index.tsx +205 -0
  80. package/templates/tests/fixture.ts +91 -11
  81. package/templates/tests/guard.spec.ts +9 -3
  82. package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
  83. package/templates/tests/lifecycle/installer.spec.ts +76 -0
  84. package/templates/tests/lifecycle/trust.spec.ts +1 -1
  85. package/templates/tests/query-utils.spec.ts +12 -9
  86. package/templates/tests/rbac-guard.spec.ts +18 -1
  87. package/templates/tests/tenant-access-cache.spec.ts +99 -0
  88. package/templates/tests/tenant-forgery.spec.ts +152 -0
  89. package/templates/tests/tenant-isolation.spec.ts +133 -0
  90. package/templates/tests/username-namespace.spec.ts +118 -0
  91. package/templates/tests/wecom-admin.spec.ts +386 -0
  92. package/templates/tests/wecom-connector.spec.ts +671 -0
  93. package/templates/tests/wecom-crypt.spec.ts +155 -0
  94. package/templates/tests/wecom-endpoints.spec.ts +457 -0
  95. package/templates/tests/wecom-landing.spec.ts +54 -0
  96. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,243 @@
1
+ // 企微 API 客户端(代开发三级凭证链 + 网页授权换 userid)。
2
+ //
3
+ // 凭证链(devkit 未覆盖,见 docs/plans/20261030-1845-wecom-daikaifa-login-plan.md §5.4):
4
+ // suite_ticket(企微推) + suite_secret → suite_access_token(套件级,2h)
5
+ // suite_access_token + auth_code → permanent_code(= 代开发应用的 secret,属于 (suite, corp))
6
+ // permanent_code + corpid → corp access_token(**与自建应用同一个 gettoken 接口**,2h)
7
+ // corp access_token + code → userid(网页授权 snsapi_base,与自建应用同一个 auth/getuserinfo)
8
+ // 代开发与第三方应用的分叉点只有两处:取永久授权码走 `/service/v2/get_permanent_code`;
9
+ // 企业 token 走 `gettoken` 而不是 `service/get_corp_token`。
10
+ //
11
+ // 缓存:两段 token 都进 kv(oj 有 Redis 就跨进程,没有就进程内),TTL = expires_in-300。
12
+ // 失效:40001/42001 视为 token 失效 → 删缓存重取一次(企微侧提前失效是常态,不重试会把登录打成 502)。
13
+ // 错误两态红线(与 ldap-login 同):上游网络/协议/企微错误码 → 502(可重试语义);
14
+ // 用户凭据层面(code 无效/不在可见范围)→ 401(不区分原因,防探测)。
15
+
16
+ const API_BASE = "https://qyapi.weixin.qq.com";
17
+ const TIMEOUT_MS = 5000;
18
+ const TOKEN_SKEW_SECS = 300;
19
+
20
+ export interface WecomHttp {
21
+ getJson: (url: string) => Promise<Record<string, unknown>>
22
+ postJson: (url: string, body: unknown) => Promise<Record<string, unknown>>
23
+ }
24
+
25
+ export interface WecomCache {
26
+ get: (key: string) => Promise<string | null>
27
+ set: (key: string, value: string, ttlSecs: number) => Promise<unknown>
28
+ del: (key: string) => Promise<unknown>
29
+ }
30
+
31
+ export interface WecomClientDeps {
32
+ http: WecomHttp
33
+ cache: WecomCache
34
+ }
35
+
36
+ export function runtimeHttp(): WecomHttp {
37
+ async function call(url: string, init: RequestInit): Promise<Record<string, unknown>> {
38
+ let res: Response;
39
+ try {
40
+ res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
41
+ }
42
+ catch (e) {
43
+ throw upstream(`企微接口不可达:${(e as Error).message}`);
44
+ }
45
+ if (!res.ok)
46
+ throw upstream(`企微接口 HTTP ${res.status}`);
47
+ try {
48
+ return await res.json() as Record<string, unknown>;
49
+ }
50
+ catch {
51
+ throw upstream("企微接口返回非 JSON");
52
+ }
53
+ }
54
+ return {
55
+ getJson: url => call(url, { method: "GET" }),
56
+ postJson: (url, body) => call(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}) }),
57
+ };
58
+ }
59
+
60
+ export function runtimeCache(): WecomCache {
61
+ return {
62
+ get: key => kv.get(key),
63
+ // kv 没有「带 TTL 的写」,只能写值再设过期——两步之间进程退出、或 expire 失败,
64
+ // 这个键就变成「永不过期」:corp secret 轮换后旧 token 仍被当作新鲜的,
65
+ // 表现是长时间 502 而日志一片安静。故显式兜底:设不上过期就删键(宁可每请求回源)。
66
+ set: async (key, value, ttlSecs) => {
67
+ await kv.set(key, value);
68
+ if (!(await kv.expire(key, ttlSecs))) {
69
+ log.warn("wecom token cached without ttl", "key", key);
70
+ await kv.del(key);
71
+ }
72
+ },
73
+ del: key => kv.del(key),
74
+ };
75
+ }
76
+
77
+ function upstream(message: string): Error {
78
+ return Object.assign(new Error(message), { status: 502 });
79
+ }
80
+
81
+ /** code 无效 / 不在应用可见范围 / 非企业成员:统一 401(不泄露具体原因,§5.3 枚举风险) */
82
+ function rejectCode(): never {
83
+ throw Object.assign(new Error("登录凭据无效或不在应用可见范围内"), { status: 401 });
84
+ }
85
+
86
+ /** 企微错误码 → 异常。`errcode` 缺失视为成功(get_permanent_code 的历史行为)。 */
87
+ function assertOk(res: Record<string, unknown>, what: string): void {
88
+ const code = res.errcode;
89
+ if (code == null || Number(code) === 0)
90
+ return;
91
+ throw upstream(`企微 ${what} 失败:errcode=${String(code)} ${String(res.errmsg ?? "")}`.trimEnd());
92
+ }
93
+
94
+ const TOKEN_EXPIRED = new Set([40001, 40014, 42001]);
95
+
96
+ export function createWecomClient(deps: WecomClientDeps) {
97
+ const { http, cache } = deps;
98
+
99
+ async function cachedToken(key: string, load: () => Promise<{ token: string, expiresIn: number }>): Promise<string> {
100
+ const hit = await cache.get(key);
101
+ if (hit)
102
+ return hit;
103
+ const { token, expiresIn } = await load();
104
+ const ttl = Math.max(60, Math.floor(expiresIn) - TOKEN_SKEW_SECS);
105
+ await cache.set(key, token, ttl);
106
+ return token;
107
+ }
108
+
109
+ /** 套件 access_token:需要 suite_ticket(过期/缺失由调用方先拦) */
110
+ async function suiteAccessToken(suiteId: string, suiteSecret: string, suiteTicket: string): Promise<string> {
111
+ return cachedToken(`wecom:suite_token:${suiteId}`, async () => {
112
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_suite_token`, {
113
+ suite_id: suiteId,
114
+ suite_secret: suiteSecret,
115
+ suite_ticket: suiteTicket,
116
+ });
117
+ assertOk(res, "get_suite_token");
118
+ if (!res.suite_access_token)
119
+ throw upstream("企微 get_suite_token 未返回 suite_access_token");
120
+ return { token: String(res.suite_access_token), expiresIn: Number(res.expires_in ?? 7200) };
121
+ });
122
+ }
123
+
124
+ /**
125
+ * auth_code → permanent_code(+ 企业信息 + 应用列表)。
126
+ * 代开发用 `/service/v2/get_permanent_code`:返回 is_customized_app=true,且**不返回 access_token**。
127
+ * auth_code 一次有效(10 分钟内),重放只会拿到企微错误码 → 502,由调用方丢弃本条事件。
128
+ */
129
+ async function permanentCodeByAuthCode(suiteAccessToken: string, authCode: string): Promise<{
130
+ permanentCode: string
131
+ corpId: string
132
+ corpName: string | null
133
+ agentIds: string[]
134
+ state: string | null
135
+ }> {
136
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
137
+ assertOk(res, "get_permanent_code");
138
+ const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
139
+ const agentList = ((res.auth_info ?? {}) as Record<string, unknown>).agent;
140
+ const agentIds = Array.isArray(agentList)
141
+ ? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
142
+ : [];
143
+ if (!res.permanent_code || !authCorp.corpid)
144
+ throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
145
+ if (agentIds.length === 0)
146
+ throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空)");
147
+ return {
148
+ permanentCode: String(res.permanent_code),
149
+ corpId: String(authCorp.corpid),
150
+ corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
151
+ agentIds,
152
+ state: res.state == null ? null : String(res.state),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * 预授权码(开通入口用):套件级、10 分钟、一次有效,拼进 install URL 交给企业管理员点开。
158
+ * 与 permanent_code 无关——它只换「安装向导里那一屏同意」,真正的永久码仍由 create_auth 回调给。
159
+ */
160
+ async function preAuthCode(suiteAccessToken: string): Promise<{ code: string, expiresIn: number }> {
161
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_pre_auth_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {});
162
+ assertOk(res, "get_pre_auth_code");
163
+ if (!res.pre_auth_code)
164
+ throw upstream("企微 get_pre_auth_code 未返回 pre_auth_code");
165
+ return { code: String(res.pre_auth_code), expiresIn: Number(res.expires_in ?? 600) };
166
+ }
167
+
168
+ /** 企业 access_token(代开发 = 自建应用的 gettoken,corpsecret 就是 permanent_code) */
169
+ async function corpAccessToken(corpId: string, agentId: string, secret: string): Promise<string> {
170
+ return cachedToken(`wecom:corp_token:${corpId}:${agentId}`, async () => {
171
+ const res = await http.getJson(`${API_BASE}/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(secret)}`);
172
+ assertOk(res, "gettoken");
173
+ if (!res.access_token)
174
+ throw upstream("企微 gettoken 未返回 access_token");
175
+ return { token: String(res.access_token), expiresIn: Number(res.expires_in ?? 7200) };
176
+ });
177
+ }
178
+
179
+ async function invalidateCorpToken(corpId: string, agentId: string): Promise<void> {
180
+ await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
181
+ }
182
+
183
+ /**
184
+ * 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
185
+ *
186
+ * 三类失败一律返回 null,绝不抛出:套件没有通讯录读权限(60011/48002)、成员不在可见范围、
187
+ * 上游抖动。补名是体验优化,不能因为拿不到名字把登录打成 5xx——
188
+ * 调用方拿到 null 就用工号兜底。
189
+ * token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
190
+ */
191
+ async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
192
+ for (let attempt = 0; attempt < 2; attempt++) {
193
+ let token: string;
194
+ try {
195
+ token = await corpAccessToken(corpId, agentId, secret);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ let res: Record<string, unknown>;
201
+ try {
202
+ res = await http.getJson(`${API_BASE}/cgi-bin/user/get?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(userid)}`);
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ const errcode = Number(res.errcode ?? 0);
208
+ if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
209
+ await invalidateCorpToken(corpId, agentId);
210
+ continue;
211
+ }
212
+ if (errcode !== 0)
213
+ return null;
214
+ const name = res.name == null ? "" : String(res.name).trim();
215
+ return name || null;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
221
+ async function userIdByCode(corpId: string, agentId: string, secret: string, code: string): Promise<string> {
222
+ for (let attempt = 0; attempt < 2; attempt++) {
223
+ const token = await corpAccessToken(corpId, agentId, secret);
224
+ const res = await http.getJson(`${API_BASE}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}&code=${encodeURIComponent(code)}`);
225
+ const errcode = Number(res.errcode ?? 0);
226
+ if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
227
+ await invalidateCorpToken(corpId, agentId);
228
+ continue;
229
+ }
230
+ if (errcode !== 0)
231
+ rejectCode();
232
+ const userid = res.userid == null ? "" : String(res.userid);
233
+ if (!userid)
234
+ rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
235
+ return userid;
236
+ }
237
+ throw upstream("企微 token 反复失效(40001/42001)");
238
+ }
239
+
240
+ return { suiteAccessToken, permanentCodeByAuthCode, preAuthCode, corpAccessToken, invalidateCorpToken, userIdByCode, userNameById };
241
+ }
242
+
243
+ export type WecomClient = ReturnType<typeof createWecomClient>;
@@ -0,0 +1,133 @@
1
+ // 企微代开发「服务商侧」部署常量(config `vars:` 段,不进 DB、不进 sys_setting)。
2
+ //
3
+ // 多租户动态性边界:**套件(suite)是部署级的**——一个部署 = 一个服务商账号下的一套代开发模板
4
+ // (suite_id/suite_secret/callback token/EncodingAESKey 全在这套模板的后台里)。
5
+ // 「哪个企业装了哪个应用」不写配置:企微 create_auth 回调推 AuthCode → 换 permanent_code →
6
+ // 落 wecom_app_profile,新增企业只需企业管理员扫码授权,**不改配置、不重启**。
7
+ // 同一企业两个应用(测试/正式)也无需改配置:profile 主键含 agent_id(D5)。
8
+ //
9
+ // 与 ldap 同口径:启用开关走 sys_setting(`wecom.enabled`),凭据走部署常量。
10
+
11
+ import { b64ToBytes } from "./crypt";
12
+
13
+ export interface WecomConfig {
14
+ /** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
15
+ suiteId: string
16
+ suiteSecret: string
17
+ /** 回调签名 token(企微后台「Token」) */
18
+ token: string
19
+ /** 回调加密密钥(43 字符 EncodingAESKey) */
20
+ encodingAesKey: string
21
+ /** AES-GCM 落库密钥(hex/base64,16 或 32 字节):加密 permanent_code/secret */
22
+ encKey: string
23
+ /**
24
+ * 连接器基址(绝对地址,无尾斜杠),如 `https://site.example.com/api/auth/wecom`。
25
+ * 两个派生地址都由它拼出,避免「同一件事配两处、改一处漏一处」:
26
+ * · 指令回调 URL = `<base>/event` → **原样填进企微后台**(须逐字一致,含协议与路径)
27
+ * · 网页授权 redirect_uri = `<base>/callback`
28
+ */
29
+ baseUrl: string
30
+ }
31
+
32
+ const REQUIRED: Array<[keyof WecomConfig, string]> = [
33
+ ["suiteId", "wecom_suite_id"],
34
+ ["suiteSecret", "wecom_suite_secret"],
35
+ ["token", "wecom_callback_token"],
36
+ ["encodingAesKey", "wecom_encoding_aes_key"],
37
+ ["encKey", "wecom_enc_key"],
38
+ ["baseUrl", "wecom_base_url"],
39
+ ];
40
+
41
+ /**
42
+ * 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
43
+ * oj 的 aesGcmEncrypt 只收「16 或 32 字节的 hex/base64」,AES-192(24 字节)明确不支持。
44
+ * 最常见的配置事故是把 32 个「随意字符」当密钥——它按 base64 解出 24 字节,加密时才报错,
45
+ * 而那时已经是企业授权回调,排查成本最高。
46
+ */
47
+ export function assertEncKey(encKey: string): void {
48
+ const hex = /^[0-9a-f]+$/i.test(encKey) && [32, 64].includes(encKey.length);
49
+ let ok = hex;
50
+ if (!ok) {
51
+ try {
52
+ ok = [16, 32].includes(b64ToBytes(encKey).length);
53
+ }
54
+ catch {
55
+ ok = false;
56
+ }
57
+ }
58
+ if (!ok)
59
+ throw Object.assign(new Error("企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64"), { status: 400 });
60
+ }
61
+
62
+ /**
63
+ * 读部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷):
64
+ * 前半段(suite 凭据)缺失 → 企微登录整体不可用;encKey 缺失则 permanent_code 无法安全落库,
65
+ * 宁可拒服务,也不退化成明文存储(§7.5 数据合规红线)。
66
+ */
67
+ export function readWecomConfig(): WecomConfig {
68
+ const cfg: WecomConfig = {
69
+ suiteId: vars.get("wecom_suite_id") ?? "",
70
+ suiteSecret: vars.get("wecom_suite_secret") ?? "",
71
+ token: vars.get("wecom_callback_token") ?? "",
72
+ encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
73
+ encKey: vars.get("wecom_enc_key") ?? "",
74
+ baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
75
+ };
76
+ const missing = REQUIRED.filter(([field]) => !cfg[field]).map(([, name]) => name);
77
+ if (missing.length)
78
+ throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}`), { status: 400 });
79
+ if (!/^https?:\/\//.test(cfg.baseUrl))
80
+ throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
81
+ assertEncKey(cfg.encKey);
82
+ return cfg;
83
+ }
84
+
85
+ /** 企微后台「指令回调 URL」:原样填这个(base + /event) */
86
+ export function eventUrlOf(cfg: WecomConfig): string {
87
+ return `${cfg.baseUrl}/event`;
88
+ }
89
+
90
+ /** 网页授权 redirect_uri(base + /callback) */
91
+ export function loginRedirectOf(cfg: WecomConfig): string {
92
+ return `${cfg.baseUrl}/callback`;
93
+ }
94
+
95
+ /** 连接器在站点里的固定挂载后缀(模块 apiPrefix `/auth` + 平台 API 前缀 `/api`):`siteRootOf` 靠它剥离 */
96
+ const CONNECTOR_SUFFIX = "/api/auth/wecom";
97
+
98
+ /**
99
+ * 站点根(origin + 子路径前缀,无尾斜杠):302 回跳落到 SPA 时用。
100
+ *
101
+ * 不能只取 origin:子路径部署(oj `mounts: [{ prefix: /oj }]`)时 SPA 的登录页在 `/oj/login`,
102
+ * 回跳到 `/login` 会打到宿主机根的别的应用(或 404)。前缀不另配一个变量——它就等于
103
+ * `wecom_base_url` 去掉本连接器的固定后缀,配一处即可,且不匹配时**报错**而不是猜。
104
+ */
105
+ export function siteRootOf(cfg: WecomConfig): string {
106
+ const url = new URL(cfg.baseUrl);
107
+ const path = url.pathname.replace(/\/+$/, "");
108
+ if (!path.endsWith(CONNECTOR_SUFFIX))
109
+ throw Object.assign(new Error(`企业微信配置错误:wecom_base_url 必须以 ${CONNECTOR_SUFFIX} 结尾(如 https://site.example.com/oj${CONNECTOR_SUFFIX})`), { status: 400 });
110
+ return `${url.origin}${path.slice(0, -CONNECTOR_SUFFIX.length)}`;
111
+ }
112
+
113
+ /**
114
+ * 代开发「开通入口」:企业管理员点开 → 企微授权安装页。
115
+ * `redirect_uri` 落在站点根下的登录页(安装完回站点,指令回调已把 profile 落好)。
116
+ */
117
+ export function installUrlOf(cfg: WecomConfig, preAuthCode: string, state: string): string {
118
+ const redirect = `${siteRootOf(cfg)}/login?wecom_installed=1`;
119
+ const q = new URLSearchParams({
120
+ suite_id: cfg.suiteId,
121
+ pre_auth_code: preAuthCode,
122
+ redirect_uri: redirect,
123
+ });
124
+ if (state)
125
+ q.set("state", state);
126
+ return `https://open.work.weixin.qq.com/3rdapp/install?${q.toString()}`;
127
+ }
128
+
129
+ /** 登录方式发现用:只看开关,不读凭据(登录页要先知道「有没有这个方式」) */
130
+ export async function wecomEnabled(): Promise<boolean> {
131
+ const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "wecom.enabled" }).all() as Array<{ value: string | null }>;
132
+ return rows[0]?.value === "1";
133
+ }
@@ -0,0 +1,312 @@
1
+ // 企微代开发「指令回调」的验签与解密原语(Phase 1)。
2
+ //
3
+ // 为什么手写:oj isolate 的 crypto 面(0.1.110 实测,devkit global.d.ts)只有
4
+ // sha256Hex / md5Hex / aesGcm* / verifyEd25519 / verifyEd25519;企微指令回调钉死
5
+ // `msg_signature = sha1(sort(token,timestamp,nonce,Encrypt))` + `AES-256-CBC(PKCS#7)`,
6
+ // 两样都没有。处置与 _platform/api/_lifecycle/sha384.ts 一致:自实现 + 上游需求 R8
7
+ // (`crypto.sha1Hex` / `crypto.aesCbcDecrypt` 原语)。落地 R8 后本文件可直接删。
8
+ //
9
+ // 边界:只做**解密**(回包走明文 `echostr` / `success`),不做加密;输入按字节处理,
10
+ // 长度/填充非法一律抛错(不猜、不截断)——宁可事件处理失败留痕,不可解出半截明文。
11
+ // S 盒不手抄 512 字节常量表:按 FIPS 197 定义式(GF 乘法逆元 + 仿射变换)在加载期生成,
12
+ // 逆 S 盒由置换求逆(加载期一次性,<5ms)。
13
+
14
+ const BLOCK = 16;
15
+
16
+ /** GF(2^8) 乘法,域多项式 x^8+x^4+x^3+x+1(0x11B),AES 唯一乘法 */
17
+ function gmul(a: number, b: number): number {
18
+ let p = 0;
19
+ let x = a & 0xFF;
20
+ let y = b & 0xFF;
21
+ for (let i = 0; i < 8; i++) {
22
+ if (y & 1)
23
+ p ^= x;
24
+ const hi = x & 0x80;
25
+ x = (x << 1) & 0xFF;
26
+ if (hi)
27
+ x ^= 0x1B;
28
+ y >>= 1;
29
+ }
30
+ return p;
31
+ }
32
+
33
+ /** 乘法逆元(0 的逆元定义为 0):255 次试乘,加载期一次性 */
34
+ function ginv(a: number): number {
35
+ if (a === 0)
36
+ return 0;
37
+ for (let x = 1; x < 256; x++) {
38
+ if (gmul(a, x) === 1)
39
+ return x;
40
+ }
41
+ throw new Error("aes: no inverse"); // 不可达(GF(2^8) 除 0 外皆可逆)
42
+ }
43
+
44
+ const rotl8 = (v: number, n: number): number => ((v << n) | (v >>> (8 - n))) & 0xFF;
45
+
46
+ // S 盒 = 逆元 ⊕ 循环移位 1/2/3/4 ⊕ 0x63(FIPS 197 §5.1.1);逆 S 盒 = 该置换的逆
47
+ const SBOX = new Uint8Array(256);
48
+ const INV_SBOX = new Uint8Array(256);
49
+ for (let a = 0; a < 256; a++) {
50
+ const inv = ginv(a);
51
+ const s = inv ^ rotl8(inv, 1) ^ rotl8(inv, 2) ^ rotl8(inv, 3) ^ rotl8(inv, 4) ^ 0x63;
52
+ SBOX[a] = s;
53
+ INV_SBOX[s] = a;
54
+ }
55
+
56
+ const NK = 8; // AES-256:密钥 8 个 32bit 字
57
+ const NR = 14; // 轮数
58
+ const RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]; // 只需前 NR/NK = 7 项
59
+
60
+ /** 密钥扩展(FIPS 197 §5.2):返回 4*(NR+1) 个 32bit 字,字内 big-endian(b0 在高位) */
61
+ function expandKey(key: Uint8Array): Uint32Array {
62
+ if (key.length !== 32)
63
+ throw new Error(`aes: key must be 32 bytes, got ${key.length}`);
64
+ const w = new Uint32Array(4 * (NR + 1));
65
+ for (let i = 0; i < NK; i++)
66
+ w[i] = ((key[4 * i]! << 24) | (key[4 * i + 1]! << 16) | (key[4 * i + 2]! << 8) | key[4 * i + 3]!) >>> 0;
67
+ for (let i = NK; i < w.length; i++) {
68
+ let t = w[i - 1]!;
69
+ if (i % NK === 0) {
70
+ // RotWord → SubWord → Rcon
71
+ t = ((t << 8) | (t >>> 24)) >>> 0;
72
+ t = ((SBOX[(t >>> 24) & 0xFF]! << 24) | (SBOX[(t >>> 16) & 0xFF]! << 16) | (SBOX[(t >>> 8) & 0xFF]! << 8) | SBOX[t & 0xFF]!) >>> 0;
73
+ t = (t ^ (RCON[i / NK - 1]! << 24)) >>> 0;
74
+ }
75
+ else if (i % NK === 4) {
76
+ // AES-256 特有:半轮 SubWord
77
+ t = ((SBOX[(t >>> 24) & 0xFF]! << 24) | (SBOX[(t >>> 16) & 0xFF]! << 16) | (SBOX[(t >>> 8) & 0xFF]! << 8) | SBOX[t & 0xFF]!) >>> 0;
78
+ }
79
+ w[i] = (w[i - NK]! ^ t) >>> 0;
80
+ }
81
+ return w;
82
+ }
83
+
84
+ /** 状态按列优先:state[r + 4c] */
85
+ function addRoundKey(state: Uint8Array, w: Uint32Array, round: number): void {
86
+ for (let c = 0; c < 4; c++) {
87
+ const word = w[round * 4 + c]!;
88
+ state[4 * c]! ^= (word >>> 24) & 0xFF;
89
+ state[4 * c + 1]! ^= (word >>> 16) & 0xFF;
90
+ state[4 * c + 2]! ^= (word >>> 8) & 0xFF;
91
+ state[4 * c + 3]! ^= word & 0xFF;
92
+ }
93
+ }
94
+
95
+ /** InvShiftRows:第 r 行循环右移 r 位 */
96
+ function invShiftRows(s: Uint8Array): void {
97
+ const t = s.slice();
98
+ for (let r = 1; r < 4; r++) {
99
+ for (let c = 0; c < 4; c++)
100
+ s[r + 4 * c] = t[r + 4 * ((c - r + 4) % 4)]!;
101
+ }
102
+ }
103
+
104
+ function invSubBytes(s: Uint8Array): void {
105
+ for (let i = 0; i < BLOCK; i++)
106
+ s[i] = INV_SBOX[s[i]!]!;
107
+ }
108
+
109
+ /** InvMixColumns:列乘 [0e 0b 0d 09] 循环矩阵 */
110
+ function invMixColumns(s: Uint8Array): void {
111
+ for (let c = 0; c < 4; c++) {
112
+ const [a0, a1, a2, a3] = [s[4 * c]!, s[4 * c + 1]!, s[4 * c + 2]!, s[4 * c + 3]!];
113
+ s[4 * c] = gmul(a0, 0x0E) ^ gmul(a1, 0x0B) ^ gmul(a2, 0x0D) ^ gmul(a3, 0x09);
114
+ s[4 * c + 1] = gmul(a0, 0x09) ^ gmul(a1, 0x0E) ^ gmul(a2, 0x0B) ^ gmul(a3, 0x0D);
115
+ s[4 * c + 2] = gmul(a0, 0x0D) ^ gmul(a1, 0x09) ^ gmul(a2, 0x0E) ^ gmul(a3, 0x0B);
116
+ s[4 * c + 3] = gmul(a0, 0x0B) ^ gmul(a1, 0x0D) ^ gmul(a2, 0x09) ^ gmul(a3, 0x0E);
117
+ }
118
+ }
119
+
120
+ /** 单块解密(等价逆密码) */
121
+ function decryptBlock(input: Uint8Array, w: Uint32Array, out: Uint8Array): void {
122
+ const s = input.slice();
123
+ addRoundKey(s, w, NR);
124
+ for (let round = NR - 1; round >= 1; round--) {
125
+ invShiftRows(s);
126
+ invSubBytes(s);
127
+ addRoundKey(s, w, round);
128
+ invMixColumns(s);
129
+ }
130
+ invShiftRows(s);
131
+ invSubBytes(s);
132
+ addRoundKey(s, w, 0);
133
+ out.set(s);
134
+ }
135
+
136
+ /** AES-256-CBC 解密(不做填充剥离) */
137
+ export function aes256CbcDecrypt(cipher: Uint8Array, key: Uint8Array, iv: Uint8Array): Uint8Array {
138
+ if (iv.length !== BLOCK)
139
+ throw new Error(`aes: iv must be 16 bytes, got ${iv.length}`);
140
+ if (cipher.length === 0 || cipher.length % BLOCK !== 0)
141
+ throw new Error(`aes: ciphertext length must be a positive multiple of 16, got ${cipher.length}`);
142
+ const w = expandKey(key);
143
+ const out = new Uint8Array(cipher.length);
144
+ const block = new Uint8Array(BLOCK);
145
+ let prev = iv.slice();
146
+ for (let off = 0; off < cipher.length; off += BLOCK) {
147
+ decryptBlock(cipher.subarray(off, off + BLOCK), w, block);
148
+ for (let i = 0; i < BLOCK; i++) {
149
+ out[off + i] = block[i]! ^ prev[i]!;
150
+ }
151
+ prev = cipher.subarray(off, off + BLOCK).slice();
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /** PKCS#7 去填充(1..16 字节,逐字节校验;非法抛错) */
157
+ export function pkcs7Unpad(data: Uint8Array): Uint8Array {
158
+ if (data.length === 0 || data.length % BLOCK !== 0)
159
+ throw new Error("pkcs7: bad length");
160
+ const pad = data[data.length - 1]!;
161
+ if (pad < 1 || pad > BLOCK || pad > data.length)
162
+ throw new Error("pkcs7: bad padding");
163
+ for (let i = data.length - pad; i < data.length; i++) {
164
+ if (data[i] !== pad)
165
+ throw new Error("pkcs7: bad padding");
166
+ }
167
+ return data.subarray(0, data.length - pad);
168
+ }
169
+
170
+ /** SHA-1(FIPS 180-4),返回 20 字节 */
171
+ export function sha1(data: Uint8Array): Uint8Array {
172
+ const len = data.length;
173
+ const withPad = new Uint8Array((((len + 8) >> 6) + 1) * 64);
174
+ withPad.set(data);
175
+ withPad[len] = 0x80;
176
+ const bits = len * 8;
177
+ // 长度域 64bit big-endian:低 32 位足够(>512MB 的输入本就不该进 isolate)
178
+ const dv = new DataView(withPad.buffer);
179
+ dv.setUint32(withPad.length - 8, Math.floor(bits / 0x100000000));
180
+ dv.setUint32(withPad.length - 4, bits >>> 0);
181
+
182
+ let h0 = 0x67452301;
183
+ let h1 = 0xEFCDAB89;
184
+ let h2 = 0x98BADCFE;
185
+ let h3 = 0x10325476;
186
+ let h4 = 0xC3D2E1F0;
187
+ const w = new Uint32Array(80);
188
+
189
+ for (let off = 0; off < withPad.length; off += 64) {
190
+ for (let i = 0; i < 16; i++)
191
+ w[i] = dv.getUint32(off + i * 4);
192
+ for (let i = 16; i < 80; i++) {
193
+ const v = w[i - 3]! ^ w[i - 8]! ^ w[i - 14]! ^ w[i - 16]!;
194
+ w[i] = ((v << 1) | (v >>> 31)) >>> 0;
195
+ }
196
+ let a = h0;
197
+ let b = h1;
198
+ let c = h2;
199
+ let d = h3;
200
+ let e = h4;
201
+ for (let i = 0; i < 80; i++) {
202
+ let f: number;
203
+ let k: number;
204
+ if (i < 20) {
205
+ f = (b & c) | (~b & d);
206
+ k = 0x5A827999;
207
+ }
208
+ else if (i < 40) {
209
+ f = b ^ c ^ d;
210
+ k = 0x6ED9EBA1;
211
+ }
212
+ else if (i < 60) {
213
+ f = (b & c) | (b & d) | (c & d);
214
+ k = 0x8F1BBCDC;
215
+ }
216
+ else {
217
+ f = b ^ c ^ d;
218
+ k = 0xCA62C1D6;
219
+ }
220
+ const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]!) >>> 0;
221
+ e = d;
222
+ d = c;
223
+ c = ((b << 30) | (b >>> 2)) >>> 0;
224
+ b = a;
225
+ a = temp;
226
+ }
227
+ h0 = (h0 + a) >>> 0;
228
+ h1 = (h1 + b) >>> 0;
229
+ h2 = (h2 + c) >>> 0;
230
+ h3 = (h3 + d) >>> 0;
231
+ h4 = (h4 + e) >>> 0;
232
+ }
233
+
234
+ const out = new Uint8Array(20);
235
+ const odv = new DataView(out.buffer);
236
+ odv.setUint32(0, h0);
237
+ odv.setUint32(4, h1);
238
+ odv.setUint32(8, h2);
239
+ odv.setUint32(12, h3);
240
+ odv.setUint32(16, h4);
241
+ return out;
242
+ }
243
+
244
+ const encoder = new TextEncoder();
245
+ const decoder = new TextDecoder();
246
+
247
+ export function utf8Bytes(s: string): Uint8Array {
248
+ return encoder.encode(s);
249
+ }
250
+
251
+ export function bytesToUtf8(b: Uint8Array): string {
252
+ return decoder.decode(b);
253
+ }
254
+
255
+ export function bytesToHex(b: Uint8Array): string {
256
+ let out = "";
257
+ for (const x of b)
258
+ out += x.toString(16).padStart(2, "0");
259
+ return out;
260
+ }
261
+
262
+ /** 标准 base64 解码(oj v0.1.30 起有全局 atob;非法输入由 atob 抛错) */
263
+ export function b64ToBytes(s: string): Uint8Array {
264
+ const bin = atob(s.replace(/\s+/g, ""));
265
+ const out = new Uint8Array(bin.length);
266
+ for (let i = 0; i < bin.length; i++)
267
+ out[i] = bin.charCodeAt(i);
268
+ return out;
269
+ }
270
+
271
+ export function bytesToB64(b: Uint8Array): string {
272
+ let bin = "";
273
+ for (const x of b)
274
+ bin += String.fromCharCode(x);
275
+ return btoa(bin);
276
+ }
277
+
278
+ export function sha1Hex(data: string | Uint8Array): string {
279
+ return bytesToHex(sha1(typeof data === "string" ? utf8Bytes(data) : data));
280
+ }
281
+
282
+ /**
283
+ * 企微签名(明文 `signature` 与密文 `msg_signature` 同算法):
284
+ * 参数按字典序排序后拼接、sha1 取十六进制。
285
+ */
286
+ export function wecomSignature(...parts: string[]): string {
287
+ return sha1Hex(parts.slice().sort().join(""));
288
+ }
289
+
290
+ /**
291
+ * 解出企微消息明文(`<xml>…</xml>` 或裸 `echostr`)。
292
+ *
293
+ * 明文结构(企微 §消息加解密):`random(16B) | msg_len(4B, big-endian) | msg | receiveid`。
294
+ * `receiveid` 对第三方平台/代开发模版是 **suite_id**;不一致即拒(错钥/错套件混用必须 fail-closed,
295
+ * 否则不同套件的回调会互相污染)。43 字符 EncodingAESKey 末尾补 "=" 才是 44 字符 base64(32 字节)。
296
+ */
297
+ export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string, expectedReceiver: string): string {
298
+ const key = b64ToBytes(`${encodingAesKey}=`);
299
+ if (key.length !== 32)
300
+ throw new Error(`wecom: EncodingAESKey must decode to 32 bytes, got ${key.length}`);
301
+ const plain = pkcs7Unpad(aes256CbcDecrypt(b64ToBytes(encryptedB64), key, key.subarray(0, BLOCK)));
302
+ if (plain.length < 20)
303
+ throw new Error("wecom: plaintext too short");
304
+ const msgLen = new DataView(plain.buffer, plain.byteOffset, plain.byteLength).getUint32(16);
305
+ if (20 + msgLen > plain.length)
306
+ throw new Error("wecom: msg length out of range");
307
+ const msg = bytesToUtf8(plain.subarray(20, 20 + msgLen));
308
+ const receiver = bytesToUtf8(plain.subarray(20 + msgLen));
309
+ if (receiver !== expectedReceiver)
310
+ throw new Error(`wecom: receiver mismatch (got ${receiver === "" ? "<empty>" : "other"}, expected suite)`);
311
+ return msg;
312
+ }