@stardeck-customer-apps/compose 0.12.0 → 0.14.0

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/SKILL.md CHANGED
@@ -12,7 +12,9 @@ Compose turns that declaration into the code Next.js actually builds:
12
12
  - `apps/web/src/modules.gen.ts` — the Module registry, `isModuleEnabled` and `isMountHeadless`
13
13
  - `apps/web/src/module-contributions.gen.ts`
14
14
  - `apps/web/src/module-i18n.gen.ts`
15
- - `apps/web/src/module-init.server.gen.ts`
15
+ - `apps/web/src/module-init.server.gen.ts` — memoized `initializeActiveEnhancements()`:
16
+ each enabled Module's root `initializeModule`, then each active slice's
17
+ `initializeEnhancement`
16
18
  - `apps/web/src/module-datastores.gen.ts`
17
19
  - `apps/web/src/module-server.gen.ts` — `MODULE_SERVER_REGISTRATIONS`, one entry
18
20
  per composed Module that ships `server/registrations.ts`
@@ -149,6 +151,38 @@ a silent fallback. It holds deployment facts: gitignore it (`apps/web/.gitignore
149
151
  the bundle. Compose does not check the export names or shapes. The file
150
152
  counts as `server/**/*.ts`, so the Module also needs `server/index.ts`.
151
153
  Needs compose 0.8.0 or later.
154
+ - **Module initializers.** Server registrations a Module makes into shared
155
+ registries (observers, resolvers, facets, drains) go in a root
156
+ `initialize.server.ts`, any Module kind:
157
+
158
+ ```ts
159
+ // src/modules/guest-ordering/initialize.server.ts — server-only, idempotent
160
+ import { registerPosCloseObserver } from "@/modules/pos/server";
161
+
162
+ import { wakeReconcileForClosedOrder } from "./server/reconcile";
163
+
164
+ export async function initializeModule() {
165
+ registerPosCloseObserver(wakeReconcileForClosedOrder);
166
+ }
167
+ ```
168
+
169
+ `initializeModule` must be a direct named function declaration callable with
170
+ zero required arguments (no arrow function, default export, re-export or
171
+ generator); compose fails otherwise. Compose imports it into
172
+ `module-init.server.gen.ts`, guarded by `isModuleEnabled("<module>")`, and
173
+ runs every Module initializer before any enhancement initializer, once per
174
+ instance. Every generated page stub (its render and its `generateMetadata`)
175
+ and every generated API route stub awaits `initializeActiveEnhancements()`
176
+ before its enablement guards, so the registrations exist in the route's own
177
+ module graph. Page stubs await it from compose 0.14.0; before that only API
178
+ stubs did. An `initialize()` export
179
+ run only from `instrumentation.ts` does not give you that: Next.js loads
180
+ instrumentation in a separate module graph, so on a cold instance the route
181
+ handlers and server components never see what it registered. A Module left
182
+ out by `--enabled` is not imported. Needs compose 0.14.0 or later (and
183
+ `@stardeck-customer-apps/eslint-plugin` 1.8.0 or later, whose
184
+ `no-module-internal-import` lets the generated file import it).
185
+
152
186
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
153
187
  the single enablement rule, and imports nothing — it is safe in client
154
188
  components.
package/dist/cli.js CHANGED
@@ -2249,14 +2249,22 @@ var datastoreRequirementSchema = import_zod.z.object({
2249
2249
  /** Checked against the connected store's accessLevel at install. */
2250
2250
  access: import_zod.z.enum(["read", "write", "admin"]).default("write")
2251
2251
  }).strict();
2252
- var permissionClaimSchema = import_zod.z.object({
2252
+ var MODULE_PERMISSION_DEFAULT_ROLES = ["manager", "member", "viewer"];
2253
+ var modulePermissionClaimSchema = import_zod.z.object({
2253
2254
  /**
2254
2255
  * Lowercase resource:action with one colon and no underscores. This grammar
2255
2256
  * is deliberately narrower than the platform permission tool's charset.
2256
2257
  */
2257
2258
  key: import_zod.z.string().regex(/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/, "must be a lowercase resource:action key").max(100, "must be at most 100 characters (platform permission tool limit)"),
2258
2259
  /** Human label for the deployment auth config row. */
2259
- description: descriptionSchema
2260
+ description: descriptionSchema,
2261
+ /**
2262
+ * The default organization roles that hold this key when the platform
2263
+ * first registers it on an app (a Blueprint fork, a Module install). Admin
2264
+ * holds every key without being listed; External Viewer never holds one.
2265
+ * Changing a role's grants afterwards is the organization's business.
2266
+ */
2267
+ roles: import_zod.z.array(import_zod.z.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2260
2268
  }).strict();
2261
2269
  var seedBundleSchema = import_zod.z.object({
2262
2270
  table: tableNameSchema,
@@ -2339,7 +2347,7 @@ var moduleManifestSchema = import_zod.z.object({
2339
2347
  * Keys this module's guards enforce. Install registers them in deployment
2340
2348
  * auth config; registration only — declaring is never granting.
2341
2349
  */
2342
- permissions: import_zod.z.array(permissionClaimSchema).optional(),
2350
+ permissions: import_zod.z.array(modulePermissionClaimSchema).optional(),
2343
2351
  /**
2344
2352
  * Optional starter rows for tables this module owns. Replay is
2345
2353
  * `INSERT ... ON CONFLICT (conflictTarget) DO NOTHING` — idempotent by
@@ -3552,17 +3560,20 @@ var import_typescript3 = __toESM(require("typescript"), 1);
3552
3560
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3553
3561
  contributions: "contributions",
3554
3562
  slotCatalogs: "slotCatalogs",
3555
- initializeEnhancement: "initializeEnhancement"
3563
+ initializeEnhancement: "initializeEnhancement",
3564
+ initializeModule: "initializeModule"
3556
3565
  };
3557
3566
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3558
3567
  contributions: "variable",
3559
3568
  slotCatalogs: "variable",
3560
- initializeEnhancement: "function"
3569
+ initializeEnhancement: "function",
3570
+ initializeModule: "function"
3561
3571
  };
3562
3572
  var CONVENTIONAL_ENTRY_FILENAME = {
3563
3573
  contributions: "contributions.ts",
3564
3574
  slotCatalogs: "slots.ts",
3565
- initializeEnhancement: "initialize.server.ts"
3575
+ initializeEnhancement: "initialize.server.ts",
3576
+ initializeModule: "initialize.server.ts"
3566
3577
  };
3567
3578
  function hasModifierKind(modifiers, kind) {
3568
3579
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3661,7 +3672,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3661
3672
  }
3662
3673
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3663
3674
  errors.push(
3664
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3675
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3665
3676
  );
3666
3677
  continue;
3667
3678
  }
@@ -3961,11 +3972,18 @@ ${result.error}`);
3961
3972
  }
3962
3973
 
3963
3974
  // ../../packages/lib/src/server/sandbox/redact-secrets.ts
3975
+ var SECRET_NAME = String.raw`[A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD)`;
3976
+ var SECRET_ASSIGNMENT = new RegExp(
3977
+ String.raw`\b(${SECRET_NAME})\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)`,
3978
+ "g"
3979
+ );
3980
+ var JSON_SECRET_PAIR = new RegExp(
3981
+ String.raw`"(${SECRET_NAME})"(\s*:\s*)"(?:[^"\\\n]|\\.)*"`,
3982
+ "g"
3983
+ );
3984
+ var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
3964
3985
  function redactSecrets(text) {
3965
- return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(
3966
- /\b([A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD))\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)/g,
3967
- "$1=***"
3968
- ).replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
3986
+ return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
3969
3987
  }
3970
3988
 
3971
3989
  // ../../packages/lib/src/server/sandbox/sh-quote.ts
@@ -4135,6 +4153,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4135
4153
  function assertInitializeEnhancementExport(source, pathLabel) {
4136
4154
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4137
4155
  }
4156
+ function assertInitializeModuleExport(source, pathLabel) {
4157
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4158
+ }
4138
4159
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4139
4160
  const { analysisErrors } = analyzeClientSafeEntryImports(
4140
4161
  source,
@@ -4654,30 +4675,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4654
4675
  }
4655
4676
  return layers;
4656
4677
  }
4657
- function emitModuleInitServerGenTs(input) {
4678
+ function moduleInitializerCalls(registry, facts) {
4679
+ const installed = new Set(registry.modules.map((m) => m.name));
4680
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4681
+ if (!isValidModuleName(name)) {
4682
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4683
+ }
4684
+ return {
4685
+ exportName: "initializeModule",
4686
+ guard: [name],
4687
+ ident: `initializeModule${index}`,
4688
+ importPath: `@/modules/${name}/initialize.server`
4689
+ };
4690
+ });
4691
+ }
4692
+ function enhancementInitializerCalls(registry, facts) {
4658
4693
  const calls = [];
4659
- const installed = new Set(input.registry.modules.map((m) => m.name));
4660
- let initializerIndex = 0;
4661
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4662
- if (!installed.has(facts.moduleName)) continue;
4663
- const peers = activePeers(input.registry, facts.moduleName);
4664
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4694
+ const installed = new Set(registry.modules.map((m) => m.name));
4695
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4696
+ if (!installed.has(f.moduleName)) continue;
4697
+ const peers = activePeers(registry, f.moduleName);
4698
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4665
4699
  if (!enh.active || !peers.has(enh.peer)) continue;
4666
4700
  if (!enh.hasInitializeServer) continue;
4667
- const ident = `initializeEnhancement${initializerIndex++}`;
4668
4701
  calls.push({
4669
- owner: facts.moduleName,
4670
- peer: enh.peer,
4671
- ident,
4672
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4702
+ exportName: "initializeEnhancement",
4703
+ guard: [f.moduleName, enh.peer],
4704
+ ident: `initializeEnhancement${calls.length}`,
4705
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4673
4706
  });
4674
4707
  }
4675
4708
  }
4676
- const importLines = calls.map(
4677
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4678
- );
4709
+ return calls;
4710
+ }
4711
+ function emitModuleInitServerGenTs(input) {
4712
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4713
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4714
+ const calls = [...moduleCalls, ...enhancementCalls];
4715
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4716
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4679
4717
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4680
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4718
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4681
4719
  );
4682
4720
  return [
4683
4721
  formatInitMarker(),
@@ -5127,6 +5165,7 @@ function buildRouteStubContent(input) {
5127
5165
  'import { notFound } from "next/navigation";',
5128
5166
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5129
5167
  "",
5168
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5130
5169
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5131
5170
  `import Page from ${emitTsString(input.importModule)};`
5132
5171
  ];
@@ -5135,6 +5174,7 @@ function buildRouteStubContent(input) {
5135
5174
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5136
5175
  "",
5137
5176
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5177
+ " await initializeActiveEnhancements();",
5138
5178
  ...emitGuardLines(guards, "return {};"),
5139
5179
  " return sourceGenerateMetadata(...args);",
5140
5180
  "}"
@@ -5145,7 +5185,10 @@ function buildRouteStubContent(input) {
5145
5185
  "type PageProps =",
5146
5186
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5147
5187
  "",
5148
- "export default function ModulePage(props: PageProps) {",
5188
+ // Module and enhancement initializers run in this route's own module graph
5189
+ // (memoized), so a cold server render sees their registrations.
5190
+ "export default async function ModulePage(props: PageProps) {",
5191
+ " await initializeActiveEnhancements();",
5149
5192
  ...emitGuardLines(guards, "notFound();"),
5150
5193
  " return <Page {...props} />;",
5151
5194
  "}",
@@ -5945,11 +5988,10 @@ var creationSourceBlueprintSchema = import_zod7.z.object({
5945
5988
  * `null`/absent means every feature Module: the platform stores that as the
5946
5989
  * explicit list of feature Modules in the published Blueprint version the fork
5947
5990
  * is cut from, so a Module a later Blueprint update adds arrives off. `[]` is
5948
- * refused: the platform serialises it to an empty
5949
- * `NEXT_PUBLIC_STARDECK_ENABLED_MODULES`, which the enablement parser reads
5950
- * back as "all" (`module-enablement.ts`) — the opposite of an empty selection.
5991
+ * a custom app: no feature Module, Library Modules only (`resolvePlatformEnv`
5992
+ * emits it as a set value naming no Module).
5951
5993
  */
5952
- enabledModules: import_zod7.z.array(moduleNameSchema).min(1, "Enable at least one Module").max(100).nullable().optional()
5994
+ enabledModules: import_zod7.z.array(moduleNameSchema).max(100).nullable().optional()
5953
5995
  });
5954
5996
  var creationSourceSchema = import_zod7.z.discriminatedUnion("type", [
5955
5997
  creationSourceBlankSchema,
@@ -6944,6 +6986,9 @@ async function readConventionalEntryIfRealFile(path5, kind, presences, files) {
6944
6986
  case "initializeEnhancement":
6945
6987
  assertInitializeEnhancementExport(source, path5);
6946
6988
  break;
6989
+ case "initializeModule":
6990
+ assertInitializeModuleExport(source, path5);
6991
+ break;
6947
6992
  }
6948
6993
  return source;
6949
6994
  }
@@ -6980,7 +7025,11 @@ async function collectCompositionEntryFacts(context, registry) {
6980
7025
  for (const mod of registry.modules) {
6981
7026
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6982
7027
  localeDirs.push(`${modulePath}/i18n`);
6983
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7028
+ conventionalPaths.push(
7029
+ `${modulePath}/contributions.ts`,
7030
+ `${modulePath}/slots.ts`,
7031
+ `${modulePath}/initialize.server.ts`
7032
+ );
6984
7033
  for (const enh of mod.enhancements) {
6985
7034
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
6986
7035
  localeDirs.push(`${peerDir}/i18n`);
@@ -7068,6 +7117,17 @@ async function collectCompositionEntryFacts(context, registry) {
7068
7117
  }
7069
7118
  }
7070
7119
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7120
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7121
+ `${modulePath}/initialize.server.ts`,
7122
+ "initializeModule",
7123
+ conventionalPresences,
7124
+ files
7125
+ ) != null;
7126
+ if (hasRootInitializeServer) {
7127
+ console.log(
7128
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7129
+ );
7130
+ }
7071
7131
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7072
7132
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7073
7133
  throw new Error(
@@ -7108,6 +7168,7 @@ async function collectCompositionEntryFacts(context, registry) {
7108
7168
  hasRootContributions,
7109
7169
  hasRootSlots,
7110
7170
  hasServerRegistrations,
7171
+ hasRootInitializeServer,
7111
7172
  rootLocales,
7112
7173
  enhancements
7113
7174
  });
package/dist/index.js CHANGED
@@ -2087,11 +2087,18 @@ ${result.error}`);
2087
2087
  }
2088
2088
 
2089
2089
  // ../../packages/lib/src/server/sandbox/redact-secrets.ts
2090
+ var SECRET_NAME = String.raw`[A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD)`;
2091
+ var SECRET_ASSIGNMENT = new RegExp(
2092
+ String.raw`\b(${SECRET_NAME})\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)`,
2093
+ "g"
2094
+ );
2095
+ var JSON_SECRET_PAIR = new RegExp(
2096
+ String.raw`"(${SECRET_NAME})"(\s*:\s*)"(?:[^"\\\n]|\\.)*"`,
2097
+ "g"
2098
+ );
2099
+ var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
2090
2100
  function redactSecrets(text) {
2091
- return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(
2092
- /\b([A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD))\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)/g,
2093
- "$1=***"
2094
- ).replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2101
+ return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2095
2102
  }
2096
2103
 
2097
2104
  // ../../packages/lib/src/server/sandbox/sh-quote.ts
@@ -2371,14 +2378,22 @@ var datastoreRequirementSchema = import_zod2.z.object({
2371
2378
  /** Checked against the connected store's accessLevel at install. */
2372
2379
  access: import_zod2.z.enum(["read", "write", "admin"]).default("write")
2373
2380
  }).strict();
2374
- var permissionClaimSchema = import_zod2.z.object({
2381
+ var MODULE_PERMISSION_DEFAULT_ROLES = ["manager", "member", "viewer"];
2382
+ var modulePermissionClaimSchema = import_zod2.z.object({
2375
2383
  /**
2376
2384
  * Lowercase resource:action with one colon and no underscores. This grammar
2377
2385
  * is deliberately narrower than the platform permission tool's charset.
2378
2386
  */
2379
2387
  key: import_zod2.z.string().regex(/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/, "must be a lowercase resource:action key").max(100, "must be at most 100 characters (platform permission tool limit)"),
2380
2388
  /** Human label for the deployment auth config row. */
2381
- description: descriptionSchema
2389
+ description: descriptionSchema,
2390
+ /**
2391
+ * The default organization roles that hold this key when the platform
2392
+ * first registers it on an app (a Blueprint fork, a Module install). Admin
2393
+ * holds every key without being listed; External Viewer never holds one.
2394
+ * Changing a role's grants afterwards is the organization's business.
2395
+ */
2396
+ roles: import_zod2.z.array(import_zod2.z.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2382
2397
  }).strict();
2383
2398
  var seedBundleSchema = import_zod2.z.object({
2384
2399
  table: tableNameSchema,
@@ -2461,7 +2476,7 @@ var moduleManifestSchema = import_zod2.z.object({
2461
2476
  * Keys this module's guards enforce. Install registers them in deployment
2462
2477
  * auth config; registration only — declaring is never granting.
2463
2478
  */
2464
- permissions: import_zod2.z.array(permissionClaimSchema).optional(),
2479
+ permissions: import_zod2.z.array(modulePermissionClaimSchema).optional(),
2465
2480
  /**
2466
2481
  * Optional starter rows for tables this module owns. Replay is
2467
2482
  * `INSERT ... ON CONFLICT (conflictTarget) DO NOTHING` — idempotent by
@@ -3674,17 +3689,20 @@ var import_typescript3 = __toESM(require("typescript"), 1);
3674
3689
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3675
3690
  contributions: "contributions",
3676
3691
  slotCatalogs: "slotCatalogs",
3677
- initializeEnhancement: "initializeEnhancement"
3692
+ initializeEnhancement: "initializeEnhancement",
3693
+ initializeModule: "initializeModule"
3678
3694
  };
3679
3695
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3680
3696
  contributions: "variable",
3681
3697
  slotCatalogs: "variable",
3682
- initializeEnhancement: "function"
3698
+ initializeEnhancement: "function",
3699
+ initializeModule: "function"
3683
3700
  };
3684
3701
  var CONVENTIONAL_ENTRY_FILENAME = {
3685
3702
  contributions: "contributions.ts",
3686
3703
  slotCatalogs: "slots.ts",
3687
- initializeEnhancement: "initialize.server.ts"
3704
+ initializeEnhancement: "initialize.server.ts",
3705
+ initializeModule: "initialize.server.ts"
3688
3706
  };
3689
3707
  function hasModifierKind(modifiers, kind) {
3690
3708
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3783,7 +3801,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3783
3801
  }
3784
3802
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3785
3803
  errors.push(
3786
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3804
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3787
3805
  );
3788
3806
  continue;
3789
3807
  }
@@ -4145,6 +4163,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4145
4163
  function assertInitializeEnhancementExport(source, pathLabel) {
4146
4164
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4147
4165
  }
4166
+ function assertInitializeModuleExport(source, pathLabel) {
4167
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4168
+ }
4148
4169
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4149
4170
  const { analysisErrors } = analyzeClientSafeEntryImports(
4150
4171
  source,
@@ -4664,30 +4685,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4664
4685
  }
4665
4686
  return layers;
4666
4687
  }
4667
- function emitModuleInitServerGenTs(input) {
4688
+ function moduleInitializerCalls(registry, facts) {
4689
+ const installed = new Set(registry.modules.map((m) => m.name));
4690
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4691
+ if (!isValidModuleName(name)) {
4692
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4693
+ }
4694
+ return {
4695
+ exportName: "initializeModule",
4696
+ guard: [name],
4697
+ ident: `initializeModule${index}`,
4698
+ importPath: `@/modules/${name}/initialize.server`
4699
+ };
4700
+ });
4701
+ }
4702
+ function enhancementInitializerCalls(registry, facts) {
4668
4703
  const calls = [];
4669
- const installed = new Set(input.registry.modules.map((m) => m.name));
4670
- let initializerIndex = 0;
4671
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4672
- if (!installed.has(facts.moduleName)) continue;
4673
- const peers = activePeers(input.registry, facts.moduleName);
4674
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4704
+ const installed = new Set(registry.modules.map((m) => m.name));
4705
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4706
+ if (!installed.has(f.moduleName)) continue;
4707
+ const peers = activePeers(registry, f.moduleName);
4708
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4675
4709
  if (!enh.active || !peers.has(enh.peer)) continue;
4676
4710
  if (!enh.hasInitializeServer) continue;
4677
- const ident = `initializeEnhancement${initializerIndex++}`;
4678
4711
  calls.push({
4679
- owner: facts.moduleName,
4680
- peer: enh.peer,
4681
- ident,
4682
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4712
+ exportName: "initializeEnhancement",
4713
+ guard: [f.moduleName, enh.peer],
4714
+ ident: `initializeEnhancement${calls.length}`,
4715
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4683
4716
  });
4684
4717
  }
4685
4718
  }
4686
- const importLines = calls.map(
4687
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4688
- );
4719
+ return calls;
4720
+ }
4721
+ function emitModuleInitServerGenTs(input) {
4722
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4723
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4724
+ const calls = [...moduleCalls, ...enhancementCalls];
4725
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4726
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4689
4727
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4690
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4728
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4691
4729
  );
4692
4730
  return [
4693
4731
  formatInitMarker(),
@@ -5137,6 +5175,7 @@ function buildRouteStubContent(input) {
5137
5175
  'import { notFound } from "next/navigation";',
5138
5176
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5139
5177
  "",
5178
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5140
5179
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5141
5180
  `import Page from ${emitTsString(input.importModule)};`
5142
5181
  ];
@@ -5145,6 +5184,7 @@ function buildRouteStubContent(input) {
5145
5184
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5146
5185
  "",
5147
5186
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5187
+ " await initializeActiveEnhancements();",
5148
5188
  ...emitGuardLines(guards, "return {};"),
5149
5189
  " return sourceGenerateMetadata(...args);",
5150
5190
  "}"
@@ -5155,7 +5195,10 @@ function buildRouteStubContent(input) {
5155
5195
  "type PageProps =",
5156
5196
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5157
5197
  "",
5158
- "export default function ModulePage(props: PageProps) {",
5198
+ // Module and enhancement initializers run in this route's own module graph
5199
+ // (memoized), so a cold server render sees their registrations.
5200
+ "export default async function ModulePage(props: PageProps) {",
5201
+ " await initializeActiveEnhancements();",
5159
5202
  ...emitGuardLines(guards, "notFound();"),
5160
5203
  " return <Page {...props} />;",
5161
5204
  "}",
@@ -5955,11 +5998,10 @@ var creationSourceBlueprintSchema = import_zod7.z.object({
5955
5998
  * `null`/absent means every feature Module: the platform stores that as the
5956
5999
  * explicit list of feature Modules in the published Blueprint version the fork
5957
6000
  * is cut from, so a Module a later Blueprint update adds arrives off. `[]` is
5958
- * refused: the platform serialises it to an empty
5959
- * `NEXT_PUBLIC_STARDECK_ENABLED_MODULES`, which the enablement parser reads
5960
- * back as "all" (`module-enablement.ts`) — the opposite of an empty selection.
6001
+ * a custom app: no feature Module, Library Modules only (`resolvePlatformEnv`
6002
+ * emits it as a set value naming no Module).
5961
6003
  */
5962
- enabledModules: import_zod7.z.array(moduleNameSchema).min(1, "Enable at least one Module").max(100).nullable().optional()
6004
+ enabledModules: import_zod7.z.array(moduleNameSchema).max(100).nullable().optional()
5963
6005
  });
5964
6006
  var creationSourceSchema = import_zod7.z.discriminatedUnion("type", [
5965
6007
  creationSourceBlankSchema,
@@ -6954,6 +6996,9 @@ async function readConventionalEntryIfRealFile(path4, kind, presences, files) {
6954
6996
  case "initializeEnhancement":
6955
6997
  assertInitializeEnhancementExport(source, path4);
6956
6998
  break;
6999
+ case "initializeModule":
7000
+ assertInitializeModuleExport(source, path4);
7001
+ break;
6957
7002
  }
6958
7003
  return source;
6959
7004
  }
@@ -6990,7 +7035,11 @@ async function collectCompositionEntryFacts(context, registry) {
6990
7035
  for (const mod of registry.modules) {
6991
7036
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6992
7037
  localeDirs.push(`${modulePath}/i18n`);
6993
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7038
+ conventionalPaths.push(
7039
+ `${modulePath}/contributions.ts`,
7040
+ `${modulePath}/slots.ts`,
7041
+ `${modulePath}/initialize.server.ts`
7042
+ );
6994
7043
  for (const enh of mod.enhancements) {
6995
7044
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
6996
7045
  localeDirs.push(`${peerDir}/i18n`);
@@ -7078,6 +7127,17 @@ async function collectCompositionEntryFacts(context, registry) {
7078
7127
  }
7079
7128
  }
7080
7129
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7130
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7131
+ `${modulePath}/initialize.server.ts`,
7132
+ "initializeModule",
7133
+ conventionalPresences,
7134
+ files
7135
+ ) != null;
7136
+ if (hasRootInitializeServer) {
7137
+ console.log(
7138
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7139
+ );
7140
+ }
7081
7141
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7082
7142
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7083
7143
  throw new Error(
@@ -7118,6 +7178,7 @@ async function collectCompositionEntryFacts(context, registry) {
7118
7178
  hasRootContributions,
7119
7179
  hasRootSlots,
7120
7180
  hasServerRegistrations,
7181
+ hasRootInitializeServer,
7121
7182
  rootLocales,
7122
7183
  enhancements
7123
7184
  });
package/dist/index.mjs CHANGED
@@ -2068,11 +2068,18 @@ ${result.error}`);
2068
2068
  }
2069
2069
 
2070
2070
  // ../../packages/lib/src/server/sandbox/redact-secrets.ts
2071
+ var SECRET_NAME = String.raw`[A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD)`;
2072
+ var SECRET_ASSIGNMENT = new RegExp(
2073
+ String.raw`\b(${SECRET_NAME})\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)`,
2074
+ "g"
2075
+ );
2076
+ var JSON_SECRET_PAIR = new RegExp(
2077
+ String.raw`"(${SECRET_NAME})"(\s*:\s*)"(?:[^"\\\n]|\\.)*"`,
2078
+ "g"
2079
+ );
2080
+ var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
2071
2081
  function redactSecrets(text) {
2072
- return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(
2073
- /\b([A-Z][A-Z0-9_]*(?:_SECRET|_SECRET_KEY|_API_KEY|_KEY|_TOKEN|_PASSWORD))\s*=\s*(?:'(?:[^']|'\\'')*'|"[^"]*"|[^\s&"',;}\]]+)/g,
2074
- "$1=***"
2075
- ).replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2082
+ return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2076
2083
  }
2077
2084
 
2078
2085
  // ../../packages/lib/src/server/sandbox/sh-quote.ts
@@ -2352,14 +2359,22 @@ var datastoreRequirementSchema = z2.object({
2352
2359
  /** Checked against the connected store's accessLevel at install. */
2353
2360
  access: z2.enum(["read", "write", "admin"]).default("write")
2354
2361
  }).strict();
2355
- var permissionClaimSchema = z2.object({
2362
+ var MODULE_PERMISSION_DEFAULT_ROLES = ["manager", "member", "viewer"];
2363
+ var modulePermissionClaimSchema = z2.object({
2356
2364
  /**
2357
2365
  * Lowercase resource:action with one colon and no underscores. This grammar
2358
2366
  * is deliberately narrower than the platform permission tool's charset.
2359
2367
  */
2360
2368
  key: z2.string().regex(/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/, "must be a lowercase resource:action key").max(100, "must be at most 100 characters (platform permission tool limit)"),
2361
2369
  /** Human label for the deployment auth config row. */
2362
- description: descriptionSchema
2370
+ description: descriptionSchema,
2371
+ /**
2372
+ * The default organization roles that hold this key when the platform
2373
+ * first registers it on an app (a Blueprint fork, a Module install). Admin
2374
+ * holds every key without being listed; External Viewer never holds one.
2375
+ * Changing a role's grants afterwards is the organization's business.
2376
+ */
2377
+ roles: z2.array(z2.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2363
2378
  }).strict();
2364
2379
  var seedBundleSchema = z2.object({
2365
2380
  table: tableNameSchema,
@@ -2442,7 +2457,7 @@ var moduleManifestSchema = z2.object({
2442
2457
  * Keys this module's guards enforce. Install registers them in deployment
2443
2458
  * auth config; registration only — declaring is never granting.
2444
2459
  */
2445
- permissions: z2.array(permissionClaimSchema).optional(),
2460
+ permissions: z2.array(modulePermissionClaimSchema).optional(),
2446
2461
  /**
2447
2462
  * Optional starter rows for tables this module owns. Replay is
2448
2463
  * `INSERT ... ON CONFLICT (conflictTarget) DO NOTHING` — idempotent by
@@ -3655,17 +3670,20 @@ import ts3 from "typescript";
3655
3670
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3656
3671
  contributions: "contributions",
3657
3672
  slotCatalogs: "slotCatalogs",
3658
- initializeEnhancement: "initializeEnhancement"
3673
+ initializeEnhancement: "initializeEnhancement",
3674
+ initializeModule: "initializeModule"
3659
3675
  };
3660
3676
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3661
3677
  contributions: "variable",
3662
3678
  slotCatalogs: "variable",
3663
- initializeEnhancement: "function"
3679
+ initializeEnhancement: "function",
3680
+ initializeModule: "function"
3664
3681
  };
3665
3682
  var CONVENTIONAL_ENTRY_FILENAME = {
3666
3683
  contributions: "contributions.ts",
3667
3684
  slotCatalogs: "slots.ts",
3668
- initializeEnhancement: "initialize.server.ts"
3685
+ initializeEnhancement: "initialize.server.ts",
3686
+ initializeModule: "initialize.server.ts"
3669
3687
  };
3670
3688
  function hasModifierKind(modifiers, kind) {
3671
3689
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3764,7 +3782,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3764
3782
  }
3765
3783
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3766
3784
  errors.push(
3767
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3785
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3768
3786
  );
3769
3787
  continue;
3770
3788
  }
@@ -4126,6 +4144,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4126
4144
  function assertInitializeEnhancementExport(source, pathLabel) {
4127
4145
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4128
4146
  }
4147
+ function assertInitializeModuleExport(source, pathLabel) {
4148
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4149
+ }
4129
4150
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4130
4151
  const { analysisErrors } = analyzeClientSafeEntryImports(
4131
4152
  source,
@@ -4645,30 +4666,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4645
4666
  }
4646
4667
  return layers;
4647
4668
  }
4648
- function emitModuleInitServerGenTs(input) {
4669
+ function moduleInitializerCalls(registry, facts) {
4670
+ const installed = new Set(registry.modules.map((m) => m.name));
4671
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4672
+ if (!isValidModuleName(name)) {
4673
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4674
+ }
4675
+ return {
4676
+ exportName: "initializeModule",
4677
+ guard: [name],
4678
+ ident: `initializeModule${index}`,
4679
+ importPath: `@/modules/${name}/initialize.server`
4680
+ };
4681
+ });
4682
+ }
4683
+ function enhancementInitializerCalls(registry, facts) {
4649
4684
  const calls = [];
4650
- const installed = new Set(input.registry.modules.map((m) => m.name));
4651
- let initializerIndex = 0;
4652
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4653
- if (!installed.has(facts.moduleName)) continue;
4654
- const peers = activePeers(input.registry, facts.moduleName);
4655
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4685
+ const installed = new Set(registry.modules.map((m) => m.name));
4686
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4687
+ if (!installed.has(f.moduleName)) continue;
4688
+ const peers = activePeers(registry, f.moduleName);
4689
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4656
4690
  if (!enh.active || !peers.has(enh.peer)) continue;
4657
4691
  if (!enh.hasInitializeServer) continue;
4658
- const ident = `initializeEnhancement${initializerIndex++}`;
4659
4692
  calls.push({
4660
- owner: facts.moduleName,
4661
- peer: enh.peer,
4662
- ident,
4663
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4693
+ exportName: "initializeEnhancement",
4694
+ guard: [f.moduleName, enh.peer],
4695
+ ident: `initializeEnhancement${calls.length}`,
4696
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4664
4697
  });
4665
4698
  }
4666
4699
  }
4667
- const importLines = calls.map(
4668
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4669
- );
4700
+ return calls;
4701
+ }
4702
+ function emitModuleInitServerGenTs(input) {
4703
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4704
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4705
+ const calls = [...moduleCalls, ...enhancementCalls];
4706
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4707
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4670
4708
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4671
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4709
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4672
4710
  );
4673
4711
  return [
4674
4712
  formatInitMarker(),
@@ -5118,6 +5156,7 @@ function buildRouteStubContent(input) {
5118
5156
  'import { notFound } from "next/navigation";',
5119
5157
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5120
5158
  "",
5159
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5121
5160
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5122
5161
  `import Page from ${emitTsString(input.importModule)};`
5123
5162
  ];
@@ -5126,6 +5165,7 @@ function buildRouteStubContent(input) {
5126
5165
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5127
5166
  "",
5128
5167
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5168
+ " await initializeActiveEnhancements();",
5129
5169
  ...emitGuardLines(guards, "return {};"),
5130
5170
  " return sourceGenerateMetadata(...args);",
5131
5171
  "}"
@@ -5136,7 +5176,10 @@ function buildRouteStubContent(input) {
5136
5176
  "type PageProps =",
5137
5177
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5138
5178
  "",
5139
- "export default function ModulePage(props: PageProps) {",
5179
+ // Module and enhancement initializers run in this route's own module graph
5180
+ // (memoized), so a cold server render sees their registrations.
5181
+ "export default async function ModulePage(props: PageProps) {",
5182
+ " await initializeActiveEnhancements();",
5140
5183
  ...emitGuardLines(guards, "notFound();"),
5141
5184
  " return <Page {...props} />;",
5142
5185
  "}",
@@ -5936,11 +5979,10 @@ var creationSourceBlueprintSchema = z7.object({
5936
5979
  * `null`/absent means every feature Module: the platform stores that as the
5937
5980
  * explicit list of feature Modules in the published Blueprint version the fork
5938
5981
  * is cut from, so a Module a later Blueprint update adds arrives off. `[]` is
5939
- * refused: the platform serialises it to an empty
5940
- * `NEXT_PUBLIC_STARDECK_ENABLED_MODULES`, which the enablement parser reads
5941
- * back as "all" (`module-enablement.ts`) — the opposite of an empty selection.
5982
+ * a custom app: no feature Module, Library Modules only (`resolvePlatformEnv`
5983
+ * emits it as a set value naming no Module).
5942
5984
  */
5943
- enabledModules: z7.array(moduleNameSchema).min(1, "Enable at least one Module").max(100).nullable().optional()
5985
+ enabledModules: z7.array(moduleNameSchema).max(100).nullable().optional()
5944
5986
  });
5945
5987
  var creationSourceSchema = z7.discriminatedUnion("type", [
5946
5988
  creationSourceBlankSchema,
@@ -6935,6 +6977,9 @@ async function readConventionalEntryIfRealFile(path4, kind, presences, files) {
6935
6977
  case "initializeEnhancement":
6936
6978
  assertInitializeEnhancementExport(source, path4);
6937
6979
  break;
6980
+ case "initializeModule":
6981
+ assertInitializeModuleExport(source, path4);
6982
+ break;
6938
6983
  }
6939
6984
  return source;
6940
6985
  }
@@ -6971,7 +7016,11 @@ async function collectCompositionEntryFacts(context, registry) {
6971
7016
  for (const mod of registry.modules) {
6972
7017
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6973
7018
  localeDirs.push(`${modulePath}/i18n`);
6974
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7019
+ conventionalPaths.push(
7020
+ `${modulePath}/contributions.ts`,
7021
+ `${modulePath}/slots.ts`,
7022
+ `${modulePath}/initialize.server.ts`
7023
+ );
6975
7024
  for (const enh of mod.enhancements) {
6976
7025
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
6977
7026
  localeDirs.push(`${peerDir}/i18n`);
@@ -7059,6 +7108,17 @@ async function collectCompositionEntryFacts(context, registry) {
7059
7108
  }
7060
7109
  }
7061
7110
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7111
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7112
+ `${modulePath}/initialize.server.ts`,
7113
+ "initializeModule",
7114
+ conventionalPresences,
7115
+ files
7116
+ ) != null;
7117
+ if (hasRootInitializeServer) {
7118
+ console.log(
7119
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7120
+ );
7121
+ }
7062
7122
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7063
7123
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7064
7124
  throw new Error(
@@ -7099,6 +7159,7 @@ async function collectCompositionEntryFacts(context, registry) {
7099
7159
  hasRootContributions,
7100
7160
  hasRootSlots,
7101
7161
  hasServerRegistrations,
7162
+ hasRootInitializeServer,
7102
7163
  rootLocales,
7103
7164
  enhancements
7104
7165
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Regenerates a Stardeck app's Module-rail artifacts — the six src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -34,7 +34,7 @@
34
34
  "build": "rm -rf dist && tsup",
35
35
  "dev": "tsup --watch",
36
36
  "format": "prettier --write . && oxlint . --fix",
37
- "typecheck": "tsc -p tsconfig.test.json",
37
+ "typecheck": "tsgo -p tsconfig.test.json",
38
38
  "lint": "oxlint src/",
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest"