@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,194 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ENVIRONMENTS } from "./environment";
6
+
7
+ /**
8
+ * Where a Worker answers, declared once per Worker per environment.
9
+ *
10
+ * ## The problem this replaces
11
+ *
12
+ * Nothing in `pithy.config.ts` described a Worker's address, so three commands each reconstructed it
13
+ * differently and none of them reconciled: `pithy env` scraped the first `routes` pattern out of
14
+ * `wrangler.jsonc`, `pithy email provision` and `pithy turnstile` read a hand-set `vars.BASE_URL`, and
15
+ * `pithy deploy` scraped the last URL wrangler happened to print. `email provision` refused to run
16
+ * without `vars.BASE_URL` while `pithy env` cheerfully printed a URL derived from routes, and nothing
17
+ * noticed when the two disagreed. Three answers to one question, in one CLI.
18
+ *
19
+ * So the address is declared, and everything else is generated from it — the `routes` entry with
20
+ * `custom_domain` and `zone_name`, and `vars.BASE_URL`. `BASE_URL` stays a wrangler var because the
21
+ * **Worker reads it at runtime**: `@pithy-sh/email` builds tracking and unsubscribe links against it and
22
+ * Turnstile binds its widget to that domain. What changes is that it is derived rather than hand-set and
23
+ * able to contradict the routes beside it.
24
+ *
25
+ * ## Per Worker, per environment
26
+ *
27
+ * Per Worker because each serves its own hostname. Per environment because `staging.api.example.com` and
28
+ * `api.example.com` are one Worker in two environments, and `env.<name>` stanzas in `wrangler.jsonc`
29
+ * replace rather than merge — so one declaration has to fan out to all of them.
30
+ *
31
+ * ## `dev` carries no domain
32
+ *
33
+ * Deliberately absent from the accepted keys. Local answers on `http://localhost:<port>` from the port
34
+ * the feature's `.dev.config.json` pinned at creation, and a domain there would be a second answer to a
35
+ * question the port allocator already answers. `requireManagedEnvironment` refuses `dev` for the same
36
+ * reason everywhere else.
37
+ */
38
+
39
+ /** A hostname a Worker answers on. No scheme, no path, no port — wrangler's `routes` pattern is a host. */
40
+ const HOSTNAME_PATTERN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
41
+
42
+ /** The environments a domain may be declared for — every managed one, never `dev`. */
43
+ export const DOMAIN_ENVIRONMENTS = ENVIRONMENTS.filter((environment) => environment !== "dev");
44
+
45
+ /** One environment's public address for one Worker. */
46
+ export const WorkerDomain = z
47
+ .object({
48
+ pattern: z
49
+ .string()
50
+ .regex(
51
+ HOSTNAME_PATTERN,
52
+ "A domain is a bare hostname — no scheme, no path, no port (e.g. `api.example.com`, not `https://api.example.com/`).",
53
+ )
54
+ .describe(
55
+ "The hostname this Worker answers on in this environment, e.g. `api.example.com`. Written into `wrangler.jsonc` as a `routes` entry with `custom_domain: true`, and into `vars.BASE_URL` as `https://<pattern>`. A bare hostname rather than a URL, because that is what wrangler's route matcher takes — a scheme here would be silently wrong.",
56
+ ),
57
+ zone: z
58
+ .string()
59
+ .regex(HOSTNAME_PATTERN, "A zone is the registrable domain on your Cloudflare account, e.g. `example.com`.")
60
+ .describe(
61
+ "The Cloudflare zone `pattern` sits under — the registrable domain as it appears on your account, e.g. `example.com` for `api.example.com`. Cloudflare needs it to attach a custom domain, and it is not always derivable from the hostname: a zone can be a subdomain, and a public-suffix guess would be wrong for exactly the adopters who are hardest to debug.",
62
+ ),
63
+ })
64
+ .describe("Where one Worker answers in one environment: the hostname, and the Cloudflare zone it sits under.")
65
+ .check((ctx) => {
66
+ // A pattern outside its own zone produces a `routes` entry Cloudflare refuses at deploy, with an
67
+ // error naming neither of the two values that disagree. Catching it here names both.
68
+ const { pattern, zone } = ctx.value;
69
+ if (pattern !== zone && !pattern.endsWith(`.${zone}`)) {
70
+ ctx.issues.push({
71
+ code: "custom",
72
+ input: ctx.value,
73
+ path: ["pattern"],
74
+ message: `The domain \`${pattern}\` is not inside the zone \`${zone}\`. A custom domain must sit on the zone it names — set \`zone\` to the registrable domain \`${pattern}\` belongs to.`,
75
+ });
76
+ }
77
+ });
78
+ export type WorkerDomain = z.infer<typeof WorkerDomain>;
79
+
80
+ /**
81
+ * A Worker's domains, keyed by environment.
82
+ *
83
+ * Every key optional: a project with no domain yet is legitimate, and so is one that has staging wired
84
+ * and production not. Adding one later is a config edit plus a deploy, never a rescaffold.
85
+ */
86
+ export const WorkerDomains = z
87
+ .object({
88
+ staging: WorkerDomain.optional().describe("Where this Worker answers in `staging`, if it has a domain yet."),
89
+ prod: WorkerDomain.optional().describe("Where this Worker answers in `prod`, if it has a domain yet."),
90
+ })
91
+ .describe(
92
+ "Where this Worker answers, per environment. `dev` is absent by design — local runs on `http://localhost:<port>` from the port pinned in `.dev.config.json`. Everything else is generated from this: the `routes` entry with `custom_domain` and `zone_name`, and `vars.BASE_URL`.",
93
+ );
94
+ export type WorkerDomains = z.infer<typeof WorkerDomains>;
95
+
96
+ /**
97
+ * The declared domain for one environment, or null where none is declared (including every `dev`).
98
+ *
99
+ * `environment` may be `undefined`, which is what `compositionEnvironment()` answers when nothing stamped
100
+ * one. That is not a `dev` default — it is "this composition names no environment", and an environment
101
+ * nobody named has declared no domain, so the answer is the same `null` an undeclared one gets.
102
+ */
103
+ export function domainFor(domains: WorkerDomains | undefined, environment: string | undefined): WorkerDomain | null {
104
+ if (!domains) return null;
105
+ if (environment === "staging") return domains.staging ?? null;
106
+ if (environment === "prod") return domains.prod ?? null;
107
+ return null;
108
+ }
109
+
110
+ /** The base URL a declared domain implies. Always `https` — a custom domain on Cloudflare is TLS-terminated. */
111
+ export function baseUrlFor(domain: WorkerDomain): string {
112
+ return `https://${domain.pattern}`;
113
+ }
114
+
115
+ /**
116
+ * The origin an environment with no declared domain resolves to.
117
+ *
118
+ * **No port, and that is the whole design.** Local dev's port is assigned per Worker per run from the
119
+ * feature's reserved block, so it is the one address nobody can write down — `@pithy-sh/auth` resolves
120
+ * dev's base URL from the host the request arrived at for exactly that reason, and a config-time value
121
+ * could only ever be a stale guess at it. So this is a *placeholder that fails closed*: a link built
122
+ * against it goes nowhere, which is useless rather than harmful.
123
+ *
124
+ * Compare what it replaces. Every capability that made an adopter write an origin got production's
125
+ * written into staging, and the failure was never "the link is broken" — it was a staging deploy mailing
126
+ * real users magic links **into production**, an unsubscribe from a staging test unsubscribing that
127
+ * person in production, and a Checkout return landing a staging payer in production on an account that
128
+ * had bought nothing. A fallback that goes nowhere is the only one that cannot do any of that.
129
+ */
130
+ export const LOCAL_ORIGIN = "http://localhost";
131
+
132
+ /** An environment's public origin, and whether the declaration is where it came from. */
133
+ export interface ResolvedOrigin {
134
+ /** The absolute origin — scheme and host, never a trailing slash. */
135
+ origin: string;
136
+ /** The hostname alone: a route pattern, and what Turnstile binds a widget to. */
137
+ hostname: string;
138
+ /** Whether `domains` declared it. `false` means {@link LOCAL_ORIGIN} was substituted. */
139
+ declared: boolean;
140
+ }
141
+
142
+ /**
143
+ * **Where is this Worker publicly reachable, in this environment?** — asked once, answered once.
144
+ *
145
+ * The two halves already existed ({@link domainFor} and {@link baseUrlFor}) and nothing composed them,
146
+ * so every caller that needed the answer did the two-step for itself and every *adopter* who needed it
147
+ * wrote a URL down. The first adopter's one Worker config carried `https://app.pithy.sh` three times —
148
+ * `auth.baseURL`, `email.baseUrl`, and the Stripe return URLs — each wrong for staging in its own way,
149
+ * each found on a different day. Fixing it inside one capability does not stop the next capability
150
+ * asking the same question, so the question gets one answer that anyone can call.
151
+ *
152
+ * **The fallback never reaches for another environment's origin.** An environment absent from `domains`
153
+ * is one that is not published, so it resolves to {@link LOCAL_ORIGIN} and nothing else. Falling back to
154
+ * production's is what the shape being replaced did, and it is the one behavior here that was actively
155
+ * dangerous rather than merely wrong.
156
+ *
157
+ * **A deployed environment must never *keep* that fallback**, and it is not this function's job to say
158
+ * so — `pithy deploy --env <name>` refuses an environment whose origin its config does not declare
159
+ * (#253), and `pithy doctor` reports it first. One rule in two halves: this one cannot invent another
160
+ * environment's origin, and deploy makes sure the placeholder never ships.
161
+ *
162
+ * **Not every origin should derive from this, and `controlplane.issuer` is the one to name.** That is an
163
+ * **identity**, not an address: a connection stores the issuer it was created with and verification
164
+ * checks that stored value, so a per-environment issuer would make a connection minted in staging
165
+ * unverifiable in production. That may be the better isolation, but it is a decision about trust rather
166
+ * than about reachability, and a helper whose job is "where am I reachable" must not sweep it up.
167
+ */
168
+ export function resolveOrigin(environment: string | undefined, domains: WorkerDomains | undefined): ResolvedOrigin {
169
+ const domain = domainFor(domains, environment);
170
+ if (domain) return { origin: baseUrlFor(domain), hostname: domain.pattern, declared: true };
171
+ return { origin: LOCAL_ORIGIN, hostname: "localhost", declared: false };
172
+ }
173
+
174
+ /**
175
+ * The one line an adopter writes: this Worker's public origin for the environment it is running in.
176
+ *
177
+ * ```ts
178
+ * const DOMAINS = { staging: { … }, prod: { … } };
179
+ * const PUBLIC_ORIGIN = originFor(compositionEnvironment(), DOMAINS);
180
+ * ```
181
+ *
182
+ * That is the line `pithy init` scaffolds, so an adopter who never thinks about it gets it right and one
183
+ * who wants a literal can still write one. `compositionEnvironment()` is `string | undefined`, and it is
184
+ * taken as such rather than defaulted to `dev` at the call site: an unstamped composition has declared no
185
+ * domain either way, and a `?? "dev"` in every adopter's config would be a default nobody chose.
186
+ *
187
+ * Named for what it is rather than for who asked. The first adopter's own version was called
188
+ * `AUTH_BASE_URL`, which is part of why `email` and `payments` were missed for days: the constant read
189
+ * as auth's private business when it is the Worker's address, and every capability that needs an origin
190
+ * needs this one.
191
+ */
192
+ export function originFor(environment: string | undefined, domains: WorkerDomains | undefined): string {
193
+ return resolveOrigin(environment, domains).origin;
194
+ }
@@ -0,0 +1,224 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PithyError, ValidationError } from "../error/pithyError";
6
+ import { NAME_SEGMENT } from "./segment";
7
+
8
+ /**
9
+ * What an environment may be called, in one module.
10
+ *
11
+ * An environment name is not decoration: it sits in the middle of every Cloudflare name a project
12
+ * composes (`<project>-<env>-<thing>`), so its length is subtracted from every other segment's
13
+ * budget, and its characters have to be legal in every namespace Pithy writes into. Until this
14
+ * module existed, `--env` was a bare string that nothing checked — a fourteen-character environment
15
+ * quietly ate the room a project name had already been accepted against.
16
+ */
17
+
18
+ /**
19
+ * The three first-class environments: local `dev`, `staging` for test users, `prod` for paid ones.
20
+ *
21
+ * **`prod`, not `production`.** The long form cost three characters of every project name, in a
22
+ * budget where thirty-three was the ceiling, and bought nothing a four-letter word does not say.
23
+ */
24
+ export const ENVIRONMENTS = ["dev", "staging", "prod"] as const;
25
+
26
+ /** One of Pithy's first-class environments. A custom environment is a `string`, and still validated. */
27
+ export type Environment = (typeof ENVIRONMENTS)[number];
28
+
29
+ /**
30
+ * The scope that sits beside the environments: one value shared by all of them.
31
+ *
32
+ * It occupies the same slot as an environment in a composed name, which is exactly why an
33
+ * environment may not be *called* `global` — a project would then have one set of names for two
34
+ * different scopes, and teardown could not tell them apart.
35
+ */
36
+ export const GLOBAL_SCOPE = "global";
37
+
38
+ /**
39
+ * The environment one branch gets — the `env.feature` stanza a feature's provisioning run writes into.
40
+ *
41
+ * **A legal environment name, and never a declared one.** Legal because it is a real wrangler stanza
42
+ * key, so every rule that governs a key governs it: seven characters, which is
43
+ * {@link MAX_ENVIRONMENT_NAME} exactly. Never declared because the two kinds of environment write to
44
+ * different files — a declared environment's ids are source, in the tracked `wrangler.jsonc`; a
45
+ * feature's are a build artifact under `.wrangler/`. A project that declared this name would own two
46
+ * files claiming one stanza, and the resolver that picks between them answers "generated" for this
47
+ * name — so a migrate would read bytes no provisioning run wrote.
48
+ *
49
+ * It lives here rather than beside the scope that uses it because it is first an environment name, and
50
+ * because {@link DeclaredEnvironments} has to refuse it: a constant defined downstream of the schema
51
+ * that rejects it would be a cycle.
52
+ */
53
+ export const FEATURE_ENVIRONMENT = "feature";
54
+
55
+ /**
56
+ * The longest an environment name may be — **the longest canonical one, `staging`**.
57
+ *
58
+ * This is a derivation input, not a preference: `WORKFLOW_DERIVED_PROJECT_NAME` and
59
+ * `FEATURE_DERIVED_PROJECT_NAME` are both computed against it, so every project name Pithy has ever
60
+ * accepted was accepted on the assumption that no environment exceeds it. Letting a custom
61
+ * environment run longer would retroactively shrink that cap — and a provisioned project cannot be
62
+ * renamed. So a longer environment is refused here, at the one place that can still say no.
63
+ *
64
+ * Read off {@link ENVIRONMENTS} rather than typed, so adding an environment cannot leave the two
65
+ * disagreeing.
66
+ */
67
+ export const MAX_ENVIRONMENT_NAME = Math.max(...ENVIRONMENTS.map((environment) => environment.length));
68
+
69
+ /**
70
+ * Is this a name an environment may carry?
71
+ *
72
+ * **Read raw, never kebabbed** — and that is the difference between this rule and the project rule.
73
+ * A project name is prose an adopter types once into `pithy.config.ts`, so `Acme Corp` is politely
74
+ * composed into `acme-corp`. An environment is an identifier repeated in `--env`, in
75
+ * `.dev.vars.<environment>`, and in a wrangler environment key; normalizing `Prod` to `prod` would
76
+ * make two spellings name one environment in some places and two in others.
77
+ */
78
+ export function isValidEnvironment(name: string): boolean {
79
+ if (name === GLOBAL_SCOPE) return false;
80
+ return name.length <= MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name);
81
+ }
82
+
83
+ /**
84
+ * Refuse a name no environment may carry, as a `ValidationError` — an environment comes from a flag
85
+ * or a config file, so it is a 400 with an action, never an internal fault.
86
+ *
87
+ * Three different mistakes get three different sentences, because "invalid environment" helps with
88
+ * none of them: `production` is the old spelling and gets the new one, a long name gets the number,
89
+ * and anything else gets the charset.
90
+ */
91
+ export function assertValidEnvironment(name: string): void {
92
+ if (isValidEnvironment(name)) return;
93
+ if (name === "production") {
94
+ throw new ValidationError({
95
+ message: `"production" is not an environment name in Pithy.`,
96
+ action: "Use `prod`.",
97
+ detail: `An environment stops at ${MAX_ENVIRONMENT_NAME} characters, the length of the longest of ${ENVIRONMENTS.join(", ")}.`,
98
+ });
99
+ }
100
+ if (name === GLOBAL_SCOPE) {
101
+ throw new ValidationError({
102
+ message: `"${GLOBAL_SCOPE}" is a scope, not an environment.`,
103
+ action: `Name the environment one of ${ENVIRONMENTS.join(", ")}.`,
104
+ detail: `${GLOBAL_SCOPE} occupies the environment slot of a composed name for values shared across every environment.`,
105
+ });
106
+ }
107
+ if (name.length > MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name)) {
108
+ throw new ValidationError({
109
+ message: `"${name}" is ${name.length} characters. An environment stops at ${MAX_ENVIRONMENT_NAME}.`,
110
+ action: `Use one of ${ENVIRONMENTS.join(", ")}, or a shorter name.`,
111
+ detail: `Every project-name budget is derived against a ${MAX_ENVIRONMENT_NAME}-character environment, and a provisioned project cannot be renamed.`,
112
+ });
113
+ }
114
+ throw new ValidationError({
115
+ message: `"${name}" can't be an environment name.`,
116
+ action: "Use lowercase letters, digits, and single hyphens, starting with a letter.",
117
+ detail: `An environment is used verbatim in Cloudflare resource names, so it must match ${NAME_SEGMENT.source}.`,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * The environments a project has when it declares nothing: `staging` for test users, `prod` for paid ones.
123
+ *
124
+ * The same two {@link ENVIRONMENTS} names minus `dev`, and the same two the starter scaffolded from a
125
+ * hardcoded template before anything could say otherwise — so a project that never opens this setting
126
+ * gets exactly what it got before.
127
+ */
128
+ export const DEFAULT_ENVIRONMENTS = ["staging", "prod"] as const;
129
+
130
+ /**
131
+ * The local environment. Never declared: it is the top-level wrangler stanza, and it always exists.
132
+ *
133
+ * Exported because "always exists and is never declared" is a fact other code has to act on, not only a
134
+ * rule this schema enforces. Anything deriving something over *every* environment a project has must add
135
+ * this one itself — {@link DeclaredEnvironments} will never hand it over — and a caller spelling `"dev"`
136
+ * inline is a caller that can spell it differently.
137
+ */
138
+ export const LOCAL_ENVIRONMENT = "dev";
139
+
140
+ /**
141
+ * **The set of deployed environments a project has**, declared once in the root `pithy.config.ts`.
142
+ *
143
+ * Until this existed a project never said. The set lived only as `env.<name>` stanzas in each Worker's
144
+ * `wrangler.jsonc` — per Worker, so two Workers in one project could disagree and nothing reconciled them
145
+ * — while `ManagedEnvironment` held a closed enum of two and `seed.productionEnvironments` invited a
146
+ * project to name a third. An adopter adding `env.live` got `pithy migrate --env live` working and
147
+ * `pithy secrets provision` skipping it in silence, because the three answers were never the same answer.
148
+ *
149
+ * **Ordered, and the order is meaningful.** It is the order provisioning walks — least-production first,
150
+ * so a mistake is made in staging before it is made in prod — and the last entry is the one a
151
+ * `global` account-level secret is written through (see `@pithy-sh/secrets`'s `resolveWriteTargets`).
152
+ * `["staging", "prod"]` therefore behaves exactly as the hardcoded pair did.
153
+ *
154
+ * **`dev` is not declarable.** It is local, it is the top-level wrangler stanza rather than an `env.dev`,
155
+ * and it resolves from Miniflare rather than an account — so a project always has it and provisioning
156
+ * never has it. Declaring it would ask a deploy to happen for an environment that never deploys.
157
+ *
158
+ * **Nothing is renamed by editing this.** `<project>-<env>-<thing>` is computed from the declaration and
159
+ * never stored, so changing a name here orphans everything already provisioned under the old one, exactly
160
+ * as renaming `name` does. `pithy doctor` reports the change rather than applying it.
161
+ */
162
+ export const DeclaredEnvironments = z
163
+ .array(
164
+ z
165
+ .string()
166
+ .describe(
167
+ "One deployed environment's name, used verbatim in the middle of every Cloudflare name the project composes and in each Worker's `env.<name>` wrangler stanza.",
168
+ ),
169
+ )
170
+ .check((ctx) => {
171
+ if (ctx.value.length === 0) {
172
+ ctx.issues.push({
173
+ code: "custom",
174
+ input: ctx.value,
175
+ message: `Declare at least one environment. A project with none can never deploy — ${DEFAULT_ENVIRONMENTS.join(" and ")} is the default.`,
176
+ });
177
+ }
178
+ const seen = new Set<string>();
179
+ for (const name of ctx.value) {
180
+ if (name === LOCAL_ENVIRONMENT) {
181
+ ctx.issues.push({
182
+ code: "custom",
183
+ input: ctx.value,
184
+ message: `"${LOCAL_ENVIRONMENT}" is local and always present, so it is never declared. List only the environments this project deploys.`,
185
+ });
186
+ continue;
187
+ }
188
+ if (name === FEATURE_ENVIRONMENT) {
189
+ ctx.issues.push({
190
+ code: "custom",
191
+ input: ctx.value,
192
+ message: `"${FEATURE_ENVIRONMENT}" is the environment a branch gets, and its config is generated rather than committed. Declaring it would give one stanza two owners.`,
193
+ });
194
+ continue;
195
+ }
196
+ if (seen.has(name)) {
197
+ ctx.issues.push({
198
+ code: "custom",
199
+ input: ctx.value,
200
+ message: `"${name}" is declared twice. One environment is one set of Cloudflare names.`,
201
+ });
202
+ continue;
203
+ }
204
+ seen.add(name);
205
+ // The naming rule is `assertValidEnvironment`'s, not a second copy of it — a declaration that
206
+ // core's namer would refuse must be refused in the declaration's own sentence, or a project could
207
+ // write down an environment nothing could ever provision a resource for.
208
+ try {
209
+ assertValidEnvironment(name);
210
+ } catch (error) {
211
+ ctx.issues.push({
212
+ code: "custom",
213
+ input: ctx.value,
214
+ message: error instanceof PithyError ? error.payload.message : `"${name}" can't be an environment name.`,
215
+ });
216
+ }
217
+ }
218
+ })
219
+ .describe(
220
+ "Every deployed environment this project has, in the order provisioning walks them — least-production first. Declared once in the root pithy.config.ts, because the set cannot be per-Worker. `dev` is never listed: it is local and always present.",
221
+ );
222
+
223
+ /** Every deployed environment a project has. Same name as its schema, as every Zod object here is. */
224
+ export type DeclaredEnvironments = z.output<typeof DeclaredEnvironments>;
@@ -0,0 +1,162 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "../error/pithyError";
5
+ import { MAX_ISSUE_DIGITS, NAMESPACE_LIMITS } from "./limits";
6
+ import { assertValidProjectName, fitSegment, kebab } from "./resource";
7
+
8
+ /**
9
+ * The names a feature environment provisions under: `<project>-f<issue>-<slug>-<binding>-<kind>` for a
10
+ * resource, `<project>-f<issue>-<slug>-<worker>` for a Worker script.
11
+ *
12
+ * **No environment segment, and no Worker segment.** A feature *is* an environment, and two Workers that
13
+ * declare the same binding are meant to share one resource — so the binding name, not the topology, is
14
+ * where sharing is expressed.
15
+ *
16
+ * Every name here is **derived and recomputed**, never stored: `provision` and `destroy` both compute the
17
+ * same string from `(identity, binding, kind)`, which is what lets teardown reconcile exactly rather than
18
+ * scan a prefix a hyphenated sibling slug could ambiguously match. That is also why these names
19
+ * **truncate** where a capability's Workflow refuses: a feature name addresses nothing that outlives the
20
+ * feature, and failing a CI run because a branch slug was long would be the worse failure.
21
+ *
22
+ * It lives in `core`, beside the rest of the naming rule, so the CLI and the facade compose one
23
+ * implementation rather than two that drift.
24
+ */
25
+
26
+ /** A Cloudflare resource kind a feature can provision. All two characters — `MAX_FEATURE_KIND` in `./limits`. */
27
+ export type FeatureResourceKind = "d1" | "kv" | "r2";
28
+
29
+ /** Every feature resource kind, for tests and for callers that enumerate. */
30
+ export const FEATURE_RESOURCE_KINDS: readonly FeatureResourceKind[] = ["d1", "kv", "r2"];
31
+
32
+ /** The identity of a feature — everything a feature name is derived from. */
33
+ export interface FeatureIdentity {
34
+ /** The project name, from the root `pithy.config.ts` (e.g. `acme`). */
35
+ project: string;
36
+ /** The issue number as a string (e.g. `69`). Digits only, up to {@link MAX_ISSUE_DIGITS}. */
37
+ issue: string;
38
+ /** The kebab-case feature slug, from the branch (e.g. `media-cli`). */
39
+ slug: string;
40
+ }
41
+
42
+ /** Below this the slug segment isn't worth keeping legible; the trailing segment is truncated to give it room. */
43
+ const MIN_SLUG_BUDGET = 3;
44
+
45
+ /**
46
+ * Refuse an issue number the budgets were not derived against.
47
+ *
48
+ * `MAX_ISSUE_DIGITS` is a term in {@link FEATURE_DERIVED_PROJECT_NAME}, so an eight-digit issue would
49
+ * quietly spend a character every project name was already accepted against. It is also the cheapest
50
+ * possible check that the value is an issue number at all — `featureResourceName` used to interpolate
51
+ * whatever string it was handed straight into a Cloudflare name.
52
+ */
53
+ function assertIssue(issue: string): void {
54
+ if (new RegExp(`^[0-9]{1,${MAX_ISSUE_DIGITS}}$`).test(issue)) return;
55
+ throw new ValidationError({
56
+ message: `"${issue}" is not an issue number.`,
57
+ action: `Use the issue's digits, up to ${MAX_ISSUE_DIGITS} of them.`,
58
+ detail: `Feature resource names reserve ${MAX_ISSUE_DIGITS} digits, and every project-name budget is derived against that.`,
59
+ });
60
+ }
61
+
62
+ /** The `<project>-f<issue>` head both feature shapes share, with the project held to the one project rule. */
63
+ function head(identity: FeatureIdentity): string {
64
+ assertValidProjectName(identity.project);
65
+ assertIssue(identity.issue);
66
+ return `${kebab(identity.project)}-f${identity.issue}`;
67
+ }
68
+
69
+ /**
70
+ * Fit `<head>-<slug>-<tail><fixed>` into `budget`, truncating the slug first and the tail only if the
71
+ * tail is what is eating the name. `fixed` is never touched — it carries the kind suffix, which is the
72
+ * only thing telling a `DB` bucket from a `DB` database.
73
+ *
74
+ * Deterministic in its inputs and hash-disambiguated on both variable segments, so two long inputs
75
+ * sharing a prefix still produce two different names.
76
+ */
77
+ function composeFeatureName(headSegment: string, slug: string, tail: string, fixed: string, budget: number): string {
78
+ for (const [role, value] of [
79
+ ["slug", slug],
80
+ ["binding or worker", tail],
81
+ ] as const) {
82
+ if (value) continue;
83
+ // An empty segment composed `acme-f69--db-d1`, or a name ending in a dash. Neither is a legal
84
+ // bucket or script name, and both used to come out of here without a word.
85
+ throw new ValidationError({
86
+ message: `A feature resource name needs a ${role}.`,
87
+ action: "Use letters, digits, and hyphens — something that survives kebabbing.",
88
+ detail: `composeFeatureName received an empty ${role} segment for "${headSegment}".`,
89
+ });
90
+ }
91
+
92
+ const inner = budget - fixed.length;
93
+ let tailSegment = tail;
94
+ let slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
95
+
96
+ if (slugBudget < MIN_SLUG_BUDGET) {
97
+ // The tail is eating the name — truncate it too, reserving the slug its minimum.
98
+ const tailBudget = inner - headSegment.length - 1 - MIN_SLUG_BUDGET - 1;
99
+ tailSegment = fitSegment(tailSegment, Math.max(1, tailBudget));
100
+ slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
101
+ }
102
+
103
+ return `${headSegment}-${fitSegment(slug, Math.max(1, slugBudget))}-${tailSegment}${fixed}`;
104
+ }
105
+
106
+ /**
107
+ * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>-<binding>-<kind>`.
108
+ *
109
+ * Held to **R2's 63**, the strictest of the three kinds a feature provisions, so one shape is legal for
110
+ * all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
111
+ * feature that provisions a bucket and a database wants them recognizably the same name.
112
+ */
113
+ export function featureResourceName(identity: FeatureIdentity, binding: string, kind: FeatureResourceKind): string {
114
+ return composeFeatureName(
115
+ head(identity),
116
+ kebab(identity.slug),
117
+ kebab(binding),
118
+ `-${kind}`,
119
+ NAMESPACE_LIMITS.r2.maxLength,
120
+ );
121
+ }
122
+
123
+ /**
124
+ * The CF Secrets Store entry name holding one of a feature's **environment-scoped** secrets —
125
+ * `<project>-f<issue>-<slug>-<secret>`.
126
+ *
127
+ * A Cloudflare account has one Secrets Store, flat and unpartitionable, so the entry name is the only
128
+ * partition there is. A feature therefore needs its own names for the same reason it needs its own
129
+ * database: without them a branch would adopt staging's master key, and its teardown would delete it.
130
+ *
131
+ * Held to the **Secrets Store** limit rather than R2's 63, like every other entry name — an entry has
132
+ * no documented Cloudflare cap, and holding it to the strictest kind's would hash
133
+ * `secrets-encryption-keys` down to nothing for no reason.
134
+ *
135
+ * A `global` secret is not named here at all: it is one account-level value every environment binds,
136
+ * so a feature binds the project's `<project>-global-<secret>` rather than minting a second copy.
137
+ */
138
+ export function featureSecretEntryName(identity: FeatureIdentity, secret: string): string {
139
+ return composeFeatureName(
140
+ head(identity),
141
+ kebab(identity.slug),
142
+ kebab(secret),
143
+ "",
144
+ NAMESPACE_LIMITS.secretEntry.maxLength,
145
+ );
146
+ }
147
+
148
+ /**
149
+ * The Worker **script name** for one of a feature's workers — `<project>-f<issue>-<slug>-<worker>`.
150
+ *
151
+ * This is the name the feature's Workers deploy under, and therefore the name a sibling's `service`
152
+ * binding must target, so RPC inside a feature environment reaches that feature's deployment rather than
153
+ * production's.
154
+ *
155
+ * Held to the **Worker rule** ({@link NAMESPACE_LIMITS.worker}), which is the bug this used to have: it
156
+ * floored the slug at one character and never touched the worker segment, so it ran unbounded — 69
157
+ * characters for a worker called `collaboration-realtime-gateway`, 109 for a 70-character one. `apps/<name>`
158
+ * has a charset rule and no length rule, so nothing upstream was going to stop it either.
159
+ */
160
+ export function featureWorkerName(identity: FeatureIdentity, worker: string): string {
161
+ return composeFeatureName(head(identity), kebab(identity.slug), kebab(worker), "", NAMESPACE_LIMITS.worker.maxLength);
162
+ }