@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.
- 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/__tests__/verify.test.ts +32 -1
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/verify.ts +7 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +111 -101
- 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
|
@@ -304,10 +304,10 @@ function getAppInfo() {
|
|
|
304
304
|
return {
|
|
305
305
|
"pkg": {
|
|
306
306
|
"name": "@oj-module/runtime",
|
|
307
|
-
"version": "0.2.
|
|
307
|
+
"version": "0.2.45",
|
|
308
308
|
"license": "MIT"
|
|
309
309
|
},
|
|
310
|
-
"lastBuildTime": "2026-10-
|
|
310
|
+
"lastBuildTime": "2026-10-31 01:51:27"
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
var init_get_app_info = __esmMin((() => {}));
|
|
@@ -7520,44 +7520,25 @@ async function loadAll(manifest) {
|
|
|
7520
7520
|
const peerRuntime = definition.peerRuntime ?? entry.peerRuntime;
|
|
7521
7521
|
if (manifest.runtimeVersion && peerRuntime && !satisfiesSemver(manifest.runtimeVersion, peerRuntime)) {
|
|
7522
7522
|
console.error(`[module-loader] 模块 "${entry.name}" 与宿主 runtime 版本不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}。已跳过加载。修复建议:升级宿主 runtime 或按兼容范围重新构建该模块(US-5)。`);
|
|
7523
|
-
modules.set(entry.name, {
|
|
7524
|
-
definition,
|
|
7525
|
-
routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
|
|
7526
|
-
status: "error",
|
|
7527
|
-
error: /* @__PURE__ */ new Error(`模块 "${entry.name}" peerRuntime 不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}`)
|
|
7528
|
-
});
|
|
7523
|
+
modules.set(entry.name, buildInstance(entry, definition, "error", /* @__PURE__ */ new Error(`模块 "${entry.name}" peerRuntime 不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}`)));
|
|
7529
7524
|
continue;
|
|
7530
7525
|
}
|
|
7531
7526
|
definitions.set(entry.name, definition);
|
|
7532
7527
|
validEntries.push(entry);
|
|
7533
|
-
modules.set(entry.name,
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
name: entry.name,
|
|
7541
|
-
description: "",
|
|
7542
|
-
version: "0.0.0",
|
|
7543
|
-
routes: []
|
|
7544
|
-
},
|
|
7545
|
-
routePrefix: entry.routePrefix ?? entry.name,
|
|
7546
|
-
status: "error",
|
|
7547
|
-
error: /* @__PURE__ */ new Error("Failed to load module entry")
|
|
7548
|
-
});
|
|
7528
|
+
modules.set(entry.name, buildInstance(entry, definition, "loaded"));
|
|
7529
|
+
} else modules.set(entry.name, buildInstance(entry, {
|
|
7530
|
+
name: entry.name,
|
|
7531
|
+
description: "",
|
|
7532
|
+
version: "0.0.0",
|
|
7533
|
+
routes: []
|
|
7534
|
+
}, "error", /* @__PURE__ */ new Error("Failed to load module entry")));
|
|
7549
7535
|
for (let i = validEntries.length - 1; i >= 0; i--) {
|
|
7550
7536
|
const entry = validEntries[i];
|
|
7551
7537
|
const definition = definitions.get(entry.name);
|
|
7552
7538
|
const missing = (definition.config?.dependencies ?? entry.dependencies ?? []).filter((dep) => !definitions.has(dep));
|
|
7553
7539
|
if (missing.length > 0) {
|
|
7554
7540
|
console.error(`[module-loader] 模块 "${entry.name}" 依赖缺失:${missing.join(", ")} 未加载。已跳过该模块(不执行生命周期、不注册路由)。修复建议:先部署依赖模块,或在清单中移除该依赖(US-9)。`);
|
|
7555
|
-
modules.set(entry.name, {
|
|
7556
|
-
definition,
|
|
7557
|
-
routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
|
|
7558
|
-
status: "missing-deps",
|
|
7559
|
-
error: /* @__PURE__ */ new Error(`模块 "${entry.name}" 依赖缺失:${missing.join(", ")}`)
|
|
7560
|
-
});
|
|
7541
|
+
modules.set(entry.name, buildInstance(entry, definition, "missing-deps", /* @__PURE__ */ new Error(`模块 "${entry.name}" 依赖缺失:${missing.join(", ")}`)));
|
|
7561
7542
|
validEntries.splice(i, 1);
|
|
7562
7543
|
definitions.delete(entry.name);
|
|
7563
7544
|
}
|
|
@@ -7565,19 +7546,14 @@ async function loadAll(manifest) {
|
|
|
7565
7546
|
const sortedEntries = topologicalSort(validEntries, definitions);
|
|
7566
7547
|
for (const entry of sortedEntries) {
|
|
7567
7548
|
const definition = definitions.get(entry.name);
|
|
7568
|
-
const ctx = createModuleContext(definition,
|
|
7549
|
+
const ctx = createModuleContext(definition, modules.get(entry.name).routePrefix);
|
|
7569
7550
|
try {
|
|
7570
7551
|
if (definition.lifecycle?.beforeInit) await definition.lifecycle.beforeInit(ctx);
|
|
7571
7552
|
if (definition.lifecycle?.onInit) await definition.lifecycle.onInit(ctx);
|
|
7572
7553
|
await mergeI18nResources(definition);
|
|
7573
7554
|
} catch (error) {
|
|
7574
7555
|
console.error(`[module-loader] Lifecycle error for "${entry.name}":`, error);
|
|
7575
|
-
modules.set(entry.name,
|
|
7576
|
-
definition,
|
|
7577
|
-
routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
|
|
7578
|
-
status: "error",
|
|
7579
|
-
error: error instanceof Error ? error : new Error(String(error))
|
|
7580
|
-
});
|
|
7556
|
+
modules.set(entry.name, buildInstance(entry, definition, "error", error instanceof Error ? error : new Error(String(error))));
|
|
7581
7557
|
}
|
|
7582
7558
|
}
|
|
7583
7559
|
/**
|
|
@@ -7633,6 +7609,8 @@ function tagModuleRoutes(routes, name) {
|
|
|
7633
7609
|
* 顶层路由 path 的首段替换为 prefix;children 为相对路径不变。
|
|
7634
7610
|
* 保持原 path 的「前导斜杠」风格:相对 `wiki` → `kb`、绝对 `/wiki` → `/kb`,
|
|
7635
7611
|
* 因此缺省 prefix = name 时路径字段逐字节不变(零漂移),仅段值一致。
|
|
7612
|
+
* 注意:多根模块(需求一)不走此改写——「首段替换」会把多个根一起覆盖成同一个 prefix
|
|
7613
|
+
* 而坍缩碰撞;多根由 getHostRoutes 另按「相对根命名空间化、绝对根原样」处理。
|
|
7636
7614
|
*/
|
|
7637
7615
|
function applyRoutePrefix(routes, prefix) {
|
|
7638
7616
|
return routes.map((route) => {
|
|
@@ -7657,6 +7635,58 @@ function rootSegmentOf(routes) {
|
|
|
7657
7635
|
if (typeof p !== "string" || p.length === 0) return void 0;
|
|
7658
7636
|
return p.replace(/^\/?/, "").split("/")[0] || void 0;
|
|
7659
7637
|
}
|
|
7638
|
+
/**
|
|
7639
|
+
* 多根模块(需求一)的相对根改写:把相对声明的根挂到 `routePrefix` 命名空间下,
|
|
7640
|
+
* 绝对路径原样保留(「顶层绝对即权威」口径,与 addRouteIdByPath 一致)。
|
|
7641
|
+
*
|
|
7642
|
+
* 为什么多根不能直接吃 `applyRoutePrefix`:那条改写是「首段替换」,多根下会把
|
|
7643
|
+
* `/crm` 与 `/wiki` 两个根一起改成同一个首段而坍缩碰撞——故多根必须另走这条路。
|
|
7644
|
+
*
|
|
7645
|
+
* 为什么相对根必须改写:一个多根模块声明 `[crm, wiki]`(相对)时,若不挂命名空间,
|
|
7646
|
+
* 两个根会直接落到站点根 `/crm`、`/wiki`——既可能撞宿主自有路由,也会与另一个同样
|
|
7647
|
+
* 相对声明的多根模块抢同一条路径(后者静默覆盖前者,菜单与路由表都看不出异常)。
|
|
7648
|
+
*/
|
|
7649
|
+
function namespaceRelativeRoots(routes, prefix) {
|
|
7650
|
+
const ns = prefix.replace(/^\/+|\/+$/g, "");
|
|
7651
|
+
if (!ns) return routes;
|
|
7652
|
+
return routes.map((route) => {
|
|
7653
|
+
const p = typeof route.path === "string" ? route.path : "";
|
|
7654
|
+
if (!p || p.startsWith("/")) return route;
|
|
7655
|
+
return {
|
|
7656
|
+
...route,
|
|
7657
|
+
path: `${ns}/${p}`
|
|
7658
|
+
};
|
|
7659
|
+
});
|
|
7660
|
+
}
|
|
7661
|
+
/**
|
|
7662
|
+
* 是否多根模块:顶层路由的「首段」去重后 > 1 即多根。
|
|
7663
|
+
* 空数组 / 单根(含单根+多子页)/ 多条同首段 → false。
|
|
7664
|
+
* 多根模块下各根按 entry.ts 原始路径独立注册为一级菜单,routePrefix 仅作命名空间。
|
|
7665
|
+
*/
|
|
7666
|
+
function isMultiRoot(routes) {
|
|
7667
|
+
const segments = /* @__PURE__ */ new Set();
|
|
7668
|
+
for (const r of routes) {
|
|
7669
|
+
const seg = (typeof r.path === "string" ? r.path : "").replace(/^\/?/, "").split("/")[0];
|
|
7670
|
+
if (seg) segments.add(seg);
|
|
7671
|
+
}
|
|
7672
|
+
return segments.size > 1;
|
|
7673
|
+
}
|
|
7674
|
+
/**
|
|
7675
|
+
* 构造模块实例(统一 routePrefix / multiRoot 计算,消除 loadAll 内散落的重复口径)。
|
|
7676
|
+
* 多根:routePrefix 回落 entry.name(命名空间,不用于路径改写);
|
|
7677
|
+
* 单根:保持 rootSegmentOf 回落(零漂移不变)。
|
|
7678
|
+
*/
|
|
7679
|
+
function buildInstance(entry, definition, status, error) {
|
|
7680
|
+
const routes = definition.routes ?? [];
|
|
7681
|
+
const multiRoot = isMultiRoot(routes);
|
|
7682
|
+
return {
|
|
7683
|
+
definition,
|
|
7684
|
+
routePrefix: entry.routePrefix ?? (multiRoot ? entry.name : rootSegmentOf(routes) ?? entry.name),
|
|
7685
|
+
multiRoot,
|
|
7686
|
+
status,
|
|
7687
|
+
error
|
|
7688
|
+
};
|
|
7689
|
+
}
|
|
7660
7690
|
function getRoutes() {
|
|
7661
7691
|
const { roles, permissions = [] } = useUserStore.getState();
|
|
7662
7692
|
const routes = [];
|
|
@@ -7682,7 +7712,9 @@ function getHostRoutes() {
|
|
|
7682
7712
|
return rewriteAdminPaths(getRoutes().map((route) => {
|
|
7683
7713
|
const mod = route.handle?.module;
|
|
7684
7714
|
if (!mod) return route;
|
|
7685
|
-
const
|
|
7715
|
+
const instance = modules.get(mod);
|
|
7716
|
+
if (instance?.multiRoot) return namespaceRelativeRoots([route], instance.routePrefix)[0] ?? route;
|
|
7717
|
+
const prefix = instance?.routePrefix ?? mod;
|
|
7686
7718
|
return applyRoutePrefix([route], prefix)[0] ?? route;
|
|
7687
7719
|
}));
|
|
7688
7720
|
}
|
|
@@ -8942,6 +8974,33 @@ var init_auth = __esmMin((() => {
|
|
|
8942
8974
|
}), { name: getAppNamespace("access-token") }));
|
|
8943
8975
|
}));
|
|
8944
8976
|
//#endregion
|
|
8977
|
+
//#region src/store/tenant.ts
|
|
8978
|
+
/**
|
|
8979
|
+
* 启动时把 `?tenant=` 落到 store(URL 优先于持久化值)。
|
|
8980
|
+
* 返回最终生效的租户,供调用方打日志/透传。
|
|
8981
|
+
*/
|
|
8982
|
+
function bootstrapTenant() {
|
|
8983
|
+
const fromUrl = new URLSearchParams(window.location.search).get(TENANT_QUERY_PARAM)?.trim();
|
|
8984
|
+
if (fromUrl) useTenantStore.getState().setTenant(fromUrl);
|
|
8985
|
+
return useTenantStore.getState().tenant;
|
|
8986
|
+
}
|
|
8987
|
+
/** 当前应携带的租户(请求层唯一取值口;空值回落部署默认,绝不发空头) */
|
|
8988
|
+
function currentTenant() {
|
|
8989
|
+
return useTenantStore.getState().tenant || "default";
|
|
8990
|
+
}
|
|
8991
|
+
var DEFAULT_TENANT, TENANT_QUERY_PARAM, TENANT_HEADER, useTenantStore;
|
|
8992
|
+
var init_tenant = __esmMin((() => {
|
|
8993
|
+
DEFAULT_TENANT = "default";
|
|
8994
|
+
TENANT_QUERY_PARAM = "tenant";
|
|
8995
|
+
TENANT_HEADER = "X-TENANT-ID";
|
|
8996
|
+
useTenantStore = create()(persist((set) => ({
|
|
8997
|
+
tenant: DEFAULT_TENANT,
|
|
8998
|
+
setTenant: (tenant) => set({ tenant: tenant.trim() || "default" }),
|
|
8999
|
+
clear: () => set({ tenant: DEFAULT_TENANT })
|
|
9000
|
+
}), { name: "oj-tenant" }));
|
|
9001
|
+
if (typeof window !== "undefined") bootstrapTenant();
|
|
9002
|
+
}));
|
|
9003
|
+
//#endregion
|
|
8945
9004
|
//#region src/utils/api-base.ts
|
|
8946
9005
|
/** 归一为不以 `/` 结尾;绝对域(http(s)://)原样剥尾斜杠;空值回落默认 */
|
|
8947
9006
|
function normalizeApiBase(raw) {
|
|
@@ -9201,6 +9260,7 @@ var init_request = __esmMin((() => {
|
|
|
9201
9260
|
init_auth();
|
|
9202
9261
|
init_header_provider();
|
|
9203
9262
|
init_preferences$3();
|
|
9263
|
+
init_tenant();
|
|
9204
9264
|
init_api_base();
|
|
9205
9265
|
init_constants$3();
|
|
9206
9266
|
init_error_response();
|
|
@@ -9221,6 +9281,7 @@ var init_request = __esmMin((() => {
|
|
|
9221
9281
|
setHeaderSafe(request.headers, AUTH_HEADER, `Bearer ${token}`);
|
|
9222
9282
|
}
|
|
9223
9283
|
setHeaderSafe(request.headers, LANG_HEADER, usePreferencesStore.getState().language);
|
|
9284
|
+
setHeaderSafe(request.headers, TENANT_HEADER, currentTenant());
|
|
9224
9285
|
const headerProvider = currentHeaderProvider();
|
|
9225
9286
|
if (headerProvider) for (const [name, value] of Object.entries(headerProvider())) setHeaderSafe(request.headers, name, value);
|
|
9226
9287
|
}],
|
|
@@ -28193,7 +28254,9 @@ init_slots();
|
|
|
28193
28254
|
init_access();
|
|
28194
28255
|
init_auth();
|
|
28195
28256
|
init_tabs();
|
|
28257
|
+
init_tenant();
|
|
28196
28258
|
init_user();
|
|
28259
|
+
init_api_base();
|
|
28197
28260
|
init_get_app_info();
|
|
28198
28261
|
init_get_redirect_path();
|
|
28199
28262
|
init_request();
|
|
@@ -28204,4 +28267,4 @@ init_ri();
|
|
|
28204
28267
|
init_menu_icons();
|
|
28205
28268
|
init_tree();
|
|
28206
28269
|
//#endregion
|
|
28207
|
-
export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, bindingDiff, canAssignRole, canDeleteRole, canEditRoleBindings, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchChangePassword, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLdapLogin, fetchLogin, fetchLoginMethods, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRbacAdminResetPassword, fetchRbacContactDetail, fetchRbacContacts, fetchRbacCreateRole, fetchRbacCreateUser, fetchRbacDeleteRole, fetchRbacOplog, fetchRbacOplogPost, fetchRbacPermissionList, fetchRbacRoleList, fetchRbacSetForbidden, fetchRbacSettings, fetchRbacUpdateBindings, fetchRbacUpdateSetting, fetchRbacUpdateUserRoles, fetchRbacUserList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchSolutionRoutes, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getHostRoutes, getModule, getModuleRoutePrefix, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, groupByModule, groupSettingsByModule, handleTree, loadAll, mapTree, menuIcons, notify, permissionPrefix, request, resolveSiteBase, roleLabel, setupI18n, traverseTreeValues, unloadModule, useAccess, useAccessStore, useAuthStore, usePreferences, useSlotNodes, useSolutionRoutes, useTabKey, useTabsStore, useUserStore, withSiteBase, external_exports as z };
|
|
28270
|
+
export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, DEFAULT_TENANT, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TENANT_HEADER, TENANT_QUERY_PARAM, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, bindingDiff, bootstrapTenant, canAssignRole, canDeleteRole, canEditRoleBindings, currentTenant, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchChangePassword, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLdapLogin, fetchLogin, fetchLoginMethods, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRbacAdminResetPassword, fetchRbacContactDetail, fetchRbacContacts, fetchRbacCreateRole, fetchRbacCreateUser, fetchRbacDeleteRole, fetchRbacOplog, fetchRbacOplogPost, fetchRbacPermissionList, fetchRbacRoleList, fetchRbacSetForbidden, fetchRbacSettings, fetchRbacUpdateBindings, fetchRbacUpdateSetting, fetchRbacUpdateUserRoles, fetchRbacUserList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchSolutionRoutes, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getHostRoutes, getModule, getModuleRoutePrefix, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, groupByModule, groupSettingsByModule, handleTree, loadAll, mapTree, menuIcons, notify, permissionPrefix, request, resolveApiBase, resolveSiteBase, roleLabel, setupI18n, traverseTreeValues, unloadModule, useAccess, useAccessStore, useAuthStore, usePreferences, useSlotNodes, useSolutionRoutes, useTabKey, useTabsStore, useTenantStore, useUserStore, withSiteBase, external_exports as z };
|
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-IiFAOepBufxbhXdsYRrEMA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="
|
|
10
|
+
<script type="importmap" nonce="IiFAOepBufxbhXdsYRrEMA==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.45",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.45",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.45",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -55,6 +55,28 @@ describe("deriveManifestRoutes(entry.ts → manifest routes)", () => {
|
|
|
55
55
|
expect(deriveManifestRoutes(def(undefined))).toEqual([]);
|
|
56
56
|
expect(deriveManifestRoutes(def([]))).toEqual([]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("d-4 多根树:每个顶层 category 根各自派生为独立 manifest route(需求一·多根)", () => {
|
|
60
|
+
// 模块 routes 数组 length>1:/crm 与 /wiki 为两个一级菜单根,各自带 children
|
|
61
|
+
const d = def([
|
|
62
|
+
{
|
|
63
|
+
path: "/crm",
|
|
64
|
+
handle: { category: "main", order: 200, title: "acme:menu.crm" },
|
|
65
|
+
children: [{ path: "/crm/customers", handle: { title: "acme:menu.customers" } }],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
path: "/wiki",
|
|
69
|
+
handle: { category: "main", order: 210, title: "acme:menu.wiki" },
|
|
70
|
+
children: [{ path: "/wiki/pages", handle: { title: "acme:menu.pages" } }],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
const out = deriveManifestRoutes(d);
|
|
74
|
+
// 仅取顶层一级菜单根(children 不进 manifest),两棵根都保留、互不坍缩
|
|
75
|
+
expect(out).toEqual([
|
|
76
|
+
{ path: "/crm", category: "main", order: 200, title_key: "acme:menu.crm" },
|
|
77
|
+
{ path: "/wiki", category: "main", order: 210, title_key: "acme:menu.wiki" },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
58
80
|
});
|
|
59
81
|
|
|
60
82
|
describe("reconcileManifestRoutes(entry.ts 为准 + 漂移警告)", () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// BDD:CLI 作为站点客户端必须自带租户头(企微代开发多租户,v3 Phase 0)。
|
|
2
|
+
//
|
|
3
|
+
// 宿主 `tenant.enable: true` 后 oj 对每个请求强制要 `X-TENANT-ID`——**登录这类
|
|
4
|
+
// 匿名端点也不放过**,缺即 `400 missing tenant header`。浏览器侧由 runtime 请求层
|
|
5
|
+
// 注入,CLI 是独立进程:2026-10-31 按 playbook 跑 `ojm market walkthrough` 时,
|
|
6
|
+
// 隔离站点刚起服就卡在登录(400 missing tenant header),install/enable/list/
|
|
7
|
+
// market setup/trial 同命运。本用例盯两件事:
|
|
8
|
+
// ① login(匿名)与 callApi(Bearer)都带 `X-TENANT-ID: default`;
|
|
9
|
+
// ② GET 无 body 时也不许因为拼头而漏掉 authorization / 误加 content-type。
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { callApi, DEFAULT_TENANT, login, TENANT_HEADER } from "../site";
|
|
12
|
+
|
|
13
|
+
interface RecordedRequest {
|
|
14
|
+
url: string
|
|
15
|
+
init: RequestInit
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 记录请求的 fetch stub:返回 oj 信封形态(Response 的最小可用面,见 unwrapEnvelope 形参) */
|
|
19
|
+
function recordingFetch(data: unknown): { fetchFn: typeof fetch, requests: RecordedRequest[] } {
|
|
20
|
+
const requests: RecordedRequest[] = [];
|
|
21
|
+
const fetchFn = (async (url: string | URL, init: RequestInit = {}) => {
|
|
22
|
+
requests.push({ url: String(url), init });
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
status: 200,
|
|
26
|
+
text: async () => JSON.stringify({ code: 0, msg: "ok", data }),
|
|
27
|
+
};
|
|
28
|
+
}) as typeof fetch;
|
|
29
|
+
return { fetchFn, requests };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const headers = (r: RecordedRequest): Record<string, string> => r.init.headers as Record<string, string>;
|
|
33
|
+
|
|
34
|
+
describe("站点客户端租户头(v3 Phase 0)", () => {
|
|
35
|
+
it("scenario: 登录(无 Bearer)也带租户头——否则服务端 400 missing tenant header", async () => {
|
|
36
|
+
const { fetchFn, requests } = recordingFetch({ access_token: "tok" });
|
|
37
|
+
|
|
38
|
+
const token = await login(fetchFn, "http://x", "u", "p");
|
|
39
|
+
|
|
40
|
+
expect(token).toBe("tok");
|
|
41
|
+
expect(headers(requests[0]!)[TENANT_HEADER]).toBe(DEFAULT_TENANT);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("scenario: 带 Bearer 的 GET / POST 都带租户头,且不干扰原有头", async () => {
|
|
45
|
+
const { fetchFn, requests } = recordingFetch({ ok: true });
|
|
46
|
+
|
|
47
|
+
await callApi<{ ok: boolean }>(fetchFn, "http://x", "tok", "GET", "/api/_platform/market/trust");
|
|
48
|
+
await callApi<{ ok: boolean }>(fetchFn, "http://x", "tok", "POST", "/api/_platform/market/install", { name: "demo" });
|
|
49
|
+
|
|
50
|
+
expect(headers(requests[0]!)).toEqual({ authorization: "Bearer tok", [TENANT_HEADER]: DEFAULT_TENANT });
|
|
51
|
+
expect(headers(requests[1]!)[TENANT_HEADER]).toBe(DEFAULT_TENANT);
|
|
52
|
+
expect(headers(requests[1]!)["content-type"]).toBe("application/json");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* BDD:模板工程的租户定性表与 oj 的 `sql_guard` 装配规则一致(企微代开发 v3 Phase 0)。
|
|
9
|
+
*
|
|
10
|
+
* oj 在 `tenant.sql_guard != Off` 时对 schema.yaml 声明的每张表强制二选一:
|
|
11
|
+
* A 租户表 —— 必须有 `tenant_id` 列(text/integer/bigint),构造器查询自动注入;
|
|
12
|
+
* B 共享表 —— schema.yaml `tenant: false` **且** config `tenant.shared_allow` 列出,双声明才豁免。
|
|
13
|
+
* 两者缺一:serve 启动 / `oj build` / `oj migrate` 三处 fail-fast。
|
|
14
|
+
*
|
|
15
|
+
* 本用例把这个隐式约定固化成可跑的断言,防的是两类真实事故:
|
|
16
|
+
* 1. 新加表忘了定性 → 上线即拒启;
|
|
17
|
+
* 2. 标了 `tenant: false` 却漏进 shared_allow(或反之)→ 声明被静默忽略,表被当租户表,守卫全拒。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const templatesRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../templates");
|
|
21
|
+
|
|
22
|
+
interface ColumnDecl {
|
|
23
|
+
type?: string
|
|
24
|
+
null?: boolean
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TableDecl {
|
|
29
|
+
tenant?: boolean
|
|
30
|
+
pk?: string | string[]
|
|
31
|
+
columns?: Record<string, ColumnDecl>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TENANT_COLUMN_TYPES = new Set(["text", "integer", "bigint"]);
|
|
35
|
+
|
|
36
|
+
function collectSchemaFiles(dir: string, out: string[] = []): string[] {
|
|
37
|
+
for (const entry of readdirSync(dir)) {
|
|
38
|
+
const full = join(dir, entry);
|
|
39
|
+
if (statSync(full).isDirectory())
|
|
40
|
+
collectSchemaFiles(full, out);
|
|
41
|
+
else if (entry === "schema.yaml")
|
|
42
|
+
out.push(full);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Loaded {
|
|
48
|
+
/** 表名 → 声明(跨模块同名表会撞,oj 侧 S002 拒启,这里同样断言唯一) */
|
|
49
|
+
tables: Map<string, TableDecl>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadSchemaTables(): Loaded {
|
|
53
|
+
const tables = new Map<string, TableDecl>();
|
|
54
|
+
for (const file of collectSchemaFiles(join(templatesRoot, "src"))) {
|
|
55
|
+
const doc = parse(readFileSync(file, "utf-8")) as { tables?: Record<string, TableDecl> } | null;
|
|
56
|
+
for (const [name, decl] of Object.entries(doc?.tables ?? {})) {
|
|
57
|
+
if (tables.has(name))
|
|
58
|
+
throw new Error(`表 ${name} 被两个模块 schema.yaml 重复声明(${file})`);
|
|
59
|
+
tables.set(name, decl);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { tables };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadTenantConfig(): {
|
|
66
|
+
enable?: boolean
|
|
67
|
+
sql_guard?: string | boolean
|
|
68
|
+
shared_allow?: string[]
|
|
69
|
+
allow_as_tenant?: boolean
|
|
70
|
+
anonymous_paths?: unknown[]
|
|
71
|
+
} {
|
|
72
|
+
const doc = parse(readFileSync(join(templatesRoot, "config.yaml"), "utf-8")) as { tenant?: Record<string, unknown> };
|
|
73
|
+
return (doc.tenant ?? {}) as never;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("templates 租户定性(A 租户表 / B 共享表)", () => {
|
|
77
|
+
it("t-1 config 已开启多租户且守卫为 deny", () => {
|
|
78
|
+
const tenant = loadTenantConfig();
|
|
79
|
+
expect(tenant.enable).toBe(true);
|
|
80
|
+
expect(tenant.sql_guard).toBe("deny");
|
|
81
|
+
expect(tenant.allow_as_tenant).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("t-2 每张表二选一:有 tenant_id 列,或 tenant:false + shared_allow 双声明", () => {
|
|
85
|
+
const { tables } = loadSchemaTables();
|
|
86
|
+
const sharedAllow = new Set(loadTenantConfig().shared_allow ?? []);
|
|
87
|
+
const violations: string[] = [];
|
|
88
|
+
|
|
89
|
+
for (const [name, decl] of tables) {
|
|
90
|
+
const shared = decl.tenant === false;
|
|
91
|
+
const tenantColumn = decl.columns?.tenant_id;
|
|
92
|
+
|
|
93
|
+
if (shared) {
|
|
94
|
+
if (!sharedAllow.has(name))
|
|
95
|
+
violations.push(`${name}: 标了 tenant:false 但不在 config tenant.shared_allow(声明会被忽略)`);
|
|
96
|
+
if (tenantColumn)
|
|
97
|
+
violations.push(`${name}: 既标 tenant:false 又声明 tenant_id 列(自相矛盾)`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!tenantColumn) {
|
|
102
|
+
violations.push(`${name}: 无 tenant_id 列且未标 tenant:false(sql_guard 非 Off 时启动即拒)`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!TENANT_COLUMN_TYPES.has(String(tenantColumn.type)))
|
|
106
|
+
violations.push(`${name}.tenant_id 类型 ${String(tenantColumn.type)} 不合法(只能是 text/integer/bigint)`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
expect(violations).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("t-3 shared_allow 不含未声明的表名(拼写错误会让豁免静默失效)", () => {
|
|
113
|
+
const { tables } = loadSchemaTables();
|
|
114
|
+
const unknown = (loadTenantConfig().shared_allow ?? []).filter(name => !tables.has(name));
|
|
115
|
+
expect(unknown).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("t-4 24 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
|
|
119
|
+
const { tables } = loadSchemaTables();
|
|
120
|
+
const shared = [...tables].filter(([, d]) => d.tenant === false).map(([n]) => n).sort();
|
|
121
|
+
// Phase 0 落地 21 张(11 A + 10 B);Phase 1 加企微三表,全部 B 组(D9:查它们时必然还没有租户上下文)
|
|
122
|
+
expect(tables.size).toBe(24);
|
|
123
|
+
expect(shared).toEqual([
|
|
124
|
+
"_oj_migrations",
|
|
125
|
+
"market_history",
|
|
126
|
+
"reg_source_seq",
|
|
127
|
+
"sys_login_throttle",
|
|
128
|
+
"sys_permission",
|
|
129
|
+
"sys_rate_window",
|
|
130
|
+
"sys_role",
|
|
131
|
+
"sys_role_permission",
|
|
132
|
+
"sys_setting",
|
|
133
|
+
"sys_solution_route",
|
|
134
|
+
"wecom_app_profile",
|
|
135
|
+
"wecom_login_ticket",
|
|
136
|
+
"wecom_suite_ticket",
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/build.ts
CHANGED
|
@@ -112,6 +112,13 @@ export const useAuthStore = _fn;
|
|
|
112
112
|
export const useSolutionRoutes = _fn;
|
|
113
113
|
export const useUserStore = _fn;
|
|
114
114
|
export const useTabsStore = _fn;
|
|
115
|
+
// 租户上下文(L2):请求头由运行时注入,模块只在登出清理 / URL 带 tenant 参数时读写
|
|
116
|
+
export const useTenantStore = _fn;
|
|
117
|
+
export const currentTenant = () => "default";
|
|
118
|
+
export const bootstrapTenant = () => "default";
|
|
119
|
+
export const DEFAULT_TENANT = "default";
|
|
120
|
+
export const TENANT_HEADER = "X-TENANT-ID";
|
|
121
|
+
export const TENANT_QUERY_PARAM = "tenant";
|
|
115
122
|
// 页签 key 唯一正确取口(keepAlive 下 useLocation 是激活页位置,0.1.21)
|
|
116
123
|
export const useTabKey = _fn;
|
|
117
124
|
// 图标
|
|
@@ -148,6 +155,8 @@ export const getRedirectPath = _fn;
|
|
|
148
155
|
// 站点部署 base(子路径部署):stub 只需占位,元数据读取期不拼 URL
|
|
149
156
|
export const resolveSiteBase = _fn;
|
|
150
157
|
export const withSiteBase = _fn;
|
|
158
|
+
// API 端点 base(整页跳转的后端端点、上传 action 用它拼 URL,与站点 base 正交)
|
|
159
|
+
export const resolveApiBase = _fn;
|
|
151
160
|
export const getYesNoOptions = _fn;
|
|
152
161
|
export const getBooleanOptions = _fn;
|
|
153
162
|
// 模块契约
|
package/src/init.ts
CHANGED
|
@@ -193,7 +193,10 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
193
193
|
if (fs.existsSync(path.join(cliDevkit, "SKILL.md"))) {
|
|
194
194
|
const ojmSkillDir = path.join(destDir, ".claude/skills/ojm-module-dev");
|
|
195
195
|
fs.mkdirSync(ojmSkillDir, { recursive: true });
|
|
196
|
-
|
|
196
|
+
// `wecom-daikaifa.md`(企业微信代开发:凭据清单 + 用户/开发/部署三视角)
|
|
197
|
+
// 随之分发——它讲的是「怎么把一个已经写好的连接器接上真实套件」,
|
|
198
|
+
// 业务工程拿到模板后自助完成对接要用,不能只留本仓。
|
|
199
|
+
for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md"])
|
|
197
200
|
copyIfAbsent(path.join(cliDevkit, name), path.join(ojmSkillDir, name), report);
|
|
198
201
|
}
|
|
199
202
|
else {
|
package/src/install.ts
CHANGED
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import process from "node:process";
|
|
11
11
|
import { UsageError } from "./exit-codes";
|
|
12
12
|
import { originOf, resolveRegistrySource } from "./registry-source";
|
|
13
|
-
import { callApi, isFutureSchema, login, pollTask, resolveSite, TASK_SCHEMA_VERSION_KNOWN, unwrapEnvelope } from "./site";
|
|
13
|
+
import { callApi, isFutureSchema, login, pollTask, resolveSite, TASK_SCHEMA_VERSION_KNOWN, tenantHeaders, unwrapEnvelope } from "./site";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* REG-R5 / S5-2②:安装目标的**唯一**归一点(grep 可证——全仓只有这一处把 `@scope/name`
|
|
@@ -316,7 +316,7 @@ export async function installCommand(target: string, opts: InstallOptions = {}):
|
|
|
316
316
|
}
|
|
317
317
|
const res = await fetchFn(`${base}/api/_platform/market/install`, {
|
|
318
318
|
method: "POST",
|
|
319
|
-
headers: { authorization: `Bearer ${token}
|
|
319
|
+
headers: { authorization: `Bearer ${token}`, ...tenantHeaders() },
|
|
320
320
|
body: form,
|
|
321
321
|
});
|
|
322
322
|
response = await unwrapInstall(res);
|
|
@@ -11,13 +11,16 @@ import { Buffer } from "node:buffer";
|
|
|
11
11
|
import { generateKeyPairSync } from "node:crypto";
|
|
12
12
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
15
|
|
|
16
16
|
import { describe, expect, it } from "vitest";
|
|
17
17
|
import { parse, stringify } from "yaml";
|
|
18
|
+
import { createDeterministicTgz } from "../../deterministic-tar";
|
|
18
19
|
import { recomputeIndex, signIndex } from "../../publish";
|
|
20
|
+
import { signCommand } from "../../sign";
|
|
19
21
|
import { buildIndex } from "../build-index";
|
|
20
22
|
import { publishPackage } from "../publish";
|
|
23
|
+
import { readTgz } from "../scan";
|
|
21
24
|
import { verifyMarket } from "../verify";
|
|
22
25
|
|
|
23
26
|
const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
|
|
@@ -72,6 +75,34 @@ describe("given 一个正常落地的包 When 离线验包", () => {
|
|
|
72
75
|
});
|
|
73
76
|
});
|
|
74
77
|
|
|
78
|
+
describe("given 真实工程包(manifest 按 C2-29 整体省略 archive 占位)When 离线验包", () => {
|
|
79
|
+
it("then manifest 结构不因此打叉(REG-CS8 回归:ojm build --bundle 不落 archive ≠ 缺必填)", async () => {
|
|
80
|
+
const root = await seed();
|
|
81
|
+
const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
|
|
82
|
+
// 解包 → 摘掉根 manifest 的 archive(fixture 有、真实方案无的字段差)→ 确定性重打包 → 重签 → 重建索引
|
|
83
|
+
const staging = mkdtempSync(join(tmpdir(), "ojm-verify-noarchive-"));
|
|
84
|
+
for (const e of readTgz(new Uint8Array(readFileSync(tgz)))) {
|
|
85
|
+
if (e.name.endsWith("/"))
|
|
86
|
+
continue;
|
|
87
|
+
const abs = join(staging, e.name);
|
|
88
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
89
|
+
writeFileSync(abs, e.content);
|
|
90
|
+
}
|
|
91
|
+
const rootManifestPath = join(staging, "manifest.yaml");
|
|
92
|
+
const manifest = parse(readFileSync(rootManifestPath, "utf-8")) as Record<string, unknown>;
|
|
93
|
+
delete manifest.archive;
|
|
94
|
+
writeFileSync(rootManifestPath, stringify(manifest));
|
|
95
|
+
await createDeterministicTgz(staging, tgz);
|
|
96
|
+
signCommand(tgz, { id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
97
|
+
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
98
|
+
|
|
99
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
100
|
+
|
|
101
|
+
expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
|
|
102
|
+
rmSync(staging, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
75
106
|
describe("given 预发布版本(文件名含连字符)包体被篡改 When 离线验包", () => {
|
|
76
107
|
it("then 完整性仍能检出(不再因文件名解析错配而静默放过,REG-CS1 边界)", async () => {
|
|
77
108
|
const root = newProject();
|
package/src/market/bootstrap.ts
CHANGED
|
@@ -138,6 +138,21 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
138
138
|
throw new Error(`[ojm] 模板 config 未含 端口: 9778 字面(模板结构变了?):${configPath}`);
|
|
139
139
|
}
|
|
140
140
|
cfg = cfg.replace("__JWT_SECRET__", randomBytes(32).toString("hex"));
|
|
141
|
+
// 站点对外地址:安装的相对市场基址(/cdn 本站点自供)与深检探针都靠它补全绝对 URL,
|
|
142
|
+
// 服务端 REG-CS6 刻意不读请求头 Host(防伪造 Host 牵引出站),**缺即拒、不猜**。
|
|
143
|
+
// 隔离站点的对外地址恒为它自己的 origin(端口本次随机派开),这里替部署侧填好。
|
|
144
|
+
const originLine = ` canonical_origin: "http://127.0.0.1:${port}"`;
|
|
145
|
+
if (cfg.includes("canonical_origin:")) {
|
|
146
|
+
cfg = cfg.split("\n").map(line => (line.includes("canonical_origin:") ? originLine : line)).join("\n");
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
cfg = cfg.includes("\nvars:")
|
|
150
|
+
? cfg.replace("\nvars:", `\nvars:\n${originLine}`)
|
|
151
|
+
: `${cfg}\nvars:\n${originLine}`;
|
|
152
|
+
}
|
|
153
|
+
if (!cfg.includes(`http://127.0.0.1:${port}`)) {
|
|
154
|
+
throw new Error(`[ojm] 模板 config 未含 vars 段(模板结构变了?):${configPath}`);
|
|
155
|
+
}
|
|
141
156
|
// 最小 SPA 挂载:真实部署里前端路由(深检会逐条探活)由站点壳承接;没有 web 挂载时
|
|
142
157
|
// 这些路径一律 404,深检会判成 anomaly。这里放一个 index.html 就够(不构建前端)。
|
|
143
158
|
const siteDir = path.join(root, "site");
|
package/src/market/setup.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
16
|
import process from "node:process";
|
|
17
17
|
|
|
18
|
-
import { resolveSite } from "../site";
|
|
18
|
+
import { resolveSite, tenantHeaders } from "../site";
|
|
19
19
|
import { resolveCdnLayout } from "./cdn-layout";
|
|
20
20
|
|
|
21
21
|
export const MARKET_CDN_URL_SETTING = "framework.market_cdn_url";
|
|
@@ -119,7 +119,7 @@ async function putSetting(fetchFn: typeof fetch, base: string, token: string, ke
|
|
|
119
119
|
async function postJson(fetchFn: typeof fetch, url: string, body: unknown): Promise<Record<string, unknown>> {
|
|
120
120
|
const res = await fetchFn(url, {
|
|
121
121
|
method: "POST",
|
|
122
|
-
headers: { "content-type": "application/json" },
|
|
122
|
+
headers: { "content-type": "application/json", ...tenantHeaders() },
|
|
123
123
|
body: JSON.stringify(body),
|
|
124
124
|
});
|
|
125
125
|
return (await unwrap(res)) as Record<string, unknown>;
|
|
@@ -128,7 +128,7 @@ async function postJson(fetchFn: typeof fetch, url: string, body: unknown): Prom
|
|
|
128
128
|
async function putJson(fetchFn: typeof fetch, url: string, token: string, body: unknown): Promise<Record<string, unknown>> {
|
|
129
129
|
const res = await fetchFn(url, {
|
|
130
130
|
method: "PUT",
|
|
131
|
-
headers: { "content-type": "application/json", "authorization": `Bearer ${token}
|
|
131
|
+
headers: { "content-type": "application/json", "authorization": `Bearer ${token}`, ...tenantHeaders() },
|
|
132
132
|
body: JSON.stringify(body),
|
|
133
133
|
});
|
|
134
134
|
return (await unwrap(res)) as Record<string, unknown>;
|