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.
- package/component-catalog.json +11939 -0
- package/component-catalog.md +176 -64
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/module-architecture.d.ts +59 -8
- package/dist/module-architecture.js +334 -115
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +107 -32
- package/dist/template-manifest.d.ts +55 -3
- package/dist/template-manifest.js +181 -12
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +9 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
- package/site-templates/minimalism-site/ailk-template.json +21 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +32 -7
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/CLAUDE.md +4 -1
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/package.json +6 -2
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/server.ts +82 -6
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/default-tenant.ts +261 -0
- package/templates/apps/api/src/services/lead-promotion.ts +161 -0
- package/templates/apps/api/src/services/leads-inbox.ts +263 -0
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/mcp/CLAUDE.md +1 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/package.json +6 -2
- package/templates/apps/mcp/src/server.ts +2 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/index.ts +2 -2
- package/templates/apps/web/.env.example +3 -3
- package/templates/apps/web/CLAUDE.md +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +214 -17
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +7 -15
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
- package/templates/apps/web/messages/en.json +29 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +6 -2
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /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,
|
|
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", "
|
|
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<
|
|
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):
|
|
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
|
-
|
|
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
|
|
180
|
-
if (!Array.isArray(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/fetch-module.js
CHANGED
|
@@ -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
|
}
|