@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,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { MAX_ENVIRONMENT_NAME } from "./environment";
5
+
6
+ /**
7
+ * What Cloudflare will actually accept as a name, per namespace — and, where it publishes nothing,
8
+ * what Pithy chooses instead.
9
+ *
10
+ * Pithy used to hold one number, 63, against every namespace it writes into, justified as "R2's
11
+ * cap". That is true of R2 and of nothing else. It over-truncated a KV title by a factor of eight
12
+ * and truncated D1 databases, Secrets Store entries, and API token labels against limits that do
13
+ * not exist — `secrets-encryption-keys` came out of it as `secrets-encryp-91c2e9`, hashed for
14
+ * nothing. A truncated name is not merely ugly: it is the name teardown must recompute exactly, and
15
+ * the name a human has to recognize in a dashboard listing that cannot be filtered.
16
+ *
17
+ * Every row below carries its source. `cloudflare` means the number is documented (limits page,
18
+ * OpenAPI schema, or wrangler's own constant, verified 2026-07-31); `pithy` means Cloudflare
19
+ * publishes no cap and this is our ceiling, chosen and stated as ours.
20
+ */
21
+
22
+ /** Where a limit comes from — Cloudflare's documentation, or Pithy's own judgment. */
23
+ export type LimitSource = "cloudflare" | "pithy";
24
+
25
+ /**
26
+ * What to do with a name that will not fit.
27
+ *
28
+ * **`refuse`** where the name is a durable address: a Workflow (renaming orphans every running
29
+ * instance), a Worker script (renaming orphans the deployment and every `service` binding pointing
30
+ * at it), a Vectorize index (renaming orphans the vectors — they cannot be re-embedded for free).
31
+ * A build error naming the limit is strictly better than a silent rename.
32
+ *
33
+ * **`truncate`** where Pithy recomputes the name from the same inputs on every command — a D1
34
+ * database, a KV namespace, a bucket, a secret entry, a token. Truncation is deterministic and
35
+ * hash-disambiguated, so provision and teardown still agree, and a failed CI run is avoided.
36
+ */
37
+ export type OverflowPolicy = "refuse" | "truncate";
38
+
39
+ /** One namespace's rule: how long a name may be, and what happens when it would be longer. */
40
+ export interface NamespaceLimit {
41
+ /** How an error message names this namespace, e.g. `an R2 bucket name`. */
42
+ readonly label: string;
43
+ /** The longest name the namespace accepts. Pithy composes ASCII only, so characters are bytes. */
44
+ readonly maxLength: number;
45
+ /** The shortest name the namespace accepts. Only R2 states one; everywhere else it is 1. */
46
+ readonly minLength: number;
47
+ /** What happens to a name that would exceed {@link maxLength}. */
48
+ readonly policy: OverflowPolicy;
49
+ /** Whether {@link maxLength} is Cloudflare's number or Pithy's. */
50
+ readonly source: LimitSource;
51
+ }
52
+
53
+ /**
54
+ * Pithy's ceiling for a namespace Cloudflare does not cap — D1 database names, Secrets Store
55
+ * entries, API token labels.
56
+ *
57
+ * **This is our number, not Cloudflare's.** No published cap is not the same as no cap: a name is
58
+ * read by humans in unfilterable listings, echoed in CLI output, and pasted into wrangler configs,
59
+ * so it needs an end. 128 is twice the longest limit Cloudflare does publish (64) and roughly twice
60
+ * the longest name Pithy can compose today, which leaves room for a capability with a long tail
61
+ * without ever reaching for the truncator.
62
+ */
63
+ export const MAX_PITHY_NAME = 128;
64
+
65
+ /**
66
+ * The rule per namespace. Read it, do not re-derive it — a limit enforced in two places eventually
67
+ * disagrees with itself, and the number an error quotes must be the number the check used.
68
+ */
69
+ export const NAMESPACE_LIMITS = {
70
+ /**
71
+ * A Workflow name: 64 characters, `^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`. Documented on the Workflows
72
+ * limits page, `maxLength: 64` in the OpenAPI schema, and `MAX_WORKFLOW_NAME_LENGTH` in wrangler.
73
+ */
74
+ workflow: { label: "a Workflow name", maxLength: 64, minLength: 1, policy: "refuse", source: "cloudflare" },
75
+ /**
76
+ * A Worker script name: 255 in general, **63 once workers.dev is on**, alphanumeric and dashes
77
+ * with no leading or trailing dash. Pithy holds the 63: it is the only number that stays true
78
+ * after an adopter enables a workers.dev subdomain, and a script cannot be renamed afterwards.
79
+ * Deliberately one *less* than the Workflow cap — they are different resources with different
80
+ * numbers, and collapsing them would loosen this one.
81
+ */
82
+ worker: { label: "a Worker script name", maxLength: 63, minLength: 1, policy: "refuse", source: "cloudflare" },
83
+ /**
84
+ * An R2 bucket: 3–63 characters, lowercase letters, digits and hyphens, and it must **start and
85
+ * end alphanumeric**. The one namespace where 63 was always the right answer.
86
+ */
87
+ r2: { label: "an R2 bucket name", maxLength: 63, minLength: 3, policy: "truncate", source: "cloudflare" },
88
+ /**
89
+ * A Vectorize index: 64 bytes, `^([a-z]+[a-z0-9_-]*[a-z0-9]+)$` — it must start with a letter,
90
+ * which the project rule already guarantees. Refused rather than truncated because a renamed
91
+ * index is an empty index until everything is re-embedded.
92
+ */
93
+ vectorizeIndex: {
94
+ label: "a Vectorize index name",
95
+ maxLength: 64,
96
+ minLength: 2,
97
+ policy: "refuse",
98
+ source: "cloudflare",
99
+ },
100
+ /**
101
+ * A KV namespace title: 512 characters, no pattern at all — spaces and mixed case are accepted
102
+ * (`workers-kv_namespace_title.maxLength` in the OpenAPI schema). Pithy still composes a kebab
103
+ * title, for the same reason it composes every other name the same way; what changed is that it
104
+ * no longer hashes one at 63.
105
+ */
106
+ kv: { label: "a KV namespace title", maxLength: 512, minLength: 1, policy: "truncate", source: "cloudflare" },
107
+ /**
108
+ * A D1 database name: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` in the OpenAPI schema, and **no documented
109
+ * length cap** — the limits page has no row for it. So the ceiling here is {@link MAX_PITHY_NAME}.
110
+ */
111
+ d1: { label: "a D1 database name", maxLength: MAX_PITHY_NAME, minLength: 1, policy: "truncate", source: "pithy" },
112
+ /**
113
+ * A Secrets Store entry name: "cannot contain spaces", wrangler enforces `/^[A-z0-9-_]+$/`, and
114
+ * **no documented length cap**. Ours, therefore — {@link MAX_PITHY_NAME}.
115
+ */
116
+ secretEntry: {
117
+ label: "a Secrets Store entry name",
118
+ maxLength: MAX_PITHY_NAME,
119
+ minLength: 1,
120
+ policy: "truncate",
121
+ source: "pithy",
122
+ },
123
+ /**
124
+ * A Cloudflare API token name: a free-text label with **no documented cap**. Ours —
125
+ * {@link MAX_PITHY_NAME} — and it matters least of all, since nothing addresses a token by name.
126
+ */
127
+ apiToken: {
128
+ label: "a Cloudflare API token name",
129
+ maxLength: MAX_PITHY_NAME,
130
+ minLength: 1,
131
+ policy: "truncate",
132
+ source: "pithy",
133
+ },
134
+ } as const satisfies Record<string, NamespaceLimit>;
135
+
136
+ /** Every namespace the naming facade can compose into. */
137
+ export type Namespace = keyof typeof NAMESPACE_LIMITS;
138
+
139
+ /**
140
+ * The longest `<capability>-<job>` tail any capability registry declares today. Read off the
141
+ * registries, not guessed: `media-audio-transcribe` and `media-video-transcribe` are both 22, ahead
142
+ * of `payments-reconcile` (18), `vector-reprocess` (16), `support-classify` (16), `email-schedule`
143
+ * (14), `secrets-rotate` (14), `storage-sweep` (13), `testers-daily` (13).
144
+ *
145
+ * {@link WORKFLOW_DERIVED_PROJECT_NAME} is derived against it, so the two move together — and
146
+ * `workflowScriptName` refuses a longer tail rather than letting a new capability silently
147
+ * invalidate the project-name cap every existing project was accepted under.
148
+ */
149
+ export const MAX_CAPABILITY_JOB = "media-audio-transcribe".length;
150
+
151
+ /**
152
+ * Digits a feature's issue number may carry. Six — `f999999`, just under a million open issues in one
153
+ * repository, which nothing this toolkit will serve is going to exhaust.
154
+ *
155
+ * The count is not free: every digit reserved here costs one character of **every** adopter's project
156
+ * name, because {@link FEATURE_DERIVED_PROJECT_NAME} is computed against it and a project name cannot
157
+ * be changed once anything is provisioned. Seven digits would buy ten million issues nobody asked for
158
+ * and charge every project a character for them.
159
+ */
160
+ export const MAX_ISSUE_DIGITS = 6;
161
+
162
+ /**
163
+ * The slug length a feature resource name keeps verbatim at the worst legal project name.
164
+ *
165
+ * Twelve, because that is where a feature slug stops being readable in a listing: below it,
166
+ * `fitSegment` has to fall back to a short head plus a six-hex disambiguator, and
167
+ * `acme-f95-medi-8f21c4-db-d1` tells a human nothing about which branch owns the database.
168
+ */
169
+ export const MIN_LEGIBLE_SLUG = 12;
170
+
171
+ /**
172
+ * The binding length a feature resource name keeps verbatim at the worst legal project name.
173
+ *
174
+ * Also twelve — `MEDIA_BUCKET`, `SESSIONS`, `CACHE`, `ASSETS`, `DB`, `ROOMS` all fit. It is
175
+ * deliberately **not** the longest binding Pithy declares (`EMAIL_SUPPRESSIONS`, 18): reserving
176
+ * that would cost every adopter six characters of project name to protect a purely cosmetic
177
+ * property, since a longer binding truncates deterministically and stays unique through its hash,
178
+ * and teardown recomputes the name rather than matching a prefix.
179
+ */
180
+ export const MIN_VERBATIM_BINDING = 12;
181
+
182
+ /** The longest feature resource kind — `d1`, `kv`, `r2` are all two characters. `feature.test.ts` pins it. */
183
+ export const MAX_FEATURE_KIND = 2;
184
+
185
+ /**
186
+ * What the longest Workflow name leaves a project.
187
+ *
188
+ * ```
189
+ * 64 a Workflow name (NAMESPACE_LIMITS.workflow)
190
+ * - 1 the hyphen before the environment
191
+ * - 7 the longest environment, `staging` (MAX_ENVIRONMENT_NAME)
192
+ * - 1 the hyphen before the tail
193
+ * - 22 the longest <capability>-<job>, `media-audio-transcribe`
194
+ * ----
195
+ * 33
196
+ * ```
197
+ */
198
+ export const WORKFLOW_DERIVED_PROJECT_NAME =
199
+ NAMESPACE_LIMITS.workflow.maxLength - 1 - MAX_ENVIRONMENT_NAME - 1 - MAX_CAPABILITY_JOB;
200
+
201
+ /**
202
+ * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>-<binding>-<kind>`,
203
+ * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
204
+ * feature Worker script gets).
205
+ *
206
+ * ```
207
+ * 63 an R2 bucket name (NAMESPACE_LIMITS.r2)
208
+ * - 2 `-f`
209
+ * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
210
+ * - 13 `-` + a legible slug (MIN_LEGIBLE_SLUG)
211
+ * - 13 `-` + a binding kept whole (MIN_VERBATIM_BINDING)
212
+ * - 3 `-` + the kind, `d1` | `kv` | `r2`
213
+ * ----
214
+ * 26
215
+ * ```
216
+ */
217
+ export const FEATURE_DERIVED_PROJECT_NAME =
218
+ NAMESPACE_LIMITS.r2.maxLength -
219
+ 2 -
220
+ MAX_ISSUE_DIGITS -
221
+ (1 + MIN_LEGIBLE_SLUG) -
222
+ (1 + MIN_VERBATIM_BINDING) -
223
+ (1 + MAX_FEATURE_KIND);
@@ -0,0 +1,143 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE } from "./environment";
5
+ import {
6
+ type FeatureIdentity,
7
+ type FeatureResourceKind,
8
+ featureResourceName,
9
+ featureSecretEntryName,
10
+ featureWorkerName,
11
+ } from "./feature";
12
+ import { resourceNames } from "./resourceNames";
13
+
14
+ /**
15
+ * **What a provisioning run names its resources, and which `env.<name>` stanza it writes them into —
16
+ * as one object, never two arguments.**
17
+ *
18
+ * Provisioning was specified for ephemeral feature environments and parameterized by `--env` before it
19
+ * was generalized to named ones. That left the namer and the target stanza as independent inputs, and
20
+ * A feature's namer with a declared environment beside it was the reachable consequence: it composed
21
+ * `<project>-f<issue>-<slug>-db` and wrote it in as `staging`'s `DB`. Nothing refused it, the ids went
22
+ * into a checked-in `wrangler.jsonc`, and a remote migrate ran against them.
23
+ *
24
+ * A guard against that combination would be a list of forbidden pairs. This is the invariant instead:
25
+ * **a scope is chosen once, and it carries both halves.** There is no call path that takes an
26
+ * environment name and a naming scheme separately, so there is no pair left to get wrong.
27
+ *
28
+ * Two scopes exist, and they are the two kinds of environment a project has:
29
+ *
30
+ * - {@link environmentScope} — a **declared** environment (`staging`, `prod`, whatever the root
31
+ * `pithy.config.ts` lists). Names are `<project>-<env>-<thing>`, the rule every other namer in the
32
+ * kit follows, so the environment segment of a name is always the stanza the name lives in.
33
+ * - {@link featureScope} — one branch's **ephemeral** environment. Names are
34
+ * `<project>-f<issue>-<slug>-<thing>`, with no environment segment because a feature *is* an
35
+ * environment, and the `f<issue>` marker is what teardown recomputes and what keeps a feature's
36
+ * database from ever being mistaken for a deployed one's.
37
+ */
38
+
39
+ /**
40
+ * Whether a secret's value differs per environment or is one value every environment binds.
41
+ *
42
+ * The same two words `@pithy-sh/secrets`' `SecretScope` uses, restated here as a literal union rather
43
+ * than imported: core carries the secret-registry *seam* and not the package, for the same reason
44
+ * `SecretRegistryEntrySeam` types `backend` as a `string`.
45
+ */
46
+ export type SecretNameScope = "environment" | "global";
47
+
48
+ /** Where a provisioning run's resources are named, and where their ids are written. */
49
+ export interface ProvisionScope {
50
+ /** The `env.<stanza>` key in each Worker's config that this scope's ids are written into. */
51
+ readonly stanza: string;
52
+ /**
53
+ * **Are this scope's ids source, or a build artifact?**
54
+ *
55
+ * The same provisioning step produces both, and the difference is not cosmetic. A declared
56
+ * environment's ids are long-lived facts about the repository: they belong in the tracked
57
+ * `wrangler.jsonc`, under review, in a pull request a human reads. A feature's are facts about one
58
+ * job — the branch is deleted, the resources are destroyed, and the ids name nothing afterwards.
59
+ *
60
+ * Writing a feature's into the tracked file was correct as designed *in CI*, where the checkout is
61
+ * throwaway, and an expectation everywhere else: a developer's worktree carried a modified tracked
62
+ * file they never edited, with no note saying it must not be committed, and `git add -A` put ids for
63
+ * deleted resources onto `main`. So a build artifact goes somewhere untracked instead, and the
64
+ * question "which is this?" is answered here, once, by the same object that answers "what is it
65
+ * called?" and "which stanza does it go in?" — because those three answers have to agree.
66
+ */
67
+ readonly source: boolean;
68
+ /** This scope's Cloudflare name for a provisionable binding. */
69
+ resource(binding: string, kind: FeatureResourceKind): string;
70
+ /** The script name a Worker deploys under in this scope. */
71
+ worker(worker: string): string;
72
+ /**
73
+ * This scope's CF Secrets Store entry name for a declared secret.
74
+ *
75
+ * A `global` secret is the one name that does **not** take the scope's segment: it is a single
76
+ * account-level value every environment binds, so every scope resolves it to the project's
77
+ * `<project>-global-<secret>`. Scoping it would mint a second copy of a value defined as one.
78
+ */
79
+ secretEntry(secret: string, secretScope: SecretNameScope): string;
80
+ }
81
+
82
+ /** The wrangler binding array a resource kind's name is composed for. */
83
+ const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2"> = { d1: "d1", kv: "kv", r2: "r2" };
84
+
85
+ /**
86
+ * The one branch every scope shares: a `global` secret resolves to the project's single
87
+ * `<project>-global-<secret>` entry, and everything else to the scope's own name for it.
88
+ *
89
+ * Written once here rather than in each scope, because "global is not scoped" is a property of the
90
+ * secret and not of the environment asking — and two copies of it is how one of them would drift.
91
+ */
92
+ function secretEntryName(project: string, secret: string, secretScope: SecretNameScope, scoped: () => string): string {
93
+ if (secretScope === GLOBAL_SCOPE) return resourceNames(project).global.secretEntry(secret);
94
+ return scoped();
95
+ }
96
+
97
+ /**
98
+ * A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
99
+ *
100
+ * The environment is validated here, once, before a single name exists: it is the middle segment of
101
+ * every name below, so an illegal one must fail before anything is created rather than at the fourth
102
+ * getter that happened to use it.
103
+ *
104
+ * **A Worker's script name is wrangler's own, `<script>-<env>`, deliberately.** It is the one name here
105
+ * that is not `<project>-<env>-<thing>`, because `wrangler deploy --env staging` has always appended the
106
+ * environment to the top-level `name` — so every project that ever deployed is already at that address.
107
+ * A Worker script is a `refuse`-policy namespace precisely because renaming one orphans the deployment
108
+ * and every `service` binding pointing at it; composing a "more correct" name here would do exactly
109
+ * that, silently, on the next deploy. The name is written out rather than left implicit so a service
110
+ * binding and a deploy agree on one string that is in the file.
111
+ */
112
+ export function environmentScope(project: string, environment: string): ProvisionScope {
113
+ assertValidEnvironment(environment);
114
+ const names = resourceNames(project).env(environment);
115
+ return {
116
+ stanza: environment,
117
+ // Long-lived ids for an environment the project ships to: reviewed, committed, kept.
118
+ source: true,
119
+ resource: (binding, kind) => names[KIND_NAMER[kind]](binding),
120
+ worker: (worker) => `${worker}-${environment}`,
121
+ secretEntry: (secret, secretScope) =>
122
+ secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * One feature's scope — the ephemeral environment a branch gets, named from the branch rather than from
128
+ * an environment.
129
+ *
130
+ * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
131
+ * in a declared environment's stanza unexpressible rather than merely discouraged.
132
+ */
133
+ export function featureScope(identity: FeatureIdentity): ProvisionScope {
134
+ return {
135
+ stanza: FEATURE_ENVIRONMENT,
136
+ // One job's ids, for resources `destroy` deletes. Never a tracked file.
137
+ source: false,
138
+ resource: (binding, kind) => featureResourceName(identity, binding, kind),
139
+ worker: (worker) => featureWorkerName(identity, worker),
140
+ secretEntry: (secret, secretScope) =>
141
+ secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret)),
142
+ };
143
+ }
@@ -0,0 +1,266 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError, ValidationError } from "../error/pithyError";
5
+ import { MAX_ENVIRONMENT_NAME } from "./environment";
6
+ import {
7
+ FEATURE_DERIVED_PROJECT_NAME,
8
+ MAX_CAPABILITY_JOB,
9
+ NAMESPACE_LIMITS,
10
+ WORKFLOW_DERIVED_PROJECT_NAME,
11
+ } from "./limits";
12
+ import { NAME_SEGMENT } from "./segment";
13
+
14
+ /**
15
+ * One naming rule, in one module: **`<project>-<env>-<thing>`**, kebab-case.
16
+ *
17
+ * Every Cloudflare namespace Pithy writes into is flat and account-wide — D1, KV, R2, Worker
18
+ * scripts, Workflows, the single Secrets Store, the API-token list. None of them can be
19
+ * partitioned, so the *name* is the partition, and the project segment is the only thing keeping
20
+ * two Pithy projects in one account from adopting or overwriting each other's resources.
21
+ *
22
+ * **Project first** because that is the ownership boundary everything turns on: teardown, the
23
+ * token listing's prefix filter, and the reaper's reservation all key on it. **Environment second**
24
+ * because these end up in listings nobody can filter — `wrangler d1 list`, the account dashboard,
25
+ * the Secrets Store — and sorting by name should group a project's resources and then that
26
+ * project's environments, so everything belonging to prod sits together. Environment last
27
+ * would scatter prod through the listing, interleaved with staging, which is exactly when
28
+ * someone acts on the wrong one.
29
+ *
30
+ * A `global`-scoped thing puts the literal `global` in the environment slot rather than omitting
31
+ * it, so the scheme has no exception to remember.
32
+ *
33
+ * This module is in `core` because it has no dependencies and every layer needs it — capabilities,
34
+ * the CLI, and the Workers runtime alike. It therefore uses **no `node:` builtin**: core is bundled
35
+ * into the adopter's Worker, where `node:crypto` is not free.
36
+ */
37
+
38
+ /**
39
+ * The default budget of the un-namespaced {@link resourceName} composer: the strictest cap of any
40
+ * namespace Pithy writes into, which is R2's 63.
41
+ *
42
+ * A caller that knows its namespace does **not** use this — it passes that namespace's own limit
43
+ * from `./limits`, or, better, goes through the facade in `./resourceNames`, which cannot pick the
44
+ * wrong budget. This number is the conservative answer for a call site that has no namespace at all.
45
+ */
46
+ export const MAX_RESOURCE_NAME = NAMESPACE_LIMITS.r2.maxLength;
47
+
48
+ /** Below this the trailing segment is not worth keeping legible, so a name this tight is refused instead. */
49
+ const MIN_THING_BUDGET = 1;
50
+
51
+ /**
52
+ * The cap on a project name — **derived, not chosen, and derived from two shapes rather than one**.
53
+ *
54
+ * A project name has to survive every name it will ever be the head of, so it gets the *smaller* of
55
+ * the two worst cases (`./limits` carries both derivations with their arithmetic):
56
+ *
57
+ * - {@link WORKFLOW_DERIVED_PROJECT_NAME} — `<project>-<env>-<capability>-<job>` against a Workflow's
58
+ * 64 characters, after the longest environment and the longest `<capability>-<job>`.
59
+ * - {@link FEATURE_DERIVED_PROJECT_NAME} — `<project>-f<issue>-<slug>-<binding>-<kind>` against R2's
60
+ * 63, with room for the reserved issue digits, a legible slug, and a binding kept whole.
61
+ *
62
+ * Neither number is repeated here. Each is a computed constant with its arithmetic in `./limits`, and a
63
+ * copy in this sentence is a copy that goes stale the first time a term of the derivation moves.
64
+ *
65
+ * The feature shape is the binding one, and it used to be invisible: the cap was derived from the
66
+ * Workflow alone, so a project inside it could still compose a feature name whose slug was hashed
67
+ * away to nothing. Taking the minimum is the only version that is true of both.
68
+ *
69
+ * **Why the cap belongs on the project name rather than on each composed name:** without it the
70
+ * refusal lands at the first `pithy <capability> provision`, by which time `pithy add` has created
71
+ * real R2 buckets and D1 databases under a name that fits *their* budget. The project is
72
+ * half-provisioned, and the only documented remedy — renaming — orphans everything already made.
73
+ * Capped here, `pithy init` and `requireProjectName` refuse it before anything is written.
74
+ */
75
+ export const MAX_PROJECT_NAME = Math.min(WORKFLOW_DERIVED_PROJECT_NAME, FEATURE_DERIVED_PROJECT_NAME);
76
+
77
+ /** Lowercase, collapse any run of non-`[a-z0-9]` to one `-`, and trim leading/trailing `-`. */
78
+ export function kebab(input: string): string {
79
+ return input
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, "-")
82
+ .replace(/^-+|-+$/g, "");
83
+ }
84
+
85
+ /**
86
+ * A stable 6-hex-character digest, FNV-1a. Pure arithmetic rather than `node:crypto` so this module
87
+ * runs unchanged in a Worker. It is a *disambiguator for truncated names*, never a security
88
+ * primitive — collision resistance beyond "two different capability names look different" is not
89
+ * claimed and must not be relied on.
90
+ */
91
+ export function hash6(input: string): string {
92
+ let h = 0x811c9dc5;
93
+ for (let i = 0; i < input.length; i += 1) {
94
+ h ^= input.charCodeAt(i);
95
+ h = Math.imul(h, 0x01000193) >>> 0;
96
+ }
97
+ return h.toString(16).padStart(8, "0").slice(0, 6);
98
+ }
99
+
100
+ /**
101
+ * Fit a kebab segment into `budget` characters: verbatim when it fits, otherwise a truncated head
102
+ * plus a short stable hash so it stays distinct and within budget. Deterministic in `segment`
103
+ * alone, so two commands computing the same name agree without storing it. The hash shrinks for
104
+ * tiny budgets, and the result never exceeds `budget` nor trails a hyphen.
105
+ */
106
+ export function fitSegment(segment: string, budget: number): string {
107
+ if (budget <= 0) return "";
108
+ if (segment.length <= budget) return segment;
109
+ if (budget < 4) return hash6(segment).slice(0, budget);
110
+ const hashLen = Math.min(6, Math.max(2, budget - 2));
111
+ const headLen = Math.max(1, budget - hashLen - 1);
112
+ return `${segment.slice(0, headLen).replace(/-+$/, "")}-${hash6(segment).slice(0, hashLen)}`;
113
+ }
114
+
115
+ /** The parts of a provisioned name. Every field is required — an absent segment is a naming bug, not a default. */
116
+ export interface ResourceNameParts {
117
+ /**
118
+ * The project name — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and
119
+ * **never guessed**. This is the ownership boundary; a wrong value here writes into another
120
+ * project's resources.
121
+ */
122
+ project: string;
123
+ /** The environment (`dev`, `staging`, `prod`), or the literal `global` for a value shared across all of them. */
124
+ env: string;
125
+ /** What this resource is — a capability, a binding, a secret, a token profile. The only segment ever truncated. */
126
+ thing: string;
127
+ }
128
+
129
+ /**
130
+ * Compose `<project>-<env>-<thing>`, kebab-cased and fitted to `budget`.
131
+ *
132
+ * **Only `thing` is ever truncated.** The project and environment segments are taken verbatim,
133
+ * because a hashed project segment would let two long project names share a prefix — and the token
134
+ * listing filters on exactly that prefix, so a collision there means one project enumerating and
135
+ * acting on another's credentials. If project and environment alone cannot fit, that is refused
136
+ * with the limit named rather than quietly hashed into an ambiguous name.
137
+ *
138
+ * `budget` defaults to {@link MAX_RESOURCE_NAME}; a namespace with its own limit passes it in.
139
+ *
140
+ * The project segment is held to {@link isValidProjectName} — the same rule the Worker and Workflow
141
+ * namers apply — so a project this accepts is one every namespace accepts. It is a backstop, not the
142
+ * gate: the real refusal belongs at `pithy init` and `requireProjectName`, before anything is written.
143
+ */
144
+ export function resourceName(parts: ResourceNameParts, budget: number = MAX_RESOURCE_NAME): string {
145
+ const project = kebab(parts.project);
146
+ const env = kebab(parts.env);
147
+ const thing = kebab(parts.thing);
148
+
149
+ for (const [role, value] of [
150
+ ["project", project],
151
+ ["environment", env],
152
+ ["thing", thing],
153
+ ] as const) {
154
+ if (!value) {
155
+ throw new ValidationError({
156
+ message: `A resource name needs a ${role}.`,
157
+ action: role === "project" ? "Set `name` in pithy.config.ts." : undefined,
158
+ detail: `resourceName received an empty ${role} segment (raw: ${JSON.stringify(parts)})`,
159
+ });
160
+ }
161
+ }
162
+
163
+ assertValidProjectName(parts.project);
164
+
165
+ const head = `${project}-${env}`;
166
+ const thingBudget = budget - head.length - 1;
167
+ if (thingBudget < MIN_THING_BUDGET) {
168
+ throw new InternalError({
169
+ message: `The project and environment names alone exceed the ${budget}-character limit for this resource.`,
170
+ action: "Shorten `name` in pithy.config.ts.",
171
+ detail: `"${head}" leaves ${thingBudget} characters for "${thing}"`,
172
+ });
173
+ }
174
+
175
+ return `${head}-${fitSegment(thing, thingBudget)}`;
176
+ }
177
+
178
+ /**
179
+ * Could every Cloudflare name this project generates be created? The one rule, in one place, so no
180
+ * namespace re-derives a looser version of it.
181
+ *
182
+ * **Read after kebabbing**, for the same reason {@link isReservedProjectName} is: the raw string never
183
+ * reaches an account, only what the composer made of it. `Acme Corp` is legal (it becomes `acme-corp`);
184
+ * `2026 Launch` is not, and neither answer can be read off the raw characters.
185
+ *
186
+ * **Two dimensions, one answer: charset and length.** Both have the same failure mode and so must have
187
+ * the same home — {@link NAME_SEGMENT} for the charset, {@link MAX_PROJECT_NAME} for the number. The
188
+ * charset is the shared segment rule rather than a project-specific one: a project name is the head of
189
+ * every composed name, so it is held to exactly what every other segment is held to.
190
+ *
191
+ * The rule matters at the *source* — `pithy init` and `requireProjectName` — because the namespaces
192
+ * disagree about it. A digit-leading project composes a legal D1 database name, a legal KV title, and a
193
+ * legal bucket name, so `pithy add` provisions all three for real; only the first host-worker deploy
194
+ * refuses it, by which point the documented remedy — rename the project — orphans everything already
195
+ * created. A 33-character project fails exactly the same way, one deploy later. One rule, enforced
196
+ * before anything is written, is the only version of this that is safe.
197
+ */
198
+ export function isValidProjectName(name: string): boolean {
199
+ const normalized = kebab(name);
200
+ return normalized.length <= MAX_PROJECT_NAME && NAME_SEGMENT.test(normalized);
201
+ }
202
+
203
+ /**
204
+ * Refuse a project name no Cloudflare namespace could carry, as a **`ValidationError`**: a project name
205
+ * is something a human typed, so it is a 400 with an action, never an internal 500 blamed on the toolkit.
206
+ *
207
+ * Length and charset are one rule with two sentences, because they are two different mistakes: a name
208
+ * that is merely long is otherwise perfect, and "use lowercase letters" would be no help at all.
209
+ */
210
+ export function assertValidProjectName(name: string): void {
211
+ if (isValidProjectName(name)) return;
212
+ const normalized = kebab(name);
213
+ if (normalized.length > MAX_PROJECT_NAME && NAME_SEGMENT.test(normalized)) {
214
+ throw new ValidationError({
215
+ message: `"${name}" is ${normalized.length} characters. A project name stops at ${MAX_PROJECT_NAME}.`,
216
+ action: `Shorten \`name\` in pithy.config.ts to ${MAX_PROJECT_NAME} characters or fewer.`,
217
+ detail: `${MAX_PROJECT_NAME} is the smaller of what a 64-character Workflow name leaves after a ${MAX_ENVIRONMENT_NAME}-character environment and a ${MAX_CAPABILITY_JOB}-character <capability>-<job> (${WORKFLOW_DERIVED_PROJECT_NAME}), and what a 63-character feature resource name leaves after a six-digit issue, a legible slug, and a binding (${FEATURE_DERIVED_PROJECT_NAME}).`,
218
+ });
219
+ }
220
+ throw new ValidationError({
221
+ message: `"${name}" can't be a project name.`,
222
+ action: "Use lowercase letters, digits, and single hyphens, starting with a letter.",
223
+ detail: `"${name}" kebabs to "${normalized}", which does not match ${NAME_SEGMENT.source}.`,
224
+ });
225
+ }
226
+
227
+ /**
228
+ * **`pithy-int-` is reserved for Pithy's own live integration tests, on any account.**
229
+ *
230
+ * Every Cloudflare name a live test creates begins with it, no real project's name may, and the
231
+ * test-debris reaper deletes nothing without it. The reservation is what makes automatic reaping safe
232
+ * in both directions: without it the reaper is guessing — either it fails to recognize the names a
233
+ * suite invented, and debris accumulates silently on a real account, or it matches loosely enough to
234
+ * one day delete a customer's database. With it, membership is decidable from the name alone.
235
+ *
236
+ * It lives here, next to the composer, because both halves of the reservation are naming facts.
237
+ * `<project>-<env>-<thing>` puts the project first and takes it **verbatim**, so the prefix of every
238
+ * generated name is decided by the project name and nothing else — which is what lets one guard, on
239
+ * `pithy init`, keep the two sets from ever overlapping. See {@link isReservedProjectName}.
240
+ *
241
+ * This is deliberately narrower than the product's own `pithy` prefix. A project may legitimately be
242
+ * named `pithy-app`, and everything it provisions is real; `pithy-int-…` is debris by definition.
243
+ */
244
+ export const RESERVED_TEST_PREFIX = "pithy-int-";
245
+
246
+ /**
247
+ * Would a project of this name generate resource names inside the reservation? The one rule
248
+ * `pithy init` enforces, stated once so no call site re-derives it.
249
+ *
250
+ * Two traps make the obvious `name.startsWith(RESERVED_TEST_PREFIX)` wrong, and both are reachable
251
+ * with a name a human would type.
252
+ *
253
+ * **Kebabbing.** The composer kebabs the project before using it, so the raw string is not what
254
+ * Cloudflare sees. `"Pithy Int Ruse"` is not prefixed by `pithy-int-`, and it normalizes straight into
255
+ * the reservation.
256
+ *
257
+ * **The trailing hyphen.** The composer appends one after the project. A project named exactly
258
+ * `pithy-int` is not prefixed by `pithy-int-` either, and it generates `pithy-int-dev-db` — inside the
259
+ * namespace the reaper deletes.
260
+ *
261
+ * Testing `` `${kebab(name)}-` `` against the prefix closes both: it compares the form the composer
262
+ * actually uses, including the separator it actually adds.
263
+ */
264
+ export function isReservedProjectName(name: string): boolean {
265
+ return `${kebab(name)}-`.startsWith(RESERVED_TEST_PREFIX);
266
+ }