@oj-module/cli 0.2.42 → 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 (98) 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/__tests__/verify.test.ts +32 -1
  16. package/src/market/bootstrap.ts +15 -0
  17. package/src/market/setup.ts +3 -3
  18. package/src/market/verify.ts +7 -3
  19. package/src/market/walkthrough.ts +35 -29
  20. package/src/site.ts +17 -1
  21. package/templates/config.yaml +111 -101
  22. package/templates/openapi.json +319 -0
  23. package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
  24. package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
  25. package/templates/src/_platform/api/_shared/respond.ts +11 -6
  26. package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
  27. package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
  28. package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
  29. package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
  30. package/templates/src/_platform/api/contacts/api.ts +4 -1
  31. package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
  32. package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
  33. package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
  34. package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
  35. package/templates/src/_platform/api/schema.yaml +28 -0
  36. package/templates/src/_platform/api/seed.sql +3 -0
  37. package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
  38. package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
  39. package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
  40. package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
  41. package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
  42. package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
  43. package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
  44. package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
  45. package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
  46. package/templates/src/auth/api/manifest.yaml +6 -0
  47. package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
  48. package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
  49. package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
  50. package/templates/src/auth/api/schema.yaml +48 -0
  51. package/templates/src/auth/api/wecom/admin/api.ts +121 -0
  52. package/templates/src/auth/api/wecom/api.ts +226 -0
  53. package/templates/src/auth/web/__client/api.schemas.ts +36 -0
  54. package/templates/src/auth/web/__client/api.ts +148 -1
  55. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
  56. package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
  57. package/templates/src/demo/api/schema.yaml +1 -0
  58. package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
  59. package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
  60. package/templates/src/home/api/schema.yaml +2 -0
  61. package/templates/src/login/web/auth-provider.ts +48 -0
  62. package/templates/src/login/web/entry.ts +21 -1
  63. package/templates/src/login/web/locales/en-US.json +11 -1
  64. package/templates/src/login/web/locales/zh-CN.json +11 -1
  65. package/templates/src/login/web/pages/login.tsx +154 -25
  66. package/templates/src/login/web/wecom.ts +51 -0
  67. package/templates/src/notification/api/_shared/notify.ts +9 -5
  68. package/templates/src/notification/api/api.ts +25 -0
  69. package/templates/src/notification/api/del/api.ts +5 -0
  70. package/templates/src/notification/api/manifest.yaml +3 -0
  71. package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
  72. package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
  73. package/templates/src/notification/api/schema.yaml +4 -0
  74. package/templates/src/personal-center/api/profile/api.ts +13 -0
  75. package/templates/src/personal-center/api/upload/api.ts +8 -1
  76. package/templates/src/system/web/entry.ts +8 -0
  77. package/templates/src/system/web/locales/en-US.json +33 -2
  78. package/templates/src/system/web/locales/zh-CN.json +33 -2
  79. package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
  80. package/templates/src/system/web/pages/users/index.tsx +1 -1
  81. package/templates/src/system/web/pages/wecom/index.tsx +205 -0
  82. package/templates/tests/fixture.ts +91 -11
  83. package/templates/tests/guard.spec.ts +9 -3
  84. package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
  85. package/templates/tests/lifecycle/installer.spec.ts +76 -0
  86. package/templates/tests/lifecycle/trust.spec.ts +1 -1
  87. package/templates/tests/query-utils.spec.ts +12 -9
  88. package/templates/tests/rbac-guard.spec.ts +18 -1
  89. package/templates/tests/tenant-access-cache.spec.ts +99 -0
  90. package/templates/tests/tenant-forgery.spec.ts +152 -0
  91. package/templates/tests/tenant-isolation.spec.ts +133 -0
  92. package/templates/tests/username-namespace.spec.ts +118 -0
  93. package/templates/tests/wecom-admin.spec.ts +386 -0
  94. package/templates/tests/wecom-connector.spec.ts +671 -0
  95. package/templates/tests/wecom-crypt.spec.ts +155 -0
  96. package/templates/tests/wecom-endpoints.spec.ts +457 -0
  97. package/templates/tests/wecom-landing.spec.ts +54 -0
  98. package/vendor/host-versions.json +3 -3
@@ -295,7 +295,11 @@ export function expiryCheck(text: string | null): VerifyCheck {
295
295
  };
296
296
  }
297
297
 
298
- /** manifest 结构:必填字段 + 内嵌原生清单 desc 非空(等值要求已移除,见 §9.3) */
298
+ /**
299
+ * manifest 结构:必填字段 + 内嵌原生清单 desc 非空(等值要求已移除,见 §9.3)。
300
+ * 必填不含 archive:契约 C2-29 允许 Form A 整体省略,`ojm build --bundle` 对真实方案的
301
+ * 落盘形态即省略(fixture 会写 file 占位)——把它当必填会让一切真实工程包验包必红。
302
+ */
299
303
  function structureCheck(bytes: Uint8Array, file: string): VerifyCheck[] {
300
304
  let entries: ReturnType<typeof readTgz>;
301
305
  try {
@@ -308,12 +312,12 @@ function structureCheck(bytes: Uint8Array, file: string): VerifyCheck[] {
308
312
  if (root === undefined)
309
313
  return [{ item: "manifest 结构", ok: false, detail: `${file}:包内缺 manifest.yaml` }];
310
314
  const manifest = (yamlParse(root.content.toString("utf-8")) ?? {}) as Record<string, unknown>;
311
- const missing = ["name", "version", "summary", "peerRuntime", "archive"].filter(k => manifest[k] === undefined);
315
+ const missing = ["name", "version", "summary", "peerRuntime"].filter(k => manifest[k] === undefined);
312
316
  const out: VerifyCheck[] = [{
313
317
  item: "manifest 结构",
314
318
  ok: missing.length === 0,
315
319
  detail: missing.length === 0
316
- ? `${file}:必填字段齐备(name/version/summary/peerRuntime/archive)`
320
+ ? `${file}:必填字段齐备(name/version/summary/peerRuntime)`
317
321
  : `${file}:缺必填字段 ${missing.join(", ")}`,
318
322
  }];
319
323
 
@@ -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) }),
@@ -1,117 +1,87 @@
1
- # oj 服务配置(字段语义见 oj 后端开发手册 oj-api-dev / 或 oj --help)。
2
- # 本文件由 ojm init 生成;config 内相对路径相对本文件所在目录。
3
- # 更多能力见手册 §10:vars / broker / tasks / oidc / ws / html_meta / secrets。
4
- #
5
- # ---- 证书与密钥的唯一落点:`./config/`(= 工程根/config/,本文件同级的子目录)----
6
- # 三件套由 `ojm init` 现场签发(dev 缺证时 `ojm dev` 也会补签),逐把的作用与后果:
7
- #
8
- # ① private.pem —— 证书签发私钥(RSA 2048)。只用来给 ③ 签名,**oj 不读它**,
9
- # 也绝不出部署机、不入仓。丢了不需要恢复:重新签发一套即可(旧的 ③ 作废)。
10
- # ② public.pem —— 与 ① 成对的公钥,oj 启动时拿它验 ③ 的 RS256 签名。
11
- # 配错路径或与 ③ 不是同一对 → oj 验签失败、启动即拒(读作「证书无效」)。
12
- # 由 server.public_key_path 引用。
13
- # ③ cert.jws —— JWS 证书(RS256 紧凑三段,payload 仅 nbf/exp),**oj 的启动闸门**:
14
- # 缺失、过期、签名对不上 → fail-fast 拒启,devkit 手册明确**无任何 config/CLI 绕过开关**。
15
- # 默认有效期 3650 天(短有效期等于定时炸弹);过期只能重新签发一套 ①②③。
16
- # 由 server.certificate_path 引用。
17
- #
18
- # 另有一对与上面无关的密钥(jwt_secret 密封用,init 密封成功后追加到本文件末尾):
19
- # secrets.private_key_path ./config/secrets-private.pem 解 jwt_secret 密文的私钥,只放部署机
20
- # secrets.public_key_path ./config/secrets-public.pem 加密用的公钥,可随代码分发
21
- # (密文形如 ENC 后跟方括号包裹的字串;未密封时 config 里是 32 字节十六进制明文)
22
- #
23
- # ojm 侧的解析入口是 `resolveCertDir()`,由本文件位置派生;改落点只需动那一处 + 本段注释。
1
+ # oj 服务配置(字段手册见 devkit oj-api-dev / oj --help)。由 ojm init 生成,相对路径相对本文件所在目录。
24
2
 
25
- # 站点挂载(oj v0.1.58 起顶层扁平表:一行=一个 URL 前缀+一个目录,api/web 恰好其一)。
26
- # 被删除的 server.api_prefix / app_spa_fallback 已由 mounts 取代;
27
- # ojm dev 的 SPA 深链回落由 ojm 自建静态层负责;ojm preview 为单服务形态,
28
- # 现场生成派生 config(.ojm-preview.yaml,注入下面的 web 挂载 + spa: true),无需手改。
29
- #
30
- # **这里没有 api 挂载是有意的**:本工程是 `src/<name>/{api,web}` 布局,模块分散在
31
- # `src/<name>/api` 下,而 mounts 的 api 项按 `<目录>/<模块>/manifest.yaml` 单层解析、
32
- # 不会递归(实测报 `module 'x' missing manifest.yaml`)。源码形态由 `ojm dev` 用
33
- # `--module-root src` 供给,产物形态由 `ojm preview` 用 `--api-path dist/api` 供给;
34
- # 两者与 mounts 的 api 项同时出现还会报「module declared in multiple places」。
35
- # 手写部署(不经 ojm)时才需要自己加回一条 `- prefix: /api` + `api: dist`。
3
+ # ---- 证书三件套在 ./config/(init 已现场签发)----
4
+ # private.pem 签发私钥:只用于给 cert.jws 签名,oj 不读它;泄露或丢失就重签一套,旧的作废
5
+ # public.pem oj 启动时验 cert.jws 签名用(server.public_key_path)
6
+ # cert.jws 启动闸门:缺失/过期/签名不符直接拒启,无 config/CLI 绕过(默认 10 年有效期)
7
+ # 另有一对 secrets-*.pem:init 密封 jwt_secret 后追加到本文件末尾,同样只放部署机。
8
+
9
+ # 站点挂载:一行 = 一个 URL 前缀 + 一个目录(api/web 二选一)。
10
+ # 这里不放 api 挂载是有意的:本工程是 src/<name>/{api,web} 布局,源码由 ojm dev
11
+ # 以 --module-root src 供给、产物由 ojm preview 以 --api-path dist/api 供给,都不走 mounts。
12
+ # 手写部署(不用 ojm)时才需要加回一条 `- prefix: /api` + `api: dist`。
36
13
  mounts:
37
- # 本地模拟 CDN:直出工程根 cdn/(内容见 cdn/README.md)。
38
- # seed 的 framework.market_cdn_url 缺省即 /cdn(本站点自供),新工程免 market setup。
39
- # 红线:生产请删掉这一条挂载,并把 system 设置 framework.market_cdn_url 指向真 CDN(绝对地址)。
40
- # 不得给此挂载设 spa——缺文件必须返回 404,回落 index.html 会让市场解析崩。
14
+ # 本地市场 CDN:直出工程根 cdn/。seed 里 framework.market_cdn_url 缺省就是 /cdn,本站点自供免 setup。
15
+ # 生产删掉这条,并把该设置指向真 CDN(绝对地址)。不能设 spa:缺文件必须 404,
16
+ # 回落 index.html 会让市场解析崩。
41
17
  - prefix: /cdn
42
18
  web: ./cdn
43
- # REG-CS5 缓存头(读面):索引(catalog/shards)与内容寻址包体统一 60s 新鲜窗口 +
44
- # 60s stale-while-revalidate——yank 最坏 120s 生效(Q8)。ETag/If-None-Match→304 由 oj
45
- # 静态层原生支持;本头只加在读面(静态挂载),写入面(ojm market build-index)不发 HTTP 头。
19
+ # 读面缓存 60s:yank 最坏 120s 生效。304 由 oj 静态层自带,这里只管新鲜窗口。
46
20
  headers:
47
21
  Cache-Control: 'max-age=60, stale-while-revalidate=60'
48
- # 生产若由 oj 直出前端(前后端同进程,即 ojm preview 的生产形态),
49
- # 取消下一行注释并先 build 出 dist/web(preview 会自动注入等价挂载,无需手改):
22
+ # 生产要 oj 直出前端(前后端同端口的 SPA,即 ojm preview 形态)就解开下面三行,先 ojm build。
23
+ # preview 自己会注入等价挂载,平时不用动。
50
24
  # - prefix: "/"
51
- # web: "./dist/web" # 相对 config.yaml → 工程根/dist/web
25
+ # web: "./dist/web" # 相对本文件 → 工程根/dist/web
52
26
  # spa: true
53
- # 嵌套 handler 组(oj v0.1.64)无需配置开关,组织方式见 api.ts 顶部注释。
54
27
 
55
28
  server:
56
- host: 127.0.0.1 # 显式钉死:oj 默认绑 [::1],localhost 解析序在反代下有坑
29
+ host: 127.0.0.1 # 钉死 127.0.0.1:oj 默认绑 [::1],localhost 在反代下解析有坑
57
30
  port: 9778
58
31
  public_key_path: ./config/public.pem
59
32
  certificate_path: ./config/cert.jws
60
- # ---- 迁移门禁 ----
61
- # auto = dev 缺省(启动即应用);release 缺省是 verify(账本落后拒启,先 oj migrate)。
62
- # 显式写出来:dev 的隐式 DB 副作用不再靠「模式默认值」猜,release 也不会因缺省拒启。
63
- migrate_on_start: auto
64
- # ---- 入参契约(v0.1.44)----
65
- # 契约生成的 .schema 在此生效:违反即 400 且 handler 不执行。
66
- # 关掉等于退回「后端零校验」,仅在 .schema 与旧 handler 冲突时临时逃生。
67
- schema_validation: true
68
- # ---- 单 isolate 堆限额(v0.1.40,默认 256MiB,下限 32MiB)----
69
- js_heap_limit_bytes: 268435456
70
- # ---- 路由级超时(v0.1.30):pattern 按声明序首个命中,且是含 base 的全路径 ----
71
- route_timeouts: []
72
- # ---- 统一响应头(v0.1.30)----
73
- response_headers: {}
74
- # ---- 热更端点(ojm install 的 reloading 段消费;显式关闭 = 安装器 reloading 段可读失败)----
33
+ migrate_on_start: auto # auto=启动即迁移;release 建议 verify(账本落后拒启,先 oj migrate)
34
+ schema_validation: true # 契约 .schema 违反即 400;仅当 .schema 与旧 handler 冲突时临时关
35
+ js_heap_limit_bytes: 268435456 # 单 isolate 堆限额(默认 256MiB,下限 32MiB)
36
+ route_timeouts: [] # 路由级超时;pattern 按声明序首个命中,写含 base 的全路径
37
+ response_headers: {} # 统一响应头
38
+ # 热更端点 /ojm:reload:安装器的 reloading 段要用,关掉安装会失败。
39
+ # 下面这行是**安装器的文本锚点**(ensureReloadApi 在锚点行下插回 reload_api: true):
40
+ # 整行必须逐字等于 `# ojm:reload`,不要删、不要加尾注。
75
41
  # ojm:reload
76
42
  reload_api: true
77
- # ---- 直传路由 PUT {base}/blob/{key} 的体积上限(v0.1.30,默认 1GiB)----
78
- # 与 max_upload_bytes(multipart,默认 10MB)是两套闸,别混。
79
- blob_upload_max_bytes: 1073741824
80
- # ---- CORS(v0.1.35):缺这一段 = 不挂 CORS 层;credentials 必须配显式 origins ----
43
+ blob_upload_max_bytes: 1073741824 # PUT blob 单文件上限(默认 1GiB;与 multipart 的 10MB 是两道闸)
81
44
  cors:
82
- origins: [] # 留空 = 放行任意源;要带凭据则必须列出具体源
45
+ origins: [] # 空 = 放行任意源;要带凭据必须列具体源
83
46
  credentials: false
84
47
  db:
85
48
  default: 'sqlite://db.sqlite' # 相对 config 目录;缺文件自动建空库
86
49
 
87
- # ---- 安装序号(ojm install 每次安装自增一次,作为 reload 热更触发变更——unchanged 判定基于
88
- # config 文档 sha,无落盘变更则热更恒失败。勿删行/勿手工回写旧值。)----
89
50
  vars:
51
+ # ojm install 每次安装自增一次,用来触发 reload 热更。别删行、别手工回写旧值。
90
52
  ojm_install_seq: 0
91
- # canonical_origin(REG-CS6 Host 头 SSRF 修复):相对 URL(/cdn 本地自供、深检路由可达性探针)
92
- # 补全为同源绝对 URL 用的站点权威 origin;**绝不读请求头 Host/x-forwarded-proto**(伪造 Host 会牵引
93
- # 出站到攻击者指定源)。部署侧填真实对外地址(如 https://your-site.example.com);缺省留空 = fail-closed,
94
- # 相对 URL 原样透传由下层 fetch 按站点自身地址解析,深检探针则跳过(info 项),绝不猜一个源。
53
+ # 站点对外地址。装方案时服务端拿它把 /cdn 这类相对地址补全成绝对 URL(深检探针也用它)。
54
+ # 故意不读请求头 Host(防伪造 Host 牵引出站):不配 = fail-closed,不会替你猜。
95
55
  # canonical_origin: "https://your-site.example.com"
96
56
 
97
- # ---- 平台 exec 白名单(ojm install 的解压/迁移两处消费;模板逐字对齐 lifecycle steps 命令构造,
98
- # 不得增删——多一个 token 都会 NotCapable)----
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
+
99
77
  platform:
100
- # 平台特权模块名单:方案安装器的 reloading 段调 server.reloadConfig 触发热更。
101
- # 缺这条 → 装在最后一步报 NotCapable: module '_platform' is not platform-privileged。
102
- modules: [_platform]
103
- # 受控根 = 后端模块源码根(`src/<name>/api` 的父目录,即 src)。ojm preview 派生 config
104
- # 时会改写为 dist,保持与 `--api-path` 同源;手写部署请按实际模块根填写。
105
- root: src
106
- # ---- U6 信任库加固(P3-1;P2-2 红线 spike 结论)----
107
- # 威胁:devkit 0.1.75 语义下 fs: 段对全部模块生效(jail = fs.root),且 trusted_paths 只增不减——
108
- # 信任库落在第三方方案模块可写的 fs.root 内即可被其伪造信任锚。加固须两端配合:
109
- # ① 本模板已将信任库默认路径移出 modules/ 安装子树(见 _lifecycle 默认 `.solutions/trusted-keys.yaml`,
110
- # 落在 fs.root 之外的部署根,第三方方案模块 jail 之外);
111
- # ② **部署侧**须按 oj 语义补 `platform.trusted_paths`(指向运行时信任库目录,该目录须先存在——
112
- # oj 对不存在的 trusted_paths 装配 fail-fast,故不可在模板里硬编码尚未创建的目录)显式授权给
113
- # _platform 特权模块写信任库,并收窄第三方方案模块的 fs.root 至其安装子树,方可彻底隔离。
114
- # 本仓负责①(代码路径);②属部署/上游配置,留作部署清单项(P3-1 备案,不硬编码以免 oj serve 装配失败)。
78
+ modules: [_platform] # 安装器热更 config 需要 _platform 特权;删了会在装到最后一步报 NotCapable
79
+ root: src # 后端模块源码根(src/<name>/api 的父目录);preview 派生 config 时改写成 dist
80
+ # 信任库默认落在安装子树之外(.solutions/trusted-keys.yaml),第三方方案模块碰不到。
81
+ # U6 加固:第三方模块的 fs 读写范围 = fs.root(= modules/ 安装子树),信任库必须在其外,
82
+ # 否则方案包可写自己的信任锚(伪造签名来源)。部署侧还应补 platform.trusted_paths 授权
83
+ # _platform 写库、并把第三方模块 fs.root 收窄到各自安装子树。
84
+ # 下面三条命令与安装器逐字对齐(含每个空格),一个 token 都不能动:
115
85
  exec:
116
86
  _platform:
117
87
  allow:
@@ -119,18 +89,14 @@ platform:
119
89
  - 'tar -xzf ${path:archive} -C ${path:dest}'
120
90
  - 'oj migrate --format json -d ${path:dir} ${raw:module}'
121
91
 
122
- # ---- 方案安装器的落盘面(fs.* 全局)----
123
- # 安装器要解包到 modules/.staging、写 dist/modules、备份 db.sqlite——全在 config 目录内。
124
- # 缺本段 = fs.* 抛 NotCapable,且失败发生在任务文件落盘之前:表现为「POST 安装返回 200、
125
- # 但 task 查询恒 404」,排查成本极高。生产收紧口径见上面 platform 段的 U6 说明
126
- # (fs.root 收窄到安装子树 + third-party 模块 jail 隔离属部署清单项)。
92
+ # 安装器落盘面:解包到 modules/.staging、写 dist/modules、备份 db.sqlite,全在 config 目录内。
93
+ # 缺这段会 NotCapable,且症状很阴:安装提交返回 200,但 task 查询恒 404。
127
94
  fs:
128
95
  root: .
129
96
  readonly: false
130
97
 
131
- # ---- LDAP 连接段(D1/PRD §4.5 两段式之连接段;oj 插件严格解析本段,未知键启动 fail-fast——只写下列键)----
132
- # 取消注释并填值即接通目录服务;应用映射段在 DB/设置页(ldap.enabled、ldap_base_dn、
133
- # ldap_uid_attr、ldap_email_attr,group=ldap,即时生效)。本段改动属 restart_pending,需重启。
98
+ # LDAP 连接段:解开注释填值即接通目录服务;字段映射在「系统设置 → LDAP」里配,改本段要重启。
99
+ # oj 严格解析这段,只写下面这些键,多一个键启动 fail-fast。
134
100
  # ldap:
135
101
  # url: "ldap://127.0.0.1:389"
136
102
  # bind_dn: ""
@@ -138,14 +104,58 @@ fs:
138
104
  # timeout_ms: 3000
139
105
  # start_tls: false
140
106
  # tls_skip_verify: false
107
+
141
108
  auth:
142
109
  jwt_secret: __JWT_SECRET__ # init 随机生成;生产必须更换
143
110
  access_token_duration: 2h
144
111
  refresh_token_duration: 720h
145
- anonymous_paths: # auth 端点是业务路由,必须显式匿名(漏列则握手 401,见 ojm 开发手册 §8)
112
+ anonymous_paths: # 不带 token 也能访问的路径;漏列则未登录访问一律 401
146
113
  - /auth/login
147
- - /auth/ldap-login # D1/AC1-12:LDAP 目录登录(匿名)
114
+ - /auth/ldap-login # LDAP 目录登录
148
115
  - /auth/refresh
149
116
  - /auth/logout
150
- # 登录页登录方式 Tabs 数据源(C5/AC2-2):未登录即可读
151
- - /system/login-methods
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