@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,136 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fromZodError } from "../error/pithyError";
|
|
6
|
+
import type { Capability } from "./capability";
|
|
7
|
+
import { refusesVanishingKey } from "./vanishingKey";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Who has to go and fix a projection that states a key which vanishes. The guard names them, and for a
|
|
11
|
+
* projection that is the capability author, not the adopter.
|
|
12
|
+
*/
|
|
13
|
+
const SUBJECT = "A capability's client projection";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A JSON value — the only thing that may cross into a browser bundle. Recursive, so an object or
|
|
17
|
+
* array of JSON values is one too. Declared as a type first because a Zod schema cannot infer its
|
|
18
|
+
* own recursion (CLAUDE.md §Zod: the const and the type share one name).
|
|
19
|
+
*/
|
|
20
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
21
|
+
|
|
22
|
+
/** Whether a value is a plain record — `{}` or `Object.create(null)`, never a `Date`, `Map`, or class. */
|
|
23
|
+
function isPlainRecord(value: unknown): boolean {
|
|
24
|
+
if (typeof value !== "object" || value === null) return false;
|
|
25
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
26
|
+
return prototype === Object.prototype || prototype === null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The JSON value space, as a schema. `z.lazy` carries the recursion. The record branch is guarded by
|
|
31
|
+
* a plain-object check first: `z.record` alone accepts a `Date` (it has no own enumerable keys) and
|
|
32
|
+
* would quietly project it as `{}`. A non-finite number is rejected for the same reason — `JSON.stringify`
|
|
33
|
+
* turns `NaN` into `null`, which is a silent value change, not a projection.
|
|
34
|
+
*
|
|
35
|
+
* `refusesVanishingKey` is the third check of the same kind, and the one this schema was missing: a
|
|
36
|
+
* `__proto__` key written by a capability is dropped by `z.record` with no issue raised, so it never
|
|
37
|
+
* reaches the bundle and nothing says it was written. See `vanishingKey.ts`.
|
|
38
|
+
*
|
|
39
|
+
* It wraps the **whole value**, outside the union, and both halves of that matter. Whole value, because
|
|
40
|
+
* this is the recursion: every string, array and object in a projection is a `JsonValue`, so guarding it
|
|
41
|
+
* once covers a space nobody can enumerate, at a depth nobody has to predict. Non-objects pass the check
|
|
42
|
+
* by definition, so the cost is a `typeof` per node. Outside the union, because a refusal raised by one
|
|
43
|
+
* branch of a union is reported as the union's own — measured, the message for a key one level down was
|
|
44
|
+
* `Invalid input`, and out here it is the guard's own sentence at the key's own path.
|
|
45
|
+
*
|
|
46
|
+
* Below an array it is `Invalid union` again, with the guard's sentence nested in `issue.errors`. That is
|
|
47
|
+
* how this union reports every rejection it makes — a `Date` at the same depth reads the same way today —
|
|
48
|
+
* so it is the union's shape to fix, not this guard's, and it is written here so the next reader knows it
|
|
49
|
+
* was seen rather than missed.
|
|
50
|
+
*/
|
|
51
|
+
export const JsonValue: z.ZodType<JsonValue> = refusesVanishingKey(
|
|
52
|
+
z.lazy(() =>
|
|
53
|
+
z.union([
|
|
54
|
+
z.string(),
|
|
55
|
+
z.number().refine((value) => Number.isFinite(value), { error: "Expected a finite number." }),
|
|
56
|
+
z.boolean(),
|
|
57
|
+
z.null(),
|
|
58
|
+
z.array(JsonValue),
|
|
59
|
+
z
|
|
60
|
+
.custom<Record<string, unknown>>(isPlainRecord, { error: "Expected a plain JSON object." })
|
|
61
|
+
.pipe(z.record(z.string(), JsonValue)),
|
|
62
|
+
]),
|
|
63
|
+
),
|
|
64
|
+
SUBJECT,
|
|
65
|
+
).describe("Any JSON value — string, finite number, boolean, null, array, or plain object.");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a capability's client projection is resolved against. A projection is per build, not per
|
|
69
|
+
* request: the plugin inlines the result into the bundle, so the only axis it varies on is the
|
|
70
|
+
* environment being built for (a Turnstile sitekey differs between staging and production).
|
|
71
|
+
*/
|
|
72
|
+
export const ClientProjectionContext = z
|
|
73
|
+
.object({
|
|
74
|
+
environment: z
|
|
75
|
+
.string()
|
|
76
|
+
.min(1)
|
|
77
|
+
.describe(
|
|
78
|
+
"The environment this bundle is built for — `dev`, `staging`, `production`, or an adopter's own name. Per-environment config (a Turnstile sitekey) resolves against it.",
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
.describe("The build-time context a capability's client projection is resolved against.");
|
|
82
|
+
export type ClientProjectionContext = z.output<typeof ClientProjectionContext>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One capability's client-safe projection — the **only** values of its config that may reach a
|
|
86
|
+
* browser bundle. `enabled` is always present so a screen can branch (`false` means the capability is
|
|
87
|
+
* absent, or configured in a way this environment cannot render); everything else is the capability's
|
|
88
|
+
* own JSON, validated by the catchall. This validation is the security boundary of `virtual:pithy/*`:
|
|
89
|
+
* a capability cannot smuggle a function, a `Date`, or an unserializable value into the bundle, and a
|
|
90
|
+
* new config field never ships to browsers unless its capability adds it here on purpose.
|
|
91
|
+
*
|
|
92
|
+
* Guarded a second time, and this is the one place in the file where that is not redundant. `JsonValue`
|
|
93
|
+
* covers every object *inside* a projection; the projection's own top level is this object, parsed by the
|
|
94
|
+
* object branch rather than the record one, and Zod drops `__proto__` there too. So the recursion closes
|
|
95
|
+
* everything below the root and the root closes itself. Two sites, and the walk in `vanishingKey.test.ts`
|
|
96
|
+
* is what says they are the only two.
|
|
97
|
+
*/
|
|
98
|
+
const PROJECTION_DESCRIPTION =
|
|
99
|
+
"A capability's client-safe projection — the only config values that reach a browser bundle.";
|
|
100
|
+
export const ClientProjection = refusesVanishingKey(
|
|
101
|
+
z
|
|
102
|
+
.object({
|
|
103
|
+
enabled: z
|
|
104
|
+
.boolean()
|
|
105
|
+
.describe(
|
|
106
|
+
"Whether the capability is composed and usable in this environment. `false` means a screen must branch, not render.",
|
|
107
|
+
),
|
|
108
|
+
})
|
|
109
|
+
.catchall(JsonValue)
|
|
110
|
+
.describe(PROJECTION_DESCRIPTION),
|
|
111
|
+
SUBJECT,
|
|
112
|
+
).describe(PROJECTION_DESCRIPTION);
|
|
113
|
+
export type ClientProjection = z.output<typeof ClientProjection>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve one capability's client projection, validated. An absent capability, or one that declares no
|
|
117
|
+
* projection, is `{ enabled: false }` — never an error, because a front end is written against the
|
|
118
|
+
* capabilities it may have, not the ones it does. A declared projection is parsed through
|
|
119
|
+
* {@link ClientProjection}, so a non-JSON value is a build failure rather than a broken bundle.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveClientProjection(
|
|
122
|
+
capability: Capability | undefined,
|
|
123
|
+
context: ClientProjectionContext,
|
|
124
|
+
): ClientProjection {
|
|
125
|
+
if (!capability?.client) return { enabled: false };
|
|
126
|
+
const resolvedContext = ClientProjectionContext.parse(context);
|
|
127
|
+
const projected = ClientProjection.safeParse(capability.client(resolvedContext));
|
|
128
|
+
if (!projected.success) {
|
|
129
|
+
throw fromZodError(projected.error, {
|
|
130
|
+
message: `The ${capability.name} capability's client projection is not JSON.`,
|
|
131
|
+
action: `Return only JSON values from the ${capability.name} capability's \`client\` projection — no functions, dates, or undefined.`,
|
|
132
|
+
detail: `environment=${resolvedContext.environment}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return projected.data;
|
|
136
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import type { Capability } from "./capability";
|
|
6
|
+
|
|
7
|
+
/** A logical group bound to one CF binding, holding named items — D1 tables, or KV stores. */
|
|
8
|
+
export interface BindingGroup<Item> {
|
|
9
|
+
/** The CF binding this group lives in (a D1 or KV binding name in the Worker env). */
|
|
10
|
+
binding: string;
|
|
11
|
+
/** The named items in this group: table name → schema, or store name → spec. */
|
|
12
|
+
items: Record<string, Item>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Adapt a capability's domain map (`databases`, `kvNamespaces`, …) — each entry a `{ binding, … }`
|
|
17
|
+
* spec — into the neutral {@link BindingGroup} shape `composeBindingGroups` consumes, projecting the
|
|
18
|
+
* domain item field (`tables`, `stores`) to `items`. One place to add the next binding type.
|
|
19
|
+
*/
|
|
20
|
+
export function bindingGroupsFrom<Spec extends { binding: string }, Item>(
|
|
21
|
+
map: Record<string, Spec> | undefined,
|
|
22
|
+
items: (spec: Spec) => Record<string, Item>,
|
|
23
|
+
): Record<string, BindingGroup<Item>> | undefined {
|
|
24
|
+
if (!map) return undefined;
|
|
25
|
+
return Object.fromEntries(
|
|
26
|
+
Object.entries(map).map(([name, spec]) => [name, { binding: spec.binding, items: items(spec) }]),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge a binding-backed group that capabilities contribute to — D1 `databases` (items are tables)
|
|
32
|
+
* or KV `kvNamespaces` (items are stores). Capabilities targeting the same group name merge their
|
|
33
|
+
* items (the project-wide contents, per group); a binding mismatch for one name, or an item name
|
|
34
|
+
* claimed twice in one group, is an author conflict caught here at assembly (internal — names are
|
|
35
|
+
* not secret). `groupLabel`/`itemLabel` make the error read in the caller's vocabulary.
|
|
36
|
+
*/
|
|
37
|
+
export function composeBindingGroups<Item>(
|
|
38
|
+
capabilities: Capability[],
|
|
39
|
+
select: (cap: Capability) => Record<string, BindingGroup<Item>> | undefined,
|
|
40
|
+
groupLabel: string,
|
|
41
|
+
itemLabel: string,
|
|
42
|
+
): Record<string, BindingGroup<Item>> {
|
|
43
|
+
const merged: Record<string, BindingGroup<Item>> = {};
|
|
44
|
+
const groupOwner: Record<string, string> = {};
|
|
45
|
+
const itemOwners = new Map<string, Record<string, string>>();
|
|
46
|
+
|
|
47
|
+
for (const cap of capabilities) {
|
|
48
|
+
for (const [name, group] of Object.entries(select(cap) ?? {})) {
|
|
49
|
+
let target = merged[name];
|
|
50
|
+
let owners = itemOwners.get(name);
|
|
51
|
+
if (!target || !owners) {
|
|
52
|
+
target = { binding: group.binding, items: {} };
|
|
53
|
+
owners = {};
|
|
54
|
+
merged[name] = target;
|
|
55
|
+
itemOwners.set(name, owners);
|
|
56
|
+
groupOwner[name] = cap.name;
|
|
57
|
+
} else if (target.binding !== group.binding) {
|
|
58
|
+
throw new InternalError({
|
|
59
|
+
message: `${groupLabel} "${name}" is bound to "${target.binding}" by capability "${groupOwner[name]}" but to "${group.binding}" by "${cap.name}".`,
|
|
60
|
+
action: `Use one binding per ${groupLabel} name across capabilities.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
for (const [item, value] of Object.entries(group.items)) {
|
|
64
|
+
if (item in target.items) {
|
|
65
|
+
throw new InternalError({
|
|
66
|
+
message: `Duplicate ${itemLabel} "${item}" in ${groupLabel} "${name}": declared by capabilities "${owners[item]}" and "${cap.name}".`,
|
|
67
|
+
action: `Rename one ${itemLabel} — every ${itemLabel} name within a ${groupLabel} must be unique.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
target.items[item] = value;
|
|
71
|
+
owners[item] = cap.name;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return merged;
|
|
76
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import type { Capability } from "./capability";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The composed set, reachable from module scope — **so a Workflow can find it** (pithy-sh/pithy#356).
|
|
9
|
+
*
|
|
10
|
+
* ## The gap this closes
|
|
11
|
+
*
|
|
12
|
+
* A capability's `compose` hook receives every other composed capability, so a route can hold
|
|
13
|
+
* `@pithy-sh/email`'s bound `enqueue` and call it. `@pithy-sh/auth` does exactly that for magic links,
|
|
14
|
+
* and an adopter copies the pattern for its own mail.
|
|
15
|
+
*
|
|
16
|
+
* **A Workflow class cannot.** The runtime constructs it with the worker `env` and nothing else. `env`
|
|
17
|
+
* carries `DB`, `EMAIL_SENDER` and every other binding, but a composed seam is a closure `compose`
|
|
18
|
+
* handed out, not a binding — and there was no way back from a durable step to the composed set. The
|
|
19
|
+
* two ways past it were both wrong and both the adopter's to take: rebuild the seam from `env` plus a
|
|
20
|
+
* restated identity (the same sending address in a second place, free to drift from `pithy.config.ts`),
|
|
21
|
+
* or pass the closure through Workflow params (which are serialized, and a closure is not). So a durable
|
|
22
|
+
* job could never send mail, and `pithy-sh/dashboard`'s monthly key-rotation notice was written, tested,
|
|
23
|
+
* and reachable by nothing.
|
|
24
|
+
*
|
|
25
|
+
* ## Why module scope is the right place, and what it costs
|
|
26
|
+
*
|
|
27
|
+
* `createBackend` runs at module load, and Cloudflare requires a Workflow class to be exported from the
|
|
28
|
+
* same worker entrypoint that exports the `fetch` handler — so the composition has already happened, in
|
|
29
|
+
* this isolate, by the time any step body runs. That is the same reasoning
|
|
30
|
+
* `@pithy-sh/secrets`' shared accessor is built on, and it is worth being explicit that it is a
|
|
31
|
+
* *reasoned* singleton rather than a convenient global: one worker assembles one backend.
|
|
32
|
+
*
|
|
33
|
+
* The cost is that a second `createBackend` in one isolate replaces the first. In a deployed Worker that
|
|
34
|
+
* cannot happen; in a test file it can, so {@link recordComposition} replaces wholesale rather than
|
|
35
|
+
* merging — a half-remembered composition would be worse than none — and {@link forgetComposition}
|
|
36
|
+
* exists so a suite can return to the un-composed state and assert what happens there.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing here is a service locator for application code.** A route has `c.var`, and a capability has
|
|
39
|
+
* its `compose` hook; both are better, both are typed, and both stay the way to reach a seam. This is
|
|
40
|
+
* for the one caller that has neither.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** The composed set, or null before any backend has been assembled in this isolate. */
|
|
44
|
+
let composed: readonly Capability[] | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record the composed set. Called once by `createBackend`, after every `compose` hook has run — so a
|
|
48
|
+
* capability found here is one whose own wiring is already complete, not one mid-assembly.
|
|
49
|
+
*/
|
|
50
|
+
export function recordComposition(capabilities: readonly Capability[]): void {
|
|
51
|
+
composed = [...capabilities];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Forget it. For a test that needs the un-composed state back; a deployed Worker never calls this. */
|
|
55
|
+
export function forgetComposition(): void {
|
|
56
|
+
composed = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The composed capabilities, or a raised wiring fault.
|
|
61
|
+
*
|
|
62
|
+
* Empty is not an answer. A caller asking this has already decided it needs a composed seam, and an
|
|
63
|
+
* empty array would send it down a "the capability is not composed" path that is indistinguishable from
|
|
64
|
+
* "no backend was assembled" — two very different things to tell somebody reading a log at 3am.
|
|
65
|
+
*/
|
|
66
|
+
export function composedCapabilities(): readonly Capability[] {
|
|
67
|
+
if (composed === null) {
|
|
68
|
+
throw new InternalError({
|
|
69
|
+
message: "This job could not reach the application's capabilities.",
|
|
70
|
+
action: "Export the Workflow class from the same worker entrypoint that calls createBackend.",
|
|
71
|
+
detail: "composedCapabilities() was called before any createBackend ran in this isolate.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return composed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One composed capability by name, narrowed by its own type guard, or a raised wiring fault naming it.
|
|
79
|
+
*
|
|
80
|
+
* The guard is the capability's own (`isEmailCapability`, and its peers), so the narrowing is the
|
|
81
|
+
* capability's declaration rather than a cast here — a capability composed under the right name but
|
|
82
|
+
* without its seams attached is caught as the wiring failure it is, not returned as something whose
|
|
83
|
+
* methods are missing at the call site.
|
|
84
|
+
*/
|
|
85
|
+
export function composedCapability<T extends Capability>(name: string, is: (capability: Capability) => boolean): T {
|
|
86
|
+
const found = composedCapabilities().find((capability) => capability.name === name && is(capability));
|
|
87
|
+
if (!found) {
|
|
88
|
+
throw new InternalError({
|
|
89
|
+
// The capability's name is in the client-safe half deliberately, exactly as `createBackend`'s
|
|
90
|
+
// missing-peer check names it: a capability name is a published part of this framework, and a
|
|
91
|
+
// wiring fault that will not say which wire is a fault nobody can act on.
|
|
92
|
+
message: `The "${name}" capability is not composed, so this job could not run.`,
|
|
93
|
+
action: `Add ${name}(...) to createBackend's capabilities (run \`pithy add ${name}\`).`,
|
|
94
|
+
detail: `no composed capability named "${name}" carrying its seams was found.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return found as T;
|
|
98
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { Capability } from "./capability";
|
|
6
|
+
|
|
7
|
+
/** Compose each capability's config schema into one object keyed by capability name. */
|
|
8
|
+
export function composeConfig(capabilities: Capability[]): z.ZodObject<Record<string, z.ZodType>> {
|
|
9
|
+
const shape: Record<string, z.ZodType> = {};
|
|
10
|
+
for (const cap of capabilities) {
|
|
11
|
+
if (cap.config) shape[cap.name] = cap.config;
|
|
12
|
+
}
|
|
13
|
+
return z.object(shape);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Validate raw config input against the composed schema; throws on mismatch. */
|
|
17
|
+
export function loadConfig(capabilities: Capability[], input: unknown): Record<string, Record<string, unknown>> {
|
|
18
|
+
return composeConfig(capabilities).parse(input) as Record<string, Record<string, unknown>>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A secret whose value is **arbitrary**, and can therefore be minted for local dev.
|
|
8
|
+
*
|
|
9
|
+
* The rule, stated once and here: a secret is generatable when nothing outside the project has to
|
|
10
|
+
* agree with its value. A session signing key is; an OAuth client secret registered with a provider,
|
|
11
|
+
* or a Stripe key, is not — a generated value there authenticates against nothing, and hides a real
|
|
12
|
+
* gap behind one that looks filled in.
|
|
13
|
+
*
|
|
14
|
+
* It lives in core, and in its own module, because both ends need it and neither can import the
|
|
15
|
+
* other: the owning capability declares it on its `@pithy-sh/secrets` registry entry, and the CLI
|
|
16
|
+
* reads the same declaration off `pithy.manifest.json` — which it must, since `pithy add` wires a
|
|
17
|
+
* capability without ever executing it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const DevSecretValue = z
|
|
21
|
+
.enum(["random"])
|
|
22
|
+
.describe("How `pithy add` mints a secret's dev value. `random` is a random string and nothing else.");
|
|
23
|
+
export type DevSecretValue = z.infer<typeof DevSecretValue>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One declared dev secret, as a manifest carries it: the registry name, and how its value is minted.
|
|
27
|
+
*
|
|
28
|
+
* The manifest's projection of the registry entry's `devValue`. Each capability's own tests assert the
|
|
29
|
+
* two agree, the way `requiredBindings` and `peerCapabilities` already mirror the runtime capability.
|
|
30
|
+
*/
|
|
31
|
+
export const DevSecret = z
|
|
32
|
+
.object({
|
|
33
|
+
name: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.describe(
|
|
37
|
+
"The registry name the secret is declared under — also the `.dev.vars` key, since local dev resolves every secret from its injected string.",
|
|
38
|
+
),
|
|
39
|
+
devValue: DevSecretValue.describe("How the dev value is minted. Must match the registry entry's `devValue`."),
|
|
40
|
+
})
|
|
41
|
+
.describe("A secret whose dev value `pithy add` mints, because its value is arbitrary.");
|
|
42
|
+
export type DevSecret = z.infer<typeof DevSecret>;
|