@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,280 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { InternalError } from "../../error/pithyError";
6
+
7
+ /**
8
+ * The configured facts a capability states into its own manifest entry (#422).
9
+ *
10
+ * **A management client cannot call an operation it has to guess an argument for.**
11
+ * `POST {base}/entitlements/grant` names the holder and never assumes it, which is right — but
12
+ * `PaymentsConfig.billingSubject` decides whether this project's holders are people or organizations,
13
+ * it is required with no default, and nothing on the wire said so. A client guessing `user` against an
14
+ * organization-billed project writes a row nothing reads: the call succeeds, the audit line is real,
15
+ * the screen says `Done.`, and the person still cannot use what somebody just gave them. That is worse
16
+ * than a refusal, because it looks like it worked.
17
+ *
18
+ * So a capability declares the configured facts a client must respect, and the manifest carries them
19
+ * beside the routes they govern. `billingSubject` is the first and will not be the last, which is why
20
+ * this is a vocabulary rather than one field bolted onto the manifest.
21
+ *
22
+ * ## This is not health, and the difference is the whole design
23
+ *
24
+ * The shapes rhyme — `configKeys`/`config` beside `healthKeys`/`health` — and a reader who folds them
25
+ * together gets both wrong. They differ on every axis that matters:
26
+ *
27
+ * - A health value is **per caller**; a configured fact is the same for everybody.
28
+ * - A health value is **produced**, so it may fail, and `unavailable` is one of its four states. A fact
29
+ * is read off resolved config at assembly, so there is no producer, no query, and no failure state to
30
+ * collapse into a zero.
31
+ * - A health value is **as old as the manifest a client cached** and must never be shown as live. A
32
+ * fact changes only when the adopter redeploys, which is when the manifest changes anyway.
33
+ * - A health value is a number to **render**. A fact is a decision to **respect** — it goes into the
34
+ * next request a client makes, not onto a rail.
35
+ *
36
+ * ## No per-key scope, deliberately
37
+ *
38
+ * A health key's `scope` is required, on the stated ground that a count is still a fact
39
+ * about somebody's security posture. A configured fact is not that. `billingSubject` names no account,
40
+ * no transaction and no amount, and reads identically against an empty database — it is a fact about
41
+ * the adopter's own configuration, and the whole manifest already sits behind `manifest:read`, the same
42
+ * gate that discloses every route, every scope and every composed version.
43
+ *
44
+ * Withholding stays addable with no wire change if a later fact wants it — a from-address, a region. A
45
+ * value absent from `config` while its key is present in `configKeys` already means "declared and not
46
+ * shown", which is the same pairing that tells `undeclared` from `withheld` for health, and
47
+ * {@link namedConfigValues} already survives it.
48
+ *
49
+ * ## What keeps it from becoming a config dump
50
+ *
51
+ * A capability's resolved config holds provider secrets, price ids and an adopter's whole catalog. The
52
+ * factory is the only door, and it is narrow on purpose: scalars only, a closed declared vocabulary,
53
+ * and a value refused unless something declared it. Nobody can write `manifestConfig: resolved`.
54
+ */
55
+
56
+ /** A fact's name: one camelCase token, so it reads as a field and never as a path or an id. */
57
+ const MANIFEST_CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
58
+
59
+ /**
60
+ * One configured fact's value. A string, a number, or a boolean — never anything else.
61
+ *
62
+ * The rule is in the type rather than in a comment: a config object, a product list, or a credentials
63
+ * bag does not compile, so the seam cannot become the route by which an adopter's catalog reaches a
64
+ * discovery read.
65
+ */
66
+ export const ManifestConfigValue = z
67
+ .union([z.string(), z.number(), z.boolean()])
68
+ .describe(
69
+ "One scalar: a word, a number, or a flag. Nothing else is representable — no nested objects, no lists, no credentials.",
70
+ );
71
+ export type ManifestConfigValue = z.output<typeof ManifestConfigValue>;
72
+
73
+ /** Anything that is not a scalar. Named as a type so the tripwire below is a list to delete from. */
74
+ type NonScalar = object | ((...args: never[]) => unknown);
75
+
76
+ /**
77
+ * `true` while `T` is scalars only, `never` the moment it admits anything else — so the assignment
78
+ * below stops compiling if {@link ManifestConfigValue} is ever widened.
79
+ *
80
+ * Declared here rather than imported from `health.ts`: that module reaches `capability.ts` and, through
81
+ * it, hono and kysely, and this one is zod-only so a browser client can read the manifest without them
82
+ * (#430). The tuple wrapper is not decoration — a bare `Extract<...> extends never` distributes over a
83
+ * union and would answer `true` for every member, which is the one way this check could quietly pass.
84
+ */
85
+ type ScalarOnly<T> = [Extract<T, NonScalar>] extends [never] ? true : never;
86
+
87
+ /** The compile-time half of "scalars only". Widen the value type and this line names the file. */
88
+ export const MANIFEST_CONFIG_VALUE_IS_SCALAR_ONLY: ScalarOnly<ManifestConfigValue> = true;
89
+
90
+ /**
91
+ * One configured fact a capability may state, declared alongside its routes.
92
+ *
93
+ * `choices` is nullable rather than optional, and a null means "no closed list" rather than "not
94
+ * decided yet": a client renders a fact it has never heard of from the summary, and offers a picker
95
+ * only where the values are enumerable. A base path has no list; what a project bills has exactly two.
96
+ */
97
+ export const ManifestConfigKey = z
98
+ .object({
99
+ key: z
100
+ .string()
101
+ .regex(MANIFEST_CONFIG_KEY_PATTERN, "A config key is one camelCase token — `billingSubject`.")
102
+ .describe("The key this fact appears under in the capability's config. One camelCase token."),
103
+ choices: z
104
+ .array(z.string().min(1))
105
+ .min(1, "A closed list needs a value in it. Write null where the values are not enumerable.")
106
+ .nullable()
107
+ .describe(
108
+ "Every value this fact may take, so a client can offer the same choice the adopter made, or null where the values are not enumerable. At least one where it is a list: an empty one is not `null` spelled differently, it is a list nothing satisfies, so the capability could never boot and the refusal would name no permitted value at all. Read off the capability's own enum rather than retyped, so a new member cannot land without the manifest learning it.",
109
+ ),
110
+ summary: z
111
+ .string()
112
+ .min(1)
113
+ .describe("One line saying what the fact governs, for a client to render beside it without knowing the key."),
114
+ })
115
+ .describe(
116
+ "One configured fact a capability states: what it is called, what it may be, and what it decides — enough for a client to respect a decision it did not make.",
117
+ );
118
+ export type ManifestConfigKey = z.output<typeof ManifestConfigKey>;
119
+
120
+ /**
121
+ * A capability's configured facts as the manifest carries them: declared key → scalar.
122
+ *
123
+ * A record rather than a closed object, because the vocabulary is federated the way scopes and audit
124
+ * actions are — the capability name already namespaces it. **Parsing tolerates a key it has never heard
125
+ * of on purpose**: a client reading a newer Worker must drop the unknown one rather than fail the whole
126
+ * manifest, which is what {@link namedConfigValues} does with it.
127
+ */
128
+ export const ManifestConfigValues = z
129
+ .record(z.string(), ManifestConfigValue)
130
+ .describe(
131
+ "One capability's configured facts: each declared key to its scalar. Unknown keys parse rather than throw, so a client of an older build reads what it knows and nothing for the rest.",
132
+ );
133
+ export type ManifestConfigValues = z.output<typeof ManifestConfigValues>;
134
+
135
+ /**
136
+ * The brand only {@link defineManifestConfig} can produce.
137
+ *
138
+ * Module-local and never exported, so nothing outside this file can write it: the factory becomes the
139
+ * **only** way to build the seam, and a fact therefore cannot reach a manifest without having been
140
+ * checked against a declaration. An inline object literal on `Capability.manifestConfig` is a compile
141
+ * error, which is what stops the next capability from stating a fact nothing validated.
142
+ */
143
+ const manifestConfigSeam: unique symbol = Symbol("pithy.controlPlane.manifestConfig");
144
+
145
+ /** What a capability declares and what it resolved to. Built only by {@link defineManifestConfig}. */
146
+ export interface CapabilityManifestConfig {
147
+ /** The brand. Present only on a declaration that went through the factory, and never serialized. */
148
+ readonly [manifestConfigSeam]: true;
149
+ /** The closed vocabulary: every fact this capability states, parsed. */
150
+ readonly keys: readonly ManifestConfigKey[];
151
+ /** What each declared fact resolved to for this composition. One answer, for every caller. */
152
+ readonly values: ManifestConfigValues;
153
+ }
154
+
155
+ /** The authoring shape — facts as written, before parsing. */
156
+ export interface CapabilityManifestConfigInput {
157
+ /** Every fact this capability states. At least one; an empty vocabulary states nothing. */
158
+ keys: readonly z.input<typeof ManifestConfigKey>[];
159
+ /** The resolved value of each declared fact. Built from the capability's parsed config, never its defaults. */
160
+ values: ManifestConfigValues;
161
+ }
162
+
163
+ /**
164
+ * Declare a capability's configured facts. The one constructor, so every declaration in the tree is
165
+ * checked — the key against the name pattern, the values against the keys, and each value against its
166
+ * own closed list.
167
+ *
168
+ * **Nothing downstream re-checks this.** `missingAdminRoutes` keeps a route declaration honest against
169
+ * the router that mounted it, and there is no equivalent here: the declaration and the value come from
170
+ * the same object, so the only defense against a fact that disagrees with the capability is building it
171
+ * from the *resolved* config at the one call site, exactly as `adminRoutes` is built from the resolved
172
+ * `basePath`.
173
+ *
174
+ * Every refusal is an {@link InternalError} rather than a bare `ZodError`, key named, because every one
175
+ * of them is an author's mistake read off a deploy that would not start — and an author reading it
176
+ * wants the problem line and the action line the rest of this kit gives them.
177
+ */
178
+ export function defineManifestConfig(input: CapabilityManifestConfigInput): CapabilityManifestConfig {
179
+ if (input.keys.length === 0) {
180
+ throw new InternalError({
181
+ message: "A capability declared configured facts with no keys.",
182
+ action: "Declare at least one key, or omit the manifest config entirely.",
183
+ detail: "defineManifestConfig received an empty key list; an empty vocabulary states no fact.",
184
+ });
185
+ }
186
+ const keys: ManifestConfigKey[] = [];
187
+ const declared = new Map<string, ManifestConfigKey>();
188
+ for (const written of input.keys) {
189
+ const parsed = ManifestConfigKey.safeParse(written);
190
+ if (!parsed.success) {
191
+ throw new InternalError({
192
+ message: "A capability declared a configured fact its own schema refuses.",
193
+ action: "Name the fact with one camelCase token, list its values or null, and say what it decides.",
194
+ detail: `manifest config key ${JSON.stringify(written)} is invalid: ${parsed.error.message}`,
195
+ });
196
+ }
197
+ if (declared.has(parsed.data.key)) {
198
+ throw new InternalError({
199
+ message: "A capability declared the same configured fact twice.",
200
+ action: "Give each fact one declaration — the second silently decides what the first meant.",
201
+ detail: `manifest config key ${parsed.data.key} is declared more than once`,
202
+ });
203
+ }
204
+ declared.set(parsed.data.key, parsed.data);
205
+ keys.push(parsed.data);
206
+ }
207
+ const parsed = ManifestConfigValues.safeParse(input.values);
208
+ if (!parsed.success) {
209
+ throw new InternalError({
210
+ message: "A capability stated a configured fact that is not a scalar.",
211
+ action: "State a word, a number or a flag. A capability's own config never crosses whole.",
212
+ detail: `manifest config values are invalid: ${parsed.error.message}`,
213
+ });
214
+ }
215
+ const values = parsed.data;
216
+ for (const key of Object.keys(values)) {
217
+ if (!declared.has(key)) {
218
+ throw new InternalError({
219
+ message: "A capability stated a configured fact it never declared.",
220
+ action: "Declare the fact alongside the capability's routes, or stop stating it.",
221
+ detail: `manifest config value ${key} has no declaration, so a client could only guess at what it means`,
222
+ });
223
+ }
224
+ }
225
+ for (const key of keys) {
226
+ // `Object.hasOwn` rather than an `undefined` check. `ManifestConfigValues` parses to a plain object, so
227
+ // `values.toString` is a function off `Object.prototype` — and the key pattern admits `toString`,
228
+ // `valueOf` and `constructor`. A declaration named after one of them would read a method as its
229
+ // value and walk straight through the guard below, which is the one state the guard exists for.
230
+ const value = Object.hasOwn(values, key.key) ? values[key.key] : undefined;
231
+ if (value === undefined) {
232
+ throw new InternalError({
233
+ message: "A capability declared a configured fact and stated no value for it.",
234
+ action: "State every declared fact, or remove the declaration.",
235
+ detail: `manifest config key ${key.key} is declared and has no value`,
236
+ });
237
+ }
238
+ if (key.choices !== null && !(typeof value === "string" && key.choices.includes(value))) {
239
+ throw new InternalError({
240
+ message: "A capability stated a configured fact its own declaration does not permit.",
241
+ action: "State one of the declared choices, or widen the declaration to the enum it came from.",
242
+ // The value itself: the declaration already said it may be published, and knowing *which* value
243
+ // was refused is most of the diagnosis.
244
+ detail: `manifest config key ${key.key} was given ${JSON.stringify(value)}, which is not one of ${key.choices.join(", ")}`,
245
+ });
246
+ }
247
+ }
248
+ return { [manifestConfigSeam]: true, keys, values };
249
+ }
250
+
251
+ /** One fact with the declaration that says how to read it. */
252
+ export interface NamedConfigValue {
253
+ /** The declaration — its closed list, if it has one, and what it decides. */
254
+ key: ManifestConfigKey;
255
+ /** The value this composition resolved it to. */
256
+ value: ManifestConfigValue;
257
+ }
258
+
259
+ /**
260
+ * Pair a capability's configured facts with their declarations, dropping any value nothing declares.
261
+ *
262
+ * This is how "an unknown fact reads as nothing rather than as an error" is real rather than
263
+ * aspirational: a client reads what this returns, so a key from a Worker newer than its build simply is
264
+ * not in the list — and a fact it cannot name is a fact it would otherwise have to guess the meaning of.
265
+ */
266
+ export function namedConfigValues(descriptor: {
267
+ configKeys: readonly ManifestConfigKey[];
268
+ config: ManifestConfigValues;
269
+ }): NamedConfigValue[] {
270
+ const named: NamedConfigValue[] = [];
271
+ for (const key of descriptor.configKeys) {
272
+ // Own keys only, for the reason {@link defineManifestConfig} states: a declared `toString` with no
273
+ // value beside it would otherwise pair with `Object.prototype.toString`, and this function's whole
274
+ // promise is that a `value` is a scalar a client can render.
275
+ if (!Object.hasOwn(descriptor.config, key.key)) continue;
276
+ const value = descriptor.config[key.key];
277
+ if (value !== undefined) named.push({ key, value });
278
+ }
279
+ return named;
280
+ }
@@ -0,0 +1,100 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Hono } from "hono";
5
+ import type { Capability } from "../../capability/capability";
6
+ import { isControlPlaneGuard } from "../http/guard";
7
+ import type { AdminRoute } from "./adminRoute";
8
+
9
+ /**
10
+ * The gate that keeps `GET /control-plane/manifest` honest.
11
+ *
12
+ * A capability declares its admin routes by hand, and a hand-maintained list beside generated
13
+ * behavior is a list that rots: someone renames a path, changes a `basePath` default, moves an
14
+ * operation behind a different scope, and the declaration keeps confidently describing the old shape.
15
+ *
16
+ * **That failure is worse than having no manifest at all.** With no manifest a client knows it is
17
+ * guessing. With a stale one it believes a route exists, calls it, and gets a 404 it has no way to
18
+ * interpret — and the adopter sees a management client that is broken for reasons entirely inside
19
+ * somebody else's package.
20
+ *
21
+ * So the declaration is checked against the router that actually mounted, the same way
22
+ * `uncoveredParamRoutes` checks that every `:segment` carries a validator. Each capability asserts this
23
+ * in its own `routeContract.test.ts`, so a drifting declaration fails that package's CI rather than
24
+ * some integration run much later.
25
+ */
26
+
27
+ /** One declared route that no composed route answers, and which capability claimed it. */
28
+ export interface AdminRouteDrift {
29
+ /** The capability whose declaration is wrong. */
30
+ capability: string;
31
+ /** The route it claims to expose. */
32
+ route: AdminRoute;
33
+ }
34
+
35
+ /** One mounted control-plane route that no capability declares. */
36
+ export interface UndeclaredAdminRoute {
37
+ /** The method it answers on. */
38
+ method: string;
39
+ /** The path it is mounted at. */
40
+ path: string;
41
+ }
42
+
43
+ /** `app.routes` entries, reduced to the pairs worth comparing. */
44
+ function mounted(app: Hono<never>): Set<string> {
45
+ return new Set(app.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`));
46
+ }
47
+
48
+ /**
49
+ * Every admin route a capability declares that the composed app does not actually serve.
50
+ *
51
+ * Empty is the passing state. A non-empty result names the capability and the route, because the fix
52
+ * is always in one of two places — the declaration or the registration — and knowing which capability
53
+ * owns both is most of the diagnosis.
54
+ *
55
+ * The comparison is on method and path only. Whether the route carries the right *scope* is not
56
+ * knowable from the router (a middleware is an opaque function to Hono), so that half stays the
57
+ * capability's responsibility and is covered by its own route tests.
58
+ */
59
+ export function missingAdminRoutes(app: Hono<never>, capabilities: readonly Capability[]): AdminRouteDrift[] {
60
+ const served = mounted(app);
61
+ const drift: AdminRouteDrift[] = [];
62
+ for (const capability of capabilities) {
63
+ for (const route of capability.adminRoutes ?? []) {
64
+ if (!served.has(`${route.method} ${route.path}`)) drift.push({ capability: capability.name, route });
65
+ }
66
+ }
67
+ return drift;
68
+ }
69
+
70
+ /**
71
+ * Every control-plane route the app actually serves that no capability declares — the other direction.
72
+ *
73
+ * {@link missingAdminRoutes} walks declared → mounted and catches a declaration that lies. This walks
74
+ * mounted → declared and catches the opposite: a route that is guarded, reachable, and invisible.
75
+ *
76
+ * **That gap stopped being cosmetic when CORS started deriving from this table** (#468). An undeclared
77
+ * admin route was previously discoverable-by-trying — the manifest omitted it, a client that knew the
78
+ * path could still call it. Now it also gets no `app.use`, so its preflight 404s and no browser can
79
+ * reach it at all, while every test in the capability that owns it keeps passing. The failure is
80
+ * invisible from the capability's own suite and shows up as a pane that will not load.
81
+ *
82
+ * A route is control-plane if its handler chain carries a {@link isControlPlaneGuard} gate, which is
83
+ * the only thing that distinguishes one — a middleware is otherwise an opaque function to Hono, and
84
+ * inferring it from the path would be guessing about the one table this is meant to check.
85
+ */
86
+ export function undeclaredAdminRoutes(app: Hono<never>, capabilities: readonly Capability[]): UndeclaredAdminRoute[] {
87
+ const declared = new Set<string>();
88
+ for (const capability of capabilities) {
89
+ for (const route of capability.adminRoutes ?? []) declared.add(`${route.method} ${route.path}`);
90
+ }
91
+
92
+ const guarded = new Map<string, UndeclaredAdminRoute>();
93
+ for (const route of app.routes) {
94
+ if (!isControlPlaneGuard(route.handler)) continue;
95
+ const method = route.method.toUpperCase();
96
+ guarded.set(`${method} ${route.path}`, { method, path: route.path });
97
+ }
98
+
99
+ return [...guarded.entries()].filter(([key]) => !declared.has(key)).map(([, route]) => route);
100
+ }
@@ -0,0 +1,213 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context } from "hono";
5
+ import type { z } from "zod";
6
+ import type { Capability, PithyHonoEnv } from "../../capability/capability";
7
+ import { InternalError } from "../../error/pithyError";
8
+ import {
9
+ type CapabilityHealthReport,
10
+ type HealthSummary,
11
+ HealthSummaryKey,
12
+ type HealthSummaryValue,
13
+ } from "./healthSummary";
14
+
15
+ /**
16
+ * The seam a capability implements to contribute a health summary to its own manifest entry (#317).
17
+ *
18
+ * The vocabulary itself — what a key may be called, what it may hold, what it costs, and the four
19
+ * states it travels in — is `./healthSummary`, and that argument is stated there in full. This is the
20
+ * other half: the branded declaration a `Capability` carries, the assembly-time check that a key
21
+ * inherits a scope its own routes already gate, and the per-request read that decides between a number,
22
+ * a withholding and a failure.
23
+ *
24
+ * **The two are separate modules because the compiler follows a type-only edge (#430).** Everything
25
+ * here needs `Capability`, `PithyHonoEnv` and `Context`, which reach D1, KV and Hono; nothing a browser
26
+ * imports needs any of it. Leaving the wire vocabulary in this file put all of that one `import type`
27
+ * away from every management client, through `adminRoute.ts`. Nothing in `./healthSummary` may import
28
+ * this module, and `tooling/browser-scopes` is the gate that says so.
29
+ */
30
+
31
+ /**
32
+ * The brand only {@link defineCapabilityHealth} can produce.
33
+ *
34
+ * Module-local and never exported, so nothing outside this file can write it: the factory becomes the
35
+ * **only** way to build the seam, and a declaration therefore cannot reach a manifest without having
36
+ * been parsed. An inline object literal on `Capability.health` is a compile error, which is what stops
37
+ * the next producer from re-inventing this with a key nothing validated — the failure mode every defect
38
+ * class in this kit has had in common.
39
+ *
40
+ * `Symbol()` rather than `Symbol.for()`, unlike the seam's `ANY_VERIFIED_CALLER`: that one is a
41
+ * requirement a route states and nobody may forge over the wire; this is a mark only this module applies.
42
+ */
43
+ const healthSeam: unique symbol = Symbol("pithy.controlPlane.capabilityHealth");
44
+
45
+ /** What a capability declares and how it produces it. Built only by {@link defineCapabilityHealth}. */
46
+ export interface CapabilityHealth {
47
+ /** The brand. Present only on a declaration that went through the factory, and never serialized. */
48
+ readonly [healthSeam]: true;
49
+ /** The closed vocabulary: every key this capability may report, parsed. */
50
+ readonly keys: readonly HealthSummaryKey[];
51
+ /** Produce every declared key for this request. Bounded by the declaration, never by adopter data. */
52
+ readonly read: (c: Context<PithyHonoEnv>) => Promise<HealthSummary>;
53
+ }
54
+
55
+ /** The authoring shape — keys as written, before parsing. */
56
+ export interface CapabilityHealthInput {
57
+ /** Every key this capability may report. At least one; an empty vocabulary is not a summary. */
58
+ keys: readonly z.input<typeof HealthSummaryKey>[];
59
+ /** Produce every declared key for this request. */
60
+ read: (c: Context<PithyHonoEnv>) => Promise<HealthSummary>;
61
+ }
62
+
63
+ /**
64
+ * Declare a capability's health summary. The one constructor, so every declaration in the tree is
65
+ * parsed — kind against states, cost against the closed vocabulary, key against the name pattern.
66
+ */
67
+ export function defineCapabilityHealth(input: CapabilityHealthInput): CapabilityHealth {
68
+ if (input.keys.length === 0) {
69
+ throw new InternalError({
70
+ message: "A capability declared a health summary with no keys.",
71
+ action: "Declare at least one key, or omit the health seam entirely.",
72
+ detail: "defineCapabilityHealth received an empty key list; an empty vocabulary is not a summary.",
73
+ });
74
+ }
75
+ const keys = input.keys.map((key) => HealthSummaryKey.parse(key));
76
+ const seen = new Set<string>();
77
+ for (const key of keys) {
78
+ if (seen.has(key.key)) {
79
+ throw new InternalError({
80
+ message: "A capability declared the same health key twice.",
81
+ action: "Give each key one declaration — the second silently decides what the first meant.",
82
+ detail: `health key ${key.key} is declared more than once`,
83
+ });
84
+ }
85
+ seen.add(key.key);
86
+ }
87
+ return { [healthSeam]: true, keys, read: input.read };
88
+ }
89
+
90
+ /** One capability's contribution, as the seam holds it between assembly and a request. */
91
+ export interface CapabilityHealthSource {
92
+ /** Which capability contributes it — named in every refusal, because the fix is always in that package. */
93
+ capability: string;
94
+ /** Its closed vocabulary. */
95
+ keys: readonly HealthSummaryKey[];
96
+ /** Its producer. */
97
+ read: (c: Context<PithyHonoEnv>) => Promise<HealthSummary>;
98
+ }
99
+
100
+ /**
101
+ * Every composed capability's health contribution, checked at assembly.
102
+ *
103
+ * **The check is that a summary inherits a scope, never invents one**, and it is compared against a
104
+ * different artifact than the one it polices: the capability's own `adminRoutes`. That is not a
105
+ * tidiness rule. `pithy dashboard connect` offers an adopter the scopes it reads off `adminRoutes`, so
106
+ * a key behind a scope no route requires is a number that could never be granted — permanently
107
+ * withheld, with nothing an adopter could do about it, and no error anywhere to say why. Failing at
108
+ * assembly turns that into a deploy that does not start.
109
+ */
110
+ export function capabilityHealthSources(
111
+ capabilities: readonly Capability[],
112
+ ): ReadonlyMap<string, CapabilityHealthSource> {
113
+ const sources = new Map<string, CapabilityHealthSource>();
114
+ for (const capability of capabilities) {
115
+ const health = capability.health;
116
+ if (!health) continue;
117
+ const gated = new Set((capability.adminRoutes ?? []).flatMap((route) => (route.scope ? [route.scope] : [])));
118
+ for (const key of health.keys) {
119
+ if (!gated.has(key.scope)) {
120
+ throw new InternalError({
121
+ message: "A capability's health summary is behind a scope none of its admin routes requires.",
122
+ action: "Put the value behind a scope the capability already gates a read with.",
123
+ detail: `capability ${capability.name} declares health key ${key.key} behind ${key.scope}, which no admin route of that capability requires — an adopter is never offered it, so the value could never be granted`,
124
+ });
125
+ }
126
+ }
127
+ sources.set(capability.name, { capability: capability.name, keys: health.keys, read: health.read });
128
+ }
129
+ return sources;
130
+ }
131
+
132
+ /** Refuse a produced summary that its declaration cannot name. Total over what a producer may return. */
133
+ function checked(source: CapabilityHealthSource, produced: HealthSummary): HealthSummary {
134
+ const declared = new Map(source.keys.map((key) => [key.key, key]));
135
+ for (const key of Object.keys(produced)) {
136
+ if (!declared.has(key)) {
137
+ throw new InternalError({
138
+ message: "A capability reported a health value it never declared.",
139
+ action: "Declare the key alongside the capability's routes, or stop reporting it.",
140
+ detail: `capability ${source.capability} produced health key ${key}, which its declaration does not name`,
141
+ });
142
+ }
143
+ }
144
+ for (const key of source.keys) {
145
+ const value = produced[key.key];
146
+ if (value === undefined) {
147
+ throw new InternalError({
148
+ message: "A capability omitted a health value it declared.",
149
+ action: "Produce every declared key, or remove the declaration.",
150
+ detail: `capability ${source.capability} declares health key ${key.key} and produced nothing for it`,
151
+ });
152
+ }
153
+ const ok =
154
+ key.kind === "count"
155
+ ? typeof value === "number" && Number.isInteger(value) && value >= 0
156
+ : typeof value === "string" && (key.states ?? []).includes(value);
157
+ if (!ok) {
158
+ throw new InternalError({
159
+ message: "A capability reported a health value its declaration does not permit.",
160
+ action: "Report a whole non-negative number for a count, or a declared member for a state.",
161
+ // The value itself, because it is a scalar the declaration already said may be published — and
162
+ // knowing *which* value was refused is most of the diagnosis.
163
+ detail: `capability ${source.capability} produced ${JSON.stringify(value)} for ${key.kind} key ${key.key}`,
164
+ });
165
+ }
166
+ }
167
+ return produced;
168
+ }
169
+
170
+ /**
171
+ * One capability's summary for one caller.
172
+ *
173
+ * **The producer is not called when no key is permitted**, so a caller with no grant costs the adopter's
174
+ * Worker nothing at all. When it *is* called, the whole of what it produced is checked before anything
175
+ * is withheld: a producer must not be able to hide a violation behind a scope the caller happens to
176
+ * lack.
177
+ *
178
+ * **A failure is caught here and goes no further.** The manifest is the read every other pane is built
179
+ * from, so one capability's bad afternoon must cost that capability's number and nothing else. Both
180
+ * kinds of failure land in the same state, because from a caller's side they are one fact — this
181
+ * capability could not say: the producer threw, or what it produced its own declaration does not
182
+ * permit. An author's mistake is not what this hides. The scope a key inherits, its name, its cost and
183
+ * the brand on the declaration are all checked at assembly, so the shape of a declaration fails a
184
+ * deploy rather than a request; what reaches here is data-dependent, which is the transient kind.
185
+ *
186
+ * **The caught error is dropped whole, and the `catch` takes no binding so that is visible rather than
187
+ * asserted.** It may be a `PithyError` whose `detail` names a row, a key id, or a query — throw-site
188
+ * context, which is exactly what a producer should put there and exactly what must not travel. Nothing
189
+ * derived from it reaches the manifest, a log, or a response: what survives is that this capability
190
+ * failed, and its name, which the manifest already carries in public. An adopter diagnoses it at their
191
+ * own throw site, where the context is theirs and stays theirs.
192
+ */
193
+ export async function readCapabilityHealth(
194
+ source: CapabilityHealthSource | undefined,
195
+ grantedScopes: readonly string[],
196
+ produce: (source: CapabilityHealthSource) => Promise<HealthSummary>,
197
+ ): Promise<CapabilityHealthReport> {
198
+ if (!source) return { state: "undeclared" };
199
+ const permitted = source.keys.filter((key) => grantedScopes.includes(key.scope));
200
+ if (permitted.length === 0) return { state: "withheld" };
201
+ let produced: HealthSummary;
202
+ try {
203
+ produced = checked(source, await produce(source));
204
+ } catch {
205
+ return { state: "unavailable" };
206
+ }
207
+ const visible: HealthSummary = {};
208
+ for (const key of permitted) {
209
+ // Present: `checked` refused anything the declaration does not name and anything it omits.
210
+ visible[key.key] = produced[key.key] as HealthSummaryValue;
211
+ }
212
+ return { state: "reported", values: visible };
213
+ }