@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,174 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError, ValidationError } from "../error/pithyError";
|
|
5
|
+
import { workflowHostName, workflowScriptName } from "../workflow/naming";
|
|
6
|
+
import { assertValidEnvironment, GLOBAL_SCOPE } from "./environment";
|
|
7
|
+
import { type FeatureIdentity, type FeatureResourceKind, featureResourceName, featureWorkerName } from "./feature";
|
|
8
|
+
import { NAMESPACE_LIMITS, type NamespaceLimit } from "./limits";
|
|
9
|
+
import { assertValidProjectName, kebab, resourceName } from "./resource";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every Cloudflare name a project composes, behind one object — **the facade**.
|
|
13
|
+
*
|
|
14
|
+
* The free functions underneath are correct and stay exported, but they share one flaw: each takes a
|
|
15
|
+
* budget, so every call site decides for itself which limit applies. That is how one number, 63, ended
|
|
16
|
+
* up standing in for eight different namespaces. Here the budget is not an argument at all. You pick a
|
|
17
|
+
* *kind of thing* — a bucket, a KV namespace, a Workflow — and the kind carries its own cap and its own
|
|
18
|
+
* refuse-or-truncate policy from `./limits`. There is no wrong budget to pass, because there is no
|
|
19
|
+
* budget to pass.
|
|
20
|
+
*
|
|
21
|
+
* **Shape: project, then scope, then kind.**
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const names = resourceNames(config.name); // the project, validated once
|
|
25
|
+
* const prod = names.env("prod"); // the environment, validated once
|
|
26
|
+
* prod.d1("DB"); // acme-prod-db
|
|
27
|
+
* prod.workflow("email", "send"); // acme-prod-email-send
|
|
28
|
+
* names.global.secretEntry("secrets-manager-cf-api-token");
|
|
29
|
+
* names.feature({ issue, slug }).resource("DB", "d1");
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Three reasons for that order rather than a flat `worker(capability, env)`:
|
|
33
|
+
*
|
|
34
|
+
* 1. **Each thing is validated once, at the point it is named.** A bad project fails at
|
|
35
|
+
* `resourceNames`, before a single name exists; a bad environment fails at `env`, not on the fourth
|
|
36
|
+
* getter that happened to use it.
|
|
37
|
+
* 2. **The environment is threaded once, not through every call.** Commands are environment-scoped —
|
|
38
|
+
* `pithy deploy --env prod` names a dozen resources in one environment — so binding it once removes a
|
|
39
|
+
* dozen chances to pass the wrong one.
|
|
40
|
+
* 3. **`global` becomes a property, not a magic string.** It is the same interface as an environment
|
|
41
|
+
* (`names.global.secretEntry(…)`), so no call site special-cases the literal, and no call site can
|
|
42
|
+
* typo it into a real environment named `globl`.
|
|
43
|
+
*
|
|
44
|
+
* The feature shape hangs off the same object for the same reason it exists at all: it is the *other*
|
|
45
|
+
* scope a project's resources live in, and it shares the project segment with everything above.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The names available within one scope — an environment, or the global scope beside them. */
|
|
49
|
+
export interface ScopedNames {
|
|
50
|
+
/** A capability's Worker script — `<project>-<env>-<capability>`. Refused, not truncated, past 63. */
|
|
51
|
+
worker(capability: string): string;
|
|
52
|
+
/** One durable job's Workflow — `<project>-<env>-<capability>-<job>`. Refused, not truncated, past 64. */
|
|
53
|
+
workflow(capability: string, job: string): string;
|
|
54
|
+
/** A D1 database. No Cloudflare cap; Pithy's ceiling, truncated with a stable hash. */
|
|
55
|
+
d1(binding: string): string;
|
|
56
|
+
/** A KV namespace title. Cloudflare's 512, not the 63 this used to be held to. */
|
|
57
|
+
kv(binding: string): string;
|
|
58
|
+
/** An R2 bucket. Cloudflare's 63 — the one namespace where that number was always right. */
|
|
59
|
+
r2(binding: string): string;
|
|
60
|
+
/** A Vectorize index. 64 bytes, refused rather than truncated: a renamed index has no vectors. */
|
|
61
|
+
vectorizeIndex(index: string): string;
|
|
62
|
+
/** A Secrets Store entry. No Cloudflare cap; Pithy's ceiling. */
|
|
63
|
+
secretEntry(secret: string): string;
|
|
64
|
+
/** A Cloudflare API token label. No Cloudflare cap; Pithy's ceiling. */
|
|
65
|
+
apiToken(profile: string): string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The names a feature environment provisions under, with the project already bound. */
|
|
69
|
+
export interface FeatureNames {
|
|
70
|
+
/** A feature's D1, KV, or R2 resource — `<project>-f<issue>-<slug>-<binding>-<kind>`. */
|
|
71
|
+
resource(binding: string, kind: FeatureResourceKind): string;
|
|
72
|
+
/** A feature's Worker script — `<project>-f<issue>-<slug>-<worker>`. */
|
|
73
|
+
worker(worker: string): string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Everything a project can be asked to name. Created once, from the project, by {@link resourceNames}. */
|
|
77
|
+
export interface ProjectNames {
|
|
78
|
+
/** The kebabbed project name every name below leads with. */
|
|
79
|
+
readonly project: string;
|
|
80
|
+
/** The names in one environment. Throws if the environment is not one this scheme accepts. */
|
|
81
|
+
env(environment: string): ScopedNames;
|
|
82
|
+
/** The names shared by every environment — the `global` scope, without typing the word. */
|
|
83
|
+
readonly global: ScopedNames;
|
|
84
|
+
/** The names a feature environment provisions under. */
|
|
85
|
+
feature(identity: Omit<FeatureIdentity, "project">): FeatureNames;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compose `<project>-<scope>-<thing>` under one namespace's rule.
|
|
90
|
+
*
|
|
91
|
+
* A `truncate` namespace hands off to {@link resourceName} with that namespace's cap, so there is still
|
|
92
|
+
* exactly one truncation rule. A `refuse` namespace composes verbatim and throws with the limit named —
|
|
93
|
+
* as an `InternalError`, because by this point the project and scope have both been validated, so an
|
|
94
|
+
* over-long name means author-controlled code asked for one.
|
|
95
|
+
*
|
|
96
|
+
* **The refuse branch here is deliberately not `../workflow/naming`'s**, though both compose
|
|
97
|
+
* `<project>-<scope>-<thing>` and both refuse rather than truncate. They answer different questions: a
|
|
98
|
+
* Workflow or Worker name is assembled from author-controlled capability and job identifiers, so that
|
|
99
|
+
* module *asserts* each segment against `NAME_SEGMENT` and measures the result in **bytes**, the
|
|
100
|
+
* unit Cloudflare states those two limits in. The kinds routed through here are named after a binding
|
|
101
|
+
* or a secret, which is an adopter's string, so it is kebabbed into shape instead. Merging the two would
|
|
102
|
+
* either start mangling a capability name or stop normalizing a binding.
|
|
103
|
+
*/
|
|
104
|
+
function scopedName(project: string, scope: string, thing: string, limit: NamespaceLimit): string {
|
|
105
|
+
if (limit.policy === "truncate") {
|
|
106
|
+
const name = resourceName({ project, env: scope, thing }, limit.maxLength);
|
|
107
|
+
if (name.length < limit.minLength) {
|
|
108
|
+
throw new InternalError({
|
|
109
|
+
message: `"${name}" is too short for ${limit.label}.`,
|
|
110
|
+
action: `Use a longer project, environment, or resource name — ${limit.label} needs ${limit.minLength} characters.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return name;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const segment = kebab(thing);
|
|
117
|
+
if (!segment) {
|
|
118
|
+
throw new ValidationError({
|
|
119
|
+
message: `A name needs something to name.`,
|
|
120
|
+
detail: `${limit.label} was asked for with an empty segment (raw: ${JSON.stringify(thing)}).`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const name = `${kebab(project)}-${scope}-${segment}`;
|
|
124
|
+
if (name.length > limit.maxLength) {
|
|
125
|
+
throw new InternalError({
|
|
126
|
+
message: `"${name}" is ${name.length} characters. ${limit.label[0]?.toUpperCase()}${limit.label.slice(1)} stops at ${limit.maxLength}.`,
|
|
127
|
+
action: "Shorten the project, environment, or resource name.",
|
|
128
|
+
detail: `${limit.label} is refused rather than truncated — renaming it after a deploy orphans what it addresses.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return name;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The getters for one scope. `global` and every environment share this, so nothing special-cases either. */
|
|
135
|
+
function scopedNames(project: string, scope: string): ScopedNames {
|
|
136
|
+
return {
|
|
137
|
+
worker: (capability) => workflowHostName({ project, capability, env: scope }),
|
|
138
|
+
workflow: (capability, job) => workflowScriptName({ project, capability, job, env: scope }),
|
|
139
|
+
d1: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.d1),
|
|
140
|
+
kv: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.kv),
|
|
141
|
+
r2: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.r2),
|
|
142
|
+
vectorizeIndex: (index) => scopedName(project, scope, index, NAMESPACE_LIMITS.vectorizeIndex),
|
|
143
|
+
secretEntry: (secret) => scopedName(project, scope, secret, NAMESPACE_LIMITS.secretEntry),
|
|
144
|
+
apiToken: (profile) => scopedName(project, scope, profile, NAMESPACE_LIMITS.apiToken),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bind a project to every name it can compose.
|
|
150
|
+
*
|
|
151
|
+
* The project is validated here — charset *and* length, the one project rule — so a project that cannot
|
|
152
|
+
* carry every namespace fails before it has provisioned any of them. That is the whole reason the
|
|
153
|
+
* project is supplied once instead of per call.
|
|
154
|
+
*/
|
|
155
|
+
export function resourceNames(project: string): ProjectNames {
|
|
156
|
+
assertValidProjectName(project);
|
|
157
|
+
const normalized = kebab(project);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
project: normalized,
|
|
161
|
+
env(environment: string): ScopedNames {
|
|
162
|
+
assertValidEnvironment(environment);
|
|
163
|
+
return scopedNames(normalized, environment);
|
|
164
|
+
},
|
|
165
|
+
global: scopedNames(normalized, GLOBAL_SCOPE),
|
|
166
|
+
feature(identity): FeatureNames {
|
|
167
|
+
const full: FeatureIdentity = { ...identity, project: normalized };
|
|
168
|
+
return {
|
|
169
|
+
resource: (binding, kind) => featureResourceName(full, binding, kind),
|
|
170
|
+
worker: (worker) => featureWorkerName(full, worker),
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a single segment of a composed name may look like — one pattern, one home.
|
|
6
|
+
*
|
|
7
|
+
* `<project>-<env>-<thing>` is built out of segments, and every one of them answers to the same
|
|
8
|
+
* shape: a project, an environment, a capability, a job. The pattern lived in three modules,
|
|
9
|
+
* character for character, which is the arrangement where a rule stops being one rule — an edit to
|
|
10
|
+
* the environment copy would silently leave the Workflow copy behind, and the two would disagree
|
|
11
|
+
* about a name the other had already accepted.
|
|
12
|
+
*
|
|
13
|
+
* A capability that genuinely needs a different rule should declare its own pattern beside this one
|
|
14
|
+
* with a comment saying what it allows that this does not, and why. Divergence is a decision, not a
|
|
15
|
+
* stale copy. `scaffoldProject`'s `WORKER_NAME` is the standing example: a Worker *directory* under
|
|
16
|
+
* `apps/` may lead with a digit, because it is a package name rather than a Cloudflare name.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A lowercase letter, then letters, digits, and single inner hyphens — no leading digit, no
|
|
21
|
+
* doubled or trailing hyphen, no underscore, no uppercase.
|
|
22
|
+
*
|
|
23
|
+
* This is the **strictest** rule of any namespace a name reaches, and therefore the rule for all of
|
|
24
|
+
* them. Cloudflare Worker script names, Workflow names, and Vectorize indexes must start with a
|
|
25
|
+
* letter; D1, KV, and R2 would take `1password-clone-dev-db` happily. The loose namespaces are the
|
|
26
|
+
* ones a project touches first, so holding everything to the strict rule is what keeps a name that
|
|
27
|
+
* provisions from being a name that cannot deploy.
|
|
28
|
+
*
|
|
29
|
+
* Not `/g`: `.test()` on a global regex advances `lastIndex`, and one shared instance would answer
|
|
30
|
+
* differently on alternate calls. `segment.test.ts` pins that.
|
|
31
|
+
*/
|
|
32
|
+
export const NAME_SEGMENT = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The reader's contract: a capability's own response object, restated for a client that does not
|
|
9
|
+
* control the Worker that wrote the response.
|
|
10
|
+
*
|
|
11
|
+
* ## One schema, two consumers, two obligations
|
|
12
|
+
*
|
|
13
|
+
* A capability states each projection once, in its `http/responses.ts`, and both consumers validate
|
|
14
|
+
* against it — the Worker's own tests and every management client. That is right for the fields and
|
|
15
|
+
* wrong for the enums, because the two consumers are answering different questions.
|
|
16
|
+
*
|
|
17
|
+
* A **Worker validating its own projection is checking itself.** It must be strict: an enum there is
|
|
18
|
+
* what the ingest path branches on and what D1 holds, and a tolerated-unknown member would license the
|
|
19
|
+
* capability to *store* a value it cannot handle. Nothing here loosens that, and nothing here should:
|
|
20
|
+
* the producer's enums are untouched, and this module never sees them at parse time.
|
|
21
|
+
*
|
|
22
|
+
* A **management client reading that response is crossing a trust boundary.** The system that wrote it
|
|
23
|
+
* is a fork, a bug, a half-finished deploy, or hostile — none of which is downstream of how anybody's
|
|
24
|
+
* releases are cut — and one member it has never heard of currently costs it the whole response rather
|
|
25
|
+
* than the one row that carried it. A support console rendered zero of twenty-five conversations that
|
|
26
|
+
* way (`pithy-sh/dashboard#15`), and the client that fixed it had to widen the kit's shape locally,
|
|
27
|
+
* which is the mirror-of-a-projection that `#113` exists to forbid.
|
|
28
|
+
*
|
|
29
|
+
* So the tolerance is published **beside** the producer's shape rather than inside it. `asRead(X)` is
|
|
30
|
+
* the whole pattern: the same object, with every enum reachable through it read as a string.
|
|
31
|
+
*
|
|
32
|
+
* ## What it is not
|
|
33
|
+
*
|
|
34
|
+
* **It is not a loosening.** A missing field, a number where a string belongs, a value outside a
|
|
35
|
+
* numeric bound, a body that is not an object — all still refuse the whole response, exactly as before.
|
|
36
|
+
* The only thing that changes is which *members* an enum-shaped field admits.
|
|
37
|
+
*
|
|
38
|
+
* **It is not a mapping.** The token is handed back verbatim. The enum stays the authority on what a
|
|
39
|
+
* value means — `SupportChannel.safeParse(value).success` is the question, and the answer is the
|
|
40
|
+
* client's license to *mark* the row. Reading an unrecognized value as the nearest one you know is a
|
|
41
|
+
* lie about a fact somebody is about to act on, and this pattern exists to make marking cheap enough
|
|
42
|
+
* that nobody reaches for mapping.
|
|
43
|
+
*
|
|
44
|
+
* **It is not selective.** A reader's obligation does not vary field by field: it does not control the
|
|
45
|
+
* writer, so *any* unknown member costs it the response. Widening two enums and leaving a third is how
|
|
46
|
+
* the same blank pane returns one field over.
|
|
47
|
+
*
|
|
48
|
+
* ## What it refuses to do
|
|
49
|
+
*
|
|
50
|
+
* It rewrites what it can see through — objects, arrays, `nullable`, `optional` — and returns anything
|
|
51
|
+
* with no enum under it as the **identical schema instance**, so a reader's view and its producer share
|
|
52
|
+
* every untouched field rather than copying it. Everything else throws at construction:
|
|
53
|
+
* {@link asRead} either produces a view with no enum left in it or refuses to produce one, because a
|
|
54
|
+
* shape that silently passed a union through would publish a "reader's contract" that still refuses an
|
|
55
|
+
* unknown member — the original defect, wearing a name that says it was handled.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** What a widened field says about itself, appended to the description the enum already carried. */
|
|
59
|
+
export const TOLERATED_MEMBER =
|
|
60
|
+
"Read as a string rather than as the enum it comes from: a client does not control the Worker that wrote this, so a member the enum does not declare is handed back verbatim instead of refusing the whole response. Ask the enum what a value means, mark what it does not declare, and never map it to the nearest value you know.";
|
|
61
|
+
|
|
62
|
+
/** The internal shape this module reads. Zod's own `def`, under either of the two spellings it has had. */
|
|
63
|
+
interface ZodDef {
|
|
64
|
+
/** The type tag — `object`, `array`, `enum`, `nullable`, and the rest. */
|
|
65
|
+
type?: string;
|
|
66
|
+
/** An object's unknown-key rule, set by `.strict()` and `.loose()` and absent on a plain object. */
|
|
67
|
+
catchall?: z.ZodType;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read a schema's internal def, tolerating both the `_zod.def` and `.def` shapes across Zod builds. */
|
|
71
|
+
function defOf(schema: z.ZodType): ZodDef {
|
|
72
|
+
const internal = schema as unknown as { _zod?: { def?: ZodDef }; def?: ZodDef };
|
|
73
|
+
return internal._zod?.def ?? internal.def ?? {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The type tags that hold no other schema, so nothing inside one can be an enum.
|
|
78
|
+
*
|
|
79
|
+
* **Written out, never derived.** A permitted set computed from what the walk happens to handle would
|
|
80
|
+
* grant an exemption to every kind somebody adds later, silently, which is the failure this roster is
|
|
81
|
+
* here to prevent. A tag not on this list and not rewritten below is refused.
|
|
82
|
+
*
|
|
83
|
+
* `literal` is on it deliberately. A literal is not a vocabulary a capability might extend — it is how
|
|
84
|
+
* a discriminated union tells its arms apart, and widening one would destroy the union rather than
|
|
85
|
+
* tolerate anything.
|
|
86
|
+
*/
|
|
87
|
+
const LEAF_TYPES: ReadonlySet<string> = new Set([
|
|
88
|
+
"string",
|
|
89
|
+
"number",
|
|
90
|
+
"int",
|
|
91
|
+
"bigint",
|
|
92
|
+
"boolean",
|
|
93
|
+
"date",
|
|
94
|
+
"literal",
|
|
95
|
+
"template_literal",
|
|
96
|
+
"file",
|
|
97
|
+
"symbol",
|
|
98
|
+
"null",
|
|
99
|
+
"undefined",
|
|
100
|
+
"void",
|
|
101
|
+
"any",
|
|
102
|
+
"unknown",
|
|
103
|
+
"never",
|
|
104
|
+
"nan",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The refusal, with the offending schema's *path* in it and never a value.
|
|
109
|
+
*
|
|
110
|
+
* Thrown while a module is being evaluated, which is exactly where it belongs: a reader's contract is
|
|
111
|
+
* built once at import, so a capability that cannot state one finds out in its own test run rather
|
|
112
|
+
* than in front of a client.
|
|
113
|
+
*/
|
|
114
|
+
function unreadable(path: string, kind: string): InternalError {
|
|
115
|
+
return new InternalError({
|
|
116
|
+
message: "This projection has no reader's contract.",
|
|
117
|
+
action: `State the reader's view of ${path} by hand, or reshape it so \`asRead\` can see through it.`,
|
|
118
|
+
detail: `asRead met a \`${kind}\` at ${path}. It rewrites objects, arrays, nullable and optional wrappers, and refuses anything else rather than return a view that may still hold an enum a reader cannot survive.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The refusal for an object whose unknown-key rule a rebuild would change. */
|
|
123
|
+
function unrebuildable(path: string): InternalError {
|
|
124
|
+
return new InternalError({
|
|
125
|
+
message: "This projection has no reader's contract.",
|
|
126
|
+
action: `Drop the unknown-key rule on ${path}, or state its reader's view by hand.`,
|
|
127
|
+
detail: `asRead met an object at ${path} that both holds an enum and declares its own unknown-key rule. Rebuilding it would silently change what it refuses, so it refuses to guess.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A rebuilt schema wearing the description the original carried. Nothing is described twice. */
|
|
132
|
+
function describedLike<T extends z.ZodType>(rebuilt: T, original: z.ZodType): T {
|
|
133
|
+
return original.description === undefined ? rebuilt : (rebuilt.describe(original.description) as T);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One node of the rewrite. Returns the identical instance when nothing under it is an enum.
|
|
138
|
+
*
|
|
139
|
+
* The identity is not an optimization. It is what keeps a reader's view from becoming a second opinion
|
|
140
|
+
* about the fields it did not widen: a field it shares with the producer is the *same object*, so a
|
|
141
|
+
* change upstream lands in both, and a client can assert that field by field.
|
|
142
|
+
*/
|
|
143
|
+
function readerOf(schema: z.ZodType, path: string): z.ZodType {
|
|
144
|
+
const { type, catchall } = defOf(schema);
|
|
145
|
+
|
|
146
|
+
if (type === "enum") {
|
|
147
|
+
const said = schema.description;
|
|
148
|
+
return z.string().describe(said === undefined ? TOLERATED_MEMBER : `${said} ${TOLERATED_MEMBER}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (schema instanceof z.ZodNullable || schema instanceof z.ZodOptional) {
|
|
152
|
+
const inner = schema.unwrap() as z.ZodType;
|
|
153
|
+
const read = readerOf(inner, path);
|
|
154
|
+
if (read === inner) return schema;
|
|
155
|
+
return describedLike(schema instanceof z.ZodNullable ? z.nullable(read) : z.optional(read), schema);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (schema instanceof z.ZodArray) {
|
|
159
|
+
const element = schema.element as z.ZodType;
|
|
160
|
+
const read = readerOf(element, `${path}[]`);
|
|
161
|
+
if (read === element) return schema;
|
|
162
|
+
return describedLike(z.array(read), schema);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (schema instanceof z.ZodObject) {
|
|
166
|
+
const shape: Record<string, z.ZodType> = {};
|
|
167
|
+
let widened = false;
|
|
168
|
+
for (const [key, field] of Object.entries(schema.shape)) {
|
|
169
|
+
const original = field as z.ZodType;
|
|
170
|
+
const read = readerOf(original, `${path}.${key}`);
|
|
171
|
+
shape[key] = read;
|
|
172
|
+
if (read !== original) widened = true;
|
|
173
|
+
}
|
|
174
|
+
if (!widened) return schema;
|
|
175
|
+
if (catchall !== undefined) throw unrebuildable(path);
|
|
176
|
+
return describedLike(z.object(shape), schema);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (type !== undefined && LEAF_TYPES.has(type)) return schema;
|
|
180
|
+
throw unreadable(path, type ?? "schema of no stated type");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The producer's schema with every enum under it read as a string.
|
|
185
|
+
*
|
|
186
|
+
* Enum in, string out; a wrapper keeps its own description and the widened member carries the enum's,
|
|
187
|
+
* with {@link TOLERATED_MEMBER} appended so the schema still documents itself. Everything else is the
|
|
188
|
+
* schema the capability declared, by identity.
|
|
189
|
+
*/
|
|
190
|
+
export type AsRead<T> = T extends z.ZodEnum
|
|
191
|
+
? z.ZodString
|
|
192
|
+
: T extends z.ZodNullable<infer Inner>
|
|
193
|
+
? z.ZodNullable<Extract<AsRead<Inner>, z.ZodType>>
|
|
194
|
+
: T extends z.ZodOptional<infer Inner>
|
|
195
|
+
? z.ZodOptional<Extract<AsRead<Inner>, z.ZodType>>
|
|
196
|
+
: T extends z.ZodArray<infer Element>
|
|
197
|
+
? z.ZodArray<Extract<AsRead<Element>, z.ZodType>>
|
|
198
|
+
: T extends z.ZodObject<infer Shape>
|
|
199
|
+
? z.ZodObject<{ [K in keyof Shape]: Extract<AsRead<Shape[K]>, z.ZodType> }>
|
|
200
|
+
: T;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A capability's response object, as a client that does not control the writer must read it.
|
|
204
|
+
*
|
|
205
|
+
* Export the result beside the producer's schema — `XAsRead` beside `X` — so a client imports tolerance
|
|
206
|
+
* rather than building it. Throws if the projection holds a shape this cannot see through; see the
|
|
207
|
+
* module docstring for why that is a refusal rather than a pass-through.
|
|
208
|
+
*/
|
|
209
|
+
export function asRead<T extends z.ZodType>(schema: T): AsRead<T> {
|
|
210
|
+
return readerOf(schema, "response") as unknown as AsRead<T>;
|
|
211
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The gate primitive for a projection that crosses a trust boundary: **nothing left this surface but the
|
|
6
|
+
* facts it publishes.**
|
|
7
|
+
*
|
|
8
|
+
* Every such gate in this kit was first written as its negative — a list of strings that must not appear
|
|
9
|
+
* in the response. A negative list is complete only against the values somebody thought of, and a
|
|
10
|
+
* projection widens by gaining a *field*, which is exactly the event a value list cannot observe. The
|
|
11
|
+
* same argument has been lost here three times over: a tripwire banning `access` while `stat` followed
|
|
12
|
+
* links identically, an escape class that arrived as three different verbs, and a bundle sweep seeded
|
|
13
|
+
* with the credential shapes of the day.
|
|
14
|
+
*
|
|
15
|
+
* So the rule is stated positively and computed rather than enumerated. Two halves, and **both are
|
|
16
|
+
* required, because either alone lets the mistake through**:
|
|
17
|
+
*
|
|
18
|
+
* 1. **Every leaf must be a published fact.** This is what catches a forbidden value arriving under a
|
|
19
|
+
* field name nobody predicted.
|
|
20
|
+
* 2. **Every key must be a published key.** This is what catches a leaf whose *type* has no vocabulary —
|
|
21
|
+
* `true`, `false` and `null` belong to every JSON document, so the first half can never police them,
|
|
22
|
+
* and a new `boolean` field is the cheapest way to widen a projection.
|
|
23
|
+
*
|
|
24
|
+
* ## Two rules for the caller, and both were paid for
|
|
25
|
+
*
|
|
26
|
+
* **Write the permitted key set out by hand.** Deriving it from `Object.keys(Schema.shape)` makes the
|
|
27
|
+
* gate read its own subject: the edit that widens the schema widens the permission, in the same commit,
|
|
28
|
+
* and the test whose entire job is to catch that passes. That happened, on the catalog read.
|
|
29
|
+
*
|
|
30
|
+
* **Seed the source with something forbidden of every JSON type.** A sweep over a document that never
|
|
31
|
+
* held anything secret passes perfectly and proves nothing. The leaf walk below once returned `[]` for
|
|
32
|
+
* every type that was not a string or a number, so booleans and nulls crossed untouched — nothing
|
|
33
|
+
* forbidden happened to be a boolean that day, which is what kept it from being a live leak, not what
|
|
34
|
+
* made it safe.
|
|
35
|
+
*
|
|
36
|
+
* ## Why it throws
|
|
37
|
+
*
|
|
38
|
+
* A value this walk cannot name is a silent exemption for a whole JSON type, granted by a fallthrough.
|
|
39
|
+
* That is the defect above, restated. So {@link unpublishedIn} refuses the document instead: a value the
|
|
40
|
+
* caller hands it must be an array, a plain object, or a JSON leaf, and anything else means a live
|
|
41
|
+
* JavaScript object rather than what a client receives. Round-trip through
|
|
42
|
+
* `JSON.parse(JSON.stringify(...))` first: that is what crosses, and what crosses is what a gate is about.
|
|
43
|
+
*
|
|
44
|
+
* **The rule is what the walk can *see*, not a list of types it distrusts.** `typeof` answers "object"
|
|
45
|
+
* for a `Date`, a `Map`, a `Set` and every class instance, and each of those descends to no keys and no
|
|
46
|
+
* leaves — a `Map`'s entries are not own properties and a getter lives on the prototype — so the descent
|
|
47
|
+
* itself was handing out the same exemption through the one branch that looked safe. So a container is
|
|
48
|
+
* one whose whole contents `Object.entries` returns: prototype `Object.prototype`, `Array.prototype`, or
|
|
49
|
+
* `null`. The first two are what `JSON.parse` produces and the third holds nothing anywhere else to
|
|
50
|
+
* find. Naming the unnameable types instead would be the enumeration this module exists to refuse, and
|
|
51
|
+
* the next class nobody thought of would walk straight through.
|
|
52
|
+
*
|
|
53
|
+
* **And `Object.entries` is then what descends, arrays included.** The rule above was stated once and only
|
|
54
|
+
* half run: the array branch still walked by position, so an own property on a plain array — a prototype
|
|
55
|
+
* this walk accepts, nothing to refuse — was contents the walk could not see. A rule the code disagrees
|
|
56
|
+
* with is a rule in a comment.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** Anything a JSON document holds that is not an object or an array. */
|
|
60
|
+
export type JsonLeaf = string | number | boolean | null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What one surface is allowed to publish: the facts, and the field names they may sit under.
|
|
64
|
+
*
|
|
65
|
+
* `keys` is a literal written at the call site. Never `Object.keys` of the schema being policed — see
|
|
66
|
+
* the note above.
|
|
67
|
+
*/
|
|
68
|
+
export interface PublishedFacts {
|
|
69
|
+
/** Every value this surface may disclose, from wherever it legitimately came. */
|
|
70
|
+
readonly leaves: Iterable<JsonLeaf>;
|
|
71
|
+
/** Every object key this surface may carry, at any depth. Written out by hand. */
|
|
72
|
+
readonly keys: Iterable<string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Everything in `document` that {@link PublishedFacts} does not permit, as lines naming what and where.
|
|
77
|
+
*
|
|
78
|
+
* Empty means the projection published only what it declared. A non-empty result is the message: each
|
|
79
|
+
* line carries the offending key or value and its JSON path, so a failure says which field widened
|
|
80
|
+
* rather than that some string was present.
|
|
81
|
+
*
|
|
82
|
+
* @param document A JSON document — what a client actually receives, not a live object.
|
|
83
|
+
* @param published The facts and keys this surface declares.
|
|
84
|
+
* @throws If `document` holds a value JSON cannot express.
|
|
85
|
+
*/
|
|
86
|
+
export function unpublishedIn(document: unknown, published: PublishedFacts): string[] {
|
|
87
|
+
const leaves = new Set<JsonLeaf>(published.leaves);
|
|
88
|
+
const keys = new Set<string>(published.keys);
|
|
89
|
+
const found: string[] = [];
|
|
90
|
+
walk(document, "", leaves, keys, found);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every leaf in a JSON document, wherever it sits, of every JSON type. Object keys are collected separately.
|
|
96
|
+
*
|
|
97
|
+
* @throws If `document` holds a value JSON cannot express, on the same terms as {@link unpublishedIn} —
|
|
98
|
+
* a caller builds its declaration from this, and a value walked to nothing becomes a permitted set that
|
|
99
|
+
* omits a field.
|
|
100
|
+
*/
|
|
101
|
+
export function leavesIn(document: unknown): JsonLeaf[] {
|
|
102
|
+
return collectLeaves(document, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every object key in a JSON document, wherever it sits.
|
|
107
|
+
*
|
|
108
|
+
* @throws If `document` holds a value JSON cannot express, on the same terms as {@link unpublishedIn}.
|
|
109
|
+
*/
|
|
110
|
+
export function keysIn(document: unknown): string[] {
|
|
111
|
+
return collectKeys(document, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectLeaves(value: unknown, path: string): JsonLeaf[] {
|
|
115
|
+
const inside = descend(value, path);
|
|
116
|
+
if (inside === undefined) return [asLeaf(value, path)];
|
|
117
|
+
return inside.flatMap(({ nested, here }) => collectLeaves(nested, here));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function collectKeys(value: unknown, path: string): string[] {
|
|
121
|
+
const inside = descend(value, path);
|
|
122
|
+
if (inside === undefined) return [];
|
|
123
|
+
return inside.flatMap(({ key, nested, here }) => (key === undefined ? [] : [key]).concat(collectKeys(nested, here)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function walk(
|
|
127
|
+
value: unknown,
|
|
128
|
+
path: string,
|
|
129
|
+
leaves: ReadonlySet<JsonLeaf>,
|
|
130
|
+
keys: ReadonlySet<string>,
|
|
131
|
+
found: string[],
|
|
132
|
+
): void {
|
|
133
|
+
const inside = descend(value, path);
|
|
134
|
+
if (inside === undefined) {
|
|
135
|
+
const leaf = asLeaf(value, path);
|
|
136
|
+
if (!leaves.has(leaf)) found.push(`value ${JSON.stringify(leaf)} at ${where(path)}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const { key, nested, here } of inside) {
|
|
140
|
+
if (key !== undefined && !keys.has(key)) found.push(`key ${JSON.stringify(key)} at ${here}`);
|
|
141
|
+
walk(nested, here, leaves, keys, found);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One step into a container: the object key if it has one, the value there, and the path to say where. */
|
|
146
|
+
interface Step {
|
|
147
|
+
/** The object key, or `undefined` for an array element, which is a position rather than a name. */
|
|
148
|
+
readonly key: string | undefined;
|
|
149
|
+
readonly nested: unknown;
|
|
150
|
+
readonly here: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The contents of a container, or `undefined` for a leaf — and a refusal for anything else.
|
|
155
|
+
*
|
|
156
|
+
* This is the single place the walk decides what it can see, so the gate, {@link leavesIn} and
|
|
157
|
+
* {@link keysIn} all descend on identical terms. A value is a container only if its prototype is one
|
|
158
|
+
* `JSON.parse` produces; a `Date`, a `Map`, a `Set` or a class instance answers `"object"` to `typeof`
|
|
159
|
+
* and yields nothing to `Object.entries`, and treating that as an empty container is the exemption this
|
|
160
|
+
* module exists to refuse.
|
|
161
|
+
*
|
|
162
|
+
* So **`Object.entries` is what descends, arrays included**. An array walked by `value.map` is walked by
|
|
163
|
+
* position, and a position is not the whole of what an array holds: `rows.cursor = "…"` is an own property
|
|
164
|
+
* on a prototype this walk accepts, and the index walk went straight past it. The rule the module states and
|
|
165
|
+
* the code it runs have to be the same rule, or the exemption comes back through whichever branch still
|
|
166
|
+
* disagrees. An index key stays a position — reported as `[0]`, never as a key a caller must permit — and
|
|
167
|
+
* every other own key is a key.
|
|
168
|
+
*/
|
|
169
|
+
function descend(value: unknown, path: string): Step[] | undefined {
|
|
170
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
171
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
172
|
+
if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) refuse(value, path);
|
|
173
|
+
const array = Array.isArray(value);
|
|
174
|
+
return Object.entries(value).map(([key, nested]) =>
|
|
175
|
+
array && isIndex(key)
|
|
176
|
+
? { key: undefined, nested, here: `${path}[${key}]` }
|
|
177
|
+
: { key, nested, here: path === "" ? key : `${path}.${key}` },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether an own key of an array is one of its positions — the canonical decimal form `Object.entries` gives. */
|
|
182
|
+
function isIndex(key: string): boolean {
|
|
183
|
+
return /^(?:0|[1-9]\d*)$/.test(key);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Narrow to a JSON leaf, or refuse the document. A type this walk cannot name is not a type it may skip. */
|
|
187
|
+
function asLeaf(value: unknown, path: string): JsonLeaf {
|
|
188
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
return refuse(value, path);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function refuse(value: unknown, path: string): never {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`A ${nameOf(value)} sits at ${where(path)}. JSON cannot express it, so this is a live object rather than what a client receives — round-trip it through JSON first.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What to call the thing in the refusal — its class where it has one, so the message names what was found. */
|
|
201
|
+
function nameOf(value: unknown): string {
|
|
202
|
+
if (value === null || typeof value !== "object") return typeof value;
|
|
203
|
+
const named: unknown = (value as { constructor?: unknown }).constructor;
|
|
204
|
+
if (typeof named === "function" && named.name !== "") return named.name;
|
|
205
|
+
return "object";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function where(path: string): string {
|
|
209
|
+
return path === "" ? "(root)" : path;
|
|
210
|
+
}
|