@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,281 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import type { MessageParams } from "../i18n/catalog";
6
+ import type { ValidationIssue } from "./payload";
7
+ import { ErrorPayload } from "./payload";
8
+
9
+ /**
10
+ * `PithyError` is the one throw/catch vehicle. A thrown thing must be `instanceof Error`
11
+ * (for `catch`, stack traces, `cause`); a Zod object is a data shape, not an `Error`. So the
12
+ * class does not extend a schema — it **carries** one. The payload is validated against
13
+ * `ErrorPayload` at construction, so every `PithyError` in flight is either a member of the kit's
14
+ * closed taxonomy or an adopter's own code under a domain the kit does not reserve — nothing else
15
+ * gets thrown. The subclasses below are sugar that default a member's `code`/`status`; they set a
16
+ * payload the union already defines, so there is no second source of truth.
17
+ */
18
+ export class PithyError extends Error {
19
+ readonly payload: ErrorPayload;
20
+
21
+ constructor(payload: ErrorPayload, options?: { cause?: unknown }) {
22
+ const parsed = ErrorPayload.parse(payload);
23
+ super(parsed.message, options);
24
+ this.payload = parsed;
25
+ this.name = "PithyError";
26
+ }
27
+ }
28
+
29
+ /** Variable parts every subclass accepts; the `code`/`status` are fixed by the subclass. */
30
+ interface ErrorArgs {
31
+ /** Override the public, safe-to-expose message. Defaults to a per-code summary. */
32
+ message?: string;
33
+ /** A remediation hint (CLI action line). */
34
+ action?: string;
35
+ /** Internal context for logs + audit. Never serialized to clients. */
36
+ detail?: string;
37
+ /**
38
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
39
+ * unlike `action` and `detail` — these cross the boundary with `message`. The `code` is the catalog
40
+ * key, so a throw site that fills a placeholder states it once, here.
41
+ */
42
+ params?: MessageParams;
43
+ }
44
+
45
+ export class ValidationError extends PithyError {
46
+ declare readonly payload: Extract<ErrorPayload, { code: "validation/invalid_input" }>;
47
+
48
+ constructor(args: ErrorArgs & { issues?: ValidationIssue[] } = {}, options?: { cause?: unknown }) {
49
+ super(
50
+ {
51
+ code: "validation/invalid_input",
52
+ status: 400,
53
+ message: args.message ?? "Invalid input.",
54
+ action: args.action,
55
+ detail: args.detail,
56
+ params: args.params,
57
+ issues: args.issues ?? [],
58
+ },
59
+ options,
60
+ );
61
+ }
62
+ }
63
+
64
+ export class UnauthorizedError extends PithyError {
65
+ declare readonly payload: Extract<ErrorPayload, { code: "auth/invalid_token" }>;
66
+
67
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
68
+ super(
69
+ {
70
+ code: "auth/invalid_token",
71
+ status: 401,
72
+ message: args.message ?? "Invalid or expired credentials.",
73
+ action: args.action,
74
+ detail: args.detail,
75
+ params: args.params,
76
+ },
77
+ options,
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * One code for every way a `signed-webhook` delivery fails to prove its origin. The step that failed
84
+ * goes in `detail`, which the HTTP codec strips — a forger learns only that it failed, and an operator
85
+ * reading the log learns which check refused it.
86
+ */
87
+ export class WebhookUnverifiedError extends PithyError {
88
+ declare readonly payload: Extract<ErrorPayload, { code: "core/webhook_unverified" }>;
89
+
90
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
91
+ super(
92
+ {
93
+ code: "core/webhook_unverified",
94
+ status: 401,
95
+ message: args.message ?? "That delivery could not be verified.",
96
+ action: args.action ?? "Sign the request with this endpoint's current secret and send it promptly.",
97
+ detail: args.detail,
98
+ params: args.params,
99
+ },
100
+ options,
101
+ );
102
+ }
103
+ }
104
+
105
+ export class ForbiddenError extends PithyError {
106
+ declare readonly payload: Extract<ErrorPayload, { code: "auth/forbidden" }>;
107
+
108
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
109
+ super(
110
+ {
111
+ code: "auth/forbidden",
112
+ status: 403,
113
+ message: args.message ?? "Forbidden.",
114
+ action: args.action,
115
+ detail: args.detail,
116
+ params: args.params,
117
+ },
118
+ options,
119
+ );
120
+ }
121
+ }
122
+
123
+ export class NotFoundError extends PithyError {
124
+ declare readonly payload: Extract<ErrorPayload, { code: "core/not_found" }>;
125
+
126
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
127
+ super(
128
+ {
129
+ code: "core/not_found",
130
+ status: 404,
131
+ message: args.message ?? "Not found.",
132
+ action: args.action,
133
+ detail: args.detail,
134
+ params: args.params,
135
+ },
136
+ options,
137
+ );
138
+ }
139
+ }
140
+
141
+ export class ConflictError extends PithyError {
142
+ declare readonly payload: Extract<ErrorPayload, { code: "core/conflict" }>;
143
+
144
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
145
+ super(
146
+ {
147
+ code: "core/conflict",
148
+ status: 409,
149
+ message: args.message ?? "Conflict.",
150
+ action: args.action,
151
+ detail: args.detail,
152
+ params: args.params,
153
+ },
154
+ options,
155
+ );
156
+ }
157
+ }
158
+
159
+ export class RateLimitError extends PithyError {
160
+ declare readonly payload: Extract<ErrorPayload, { code: "rate_limit/exceeded" }>;
161
+
162
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
163
+ super(
164
+ {
165
+ code: "rate_limit/exceeded",
166
+ status: 429,
167
+ message: args.message ?? "Too many requests.",
168
+ action: args.action,
169
+ detail: args.detail,
170
+ params: args.params,
171
+ },
172
+ options,
173
+ );
174
+ }
175
+ }
176
+
177
+ export class InternalError extends PithyError {
178
+ declare readonly payload: Extract<ErrorPayload, { code: "core/internal" }>;
179
+
180
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
181
+ super(
182
+ {
183
+ code: "core/internal",
184
+ status: 500,
185
+ message: args.message ?? "Something unexpected happened.",
186
+ action: args.action,
187
+ detail: args.detail,
188
+ params: args.params,
189
+ },
190
+ options,
191
+ );
192
+ }
193
+ }
194
+
195
+ /**
196
+ * A service this one depends on and does not control failed. Reach for this — never `InternalError`
197
+ * — whenever the thing that broke sits behind a network call: a proxied request, a REST client, a
198
+ * provider. A 500 tells the operator to read *our* logs, and for an upstream outage that is the
199
+ * wrong system. The endpoint and the upstream's own status belong in `detail`.
200
+ */
201
+ export class UpstreamError extends PithyError {
202
+ declare readonly payload: Extract<ErrorPayload, { code: "core/upstream_failed" }>;
203
+
204
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
205
+ super(
206
+ {
207
+ code: "core/upstream_failed",
208
+ status: 502,
209
+ message: args.message ?? "An upstream service could not be reached.",
210
+ action: args.action,
211
+ detail: args.detail,
212
+ params: args.params,
213
+ },
214
+ options,
215
+ );
216
+ }
217
+ }
218
+
219
+ /** The same dependency, out of time rather than answered. Retryable, and possibly already applied. */
220
+ export class UpstreamTimeoutError extends PithyError {
221
+ declare readonly payload: Extract<ErrorPayload, { code: "core/upstream_timeout" }>;
222
+
223
+ constructor(args: ErrorArgs = {}, options?: { cause?: unknown }) {
224
+ super(
225
+ {
226
+ code: "core/upstream_timeout",
227
+ status: 504,
228
+ message: args.message ?? "An upstream service did not answer in time.",
229
+ action: args.action ?? "Try again in a moment.",
230
+ detail: args.detail,
231
+ params: args.params,
232
+ },
233
+ options,
234
+ );
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Map a `ZodError` to a `validation/invalid_input` `PithyError`. Zod is the boundary validator
240
+ * everywhere (HTTP input, KV, env, D1 rows), so this is the most common error path — one place
241
+ * turns any failed parse into the typed family, carrying the field-level issues to the client.
242
+ */
243
+ export function fromZodError(error: z.ZodError, args: ErrorArgs = {}): ValidationError {
244
+ const issues: ValidationIssue[] = error.issues.map((issue) => ({
245
+ path: issue.path.filter((segment): segment is string | number => typeof segment !== "symbol"),
246
+ message: issue.message,
247
+ code: issue.code,
248
+ }));
249
+ return new ValidationError({
250
+ message: args.message,
251
+ action: args.action,
252
+ detail: args.detail,
253
+ params: args.params,
254
+ issues,
255
+ });
256
+ }
257
+
258
+ /**
259
+ * The message of an unknown throw, for use as a `PithyError` `detail` — `error.message` for an
260
+ * `Error`, else its string form. One source of truth so every catch site that wants the cause text
261
+ * does it the same way (CLAUDE.md §Errors).
262
+ */
263
+ export function messageOf(error: unknown): string {
264
+ return error instanceof Error ? error.message : String(error);
265
+ }
266
+
267
+ /**
268
+ * One caught error as one sentence for a human: its {@link messageOf}, then its `action` when it has
269
+ * one. For a caller that has to *report* a failure rather than re-throw it — a CLI check that must keep
270
+ * running, a diagnostic that collects several.
271
+ *
272
+ * **Both halves, because they answer different questions**: the message says what is wrong and where,
273
+ * the action says what to do about it. A reporter that keeps only the message drops the fix.
274
+ *
275
+ * **Never `detail`.** That is throw-site context, deliberately stripped at the HTTP boundary, and a
276
+ * string built here is printed to a terminal and pasted into issues. Same rule, one line up.
277
+ */
278
+ export function sentenceOf(error: unknown): string {
279
+ const action = error instanceof PithyError ? error.payload.action : undefined;
280
+ return action ? `${messageOf(error)} ${action}` : messageOf(error);
281
+ }
@@ -0,0 +1,36 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ErrorPayload } from "./payload";
5
+ import { PublicErrorPayload } from "./payload";
6
+
7
+ /**
8
+ * Render an error for the terminal: the public `message` becomes the problem line and the
9
+ * optional `action` the action line — the two-line brand-voice shape `docs/CLI.md` §3.3 specifies.
10
+ * This unifies the CLI's error output into the one `PithyError` family: the CLI catches a
11
+ * `PithyError`, colorizes the first line via its `style.ts`, and prints this. `detail` is internal
12
+ * and never rendered. The same payload that encodes to HTTP renders here — one error, two surfaces.
13
+ */
14
+ export function renderTerminal(payload: ErrorPayload): string {
15
+ return payload.action ? `${payload.message}\n${payload.action}` : payload.message;
16
+ }
17
+
18
+ /** An error as an operator's machine surface states it: the wire fields, plus the remedy. */
19
+ export type OperatorError = PublicErrorPayload & { action?: string };
20
+
21
+ /**
22
+ * The machine-readable projection for an **operator's** consumer — the CLI's `--json` error line.
23
+ *
24
+ * The same audience as {@link renderTerminal}, in the shape a script can read. `action` belongs on it
25
+ * for the reason it belongs on the terminal: whoever runs `pithy secrets create --json` is the person
26
+ * who can act on "Bind a D1 database named DB in wrangler.jsonc". Dropping it because the *HTTP*
27
+ * surface must would be classifying the field by the encoder that happened to be reused rather than
28
+ * by who is reading.
29
+ *
30
+ * `detail` is still gone, and gone by the same schema that removes it on the wire — the public parse.
31
+ * One rule, applied twice, rather than a second rule here that could drift from the first.
32
+ */
33
+ export function operatorError(payload: ErrorPayload): OperatorError {
34
+ const wire = PublicErrorPayload.parse(payload);
35
+ return payload.action === undefined ? wire : { ...wire, action: payload.action };
36
+ }
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { Locale } from "../i18n/locale";
6
+
7
+ /**
8
+ * The auth seam. `@pithy-sh/core` defines this shape; `@pithy-sh/auth` populates it on the
9
+ * request via the `bearer`/`session` strategies. Other capabilities depend only on this
10
+ * object (and `requireAuth`), never on auth internals.
11
+ *
12
+ * **`locale` rides here rather than being fetched**, because the session lookup has already loaded the
13
+ * user row and a second read of the same fact would be a query per request for a string auth is
14
+ * holding. It is the `user` link of `@pithy-sh/i18n`'s server chain — the one that makes a reader's
15
+ * stored choice outrank their device's `Accept-Language`. That link read `c.var.locale` at first,
16
+ * which nothing ever wrote, so it silently contributed nothing and the chain degraded to
17
+ * `param → cookie → header → default` while the docs said otherwise.
18
+ */
19
+ export const AuthContext = z
20
+ .object({
21
+ userId: z.string().describe("ID of the authenticated user (populated by @pithy-sh/auth)."),
22
+ sessionId: z.string().describe("ID of the active session this request belongs to."),
23
+ scopes: z.array(z.string()).default([]).describe("Permission scopes granted to this session."),
24
+ locale: Locale.nullish().describe(
25
+ "The reader's own stored language, from `pithy_auth_users.locale`; null or absent when they have never chosen.",
26
+ ),
27
+ })
28
+ .describe("Per-request authenticated identity; the seam other capabilities depend on.");
29
+ export type AuthContext = z.infer<typeof AuthContext>;
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context, Hono, Next } from "hono";
5
+
6
+ /**
7
+ * Gate 2 of the route request contract (issue #74). Its sibling, the `no-raw-request-input` Biome
8
+ * plugin, covers query and body: with `c.req.query()` and `c.req.json()` unavailable, the only way
9
+ * to read either is `c.req.valid()`, which only exists once a validator declared it. Path params
10
+ * have no such chokepoint — a handler can always reach one through a validator it never declared —
11
+ * so they need a positive check instead of a ban, and this is it.
12
+ *
13
+ * A composed app is the only place the answer lives: `Capability.routes` registers onto the app it
14
+ * is handed, so nothing static can see the finished route table. `app.routes` can — it carries one
15
+ * entry per registered handler, in order, with the path pattern intact.
16
+ *
17
+ * Identifying a param validator is the awkward part. `@hono/zod-validator` returns the same
18
+ * anonymous closure whatever its target, so there is nothing to inspect: no name, no property, no
19
+ * distinguishing shape. What does differ is *behavior* — `hono/validator` reads `c.req.param()`
20
+ * for the `param` target and nothing else does. So each middleware is invoked once against a probe
21
+ * context that records which accessor it touched. It is a real call, which is why only middleware
22
+ * are probed (never the terminal handler) and why every throw is swallowed: a guard meeting an
23
+ * empty context is expected to reject, and a validator meeting empty params is expected to fail
24
+ * validation. Either way the flag has already been set or not.
25
+ */
26
+
27
+ /** One route that reads path params without declaring a schema for them. */
28
+ export interface UncoveredRoute {
29
+ /** HTTP method as Hono records it (`GET`, `POST`, `ALL`, …). */
30
+ method: string;
31
+ /** The registered path pattern, e.g. `/leaderboard/:board/entries/:userId`. */
32
+ path: string;
33
+ /** The `:segment` names the path declares. */
34
+ params: string[];
35
+ }
36
+
37
+ /** The `:segment` names in a path pattern, in order. */
38
+ export function pathParams(path: string): string[] {
39
+ return [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1] as string);
40
+ }
41
+
42
+ /**
43
+ * A context that answers every accessor `hono/validator` reaches for, and records the one that
44
+ * identifies a `param` validator. Deliberately minimal: anything a real guard reads is absent, so
45
+ * a guard rejects immediately instead of doing work.
46
+ */
47
+ function probeContext(record: () => void) {
48
+ const req = {
49
+ param: () => {
50
+ record();
51
+ return {};
52
+ },
53
+ queries: () => ({}),
54
+ query: () => ({}),
55
+ header: (name?: string) => (name === undefined ? {} : undefined),
56
+ json: async () => ({}),
57
+ parseBody: async () => ({}),
58
+ arrayBuffer: async () => new ArrayBuffer(0),
59
+ valid: () => ({}),
60
+ addValidatedData: () => {},
61
+ bodyCache: {},
62
+ url: "http://route-contract.probe/",
63
+ raw: new Request("http://route-contract.probe/"),
64
+ };
65
+ return { req, env: {}, var: {}, get: () => undefined, set: () => {} } as unknown as Context;
66
+ }
67
+
68
+ /** Does this middleware read `c.req.param()` — i.e. is it a `zValidator("param", …)`? */
69
+ async function readsPathParams(handler: (c: Context, next: Next) => unknown): Promise<boolean> {
70
+ let read = false;
71
+ try {
72
+ await handler(
73
+ probeContext(() => {
74
+ read = true;
75
+ }),
76
+ (async () => {}) as Next,
77
+ );
78
+ } catch {
79
+ // Expected: a guard rejects an empty context, a validator rejects empty params. Both are fine —
80
+ // whether `c.req.param()` was reached has already been decided by the time anything throws.
81
+ }
82
+ return read;
83
+ }
84
+
85
+ /**
86
+ * Every route on this app that declares a `:segment` but registers no param validator for it.
87
+ * An empty array is the contract being met. Feed it a composed app — one that every capability's
88
+ * `routes` has already been applied to.
89
+ */
90
+ export async function uncoveredParamRoutes(app: Hono<never>): Promise<UncoveredRoute[]> {
91
+ const grouped = new Map<string, { method: string; path: string; handlers: ((c: Context, n: Next) => unknown)[] }>();
92
+ for (const route of app.routes) {
93
+ const key = `${route.method} ${route.path}`;
94
+ const entry = grouped.get(key) ?? { method: route.method, path: route.path, handlers: [] };
95
+ entry.handlers.push(route.handler as (c: Context, n: Next) => unknown);
96
+ grouped.set(key, entry);
97
+ }
98
+
99
+ const uncovered: UncoveredRoute[] = [];
100
+ for (const { method, path, handlers } of grouped.values()) {
101
+ const params = pathParams(path);
102
+ if (params.length === 0) continue;
103
+ // The last entry is the route's own handler; probing it would run real business logic.
104
+ const middleware = handlers.slice(0, -1);
105
+ let covered = false;
106
+ for (const handler of middleware) {
107
+ if (await readsPathParams(handler)) {
108
+ covered = true;
109
+ break;
110
+ }
111
+ }
112
+ if (!covered) uncovered.push({ method, path, params });
113
+ }
114
+ return uncovered;
115
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MiddlewareHandler } from "hono";
5
+ import type { PithyHonoEnv } from "../capability/capability";
6
+ import { ForbiddenError } from "../error/pithyError";
7
+
8
+ /**
9
+ * The same-origin seam — one CSRF decision per Worker, made where the origins are known.
10
+ *
11
+ * ## Why the gate arrives already bound
12
+ *
13
+ * The origins a cookie-authenticated mutation may come from are the composed auth capability's:
14
+ * its `baseURL` plus its `trustedOrigins`. An adopter's own routes need the same rule, and the
15
+ * `routes` hook hands them only the Hono app — so the only way to write the check beside auth's own
16
+ * was to rebuild it from the request and hope the two agreed. They are free not to, and a Worker with
17
+ * two same-origin implementations has the weaker one as its actual policy.
18
+ *
19
+ * So the capability that resolved the origins publishes the gate **already bound** to them, and
20
+ * {@link requireSameOrigin} takes no argument. There is no origin list to pass, therefore no wrong one
21
+ * to pass — which is the failure mode, not the ceremony of passing it.
22
+ *
23
+ * The alternative considered was handing resolved capability config to the `routes` hook. That serves
24
+ * more cases (a base path, a rail toggle) and is worse here: it hands every adopter the raw material
25
+ * for a second, differently-bound copy of a security decision, which is the thing being removed.
26
+ *
27
+ * ## It fails closed
28
+ *
29
+ * With no capability publishing a policy the gate denies, exactly as `requireEntitlement` does with no
30
+ * provider composed. A gate that arrives with a package must not stand open when that package is
31
+ * absent; the denial names the reason in `detail`, where an operator reads it and a client does not.
32
+ */
33
+
34
+ /**
35
+ * A same-origin gate, already bound to the origins the composing capability resolved. Published on
36
+ * `c.var.sameOrigin`, consumed by {@link requireSameOrigin}, and null when nothing published one.
37
+ *
38
+ * Deliberately a bound middleware rather than the origin list: a list on the request context is an
39
+ * invitation to build a second checker beside the first, and the point of the seam is that there is
40
+ * one.
41
+ */
42
+ export type SameOriginGate = MiddlewareHandler<PithyHonoEnv>;
43
+
44
+ /**
45
+ * Require this request to come from an origin the Worker trusts — the CSRF gate a cookie-authenticated
46
+ * mutating route wears, beside the strategy that says who the caller is.
47
+ *
48
+ * app.post("/organizations", requireAuth(), requireSameOrigin(), zValidator(…), handler)
49
+ *
50
+ * A bearer request carries no ambient credential and is CSRF-exempt; that exemption belongs to the
51
+ * published gate, since the capability that owns the credential model is the one that knows.
52
+ */
53
+ export function requireSameOrigin(): MiddlewareHandler<PithyHonoEnv> {
54
+ return async (c, next) => {
55
+ const gate = c.var.sameOrigin;
56
+ if (!gate) {
57
+ throw new ForbiddenError({
58
+ message: "Cross-origin request rejected.",
59
+ action: "Compose a capability that resolves this Worker's trusted origins (auth), then retry.",
60
+ detail: "no same-origin policy on the request — no composed capability published one",
61
+ });
62
+ }
63
+ // `return await`, not `await`: a gate may answer by *returning* a Response rather than throwing,
64
+ // and a wrapper that swallowed it would leave the request unanswered — a refusal reported as a 500.
65
+ return await gate(c, next);
66
+ };
67
+ }