@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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
@@ -0,0 +1,486 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ControlPlaneScope } from "../scope/scope";
6
+
7
+ /**
8
+ * The health vocabulary a manifest carries and a management client renders (#317, #350).
9
+ *
10
+ * **The count is not the expensive part. The round trip is.** A management client that wants to say
11
+ * "3 secrets need rotating" beside a rail already read the manifest; making it call again — once per
12
+ * capability it wants a number from, against a customer's production Worker, on every screen load —
13
+ * spends a credential per number nobody has asked for yet.
14
+ *
15
+ * The manifest is the right place because it already carries `version` per capability, and a version is
16
+ * a fact about a capability's *state* rather than about its routes. Nobody discovers anything from it;
17
+ * it is there because a management client needs it and a second call would be wasteful. This is the
18
+ * same argument, already accepted once.
19
+ *
20
+ * ## What keeps it from becoming a data API
21
+ *
22
+ * Three rules, and every one of them is in the type rather than in a comment:
23
+ *
24
+ * 1. **Scalars only.** {@link HealthSummaryValue} is `number | string` and nothing else, so a
25
+ * projection, a row, or a list does not compile — {@link HEALTH_SUMMARY_IS_SCALAR_ONLY} fails the
26
+ * build the moment somebody widens it.
27
+ * 2. **A closed vocabulary per capability**, declared alongside its routes. A produced key nobody
28
+ * declared is refused; the manifest never carries a number a client cannot name.
29
+ * 3. **Nothing that costs a query to produce.** {@link HealthValueCost} has `memory` and `indexed` and
30
+ * no third member, so a value that would need a table scan cannot be declared at all. A count that
31
+ * scans on every manifest read has moved the cost rather than removed it, and the manifest is the
32
+ * most frequently fetched thing this seam serves.
33
+ *
34
+ * ## A withheld number and a zero are different facts
35
+ *
36
+ * A count is less sensitive than the thing it counts, but "3 secrets need rotating" is still a fact
37
+ * about somebody's security posture. So a key inherits a scope the capability *already* gates a read
38
+ * with, and a caller without that scope gets no value at all rather than a zero.
39
+ *
40
+ * ## And a broken store is a fourth fact (#350)
41
+ *
42
+ * #317 got three states right and left the fourth to a reviewer, deliberately: a producer that throws
43
+ * is neither a withheld number nor a zero, and returning null for it would have made a sick store
44
+ * indistinguishable from one this caller may not look at. The reasoning was right. The behavior was
45
+ * that the throw propagated, the whole manifest read failed, and one sick capability blanked Overview
46
+ * for every capability beside it — with nothing on screen saying which one.
47
+ *
48
+ * So there are four, and they live on {@link CapabilityHealthReport}: `undeclared`, `withheld`,
49
+ * `reported`, `unavailable`. The state rides on the value rather than beside it, so a consumer reaches
50
+ * a number only by narrowing, and one that forgets the failure does not render a zero — it does not
51
+ * compile.
52
+ *
53
+ * **Staleness.** These numbers are as old as the manifest a client cached. They are right for a rail
54
+ * and must never be presented as live.
55
+ *
56
+ * ## Why this is not in `health.ts` (#430)
57
+ *
58
+ * It was, and that put the whole Workers data layer one type-only edge from a browser. `adminRoute.ts`
59
+ * imports `healthReport` and `HealthSummaryKey` to describe a manifest entry; every capability's
60
+ * control-plane scope declaration imports `AdminRoute` from `adminRoute.ts`; and `health.ts` imports
61
+ * `Capability` and `PithyHonoEnv`, which reach `data/db.ts` and `kv/kv.ts`. Eight modules a browser is
62
+ * meant to import were compiling `@cloudflare/workers-types`, `hono`, `kysely` and `kysely-d1` — 46 kit
63
+ * files to declare five strings — and `coverage.test.ts`'s type-only rule was satisfied throughout,
64
+ * because **the compiler follows a type-only edge exactly as it follows a value one**.
65
+ *
66
+ * So the seam is next door and this is the wire. This module's whole import list is `zod` and a scope,
67
+ * and the split is along a line nothing crosses: not one symbol here needs a `Context`, a `Capability`
68
+ * or an `InternalError`. `tooling/browser-scopes` compiles each scope declaration as a browser program
69
+ * and fails on anything but `zod`, so the line is held by a gate rather than by this paragraph.
70
+ */
71
+
72
+ /** A health key's name: one camelCase token, so it reads as a field and never as a path or an id. */
73
+ const HEALTH_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
74
+
75
+ /** What kind of scalar a key reports. */
76
+ export const HealthValueKind = z
77
+ .enum(["count", "state"])
78
+ .describe(
79
+ "What kind of scalar this key reports: a `count` (a whole non-negative number) or a `state` (one member of a closed list). There is no third kind, because a third kind is how a summary becomes a projection.",
80
+ );
81
+ export type HealthValueKind = z.output<typeof HealthValueKind>;
82
+
83
+ /**
84
+ * What producing one value costs on a manifest read.
85
+ *
86
+ * **There is deliberately no member for a scan.** That is the whole constraint expressed as a
87
+ * vocabulary: a capability whose number would need one cannot state its cost, so it cannot declare the
88
+ * key, so the manifest stays cheap by construction rather than by everybody remembering.
89
+ */
90
+ export const HealthValueCost = z
91
+ .enum(["memory", "indexed"])
92
+ .describe(
93
+ "What this value costs to produce on a manifest read: `memory` for something already in the Worker's hands, `indexed` for a lookup an index answers and whose bound is a declaration rather than the adopter's data. No member names a scan, so a value that would need one cannot be declared.",
94
+ );
95
+ export type HealthValueCost = z.output<typeof HealthValueCost>;
96
+
97
+ /**
98
+ * One value a summary may carry. A number or a member of a declared enum — never anything else.
99
+ *
100
+ * This is the rule the issue asked to be in the type: `number | string` has no room for a row, an id, a
101
+ * nested object, or a collection that grows with an adopter's data.
102
+ */
103
+ export const HealthSummaryValue = z
104
+ .union([z.number(), z.string()])
105
+ .describe(
106
+ "One scalar: a number, or a member of the closed list its key declares. Nothing else is representable — no names, no ids, no rows, no nested objects.",
107
+ );
108
+ export type HealthSummaryValue = z.output<typeof HealthSummaryValue>;
109
+
110
+ /** Anything that is not a scalar. Named as a type so the tripwire below is a list to delete from. */
111
+ type NonScalar = object | ((...args: never[]) => unknown);
112
+
113
+ /**
114
+ * `true` while `T` is scalars only, `never` the moment it admits anything else — so the assignment
115
+ * below stops compiling if {@link HealthSummaryValue} is ever widened.
116
+ *
117
+ * The tuple wrapper is not decoration: a bare `Extract<...> extends never` distributes over a union and
118
+ * would answer `true` for every member, which is the one way this check could quietly pass.
119
+ */
120
+ export type ScalarOnly<T> = [Extract<T, NonScalar>] extends [never] ? true : never;
121
+
122
+ /** The compile-time half of "scalars only". Widen the value type and this line names the file. */
123
+ export const HEALTH_SUMMARY_IS_SCALAR_ONLY: ScalarOnly<HealthSummaryValue> = true;
124
+
125
+ /**
126
+ * What a count must be for its key to be nominal — a bound, in either direction or both.
127
+ *
128
+ * **Both are optional and at least one is required**, which is the whole of the shape. `atMost: 0` is
129
+ * `secretsDueForRotation`, where zero is the good answer; `atLeast: 1` is a count of things that must
130
+ * exist, where zero is the fault. A declaration carrying neither is a claim with no content, and one
131
+ * carrying an inverted pair is a claim nothing can satisfy — both would make every value on that key
132
+ * want attention forever, which is worse than declaring nothing.
133
+ *
134
+ * Inclusive at the edge. A cadence saying *no more than zero overdue* means zero is fine.
135
+ */
136
+ export const HealthCountNominal = z
137
+ .object({
138
+ atMost: z
139
+ .number()
140
+ .int()
141
+ .nonnegative()
142
+ .optional()
143
+ .describe("The largest nominal value, inclusive. Above it the value wants attention."),
144
+ atLeast: z
145
+ .number()
146
+ .int()
147
+ .nonnegative()
148
+ .optional()
149
+ .describe("The smallest nominal value, inclusive. Below it the value wants attention."),
150
+ })
151
+ .describe(
152
+ "A count's nominal range: at least one bound, inclusive at its edge. Neither is a claim with no content; an inverted pair is one nothing can satisfy.",
153
+ );
154
+ export type HealthCountNominal = z.output<typeof HealthCountNominal>;
155
+
156
+ /**
157
+ * What a key claims a nominal value is, or null where it makes no claim at all.
158
+ *
159
+ * **Null is the default and stays supported forever.** Some measures genuinely have no good or bad
160
+ * value — a count of things that simply exist — and a vocabulary that forced a claim would collect
161
+ * invented ones. What must never happen is a client reading null as *fine*; {@link standingOf} answers
162
+ * `unknowable` for it, and that is the point of this whole field.
163
+ */
164
+ /*
165
+ **These two are the shape; `HealthSummaryKey` is the gate.** Standalone, this accepts `{}` and `[]` —
166
+ the rules that refuse them depend on `kind`, which lives on the key rather than here, so they are
167
+ enforced in that object's refine. A consumer validating a nominal on its own gets the loose shape and
168
+ should parse the whole key instead.
169
+ */
170
+ export const HealthNominal = z
171
+ .union([HealthCountNominal, z.array(z.string().min(1))])
172
+ .nullable()
173
+ .describe(
174
+ "What this key's nominal value is: a bound for a `count`, the nominal members for a `state`, or null where the capability makes no claim. Which shape applies is decided by `kind`.",
175
+ );
176
+ export type HealthNominal = z.output<typeof HealthNominal>;
177
+
178
+ /**
179
+ * Whether a declared nominal fits the kind it was declared on.
180
+ *
181
+ * A free function rather than an inline predicate because the refine needs it before the object exists
182
+ * and because `states` is read from the same key — a `state` key's nominal must name members that key
183
+ * actually declares, or the claim is unverifiable: a producer can never send a value that matches it.
184
+ *
185
+ * Null is always suitable. That is the default, and a key that grades nothing is the common case.
186
+ */
187
+ function nominalSuitsKind(kind: HealthValueKind, nominal: HealthNominal, states: readonly string[] | null): boolean {
188
+ if (nominal === null) return true;
189
+ if (kind === "count") {
190
+ if (Array.isArray(nominal)) return false;
191
+ const { atMost, atLeast } = nominal;
192
+ // A bound with no side is a claim with no content; an inverted one is a claim nothing satisfies.
193
+ if (atMost === undefined && atLeast === undefined) return false;
194
+ return atMost === undefined || atLeast === undefined || atLeast <= atMost;
195
+ }
196
+ if (!Array.isArray(nominal) || nominal.length === 0) return false;
197
+ // Every nominal member has to be one the key declares, or the claim is one no producer could satisfy.
198
+ //
199
+ // `states` is null only on a declaration the refine above this one has already refused — a `state`
200
+ // key must declare a non-empty list. Answering `true` there reports the one real fault rather than
201
+ // two, and this is never the only thing standing between a bad declaration and the wire.
202
+ return states === null ? true : nominal.every((member) => states.includes(member));
203
+ }
204
+
205
+ /**
206
+ * One key a capability may report, declared alongside its routes.
207
+ *
208
+ * `scope` is **not nullable**, unlike an admin route's. A route may need only a verified caller — the
209
+ * seam's `ping` does — but a number about somebody's posture never may, and an optional field here is a
210
+ * field somebody forgets.
211
+ */
212
+ export const HealthSummaryKey = z
213
+ .object({
214
+ key: z
215
+ .string()
216
+ .regex(HEALTH_KEY_PATTERN, "A health key is one camelCase token — `secretsDueForRotation`.")
217
+ .describe("The key this value appears under in the capability's summary. One camelCase token."),
218
+ kind: HealthValueKind.describe("Whether the value is a count or a member of a closed list."),
219
+ states: z
220
+ .array(z.string().min(1))
221
+ .nullable()
222
+ .describe(
223
+ "Every value a `state` key may take, so a client can render each one it knows and nothing for one it does not. Null for a `count`, and required for a `state`: an enum with no members is a string field wearing a costume.",
224
+ ),
225
+ scope: ControlPlaneScope.describe(
226
+ "The scope this value is behind — one the capability's own admin routes already require, never a new one. A caller without it gets no value rather than a zero.",
227
+ ),
228
+ cost: HealthValueCost.describe("What producing this value costs on a manifest read."),
229
+ summary: z
230
+ .string()
231
+ .min(1)
232
+ .describe("One line saying what the number means, for a client to render beside it without knowing the key."),
233
+ /*
234
+ **#471. `summary` says what the value means; this says what it should be.**
235
+
236
+ Without it a client holds a number, a scope, a cost and an English sentence, and cannot tell a
237
+ good value from a bad one: `secretsDueForRotation: 0` is the good answer and a `verifiedSenders: 0`
238
+ would be a fault, from declarations identical in every other field. A management client that
239
+ rendered either as a finding was claiming a verdict the manifest never carried.
240
+
241
+ `.default(null)` rather than required, and permanently: it is what makes every manifest built
242
+ before this field parse unchanged, and it is the honest answer for a measure nobody grades.
243
+ */
244
+ nominal: HealthNominal.default(null).describe(
245
+ "What a nominal value is for this key — a bound for a `count`, the nominal members for a `state`, or null where the capability makes no claim. A client reads null as `unknowable`, never as healthy.",
246
+ ),
247
+ })
248
+ .refine((key) => (key.kind === "state" ? key.states !== null && key.states.length > 0 : key.states === null), {
249
+ message: "A `state` key declares a non-empty closed list; a `count` key declares none.",
250
+ path: ["states"],
251
+ })
252
+ /*
253
+ The shape of `nominal` is decided by `kind`, exactly as `states` is one refine above.
254
+
255
+ **Asserted both ways in the suite**, because a refine written for one direction admits the other —
256
+ the lesson the `states` refine already records, and the reason this is a predicate over both rather
257
+ than a check that a count's nominal is an object.
258
+ */
259
+ .refine((key) => nominalSuitsKind(key.kind, key.nominal, key.states), {
260
+ message:
261
+ "A `count` key's nominal is a bound with at least one satisfiable side; a `state` key's is a non-empty list of members it declares.",
262
+ path: ["nominal"],
263
+ })
264
+ .describe(
265
+ "One bounded scalar a capability contributes to its manifest entry: what it is called, what it may be, what it costs, and which scope it is behind.",
266
+ );
267
+ export type HealthSummaryKey = z.output<typeof HealthSummaryKey>;
268
+
269
+ /**
270
+ * A capability's summary as the manifest carries it: declared key → scalar.
271
+ *
272
+ * A record rather than a closed object, because the vocabulary is federated the way scopes and audit
273
+ * actions are. **Parsing tolerates a key it has never heard of on purpose** — a client reading a newer
274
+ * Worker must render the unknown one as nothing rather than fail the whole manifest.
275
+ */
276
+ export const HealthSummary = z
277
+ .record(z.string(), HealthSummaryValue)
278
+ .describe(
279
+ "One capability's health summary: each declared key to its scalar. Unknown keys parse rather than throw, so a client of an older build renders what it knows and nothing for the rest.",
280
+ );
281
+ export type HealthSummary = z.output<typeof HealthSummary>;
282
+
283
+ /**
284
+ * What one capability's summary is, for one caller. Four states, and the value carries which.
285
+ *
286
+ * A discriminated union rather than a nullable record with a flag beside it, and that is the whole
287
+ * point of #350. A flag is the same information and the opposite property: correct for whoever
288
+ * remembered to read it, and a zero for everybody else. Here the scalars are unreachable without
289
+ * narrowing on `state`, so forgetting the sick case is a type error rather than a screen that says
290
+ * everything is fine.
291
+ *
292
+ * - `undeclared` — the capability contributes no summary. Nothing to show, and nothing wrong.
293
+ * - `withheld` — it declares one, and this connection was not granted the scope it sits behind. Read
294
+ * `healthKeys` beside this and a client can say a number exists that it may not see.
295
+ * - `reported` — the values, each one named by a declaration that travels with it. Zero is one of these.
296
+ * - `unavailable` — producing the summary failed. Not zero, not withheld, and never both.
297
+ */
298
+ export const CapabilityHealthReport = z
299
+ .discriminatedUnion("state", [
300
+ z
301
+ .object({
302
+ state: z.literal("undeclared").describe("This capability contributes no summary at all."),
303
+ })
304
+ .describe("A capability with nothing to report. Nothing to show, and nothing wrong."),
305
+ z
306
+ .object({
307
+ state: z.literal("withheld").describe("A summary exists that this connection was not granted."),
308
+ })
309
+ .describe(
310
+ "A declared summary this caller may not see. Read `healthKeys` beside it to say a number exists rather than to say there is none.",
311
+ ),
312
+ z
313
+ .object({
314
+ state: z.literal("reported").describe("The summary was produced and this caller may see it."),
315
+ values: HealthSummary.describe("Each declared key this caller is entitled to, and its scalar. Zero is one."),
316
+ })
317
+ .describe("The numbers, each one named by a declaration that travels beside it in `healthKeys`."),
318
+ z
319
+ .object({
320
+ state: z.literal("unavailable").describe("Producing this capability's summary failed on this read."),
321
+ })
322
+ .describe(
323
+ "A summary that could not be produced. Deliberately empty: what the producer threw may name a row or a key, so nothing derived from it travels — there is nowhere to put it.",
324
+ ),
325
+ ])
326
+ .describe(
327
+ "What one capability's summary is, for one caller: nothing declared, declared and withheld, reported, or failed. The state is on the value, so a scalar is unreachable without narrowing.",
328
+ );
329
+ export type CapabilityHealthReport = z.output<typeof CapabilityHealthReport>;
330
+
331
+ /** A manifest entry's health as the wire carries it: the #317 fields, plus the flag #350 added. */
332
+ export interface CapabilityHealthWire {
333
+ /** The closed vocabulary this capability may report. Empty when it declares none. */
334
+ healthKeys: readonly HealthSummaryKey[];
335
+ /** The values this caller may see, or null when there are none to give. Null is never zero. */
336
+ health: HealthSummary | null;
337
+ /** Whether producing the summary failed on this read. */
338
+ healthUnavailable: boolean;
339
+ }
340
+
341
+ /**
342
+ * Read the four states off a manifest entry.
343
+ *
344
+ * **The failure flag is checked first and wins.** A Worker that sends both a failure and values is
345
+ * describing a producer that did not finish, and reading what it sent anyway is reading whatever the
346
+ * failure left behind.
347
+ *
348
+ * A Worker deployed before #350 sends no flag, which defaults to false, and lands on the three states
349
+ * it already had. A Worker deployed before #317 sends neither field and lands on `undeclared`.
350
+ */
351
+ export function healthReport(entry: CapabilityHealthWire): CapabilityHealthReport {
352
+ if (entry.healthUnavailable) return { state: "unavailable" };
353
+ if (entry.health) return { state: "reported", values: entry.health };
354
+ return entry.healthKeys.length === 0 ? { state: "undeclared" } : { state: "withheld" };
355
+ }
356
+
357
+ /**
358
+ * Put a report back on the wire.
359
+ *
360
+ * The one place the two fields are written, so a handler cannot set the values and forget the flag.
361
+ * `undeclared` and `withheld` encode alike on purpose — `healthKeys` rides in the same entry and tells
362
+ * them apart, which is the arrangement #317 chose and this does not disturb.
363
+ */
364
+ export function healthWire(report: CapabilityHealthReport): {
365
+ health: HealthSummary | null;
366
+ healthUnavailable: boolean;
367
+ } {
368
+ return {
369
+ health: report.state === "reported" ? report.values : null,
370
+ healthUnavailable: report.state === "unavailable",
371
+ };
372
+ }
373
+
374
+ /** One value with the declaration that says how to render it. */
375
+ export interface NamedHealthValue {
376
+ /** The declaration — its kind, its closed list, what it means, and what it cost. */
377
+ key: HealthSummaryKey;
378
+ /** The scalar reported for it. */
379
+ value: HealthSummaryValue;
380
+ }
381
+
382
+ /**
383
+ * Pair a capability's reported values with their declarations, dropping any value nothing declares.
384
+ *
385
+ * This is how "an unknown summary key is renderable as nothing rather than as an error" is real rather
386
+ * than aspirational: a client renders what this returns, so a key from a Worker newer than its
387
+ * declaration simply is not in the list.
388
+ *
389
+ * **Only `reported` has values.** A withheld summary and a failed one both name nothing here, which is
390
+ * right for a list of numbers — and it is why a surface that must say *why* there is no number reads
391
+ * `state` rather than the length of this.
392
+ */
393
+ export function namedHealthValues(descriptor: {
394
+ healthKeys: readonly HealthSummaryKey[];
395
+ health: CapabilityHealthReport;
396
+ }): NamedHealthValue[] {
397
+ const health = descriptor.health;
398
+ if (health.state !== "reported") return [];
399
+ const named: NamedHealthValue[] = [];
400
+ for (const key of descriptor.healthKeys) {
401
+ const value = health.values[key.key];
402
+ if (value !== undefined) named.push({ key, value });
403
+ }
404
+ return named;
405
+ }
406
+
407
+ /**
408
+ * Where one value stands against its own declaration — #471.
409
+ *
410
+ * Three answers, and the third is the reason this exists.
411
+ *
412
+ * - `nominal` — the value is what the capability said it should be.
413
+ * - `attention` — it is not.
414
+ * - `unknowable` — **nobody said what it should be**, so nothing can be concluded.
415
+ *
416
+ * **`unknowable` is never `nominal`, and a client must not collapse them.** A key that declares no
417
+ * bound is a key nobody can grade; answering `nominal` for it would let a management client read
418
+ * healthy because nothing told it otherwise, which is the defect this whole field was filed to remove
419
+ * rather than relocate. #350 made the four report states a discriminated union for the same reason —
420
+ * so a consumer that forgets the sick case gets a type error instead of a screen that lies — and this
421
+ * is the same choice one level down.
422
+ *
423
+ * It is also the answer for a value whose **type** contradicts its `kind` — a string where a count was
424
+ * declared. `checked()` refuses that on the producing side, but a client parses manifests from Workers
425
+ * it does not control, and grading a value that is not the kind of thing being graded is inventing an
426
+ * answer about it.
427
+ *
428
+ * **A `state` value outside its own `states` is a different case and is `attention`, deliberately.** It
429
+ * is a string, so it is the kind of thing being graded; it is simply not one the capability said it
430
+ * would send. Answering `unknowable` there would file a Worker reporting `storeState: "exploded"` under
431
+ * *nothing can be concluded*, when what can be concluded is that something is wrong.
432
+ */
433
+ export type HealthStanding = "nominal" | "attention" | "unknowable";
434
+
435
+ /** Where {@link HealthSummaryValue} stands against the key that declared it. See {@link HealthStanding}. */
436
+ export function standingOf(key: HealthSummaryKey, value: HealthSummaryValue): HealthStanding {
437
+ const nominal = key.nominal;
438
+ /*
439
+ **`== null`, which is `undefined` as well, and the asymmetry is what made it a defect.**
440
+
441
+ A key that reaches here without the field is one somebody built by hand and asserted into the type
442
+ rather than parsed — `.default(null)` fills it on every parsed path. With `=== null` the `state`
443
+ branch below still answered `unknowable`, because `!Array.isArray(undefined)` catches it, while this
444
+ one fell through to `nominal.atMost` and threw. One malformed input, two behaviors, and the noisier
445
+ of the two on the branch a client is likelier to hit.
446
+
447
+ This function is exported, and this module's own doctrine is that a client parses manifests from
448
+ Workers it does not control. Answering the question is what it is for; throwing is not an answer.
449
+ */
450
+ if (nominal == null) return "unknowable";
451
+ if (key.kind === "count") {
452
+ if (Array.isArray(nominal) || typeof value !== "number" || !Number.isInteger(value)) return "unknowable";
453
+ // Inclusive at both edges — a cadence saying *no more than zero overdue* means zero is fine.
454
+ if (nominal.atMost !== undefined && value > nominal.atMost) return "attention";
455
+ if (nominal.atLeast !== undefined && value < nominal.atLeast) return "attention";
456
+ return "nominal";
457
+ }
458
+ if (!Array.isArray(nominal) || typeof value !== "string") return "unknowable";
459
+ return nominal.includes(value) ? "nominal" : "attention";
460
+ }
461
+
462
+ /**
463
+ * Every value this capability reported that wants somebody's attention, in declaration order.
464
+ *
465
+ * `namedHealthValues` one filter later, and here rather than in every client so the filter is written
466
+ * once. The three stateless reports answer empty: `undeclared`, `withheld` and `unavailable` are states
467
+ * of the *report* rather than of any value, and folding one of them into a list of graded measures is
468
+ * exactly the collapse #350 and #471 each exist to prevent — a withheld number is not a bad number, and
469
+ * a failed read is not a finding about the thing that failed to be read.
470
+ *
471
+ * A value standing at `unknowable` is not here either. The list is what wants attention, and a measure
472
+ * nobody grades cannot want it.
473
+ *
474
+ * **So an empty list is not a clean bill of health, and a client that renders it as one has rebuilt
475
+ * #471 one layer up.** Five situations answer `[]`: nothing declared, withheld, unavailable, everything
476
+ * nominal, and everything ungradeable. Three of those are *could not look* rather than *nothing is
477
+ * wrong*. A surface that wants to tell them apart has what it needs — the report's own state separates
478
+ * the first three, and mapping {@link namedHealthValues} through {@link standingOf} separates the last
479
+ * two — but it has to ask, and this function deliberately does not answer it.
480
+ */
481
+ export function healthAttention(descriptor: {
482
+ healthKeys: readonly HealthSummaryKey[];
483
+ health: CapabilityHealthReport;
484
+ }): NamedHealthValue[] {
485
+ return namedHealthValues(descriptor).filter((named) => standingOf(named.key, named.value) === "attention");
486
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "../../error/pithyError";
5
+ import type { MessageParams } from "../../i18n/catalog";
6
+
7
+ /**
8
+ * The `control-plane` strategy's throw sugar. The `controlplane/*` codes live in core's closed
9
+ * `KitErrorPayload` union (CLAUDE.md §Errors); these subclasses are the vehicles that set one.
10
+ *
11
+ * The security posture here is stricter than elsewhere in the tree. Public `message` and `action` are
12
+ * written for an operator reading a management client's logs, and say nothing a caller could use to
13
+ * probe the seam. Throw-site context — which verification step failed, which `kid` was presented,
14
+ * which environment the connection is bound to — goes in `detail`, which the HTTP codec strips.
15
+ *
16
+ * {@link ControlPlaneInvalidCredentialError} in particular is deliberately one error for every
17
+ * failing step. Distinguishing "unknown key" from "bad signature" from "replayed token" would turn
18
+ * the response into an oracle that tells an attacker exactly how far they got.
19
+ */
20
+
21
+ interface ControlPlaneErrorArgs {
22
+ message?: string;
23
+ action?: string;
24
+ detail?: string;
25
+ /**
26
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
27
+ * unlike `action` and `detail` — these cross the boundary with `message`.
28
+ */
29
+ params?: MessageParams;
30
+ }
31
+
32
+ /**
33
+ * No connection is registered for this environment. The shipped default of a Worker that composes the
34
+ * seam and has never been connected — present, and denying everything.
35
+ */
36
+ export class ControlPlaneNotConnectedError extends PithyError {
37
+ constructor(args: ControlPlaneErrorArgs = {}, options?: { cause?: unknown }) {
38
+ super(
39
+ {
40
+ code: "controlplane/not_connected",
41
+ status: 403,
42
+ message: args.message ?? "No management client is connected to this environment.",
43
+ action: args.action ?? "Run pithy dashboard connect --env <environment> to register one.",
44
+ detail: args.detail,
45
+ params: args.params,
46
+ },
47
+ options,
48
+ );
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The credential failed verification. One error for every step — pass the step in `detail` so the log
54
+ * can say which, and the caller cannot.
55
+ */
56
+ export class ControlPlaneInvalidCredentialError extends PithyError {
57
+ constructor(args: ControlPlaneErrorArgs = {}, options?: { cause?: unknown }) {
58
+ super(
59
+ {
60
+ code: "controlplane/invalid_credential",
61
+ status: 401,
62
+ message: args.message ?? "That credential is not valid here.",
63
+ action: args.action ?? "Rotate the connection's key, or re-run pithy dashboard connect --update.",
64
+ detail: args.detail,
65
+ params: args.params,
66
+ },
67
+ options,
68
+ );
69
+ }
70
+ }
71
+
72
+ /** The credential verified, but nothing granted it this operation. */
73
+ export class ControlPlaneInsufficientScopeError extends PithyError {
74
+ constructor(args: ControlPlaneErrorArgs = {}, options?: { cause?: unknown }) {
75
+ super(
76
+ {
77
+ code: "controlplane/insufficient_scope",
78
+ status: 403,
79
+ message: args.message ?? "This connection is not scoped for that operation.",
80
+ action: args.action ?? "Re-run pithy dashboard connect --update and grant the scope this call needs.",
81
+ detail: args.detail,
82
+ params: args.params,
83
+ },
84
+ options,
85
+ );
86
+ }
87
+ }
88
+
89
+ /** The named key is not registered on this connection. */
90
+ export class ControlPlaneKeyNotFoundError extends PithyError {
91
+ constructor(args: ControlPlaneErrorArgs = {}, options?: { cause?: unknown }) {
92
+ super(
93
+ {
94
+ code: "controlplane/key_not_found",
95
+ status: 404,
96
+ message: args.message ?? "No registered key answers to that key id.",
97
+ action: args.action ?? "Read GET /control-plane/keys for the keys this connection currently trusts.",
98
+ detail: args.detail,
99
+ params: args.params,
100
+ },
101
+ options,
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * A key operation conflicts with the connection's current state — a duplicate registration, or an
108
+ * expiry that would leave no live key. The second is the lockout case, and refusing it is the reason
109
+ * expiry is a separate call from registration.
110
+ */
111
+ export class ControlPlaneKeyConflictError extends PithyError {
112
+ constructor(args: ControlPlaneErrorArgs = {}, options?: { cause?: unknown }) {
113
+ super(
114
+ {
115
+ code: "controlplane/key_conflict",
116
+ status: 409,
117
+ message: args.message ?? "That key operation conflicts with the connection's current state.",
118
+ action: args.action ?? "Register the replacement key and prove it with a ping before expiring this one.",
119
+ detail: args.detail,
120
+ params: args.params,
121
+ },
122
+ options,
123
+ );
124
+ }
125
+ }