@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,159 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ControlPlaneKeyConflictError, ControlPlaneKeyNotFoundError } from "../error/errors";
|
|
5
|
+
import type { RegisteredKey } from "./connection";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The registered-key lifecycle: pure array logic over a connection's `keys` column.
|
|
9
|
+
*
|
|
10
|
+
* **Registering a key and expiring one are two operations, and folding them into a single `rotate()`
|
|
11
|
+
* is the one design mistake this module exists to prevent.** One call would make append-and-expire
|
|
12
|
+
* atomic, and atomic is precisely wrong here. The whole safety property is that the old key outlives
|
|
13
|
+
* the new one's first successful call: a mis-copied JWK, a private key the management client cannot
|
|
14
|
+
* actually sign with, or a Worker that never answers leaves the adopter holding a credential that
|
|
15
|
+
* still works. Fuse the two writes together and that same mistake locks them out of the seam with
|
|
16
|
+
* nothing left to authenticate the fix with. Kept apart, a broken rotation costs a stale key.
|
|
17
|
+
*
|
|
18
|
+
* So the safe ordering is enforced here rather than documented: {@link appendKey} may not touch an
|
|
19
|
+
* existing key's window, and {@link expireKey} refuses unless another key is already live.
|
|
20
|
+
*
|
|
21
|
+
* Nothing in this module reads a clock, a binding, or D1. `now` is injected on every call, which is
|
|
22
|
+
* what lets a test assert what a connection looks like a year into a failed rotation.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Every key that may verify a call at `now`: not revoked, started, and not yet ended. */
|
|
26
|
+
export function activeKeys(keys: readonly RegisteredKey[], now: Date): RegisteredKey[] {
|
|
27
|
+
return keys.filter((key) => !isRetired(key, now) && key.validFrom.getTime() <= now.getTime());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The key that may verify this call, or null. Matching on `kid` alone would be the vulnerability —
|
|
32
|
+
* the window and the revocation are checked here so no caller can forget them.
|
|
33
|
+
*/
|
|
34
|
+
export function findVerifyingKey(keys: readonly RegisteredKey[], keyId: string, now: Date): RegisteredKey | null {
|
|
35
|
+
return activeKeys(keys, now).find((key) => key.keyId === keyId) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register a key. Appends, and appends only — no existing key's `validUntil` moves, so a connection
|
|
40
|
+
* mid-rotation always has the key it arrived with.
|
|
41
|
+
*
|
|
42
|
+
* A key that is already retired at `now` is refused rather than stored: it could never be proven by a
|
|
43
|
+
* ping, so registering it would only offer a second step that can never safely be taken.
|
|
44
|
+
*/
|
|
45
|
+
export function appendKey(keys: readonly RegisteredKey[], key: RegisteredKey, now: Date): RegisteredKey[] {
|
|
46
|
+
if (keys.some((existing) => existing.keyId === key.keyId)) {
|
|
47
|
+
throw new ControlPlaneKeyConflictError({
|
|
48
|
+
message: "That key id is already registered on this connection.",
|
|
49
|
+
action: "Register the replacement under a new key id.",
|
|
50
|
+
detail: `duplicate keyId ${key.keyId}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (isRetired(key, now)) {
|
|
54
|
+
throw new ControlPlaneKeyConflictError({
|
|
55
|
+
message: "That key has already expired or been revoked.",
|
|
56
|
+
action: "Register a key with an open-ended validity window.",
|
|
57
|
+
detail: `keyId ${key.keyId} is retired at registration`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return [...keys, key];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Close a key's window at `now`, once a replacement has proven itself.
|
|
65
|
+
*
|
|
66
|
+
* `provenKeyId` is the key that verified the request asking for this expiry — proof by use, and the
|
|
67
|
+
* reason the seam's `ping` scope is held implicitly by every connection. Two refusals guard the
|
|
68
|
+
* ordering: a prover that is not itself live has proven nothing, and an expiry that would leave no
|
|
69
|
+
* live key is the lockout, whatever the caller believes about their replacement.
|
|
70
|
+
*
|
|
71
|
+
* Re-expiring a key already closed is a no-op on its end date. The window a call was rejected under
|
|
72
|
+
* must not move retroactively.
|
|
73
|
+
*/
|
|
74
|
+
export function expireKey(
|
|
75
|
+
keys: readonly RegisteredKey[],
|
|
76
|
+
keyId: string,
|
|
77
|
+
options: { provenKeyId: string; now: Date },
|
|
78
|
+
): RegisteredKey[] {
|
|
79
|
+
const { provenKeyId, now } = options;
|
|
80
|
+
if (!keys.some((key) => key.keyId === keyId)) {
|
|
81
|
+
throw new ControlPlaneKeyNotFoundError({ detail: `keyId ${keyId} is not registered on this connection` });
|
|
82
|
+
}
|
|
83
|
+
if (findVerifyingKey(keys, provenKeyId, now) === null) {
|
|
84
|
+
throw new ControlPlaneKeyConflictError({
|
|
85
|
+
message: "The replacement key has not been proven on this connection.",
|
|
86
|
+
action: "Call the ping route with the replacement key, then expire this one.",
|
|
87
|
+
detail: `provenKeyId ${provenKeyId} is not live at ${now.toISOString()}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const expired = keys.map((key) =>
|
|
92
|
+
key.keyId === keyId && key.validUntil === null ? { ...key, validUntil: now } : key,
|
|
93
|
+
);
|
|
94
|
+
if (activeKeys(expired, now).length === 0) {
|
|
95
|
+
throw new ControlPlaneKeyConflictError({
|
|
96
|
+
message: "Expiring that key would leave this connection with no live key.",
|
|
97
|
+
action: "Register a replacement key and prove it before expiring this one.",
|
|
98
|
+
detail: `expiring keyId ${keyId} would empty the connection's live set`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return expired;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Drop keys retired longer ago than `retentionMs`, then cap the array at `maxKeys`.
|
|
106
|
+
*
|
|
107
|
+
* The column is versioned, not a log — a superseded key is kept only long enough for the audit trail
|
|
108
|
+
* and a `GET /control-plane/keys` to explain a rotation. Two keys are never dropped: a live one, and
|
|
109
|
+
* one registered ahead of its `validFrom`, which is unretired rather than dead. So the cap is a
|
|
110
|
+
* target, not a guarantee; a connection with more live keys than `maxKeys` keeps all of them.
|
|
111
|
+
*/
|
|
112
|
+
export function pruneKeys(
|
|
113
|
+
keys: readonly RegisteredKey[],
|
|
114
|
+
options: { now: Date; retentionMs: number; maxKeys: number },
|
|
115
|
+
): RegisteredKey[] {
|
|
116
|
+
const { now, retentionMs, maxKeys } = options;
|
|
117
|
+
const live = new Set(activeKeys(keys, now).map((key) => key.keyId));
|
|
118
|
+
|
|
119
|
+
const kept = keys.filter((key) => {
|
|
120
|
+
if (live.has(key.keyId)) return true;
|
|
121
|
+
const retired = retiredAt(key);
|
|
122
|
+
if (retired === null) return true;
|
|
123
|
+
return now.getTime() - retired.getTime() <= retentionMs;
|
|
124
|
+
});
|
|
125
|
+
if (kept.length <= maxKeys) return kept;
|
|
126
|
+
|
|
127
|
+
// Over the cap: shed the keys that have been retired longest, which are the least useful to anyone
|
|
128
|
+
// reading the trail. `keyId` is unique (appendKey enforces it), so a set of ids is a safe drop list.
|
|
129
|
+
const dropped = new Set(
|
|
130
|
+
kept
|
|
131
|
+
.flatMap((key) => {
|
|
132
|
+
const retired = live.has(key.keyId) ? null : retiredAt(key);
|
|
133
|
+
return retired === null ? [] : [{ keyId: key.keyId, retiredAt: retired.getTime() }];
|
|
134
|
+
})
|
|
135
|
+
.sort((a, b) => a.retiredAt - b.retiredAt)
|
|
136
|
+
.slice(0, kept.length - maxKeys)
|
|
137
|
+
.map((entry) => entry.keyId),
|
|
138
|
+
);
|
|
139
|
+
return kept.filter((key) => !dropped.has(key.keyId));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* When a key stopped being usable, or null while it is still in play. Revocation and expiry can both
|
|
144
|
+
* be set; the earlier one is when the key actually went cold.
|
|
145
|
+
*/
|
|
146
|
+
function retiredAt(key: RegisteredKey): Date | null {
|
|
147
|
+
const ends = [key.revokedAt, key.validUntil].filter((date): date is Date => date !== null);
|
|
148
|
+
if (ends.length === 0) return null;
|
|
149
|
+
return ends.reduce((earliest, date) => (date.getTime() < earliest.getTime() ? date : earliest));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Is this key cold at `now`? Revocation counts whatever date it carries — it is the adopter's
|
|
154
|
+
* unilateral control and does not wait for a window to close. A `validUntil` still ahead is not
|
|
155
|
+
* retirement, and neither is a `validFrom` still ahead: that key is pending, not spent.
|
|
156
|
+
*/
|
|
157
|
+
function isRetired(key: RegisteredKey, now: Date): boolean {
|
|
158
|
+
return key.revokedAt !== null || (key.validUntil !== null && key.validUntil.getTime() <= now.getTime());
|
|
159
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { SQLiteDate } from "../../data/codecs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One spent control-plane token, as a row.
|
|
9
|
+
*
|
|
10
|
+
* The row's **existence is the whole signal** — a claim is an insert that either wins the primary key or
|
|
11
|
+
* does not. The columns beside `jti` exist for two other jobs: `expiresAt` is what lets the table be
|
|
12
|
+
* pruned rather than grow forever, and `connectionId` is for the incident, not the decision. A replay is
|
|
13
|
+
* refused whatever connection presents it, because the `jti` alone is the key: scoping the uniqueness per
|
|
14
|
+
* connection would let a token captured from one connection be spent again against another, which is
|
|
15
|
+
* exactly the property being bought.
|
|
16
|
+
*/
|
|
17
|
+
export const ControlPlaneReplay = z
|
|
18
|
+
.object({
|
|
19
|
+
jti: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(128)
|
|
23
|
+
.describe(
|
|
24
|
+
"The token's unique id, minted by the management client, and the primary key that decides the race. Bounded because it arrives from a caller and becomes a stored key — an unbounded id would be an unbounded write.",
|
|
25
|
+
),
|
|
26
|
+
connectionId: z
|
|
27
|
+
.string()
|
|
28
|
+
.min(1)
|
|
29
|
+
.describe(
|
|
30
|
+
"The connection whose token claimed this jti. Recorded for forensics only — the claim is decided by the jti alone, so that a token cannot be replayed against a second connection.",
|
|
31
|
+
),
|
|
32
|
+
expiresAt: SQLiteDate.describe(
|
|
33
|
+
"When this record may be pruned — the instant past which no token carrying this jti could still be accepted. Rows are deleted after it, never before: forgetting a jti while its token is still live is precisely the replay this table exists to stop.",
|
|
34
|
+
),
|
|
35
|
+
})
|
|
36
|
+
.describe(
|
|
37
|
+
"A spent control-plane token id. Its presence is the refusal; the other columns are for pruning and for the incident.",
|
|
38
|
+
);
|
|
39
|
+
export type ControlPlaneReplay = z.output<typeof ControlPlaneReplay>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Kysely } from "kysely";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import { createDatabase, type DatabaseSchema } from "../../data/db";
|
|
8
|
+
import { ControlPlaneConnection } from "./connection";
|
|
9
|
+
import { ControlPlaneReplay } from "./replay";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The connections table. `CamelCasePlugin` snake-cases it to `pithy_controlplane_connections` — the
|
|
13
|
+
* namespace is the single word `controlplane`, matching the migration namespace and the
|
|
14
|
+
* `controlplane/*` error codes, because the prefix pattern admits no hyphen.
|
|
15
|
+
*/
|
|
16
|
+
export const CONTROL_PLANE_CONNECTIONS_TABLE = "pithyControlplaneConnections";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The replay table — spent token ids. Snake-cases to `pithy_controlplane_replays`.
|
|
20
|
+
*
|
|
21
|
+
* In the same database as the connections rather than beside the sessions in KV, because that is what
|
|
22
|
+
* makes the claim a primary-key insert and therefore strongly consistent. See `../replay/guard.ts`.
|
|
23
|
+
*/
|
|
24
|
+
export const CONTROL_PLANE_REPLAYS_TABLE = "pithyControlplaneReplays";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The control-plane tables map. Both are always present: a Worker that composes the seam and has never
|
|
28
|
+
* been connected still needs somewhere for the answer "nobody is connected" to come from, and the
|
|
29
|
+
* replay table must exist before the first management call rather than on first use.
|
|
30
|
+
*/
|
|
31
|
+
export function controlPlaneTables(): Record<string, z.ZodObject> {
|
|
32
|
+
return {
|
|
33
|
+
[CONTROL_PLANE_CONNECTIONS_TABLE]: ControlPlaneConnection,
|
|
34
|
+
[CONTROL_PLANE_REPLAYS_TABLE]: ControlPlaneReplay,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The typed Kysely database over the control-plane tables. */
|
|
39
|
+
export type ControlPlaneTables = {
|
|
40
|
+
[CONTROL_PLANE_CONNECTIONS_TABLE]: typeof ControlPlaneConnection;
|
|
41
|
+
[CONTROL_PLANE_REPLAYS_TABLE]: typeof ControlPlaneReplay;
|
|
42
|
+
};
|
|
43
|
+
export type ControlPlaneDatabase = Kysely<DatabaseSchema<ControlPlaneTables>>;
|
|
44
|
+
|
|
45
|
+
/** Build the control-plane database from the `DB` binding (CamelCasePlugin installed). */
|
|
46
|
+
export function controlPlaneDatabase(d1: D1Database): ControlPlaneDatabase {
|
|
47
|
+
return createDatabase(d1, {
|
|
48
|
+
[CONTROL_PLANE_CONNECTIONS_TABLE]: ControlPlaneConnection,
|
|
49
|
+
[CONTROL_PLANE_REPLAYS_TABLE]: ControlPlaneReplay,
|
|
50
|
+
}) as unknown as ControlPlaneDatabase;
|
|
51
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ControlPlaneScope } from "../scope/scope";
|
|
6
|
+
import { ManifestConfigKey, ManifestConfigValues } from "./configuration";
|
|
7
|
+
import { CapabilityHealthReport, HealthSummary, HealthSummaryKey, healthReport, healthWire } from "./healthSummary";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a capability tells a management client about its own admin surface.
|
|
11
|
+
*
|
|
12
|
+
* **This exists because knowing a capability is installed is not enough to call it.** A management
|
|
13
|
+
* client that learns only names still has to hold a route table for every capability, guess that
|
|
14
|
+
* payments is mounted at `/payments`, and know out-of-band which scope each operation needs. Every one
|
|
15
|
+
* of those is a thing that can be wrong, and `basePath` is configurable — an adopter who mounts
|
|
16
|
+
* payments at `/billing` would silently 404 a client that hardcoded the default.
|
|
17
|
+
*
|
|
18
|
+
* So the capability declares the routes it actually registered, with the paths it actually used, and
|
|
19
|
+
* `GET /control-plane/manifest` reports them. That is what makes discovery-over-configuration real
|
|
20
|
+
* rather than aspirational: a client composes its navigation *and* its calls from what the Worker says
|
|
21
|
+
* about itself, and an adopter who moves a mount point or upgrades a capability changes what the client
|
|
22
|
+
* does without either side coordinating.
|
|
23
|
+
*
|
|
24
|
+
* **The declaration is checked against the router**, not trusted — see `missingAdminRoutes`. A manifest
|
|
25
|
+
* that drifts from the routes is worse than no manifest, because a client would believe it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The HTTP methods an admin route may use. */
|
|
29
|
+
export const AdminRouteMethod = z
|
|
30
|
+
.enum(["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
31
|
+
.describe("The HTTP method this admin route answers on.");
|
|
32
|
+
export type AdminRouteMethod = z.infer<typeof AdminRouteMethod>;
|
|
33
|
+
|
|
34
|
+
/** One admin route a capability contributes behind the `control-plane` strategy. */
|
|
35
|
+
export const AdminRoute = z
|
|
36
|
+
.object({
|
|
37
|
+
method: AdminRouteMethod.describe("The method to call this route with."),
|
|
38
|
+
path: z
|
|
39
|
+
.string()
|
|
40
|
+
.min(1)
|
|
41
|
+
.describe(
|
|
42
|
+
"The **fully mounted** path, with the capability's configured `basePath` already applied — `/billing/entitlements/grant`, not `/entitlements/grant`. Built by the capability at assembly, so an adopter who moved the mount point is reflected here rather than silently breaking a client that assumed the default.",
|
|
43
|
+
),
|
|
44
|
+
scope: ControlPlaneScope.nullable().describe(
|
|
45
|
+
"The scope this route requires, or null when it needs only a verified caller (the seam's `ping`). A client compares it against the connection's granted scopes to know which operations it may actually offer, instead of discovering a 403 by trying.",
|
|
46
|
+
),
|
|
47
|
+
summary: z
|
|
48
|
+
.string()
|
|
49
|
+
.min(1)
|
|
50
|
+
.describe("One line describing what the route does, for a management client to render beside it."),
|
|
51
|
+
})
|
|
52
|
+
.describe(
|
|
53
|
+
"One admin route, described well enough for a management client to call it without holding any capability-specific knowledge.",
|
|
54
|
+
);
|
|
55
|
+
export type AdminRoute = z.infer<typeof AdminRoute>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One composed capability's declaration — everything about it that is the same for every caller.
|
|
59
|
+
*
|
|
60
|
+
* Split from {@link CapabilityDescriptor} because the health *values* are the one part of a manifest
|
|
61
|
+
* entry that depends on who is asking, and the seam holds this half from assembly (`compose`) while the
|
|
62
|
+
* other half is resolved per request.
|
|
63
|
+
*/
|
|
64
|
+
export const CapabilityDeclaration = z
|
|
65
|
+
.object({
|
|
66
|
+
name: z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.describe("The capability's name — the same token used as its migration namespace and error domain."),
|
|
70
|
+
version: z
|
|
71
|
+
.string()
|
|
72
|
+
.nullable()
|
|
73
|
+
.describe(
|
|
74
|
+
"The npm version of the package supplying this capability, or null where there is none — the adopter's own `app` capability has a name and no package. Reported per capability and never aggregated: the package name is the join key against a release feed, and a project composes some capabilities and not others, so only the intersection of what it composes and what changed is worth reporting.",
|
|
75
|
+
),
|
|
76
|
+
adminRoutes: z
|
|
77
|
+
.array(AdminRoute)
|
|
78
|
+
.describe(
|
|
79
|
+
"Every admin route this capability contributes, or empty when it contributes none. Most capabilities are empty: having no management surface is the normal case, and saying so explicitly is what lets a client render a capability it cannot act on.",
|
|
80
|
+
),
|
|
81
|
+
// **Defaulted, not required, and that is the compatibility mechanism.** This manifest carries no schema
|
|
82
|
+
// version on purpose — a client dispatches on what the Worker declares right now — so absence is the
|
|
83
|
+
// only way a new field can ship without breaking every Worker deployed before it. A required one fails
|
|
84
|
+
// at the object level, which costs the client the *whole* manifest rather than the part it did not
|
|
85
|
+
// know about, and every pane goes dark for an adopter whose only mistake was not upgrading yet (#352).
|
|
86
|
+
healthKeys: z
|
|
87
|
+
.array(HealthSummaryKey)
|
|
88
|
+
.default([])
|
|
89
|
+
.describe(
|
|
90
|
+
"The closed vocabulary of scalars this capability may report about its own state, or empty when it reports none — including when the Worker predates this field and says nothing at all. Declared alongside the routes so a client can render a key it has never heard of from what the Worker says about it — and so a *withheld* number is visible as a key with no value, rather than as silence.",
|
|
91
|
+
),
|
|
92
|
+
// Both defaulted, for the reason stated above `healthKeys` and with the same force (#422). The two
|
|
93
|
+
// arrived together and travel together: a declaration with no value is a control with nothing behind
|
|
94
|
+
// it, and a value with no declaration is a fact a client can only guess the meaning of.
|
|
95
|
+
configKeys: z
|
|
96
|
+
.array(ManifestConfigKey)
|
|
97
|
+
.default([])
|
|
98
|
+
.describe(
|
|
99
|
+
"The configured facts this capability states, or empty when it states none — including when the Worker predates this field. Declared alongside the routes, because a fact is what a client needs to *call* one: `POST /billing/entitlements/grant` names a holder and never assumes it, so a client that cannot learn what this project bills can only guess — and a guess writes a row nothing reads.",
|
|
100
|
+
),
|
|
101
|
+
config: ManifestConfigValues.default({}).describe(
|
|
102
|
+
"What each declared fact resolved to for this deployment. The same answer for every caller, read off the capability's parsed config at assembly: there is no producer here, so no failure state, no per-request cost, and no staleness beyond the manifest itself.",
|
|
103
|
+
),
|
|
104
|
+
})
|
|
105
|
+
.describe(
|
|
106
|
+
"One capability this Worker composes, the version it is at, the admin surface it exposes, the summary it may report, and the configured facts a client must respect to call any of it.",
|
|
107
|
+
);
|
|
108
|
+
export type CapabilityDeclaration = z.infer<typeof CapabilityDeclaration>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One composed capability's manifest entry **as it goes over the wire**.
|
|
112
|
+
*
|
|
113
|
+
* Two flat fields, and neither is what a consumer reads: {@link CapabilityDescriptor} decodes them into
|
|
114
|
+
* one value that says which of the four states it is in. They are flat here because the wire has an
|
|
115
|
+
* older half to keep working. A Worker deployed before #350 sends no `healthUnavailable` and a Worker
|
|
116
|
+
* deployed before #317 sends neither field, and both must still parse; and a client pinned to a build
|
|
117
|
+
* older than the Worker it is reading strips the field it has never heard of and lands on `health:
|
|
118
|
+
* null`, which renders as silence rather than as a zero. Putting the fourth state *inside* `health`
|
|
119
|
+
* would have cost that client the whole manifest for one capability's bad afternoon, which is the
|
|
120
|
+
* failure #352 is about.
|
|
121
|
+
*/
|
|
122
|
+
const CapabilityDescriptorWire = CapabilityDeclaration.extend({
|
|
123
|
+
// Defaulted for the same reason as `healthKeys` above, and it lands on the meaning it already had: a
|
|
124
|
+
// Worker that says nothing about health has nothing declared, which is the first of the four states.
|
|
125
|
+
health: HealthSummary.nullable()
|
|
126
|
+
.default(null)
|
|
127
|
+
.describe(
|
|
128
|
+
"Every declared value this caller may see, or null when there is none to give — either nothing is declared, or this connection lacks the scope the value is behind, or producing it failed, or the Worker predates this field. **Null is never zero.**",
|
|
129
|
+
),
|
|
130
|
+
healthUnavailable: z
|
|
131
|
+
.boolean()
|
|
132
|
+
.default(false)
|
|
133
|
+
.describe(
|
|
134
|
+
"Whether producing this capability's summary failed on this read — the fourth state (#350), and the reason `health` being null is not enough on its own. Carries no message, no code and no error: what the producer threw may name a row or a key, so nothing derived from it travels. False when the Worker predates this field.",
|
|
135
|
+
),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One composed capability, as `GET /control-plane/manifest` reports it to **this** caller.
|
|
140
|
+
*
|
|
141
|
+
* `health` is the only per-caller part of a manifest entry, and it is a four-state value rather than a
|
|
142
|
+
* nullable record: a capability that declares nothing, one that declares a number this connection was
|
|
143
|
+
* not granted, one that reports a number — zero included — and one whose producer failed. A management
|
|
144
|
+
* client that collapsed any two of those tells an adopter something untrue. The first two collapsed
|
|
145
|
+
* says everything is fine when it has simply not been allowed to look; the last two collapsed says
|
|
146
|
+
* nothing is pending when the store is down.
|
|
147
|
+
*
|
|
148
|
+
* The state travels **on** the value, so the scalars are unreachable without narrowing and a consumer
|
|
149
|
+
* that forgets the sick case gets a type error rather than a zero.
|
|
150
|
+
*/
|
|
151
|
+
const CapabilityDescriptorRead = CapabilityDeclaration.extend({
|
|
152
|
+
health: CapabilityHealthReport.describe(
|
|
153
|
+
"What this caller is told about the capability's own state: nothing declared, a number withheld, the numbers themselves, or a producer that failed. Four states on one value, so none of them is reachable by forgetting to check another.",
|
|
154
|
+
),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const CapabilityDescriptor = z
|
|
158
|
+
.codec(CapabilityDescriptorWire, CapabilityDescriptorRead, {
|
|
159
|
+
decode: ({ health, healthUnavailable, ...declaration }) => ({
|
|
160
|
+
...declaration,
|
|
161
|
+
health: healthReport({ healthKeys: declaration.healthKeys, health, healthUnavailable }),
|
|
162
|
+
}),
|
|
163
|
+
// Both fields, from the one value, in the one place. A handler cannot write the numbers and forget
|
|
164
|
+
// the flag, which is what makes `encode(decode(entry))` the entry again — the property the seam's
|
|
165
|
+
// response contract test rests on.
|
|
166
|
+
encode: ({ health, healthKeys, configKeys, config, ...declaration }) => ({
|
|
167
|
+
...declaration,
|
|
168
|
+
// Every defaulted field is optional on the way back out, and empty is the meaning absence already
|
|
169
|
+
// had: a capability that declares no summary, and one that states no configured fact.
|
|
170
|
+
//
|
|
171
|
+
// **The `map` is load-bearing for the compiler and inert at run time, and both halves are the
|
|
172
|
+
// point (#471).** `HealthSummaryKey.nominal` is `.default(null)`, so the key's input type has it
|
|
173
|
+
// optional and its output type has it present — and this encode sits exactly on that seam, so
|
|
174
|
+
// spreading the keys through unchanged does not compile.
|
|
175
|
+
//
|
|
176
|
+
// At run time the `?? null` never fires: the read side validates before this transform, and it
|
|
177
|
+
// demands `nominal`, so no value carrying a key without one ever reaches here. It is written as a
|
|
178
|
+
// `??` rather than a cast because the two branches genuinely mean the same thing — absence and
|
|
179
|
+
// null are both *this capability makes no claim* — so the widening is stated rather than asserted,
|
|
180
|
+
// and the day the read side stops validating first this keeps being correct instead of becoming
|
|
181
|
+
// a lie the type system was told to ignore.
|
|
182
|
+
healthKeys: (healthKeys ?? []).map((key) => ({ ...key, nominal: key.nominal ?? null })),
|
|
183
|
+
configKeys: configKeys ?? [],
|
|
184
|
+
config: config ?? {},
|
|
185
|
+
...healthWire(health),
|
|
186
|
+
}),
|
|
187
|
+
})
|
|
188
|
+
.describe(
|
|
189
|
+
"One capability this Worker composes, as reported to one caller: its version, its admin surface, and the summary that caller is entitled to — or the named reason there is none.",
|
|
190
|
+
);
|
|
191
|
+
export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The manifest a management client reads to build itself.
|
|
195
|
+
*
|
|
196
|
+
* **There is deliberately no manifest schema version, and that is a different thing from the two build
|
|
197
|
+
* identities below.** A client dispatches on the routes described here — what this Worker declares right
|
|
198
|
+
* now — so a capability that changed its paths or its scopes reports the change directly, and a schema
|
|
199
|
+
* version would be a second source of truth to keep in sync with the first. That reasoning is unchanged.
|
|
200
|
+
*
|
|
201
|
+
* What the manifest does carry is **identity**, not schema, and it carries two of them because they
|
|
202
|
+
* answer questions neither can answer alone. `version` is Cloudflare's opaque per-deploy id: it says
|
|
203
|
+
* *exactly which build* is running, which is what forensics needs, what reproduces a report, and what
|
|
204
|
+
* pins the code an audited action ran against. It carries no version semantics, so it says nothing about
|
|
205
|
+
* features. `capabilities[].version` is the npm version of each composed package: it says *which
|
|
206
|
+
* features*, which is what answers "should this customer upgrade", "which customers are exposed to what
|
|
207
|
+
* we just fixed", and "does this project predate the capability a pane needs". Reporting only one leaves
|
|
208
|
+
* half the questions unanswerable, and they are the halves people actually ask.
|
|
209
|
+
*
|
|
210
|
+
* **And each entry carries a third thing: the configured facts that capability states** (#422). Knowing
|
|
211
|
+
* where a route is and which scope it needs is not always enough to call it — `POST
|
|
212
|
+
* {base}/entitlements/grant` names a holder, and whether this project's holders are people or
|
|
213
|
+
* organizations is a decision the adopter made in config. A client that cannot read the decision guesses
|
|
214
|
+
* it, and a guess writes a row nothing reads. See `discovery/configuration.ts` for why a fact is not a
|
|
215
|
+
* health number, despite the two fields sitting side by side.
|
|
216
|
+
*/
|
|
217
|
+
export const ControlPlaneManifest = z
|
|
218
|
+
.object({
|
|
219
|
+
environment: z
|
|
220
|
+
.string()
|
|
221
|
+
.describe("The environment this connection is bound to, echoed so a client can label what it is looking at."),
|
|
222
|
+
connectionId: z.string().describe("The connection this call authenticated as."),
|
|
223
|
+
version: z
|
|
224
|
+
.string()
|
|
225
|
+
.nullable()
|
|
226
|
+
.describe(
|
|
227
|
+
"The Cloudflare version id of the build answering this call, from the `CF_VERSION_METADATA` binding, or null where the binding is absent. Opaque and per-deploy: it identifies which build is running, never which features it has. Null is honest — a Worker scaffolded before the binding was declared genuinely cannot say.",
|
|
228
|
+
),
|
|
229
|
+
capabilities: z
|
|
230
|
+
.array(CapabilityDescriptor)
|
|
231
|
+
.describe(
|
|
232
|
+
"Every capability composed into this Worker, in composition order with the adopter's own app last. A capability absent here has no data to show, which is a fact the client discovers rather than a setting anybody maintains.",
|
|
233
|
+
),
|
|
234
|
+
grantedScopes: z
|
|
235
|
+
.array(ControlPlaneScope)
|
|
236
|
+
.describe(
|
|
237
|
+
"Every scope this connection holds. Together with each route's own `scope`, this is what tells a client which operations to offer and which to hide.",
|
|
238
|
+
),
|
|
239
|
+
})
|
|
240
|
+
.describe("What this Worker is, what it composes, and how to call the admin surface it exposes.");
|
|
241
|
+
export type ControlPlaneManifest = z.infer<typeof ControlPlaneManifest>;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The manifest as the Worker sends it, before a client decodes it.
|
|
245
|
+
*
|
|
246
|
+
* The handler builds this and is `satisfies`-checked against it, which keeps the response a compile-time
|
|
247
|
+
* contract rather than a validation pass over values this Worker just built. A client parses with
|
|
248
|
+
* {@link ControlPlaneManifest} and gets the four-state health value out the far side.
|
|
249
|
+
*/
|
|
250
|
+
export type ControlPlaneManifestWire = z.input<typeof ControlPlaneManifest>;
|