@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
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.42",
307
+ "version": "0.2.45",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-26 10:15:05"
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
- definition,
7535
- routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7536
- status: "loaded"
7537
- });
7538
- } else modules.set(entry.name, {
7539
- definition: {
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, entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name);
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 prefix = modules.get(mod)?.routePrefix ?? mod;
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 };
@@ -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-2Xr8lEfbU8h45hDFonkWmg=='; 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" />
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="2Xr8lEfbU8h45hDFonkWmg==">{
10
+ <script type="importmap" nonce="IiFAOepBufxbhXdsYRrEMA==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -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.42",
11
- "@oj-module/runtime/contract": "0.2.42",
12
- "@oj-module/runtime/contract/errors": "0.2.42",
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
- for (const name of ["SKILL.md", "manual.md", "README.md"])
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();
@@ -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");
@@ -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>;