@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,199 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MiddlewareHandler } from "hono";
|
|
5
|
+
import type { PithyHonoEnv } from "../capability/capability";
|
|
6
|
+
import { InternalError, messageOf, PithyError, UnauthorizedError } from "../error/pithyError";
|
|
7
|
+
import {
|
|
8
|
+
ENTITLEMENT_DENIED_ACTION,
|
|
9
|
+
type EntitlementHolder,
|
|
10
|
+
EntitlementKey,
|
|
11
|
+
grantedEntitlementKeys,
|
|
12
|
+
} from "./entitlement";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The entitlement gate — the middleware half of the seam. `requireEntitlement("pro")` is the
|
|
16
|
+
* `verification`-strategy sibling every paid route carries: it says what the caller must *hold*, the
|
|
17
|
+
* way `requireAuth()` says who the caller must *be*, and it belongs on the route line beside it.
|
|
18
|
+
*
|
|
19
|
+
* app.get("/reports", requireAuth(), requireEntitlement("pro"), zValidator(…), handler)
|
|
20
|
+
*
|
|
21
|
+
* It lives in `@pithy-sh/core` rather than in `@pithy-sh/payments` for the same reason `requireAuth()`
|
|
22
|
+
* is copied into each capability instead of imported from `@pithy-sh/auth`: a gate that arrives with a
|
|
23
|
+
* package fails **open** when that package is absent. Depending on the core seam means an uncomposed
|
|
24
|
+
* provider leaves `noEntitlementProvider` on the request, and every gate denies.
|
|
25
|
+
*
|
|
26
|
+
* Two names, not one variadic, because "any of these" and "all of these" are both plausible readings
|
|
27
|
+
* of `requireEntitlement("pro", "team")` and the wrong reading is a security bug rather than a typo.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Why a gate denied — recorded in `detail` and in the audit event, never sent to the client.
|
|
32
|
+
*
|
|
33
|
+
* **Three readings, and keeping them apart is the whole job.** Nothing composed is one. A holder that has
|
|
34
|
+
* bought nothing is the second. A caller acting for **no** holder at all is the third — the ordinary state
|
|
35
|
+
* of somebody signed in with no organization selected, and before #412 it was spelled `payments resolved
|
|
36
|
+
* []`, identically to the second. They want opposite fixes: one is a sale, the other is a subject resolver
|
|
37
|
+
* returning nothing, and an operator reading one sentence had no way to tell which they were looking at.
|
|
38
|
+
*
|
|
39
|
+
* The holder is named only when the resolver reports one, and it is named as {@link EntitlementHolder}'s
|
|
40
|
+
* `label` — display text, never a value this gate compares. A resolver that cannot say omits it, and the
|
|
41
|
+
* sentence is exactly what it was.
|
|
42
|
+
*/
|
|
43
|
+
function denialDetail(
|
|
44
|
+
keys: readonly string[],
|
|
45
|
+
provider: string | null,
|
|
46
|
+
held: ReadonlySet<string>,
|
|
47
|
+
reading: HolderReading,
|
|
48
|
+
): string {
|
|
49
|
+
const required = keys.join(" | ");
|
|
50
|
+
if (provider === null) {
|
|
51
|
+
return `no entitlement provider is composed, so \`${required}\` cannot be held. Add a payments capability to this Worker's pithy.config.ts.`;
|
|
52
|
+
}
|
|
53
|
+
switch (reading.kind) {
|
|
54
|
+
// The resolver could have answered and said nobody. Distinct from `failed` below, and the distinction is
|
|
55
|
+
// the point: this is a caller with no organization selected, which is a wiring or session question, not
|
|
56
|
+
// a broken provider.
|
|
57
|
+
case "nobody":
|
|
58
|
+
return `${provider} resolved no holder for this caller, so nothing can be held; the route requires \`${required}\`. Under organization billing this is a caller acting for no organization, not an unentitled one.`;
|
|
59
|
+
// It tried and raised. Saying "no holder" here would send an operator to an unwired resolver when the
|
|
60
|
+
// resolver is wired and erroring — the opposite diagnosis, and the exact ambiguity this sentence set
|
|
61
|
+
// exists to remove. The cause is already on the log line beside this.
|
|
62
|
+
case "failed":
|
|
63
|
+
return `${provider} could not resolve a holder for this caller — the read raised, see the preceding log line; the route requires \`${required}\`.`;
|
|
64
|
+
case "known":
|
|
65
|
+
return `${provider} resolved [${[...held].sort().join(", ")}] for ${reading.holder.label}; the route requires \`${required}\`.`;
|
|
66
|
+
// A resolver that never offered a holder at all — every one written before this existed. Reads exactly
|
|
67
|
+
// as it always did, which is what keeps the three sentences above meaning something when they appear.
|
|
68
|
+
default:
|
|
69
|
+
return `${provider} resolved [${[...held].sort().join(", ")}]; the route requires \`${required}\`.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The gate's denial: one code, one status, whatever the reason. The reason rides in `detail`. */
|
|
74
|
+
function denied(
|
|
75
|
+
keys: readonly string[],
|
|
76
|
+
provider: string | null,
|
|
77
|
+
held: ReadonlySet<string>,
|
|
78
|
+
reading: HolderReading,
|
|
79
|
+
): PithyError {
|
|
80
|
+
return new PithyError({
|
|
81
|
+
code: "payments/entitlement_required",
|
|
82
|
+
status: 403,
|
|
83
|
+
message: "This feature requires an active subscription or purchase.",
|
|
84
|
+
action: "Purchase or restore the product that grants access, then retry.",
|
|
85
|
+
detail: denialDetail(keys, provider, held, reading),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What asking the resolver who it answered for actually produced. Four states, because collapsing any two
|
|
91
|
+
* of them gives an operator the wrong diagnosis:
|
|
92
|
+
*
|
|
93
|
+
* - `unavailable` — the resolver offers no `holder()`. Nothing to report, and nothing went wrong.
|
|
94
|
+
* - `nobody` — it answered, and the answer is that this caller acts for no holder.
|
|
95
|
+
* - `known` — it answered with one.
|
|
96
|
+
* - `failed` — it raised. **Not `nobody`.** A caller with no organization selected and a session store that
|
|
97
|
+
* is down look identical from the gate, and they are opposite problems: one is wiring, one is an outage.
|
|
98
|
+
*/
|
|
99
|
+
type HolderReading =
|
|
100
|
+
| { readonly kind: "unavailable" }
|
|
101
|
+
| { readonly kind: "nobody" }
|
|
102
|
+
| { readonly kind: "known"; readonly holder: EntitlementHolder }
|
|
103
|
+
| { readonly kind: "failed" };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Require any one of `keys`. Every key is parsed at construction, so a store SKU where an entitlement
|
|
107
|
+
* key belongs (`com.acme.pro.monthly`) fails on deploy rather than gating on something no projection
|
|
108
|
+
* ever writes. An empty list is refused for the same reason: it reads as "nothing required" but would
|
|
109
|
+
* deny every caller forever.
|
|
110
|
+
*/
|
|
111
|
+
export function requireAnyEntitlement(keys: readonly string[]): MiddlewareHandler<PithyHonoEnv> {
|
|
112
|
+
if (keys.length === 0) {
|
|
113
|
+
throw new InternalError({
|
|
114
|
+
message: "requireAnyEntitlement() needs at least one entitlement key.",
|
|
115
|
+
action: "Name the entitlements that grant access, or drop the gate if the route is not paid.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const required = keys.map((key) => EntitlementKey.parse(key));
|
|
119
|
+
|
|
120
|
+
return async (c, next) => {
|
|
121
|
+
// A gate is not an identity check: an anonymous caller is 401, so a 403 never confirms to an
|
|
122
|
+
// unauthenticated stranger that the route exists and is paid.
|
|
123
|
+
const auth = c.var.auth;
|
|
124
|
+
if (!auth) {
|
|
125
|
+
throw new UnauthorizedError({
|
|
126
|
+
message: "Authentication required.",
|
|
127
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resolver = c.var.entitlements;
|
|
132
|
+
// A provider whose store is unreachable must not become an open door, so a throw is a denial.
|
|
133
|
+
// The cause goes to the log, where an operator sees the difference between "unentitled" and
|
|
134
|
+
// "broken" — the response cannot, without telling a client about our infrastructure.
|
|
135
|
+
let held: Set<string>;
|
|
136
|
+
try {
|
|
137
|
+
held = grantedEntitlementKeys(await resolver.list(), new Date());
|
|
138
|
+
} catch (error) {
|
|
139
|
+
c.var.log.error("entitlement resolution failed", { provider: resolver.provider, cause: messageOf(error) });
|
|
140
|
+
held = new Set();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// **Read after the decision, never before it.** Everything below this line is reporting: the gate has
|
|
144
|
+
// already passed or denied on `held` alone, so nothing the holder says can change the outcome. A holder
|
|
145
|
+
// read above the check would be a holder in reach of the check.
|
|
146
|
+
if (required.some((key) => held.has(key))) {
|
|
147
|
+
await next();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Who the resolver answered for — see {@link HolderReading} for why a throw is its own state rather
|
|
152
|
+
// than folding into "nobody".
|
|
153
|
+
let reading: HolderReading = { kind: "unavailable" };
|
|
154
|
+
if (resolver.holder) {
|
|
155
|
+
try {
|
|
156
|
+
const answered = await resolver.holder();
|
|
157
|
+
reading = answered === undefined ? { kind: "nobody" } : { kind: "known", holder: answered };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Same rule the list read follows one step up: a provider that cannot answer is a denial, never an
|
|
160
|
+
// open door and never a 500. This one is strictly weaker — it costs a log line, not a decision.
|
|
161
|
+
reading = { kind: "failed" };
|
|
162
|
+
c.var.log.error("entitlement holder resolution failed", {
|
|
163
|
+
provider: resolver.provider,
|
|
164
|
+
cause: messageOf(error),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A blocked attempt is a first-class audit record (`outcome: "denied"`), not only a 403. `emit()`
|
|
170
|
+
// is non-fatal by contract and a no-op with no audit capability composed, so this is always safe.
|
|
171
|
+
//
|
|
172
|
+
// `tenant` is what makes the trail readable per customer — "which of our companies is hitting the
|
|
173
|
+
// paywall" is the question, and `actorId` cannot answer it because one person acts in two tenants. It
|
|
174
|
+
// is the value the **resolver reported**, never one derived here from `auth`: this middleware has no
|
|
175
|
+
// idea whose action it was, and guessing would put the wrong customer on a row somebody bills from.
|
|
176
|
+
// `null` is a real answer meaning *not tenant-scoped*, which is what per-person billing is.
|
|
177
|
+
await c.var.emit({
|
|
178
|
+
action: ENTITLEMENT_DENIED_ACTION,
|
|
179
|
+
outcome: "denied",
|
|
180
|
+
severity: "info",
|
|
181
|
+
actorType: "user",
|
|
182
|
+
actorId: auth.userId,
|
|
183
|
+
sessionId: auth.sessionId,
|
|
184
|
+
resourceType: "entitlement",
|
|
185
|
+
resourceId: required.join("|"),
|
|
186
|
+
// Only a holder we actually have. A failed read stamps null — "not tenant-scoped" is wrong but
|
|
187
|
+
// inventing a tenant from a read that raised would be worse, and the log line carries the truth.
|
|
188
|
+
tenant: reading.kind === "known" ? reading.holder.tenant : null,
|
|
189
|
+
metadata: { provider: resolver.provider, required, held: [...held].sort() },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
throw denied(required, resolver.provider, held, reading);
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Require one entitlement. The common case: one key gates one feature. */
|
|
197
|
+
export function requireEntitlement(key: string): MiddlewareHandler<PithyHonoEnv> {
|
|
198
|
+
return requireAnyEntitlement([key]);
|
|
199
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ENVIRONMENT_VAR } from "../worker/identity";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the *ambient* environment says about the composition being assembled — the two questions a
|
|
8
|
+
* capability may ask before it registers anything, and the one reader for both.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a capability can ask this at all
|
|
11
|
+
*
|
|
12
|
+
* A Worker is handed its bindings per request, so `c.env` does not exist when `createBackend` calls a
|
|
13
|
+
* capability's `routes()`. `process.env` does: with `nodejs_compat` and a compatibility date past
|
|
14
|
+
* 2025-04-01 — every Worker `pithy init` scaffolds — workerd populates `process.env` from the script's
|
|
15
|
+
* own `vars` and secrets, and it is populated at module scope, before the first request. Verified
|
|
16
|
+
* against a real `wrangler dev`: at module scope `Object.keys(process.env)` is exactly the declared
|
|
17
|
+
* vars.
|
|
18
|
+
*
|
|
19
|
+
* That is the whole reach of this module, and the limit is the interesting half:
|
|
20
|
+
*
|
|
21
|
+
* - **`ENVIRONMENT` is here**, because `pithy init` stamps it into every environment stanza of every
|
|
22
|
+
* Worker's `wrangler.jsonc` ({@link ENVIRONMENT_VAR}). A composition can therefore know it is `dev`
|
|
23
|
+
* without being told.
|
|
24
|
+
* - **The host's environment is not here.** `CI=true` in the shell that ran `wrangler dev` does *not*
|
|
25
|
+
* appear inside the Worker — workerd replaces `process.env` with the bindings rather than merging.
|
|
26
|
+
* {@link ./ci.ts} carries that consequence and what is done about it.
|
|
27
|
+
*
|
|
28
|
+
* Read at call time, never captured at import: a module-scope snapshot is unstubbable in a test and
|
|
29
|
+
* would freeze the answer for the life of an isolate that outlives a `.dev.vars` edit.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A process environment as this module reads one — the shape of `process.env`, and nothing assumed of it. */
|
|
33
|
+
export type AmbientEnv = Readonly<Record<string, string | undefined>>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The ambient environment, or an empty one where there is no `process` at all.
|
|
37
|
+
*
|
|
38
|
+
* Guarded rather than assumed: this module is bundled into Workers, and a runtime without
|
|
39
|
+
* `nodejs_compat` has no `process`. An empty environment answers "nothing was stamped", which is the
|
|
40
|
+
* refusing answer everywhere it is used — a missing `process` must never read as `dev`.
|
|
41
|
+
*/
|
|
42
|
+
export function ambientEnv(): AmbientEnv {
|
|
43
|
+
return (globalThis as { process?: { env?: AmbientEnv } }).process?.env ?? {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether an environment variable counts as **set**: any non-blank value, blank is no override.
|
|
48
|
+
*
|
|
49
|
+
* One rule, stated once. It is `PITHY_OFFLINE`'s (#218), and the reason it is not "`=== "true"`" is
|
|
50
|
+
* that runners disagree — `true`, `1`, and an operator's own word all mean the same thing, and a
|
|
51
|
+
* variable someone deliberately blanked (`CI=`) is the one case that means "no".
|
|
52
|
+
*/
|
|
53
|
+
export function ambientFlag(env: AmbientEnv, name: string): boolean {
|
|
54
|
+
return (env[name] ?? "").trim().length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The environment this composition was stamped for — `dev`, `staging`, `prod`, or an adopter's own —
|
|
59
|
+
* and `undefined` where nothing stamped one.
|
|
60
|
+
*
|
|
61
|
+
* **`undefined`, never a defaulted `dev`.** The callers are gates, and a gate that assumes `dev` when
|
|
62
|
+
* it was told nothing opens itself in exactly the deployment whose `wrangler.jsonc` lost the var.
|
|
63
|
+
*/
|
|
64
|
+
export function compositionEnvironment(env: AmbientEnv = ambientEnv()): string | undefined {
|
|
65
|
+
const value = (env[ENVIRONMENT_VAR] ?? "").trim();
|
|
66
|
+
return value.length > 0 ? value : undefined;
|
|
67
|
+
}
|
package/src/env/ci.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type AmbientEnv, ambientEnv, ambientFlag } from "./ambient";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Is this continuous integration? One read, one rule, one place — the convention starts here.
|
|
8
|
+
*
|
|
9
|
+
* Nothing in this repository read `CI` before #243, so this is the declaration rather than a fourth
|
|
10
|
+
* copy of one. It exists because a gate that inlines `!!process.env.CI` at the point it guards is a
|
|
11
|
+
* gate whose meaning is re-decided by whoever writes the next one.
|
|
12
|
+
*
|
|
13
|
+
* ## What counts as set
|
|
14
|
+
*
|
|
15
|
+
* **Any non-blank value.** `CI=true`, `CI=1`, and `CI=woodpecker` are all CI; `CI=` (set but empty,
|
|
16
|
+
* which some runners do) and an absent `CI` are not. That is `PITHY_OFFLINE`'s rule (#218) applied
|
|
17
|
+
* unchanged, rather than a second rule for a second variable — see {@link ambientFlag}.
|
|
18
|
+
*
|
|
19
|
+
* ## Why anything reads it
|
|
20
|
+
*
|
|
21
|
+
* CI is where an unsupervised capability is most reachable and least watched. A `dev` composition is
|
|
22
|
+
* exactly what CI boots — integration suites, packaging checks, `pithy dev` itself — so "not `dev`"
|
|
23
|
+
* does not cover CI, and CI is not production, so neither signal implies the other. Anything gated on
|
|
24
|
+
* both asks twice, in two statements: an `||` folded into one expression is one edit from an `&&`, and
|
|
25
|
+
* the failure mode is a session-minting endpoint that answers.
|
|
26
|
+
*
|
|
27
|
+
* ## Where it can be read, and where it cannot
|
|
28
|
+
*
|
|
29
|
+
* In the CLI, and in anything else running under Node, this is the host's own `CI`. **Inside a Worker
|
|
30
|
+
* it is not**: workerd populates `process.env` from the script's bindings and nothing else, so the
|
|
31
|
+
* shell variable does not cross that boundary (see {@link ./ambient.ts}). A Worker-side gate is
|
|
32
|
+
* therefore only as truthful as what started the Worker — which is why `pithy dev` forwards `CI` into
|
|
33
|
+
* every Worker it launches as a var, and why a gate that depends on this must also carry a second,
|
|
34
|
+
* independent refusal that needs no forwarding.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The variable every CI runner sets without configuration. The name is not ours to choose. */
|
|
38
|
+
export const CI_ENV = "CI";
|
|
39
|
+
|
|
40
|
+
/** Whether this process is running under continuous integration. Any non-blank `CI`; blank is not CI. */
|
|
41
|
+
export function isContinuousIntegration(env: AmbientEnv = ambientEnv()): boolean {
|
|
42
|
+
return ambientFlag(env, CI_ENV);
|
|
43
|
+
}
|
package/src/env/stem.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a member of the dev set publishes its address, and how a sibling looks it up.
|
|
6
|
+
*
|
|
7
|
+
* `pithy dev` pins one port per worker at feature-create and exports `<STEM>_PORT` / `<STEM>_ORIGIN`
|
|
8
|
+
* for every one of them, so a worker reaches its peers at a known address rather than through
|
|
9
|
+
* wrangler's cross-`wrangler dev` service registry (CLAUDE.md §CLI). The stem is the join: the CLI
|
|
10
|
+
* writes the var, and code running *inside* the Worker reads it.
|
|
11
|
+
*
|
|
12
|
+
* **It lives in core because both ends are now real.** It was the CLI's private helper while only the
|
|
13
|
+
* CLI used it; the loopback Workflow dispatcher ({@link ../workflow/loopback.ts}) made the runtime the
|
|
14
|
+
* other end of the same wire, and two copies of a name-derivation rule are two answers the first day
|
|
15
|
+
* one of them learns about a character the other does not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The env-var stem for a worker or capability: uppercased, every non-alphanumeric run collapsed to a
|
|
20
|
+
* single `_`, leading and trailing separators dropped. `media-cli` → `MEDIA_CLI`.
|
|
21
|
+
*
|
|
22
|
+
* Deterministic, so the name a worker publishes is the name every sibling looks up.
|
|
23
|
+
*/
|
|
24
|
+
export function envStem(name: string): string {
|
|
25
|
+
return name
|
|
26
|
+
.toUpperCase()
|
|
27
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
28
|
+
.replace(/^_+|_+$/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The var carrying one member's loopback address — `email` → `EMAIL_ORIGIN`. */
|
|
32
|
+
export function originVarName(name: string): string {
|
|
33
|
+
return `${envStem(name)}_ORIGIN`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reading a thrown value that no type describes — the one place the kit records **what a runtime does to
|
|
6
|
+
* an error on its way out** (#223), and **the one place that decides whether what it says may be shown**
|
|
7
|
+
* (#228).
|
|
8
|
+
*
|
|
9
|
+
* Nothing here classifies anything. Classification is policy and belongs to the surface that refuses:
|
|
10
|
+
* which causes a loader recognizes, and what each refusal says, differ per surface and are meant to.
|
|
11
|
+
* This module holds only what does not — the facts about the shapes a runtime hands over, and the filter
|
|
12
|
+
* that decides whether one of its strings is safe to put in front of an adopter.
|
|
13
|
+
*
|
|
14
|
+
* **The filter is here for the same reason the facts are, and the argument took two issues to finish.**
|
|
15
|
+
* #223 moved `rootCause`, `prop` and `isBuildFailureWrapper` in and deliberately left `safeReason` beside
|
|
16
|
+
* the three refusals, on the reading that what a surface may say is that surface's business. That is true
|
|
17
|
+
* of the sentences and false of the filter: whether a string carries a path, a stack frame or half a
|
|
18
|
+
* parser's ANSI box is a property of the string, and the answer cannot differ between the CLI, a
|
|
19
|
+
* capability loader and the vite plugin without one of them being wrong. It was three near-verbatim
|
|
20
|
+
* copies, and the hole #223 found in it had to be closed three times (#228).
|
|
21
|
+
*
|
|
22
|
+
* `@pithy-sh/vite` depends on this package and on nothing else in the kit. That constraint is what made
|
|
23
|
+
* core the right home for `rootCause`; it applies to the filter identically.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read a property off an unknown throwable without widening anything to `any`.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately duck-typed. Bun's `ResolveMessage` and `BuildMessage` — the two shapes the CLI's `bin` and
|
|
30
|
+
* the vite plugin actually catch, since both run on Bun — are their own classes and are **not**
|
|
31
|
+
* `instanceof Error`. An `instanceof` gate here passes a whole suite under vitest, which runs on Node,
|
|
32
|
+
* and silently drops the parser's own sentence on the runtime that ships. That is #207, and it is why
|
|
33
|
+
* every reader of a cause in this kit goes through this function.
|
|
34
|
+
*
|
|
35
|
+
* Non-enumerable properties are read too — `AggregateError.errors` is one, and {@link rootCause}
|
|
36
|
+
* depends on it.
|
|
37
|
+
*/
|
|
38
|
+
export function prop(cause: unknown, key: string): unknown {
|
|
39
|
+
if (typeof cause !== "object" || cause === null) return undefined;
|
|
40
|
+
return (cause as Record<string, unknown>)[key];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The diagnostic **inside** Bun's `AggregateError` wrapper — the thing a classifier must actually look at.
|
|
45
|
+
*
|
|
46
|
+
* `import()` on Bun hands one build diagnostic over bare and **two or more inside an `AggregateError`**,
|
|
47
|
+
* whose own `message` is `N errors building "<absolute path>"` and whose `Object.keys` is empty. Node
|
|
48
|
+
* throws the diagnostic directly, so no Node-shaped fixture can show this: it is found by running on Bun,
|
|
49
|
+
* and only by running on Bun with a failure of the *realistic* shape.
|
|
50
|
+
*
|
|
51
|
+
* **And the realistic shape is the wrapped one.** A stray brace cascades — one missing `}` in a
|
|
52
|
+
* `pithy.config.ts` produced four diagnostics on Bun 1.3.14 — so a syntax error arrives wrapped far more
|
|
53
|
+
* often than bare. Classified as-is, the wrapper matches nothing: not a resolution failure, not a parse
|
|
54
|
+
* error, just something with a message. The refusal then says *the config threw while loading, run the
|
|
55
|
+
* file directly* while holding the line and column in its hand. #207 fixed the bare case and left this
|
|
56
|
+
* one; #217 hit it in the capability loaders; #223 is it a third time, which is why it lives here.
|
|
57
|
+
*
|
|
58
|
+
* The **first** diagnostic only. The rest are the cascade, not the fault, and naming four positions
|
|
59
|
+
* buries the one that matters.
|
|
60
|
+
*
|
|
61
|
+
* Bounded to four levels: a wrapper holding a wrapper is not a shape any runtime produces today, and an
|
|
62
|
+
* unbounded walk over an adopter's own `errors` field is not something this should be talked into.
|
|
63
|
+
* Anything else — a bare diagnostic, an empty `errors`, a non-array `errors` — is returned untouched, so
|
|
64
|
+
* a runtime that stops wrapping needs no change here.
|
|
65
|
+
*/
|
|
66
|
+
export function rootCause(cause: unknown): unknown {
|
|
67
|
+
let current = cause;
|
|
68
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
69
|
+
const errors = prop(current, "errors");
|
|
70
|
+
if (!Array.isArray(errors) || errors.length === 0 || errors[0] === undefined) return current;
|
|
71
|
+
current = errors[0];
|
|
72
|
+
}
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Bun's wrapper announces itself: `4 errors building "<absolute path>"`, and nothing else says that. */
|
|
77
|
+
const BUILD_FAILURE = /^\d+ errors? building "/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The wrapper **after the diagnostics are gone** — a build that failed, and that is all that is left.
|
|
81
|
+
*
|
|
82
|
+
* {@link rootCause} handles the first `import()`. It cannot handle the second: Bun caches a failed module
|
|
83
|
+
* and re-throws an `AggregateError` with **no `errors` at all**, just the count and the path. There is
|
|
84
|
+
* nothing to unwrap, so a classifier that only unwraps reads a syntax error as "it threw" from the second
|
|
85
|
+
* caller onward — which is what `pithy doctor` shows an adopter, because resolving the project's account
|
|
86
|
+
* loads the root config before the report does (#206, #223).
|
|
87
|
+
*
|
|
88
|
+
* Nothing else Bun throws degrades this way: a `ResolveMessage`, a bare `BuildMessage` and a module's own
|
|
89
|
+
* `Error` are byte-identical on every import. Only the wrapper forgets.
|
|
90
|
+
*
|
|
91
|
+
* So the wrapper is recognized by its own message. **What it proves is narrow and worth stating:** the
|
|
92
|
+
* module reached the builder and the builder produced diagnostics. It did not fail to resolve — Bun
|
|
93
|
+
* throws the first `ResolveMessage` bare, wrapping nothing, even for a file with several bad imports —
|
|
94
|
+
* and it did not run, so it cannot have thrown. It failed to parse or to build. A caller may say that
|
|
95
|
+
* much and no more: the message itself carries an absolute path and a count, neither of which is the
|
|
96
|
+
* adopter's business, so it is a *predicate* here rather than a string anyone is tempted to print.
|
|
97
|
+
*/
|
|
98
|
+
export function isBuildFailureWrapper(cause: unknown): boolean {
|
|
99
|
+
const message = prop(cause, "message");
|
|
100
|
+
return typeof message === "string" && BUILD_FAILURE.test(message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Escape sequences a runtime colors its diagnostics with. They are formatting, and they never travel. */
|
|
104
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping the control characters is the point.
|
|
105
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
106
|
+
|
|
107
|
+
/** Anything that looks like the start of an absolute path — POSIX, `~`, or a Windows drive. */
|
|
108
|
+
const ABSOLUTE_PATH = /(^|[\s'"(])(\/|~\/|[A-Za-z]:[\\/])/;
|
|
109
|
+
|
|
110
|
+
/** A stack frame, in the one spelling every runtime here agrees on. */
|
|
111
|
+
const STACK_FRAME = /\bat \S+:\d+:\d+/;
|
|
112
|
+
|
|
113
|
+
/** The longest a reason may be before it has stopped being a sentence and started being a diagnostic. */
|
|
114
|
+
const REASON_LIMIT = 160;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The thrown value's message, **de-colored** — `undefined` when it has none.
|
|
118
|
+
*
|
|
119
|
+
* Duck-typed for {@link prop}'s reason. The de-coloring is here rather than at each caller because an
|
|
120
|
+
* escape sequence is formatting the runtime added, not something a surface has an opinion about — and
|
|
121
|
+
* because the three copies of this had already drifted: two stripped with `\u001b`, the third with a
|
|
122
|
+
* literal escape character sitting in its source where no reviewer would see it (#228).
|
|
123
|
+
*
|
|
124
|
+
* This says what the cause *says*. Whether any of it may be shown is {@link safeReason}.
|
|
125
|
+
*/
|
|
126
|
+
export function causeMessage(cause: unknown): string | undefined {
|
|
127
|
+
const message = prop(cause, "message");
|
|
128
|
+
return typeof message === "string" ? message.replace(ANSI, "") : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The cause's own message, but **only when the whole of it is one safe sentence** — the kit's single
|
|
133
|
+
* decision about whether a runtime's string may reach an adopter (#228).
|
|
134
|
+
*
|
|
135
|
+
* A parser's reason (`Expected identifier but found "{"`) is a sentence an adopter acts on. The
|
|
136
|
+
* diagnostic a bundler wraps the same fault in is a multi-line ANSI box quoting an absolute path and the
|
|
137
|
+
* source line, which is throw-site context wearing a message's clothes. What is dropped here still
|
|
138
|
+
* reaches `detail`, which the CLI renderer never prints and the HTTP codec strips — so a refusal costs a
|
|
139
|
+
* developer one frame of digging, and a mistake puts a path, a source line or a stack frame in a field
|
|
140
|
+
* that is rendered.
|
|
141
|
+
*
|
|
142
|
+
* **Provenance first, then content.** Bun's build-failure wrapper is refused outright, before a single
|
|
143
|
+
* content test runs. The content tests exist for a diagnostic that *might* be safe; the wrapper never is,
|
|
144
|
+
* because its whole message is a count nobody asked for and a path that must not travel. Leaving it to
|
|
145
|
+
* the content tests is what leaked: `2 errors building "app/config:12:5.ts"` has no leading slash, sailed
|
|
146
|
+
* through the absolute-path check, and produced *The config does not parse: 2 errors building
|
|
147
|
+
* "app/config:12:5.ts". Line 12, column 5.* — a path the adopter never wrote, and a position fabricated
|
|
148
|
+
* out of a file name (#223). That suppression was written three times because the filter was. It is
|
|
149
|
+
* written once here, and {@link failurePosition} refuses the same shape for the same reason.
|
|
150
|
+
*
|
|
151
|
+
* A trailing period comes off: every caller puts this inside a sentence of its own.
|
|
152
|
+
*/
|
|
153
|
+
export function safeReason(cause: unknown): string | undefined {
|
|
154
|
+
if (isBuildFailureWrapper(cause)) return undefined;
|
|
155
|
+
const text = (causeMessage(cause) ?? "").trim();
|
|
156
|
+
if (text.length === 0 || text.length > REASON_LIMIT) return undefined;
|
|
157
|
+
if (text.includes("\n")) return undefined;
|
|
158
|
+
if (ABSOLUTE_PATH.test(text)) return undefined;
|
|
159
|
+
if (STACK_FRAME.test(text)) return undefined;
|
|
160
|
+
return text.replace(/\.$/, "");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Where a runtime says a failure was. Two numbers, and never the file they were quoted beside. */
|
|
164
|
+
export interface FailurePosition {
|
|
165
|
+
/** 1-based line, as the runtime reported it. */
|
|
166
|
+
line: number;
|
|
167
|
+
/** Column, as the runtime reported it. */
|
|
168
|
+
column: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `line`/`column`, from the structured position where a runtime gives one, else from its own `…:LINE:COL`.
|
|
173
|
+
*
|
|
174
|
+
* A recorded fact about two runtimes: Bun's `BuildMessage` carries `position`, and a transform that has no
|
|
175
|
+
* such field states the same thing in its message tail. Only the two numbers are lifted out — never the
|
|
176
|
+
* path they are appended to. Whether a surface prints them is that surface's policy: two of the three
|
|
177
|
+
* classifiers do, and the capability loader deliberately does not.
|
|
178
|
+
*
|
|
179
|
+
* Refuses Bun's build-failure wrapper on provenance, exactly as {@link safeReason} does. That message
|
|
180
|
+
* holds a file name and a count and nothing about where anything failed, so a `:12:5` found in it is a
|
|
181
|
+
* coincidence of the path's own characters — and a fabricated position reads as authoritative.
|
|
182
|
+
*/
|
|
183
|
+
export function failurePosition(cause: unknown): FailurePosition | undefined {
|
|
184
|
+
if (isBuildFailureWrapper(cause)) return undefined;
|
|
185
|
+
const position = prop(cause, "position");
|
|
186
|
+
const line = prop(position, "line");
|
|
187
|
+
const column = prop(position, "column");
|
|
188
|
+
if (typeof line === "number" && line > 0 && typeof column === "number") return { line, column };
|
|
189
|
+
const match = /:(\d+):(\d+)/.exec(causeMessage(cause) ?? "");
|
|
190
|
+
if (!match?.[1] || !match[2]) return undefined;
|
|
191
|
+
return { line: Number(match[1]), column: Number(match[2]) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The specifier that did not resolve.
|
|
196
|
+
*
|
|
197
|
+
* A recorded fact about two runtimes: Bun's `ResolveMessage` carries it as a **field**, and Node states it
|
|
198
|
+
* in prose. The field is preferred wherever there is one, because the prose around it names the referrer's
|
|
199
|
+
* absolute path — the specifier is the adopter's own import, the referrer is our frame.
|
|
200
|
+
*
|
|
201
|
+
* Whether a surface names it, and what it advises when it does, is that surface's policy and stays there.
|
|
202
|
+
*/
|
|
203
|
+
export function unresolvedSpecifier(cause: unknown): string | undefined {
|
|
204
|
+
const field = prop(cause, "specifier");
|
|
205
|
+
if (typeof field === "string" && field.length > 0) return field;
|
|
206
|
+
const match = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(causeMessage(cause) ?? "");
|
|
207
|
+
return match?.[1];
|
|
208
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import type { ErrorPayload } from "./payload";
|
|
6
|
+
import { PublicErrorPayload } from "./payload";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **The one place an error becomes bytes for a client.** Whatever the transport.
|
|
10
|
+
*
|
|
11
|
+
* An error leaves this process by more than one road. A JSON response is the busiest, and it had the
|
|
12
|
+
* boundary written into it — but a Durable Object also pushes error frames down a WebSocket, and a
|
|
13
|
+
* transport that grew its own projection grew its own idea of what a client may read. That is how
|
|
14
|
+
* `action` reached a browser after the schema had already classified it: not because anyone disagreed
|
|
15
|
+
* with the rule, but because the rule lived inside the HTTP codec, and a socket is not HTTP.
|
|
16
|
+
*
|
|
17
|
+
* So the rule sits here, above every transport, and each transport calls it:
|
|
18
|
+
*
|
|
19
|
+
* - `HttpError.encode` (./http) — the JSON body of a response.
|
|
20
|
+
* - The multiplayer session Durable Object — the `error` frame on a player's socket.
|
|
21
|
+
* - Anything added next. An SSE `event:`, a queue consumer echoing a failure, a Workflow surfacing one.
|
|
22
|
+
* None of them needs to know *what* is stripped; they need to know they must come through here.
|
|
23
|
+
*
|
|
24
|
+
* What it strips, and why that is not a list to maintain: `action` and `detail` are removed by name,
|
|
25
|
+
* and the result is then parsed by `PublicErrorPayload`, which has neither key. The parse is what makes
|
|
26
|
+
* the boundary hold for an **adopter's** code as well as the kit's — it is a property of the schema, so
|
|
27
|
+
* a field classified as operator-facing tomorrow is stripped here without this function changing.
|
|
28
|
+
*
|
|
29
|
+
* The mirror is {@link operatorError} in ./terminal, which keeps `action` because whoever ran the
|
|
30
|
+
* command is the person who can act on "Bind a D1 database named DB in wrangler.jsonc". Two audiences,
|
|
31
|
+
* two functions, and neither one is a transport.
|
|
32
|
+
*
|
|
33
|
+
* It takes the **input** side of `ErrorPayload` rather than the output side, which is the wider of the
|
|
34
|
+
* two: an adopter's code carries a brand once parsed, and a codec's encode side is handed the shape
|
|
35
|
+
* before that brand exists. A `PithyError`'s own payload satisfies it either way, and the brand is not
|
|
36
|
+
* something this function needs — it parses what it is given.
|
|
37
|
+
*/
|
|
38
|
+
export function clientError(payload: z.input<typeof ErrorPayload>): PublicErrorPayload {
|
|
39
|
+
// Removed by name first, then by schema. Doubled deliberately: the spread states the intent at the
|
|
40
|
+
// one site that has it, and the parse is what an adopter's own code is held to.
|
|
41
|
+
const { action: _action, detail: _detail, ...wire } = payload;
|
|
42
|
+
return PublicErrorPayload.parse(wire);
|
|
43
|
+
}
|