create-ailk 0.4.0 → 0.5.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.
Files changed (146) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +29 -1
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +50 -2
  6. package/dist/lib/extract-module-bundle.js +223 -15
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.js +4 -0
  11. package/dist/module-architecture.d.ts +59 -8
  12. package/dist/module-architecture.js +334 -115
  13. package/dist/parse-args.js +6 -2
  14. package/dist/programmatic.d.ts +10 -0
  15. package/dist/programmatic.js +134 -19
  16. package/dist/site-chrome.d.ts +80 -0
  17. package/dist/site-chrome.js +135 -0
  18. package/dist/surfaces.d.ts +21 -6
  19. package/dist/surfaces.js +107 -32
  20. package/dist/template-manifest.d.ts +55 -3
  21. package/dist/template-manifest.js +181 -12
  22. package/dist/template-names.d.ts +1 -1
  23. package/dist/template-names.js +1 -0
  24. package/package.json +9 -2
  25. package/site-templates/_base/ailk-template.json +6 -0
  26. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  29. package/site-templates/marketing-site/ailk-template.json +11 -0
  30. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  31. package/site-templates/marketing-site/provenance.json +4 -0
  32. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  33. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  34. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  35. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  36. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  37. package/site-templates/minimalism-site/ailk-template.json +21 -0
  38. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  39. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  40. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  41. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  42. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  44. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  45. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  46. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  47. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  48. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  49. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  50. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  51. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  52. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  53. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  54. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  55. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  56. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  57. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  58. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  59. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  60. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  61. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  62. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  63. package/site-templates/minimalism-site/provenance.json +4 -0
  64. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  65. package/site-templates/saas-product/ailk-template.json +16 -0
  66. package/site-templates/saas-product/provenance.json +10 -0
  67. package/templates/.claude/skills/README.md +16 -25
  68. package/templates/.env.example +32 -7
  69. package/templates/.github/workflows/ci.yml +48 -0
  70. package/templates/README.md +3 -1
  71. package/templates/apps/api/CLAUDE.md +4 -1
  72. package/templates/apps/api/eslint.config.mjs +69 -0
  73. package/templates/apps/api/package.json +6 -2
  74. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  75. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  76. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
  77. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  78. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  79. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  80. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  82. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  83. package/templates/apps/api/src/server.ts +82 -6
  84. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  85. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  86. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  87. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  88. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  89. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  90. package/templates/apps/mcp/CLAUDE.md +1 -1
  91. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  92. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  93. package/templates/apps/mcp/eslint.config.mjs +69 -0
  94. package/templates/apps/mcp/package.json +6 -2
  95. package/templates/apps/mcp/src/server.ts +2 -2
  96. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  97. package/templates/apps/mcp/src/tools/index.ts +2 -2
  98. package/templates/apps/web/.env.example +3 -3
  99. package/templates/apps/web/CLAUDE.md +2 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  101. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  103. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  104. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  105. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  106. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  107. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  108. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  109. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  111. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  112. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  113. package/templates/apps/web/components/google-analytics.tsx +18 -1
  114. package/templates/apps/web/eslint.config.mjs +69 -0
  115. package/templates/apps/web/jest.config.cjs +7 -15
  116. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  117. package/templates/apps/web/lib/nav-config.ts +27 -7
  118. package/templates/apps/web/lib/server-api.ts +1 -1
  119. package/templates/apps/web/lib/site-chrome.ts +29 -0
  120. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  121. package/templates/apps/web/messages/en.json +29 -2
  122. package/templates/apps/web/next.config.mjs +20 -0
  123. package/templates/apps/web/package.json +6 -2
  124. package/templates/apps/web/sentry.client.config.ts +131 -0
  125. package/templates/apps/web/sentry.edge.config.ts +72 -0
  126. package/templates/apps/web/sentry.server.config.ts +72 -0
  127. package/templates/apps/web/src/lib/routes.ts +13 -18
  128. package/templates/brand_assets/README.md +24 -0
  129. package/templates/content/en/AboutPage/about.mdx +160 -15
  130. package/templates/content/en/ContactPage/contact.mdx +90 -13
  131. package/templates/content/en/FAQPage/faq.mdx +62 -22
  132. package/templates/content/en/HomePage/home.mdx +244 -20
  133. package/templates/content/en/LandingPage/blog.mdx +83 -0
  134. package/templates/content/en/LandingPage/features.mdx +103 -0
  135. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  136. package/templates/database/CHANGELOG.md +7 -0
  137. package/templates/database/__tests__/seed.test.ts +1 -1
  138. package/templates/database/eslint.config.mjs +69 -0
  139. package/templates/database/package.json +7 -3
  140. package/templates/database/seed.ts +1 -1
  141. package/templates/package.json +1 -14
  142. package/versions.json +52 -0
  143. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  144. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  145. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  146. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
package/dist/cli.js CHANGED
@@ -33,7 +33,7 @@ import { fileURLToPath } from "node:url";
33
33
  import { proCallout } from "./lib/pro-callout.js";
34
34
  import { helpText, parseCliArgs, parseModules } from "./parse-args.js";
35
35
  import { formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncRoutesSync, } from "./programmatic.js";
36
- import { CORE_SURFACE_PATHS } from "./surfaces.js";
36
+ import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
37
37
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
38
38
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
39
39
  const PKG_JSON = resolve(PKG_ROOT, "package.json");
@@ -41,6 +41,17 @@ function readPackageVersion() {
41
41
  const raw = readFileSync(PKG_JSON, "utf8");
42
42
  return JSON.parse(raw).version;
43
43
  }
44
+ /** Raw value of a `--flag <value>` or `--flag=<value>` argv entry, if present. */
45
+ function readRawFlagValue(argv, flag) {
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const arg = argv[i];
48
+ if (arg === flag)
49
+ return argv[i + 1];
50
+ if (arg?.startsWith(`${flag}=`))
51
+ return arg.slice(flag.length + 1);
52
+ }
53
+ return undefined;
54
+ }
44
55
  async function main() {
45
56
  const argv = process.argv.slice(2);
46
57
  // Subcommand dispatch, ahead of the flag parser: `sync-routes` has its own
@@ -60,6 +71,23 @@ async function main() {
60
71
  process.exit(!args.apply && result.missing.length > 0 ? 1 : 0);
61
72
  }
62
73
  const opts = parseCliArgs(argv);
74
+ // Deprecation notice for a retired `--surfaces` id (#5563 D2). `surfaces.ts`
75
+ // stays pure — `canonicalizeSurface` only resolves the alias — so this is
76
+ // the one place that prints: compare the raw `--surfaces` argv token
77
+ // against SURFACE_ALIASES rather than diffing the (already-canonical)
78
+ // parsed result.
79
+ const rawSurfaces = readRawFlagValue(argv, "--surfaces");
80
+ if (rawSurfaces !== undefined) {
81
+ for (const token of rawSurfaces
82
+ .split(",")
83
+ .map((s) => s.trim().toLowerCase())
84
+ .filter(Boolean)) {
85
+ const canonical = SURFACE_ALIASES[token];
86
+ if (canonical !== undefined) {
87
+ process.stdout.write(`[create-ailk] "--surfaces ${token}" is retired; using "${canonical}" (#5563).\n`);
88
+ }
89
+ }
90
+ }
63
91
  if (opts.help) {
64
92
  process.stdout.write(helpText + "\n" + SYNC_ROUTES_HELP);
65
93
  process.exit(0);
@@ -181,11 +181,14 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
181
181
  forbidden_patterns: ARCHITECTURE_BASE.web.forbidden_patterns.map(forbiddenToYaml),
182
182
  },
183
183
  };
184
- // Additional apps (api, mcp, workspace) in canonical order.
184
+ // Additional apps (api, mcp, admin) in canonical order. `admin` was
185
+ // `workspace` until #5637 renamed the owner surface; the slice's key was
186
+ // renamed there and this list was not, which dropped the Admin app from
187
+ // every derived architecture without failing anything but its own test.
185
188
  const extraApps = {};
186
189
  for (const s of slices)
187
190
  Object.assign(extraApps, s.apps ?? {});
188
- for (const key of ["api", "mcp", "workspace"]) {
191
+ for (const key of ["api", "mcp", "admin"]) {
189
192
  const a = extraApps[key];
190
193
  if (a)
191
194
  apps[key] = appToYaml(a);
@@ -1,4 +1,23 @@
1
+ import { type TemplateLayout } from "../site-chrome.js";
1
2
  import type { WorkspaceVersionMap } from "../substitute.js";
3
+ /** Exported for `fetch-template.ts`/`fetch-module.ts`'s Content-Length guard (#5514) — one cap, not two literals. */
4
+ export declare const MAX_TOTAL_SIZE_BYTES: number;
5
+ /**
6
+ * The file-grain ownership registry (#5618 D4): scaffold-relative destination
7
+ * → the layer that wrote it (a `subject` string — `template "_base"`,
8
+ * `module "docs-blog"`, `template "marketing-site"`). Caller-owned and
9
+ * CALL-SCOPED: `scaffold()` constructs one per call and threads it through
10
+ * every `installBundleTree` of that scaffold, so two `scaffold()` calls in
11
+ * one process share no state (the frozen-registry posture of #5507). It is
12
+ * the third ownership view beside `ownedFiles` (which repo file a module
13
+ * physically owns) and `pageContributions` (which route+content it brings):
14
+ * which scaffold destination a LAYER wrote at install time.
15
+ *
16
+ * Layer 0 — the `templates/` snapshot — is deliberately outside it: the
17
+ * registry starts empty at the base-layer boundary, so a destination it does
18
+ * not hold is the snapshot's, which a module may freely add to and patch.
19
+ */
20
+ export type LayerOwnership = Map<string, string>;
2
21
  export interface ExtractModuleBundleOptions {
3
22
  /** The fetched bundle bytes (a gzipped tar archive). */
4
23
  bytes: Uint8Array;
@@ -19,6 +38,10 @@ export interface ExtractModuleBundleOptions {
19
38
  * this list (record D10, DD4). See `InstallBundleTreeOptions`.
20
39
  */
21
40
  templateRequires?: readonly string[];
41
+ /** The registry's `extends` for this template (#5618 D2). See `InstallBundleTreeOptions`. */
42
+ templateExtends?: string;
43
+ /** The call-scoped ownership registry (#5618 D4). See `InstallBundleTreeOptions`. */
44
+ ownership?: LayerOwnership;
22
45
  }
23
46
  /**
24
47
  * Extract a fetched module bundle into `targetDir`, merging its files onto
@@ -28,7 +51,7 @@ export interface ExtractModuleBundleOptions {
28
51
  * those cases: validation AND patch computation both complete before any
29
52
  * file is copied or written into place.
30
53
  */
31
- export declare function extractModuleBundle(options: ExtractModuleBundleOptions): Promise<void>;
54
+ export declare function extractModuleBundle(options: ExtractModuleBundleOptions): Promise<InstallBundleTreeResult>;
32
55
  export interface InstallBundleTreeOptions {
33
56
  /**
34
57
  * The validated tree whose files land in the scaffold. Two source roots,
@@ -48,9 +71,34 @@ export interface InstallBundleTreeOptions {
48
71
  * (record D10, DD4). Absent ⟹ a module bundle, read exactly as before.
49
72
  */
50
73
  templateRequires?: readonly string[];
74
+ /**
75
+ * Template trees only (#5618 D2): the client registry's `extends` for this
76
+ * template, which the tree's `ailk-template.json` must declare verbatim —
77
+ * the sibling of the `requires` cross-check above. Absent for the base
78
+ * layer itself (the chain's terminus declares no parent) and for a module.
79
+ */
80
+ templateExtends?: string;
81
+ /**
82
+ * The call-scoped ownership registry (#5618 D4) — destination → the layer
83
+ * that wrote it. Present ⟹ this install is one layer of a composed
84
+ * scaffold: a MODULE tree is refused (before its first write) for any
85
+ * destination an earlier layer owns unless it is a declared `patches`
86
+ * target, and every layer records its destinations on success. Absent ⟹
87
+ * the pre-#5618 single-tree behaviour, unchecked and unrecorded.
88
+ */
89
+ ownership?: LayerOwnership;
51
90
  /** Canonical `@working-theory/*` versions for `workspace:*` substitution in patch targets. */
52
91
  versions?: WorkspaceVersionMap;
53
92
  }
93
+ /** `installBundleTree`'s result — presently only the template-only `layout` knobs. */
94
+ export interface InstallBundleTreeResult {
95
+ /**
96
+ * The installed template's validated `layout` block (#5612 D1), unresolved
97
+ * (defaults NOT filled — `resolveSiteChrome` does that). `undefined` for a
98
+ * module install, or a template whose manifest declares no `layout`.
99
+ */
100
+ layout?: TemplateLayout;
101
+ }
54
102
  /**
55
103
  * Apply one resolved manifest's `installMap`, plain content and `patches`
56
104
  * from `sourceRoot` into `targetDir`.
@@ -61,4 +109,4 @@ export interface InstallBundleTreeOptions {
61
109
  * patch computation both complete before the first byte is written, so a
62
110
  * rejected tree leaves `targetDir` exactly as it was.
63
111
  */
64
- export declare function installBundleTree(options: InstallBundleTreeOptions): void;
112
+ export declare function installBundleTree(options: InstallBundleTreeOptions): InstallBundleTreeResult;
@@ -83,10 +83,13 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
83
83
  import { pipeline } from "node:stream/promises";
84
84
  import { createGunzip } from "node:zlib";
85
85
  import * as tar from "tar";
86
+ import { coOwnedModuleDestinations, } from "../module-architecture.js";
87
+ import { validateTemplateLayout, } from "../site-chrome.js";
86
88
  import { computeModulePatches, writeModulePatches } from "./apply-module-patches.js";
87
89
  /** Soft limits to prevent decompression-bomb / zip-bomb attacks. Same values as extractBundle. */
88
90
  const MAX_ENTRIES = 1000;
89
- const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
91
+ /** Exported for `fetch-template.ts`/`fetch-module.ts`'s Content-Length guard (#5514) — one cap, not two literals. */
92
+ export const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
90
93
  /** The bundle-root manifest file (spec § D7). Never copied into the scaffold. */
91
94
  const AILK_MODULE_JSON = "ailk-module.json";
92
95
  /**
@@ -164,7 +167,7 @@ function readAilkModuleManifest(extractedRoot) {
164
167
  * is whatever the archive says. Compared as SETS, not as sequences — the
165
168
  * registry's declaration order is not part of the contract.
166
169
  */
167
- function readAilkTemplateManifest(sourceRoot, name, expectedRequires) {
170
+ function readAilkTemplateManifest(sourceRoot, name, expectedRequires, expectedExtends) {
168
171
  const manifestPath = join(sourceRoot, AILK_TEMPLATE_JSON);
169
172
  if (!existsSync(manifestPath)) {
170
173
  throw new Error(`template "${name}": bundle carries no ${AILK_TEMPLATE_JSON} — a template manifest is mandatory`);
@@ -176,15 +179,44 @@ function readAilkTemplateManifest(sourceRoot, name, expectedRequires) {
176
179
  catch {
177
180
  throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} is not readable JSON`);
178
181
  }
179
- const declared = manifest.requires;
180
- if (!Array.isArray(declared)) {
182
+ const declaredRaw = manifest.requires;
183
+ if (!Array.isArray(declaredRaw)) {
181
184
  throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} declares no "requires" list`);
182
185
  }
186
+ // #5514: every element must be a string BEFORE the comparison below — a
187
+ // prior `join(",")` comparison would let a non-string element (or one
188
+ // containing a comma) defeat the disagreement check by colliding with an
189
+ // unrelated element-set on the joined string.
190
+ for (const item of declaredRaw) {
191
+ if (typeof item !== "string") {
192
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} "requires" contains a non-string entry`);
193
+ }
194
+ }
195
+ const declared = declaredRaw;
183
196
  const expected = [...new Set(expectedRequires)].sort();
184
197
  const actual = [...new Set(declared)].sort();
185
- if (expected.join(",") !== actual.join(",")) {
198
+ const disagrees = expected.length !== actual.length ||
199
+ expected.some((value, index) => value !== actual[index]);
200
+ if (disagrees) {
186
201
  throw new Error(`template "${name}": bundle requires [${actual.join(", ")}] but this create-ailk's registry requires [${expected.join(", ")}] — upgrade create-ailk`);
187
202
  }
203
+ // #5618 D2 — `extends` joins `requires` as the sibling cross-check, with
204
+ // the same "upgrade create-ailk" posture: the registry's value is the
205
+ // caller's compile-checked lineage, the bundle's is whatever the archive
206
+ // says. Compared exactly (a single parent, not a set). The base layer is
207
+ // the terminus and declares none — its caller passes none.
208
+ const declaredExtends = manifest.extends;
209
+ if (declaredExtends !== undefined && typeof declaredExtends !== "string") {
210
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} "extends" is not a string`);
211
+ }
212
+ if (declaredExtends !== expectedExtends) {
213
+ const show = (value) => value === undefined ? "nothing" : `"${value}"`;
214
+ throw new Error(`template "${name}": bundle extends ${show(declaredExtends)} but this create-ailk's registry extends ${show(expectedExtends)} — upgrade create-ailk`);
215
+ }
216
+ // #5612 D1 — validated here (throws naming the field on an unrecognized
217
+ // value), never merely stored: a bundle's `layout` is untrusted JSON,
218
+ // unlike the code registry's compile-time-checked `TemplateManifest.layout`.
219
+ validateTemplateLayout(manifest.layout, `template "${name}"`);
188
220
  return manifest;
189
221
  }
190
222
  /**
@@ -193,7 +225,7 @@ function readAilkTemplateManifest(sourceRoot, name, expectedRequires) {
193
225
  * bad entry rejects the whole bundle with nothing written to `targetDir`.
194
226
  * Returns the map unchanged (validation only) for the caller to apply.
195
227
  */
196
- function validateInstallMap(installMap, extractedRoot, targetAbs, subject) {
228
+ function validateInstallMap(installMap, extractedRoot, targetAbs, subject, isTemplate) {
197
229
  for (const [source, destination] of Object.entries(installMap)) {
198
230
  if (hasUnsafeSegment(source)) {
199
231
  throw new Error(`${subject}: unsafe installMap source "${source}"`);
@@ -219,6 +251,13 @@ function validateInstallMap(installMap, extractedRoot, targetAbs, subject) {
219
251
  if (!isPathInside(targetAbs, destAbs)) {
220
252
  throw new Error(`${subject}: installMap destination "${destination}" resolves outside target`);
221
253
  }
254
+ // #5514 (Medium) — content, never code (spec 5462 D9/D16): a template's
255
+ // installMap can only ever ADD a file, so a code-extension destination
256
+ // is refused outright, same fail-closed point as the containment checks
257
+ // above.
258
+ if (isTemplate && hasTemplateForbiddenExtension(destination)) {
259
+ throw new Error(`${subject}: a template must not carry a code file — installMap destination "${destination}" (spec 5462 D9)`);
260
+ }
222
261
  }
223
262
  }
224
263
  /**
@@ -250,11 +289,130 @@ function validatePatches(patches, targetAbs, subject) {
250
289
  }
251
290
  }
252
291
  }
292
+ /**
293
+ * TEMPLATE-only "content, never code" guard (spec 5462 § D9/D16; #5514). A
294
+ * template's manifest and tree are UNTRUSTED — fetched from the registry —
295
+ * so the rule that a template contributes content, never code, is enforced
296
+ * here, at the same fail-closed validation point as `installMap`/`patches`
297
+ * below, rather than only by the two jest cases that assert it against the
298
+ * `site-templates/` tree this create-ailk itself ships (those only prove
299
+ * OUR tree is honest, not that a hostile registry response is refused).
300
+ */
301
+ const TEMPLATE_FORBIDDEN_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
302
+ function hasTemplateForbiddenExtension(p) {
303
+ return TEMPLATE_FORBIDDEN_EXTENSIONS.some((ext) => p.toLowerCase().endsWith(ext));
304
+ }
305
+ /**
306
+ * Walks `srcRoot` and throws on the first file (not in `excluded`) whose
307
+ * name ends in a code extension — before any copy, so a violation rejects
308
+ * the whole tree with nothing written to `targetDir` (same posture as
309
+ * `validateInstallMap`/`validatePatches`). `excluded` is the SAME
310
+ * POSIX-normalized set `copyTree` below will use, so the manifest itself and
311
+ * any installMap source never trip this guard a second time.
312
+ */
313
+ function validateTemplateContentOnly(srcRoot, excluded, subject) {
314
+ const stack = [srcRoot];
315
+ while (stack.length > 0) {
316
+ const dir = stack.pop();
317
+ if (dir === undefined)
318
+ break;
319
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
320
+ const srcPath = join(dir, entry.name);
321
+ if (entry.isDirectory()) {
322
+ stack.push(srcPath);
323
+ continue;
324
+ }
325
+ if (!entry.isFile())
326
+ continue;
327
+ const rel = relative(srcRoot, srcPath).split(sep).join("/");
328
+ if (excluded.has(rel))
329
+ continue;
330
+ if (hasTemplateForbiddenExtension(rel)) {
331
+ throw new Error(`${subject}: a template must not carry a code file — "${rel}" (spec 5462 D9)`);
332
+ }
333
+ // symlinks: never present — safeExtractTarGz rejects them outright.
334
+ }
335
+ }
336
+ }
253
337
  /** C0 (0x00-0x1F) and C1 (0x7F-0x9F) control characters, newline excepted. */
254
338
  function stripControlCharacters(text) {
255
339
  // eslint-disable-next-line no-control-regex -- deliberately stripping control chars
256
340
  return text.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, "");
257
341
  }
342
+ /**
343
+ * The additive-overlap rule (#5618 D4), checked in the validation block
344
+ * before the first write so a refusal leaves `targetDir` exactly as it was:
345
+ *
346
+ * | incoming layer | destination owned by | outcome |
347
+ * | base | nothing (it is first) | records |
348
+ * | module | base or an earlier module | REFUSED — unless a declared |
349
+ * | | | `patches` target |
350
+ * | module | nothing recorded | allowed, records |
351
+ * | template | anything | allowed, records |
352
+ *
353
+ * The template layer may overwrite anything because it is the customer's
354
+ * copy — that is how a template overrides a module's seed page (D3), and
355
+ * refusing it would refuse `--template marketing-site` as shipped. The
356
+ * `patches` escape hatch is unambiguously module-only: a template carrying
357
+ * `patches` at all is refused outright above.
358
+ *
359
+ * The SECOND module-only escape hatch is DECLARED CO-OWNERSHIP
360
+ * (`coOwnedModuleDestinations()`): a repo file that more than one module's
361
+ * `ownedFiles` names. Both bundles stage it from the same source, so the
362
+ * second to land writes the bytes already there — no disagreement to
363
+ * resolve, which is the only thing this rule protects against. #5677 needs
364
+ * it: the review surface registers under `multi-tenant || signal-collectors`
365
+ * and so must ship in both bundles, and `saas-product` selects both. An
366
+ * overlap neither module declares is still refused.
367
+ *
368
+ * Mirrors `mergeRoster`'s `claimedBy` refusal one grain down: a collision is
369
+ * named with both claimants and the path, never resolved by a silent
370
+ * last-writer-wins.
371
+ */
372
+ function validateLayerOwnership(destinations, ownership, subject, isTemplate, patchTargets, coOwned) {
373
+ if (isTemplate)
374
+ return;
375
+ for (const destination of destinations) {
376
+ const owner = ownership.get(destination);
377
+ if (owner !== undefined &&
378
+ !patchTargets.has(destination) &&
379
+ !coOwned.has(destination)) {
380
+ throw new Error(`${subject}: destination "${destination}" is already owned by ${owner} — a module's contribution to a file another layer wrote must be a declared patch, never an overwrite (#5618 D4)`);
381
+ }
382
+ }
383
+ }
384
+ /**
385
+ * Every scaffold-relative destination this tree will write: each unmapped
386
+ * staged file at its own path (exactly the set `copyTree` lands, same walk,
387
+ * same exclusions) plus every `installMap` destination. Computed before any
388
+ * write, for `validateLayerOwnership` and then for the record.
389
+ */
390
+ function collectLayerDestinations(srcRoot, excluded, installMap) {
391
+ const out = [];
392
+ const stack = [srcRoot];
393
+ while (stack.length > 0) {
394
+ const dir = stack.pop();
395
+ if (dir === undefined)
396
+ break;
397
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
398
+ const srcPath = join(dir, entry.name);
399
+ if (entry.isDirectory()) {
400
+ stack.push(srcPath);
401
+ continue;
402
+ }
403
+ if (!entry.isFile())
404
+ continue;
405
+ const rel = relative(srcRoot, srcPath).split(sep).join("/");
406
+ if (excluded.has(rel))
407
+ continue;
408
+ out.push(rel);
409
+ }
410
+ }
411
+ for (const destination of Object.values(installMap)) {
412
+ out.push(normalize(destination).split(sep).join("/"));
413
+ }
414
+ return out;
415
+ }
258
416
  /**
259
417
  * Extract a fetched module bundle into `targetDir`, merging its files onto
260
418
  * the tree `copyTemplate` already wrote. Throws on any unsafe entry,
@@ -278,11 +436,15 @@ export async function extractModuleBundle(options) {
278
436
  await safeExtractTarGz(stagingTar, extractedRoot, templateRequires !== undefined
279
437
  ? `template "${moduleName}"`
280
438
  : `module "${moduleName}"`);
281
- installBundleTree({
439
+ return installBundleTree({
282
440
  sourceRoot: extractedRoot,
283
441
  targetDir: targetAbs,
284
442
  name: moduleName,
285
443
  ...(templateRequires !== undefined ? { templateRequires } : {}),
444
+ ...(options.templateExtends !== undefined
445
+ ? { templateExtends: options.templateExtends }
446
+ : {}),
447
+ ...(options.ownership !== undefined ? { ownership: options.ownership } : {}),
286
448
  ...(options.versions !== undefined ? { versions: options.versions } : {}),
287
449
  });
288
450
  }
@@ -301,7 +463,7 @@ export async function extractModuleBundle(options) {
301
463
  * rejected tree leaves `targetDir` exactly as it was.
302
464
  */
303
465
  export function installBundleTree(options) {
304
- const { sourceRoot, name, templateRequires } = options;
466
+ const { sourceRoot, name, templateRequires, templateExtends, ownership } = options;
305
467
  const targetAbs = resolve(options.targetDir);
306
468
  const isTemplate = templateRequires !== undefined;
307
469
  const subject = isTemplate ? `template "${name}"` : `module "${name}"`;
@@ -309,16 +471,51 @@ export function installBundleTree(options) {
309
471
  // whole tree with nothing written to targetDir (same posture as the
310
472
  // tar-entry validation in safeExtractTarGz).
311
473
  const manifest = isTemplate
312
- ? readAilkTemplateManifest(sourceRoot, name, templateRequires)
474
+ ? readAilkTemplateManifest(sourceRoot, name, templateRequires, templateExtends)
313
475
  : readAilkModuleManifest(sourceRoot);
314
476
  const installMap = manifest?.installMap ?? {};
315
- validateInstallMap(installMap, sourceRoot, targetAbs, subject);
477
+ validateInstallMap(installMap, sourceRoot, targetAbs, subject, isTemplate);
316
478
  // patches validation (spec 5418 § 3 D6) runs at the same point, before
317
479
  // any copy — a bad target/kind/op rejects the whole tree with nothing
318
480
  // written to targetDir, same posture as installMap and the tar-entry
319
481
  // validation above.
320
482
  const patches = manifest?.patches ?? [];
483
+ // #5514 (Medium) — content, never code (spec 5462 D9/D16): every `patches`
484
+ // kind either edits a code file directly (`next-config`, `jest-config`)
485
+ // or a manifest a template has no legitimate reason to touch
486
+ // (`package.json`/`tsconfig.json` belong to the scaffold, not to a
487
+ // content template) — no shipped template uses it, so the simplest
488
+ // fail-closed reading refuses it outright rather than enumerating an
489
+ // allowed subset that has no current user.
490
+ if (isTemplate && patches.length > 0) {
491
+ throw new Error(`${subject}: a template must not carry patches — content, never code (spec 5462 D9)`);
492
+ }
321
493
  validatePatches(patches, targetAbs, subject);
494
+ // The exclusion set copyTree/the content-only guard both use — built from
495
+ // NORMALIZED installMap keys (#5514) so a "./x" key excludes the same
496
+ // string `rel`'s traversal below computes, rather than colliding with it
497
+ // only by accident. Both manifest filenames are excluded unconditionally
498
+ // (previously a ternary picked one) — a module bundle carrying a stray
499
+ // ailk-template.json, or vice versa, should never land either.
500
+ const excluded = new Set([
501
+ AILK_TEMPLATE_JSON,
502
+ AILK_MODULE_JSON,
503
+ ...Object.keys(installMap).map((key) => normalize(key).split(sep).join("/")),
504
+ ]);
505
+ if (isTemplate) {
506
+ validateTemplateContentOnly(sourceRoot, excluded, subject);
507
+ }
508
+ // #5618 D4 — the additive-overlap check, at the same seam as the
509
+ // installMap/patches/content-only validation above: BEFORE the first write
510
+ // at copyTree below, so a refused layer leaves targetDir exactly as it
511
+ // was. The destination set is what copyTree + the installMap loop will
512
+ // land; the record happens after writeModulePatches, once it has.
513
+ const layerDestinations = ownership !== undefined
514
+ ? collectLayerDestinations(sourceRoot, excluded, installMap)
515
+ : [];
516
+ if (ownership !== undefined) {
517
+ validateLayerOwnership(layerDestinations, ownership, subject, isTemplate, new Set(patches.map((patch) => normalize(patch.target).split(sep).join("/"))), coOwnedModuleDestinations());
518
+ }
322
519
  // Compute every patch target's new content BEFORE any write to
323
520
  // targetDir — including before copyTree/installMap below, not only
324
521
  // before the patch step's own write (spec 5418 § 3 D4/§ 6.2): a
@@ -353,10 +550,6 @@ export function installBundleTree(options) {
353
550
  // every installMap source are excluded from the whole-tree copy — the
354
551
  // manifest never lands in the scaffold, and a mapped source lands ONLY
355
552
  // at its mapped destination, never also at its original path.
356
- const excluded = new Set([
357
- isTemplate ? AILK_TEMPLATE_JSON : AILK_MODULE_JSON,
358
- ...Object.keys(installMap),
359
- ]);
360
553
  copyTree(sourceRoot, targetAbs, excluded);
361
554
  for (const [source, destination] of Object.entries(installMap)) {
362
555
  const destAbs = resolve(targetAbs, destination);
@@ -369,9 +562,24 @@ export function installBundleTree(options) {
369
562
  // COMPUTED above, before either ran) overwrites rather than conflicts
370
563
  // with content copyTree/installMap just placed at the same path.
371
564
  writeModulePatches(computedPatches, targetAbs);
565
+ // #5618 D4 — record this layer's destinations only now that every one of
566
+ // them is in place (a later layer's overwrite is judged against the layer
567
+ // that actually wrote the bytes). Patch targets are NOT recorded: a patch
568
+ // edits a file another layer (or the snapshot) owns and does not claim it.
569
+ if (ownership !== undefined) {
570
+ for (const destination of layerDestinations)
571
+ ownership.set(destination, subject);
572
+ }
372
573
  if (manifest?.postInstall) {
373
574
  process.stdout.write(`${stripControlCharacters(manifest.postInstall)}\n`);
374
575
  }
576
+ // #5612 D1/D2 — already validated above (readAilkTemplateManifest calls
577
+ // validateTemplateLayout); returned as-is (defaults unfilled) for the
578
+ // caller to resolve alongside whatever the code registry's own
579
+ // `TemplateManifest.layout` says.
580
+ return isTemplate && manifest?.layout !== undefined
581
+ ? { layout: manifest.layout }
582
+ : {};
375
583
  }
376
584
  /**
377
585
  * Recursively copy every file from `srcRoot` into `destRoot`, creating parent
@@ -470,7 +678,7 @@ async function safeExtractTarGz(tarGzPath, targetDir, subject) {
470
678
  },
471
679
  });
472
680
  try {
473
- await pipeline(createReadStream(tarGzPath), createGunzip(), extractStream);
681
+ await pipeline(createReadStream(tarGzPath), createGunzip({ maxOutputLength: MAX_TOTAL_SIZE_BYTES }), extractStream);
474
682
  }
475
683
  catch (err) {
476
684
  if (captured.error !== null)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-request timeout applied to every template/module registry fetch.
3
+ * 30s is generous for a small tarball fetch over a real network while still
4
+ * bounding a hung connection or slow-drip response.
5
+ */
6
+ export declare const REQUEST_TIMEOUT_MS = 30000;
7
+ /** `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` — one call site, one constant. */
8
+ export declare function fetchTimeoutSignal(): AbortSignal;
9
+ /**
10
+ * Reject a registry URL whose origin is not `https:`, unless its host is
11
+ * loopback (above) — the mock registry the test suite spawns is deliberately
12
+ * plain `http://127.0.0.1:<port>`. `ErrorCtor` lets each
13
+ * caller throw its own error class (`FetchTemplateError` / `FetchModuleError`)
14
+ * so this is a new FAILURE MODE, never a new error type from the caller's
15
+ * point of view — and never a licence error (`isLicenseError` stays false).
16
+ */
17
+ export declare function assertHttpsRegistryUrl<E extends Error>(registryUrl: string, ErrorCtor: new (message: string) => E): void;
18
+ /**
19
+ * Reject a `Response` whose `Content-Length` header declares a size larger
20
+ * than `MAX_TOTAL_SIZE_BYTES` — called by the caller right after its
21
+ * existing status-code checks succeed, BEFORE `.arrayBuffer()` ever runs, so
22
+ * an oversized body is never buffered into memory. A missing or unparseable
23
+ * `Content-Length` is not itself a violation (a chunked/streamed response may
24
+ * omit it) — this is defense in depth against a declared size, not a
25
+ * replacement for the tar-entry cumulative-size cap `extract-module-bundle.ts`
26
+ * still enforces downstream.
27
+ */
28
+ export declare function assertContentLengthWithinLimit<E extends Error>(response: Response, ErrorCtor: new (message: string) => E): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared network-safety guards for `fetch-template.ts` / `fetch-module.ts`
3
+ * (issue #5514, Lows 2 and 5 — a post-merge backstop on #5510's
4
+ * `create-ailk --template` feature).
5
+ *
6
+ * A plain internal helper module, not a shared package: both callers already
7
+ * state at length (see their file docs) why `create-ailk` — the one package
8
+ * in this monorepo published to public npm — cannot take a runtime
9
+ * dependency on anything `restricted`-tier. A sibling file inside
10
+ * `packages/create-ailk/src/lib/` carries no such dependency and is exactly
11
+ * the right place for logic the two fetchers would otherwise duplicate a
12
+ * second time.
13
+ *
14
+ * Three guards live here:
15
+ * - `REQUEST_TIMEOUT_MS` / `fetchTimeoutSignal()` — neither fetcher had a
16
+ * request timeout before this; a hung or slow-drip response could block
17
+ * `create-ailk` forever.
18
+ * - `assertHttpsRegistryUrl()` — neither fetcher validated the scheme of
19
+ * the registry URL (env-var-overridable) it was about to send a bearer
20
+ * JWT to. Rejects any non-`https:` origin, UNLESS the test-mode seam
21
+ * (`NODE_ENV === "test"`) is active — the same seam
22
+ * `template-scaffold.test.ts`'s `run()` helper already relies on when it
23
+ * spreads `process.env` into the CLI subprocess it spawns against the
24
+ * loopback mock registry (`http://127.0.0.1:<port>`). Confirmed
25
+ * empirically: Jest sets `NODE_ENV=test` for its own process (and thus
26
+ * every child it spawns, since `run()` inherits `process.env`) whenever
27
+ * the ambient environment does not already set it — there is no other
28
+ * test-mode flag anywhere in this codebase (`AILK_ALLOW*`, `insecure`,
29
+ * `__TEST__`, etc. do not exist).
30
+ * - `assertContentLengthWithinLimit()` — neither fetcher checked a
31
+ * response's declared size against `MAX_TOTAL_SIZE_BYTES` before buffering
32
+ * the WHOLE body via `.arrayBuffer()`. That cap was previously enforced
33
+ * only downstream, against tar entries, in
34
+ * `extract-module-bundle.ts`'s `safeExtractTarGz` — too late to stop an
35
+ * oversized response from being held in memory first.
36
+ */
37
+ import { MAX_TOTAL_SIZE_BYTES } from "./extract-module-bundle.js";
38
+ /**
39
+ * Per-request timeout applied to every template/module registry fetch.
40
+ * 30s is generous for a small tarball fetch over a real network while still
41
+ * bounding a hung connection or slow-drip response.
42
+ */
43
+ export const REQUEST_TIMEOUT_MS = 30_000;
44
+ /** `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` — one call site, one constant. */
45
+ export function fetchTimeoutSignal() {
46
+ return AbortSignal.timeout(REQUEST_TIMEOUT_MS);
47
+ }
48
+ /**
49
+ * `true` iff `hostname` is a loopback host — the ONLY hosts a plain `http:`
50
+ * registry URL is accepted for. The test suite spawns its mock registry on
51
+ * `127.0.0.1`; nothing else needs cleartext. (An earlier revision keyed this
52
+ * on `NODE_ENV=test`, an ambient variable any environment can export — a
53
+ * security control in a public npm package must not carry a kill switch.)
54
+ */
55
+ function isLoopbackHost(hostname) {
56
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
57
+ }
58
+ /**
59
+ * Reject a registry URL whose origin is not `https:`, unless its host is
60
+ * loopback (above) — the mock registry the test suite spawns is deliberately
61
+ * plain `http://127.0.0.1:<port>`. `ErrorCtor` lets each
62
+ * caller throw its own error class (`FetchTemplateError` / `FetchModuleError`)
63
+ * so this is a new FAILURE MODE, never a new error type from the caller's
64
+ * point of view — and never a licence error (`isLicenseError` stays false).
65
+ */
66
+ export function assertHttpsRegistryUrl(registryUrl, ErrorCtor) {
67
+ let scheme;
68
+ let hostname;
69
+ try {
70
+ const parsed = new URL(registryUrl);
71
+ scheme = parsed.protocol;
72
+ hostname = parsed.hostname;
73
+ }
74
+ catch {
75
+ throw new ErrorCtor(`refusing to use registry URL "${registryUrl}": not a valid URL`);
76
+ }
77
+ if (scheme !== "https:" && !isLoopbackHost(hostname)) {
78
+ throw new ErrorCtor(`refusing to use registry URL "${registryUrl}": scheme must be https:, got "${scheme}"`);
79
+ }
80
+ }
81
+ /**
82
+ * Reject a `Response` whose `Content-Length` header declares a size larger
83
+ * than `MAX_TOTAL_SIZE_BYTES` — called by the caller right after its
84
+ * existing status-code checks succeed, BEFORE `.arrayBuffer()` ever runs, so
85
+ * an oversized body is never buffered into memory. A missing or unparseable
86
+ * `Content-Length` is not itself a violation (a chunked/streamed response may
87
+ * omit it) — this is defense in depth against a declared size, not a
88
+ * replacement for the tar-entry cumulative-size cap `extract-module-bundle.ts`
89
+ * still enforces downstream.
90
+ */
91
+ export function assertContentLengthWithinLimit(response, ErrorCtor) {
92
+ const header = response.headers.get("content-length");
93
+ if (header === null)
94
+ return;
95
+ const declared = Number(header);
96
+ if (!Number.isFinite(declared))
97
+ return;
98
+ if (declared > MAX_TOTAL_SIZE_BYTES) {
99
+ throw new ErrorCtor(`refusing response: declared Content-Length ${declared} exceeds the ${MAX_TOTAL_SIZE_BYTES}-byte limit`);
100
+ }
101
+ }
@@ -36,6 +36,7 @@
36
36
  * exit uniformly.
37
37
  */
38
38
  import pkg from "../../package.json" with { type: "json" };
39
+ import { assertContentLengthWithinLimit, assertHttpsRegistryUrl, fetchTimeoutSignal, } from "./fetch-guard.js";
39
40
  export const DEFAULT_AILK_MODULE_REGISTRY_URL = "https://ailaunchkit.ai/api/modules";
40
41
  /**
41
42
  * This package's own `package.json` `"version"`. Deliberately a static JSON
@@ -79,6 +80,7 @@ export async function fetchModule(options) {
79
80
  const registryUrl = options.registryUrl ??
80
81
  process.env["AILK_MODULE_REGISTRY_URL"] ??
81
82
  DEFAULT_AILK_MODULE_REGISTRY_URL;
83
+ assertHttpsRegistryUrl(registryUrl, FetchModuleError);
82
84
  const url = buildUrl(registryUrl, moduleName, version);
83
85
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
84
86
  let response;
@@ -90,6 +92,7 @@ export async function fetchModule(options) {
90
92
  Accept: "application/gzip, application/octet-stream",
91
93
  },
92
94
  redirect: "error",
95
+ signal: fetchTimeoutSignal(),
93
96
  });
94
97
  }
95
98
  catch (err) {
@@ -111,6 +114,7 @@ export async function fetchModule(options) {
111
114
  const body = await safeReadBody(response);
112
115
  throw new FetchModuleError(`module registry returned ${response.status} for "${moduleName}": ${body}`);
113
116
  }
117
+ assertContentLengthWithinLimit(response, FetchModuleError);
114
118
  const buf = await response.arrayBuffer();
115
119
  return new Uint8Array(buf);
116
120
  }