create-daloy 1.0.0-beta.2 → 1.0.0-beta.4

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/README.md CHANGED
@@ -196,6 +196,11 @@ For Node-style templates, the bundle adds the following.
196
196
  [npm-audit-guide](https://www.aikido.dev/blog/npm-audit-guide) write-ups warn
197
197
  about, and the Deno scaffold gets it too (Deno has no `audit` built in, so
198
198
  without OSV-Scanner a Deno scaffold would have no scheduled SCA at all).
199
+ - `.github/workflows/eol-scan.yml` — daily runtime end-of-life detection for
200
+ pinned Node, Bun, and Deno versions in package metadata and GitHub Actions.
201
+ A runtime can keep running after it stops receiving security patches; this
202
+ job fails once a pinned runtime cycle is already EOL and warns within 90 days
203
+ of EOL.
199
204
 
200
205
  **Secret and static analysis**
201
206
 
@@ -235,8 +240,9 @@ out a reusable package, opt into npm trusted publishing yourself.
235
240
  For `deno-basic`, `--with-ci` generates a Deno-native CI workflow, a manual-only
236
241
  container publish starter for GHCR that is guarded to `main` or a tag by
237
242
  default, plus CodeQL, Opengrep, **OSV-Scanner** (the only scheduled SCA layer
238
- a Deno scaffold has, since Deno ships no `audit`), Scorecard, zizmor,
239
- Dependabot for GitHub Actions, CODEOWNERS, and `SECURITY.md`.
243
+ a Deno scaffold has, since Deno ships no `audit`), runtime EOL scanning,
244
+ Scorecard, zizmor, Dependabot for GitHub Actions, CODEOWNERS, and
245
+ `SECURITY.md`.
240
246
 
241
247
  If you want the governance bundle but not the deployment starter, pass
242
248
  `--with-ci --no-deploy`. If you only want a deployment starter, pass
@@ -1137,14 +1137,26 @@ async function writePackageJson(dir, packageJson) {
1137
1137
  await writeFile(path.join(dir, "package.json"), JSON.stringify(packageJson, null, 2) + "\n", "utf8");
1138
1138
  }
1139
1139
 
1140
- async function addLockfileVerifyScript(dir) {
1140
+ async function addSecurityScripts(dir, packageManager) {
1141
1141
  const packageJson = await readPackageJsonIfPresent(dir);
1142
1142
  if (!packageJson) return;
1143
1143
  packageJson.scripts ??= {};
1144
1144
  packageJson.scripts["verify:lockfile"] = "node scripts/verify-lockfile-sources.mjs";
1145
+ packageJson.scripts["verify:runtime-eol"] =
1146
+ packageManager === "bun" ? "bun scripts/verify-runtime-eol.mjs" : "node scripts/verify-runtime-eol.mjs";
1145
1147
  await writePackageJson(dir, packageJson);
1146
1148
  }
1147
1149
 
1150
+ async function addDenoSecurityTasks(dir) {
1151
+ const file = path.join(dir, "deno.json");
1152
+ if (!existsSync(file)) return;
1153
+ const denoJson = JSON.parse(await readFile(file, "utf8"));
1154
+ denoJson.tasks ??= {};
1155
+ denoJson.tasks["verify:runtime-eol"] =
1156
+ "deno run --allow-read --allow-net=endoflife.date scripts/verify-runtime-eol.mjs";
1157
+ await writeFile(file, JSON.stringify(denoJson, null, 2) + "\n", "utf8");
1158
+ }
1159
+
1148
1160
  function renderCiReplacements({ packageManager, template, packageJson, codeOwner, includeSecurityBundle = true }) {
1149
1161
  const setupPm = setupPackageManagerStep(packageManager);
1150
1162
  const needsBunRuntime = template === "bun-basic" && packageManager !== "bun";
@@ -1183,6 +1195,7 @@ function renderCiReplacements({ packageManager, template, packageJson, codeOwner
1183
1195
  ["__SETUP_BUN_RUNTIME_STEP__", needsBunRuntime ? setupBunStep() : ""],
1184
1196
  ["__INSTALL_COMMAND__", installCommand(packageManager)],
1185
1197
  ["__VERIFY_LOCKFILE_COMMAND__", runScriptCommand(packageManager, "verify:lockfile")],
1198
+ ["__RUNTIME_EOL_COMMAND__", runScriptCommand(packageManager, "verify:runtime-eol")],
1186
1199
  ["__TYPECHECK_COMMAND__", runScriptCommand(packageManager, "typecheck")],
1187
1200
  ["__TEST_COMMAND__", runScriptCommand(packageManager, "test")],
1188
1201
  ["__BUILD_STEP__", buildStep],
@@ -1217,14 +1230,15 @@ async function replacePlaceholdersInTree(dir, replacements) {
1217
1230
  async function pruneCiBundle(targetDir, flavor, { includeSecurityBundle, includeDeployWorkflow }) {
1218
1231
  if (!includeSecurityBundle) {
1219
1232
  const workflowFiles = flavor === "deno"
1220
- ? ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "zizmor.yml"]
1221
- : ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "vuln-scan.yml", "zizmor.yml"];
1233
+ ? ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "eol-scan.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "zizmor.yml"]
1234
+ : ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "eol-scan.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "vuln-scan.yml", "zizmor.yml"];
1222
1235
  for (const file of workflowFiles) {
1223
1236
  await rm(path.join(targetDir, ".github", "workflows", file), { force: true });
1224
1237
  }
1225
1238
  await rm(path.join(targetDir, ".github", "dependabot.yml"), { force: true });
1226
1239
  await rm(path.join(targetDir, ".github", "CODEOWNERS"), { force: true });
1227
1240
  await rm(path.join(targetDir, "SECURITY.md"), { force: true });
1241
+ await rm(path.join(targetDir, "scripts", "verify-runtime-eol.mjs"), { force: true });
1228
1242
  if (flavor === "node") {
1229
1243
  await rm(path.join(targetDir, "scripts", "verify-lockfile-sources.mjs"), { force: true });
1230
1244
  }
@@ -1264,6 +1278,9 @@ async function copyCiBundle(
1264
1278
  `Invalid --code-owner "${candidate}". Use a GitHub handle (@user), a team (@org/team), or an email address.`,
1265
1279
  );
1266
1280
  }
1281
+ if (includeSecurityBundle) {
1282
+ await addDenoSecurityTasks(targetDir);
1283
+ }
1267
1284
  await replacePlaceholdersInTree(targetDir, new Map([["__CODE_OWNER__", owner]]));
1268
1285
  return;
1269
1286
  }
@@ -1274,7 +1291,7 @@ async function copyCiBundle(
1274
1291
  `Invalid --code-owner "${candidate}". Use a GitHub handle (@user), a team (@org/team), or an email address.`,
1275
1292
  );
1276
1293
  }
1277
- await addLockfileVerifyScript(targetDir);
1294
+ await addSecurityScripts(targetDir, packageManager);
1278
1295
  }
1279
1296
  const packageJson = await readPackageJsonIfPresent(targetDir);
1280
1297
  await replacePlaceholdersInTree(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-daloy",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/sbom.cdx.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:85335f39-314a-5935-b7db-6add01a96fca",
4
+ "serialNumber": "urn:uuid:1f97437c-9665-5c96-94a2-d8d548acda4b",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-06-22T21:07:22.648Z",
7
+ "timestamp": "2026-06-26T13:41:50.237Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-beta.2"
12
+ "version": "1.0.0-beta.4"
13
13
  }
14
14
  ],
15
15
  "authors": [],
16
16
  "component": {
17
17
  "type": "library",
18
- "bom-ref": "pkg:npm/create-daloy@1.0.0-beta.2",
18
+ "bom-ref": "pkg:npm/create-daloy@1.0.0-beta.4",
19
19
  "name": "create-daloy",
20
- "version": "1.0.0-beta.2",
20
+ "version": "1.0.0-beta.4",
21
21
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
22
- "purl": "pkg:npm/create-daloy@1.0.0-beta.2",
22
+ "purl": "pkg:npm/create-daloy@1.0.0-beta.4",
23
23
  "licenses": [
24
24
  {
25
25
  "license": {
@@ -42,9 +42,9 @@
42
42
  }
43
43
  ],
44
44
  "swid": {
45
- "tagId": "swidtag-create-daloy-1.0.0-beta.2",
45
+ "tagId": "swidtag-create-daloy-1.0.0-beta.4",
46
46
  "name": "create-daloy",
47
- "version": "1.0.0-beta.2",
47
+ "version": "1.0.0-beta.4",
48
48
  "tagVersion": 0,
49
49
  "patch": false
50
50
  }
@@ -53,7 +53,7 @@
53
53
  "components": [],
54
54
  "dependencies": [
55
55
  {
56
- "ref": "pkg:npm/create-daloy@1.0.0-beta.2",
56
+ "ref": "pkg:npm/create-daloy@1.0.0-beta.4",
57
57
  "dependsOn": []
58
58
  }
59
59
  ]
package/sbom.spdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "create-daloy-1.0.0-beta.2",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-1.0.0-beta.2-85335f39-314a-5935-b7db-6add01a96fca",
5
+ "name": "create-daloy-1.0.0-beta.4",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-1.0.0-beta.4-1f97437c-9665-5c96-94a2-d8d548acda4b",
7
7
  "creationInfo": {
8
- "created": "2026-06-22T21:07:22.648Z",
8
+ "created": "2026-06-26T13:41:50.237Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package-create-daloy",
18
18
  "name": "create-daloy",
19
- "versionInfo": "1.0.0-beta.2",
19
+ "versionInfo": "1.0.0-beta.4",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/create-daloy@1.0.0-beta.2"
30
+ "referenceLocator": "pkg:npm/create-daloy@1.0.0-beta.4"
31
31
  }
32
32
  ]
33
33
  }
@@ -27,6 +27,9 @@ The `--with-ci` bundle adds these defaults:
27
27
  Semgrep, verified via sigstore cosign before execution), OpenSSF
28
28
  Scorecard, zizmor, Dependabot for GitHub Actions and Docker
29
29
  base images, and CODEOWNERS are generated.
30
+ - A daily scheduled `eol-scan.yml` checks pinned Deno versions in `deno.json`
31
+ and GitHub Actions against endoflife.date so a runtime that has stopped
32
+ receiving security patches does not stay unnoticed on `main`.
30
33
  - A `secret-scan.yml` workflow runs [gitleaks](https://github.com/gitleaks/gitleaks) against the working tree on every PR / push and against the **full git history** on a daily schedule. The gitleaks binary is downloaded from a pinned official release and verified by SHA-256 before execution so the scan does not introduce a new third-party action into the supply chain. Findings block the merge; matched values are redacted from the public log. The rationale (and the remediation playbook for a confirmed leak) follows Aikido's [Secrets Detection guide](https://www.aikido.dev/blog/secret-detection-application-security): a secret in any commit, branch, or tag should be treated as compromised, and detection must consider the entire history — not just the latest snapshot — alongside GitHub-native push protection.
31
34
 
32
35
  ## Cloud posture (operator checklist)
@@ -62,4 +65,4 @@ Before relying on these files for a company project:
62
65
  1. Replace `@your-org/security-team` in `.github/CODEOWNERS` or pass `--code-owner` when scaffolding.
63
66
  2. Protect the `main` branch and require the CI, CodeQL, Opengrep, Scorecard, and zizmor checks.
64
67
  3. Enable GitHub secret scanning and push protection.
65
- 4. Keep Deno permissions narrow; do not switch tasks to `--allow-all`.
68
+ 4. Keep Deno permissions narrow; do not switch tasks to `--allow-all`.
@@ -0,0 +1,57 @@
1
+ # End-of-life runtime scan generated by create-daloy --with-ci.
2
+ #
3
+ # Why this exists:
4
+ # A Deno version can keep running after it stops receiving security patches.
5
+ # This scheduled check looks at pinned runtime versions in deno.json and
6
+ # GitHub Actions, then compares them with endoflife.date so old runtime pins
7
+ # do not sit unnoticed on main.
8
+ #
9
+ # Hardening:
10
+ # * `permissions: {}` at the top level; the job opts in to contents: read.
11
+ # * Third-party actions are SHA-pinned and managed by Dependabot.
12
+ # * `step-security/harden-runner` runs in audit mode.
13
+ # * `actions/checkout` runs with `persist-credentials: false`.
14
+
15
+ name: EOL runtime scan
16
+
17
+ on:
18
+ schedule:
19
+ # 08:21 UTC daily, offset from the other scheduled security scans.
20
+ - cron: "21 8 * * *"
21
+ workflow_dispatch:
22
+
23
+ permissions: {}
24
+
25
+ concurrency:
26
+ group: eol-scan-${{ github.workflow }}-${{ github.ref }}
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ eol:
31
+ name: Daily EOL runtime scan
32
+ runs-on: ubuntu-latest
33
+ timeout-minutes: 10
34
+ permissions:
35
+ contents: read
36
+
37
+ steps:
38
+ - name: Harden runner
39
+ uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2
40
+ with:
41
+ egress-policy: audit
42
+ disable-sudo: true
43
+ disable-file-monitoring: false
44
+
45
+ - name: Checkout
46
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
47
+ with:
48
+ persist-credentials: false
49
+ show-progress: false
50
+
51
+ - name: Set up Deno
52
+ uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
53
+ with:
54
+ deno-version: v2.x
55
+
56
+ - name: Verify pinned runtimes are not EOL
57
+ run: deno task verify:runtime-eol
@@ -0,0 +1,211 @@
1
+ const EOL_WARN_DAYS = 90;
2
+ const FETCH_TIMEOUT_MS = 10_000;
3
+ const RUNTIME_FEEDS = {
4
+ node: "nodejs",
5
+ bun: "bun",
6
+ deno: "deno",
7
+ };
8
+
9
+ const isDeno = typeof globalThis.Deno !== "undefined";
10
+ const args = isDeno ? globalThis.Deno.args : globalThis.process.argv.slice(2);
11
+ const cwd = isDeno ? globalThis.Deno.cwd() : globalThis.process.cwd();
12
+
13
+ function joinPath(...parts) {
14
+ return parts.filter(Boolean).join("/").replace(/\/+/g, "/");
15
+ }
16
+
17
+ async function readText(file) {
18
+ if (isDeno) return await globalThis.Deno.readTextFile(file);
19
+ const { readFile } = await import("node:fs/promises");
20
+ return await readFile(file, "utf8");
21
+ }
22
+
23
+ async function readDirNames(dir) {
24
+ if (isDeno) {
25
+ const out = [];
26
+ for await (const entry of globalThis.Deno.readDir(dir)) out.push(entry.name);
27
+ return out;
28
+ }
29
+ const { readdir } = await import("node:fs/promises");
30
+ return await readdir(dir);
31
+ }
32
+
33
+ function parseVersionKey(value) {
34
+ if (typeof value !== "string") return null;
35
+ if (/latest/i.test(value)) return null;
36
+ const match = value.match(/v?\D*(\d+)(?:\.(\d+))?/);
37
+ if (!match) return null;
38
+ return match[2] ? `${Number(match[1])}.${Number(match[2])}` : String(Number(match[1]));
39
+ }
40
+
41
+ function collectFromPackageJson(json, source) {
42
+ const out = [];
43
+ const engines = json && typeof json === "object" ? json.engines : null;
44
+ if (!engines || typeof engines !== "object") return out;
45
+ for (const runtime of Object.keys(RUNTIME_FEEDS)) {
46
+ const version = parseVersionKey(engines[runtime]);
47
+ if (version) out.push({ runtime, version, source: `${source}: engines.${runtime}` });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function collectFromDenoJson(json, source) {
53
+ const out = [];
54
+ const version = parseVersionKey(json?.runtime?.deno ?? json?.engines?.deno);
55
+ if (version) out.push({ runtime: "deno", version, source: `${source}: deno runtime` });
56
+ return out;
57
+ }
58
+
59
+ function collectFromWorkflow(yaml, source) {
60
+ const out = [];
61
+ const patterns = [
62
+ ["node", /node-version:\s*['"]?([^'"\n#]+)/g],
63
+ ["bun", /bun-version:\s*['"]?([^'"\n#]+)/g],
64
+ ["deno", /deno-version:\s*['"]?([^'"\n#]+)/g],
65
+ ];
66
+ for (const [runtime, pattern] of patterns) {
67
+ let match = null;
68
+ while ((match = pattern.exec(yaml)) !== null) {
69
+ const version = parseVersionKey(match[1]);
70
+ if (version) out.push({ runtime, version, source });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ async function collectPinnedVersions() {
77
+ const found = [];
78
+ try {
79
+ found.push(...collectFromPackageJson(JSON.parse(await readText(joinPath(cwd, "package.json"))), "package.json"));
80
+ } catch {
81
+ // No package.json is fine for Deno-native scaffolds.
82
+ }
83
+ try {
84
+ found.push(...collectFromDenoJson(JSON.parse(await readText(joinPath(cwd, "deno.json"))), "deno.json"));
85
+ } catch {
86
+ // Node/Bun scaffolds do not ship deno.json.
87
+ }
88
+ try {
89
+ const workflowsDir = joinPath(cwd, ".github", "workflows");
90
+ for (const name of await readDirNames(workflowsDir)) {
91
+ if (!/\.(ya?ml)$/.test(name)) continue;
92
+ found.push(...collectFromWorkflow(await readText(joinPath(workflowsDir, name)), `.github/workflows/${name}`));
93
+ }
94
+ } catch {
95
+ // A local run without GitHub workflows should still scan package metadata.
96
+ }
97
+ return found;
98
+ }
99
+
100
+ async function fetchFeed(runtime) {
101
+ const feed = RUNTIME_FEEDS[runtime];
102
+ const ac = new AbortController();
103
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
104
+ try {
105
+ const res = await fetch(`https://endoflife.date/api/${feed}.json`, {
106
+ headers: {
107
+ "User-Agent": "daloy-runtime-eol-scan/1",
108
+ Accept: "application/json",
109
+ },
110
+ signal: ac.signal,
111
+ });
112
+ if (!res.ok) throw new Error(`${feed} feed returned HTTP ${res.status}`);
113
+ const json = await res.json();
114
+ if (!Array.isArray(json)) throw new Error(`${feed} feed returned a non-array payload`);
115
+ return json;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ function cycleNumber(cycle) {
122
+ const n = Number.parseFloat(String(cycle));
123
+ return Number.isFinite(n) ? n : -1;
124
+ }
125
+
126
+ function findCycle(feed, version) {
127
+ const exact = feed.find((item) => String(item.cycle) === version);
128
+ if (exact) return exact;
129
+ if (!/^\d+$/.test(version)) return null;
130
+ const major = Number(version);
131
+ const candidates = feed
132
+ .filter((item) => Math.floor(cycleNumber(item.cycle)) === major)
133
+ .sort((a, b) => cycleNumber(b.cycle) - cycleNumber(a.cycle));
134
+ return candidates[0] ?? null;
135
+ }
136
+
137
+ function daysUntil(date, now) {
138
+ return Math.floor((date.getTime() - now.getTime()) / 86_400_000);
139
+ }
140
+
141
+ function groupPinned(pinned) {
142
+ const grouped = new Map();
143
+ for (const pin of pinned) {
144
+ const key = `${pin.runtime}@${pin.version}`;
145
+ const current = grouped.get(key);
146
+ if (current) current.sources.add(pin.source);
147
+ else grouped.set(key, { ...pin, sources: new Set([pin.source]) });
148
+ }
149
+ return [...grouped.values()];
150
+ }
151
+
152
+ function failProcess() {
153
+ if (globalThis.process) {
154
+ globalThis.process.exitCode = 1;
155
+ return;
156
+ }
157
+ if (isDeno) globalThis.Deno.exit(1);
158
+ }
159
+
160
+ async function main() {
161
+ const strict = args.includes("--strict");
162
+ const now = new Date();
163
+ const findings = [];
164
+ const pinned = groupPinned(await collectPinnedVersions());
165
+
166
+ if (pinned.length === 0) {
167
+ console.log("verify-runtime-eol: no pinned runtime versions found.");
168
+ return;
169
+ }
170
+
171
+ const feeds = new Map();
172
+ for (const pin of pinned) {
173
+ if (!feeds.has(pin.runtime)) feeds.set(pin.runtime, await fetchFeed(pin.runtime));
174
+ const cycle = findCycle(feeds.get(pin.runtime), pin.version);
175
+ if (!cycle || cycle.eol === false) continue;
176
+ const eol = new Date(cycle.eol);
177
+ if (Number.isNaN(eol.getTime())) continue;
178
+ const remaining = daysUntil(eol, now);
179
+ if (remaining < 0 || remaining <= EOL_WARN_DAYS) {
180
+ findings.push({
181
+ runtime: pin.runtime,
182
+ version: pin.version,
183
+ cycle: cycle.cycle,
184
+ eol: cycle.eol,
185
+ days: remaining,
186
+ severity: remaining < 0 ? "eol" : "warn",
187
+ sources: [...pin.sources].sort(),
188
+ });
189
+ }
190
+ }
191
+
192
+ for (const finding of findings) {
193
+ const label = finding.severity === "eol" ? "EOL" : "WARNING";
194
+ const timing =
195
+ finding.severity === "eol"
196
+ ? `${Math.abs(finding.days)} day(s) ago`
197
+ : `in ${finding.days} day(s)`;
198
+ console.error(
199
+ `${label}: ${finding.runtime} ${finding.version} maps to cycle ${finding.cycle}, EOL ${timing} (${finding.eol}). Sources: ${finding.sources.join(", ")}`
200
+ );
201
+ }
202
+
203
+ if (findings.some((f) => f.severity === "eol") || (strict && findings.length > 0)) {
204
+ failProcess();
205
+ return;
206
+ }
207
+
208
+ console.log("verify-runtime-eol: all pinned runtimes are inside supported windows.");
209
+ }
210
+
211
+ await main();
@@ -25,6 +25,7 @@ The `--with-ci` bundle adds these defaults:
25
25
  - CodeQL, OpenSSF Scorecard, zizmor, Dependabot, and CODEOWNERS are generated.
26
26
  - A weekly scheduled `opengrep.yml` runs a second SAST engine (Aikido's LGPL-2.1 fork of Semgrep) against the repository using the curated `p/security-audit`, `p/owasp-top-ten`, `p/cwe-top-25`, `p/javascript`, `p/typescript`, `p/nodejs`, and `p/secrets` rule packs. The Opengrep binary is downloaded from a pinned GitHub release and its sigstore cosign signature is verified against the official `opengrep/opengrep` release identity before it runs — no third-party action sits in the supply chain just for this scan. Running two SAST engines (CodeQL + Opengrep) catches different bug classes than either alone; see Aikido's [Ultimate SAST Guide](https://www.aikido.dev/blog/ultimate-sast-guide-static-application-security-testing) for why a layered SAST posture is the recommended default.
27
27
  - A daily scheduled `vuln-scan.yml` runs the package manager's audit against the committed lockfile so newly-disclosed CVEs are surfaced even when no PR or push has run CI (SOC 2 CC7.1 continuous-vulnerability-management evidence).
28
+ - A daily scheduled `eol-scan.yml` checks pinned Node, Bun, and Deno versions in package metadata and GitHub Actions against endoflife.date so a runtime that has stopped receiving security patches does not stay unnoticed on `main`.
28
29
  - A weekly scheduled `dast.yml` boots the built application and runs an OWASP ZAP baseline scan against it. CodeQL + Opengrep + the `verify:*` family are the SAST half of the posture; this is the DAST half — see Aikido's [SAST vs DAST](https://www.aikido.dev/blog/sast-vs-dast-what-you-need-to-now) write-up for why both matter. Findings are summarized in the workflow log and the job fails on HIGH-risk alerts; MEDIUM / LOW / INFO are surfaced for triage but non-blocking.
29
30
  - A `secret-scan.yml` workflow runs [gitleaks](https://github.com/gitleaks/gitleaks) against the working tree on every PR / push and against the **full git history** on a daily schedule. The gitleaks binary is downloaded from a pinned official release and verified by SHA-256 before execution so the scan does not introduce a new third-party action into the supply chain. Findings block the merge; matched values are redacted from the public log. The rationale (and the remediation playbook for a confirmed leak) follows Aikido's [Secrets Detection guide](https://www.aikido.dev/blog/secret-detection-application-security): a secret in any commit, branch, or tag should be treated as compromised, and detection must consider the entire history — not just the latest snapshot — alongside GitHub-native push protection.
30
31
  - No npm publish workflow is generated: this scaffold is a REST API service, not a published package. If you later carve out a reusable library you can opt into npm trusted publishing yourself.
@@ -162,4 +163,4 @@ Before relying on these files for a company project:
162
163
  1. Replace `@your-org/security-team` in `.github/CODEOWNERS` or pass `--code-owner` when scaffolding.
163
164
  2. Protect the `main` branch and require the CI, CodeQL, Opengrep, Scorecard, and zizmor checks.
164
165
  3. Enable GitHub secret scanning and push protection.
165
- 4. Keep `ignore-scripts=true` and the `pnpm-workspace.yaml` supply-chain settings on when using pnpm.
166
+ 4. Keep `ignore-scripts=true` and the `pnpm-workspace.yaml` supply-chain settings on when using pnpm.
@@ -0,0 +1,67 @@
1
+ # End-of-life runtime scan generated by create-daloy --with-ci.
2
+ #
3
+ # Why this exists:
4
+ # A runtime version can keep running after it stops receiving security
5
+ # patches. This scheduled check looks at pinned Node/Bun/Deno versions in
6
+ # package metadata and GitHub Actions, then compares them with
7
+ # endoflife.date so old runtime pins do not sit unnoticed on main.
8
+ #
9
+ # Hardening:
10
+ # * `permissions: {}` at the top level; the job opts in to contents: read.
11
+ # * Third-party actions are SHA-pinned and managed by Dependabot.
12
+ # * `step-security/harden-runner` runs in audit mode.
13
+ # * `actions/checkout` runs with `persist-credentials: false`.
14
+ # * Install steps run with lifecycle scripts disabled.
15
+
16
+ name: EOL runtime scan
17
+
18
+ on:
19
+ schedule:
20
+ # 08:21 UTC daily, offset from the other scheduled security scans.
21
+ - cron: "21 8 * * *"
22
+ workflow_dispatch:
23
+
24
+ permissions: {}
25
+
26
+ concurrency:
27
+ group: eol-scan-${{ github.workflow }}-${{ github.ref }}
28
+ cancel-in-progress: true
29
+
30
+ jobs:
31
+ eol:
32
+ name: Daily EOL runtime scan
33
+ runs-on: ubuntu-latest
34
+ timeout-minutes: 10
35
+ permissions:
36
+ contents: read
37
+
38
+ steps:
39
+ - name: Harden runner
40
+ uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2
41
+ with:
42
+ egress-policy: audit
43
+ disable-sudo: true
44
+ disable-file-monitoring: false
45
+
46
+ - name: Checkout
47
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
48
+ with:
49
+ persist-credentials: false
50
+ show-progress: false
51
+
52
+ __SETUP_PACKAGE_MANAGER_STEP__
53
+
54
+ - name: Set up Node.js
55
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
56
+ with:
57
+ node-version: 24
58
+
59
+ __SETUP_BUN_RUNTIME_STEP__
60
+
61
+ - name: Install dependencies (no scripts)
62
+ run: __INSTALL_COMMAND__
63
+ env:
64
+ npm_config_ignore_scripts: "true"
65
+
66
+ - name: Verify pinned runtimes are not EOL
67
+ run: __RUNTIME_EOL_COMMAND__
@@ -0,0 +1,211 @@
1
+ const EOL_WARN_DAYS = 90;
2
+ const FETCH_TIMEOUT_MS = 10_000;
3
+ const RUNTIME_FEEDS = {
4
+ node: "nodejs",
5
+ bun: "bun",
6
+ deno: "deno",
7
+ };
8
+
9
+ const isDeno = typeof globalThis.Deno !== "undefined";
10
+ const args = isDeno ? globalThis.Deno.args : globalThis.process.argv.slice(2);
11
+ const cwd = isDeno ? globalThis.Deno.cwd() : globalThis.process.cwd();
12
+
13
+ function joinPath(...parts) {
14
+ return parts.filter(Boolean).join("/").replace(/\/+/g, "/");
15
+ }
16
+
17
+ async function readText(file) {
18
+ if (isDeno) return await globalThis.Deno.readTextFile(file);
19
+ const { readFile } = await import("node:fs/promises");
20
+ return await readFile(file, "utf8");
21
+ }
22
+
23
+ async function readDirNames(dir) {
24
+ if (isDeno) {
25
+ const out = [];
26
+ for await (const entry of globalThis.Deno.readDir(dir)) out.push(entry.name);
27
+ return out;
28
+ }
29
+ const { readdir } = await import("node:fs/promises");
30
+ return await readdir(dir);
31
+ }
32
+
33
+ function parseVersionKey(value) {
34
+ if (typeof value !== "string") return null;
35
+ if (/latest/i.test(value)) return null;
36
+ const match = value.match(/v?\D*(\d+)(?:\.(\d+))?/);
37
+ if (!match) return null;
38
+ return match[2] ? `${Number(match[1])}.${Number(match[2])}` : String(Number(match[1]));
39
+ }
40
+
41
+ function collectFromPackageJson(json, source) {
42
+ const out = [];
43
+ const engines = json && typeof json === "object" ? json.engines : null;
44
+ if (!engines || typeof engines !== "object") return out;
45
+ for (const runtime of Object.keys(RUNTIME_FEEDS)) {
46
+ const version = parseVersionKey(engines[runtime]);
47
+ if (version) out.push({ runtime, version, source: `${source}: engines.${runtime}` });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function collectFromDenoJson(json, source) {
53
+ const out = [];
54
+ const version = parseVersionKey(json?.runtime?.deno ?? json?.engines?.deno);
55
+ if (version) out.push({ runtime: "deno", version, source: `${source}: deno runtime` });
56
+ return out;
57
+ }
58
+
59
+ function collectFromWorkflow(yaml, source) {
60
+ const out = [];
61
+ const patterns = [
62
+ ["node", /node-version:\s*['"]?([^'"\n#]+)/g],
63
+ ["bun", /bun-version:\s*['"]?([^'"\n#]+)/g],
64
+ ["deno", /deno-version:\s*['"]?([^'"\n#]+)/g],
65
+ ];
66
+ for (const [runtime, pattern] of patterns) {
67
+ let match = null;
68
+ while ((match = pattern.exec(yaml)) !== null) {
69
+ const version = parseVersionKey(match[1]);
70
+ if (version) out.push({ runtime, version, source });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ async function collectPinnedVersions() {
77
+ const found = [];
78
+ try {
79
+ found.push(...collectFromPackageJson(JSON.parse(await readText(joinPath(cwd, "package.json"))), "package.json"));
80
+ } catch {
81
+ // No package.json is fine for Deno-native scaffolds.
82
+ }
83
+ try {
84
+ found.push(...collectFromDenoJson(JSON.parse(await readText(joinPath(cwd, "deno.json"))), "deno.json"));
85
+ } catch {
86
+ // Node/Bun scaffolds do not ship deno.json.
87
+ }
88
+ try {
89
+ const workflowsDir = joinPath(cwd, ".github", "workflows");
90
+ for (const name of await readDirNames(workflowsDir)) {
91
+ if (!/\.(ya?ml)$/.test(name)) continue;
92
+ found.push(...collectFromWorkflow(await readText(joinPath(workflowsDir, name)), `.github/workflows/${name}`));
93
+ }
94
+ } catch {
95
+ // A local run without GitHub workflows should still scan package metadata.
96
+ }
97
+ return found;
98
+ }
99
+
100
+ async function fetchFeed(runtime) {
101
+ const feed = RUNTIME_FEEDS[runtime];
102
+ const ac = new AbortController();
103
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
104
+ try {
105
+ const res = await fetch(`https://endoflife.date/api/${feed}.json`, {
106
+ headers: {
107
+ "User-Agent": "daloy-runtime-eol-scan/1",
108
+ Accept: "application/json",
109
+ },
110
+ signal: ac.signal,
111
+ });
112
+ if (!res.ok) throw new Error(`${feed} feed returned HTTP ${res.status}`);
113
+ const json = await res.json();
114
+ if (!Array.isArray(json)) throw new Error(`${feed} feed returned a non-array payload`);
115
+ return json;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ function cycleNumber(cycle) {
122
+ const n = Number.parseFloat(String(cycle));
123
+ return Number.isFinite(n) ? n : -1;
124
+ }
125
+
126
+ function findCycle(feed, version) {
127
+ const exact = feed.find((item) => String(item.cycle) === version);
128
+ if (exact) return exact;
129
+ if (!/^\d+$/.test(version)) return null;
130
+ const major = Number(version);
131
+ const candidates = feed
132
+ .filter((item) => Math.floor(cycleNumber(item.cycle)) === major)
133
+ .sort((a, b) => cycleNumber(b.cycle) - cycleNumber(a.cycle));
134
+ return candidates[0] ?? null;
135
+ }
136
+
137
+ function daysUntil(date, now) {
138
+ return Math.floor((date.getTime() - now.getTime()) / 86_400_000);
139
+ }
140
+
141
+ function groupPinned(pinned) {
142
+ const grouped = new Map();
143
+ for (const pin of pinned) {
144
+ const key = `${pin.runtime}@${pin.version}`;
145
+ const current = grouped.get(key);
146
+ if (current) current.sources.add(pin.source);
147
+ else grouped.set(key, { ...pin, sources: new Set([pin.source]) });
148
+ }
149
+ return [...grouped.values()];
150
+ }
151
+
152
+ function failProcess() {
153
+ if (globalThis.process) {
154
+ globalThis.process.exitCode = 1;
155
+ return;
156
+ }
157
+ if (isDeno) globalThis.Deno.exit(1);
158
+ }
159
+
160
+ async function main() {
161
+ const strict = args.includes("--strict");
162
+ const now = new Date();
163
+ const findings = [];
164
+ const pinned = groupPinned(await collectPinnedVersions());
165
+
166
+ if (pinned.length === 0) {
167
+ console.log("verify-runtime-eol: no pinned runtime versions found.");
168
+ return;
169
+ }
170
+
171
+ const feeds = new Map();
172
+ for (const pin of pinned) {
173
+ if (!feeds.has(pin.runtime)) feeds.set(pin.runtime, await fetchFeed(pin.runtime));
174
+ const cycle = findCycle(feeds.get(pin.runtime), pin.version);
175
+ if (!cycle || cycle.eol === false) continue;
176
+ const eol = new Date(cycle.eol);
177
+ if (Number.isNaN(eol.getTime())) continue;
178
+ const remaining = daysUntil(eol, now);
179
+ if (remaining < 0 || remaining <= EOL_WARN_DAYS) {
180
+ findings.push({
181
+ runtime: pin.runtime,
182
+ version: pin.version,
183
+ cycle: cycle.cycle,
184
+ eol: cycle.eol,
185
+ days: remaining,
186
+ severity: remaining < 0 ? "eol" : "warn",
187
+ sources: [...pin.sources].sort(),
188
+ });
189
+ }
190
+ }
191
+
192
+ for (const finding of findings) {
193
+ const label = finding.severity === "eol" ? "EOL" : "WARNING";
194
+ const timing =
195
+ finding.severity === "eol"
196
+ ? `${Math.abs(finding.days)} day(s) ago`
197
+ : `in ${finding.days} day(s)`;
198
+ console.error(
199
+ `${label}: ${finding.runtime} ${finding.version} maps to cycle ${finding.cycle}, EOL ${timing} (${finding.eol}). Sources: ${finding.sources.join(", ")}`
200
+ );
201
+ }
202
+
203
+ if (findings.some((f) => f.severity === "eol") || (strict && findings.length > 0)) {
204
+ failProcess();
205
+ return;
206
+ }
207
+
208
+ console.log("verify-runtime-eol: all pinned runtimes are inside supported windows.");
209
+ }
210
+
211
+ await main();
@@ -2,10 +2,10 @@
2
2
  # daloyjs-pre-push-contract-hook v1
3
3
  #
4
4
  # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
- # error-level issues: missing or duplicate operationIds, response examples that
6
- # don't match their schema, accidental body schemas on safe methods, or routes
7
- # with no declared responses. Catches the class of drift `tsc` can't see before
8
- # it leaves your machine, with CI as the backstop.
5
+ # error-level issues: missing or duplicate operationIds, examples that don't
6
+ # match their schema, or routes with no declared responses. Safe-method body
7
+ # schemas are reported as warnings. Catches the class of drift `tsc` can't see
8
+ # before it leaves your machine, with CI as the backstop.
9
9
  #
10
10
  # Enable once per clone: bun run hooks:install (sets core.hooksPath here)
11
11
  # Bypass once: git push --no-verify
@@ -19,7 +19,7 @@
19
19
  "hooks:install": "git config core.hooksPath .githooks"
20
20
  },
21
21
  "dependencies": {
22
- "@daloyjs/core": "^1.0.0-beta.2",
22
+ "@daloyjs/core": "^1.0.0-beta.4",
23
23
  "zod": "^4.4.3"
24
24
  },
25
25
  "devDependencies": {
@@ -2,12 +2,12 @@
2
2
  # daloyjs-pre-push-contract-hook v1
3
3
  #
4
4
  # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
- # error-level issues: missing or duplicate operationIds, response examples that
6
- # don't match their schema, accidental body schemas on safe methods, or routes
7
- # with no declared responses. Catches the class of drift `tsc` can't see before
8
- # it leaves your machine, with CI as the backstop.
5
+ # error-level issues: missing or duplicate operationIds, examples that don't
6
+ # match their schema, or routes with no declared responses. Safe-method body
7
+ # schemas are reported as warnings. Catches the class of drift `tsc` can't see
8
+ # before it leaves your machine, with CI as the backstop.
9
9
  #
10
- # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
10
+ # Enable once per clone: pnpm hooks:install (sets core.hooksPath here)
11
11
  # Bypass once: git push --no-verify
12
12
  DALOY="./node_modules/.bin/daloy"
13
13
  if [ ! -x "$DALOY" ]; then
@@ -7,13 +7,13 @@
7
7
  "dev": "wrangler dev",
8
8
  "deploy": "wrangler deploy",
9
9
  "typecheck": "tsc --noEmit",
10
- "test": "node --import tsx/esm --test tests/**/*.test.ts",
10
+ "test": "node --import tsx --test tests/**/*.test.ts",
11
11
  "contract": "daloy inspect --check src/index.ts",
12
12
  "audit": "pnpm audit --prod",
13
13
  "hooks:install": "git config core.hooksPath .githooks"
14
14
  },
15
15
  "dependencies": {
16
- "@daloyjs/core": "^1.0.0-beta.2",
16
+ "@daloyjs/core": "^1.0.0-beta.4",
17
17
  "zod": "^4.4.3"
18
18
  },
19
19
  "devDependencies": {
@@ -2,10 +2,10 @@
2
2
  # daloyjs-pre-push-contract-hook v1
3
3
  #
4
4
  # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
- # error-level issues: missing or duplicate operationIds, response examples that
6
- # don't match their schema, accidental body schemas on safe methods, or routes
7
- # with no declared responses. Catches the class of drift the type-checker can't
8
- # see before it leaves your machine, with CI as the backstop.
5
+ # error-level issues: missing or duplicate operationIds, examples that don't
6
+ # match their schema, or routes with no declared responses. Safe-method body
7
+ # schemas are reported as warnings. Catches the class of drift the type-checker
8
+ # can't see before it leaves your machine, with CI as the backstop.
9
9
  #
10
10
  # Enable once per clone: deno task hooks:install (sets core.hooksPath here)
11
11
  # Bypass once: git push --no-verify
@@ -10,11 +10,11 @@
10
10
  "hooks:install": "git config core.hooksPath .githooks"
11
11
  },
12
12
  "imports": {
13
- "@daloyjs/core": "jsr:@daloyjs/daloy@^1.0.0-beta.2",
14
- "@daloyjs/core/banner": "jsr:@daloyjs/daloy@^1.0.0-beta.2/banner",
15
- "@daloyjs/core/contract": "jsr:@daloyjs/daloy@^1.0.0-beta.2/contract",
16
- "@daloyjs/core/deno": "jsr:@daloyjs/daloy@^1.0.0-beta.2/deno",
17
- "@daloyjs/core/openapi": "jsr:@daloyjs/daloy@^1.0.0-beta.2/openapi",
13
+ "@daloyjs/core": "jsr:@daloyjs/daloy@^1.0.0-beta.4",
14
+ "@daloyjs/core/banner": "jsr:@daloyjs/daloy@^1.0.0-beta.4/banner",
15
+ "@daloyjs/core/contract": "jsr:@daloyjs/daloy@^1.0.0-beta.4/contract",
16
+ "@daloyjs/core/deno": "jsr:@daloyjs/daloy@^1.0.0-beta.4/deno",
17
+ "@daloyjs/core/openapi": "jsr:@daloyjs/daloy@^1.0.0-beta.4/openapi",
18
18
  "zod": "npm:zod@^4.4.3"
19
19
  },
20
20
  "compilerOptions": {
@@ -2,12 +2,12 @@
2
2
  # daloyjs-pre-push-contract-hook v1
3
3
  #
4
4
  # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
- # error-level issues: missing or duplicate operationIds, response examples that
6
- # don't match their schema, accidental body schemas on safe methods, or routes
7
- # with no declared responses. Catches the class of drift `tsc` can't see before
8
- # it leaves your machine, with CI as the backstop.
5
+ # error-level issues: missing or duplicate operationIds, examples that don't
6
+ # match their schema, or routes with no declared responses. Safe-method body
7
+ # schemas are reported as warnings. Catches the class of drift `tsc` can't see
8
+ # before it leaves your machine, with CI as the backstop.
9
9
  #
10
- # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
10
+ # Enable once per clone: pnpm hooks:install (sets core.hooksPath here)
11
11
  # Bypass once: git push --no-verify
12
12
  DALOY="./node_modules/.bin/daloy"
13
13
  if [ ! -x "$DALOY" ]; then
@@ -11,16 +11,16 @@
11
11
  "start": "node dist/index.js",
12
12
  "build": "tsc -p tsconfig.build.json",
13
13
  "typecheck": "tsc --noEmit",
14
- "test": "node --import tsx/esm --test tests/**/*.test.ts",
14
+ "test": "node --import tsx --test tests/**/*.test.ts",
15
15
  "contract": "daloy inspect --check src/build-app.ts",
16
- "gen:openapi": "node --import tsx/esm scripts/dump-openapi.ts",
16
+ "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
17
17
  "gen:client": "openapi-ts",
18
18
  "gen": "pnpm gen:openapi && pnpm gen:client",
19
19
  "audit": "pnpm audit --prod",
20
20
  "hooks:install": "git config core.hooksPath .githooks"
21
21
  },
22
22
  "dependencies": {
23
- "@daloyjs/core": "^1.0.0-beta.2",
23
+ "@daloyjs/core": "^1.0.0-beta.4",
24
24
  "zod": "^4.4.3"
25
25
  },
26
26
  "devDependencies": {
@@ -2,12 +2,12 @@
2
2
  # daloyjs-pre-push-contract-hook v1
3
3
  #
4
4
  # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
- # error-level issues: missing or duplicate operationIds, response examples that
6
- # don't match their schema, accidental body schemas on safe methods, or routes
7
- # with no declared responses. Catches the class of drift `tsc` can't see before
8
- # it leaves your machine, with CI as the backstop.
5
+ # error-level issues: missing or duplicate operationIds, examples that don't
6
+ # match their schema, or routes with no declared responses. Safe-method body
7
+ # schemas are reported as warnings. Catches the class of drift `tsc` can't see
8
+ # before it leaves your machine, with CI as the backstop.
9
9
  #
10
- # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
10
+ # Enable once per clone: pnpm hooks:install (sets core.hooksPath here)
11
11
  # Bypass once: git push --no-verify
12
12
  DALOY="./node_modules/.bin/daloy"
13
13
  if [ ! -x "$DALOY" ]; then
@@ -4,16 +4,16 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "node --import tsx/esm src/dev.ts",
7
+ "dev": "node --import tsx src/dev.ts",
8
8
  "deploy": "vercel deploy",
9
9
  "typecheck": "tsc --noEmit",
10
- "test": "node --import tsx/esm --test tests/**/*.test.ts",
10
+ "test": "node --import tsx --test tests/**/*.test.ts",
11
11
  "contract": "daloy inspect --check api/index.ts",
12
12
  "audit": "pnpm audit --prod",
13
13
  "hooks:install": "git config core.hooksPath .githooks"
14
14
  },
15
15
  "dependencies": {
16
- "@daloyjs/core": "^1.0.0-beta.2",
16
+ "@daloyjs/core": "^1.0.0-beta.4",
17
17
  "zod": "^4.4.3"
18
18
  },
19
19
  "devDependencies": {