@pithy-sh/core 0.1.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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
@@ -0,0 +1,156 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Semantic versions: one parse, one format, one order.
6
+ *
7
+ * Anything that has to *rank* versions needs semver §11.4 — order a release feed, decide what sits
8
+ * between installed and latest, sort a prerelease against the stable it precedes. §11.4 is short and
9
+ * every clause of it is a trap: numeric identifiers compare numerically and alphanumerics compare
10
+ * lexically, a numeric identifier ranks *below* an alphanumeric one, a longer identifier set wins when
11
+ * every shared identifier is equal, and a stable outranks every prerelease of the same core. Each of
12
+ * those is one line to get wrong, and getting one wrong shows up as a feed in the wrong order rather
13
+ * than as an error.
14
+ *
15
+ * So it lives here once. The kit's update notifier uses it and ignores the prerelease field, which is
16
+ * the narrowness it wants rather than a second implementation.
17
+ *
18
+ * **The parse is a split, not a string.** A version held as one string compares as text, and text puts
19
+ * `1.10.0` below `1.9.0`. Split into numbers, the core comparison is three integer comparisons — and
20
+ * for an adopter storing versions in SQLite, three integer columns are a row-value predicate
21
+ * (`(major, minor, patch) > (?, ?, ?)`) that the database orders correctly on its own. SQL cannot
22
+ * express prerelease precedence in that predicate, so a query built that way over-selects a stable
23
+ * against a prerelease of the same core and {@link compareSemver} settles it in app code.
24
+ */
25
+
26
+ import { z } from "zod";
27
+
28
+ /** The largest version part that survives a round trip through a number. */
29
+ const MAX_PART = Number.MAX_SAFE_INTEGER;
30
+
31
+ /**
32
+ * Semver's grammar, minus what carries no order. Build metadata (`+sha`) is matched so it can be
33
+ * discarded: the spec excludes it from precedence, so keeping it would imply an ordering it does not
34
+ * have. A leading `v` is tolerated because tags and changelogs carry one.
35
+ */
36
+ const SEMVER_PATTERN =
37
+ /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
38
+
39
+ export const Semver = z
40
+ .object({
41
+ major: z.number().int().nonnegative().describe("The major version. A breaking change increments it."),
42
+ minor: z.number().int().nonnegative().describe("The minor version. A backwards-compatible feature increments it."),
43
+ patch: z.number().int().nonnegative().describe("The patch version. A backwards-compatible fix increments it."),
44
+ prerelease: z
45
+ .string()
46
+ .nullable()
47
+ .describe(
48
+ "The prerelease tag without its leading hyphen (`rc.1`), or null for a stable release. Null sorts above any prerelease of the same core.",
49
+ ),
50
+ })
51
+ .describe(
52
+ "A semantic version split into its parts. Three numbers a database can compare as a row value, plus the prerelease tag that only `compareSemver` can order.",
53
+ );
54
+ export type Semver = z.output<typeof Semver>;
55
+
56
+ /** How far behind a version is, in each place. */
57
+ export const SemverGap = z
58
+ .object({
59
+ major: z.number().int().nonnegative().describe("How many major versions have been published since."),
60
+ minor: z.number().int().nonnegative().describe("How many minor versions have accrued within the current major."),
61
+ patch: z.number().int().nonnegative().describe("How many patch versions have accrued within the current minor."),
62
+ })
63
+ .describe(
64
+ "How far an installed version is behind the latest one, counted in version places. Three counts and nothing else, so two versions with the same core are zero apart here however their prereleases rank — that difference is precedence, and `compareSemver` is what answers it.",
65
+ );
66
+ export type SemverGap = z.output<typeof SemverGap>;
67
+
68
+ /** Parse a version string into its parts. Returns null for anything that is not a version. */
69
+ export function parseSemver(value: string): Semver | null {
70
+ const match = SEMVER_PATTERN.exec(value.trim());
71
+ if (!match) return null;
72
+ const [, major, minor, patch, prerelease] = match;
73
+ const parts = [Number(major), Number(minor), Number(patch)] as const;
74
+ // A part past a safe integer cannot round-trip, and silently truncating one would make the
75
+ // comparison lie. Refuse it as unparseable instead.
76
+ if (parts.some((part) => part > MAX_PART)) return null;
77
+ return { major: parts[0], minor: parts[1], patch: parts[2], prerelease: prerelease ?? null };
78
+ }
79
+
80
+ /** Render a parsed version back to its canonical string. */
81
+ export function formatSemver(version: Semver): string {
82
+ const core = `${version.major}.${version.minor}.${version.patch}`;
83
+ return version.prerelease === null ? core : `${core}-${version.prerelease}`;
84
+ }
85
+
86
+ /** Compare two dot-separated prerelease identifiers, per semver §11.4. */
87
+ function comparePrereleaseIdentifier(a: string, b: string): number {
88
+ const aNumeric = /^\d+$/.test(a);
89
+ const bNumeric = /^\d+$/.test(b);
90
+ // Numeric identifiers compare numerically and always rank below alphanumeric ones. As digit strings
91
+ // rather than through `Number`, because an identifier is not bounded the way `MAX_PART` bounds the
92
+ // core: above 2^53 two distinct identifiers round to the same float and compare equal, which would
93
+ // leave a `latest` decided by whatever order the rows arrived in. The grammar forbids a leading zero,
94
+ // so more digits is a larger number and equal lengths order lexicographically.
95
+ if (aNumeric && bNumeric) {
96
+ if (a.length !== b.length) return a.length - b.length;
97
+ return a < b ? -1 : a > b ? 1 : 0;
98
+ }
99
+ if (aNumeric) return -1;
100
+ if (bNumeric) return 1;
101
+ return a < b ? -1 : a > b ? 1 : 0;
102
+ }
103
+
104
+ /** Compare two prerelease tags. A larger set of identifiers wins when every shared one is equal. */
105
+ function comparePrerelease(a: string, b: string): number {
106
+ const left = a.split(".");
107
+ const right = b.split(".");
108
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
109
+ const l = left[i];
110
+ const r = right[i];
111
+ if (l === undefined) return -1;
112
+ if (r === undefined) return 1;
113
+ const ordered = comparePrereleaseIdentifier(l, r);
114
+ if (ordered !== 0) return ordered;
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * Compare two versions by semver precedence. Negative when `a` precedes `b`. Suitable as an
121
+ * `Array.prototype.sort` comparator, so it accepts the nulls {@link parseSemver} returns — an
122
+ * unparseable version sorts below every real one rather than throwing mid-sort.
123
+ */
124
+ export function compareSemver(a: Semver | null, b: Semver | null): number {
125
+ if (a === null || b === null) return a === b ? 0 : a === null ? -1 : 1;
126
+ if (a.major !== b.major) return a.major - b.major;
127
+ if (a.minor !== b.minor) return a.minor - b.minor;
128
+ if (a.patch !== b.patch) return a.patch - b.patch;
129
+ if (a.prerelease === b.prerelease) return 0;
130
+ // Stable outranks any prerelease of the same core.
131
+ if (a.prerelease === null) return 1;
132
+ if (b.prerelease === null) return -1;
133
+ return comparePrerelease(a.prerelease, b.prerelease);
134
+ }
135
+
136
+ /**
137
+ * How far `installed` is behind `latest`, per place.
138
+ *
139
+ * Each place counts only what accrued while the places above it stayed put, which is what makes the
140
+ * number mean something: `1.2.3` against `1.6.1` is four minors and one patch behind, not four minors
141
+ * and *negative two* patches. Somebody running something newer than we know about is ahead, not behind,
142
+ * so the gap floors at zero rather than going negative.
143
+ *
144
+ * **A zero gap is not a claim of being current.** Two versions with the same core sit zero places apart
145
+ * whatever their prereleases say, so `1.2.3-rc.1` against `1.2.3` measures zero even though it precedes
146
+ * it. That is the shape being honest about its limits rather than a bug to paper over: counting places
147
+ * cannot express a prerelease, {@link compareSemver} is what ranks them, and inventing a phantom patch
148
+ * here would put a number on screen that no release actually carries.
149
+ */
150
+ export function semverGap(installed: Semver, latest: Semver): SemverGap {
151
+ if (compareSemver(installed, latest) >= 0) return { major: 0, minor: 0, patch: 0 };
152
+ const major = Math.max(0, latest.major - installed.major);
153
+ const minor = major > 0 ? latest.minor : Math.max(0, latest.minor - installed.minor);
154
+ const patch = major > 0 || minor > 0 ? latest.patch : Math.max(0, latest.patch - installed.patch);
155
+ return { major, minor, patch };
156
+ }
@@ -0,0 +1,165 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * `source` with every comment blanked — the one comment stripper this repository's gates share.
6
+ *
7
+ * A tripwire that reads source has to walk past prose, because this repository's docblocks quote the
8
+ * very thing their gate forbids: a paragraph explaining why a config must never read `process.env`, a
9
+ * header naming `@cloudflare/workers-types` in a sentence. Blanking is what makes the prose invisible
10
+ * without making the file shorter.
11
+ *
12
+ * **Blanked rather than deleted: every character becomes a space and every newline survives**, so the
13
+ * line number in a failure is the line number in the file, a caller may split on `\n` and index, and a
14
+ * caller may slice the blanked text at an offset it measured on the original. Deleting is the cheaper
15
+ * thing to write and it silently joins the code either side of a block comment onto one line, which
16
+ * costs the position of everything below it.
17
+ *
18
+ * **It walks the source rather than matching a pattern, because the pattern has two measured false
19
+ * negatives.** One `replace` over a comment pattern has no notion of a string, and a string is where a
20
+ * comment can be forged:
21
+ *
22
+ * - `const base = "https://api.cloudflare.com";` opens a line comment at the `//` inside the URL, and
23
+ * everything after it on that line is blanked — a `process.env.CLOUDFLARE_API_TOKEN` read beside it,
24
+ * a `D1Database` beside it, whatever the caller was looking for.
25
+ * - `include: ["**\/*.workers.test.ts"]` opens a block comment at the `/*` inside the glob, which runs
26
+ * to the next `*\/` anywhere later in the file — the next docblock will do — and blanks every line
27
+ * between.
28
+ *
29
+ * Both were measured against planted source, and both are silent: the scan reports nothing and passes.
30
+ * A prohibition that a URL can switch off is worse than no prohibition, because the file it exempts
31
+ * looks scanned.
32
+ *
33
+ * **Strings are stepped over, not blanked.** The job is comments, and preserving a string keeps a real
34
+ * read inside a template — `` `${process.env.CLOUDFLARE_API_TOKEN}` `` — visible to the caller. A string
35
+ * that merely *contains* the text is then a false positive, which is the direction a gate should fail in.
36
+ *
37
+ * **A regex literal is stepped over too**, because `/^["']|["']$/` is a quote a string state would open
38
+ * on, and a runaway string swallows every line to the next quote. Told apart from division by the last
39
+ * significant character: a `/` where a value may begin starts a literal, and one after an operand is a
40
+ * divide. An unterminated string or regex stops at the newline, so a bad guess costs one line rather
41
+ * than the rest of the file.
42
+ *
43
+ * ## Why it lives in `@pithy-sh/core`, of all places
44
+ *
45
+ * It was written in `@pithy-sh/cli`'s `ci/sourceFiles.ts`, beside the walk its first three callers used
46
+ * (#437). `packages/core/src/worker-safety.test.ts` is the caller that could not follow it there:
47
+ * it guards which bare specifiers core's shipped source imports, it had the naive pattern, and
48
+ * `@pithy-sh/core` must never depend on `@pithy-sh/cli` — core is bundled into the adopter's Worker, so
49
+ * its dependency set is a shipped surface and the arrow runs cli → core only (#439).
50
+ *
51
+ * The alternatives were a documented copy and a drift gate over two copies. That pattern is real here —
52
+ * `envIsolation.workers.test.ts` restates `CLOUDFLARE_ENV_KEYS` under exactly this constraint — and it
53
+ * holds for two string constants, which a gate can compare for equality. Over a hundred-line walk it
54
+ * degrades into comparing function text, which changes on a rename and says nothing about behavior.
55
+ *
56
+ * So the third home, and core is the only package every caller already imports: the CLI, `email`,
57
+ * `payments` and `ui-react` all declare it, and `worker-safety.test.ts` reaches it with a relative path.
58
+ * The cost is honest and small. This is source-text tooling inside the package that ships into a Worker
59
+ * — but core has no barrel, so a module nothing imports is never reached by an adopter's bundler at all,
60
+ * and the price is bytes in a tarball rather than bytes in a Worker. And it earns its keep as a Worker
61
+ * neighbor on its own terms: **it imports nothing and touches no Node builtin**, which is the property
62
+ * `worker-safety.test.ts` exists to enforce, so the gate now polices the stripper it reads with.
63
+ */
64
+ export function blankComments(source: string): string {
65
+ let out = "";
66
+ let previous = "";
67
+ let index = 0;
68
+ while (index < source.length) {
69
+ const char = source[index] as string;
70
+ const pair = source.slice(index, index + 2);
71
+ if (pair === "//" || pair === "/*") {
72
+ const stop = endOfComment(source, index, pair);
73
+ out += source.slice(index, stop).replace(/[^\n]/g, " ");
74
+ index = stop;
75
+ continue;
76
+ }
77
+ if (char === '"' || char === "'" || char === "`") {
78
+ const stop = endOfQuoted(source, index, char, char !== "`");
79
+ out += source.slice(index, stop);
80
+ index = stop;
81
+ previous = char;
82
+ continue;
83
+ }
84
+ if (char === "/" && VALUE_POSITION.has(previous)) {
85
+ const stop = endOfRegex(source, index);
86
+ out += source.slice(index, stop);
87
+ index = stop;
88
+ previous = "/";
89
+ continue;
90
+ }
91
+ out += char;
92
+ if (!/\s/.test(char)) previous = char;
93
+ index += 1;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * After one of these, a `/` opens a regex literal; after anything else it divides. The empty string is
100
+ * the start of the file. A wrong guess is bounded — see {@link endOfRegex}.
101
+ */
102
+ const VALUE_POSITION = new Set([
103
+ "",
104
+ "(",
105
+ ",",
106
+ "=",
107
+ ":",
108
+ "[",
109
+ "!",
110
+ "&",
111
+ "|",
112
+ "?",
113
+ "{",
114
+ "}",
115
+ ";",
116
+ "+",
117
+ "-",
118
+ "*",
119
+ "%",
120
+ "^",
121
+ "~",
122
+ "<",
123
+ ">",
124
+ ]);
125
+
126
+ /** Where a comment ends. An unterminated block comment runs to the end of the file, as it does for tsc. */
127
+ function endOfComment(source: string, start: number, pair: string): number {
128
+ if (pair === "//") {
129
+ const newline = source.indexOf("\n", start);
130
+ return newline === -1 ? source.length : newline;
131
+ }
132
+ const close = source.indexOf("*/", start + 2);
133
+ return close === -1 ? source.length : close + 2;
134
+ }
135
+
136
+ /** Where a quoted run ends: past its closing quote, or at the newline a single-line quote cannot cross. */
137
+ function endOfQuoted(source: string, start: number, quote: string, singleLine: boolean): number {
138
+ for (let index = start + 1; index < source.length; index += 1) {
139
+ const char = source[index];
140
+ if (char === "\\") {
141
+ index += 1;
142
+ continue;
143
+ }
144
+ if (char === quote) return index + 1;
145
+ if (char === "\n" && singleLine) return index;
146
+ }
147
+ return source.length;
148
+ }
149
+
150
+ /** Where a regex literal ends. A `/` inside a character class does not close it; a newline does. */
151
+ function endOfRegex(source: string, start: number): number {
152
+ let inClass = false;
153
+ for (let index = start + 1; index < source.length; index += 1) {
154
+ const char = source[index];
155
+ if (char === "\\") {
156
+ index += 1;
157
+ continue;
158
+ }
159
+ if (char === "\n") return index;
160
+ if (char === "[") inClass = true;
161
+ else if (char === "]") inClass = false;
162
+ else if (char === "/" && !inClass) return index + 1;
163
+ }
164
+ return source.length;
165
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/core knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/core";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **Where every Pithy Worker answers liveness — written once, read by everyone who needs it.**
6
+ *
7
+ * `createBackend` mounts this route, the CLI's route allowlist keeps it worker-first, `pithy deploy`
8
+ * probes it after a release, and the bare home screen a no-auth scaffold ships fetches it. Four sites,
9
+ * one path, and until #400 each of them wrote it out.
10
+ *
11
+ * The failure that earned this module is the quietest one in the front end. `home.bare.tsx` is the
12
+ * **only** screen a project with no auth composed gets, and its entire content is that one request. A
13
+ * rename here without a matching edit there renders *"The worker says: unknown."* — a 200, no error, no
14
+ * failed build, nothing in a log. The adopter's first screen showing a word that is not a status, for a
15
+ * reason nowhere on the page.
16
+ *
17
+ * A gate comparing the copies would have watched that. This removes it instead: the template imports
18
+ * the constant, so a rename moves the screen with it, and there is no second string left to disagree
19
+ * with. `docs/CONVENTIONS.md` § *Seeded files* is the general form — **removing the class beats
20
+ * watching it** — and #377, #366, #393 and #394 are the same move made earlier.
21
+ *
22
+ * ## Why a module of its own
23
+ *
24
+ * Because a client bundle imports it. Everything else in `@pithy-sh/core` that a route path could sit
25
+ * beside pulls Hono, the Workers types, or both, and the scaffolded SPA is a browser program that must
26
+ * import neither. This file declares one string and imports nothing, so the seeded screen can read it
27
+ * without dragging a server runtime into the client's program.
28
+ */
29
+
30
+ /**
31
+ * `GET /health` — public, and deliberately so.
32
+ *
33
+ * It reads nothing about the caller, so there is no session to send it. That is what makes it the one
34
+ * request the bare home screen can make with no auth capability in the project, and why
35
+ * `sameOrigin.test.ts` exempts that screen's `fetch` rather than routing it through a credentialed
36
+ * primitive.
37
+ *
38
+ * The leading slash is part of the value. Hono mounts it, `firstSegment` splits it, and `verifyDeploy`
39
+ * appends it to a declared origin — all three want the same string, and a bare `health` would make two
40
+ * of them concatenate wrongly in silence.
41
+ */
42
+ export const HEALTH_PATH = "/health";
@@ -0,0 +1,243 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What a running Worker knows about itself — the three vars every Pithy Worker is scaffolded with, and
6
+ * the one reader for them.
7
+ *
8
+ * The Cloudflare runtime hands a script **nothing** about its own identity. `TraceItem.scriptName` is
9
+ * what a *tail consumer* sees about someone else, `version_metadata` carries an id, a tag and a
10
+ * creation time but no name, and `navigator.userAgent` is the constant `"Cloudflare-Workers"` in every
11
+ * Worker alive. So
12
+ * identity is stamped, not derived: `pithy init` and `pithy worker add` write `PROJECT`, `ENVIRONMENT`,
13
+ * and `WORKER` into every environment stanza of the Worker's own `wrangler.jsonc`.
14
+ *
15
+ * Stamping also beats deriving even where a value looks recoverable. A host's script name leads with
16
+ * its project, but `<project>-<env>-<capability>` cannot be parsed back into its parts once a project
17
+ * name contains a hyphen — and wrangler appends `-staging`/`-prod` at deploy, so the script name is not
18
+ * even stable across environments while `apps/<name>/` is.
19
+ *
20
+ * The names live here because they had already been copied: `ENVIRONMENT_VAR` was declared privately in
21
+ * both `workflow/host.ts` and `controlPlane/capability.ts`, character for character. A third copy for
22
+ * `WORKER` would have made the drift structural — the arrangement where a rule stops being one rule.
23
+ */
24
+
25
+ /** The var naming the owning project: the root `pithy.config.ts` `name`, kebabed. */
26
+ export const PROJECT_VAR = "PROJECT";
27
+
28
+ /** The var naming the environment this deployment serves — `dev`, `staging`, or `prod`. */
29
+ export const ENVIRONMENT_VAR = "ENVIRONMENT";
30
+
31
+ /**
32
+ * The var carrying a Worker's **own** dev origin, from `pithy dev` to whatever launches that Worker.
33
+ *
34
+ * Not stamped into `wrangler.jsonc` like the three above, and that is the whole point of it. A dev port
35
+ * is *allocated* — every checkout reserves its own block — so this is the one identity value a file in
36
+ * the repository cannot state: written down, it is right in the first checkout on a machine and wrong
37
+ * in every other one (#462).
38
+ *
39
+ * It lives here for the reason the others do. `pithy dev` writes it and `@pithy-sh/vite` reads it, and
40
+ * those two packages cannot import each other — the CLI depends on vite's package, and vite's may not
41
+ * depend on the CLI. A privately declared copy on each side is the arrangement where one rule quietly
42
+ * becomes two.
43
+ *
44
+ * **A carrier, not a binding.** It reaches a *process*, not a Worker: `process.env` inside workerd is
45
+ * that script's own vars and nothing else. `@pithy-sh/vite`'s `devWorkerConfig()` is what turns it into
46
+ * the Worker's `BASE_URL`, which is the binding anything at runtime actually reads.
47
+ */
48
+ export const WORKER_ORIGIN_VAR = "PITHY_WORKER_ORIGIN";
49
+
50
+ /**
51
+ * The var naming the Worker itself: its `apps/<name>` **directory** name, not its deploy name.
52
+ *
53
+ * The directory name is the identity everything else already keys on — the `apps/*` registry, the
54
+ * `--worker` flag, the `.dev.config.json` port block. The deploy name (`<project>-<name>`) would
55
+ * re-encode the project as noise beside a `project` column that already carries it, and the deployed
56
+ * script's real name varies by environment where the directory does not.
57
+ */
58
+ export const WORKER_VAR = "WORKER";
59
+
60
+ /**
61
+ * The version-metadata binding Cloudflare injects, naming the exact build that is running.
62
+ *
63
+ * Not a var — a binding, declared as `"version_metadata": { "binding": "CF_VERSION_METADATA" }` and
64
+ * populated by the platform rather than by the scaffold. It belongs beside the three vars anyway,
65
+ * because it answers the same question they do and is read on the same path: this is what a Worker knows
66
+ * about itself.
67
+ *
68
+ * **The name is the join.** It is what `pithy init` and `pithy worker add` write and what this module
69
+ * reads; binding a differently-named one creates a binding nothing consumes, which is the failure this
70
+ * whole thread is repairing — the reader shipped, the template never declared it, and the field was
71
+ * silently absent in every scaffolded project.
72
+ */
73
+ export const VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
74
+
75
+ /** Where a Worker is running, as it can state about itself. Every field is `null` when unstamped. */
76
+ export interface WorkerIdentity {
77
+ /** The owning project, or `null` when the Worker carries no `PROJECT` var. */
78
+ project: string | null;
79
+ /** The environment this deployment serves, or `null` when unstamped. */
80
+ environment: string | null;
81
+ /** The Worker's `apps/<name>` directory name, or `null` when unstamped. */
82
+ worker: string | null;
83
+ /**
84
+ * The deployed build's Cloudflare version id, or `null` off-platform and wherever the
85
+ * `CF_VERSION_METADATA` binding is absent.
86
+ *
87
+ * Opaque and per-deploy: it identifies *exactly which build* is running, which is the right answer for
88
+ * forensics, for reproducing a report, and for pinning what an audited action ran against. It carries
89
+ * no version semantics, so it says nothing about which features a Worker has — that is what the
90
+ * composed package versions in the control-plane manifest are for.
91
+ */
92
+ version: string | null;
93
+ }
94
+
95
+ /**
96
+ * Everything the `CF_VERSION_METADATA` binding carries about the build that is running.
97
+ *
98
+ * **Measured, not read off the docs.** A `wrangler dev` (4.120.1 / miniflare 4) against a Worker
99
+ * declaring `version_metadata` hands the binding as `{ id, tag, timestamp }` — three own keys, every
100
+ * value a string, `timestamp` an ISO-8601 instant. Every field is independently `null` here, because a
101
+ * partial binding is what a platform change looks like from inside a Worker and losing the id over a
102
+ * renamed sibling would be the worse trade.
103
+ *
104
+ * **It names a *version*, never a deployment.** That distinction is the whole ceiling on what a client
105
+ * can conclude from it — see {@link workerVersionMetadata}.
106
+ */
107
+ export interface WorkerVersionMetadata {
108
+ /** The version id: opaque, per version, and the answer to "which build". `null` where the binding is. */
109
+ id: string | null;
110
+ /**
111
+ * The version tag, set only by `wrangler versions upload --tag`, or `null`.
112
+ *
113
+ * Empty in every local dev and in every deploy nobody tagged, so `""` reads as absent rather than as a
114
+ * tag to compare against. Read here so this reader mirrors the binding; deliberately **not** stamped
115
+ * on the control-plane header — it is adopter-authored free text, and the two questions a management
116
+ * client asks are answered without it.
117
+ */
118
+ tag: string | null;
119
+ /**
120
+ * When this **version was uploaded**, ISO-8601, verbatim as the platform issued it — or `null`.
121
+ *
122
+ * **Cloudflare has two objects here, and this binding describes one of them.** A *version* is an
123
+ * immutable upload of code and config: an id, a created timestamp and a tag, fixed at upload and never
124
+ * again. A *deployment* points at one or more versions with traffic percentages, and is its own object
125
+ * with its own id and its own time. `CF_VERSION_METADATA` reports the **version**, faithfully — and
126
+ * the runtime hands a script **no binding for the deployment at all**.
127
+ *
128
+ * Everything else follows from that. A rollback creates a *new deployment* aimed at an *existing
129
+ * version*, so nothing in this binding moves: not staleness, not ambiguity, a different object. The
130
+ * same answer covers the next question and the one after — a traffic split, a gradual rollout, which
131
+ * deployment is serving: a Worker cannot see any of it. An ordinary redeploy is not affected, because
132
+ * every `wrangler deploy` uploads and so mints a new version, id and all, even for a one-character
133
+ * change.
134
+ *
135
+ * Measured, 2026-08-10, which is what turned that from a reading of the docs into a fact: a probe
136
+ * Worker on a real account, version `A` uploaded, `B` four seconds later, then a real `wrangler
137
+ * rollback` to `A`. Two minutes on, the Worker reported `A`'s id **and `A`'s original timestamp**,
138
+ * unmoved — while wrangler's own rollback output named the new deployment's version list.
139
+ *
140
+ * The value is relayed for comparison and never interpreted. A consumer's rule is
141
+ * `workerBuildChanged` in `controlPlane/wire.ts`: anything that moved is a change, and a direction is
142
+ * not a meaning — propagation lag alone can make an older version answer for a while after a deploy.
143
+ *
144
+ * `null` for anything that is not a parseable instant. That is the one place this reader refuses
145
+ * rather than relays, and the consumer is the reason: a client does `Date.parse` on it and compares,
146
+ * an unparseable string is `NaN`, and every `NaN` comparison is false — so garbage would read as
147
+ * "not newer", which is silently indistinguishable from "unchanged".
148
+ */
149
+ createdAt: string | null;
150
+ }
151
+
152
+ /** One binding field, or `null` for anything that is not a non-empty string. */
153
+ function carried(meta: Record<string, unknown>, field: string): string | null {
154
+ const value = meta[field];
155
+ return typeof value === "string" && value.trim() !== "" ? value : null;
156
+ }
157
+
158
+ /** One var, trimmed, or `null` for anything that is not a non-empty string. */
159
+ function stamped(env: Record<string, unknown>, name: string): string | null {
160
+ const value = env[name];
161
+ if (typeof value !== "string") return null;
162
+ const trimmed = value.trim();
163
+ return trimmed === "" ? null : trimmed;
164
+ }
165
+
166
+ /**
167
+ * Read a Worker's stamped identity off its `env`.
168
+ *
169
+ * **Never throws, and never guesses.** Both matter on the one call path this exists for: the audit
170
+ * recorder stamps origin onto every event, and the recorder is contractually non-fatal — a throw here
171
+ * would turn "this row has no origin" into "there is no row", which is the strictly worse failure. An
172
+ * absent var is an ordinary, permanent state: a Worker scaffolded before these vars existed carries
173
+ * none of them and nothing back-fills it, so `null` is reported rather than inferred. An invented
174
+ * origin would be indistinguishable from a real one for as long as the trail is kept.
175
+ */
176
+ export function workerIdentity(env: unknown): WorkerIdentity {
177
+ if (typeof env !== "object" || env === null || Array.isArray(env)) {
178
+ return { project: null, environment: null, worker: null, version: null };
179
+ }
180
+ const vars = env as Record<string, unknown>;
181
+ return {
182
+ project: stamped(vars, PROJECT_VAR),
183
+ environment: stamped(vars, ENVIRONMENT_VAR),
184
+ worker: stamped(vars, WORKER_VAR),
185
+ version: workerVersion(env),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Read the whole `CF_VERSION_METADATA` binding: which build is running, and when that build was made.
191
+ *
192
+ * **The one reader of the binding**, which is why it returns all of it. The seam used to take the id and
193
+ * drop the rest, and the module docstring above described a two-field binding — so the field this
194
+ * function exists for was discarded by a reader that did not know it was there.
195
+ *
196
+ * ## What a client can conclude
197
+ *
198
+ * Holding the last `(id, createdAt)` it saw, and comparing field by field only where both sides carried
199
+ * a value: anything that differs is a change. Absence on either side is silence, never change.
200
+ * `workerBuildChanged` in `controlPlane/wire.ts` is that rule, written once so no client hand-rolls it.
201
+ *
202
+ * ## What it cannot see, and why that is a boundary rather than a gap
203
+ *
204
+ * **This binding describes a version. Nothing inside a Worker can observe a deployment** — see
205
+ * {@link WorkerVersionMetadata.createdAt} for the two objects and the measurement. So a rollback, a
206
+ * traffic split, a gradual rollout: all invisible here, and no field the seam could add would change
207
+ * that, because the runtime hands a script no binding for the object that carries them. A client that
208
+ * needs a deployment reads Cloudflare's deployments API, in the customer's own account.
209
+ *
210
+ * The temptation is to synthesise one. Isolate boot time is the only in-Worker candidate and it is
211
+ * worse than nothing: it changes on every cold start, so it would report a redeploy dozens of times a
212
+ * day, and a false change costs more than a missed one for a client that invalidates a rendered pane on
213
+ * it. Nothing is stamped.
214
+ *
215
+ * Same never-throws, never-guesses contract as {@link workerIdentity}.
216
+ */
217
+ export function workerVersionMetadata(env: unknown): WorkerVersionMetadata {
218
+ const absent: WorkerVersionMetadata = { id: null, tag: null, createdAt: null };
219
+ if (typeof env !== "object" || env === null || Array.isArray(env)) return absent;
220
+ const meta = (env as Record<string, unknown>)[VERSION_METADATA_BINDING];
221
+ if (typeof meta !== "object" || meta === null) return absent;
222
+ const fields = meta as Record<string, unknown>;
223
+ const createdAt = carried(fields, "timestamp");
224
+ return {
225
+ id: carried(fields, "id"),
226
+ tag: carried(fields, "tag"),
227
+ // Relayed verbatim, never re-encoded — but only once it is a time at all.
228
+ createdAt: createdAt !== null && Number.isFinite(Date.parse(createdAt)) ? createdAt : null,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * The deployed build's version id, off the `CF_VERSION_METADATA` binding, or `null`.
234
+ *
235
+ * Kept on its own because four callers want the id and nothing else: the request logger's `version`
236
+ * correlation field, `/health`, the control-plane manifest, and the seam's response header. Its shape
237
+ * is unchanged and deliberately so — every one of those compares or records a single opaque string, and
238
+ * widening it would have rewritten an audit column and a wire field to carry something none of them
239
+ * asked for. {@link workerVersionMetadata} is where the rest of the binding lives.
240
+ */
241
+ export function workerVersion(env: unknown): string | null {
242
+ return workerVersionMetadata(env).id;
243
+ }