@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.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- 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
|
+
}
|