@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
@@ -7,7 +7,7 @@
7
7
  * 输出是一张 AC 表 + 汇总行;有 FAIL 即退出码 1。`--strict` 首败即停(调试用)。
8
8
  *
9
9
  * T12 追加:upgrade / rollback(主链路)与 neg-tamper / nosig / badindex / stranger(负向)。
10
- * neg-* 统一用 9.9.9 版本与主链路隔离;篡改的文件在 finally 里回抄原始字节(不重签),
10
+ * neg-* 统一用 0.0.9 版本(低于主链路 latest 0.2.0,永远成不了 latest)与主链路隔离;篡改的文件在 finally 里回抄原始字节(不重签),
11
11
  * 收尾摘包重铺索引——否则后续 op 全挂。
12
12
  */
13
13
 
@@ -22,7 +22,7 @@ import process from "node:process";
22
22
  import { resolveLayout } from "../layout";
23
23
  import { writeIndexFile } from "../publish";
24
24
  import { signCommand } from "../sign";
25
- import { callApi, login, pollTask, unwrapEnvelope } from "../site";
25
+ import { callApi, login, pollTask, tenantHeaders, unwrapEnvelope } from "../site";
26
26
  import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
27
27
  import { buildIndex } from "./build-index";
28
28
  import { FAILURE_PLAYBOOK } from "./failure-playbook";
@@ -70,8 +70,14 @@ export interface WalkthroughResult {
70
70
  port: number
71
71
  }
72
72
 
73
- /** neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除 */
74
- const NEG_VERSION = "9.9.9";
73
+ /**
74
+ * neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除。
75
+ * 必须低于主链路 latest(0.2.0),否则发布后它会成为 latest,摘除时 REG-CS2 单调性守卫
76
+ * 会把它判成「latest 回退」而拒建索引,导致负向用例的安装拒绝断言没机会跑(9.9.9 曾踩此坑)。
77
+ */
78
+ const NEG_VERSION = "0.0.9";
79
+ /** neg-* 独立方案名:与主链路 demo-solution 隔离,避免共享迁移账本(OJ-9 降级守卫会误拦 neg 包的低 seq 迁移)与 latest 守卫串扰 */
80
+ const NEG_NAME = "neg-solution";
75
81
 
76
82
  const OPS: Op[] = [
77
83
  {
@@ -224,14 +230,14 @@ const OPS: Op[] = [
224
230
  key: "neg-tamper",
225
231
  title: "负向:包体篡改(sha256 不符)",
226
232
  run: async (ctx) => {
227
- await publishVersion(ctx, NEG_VERSION);
228
- const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
233
+ await publishVersion(ctx, NEG_VERSION, NEG_NAME);
234
+ const tgz = path.join(ctx.site.cdnDir, `${NEG_NAME}-${NEG_VERSION}.tgz`);
229
235
  const orig = fs.readFileSync(tgz);
230
236
  try {
231
237
  const tampered = Buffer.from(orig);
232
238
  tampered[Math.floor(tampered.length / 2)] ^= 0x01; // 翻转中间一个字节
233
239
  fs.writeFileSync(tgz, tampered);
234
- const task = await installOnce(ctx, NEG_VERSION);
240
+ const task = await installOnce(ctx, NEG_VERSION, NEG_NAME);
235
241
  const rejected = task.status === "failed" && task.stage === "verifying";
236
242
  const noDrop = !fs.existsSync(path.join(ctx.site.root, "api", "dist", `${ctx.name}-${NEG_VERSION}`));
237
243
  const ok = rejected && noDrop;
@@ -244,7 +250,7 @@ const OPS: Op[] = [
244
250
  }
245
251
  finally {
246
252
  fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
247
- await removeVersion(ctx, NEG_VERSION);
253
+ await removeVersion(ctx, NEG_VERSION, NEG_NAME);
248
254
  }
249
255
  },
250
256
  },
@@ -252,21 +258,21 @@ const OPS: Op[] = [
252
258
  key: "neg-nosig",
253
259
  title: "负向:缺包级签名(sig_missing)",
254
260
  run: async (ctx) => {
255
- await publishVersion(ctx, NEG_VERSION);
256
- const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
261
+ await publishVersion(ctx, NEG_VERSION, NEG_NAME);
262
+ const tgz = path.join(ctx.site.cdnDir, `${NEG_NAME}-${NEG_VERSION}.tgz`);
257
263
  const sig = `${tgz}.sig`;
258
264
  const origSig = fs.readFileSync(sig);
259
265
  try {
260
266
  fs.rmSync(sig);
261
267
  await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem }); // Form B 缺 archive.signature
262
- const task = await installOnce(ctx, NEG_VERSION);
268
+ const task = await installOnce(ctx, NEG_VERSION, NEG_NAME);
263
269
  const reason = task.error?.reason ?? task.message;
264
270
  const ok = task.status === "failed" && reason.includes("sig_missing");
265
271
  return { ok, detail: ok ? "failed,失败码 sig_missing(G4 硬 fail-closed)" : `status=${task.status}:${reason}` };
266
272
  }
267
273
  finally {
268
274
  fs.writeFileSync(sig, origSig); // 回抄原始字节
269
- await removeVersion(ctx, NEG_VERSION);
275
+ await removeVersion(ctx, NEG_VERSION, NEG_NAME);
270
276
  }
271
277
  },
272
278
  },
@@ -274,14 +280,14 @@ const OPS: Op[] = [
274
280
  key: "neg-badindex",
275
281
  title: "负向:索引篡改(验签拒载)",
276
282
  run: async (ctx) => {
277
- await publishVersion(ctx, NEG_VERSION);
283
+ await publishVersion(ctx, NEG_VERSION, NEG_NAME);
278
284
  const catalog = path.join(ctx.site.cdnDir, "catalog.yaml");
279
285
  const orig = fs.readFileSync(catalog);
280
286
  try {
281
287
  fs.appendFileSync(catalog, "# tampered\n"); // 索引字节变、签名失配
282
288
  let msg = "";
283
289
  try {
284
- await installOnce(ctx, NEG_VERSION);
290
+ await installOnce(ctx, NEG_VERSION, NEG_NAME);
285
291
  }
286
292
  catch (error) {
287
293
  msg = (error as Error).message; // 预期:安装 POST 即 502,任务都未创建
@@ -291,7 +297,7 @@ const OPS: Op[] = [
291
297
  }
292
298
  finally {
293
299
  writeIndexFile(catalog, orig.toString("utf-8")); // 回抄原始字节(也走唯一写入口,不留下直写索引的路径)
294
- await removeVersion(ctx, NEG_VERSION);
300
+ await removeVersion(ctx, NEG_VERSION, NEG_NAME);
295
301
  }
296
302
  },
297
303
  },
@@ -299,21 +305,21 @@ const OPS: Op[] = [
299
305
  key: "neg-stranger",
300
306
  title: "负向:未登记 key(TOFU 闭环)",
301
307
  run: async (ctx) => {
302
- await publishVersion(ctx, NEG_VERSION);
303
- const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
308
+ await publishVersion(ctx, NEG_VERSION, NEG_NAME);
309
+ const tgz = path.join(ctx.site.cdnDir, `${NEG_NAME}-${NEG_VERSION}.tgz`);
304
310
  const sig = `${tgz}.sig`;
305
311
  const orig = fs.readFileSync(tgz);
306
312
  const origSig = fs.readFileSync(sig);
307
313
  const stranger = derivePublisherKey(randomBytes(16).toString("hex")); // 未登记身份,私钥只在内存
308
314
  const before = await trustKeys(ctx); // 信任库快照(F3 diff 基线)
309
315
  try {
310
- signCommand(tgz, { id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem }); // 覆盖侧车签名
316
+ signCommand(tgz, { id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem, registryId: "trial.registry" }); // 覆盖侧车签名(REG-CS3 v2 规范串首段需 registry 身份)
311
317
  await buildIndex({ projectRoot: ctx.site.root, id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem });
312
- const first = await installOnce(ctx, NEG_VERSION);
318
+ const first = await installOnce(ctx, NEG_VERSION, NEG_NAME);
313
319
  if (first.status !== "needs_trust")
314
320
  return { ok: false, detail: `未停 needs_trust:status=${first.status}:${first.message}` };
315
321
  await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: first.id });
316
- const second = await installOnce(ctx, NEG_VERSION);
322
+ const second = await installOnce(ctx, NEG_VERSION, NEG_NAME);
317
323
  if (second.status !== "done")
318
324
  return { ok: false, detail: `确认后重装未 done:status=${second.status}:${second.message}` };
319
325
  // 收尾(F3):显式 revoke 陌生 key,快照 diff 断言无残留——不靠 uninstall 兜底(它本就不摘信任)
@@ -332,7 +338,7 @@ const OPS: Op[] = [
332
338
  finally {
333
339
  fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
334
340
  fs.writeFileSync(sig, origSig);
335
- await removeVersion(ctx, NEG_VERSION);
341
+ await removeVersion(ctx, NEG_VERSION, NEG_NAME);
336
342
  }
337
343
  },
338
344
  },
@@ -343,7 +349,7 @@ const OPS: Op[] = [
343
349
  const evil = path.join(ctx.site.cdnDir, `${ctx.name}-evil.tgz`);
344
350
  buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需发布进索引(file 源直传)
345
351
  const distDir = resolveLayout(ctx.site.root).apiDist;
346
- const before = fs.readdirSync(distDir).sort(); // 零副作用基线:9.9.9 目录可能是前序 neg-stranger 的合法产物
352
+ const before = fs.readdirSync(distDir).sort(); // 零副作用基线:0.0.9 目录可能是前序 neg-stranger 的合法产物
347
353
  try {
348
354
  let msg = "";
349
355
  try {
@@ -421,8 +427,8 @@ async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
421
427
  return task;
422
428
  }
423
429
 
424
- async function installOnce(ctx: Ctx, version: string): Promise<TaskRecord> {
425
- const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/install", { name: ctx.name, version, source: "cdn" });
430
+ async function installOnce(ctx: Ctx, version: string, name: string = ctx.name): Promise<TaskRecord> {
431
+ const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/install", { name, version, source: "cdn" });
426
432
  const taskId = String(started.task_id ?? started.id ?? "");
427
433
  if (taskId === "")
428
434
  throw new Error("[ojm] 安装接口未返回 task id");
@@ -438,7 +444,7 @@ async function installFile(ctx: Ctx, tgzPath: string, version: string): Promise<
438
444
  form.append("file", new Blob([fs.readFileSync(tgzPath)], { type: "application/gzip" }), path.basename(tgzPath));
439
445
  const res = await fetch(`${ctx.base}/api/_platform/market/install`, {
440
446
  method: "POST",
441
- headers: { authorization: `Bearer ${ctx.token}` },
447
+ headers: { authorization: `Bearer ${ctx.token}`, ...tenantHeaders() },
442
448
  body: form,
443
449
  });
444
450
  await unwrapEnvelope(res); // 非 0 code 抛人话(400 zip-slip / 502 索引等)
@@ -467,14 +473,14 @@ async function indexCard(ctx: Ctx): Promise<Record<string, unknown> | undefined>
467
473
  }
468
474
 
469
475
  /** 发布某版本 fixture 进隔离 CDN 并重铺索引(upgrade/rollback/neg-* 的共用前置) */
470
- async function publishVersion(ctx: Ctx, version: string): Promise<void> {
471
- await publishPackage({ projectRoot: ctx.site.root, fixture: `${ctx.name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem, registryId: "trial.registry" });
476
+ async function publishVersion(ctx: Ctx, version: string, name: string = ctx.name): Promise<void> {
477
+ await publishPackage({ projectRoot: ctx.site.root, fixture: `${name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem, registryId: "trial.registry" });
472
478
  await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
473
479
  }
474
480
 
475
481
  /** neg op 收尾:整包摘除(tgz + 侧车签名)并重铺索引,恢复主链路原状 */
476
- async function removeVersion(ctx: Ctx, version: string): Promise<void> {
477
- for (const file of [`${ctx.name}-${version}.tgz`, `${ctx.name}-${version}.tgz.sig`])
482
+ async function removeVersion(ctx: Ctx, version: string, name: string = ctx.name): Promise<void> {
483
+ for (const file of [`${name}-${version}.tgz`, `${name}-${version}.tgz.sig`])
478
484
  fs.rmSync(path.join(ctx.site.cdnDir, file), { force: true });
479
485
  await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
480
486
  }
package/src/site.ts CHANGED
@@ -6,6 +6,21 @@
6
6
 
7
7
  import process from "node:process";
8
8
 
9
+ /**
10
+ * 租户头(企微代开发多租户,v3 Phase 0):宿主 `tenant.enable: true` 时 oj 对每个请求
11
+ * **强制**要求该头——登录这类匿名端点同样不放过,缺即 `400 missing tenant header`。
12
+ * 浏览器侧由 runtime 请求层统一注入;CLI 是独立进程,必须自己带上,否则
13
+ * install / enable / list / market setup / walkthrough / trial 全线打不通站点。
14
+ * CLI 只以默认租户身份做运维操作(与 templates 的 DEFAULT_TENANT 对齐)。
15
+ */
16
+ export const TENANT_HEADER = "X-TENANT-ID";
17
+ export const DEFAULT_TENANT = "default";
18
+
19
+ /** 每个发往站点的请求都要带的租户头 */
20
+ export function tenantHeaders(): Record<string, string> {
21
+ return { [TENANT_HEADER]: DEFAULT_TENANT };
22
+ }
23
+
9
24
  /** 任务记录(线形状镜像;真源 templates/src/_platform/api/_lifecycle/types.ts:65,客户端不 import 模板内部) */
10
25
  export interface TaskRecord {
11
26
  /**
@@ -84,7 +99,7 @@ export async function unwrapEnvelope(res: { ok: boolean, status: number, text: (
84
99
  export async function login(fetchFn: typeof fetch, base: string, user: string, pass: string): Promise<string> {
85
100
  const data = await unwrapEnvelope(await fetchFn(`${base}/api/auth/login`, {
86
101
  method: "POST",
87
- headers: { "content-type": "application/json" },
102
+ headers: { "content-type": "application/json", ...tenantHeaders() },
88
103
  body: JSON.stringify({ username: user, password: pass }),
89
104
  })) as { access_token?: string };
90
105
  const token = String(data?.access_token ?? "");
@@ -99,6 +114,7 @@ export async function callApi<T>(fetchFn: typeof fetch, base: string, token: str
99
114
  method,
100
115
  headers: {
101
116
  authorization: `Bearer ${token}`,
117
+ ...tenantHeaders(),
102
118
  ...(body === undefined ? {} : { "content-type": "application/json" }),
103
119
  },
104
120
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -35,7 +35,11 @@ server:
35
35
  js_heap_limit_bytes: 268435456 # 单 isolate 堆限额(默认 256MiB,下限 32MiB)
36
36
  route_timeouts: [] # 路由级超时;pattern 按声明序首个命中,写含 base 的全路径
37
37
  response_headers: {} # 统一响应头
38
- reload_api: true # 热更端点 /ojm:reload:安装器的 reloading 段要用,关掉安装会失败
38
+ # 热更端点 /ojm:reload:安装器的 reloading 段要用,关掉安装会失败。
39
+ # 下面这行是**安装器的文本锚点**(ensureReloadApi 在锚点行下插回 reload_api: true):
40
+ # 整行必须逐字等于 `# ojm:reload`,不要删、不要加尾注。
41
+ # ojm:reload
42
+ reload_api: true
39
43
  blob_upload_max_bytes: 1073741824 # PUT blob 单文件上限(默认 1GiB;与 multipart 的 10MB 是两道闸)
40
44
  cors:
41
45
  origins: [] # 空 = 放行任意源;要带凭据必须列具体源
@@ -50,11 +54,33 @@ vars:
50
54
  # 故意不读请求头 Host(防伪造 Host 牵引出站):不配 = fail-closed,不会替你猜。
51
55
  # canonical_origin: "https://your-site.example.com"
52
56
 
57
+ # ---- 企业微信代开发(服务商侧,部署级常量)----
58
+ # 一个部署 = 一个服务商账号下的一套代开发模板,所以这 6 个值是「部署」维度,不进 DB。
59
+ # 「哪个企业装了哪个应用」不写在这里:企微 create_auth 回调推 AuthCode → 换永久授权码 →
60
+ # 落 wecom_app_profile(主键含 agent_id,同一企业测试/正式两个应用天然并存),
61
+ # 新增企业只需企业管理员扫码授权,**不改配置、不重启**。
62
+ # 六项任一为空 → 企微登录整体 400 拒绝服务(宁可不登录,也不明文存 permanent_code)。
63
+ # wecom_suite_id 代开发应用模版 id(企微后台「应用管理 → 代开发」里的 SuiteID)
64
+ # wecom_suite_secret 套件密钥(SuiteSecret)
65
+ # wecom_callback_token 「Token」(回调验签用)
66
+ # wecom_encoding_aes_key 「EncodingAESKey」(43 字符,回调解密用)
67
+ # wecom_enc_key permanent_code 落库加密密钥(hex/base64,16 或 32 字节;建议 oj secret keygen)
68
+ # wecom_base_url 连接器基址(绝对地址,无尾斜杠),如 https://site.example.com/api/auth/wecom
69
+ # → 企微后台「指令回调URL」填 <base>/event;网页授权 redirect_uri 用 <base>/callback
70
+ wecom_suite_id: ''
71
+ wecom_suite_secret: ''
72
+ wecom_callback_token: ''
73
+ wecom_encoding_aes_key: ''
74
+ wecom_enc_key: ''
75
+ wecom_base_url: ''
76
+
53
77
  platform:
54
78
  modules: [_platform] # 安装器热更 config 需要 _platform 特权;删了会在装到最后一步报 NotCapable
55
79
  root: src # 后端模块源码根(src/<name>/api 的父目录);preview 派生 config 时改写成 dist
56
80
  # 信任库默认落在安装子树之外(.solutions/trusted-keys.yaml),第三方方案模块碰不到。
57
- # 部署侧还应补 platform.trusted_paths 授权 _platform 写库、并把第三方模块 fs.root 收窄到各自安装子树。
81
+ # U6 加固:第三方模块的 fs 读写范围 = fs.root(= modules/ 安装子树),信任库必须在其外,
82
+ # 否则方案包可写自己的信任锚(伪造签名来源)。部署侧还应补 platform.trusted_paths 授权
83
+ # _platform 写库、并把第三方模块 fs.root 收窄到各自安装子树。
58
84
  # 下面三条命令与安装器逐字对齐(含每个空格),一个 token 都不能动:
59
85
  exec:
60
86
  _platform:
@@ -89,3 +115,47 @@ auth:
89
115
  - /auth/refresh
90
116
  - /auth/logout
91
117
  - /system/login-methods # 登录页「登录方式」Tabs 的数据源,未登录就要能读
118
+ - /auth/wecom/login # 企微授权入口:跳转前必然未登录
119
+ - /auth/wecom/callback # 企微 302 回跳:code 即凭证,此时还没有本地会话
120
+ - /auth/wecom/ticket # 一次性票据换 TokenBundle:票据本身就是「刚验过的登录证明」
121
+ - /auth/wecom/event # 企微指令回调:靠 msg_signature 验签,不走 Bearer
122
+
123
+ # 多租户(企微代开发 v3 Phase 0):开启后所有 {base} 请求必须带 X-TENANT-ID,否则 400。
124
+ # 前端由 tenantStore + headerProvider 统一注入(单租户部署恒为 default,UX 不变)。
125
+ tenant:
126
+ enable: true
127
+ header_key: X-TENANT-ID
128
+ sql_guard: deny # 构造器查询自动注入 tenant_id;裸 SQL 漏写/绑错租户直接拒
129
+ allow_as_tenant: true # 企微 302 回调(浏览器带不了自定义头)用 db.asTenant(服务端推导值) 补身份
130
+ # 共享表白名单:schema.yaml 里 tenant: false 的表必须在此再列一次,双声明才豁免守卫。
131
+ # 只放全局映射/凭证/框架目录,不放租户业务数据。
132
+ shared_allow:
133
+ - _oj_migrations # oj 迁移 journal(自建自管)
134
+ - sys_setting # 登录方式发现是未登录端点,此刻无租户上下文(D8)
135
+ - sys_solution_route # 站点级路由/启用注册表(安装是站点操作)
136
+ - sys_role # 框架角色目录
137
+ - sys_permission # 权限码目录
138
+ - sys_role_permission # 角色→权限映射(同上,分配关系在 sys_user_role)
139
+ - sys_rate_window # 限速窗口(登录前就要用)
140
+ - sys_login_throttle # 登录节流(登录前就要用)
141
+ - market_history # 解决方案安装/升级/回滚是站点级留痕
142
+ - reg_source_seq # 索引新鲜度水位属于站点(防回放)
143
+ - wecom_app_profile # 企微 corp→租户映射权威:查它时必然还没有租户上下文(D9)
144
+ - wecom_suite_ticket # 套件级凭证,按 suite_id 全局唯一
145
+ - wecom_login_ticket # 一次性登录票据:跨「匿名 302 回跳」与「已认证换取」两段
146
+ # 豁免缺租户头的路径(去 base 前缀后的形态)。与 auth.anonymous_paths 同语义,
147
+ # 但这是「没有租户头也不 400」的逃生口,只给**真的拿不到头**的端点。
148
+ # 登录类端点不在此列:前端 headerProvider 每请求都注头(单租户恒 default),
149
+ # 少了头应当直接 400 `missing tenant header`,而不是落进匿名态后被 sql_guard 兜成 500。
150
+ # `/auth/wecom/**` 是例外:企微回调与 302 回跳由浏览器发起,带不了自定义头,
151
+ # 租户只能由服务端从 profile/票据推导(db.asTenant,见 store.ts::ensureTenant)。
152
+ anonymous_paths:
153
+ - /system/login-methods # 登录方式发现:读共享表 sys_setting,天然无租户语义
154
+ - /auth/wecom/login # 发起授权:此时还没有任何身份
155
+ - /auth/wecom/callback # 企微 302 回跳:浏览器带不了头,租户由 state→profile 推导
156
+ - /auth/wecom/ticket # 一次性票据换会话:租户由票据行携带
157
+ - /auth/wecom/event # 企微指令回调:验签+解密即认证,无租户语义
158
+ # tenant.require_signed_claim:租户头的服务端绑定(平台级 fail-closed)。
159
+ # 暂保持 false——上游 oj 的 jwt.sign 目前会静默丢弃自定义 claim(需求 R6),
160
+ # 开启前无法把 tenant 放进 token。R6 落地后改 true,裸头即失去自证能力。
161
+ require_signed_claim: false
@@ -882,6 +882,325 @@
882
882
  }
883
883
  }
884
884
  },
885
+ "/api/auth/wecom/admin/apps": {
886
+ "get": {
887
+ "operationId": "auth_wecom_admin_apps_get",
888
+ "summary": "GET api/auth/wecom/admin/apps",
889
+ "x-oj-file": "auth/wecom/admin/api.ts",
890
+ "tags": [
891
+ "auth"
892
+ ],
893
+ "responses": {
894
+ "200": {
895
+ "description": "OK",
896
+ "x-oj-response-enforced": false,
897
+ "content": {
898
+ "application/json": {
899
+ "schema": {
900
+ "type": "object",
901
+ "required": [
902
+ "code",
903
+ "msg",
904
+ "data"
905
+ ],
906
+ "properties": {
907
+ "code": {
908
+ "type": "integer"
909
+ },
910
+ "msg": {
911
+ "type": "string"
912
+ },
913
+ "data": {
914
+ "type": "array",
915
+ "items": {
916
+ "type": "object",
917
+ "required": [
918
+ "suite_id",
919
+ "corp_id",
920
+ "agent_id",
921
+ "auth_corp_id",
922
+ "env",
923
+ "status",
924
+ "installed_at",
925
+ "update_time"
926
+ ],
927
+ "properties": {
928
+ "suite_id": {
929
+ "type": "string"
930
+ },
931
+ "corp_id": {
932
+ "type": "string"
933
+ },
934
+ "agent_id": {
935
+ "type": "string"
936
+ },
937
+ "auth_corp_id": {
938
+ "type": "string"
939
+ },
940
+ "env": {
941
+ "type": "string"
942
+ },
943
+ "corp_name": {
944
+ "type": [
945
+ "string",
946
+ "null"
947
+ ]
948
+ },
949
+ "status": {
950
+ "type": "string",
951
+ "enum": [
952
+ "active",
953
+ "disabled"
954
+ ]
955
+ },
956
+ "installed_at": {
957
+ "type": "number"
958
+ },
959
+ "update_time": {
960
+ "type": "number"
961
+ }
962
+ }
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ },
973
+ "/api/auth/wecom/admin/status": {
974
+ "post": {
975
+ "operationId": "auth_wecom_admin_status_post",
976
+ "summary": "POST api/auth/wecom/admin/status",
977
+ "x-oj-file": "auth/wecom/admin/api.ts",
978
+ "tags": [
979
+ "auth"
980
+ ],
981
+ "requestBody": {
982
+ "required": true,
983
+ "content": {
984
+ "application/json": {
985
+ "schema": {
986
+ "type": "object",
987
+ "required": [
988
+ "corpId",
989
+ "agentId",
990
+ "status"
991
+ ],
992
+ "properties": {
993
+ "corpId": {
994
+ "type": "string",
995
+ "minLength": 1
996
+ },
997
+ "agentId": {
998
+ "type": "string",
999
+ "minLength": 1
1000
+ },
1001
+ "status": {
1002
+ "type": "string",
1003
+ "enum": [
1004
+ "active",
1005
+ "disabled"
1006
+ ]
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ },
1013
+ "responses": {
1014
+ "200": {
1015
+ "description": "OK",
1016
+ "x-oj-response-enforced": false,
1017
+ "content": {
1018
+ "application/json": {
1019
+ "schema": {
1020
+ "type": "object",
1021
+ "required": [
1022
+ "code",
1023
+ "msg",
1024
+ "data"
1025
+ ],
1026
+ "properties": {
1027
+ "code": {
1028
+ "type": "integer"
1029
+ },
1030
+ "msg": {
1031
+ "type": "string"
1032
+ },
1033
+ "data": {
1034
+ "type": "object",
1035
+ "required": [
1036
+ "updated",
1037
+ "revokedSessions"
1038
+ ],
1039
+ "properties": {
1040
+ "updated": {
1041
+ "type": "number"
1042
+ },
1043
+ "revokedSessions": {
1044
+ "type": "number"
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ },
1056
+ "/api/auth/wecom/admin/install": {
1057
+ "get": {
1058
+ "operationId": "auth_wecom_admin_install_get",
1059
+ "summary": "GET api/auth/wecom/admin/install",
1060
+ "x-oj-file": "auth/wecom/admin/api.ts",
1061
+ "tags": [
1062
+ "auth"
1063
+ ],
1064
+ "responses": {
1065
+ "200": {
1066
+ "description": "OK",
1067
+ "x-oj-response-enforced": false,
1068
+ "content": {
1069
+ "application/json": {
1070
+ "schema": {
1071
+ "type": "object",
1072
+ "required": [
1073
+ "code",
1074
+ "msg",
1075
+ "data"
1076
+ ],
1077
+ "properties": {
1078
+ "code": {
1079
+ "type": "integer"
1080
+ },
1081
+ "msg": {
1082
+ "type": "string"
1083
+ },
1084
+ "data": {
1085
+ "type": "object",
1086
+ "required": [
1087
+ "installUrl",
1088
+ "callbackUrl",
1089
+ "expiresIn"
1090
+ ],
1091
+ "properties": {
1092
+ "installUrl": {
1093
+ "type": "string"
1094
+ },
1095
+ "callbackUrl": {
1096
+ "type": "string"
1097
+ },
1098
+ "expiresIn": {
1099
+ "type": "number"
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+ },
1111
+ "/api/auth/wecom/login": {
1112
+ "get": {
1113
+ "operationId": "auth_wecom_login_get",
1114
+ "summary": "GET api/auth/wecom/login",
1115
+ "x-oj-file": "auth/wecom/api.ts",
1116
+ "tags": [
1117
+ "auth"
1118
+ ],
1119
+ "responses": {
1120
+ "200": {
1121
+ "description": "OK"
1122
+ }
1123
+ }
1124
+ }
1125
+ },
1126
+ "/api/auth/wecom/callback": {
1127
+ "get": {
1128
+ "operationId": "auth_wecom_callback_get",
1129
+ "summary": "GET api/auth/wecom/callback",
1130
+ "x-oj-file": "auth/wecom/api.ts",
1131
+ "tags": [
1132
+ "auth"
1133
+ ],
1134
+ "responses": {
1135
+ "200": {
1136
+ "description": "OK"
1137
+ }
1138
+ }
1139
+ }
1140
+ },
1141
+ "/api/auth/wecom/ticket": {
1142
+ "post": {
1143
+ "operationId": "auth_wecom_ticket_post",
1144
+ "summary": "POST api/auth/wecom/ticket",
1145
+ "x-oj-file": "auth/wecom/api.ts",
1146
+ "tags": [
1147
+ "auth"
1148
+ ],
1149
+ "requestBody": {
1150
+ "required": true,
1151
+ "content": {
1152
+ "application/json": {
1153
+ "schema": {
1154
+ "type": "object",
1155
+ "required": [
1156
+ "ticket"
1157
+ ],
1158
+ "properties": {
1159
+ "ticket": {
1160
+ "type": "string",
1161
+ "minLength": 32,
1162
+ "maxLength": 128
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+ },
1169
+ "responses": {
1170
+ "200": {
1171
+ "description": "OK"
1172
+ }
1173
+ }
1174
+ }
1175
+ },
1176
+ "/api/auth/wecom/event": {
1177
+ "get": {
1178
+ "operationId": "auth_wecom_event_get",
1179
+ "summary": "GET api/auth/wecom/event",
1180
+ "x-oj-file": "auth/wecom/api.ts",
1181
+ "tags": [
1182
+ "auth"
1183
+ ],
1184
+ "responses": {
1185
+ "200": {
1186
+ "description": "OK"
1187
+ }
1188
+ }
1189
+ },
1190
+ "post": {
1191
+ "operationId": "auth_wecom_event_post",
1192
+ "summary": "POST api/auth/wecom/event",
1193
+ "x-oj-file": "auth/wecom/api.ts",
1194
+ "tags": [
1195
+ "auth"
1196
+ ],
1197
+ "responses": {
1198
+ "200": {
1199
+ "description": "OK"
1200
+ }
1201
+ }
1202
+ }
1203
+ },
885
1204
  "/api/demo/todos": {
886
1205
  "get": {
887
1206
  "operationId": "demo_todos_get",
@@ -8,8 +8,12 @@ import type { InstallContext } from "./preflight";
8
8
  import { fsFace } from "../atomic";
9
9
  import { MAX_PACKAGE_BYTES } from "../limits";
10
10
 
11
- /** 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络) */
12
- export type FetchFace = (url: string) => Promise<{
11
+ /**
12
+ * 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络)。
13
+ * init 形参**可选**(cache/头/重定向按需):少参实现仍可赋给本形,故既有 stub 不受影响;
14
+ * 探活 Probe 靠它带上租户头(见 steps/smoke.ts)。
15
+ */
16
+ export type FetchFace = (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => Promise<{
13
17
  ok: boolean
14
18
  status: number
15
19
  arrayBuffer: () => Promise<ArrayBuffer>