@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.
- package/devkit/README.md +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +392 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +226 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- 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-* 统一用
|
|
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
|
-
/**
|
|
74
|
-
|
|
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, `${
|
|
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, `${
|
|
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, `${
|
|
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(); // 零副作用基线:
|
|
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
|
|
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: `${
|
|
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 [`${
|
|
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) }),
|
package/templates/config.yaml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
package/templates/openapi.json
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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>
|