@pithy-sh/core 0.8.0 → 0.10.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 (166) hide show
  1. package/dist/asyncapi/document.d.ts +46 -0
  2. package/dist/asyncapi/document.d.ts.map +1 -0
  3. package/dist/asyncapi/document.js +115 -0
  4. package/dist/capability/capability.d.ts +7 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/composition.d.ts.map +1 -1
  7. package/dist/capability/composition.js +5 -1
  8. package/dist/capability/settings.d.ts +10 -2
  9. package/dist/capability/settings.d.ts.map +1 -1
  10. package/dist/capability/settings.js +5 -3
  11. package/dist/cli/command.d.ts +364 -0
  12. package/dist/cli/command.d.ts.map +1 -0
  13. package/dist/cli/command.js +418 -0
  14. package/dist/cli/commandCatalog.generated.d.ts +2275 -0
  15. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  16. package/dist/cli/commandCatalog.generated.js +2390 -0
  17. package/dist/cli/commandFacts.d.ts +164 -0
  18. package/dist/cli/commandFacts.d.ts.map +1 -0
  19. package/dist/cli/commandFacts.js +215 -0
  20. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  21. package/dist/controlPlane/error/errors.js +4 -3
  22. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  23. package/dist/controlPlane/http/guard.js +11 -1
  24. package/dist/controlPlane/http/routes.d.ts +1 -1
  25. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  26. package/dist/controlPlane/http/routes.js +34 -7
  27. package/dist/createBackend.d.ts +7 -0
  28. package/dist/createBackend.d.ts.map +1 -1
  29. package/dist/createBackend.js +34 -4
  30. package/dist/entitlement/require.js +1 -1
  31. package/dist/error/cause.d.ts +16 -116
  32. package/dist/error/cause.d.ts.map +1 -1
  33. package/dist/error/cause.js +19 -153
  34. package/dist/error/http.d.ts +40 -0
  35. package/dist/error/http.d.ts.map +1 -1
  36. package/dist/error/payload.d.ts +80 -0
  37. package/dist/error/payload.d.ts.map +1 -1
  38. package/dist/error/payload.js +26 -0
  39. package/dist/error/pithyError.d.ts +44 -0
  40. package/dist/error/pithyError.d.ts.map +1 -1
  41. package/dist/error/pithyError.js +56 -1
  42. package/dist/error/unresolved.d.ts +265 -0
  43. package/dist/error/unresolved.d.ts.map +1 -0
  44. package/dist/error/unresolved.js +599 -0
  45. package/dist/error/wire.d.ts +20 -0
  46. package/dist/error/wire.d.ts.map +1 -0
  47. package/dist/error/wire.js +19 -0
  48. package/dist/http/oidcWebhook.d.ts +3 -3
  49. package/dist/http/oidcWebhook.js +3 -3
  50. package/dist/http/routeContract.js +1 -1
  51. package/dist/http/routeSpec.d.ts +66 -0
  52. package/dist/http/routeSpec.d.ts.map +1 -0
  53. package/dist/http/routeSpec.js +40 -0
  54. package/dist/http/sameOrigin.d.ts +1 -1
  55. package/dist/http/sameOrigin.js +1 -1
  56. package/dist/http/signedWebhook.d.ts +3 -3
  57. package/dist/http/signedWebhook.js +3 -3
  58. package/dist/http/socket.d.ts +52 -0
  59. package/dist/http/socket.d.ts.map +1 -0
  60. package/dist/http/socket.js +2 -0
  61. package/dist/http/subprotocol.d.ts +29 -0
  62. package/dist/http/subprotocol.d.ts.map +1 -0
  63. package/dist/http/subprotocol.js +37 -0
  64. package/dist/http/validation.d.ts +40 -8
  65. package/dist/http/validation.d.ts.map +1 -1
  66. package/dist/http/validation.js +25 -1
  67. package/dist/index.js +2 -2
  68. package/dist/logger/record.d.ts +23 -0
  69. package/dist/logger/record.d.ts.map +1 -1
  70. package/dist/migrations/bookkeeping.d.ts +21 -0
  71. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  72. package/dist/migrations/bookkeeping.js +32 -3
  73. package/dist/migrations/groups.d.ts +140 -0
  74. package/dist/migrations/groups.d.ts.map +1 -0
  75. package/dist/migrations/groups.js +191 -0
  76. package/dist/migrations/migrateStep.d.ts +13 -0
  77. package/dist/migrations/migrateStep.d.ts.map +1 -0
  78. package/dist/migrations/migrateStep.js +20 -0
  79. package/dist/migrations/owner.d.ts +7 -0
  80. package/dist/migrations/owner.d.ts.map +1 -1
  81. package/dist/migrations/owner.js +11 -7
  82. package/dist/migrations/retained.js +2 -2
  83. package/dist/migrations/runner.d.ts +76 -5
  84. package/dist/migrations/runner.d.ts.map +1 -1
  85. package/dist/migrations/runner.js +130 -19
  86. package/dist/naming/domains.d.ts +30 -0
  87. package/dist/naming/domains.d.ts.map +1 -1
  88. package/dist/naming/domains.js +83 -4
  89. package/dist/naming/environment.d.ts.map +1 -1
  90. package/dist/naming/environment.js +2 -1
  91. package/dist/naming/packageName.d.ts +17 -0
  92. package/dist/naming/packageName.d.ts.map +1 -0
  93. package/dist/naming/packageName.js +22 -0
  94. package/dist/naming/provisionScope.d.ts.map +1 -1
  95. package/dist/naming/provisionScope.js +2 -1
  96. package/dist/openapi/document.d.ts +106 -0
  97. package/dist/openapi/document.d.ts.map +1 -0
  98. package/dist/openapi/document.js +314 -0
  99. package/dist/openapi/routeGroups.d.ts +8 -0
  100. package/dist/openapi/routeGroups.d.ts.map +1 -0
  101. package/dist/openapi/routeGroups.js +23 -0
  102. package/dist/seed/devLogin.d.ts +33 -0
  103. package/dist/seed/devLogin.d.ts.map +1 -1
  104. package/dist/seed/devLogin.js +28 -1
  105. package/dist/text/comments.js +66 -4
  106. package/dist/text/literals.d.ts +43 -0
  107. package/dist/text/literals.d.ts.map +1 -0
  108. package/dist/text/literals.js +293 -0
  109. package/dist/version.generated.d.ts +1 -1
  110. package/dist/version.generated.d.ts.map +1 -1
  111. package/dist/version.generated.js +1 -1
  112. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  113. package/dist/workflow/dispatchRoute.js +12 -6
  114. package/dist/workflow/faults.d.ts.map +1 -1
  115. package/dist/workflow/faults.js +2 -3
  116. package/dist/workflow/hostEnv.d.ts +1 -0
  117. package/dist/workflow/hostEnv.d.ts.map +1 -1
  118. package/dist/workflow/hostEnv.js +4 -2
  119. package/dist/workflow/loopback.d.ts.map +1 -1
  120. package/dist/workflow/loopback.js +2 -1
  121. package/package.json +3 -3
  122. package/src/asyncapi/document.ts +164 -0
  123. package/src/capability/capability.ts +7 -0
  124. package/src/capability/composition.ts +2 -1
  125. package/src/capability/settings.ts +19 -2
  126. package/src/cli/command.ts +865 -0
  127. package/src/cli/commandCatalog.generated.ts +2533 -0
  128. package/src/cli/commandFacts.ts +219 -0
  129. package/src/controlPlane/error/errors.ts +10 -3
  130. package/src/controlPlane/http/guard.ts +14 -2
  131. package/src/controlPlane/http/routes.ts +57 -9
  132. package/src/createBackend.ts +35 -2
  133. package/src/entitlement/require.ts +1 -1
  134. package/src/error/cause.ts +18 -181
  135. package/src/error/payload.ts +59 -0
  136. package/src/error/pithyError.ts +76 -0
  137. package/src/error/unresolved.ts +853 -0
  138. package/src/error/wire.ts +40 -0
  139. package/src/http/oidcWebhook.ts +3 -3
  140. package/src/http/routeContract.ts +1 -1
  141. package/src/http/routeSpec.ts +81 -0
  142. package/src/http/sameOrigin.ts +1 -1
  143. package/src/http/signedWebhook.ts +3 -3
  144. package/src/http/socket.ts +55 -0
  145. package/src/http/subprotocol.ts +47 -0
  146. package/src/http/validation.ts +50 -8
  147. package/src/migrations/bookkeeping.ts +39 -2
  148. package/src/migrations/groups.ts +295 -0
  149. package/src/migrations/migrateStep.ts +19 -0
  150. package/src/migrations/owner.ts +10 -6
  151. package/src/migrations/retained.ts +2 -2
  152. package/src/migrations/runner.ts +214 -22
  153. package/src/naming/domains.ts +97 -16
  154. package/src/naming/environment.ts +2 -1
  155. package/src/naming/packageName.ts +20 -0
  156. package/src/naming/provisionScope.ts +2 -1
  157. package/src/openapi/document.ts +412 -0
  158. package/src/openapi/routeGroups.ts +25 -0
  159. package/src/seed/devLogin.ts +31 -0
  160. package/src/text/comments.ts +56 -4
  161. package/src/text/literals.ts +294 -0
  162. package/src/version.generated.ts +1 -1
  163. package/src/workflow/dispatchRoute.ts +13 -6
  164. package/src/workflow/faults.ts +5 -3
  165. package/src/workflow/hostEnv.ts +9 -1
  166. package/src/workflow/loopback.ts +2 -1
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ // Its own module, importing nothing but zod, so a browser and a socket's frame schemas can name the error
7
+ // shape without reaching the HTTP codec or Hono. The OpenAPI and AsyncAPI documents both describe it here.
8
+
9
+ /**
10
+ * The wire body of every error a route returns, whatever its status: `pithyErrorHandler` renders
11
+ * `{ error: clientError(payload) }`, and a socket's error frame carries the same `error` object. The payload's own schema is a union over every kit code, which
12
+ * would put several hundred variants into each operation, so the contract describes the envelope and
13
+ * names the code list's home instead.
14
+ */
15
+ export const ErrorBody = z
16
+ .object({
17
+ error: z
18
+ .object({
19
+ code: z
20
+ .string()
21
+ .describe(
22
+ "Namespaced `domain/reason`, e.g. `auth/invalid_token`. Stable: a client switches on it. The full list is `pithy schema --json`'s `errorCodes`.",
23
+ ),
24
+ status: z.number().int().describe("The HTTP status, repeated in the body."),
25
+ message: z
26
+ .string()
27
+ .describe("English, for a person. A client that translates renders its own wording for `code` instead."),
28
+ params: z
29
+ .record(z.string(), z.unknown())
30
+ .optional()
31
+ .describe("Values a translating client interpolates into its own wording for `code`."),
32
+ issues: z
33
+ .array(z.unknown())
34
+ .optional()
35
+ .describe("For `validation/invalid_input`: one entry per field that failed, with its path."),
36
+ })
37
+ .describe("The public error payload. Never carries the operator's remedy or internal detail."),
38
+ })
39
+ .describe("The body of every error response.");
40
+ export type ErrorBody = z.infer<typeof ErrorBody>;
@@ -491,8 +491,8 @@ export async function verifyOidcToken(token: string, options: VerifyOidcTokenOpt
491
491
  }
492
492
 
493
493
  /**
494
- * The OIDC gate, as a route wears it: `app.post(path, requireOidcWebhook({…}), zValidator("json", Body,
495
- * validationHook), handler)`.
494
+ * The OIDC gate, as a route wears it: `app.post(path, requireOidcWebhook({…}), validate("json", Body),
495
+ * handler)`.
496
496
  *
497
497
  * ## Why the header is read before anything else
498
498
  *
@@ -528,7 +528,7 @@ export async function verifyOidcToken(token: string, options: VerifyOidcTokenOpt
528
528
  * null-check.
529
529
  *
530
530
  * The body is never read here — unlike the HMAC gate, whose proof covers the bytes. An OIDC token proves who
531
- * sent the request and says nothing about what they sent, so the route's `zValidator` is the only thing that
531
+ * sent the request and says nothing about what they sent, so the route's `validate` is the only thing that
532
532
  * touches the body, and it sees an untouched stream.
533
533
  */
534
534
  export function requireOidcWebhook(options: OidcWebhookGuardOptions): MiddlewareHandler<PithyHonoEnv> {
@@ -65,7 +65,7 @@ function probeContext(record: () => void) {
65
65
  return { req, env: {}, var: {}, get: () => undefined, set: () => {} } as unknown as Context;
66
66
  }
67
67
 
68
- /** Does this middleware read `c.req.param()` — i.e. is it a `zValidator("param", …)`? */
68
+ /** Does this middleware read `c.req.param()` — i.e. is it a `validate("param", …)`? */
69
69
  async function readsPathParams(handler: (c: Context, next: Next) => unknown): Promise<boolean> {
70
70
  let read = false;
71
71
  try {
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MiddlewareHandler } from "hono";
5
+ import type { z } from "zod";
6
+ import type { VerificationStrategy } from "./verification";
7
+
8
+ /**
9
+ * The third half of a route declaration. `validate` says what a caller may send and the guards say who
10
+ * may send it; this says what the route is for, how its caller is verified, and what comes back. It is
11
+ * the part the OpenAPI contract (`../openapi/document.ts`) cannot read from anywhere else: a guard is an
12
+ * anonymous closure, and a handler's `c.json(… satisfies Response)` is a compile-time check that leaves
13
+ * nothing behind at runtime.
14
+ *
15
+ * It sits first on the route line, ahead of the guards, and does nothing when it runs but call `next`.
16
+ * Its whole value is the spec it carries under {@link ROUTE_SPEC}, which a reader of the composed app
17
+ * finds by walking `app.routes`.
18
+ *
19
+ * ```ts
20
+ * app.get(
21
+ * `${base}/:board/top`,
22
+ * describeRoute({ summary: "Read the top of a board.", verification: ["bearer", "session"], responses: { 200: TopPage } }),
23
+ * requireAuth(),
24
+ * validate("param", BoardParam),
25
+ * handler,
26
+ * );
27
+ * ```
28
+ */
29
+
30
+ /** The key a {@link describeRoute} middleware carries its spec under. Global, so two copies of core agree. */
31
+ export const ROUTE_SPEC = Symbol.for("pithy.routeSpec");
32
+
33
+ /** One response a route can give that is not a JSON body: bytes, a redirect, HTML, or nothing at all. */
34
+ export interface RouteResponse {
35
+ /** What this response means, in one sentence. */
36
+ readonly description: string;
37
+ /** The JSON body, when there is one. Its input side is the wire: what a client parses with it. */
38
+ readonly schema?: z.ZodType;
39
+ /** The media type when the body is not JSON, e.g. `text/html` or `application/octet-stream`. */
40
+ readonly contentType?: string;
41
+ }
42
+
43
+ /** The HTTP methods an `app.all` route is documented under. OpenAPI has no "any method". */
44
+ export type DocumentedMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
45
+
46
+ /** What a route is for, how its caller is verified, and what it returns. */
47
+ export interface RouteSpec {
48
+ /** One sentence, in the brand voice. The operation's title in the contract. */
49
+ readonly summary: string;
50
+ /** Anything a client author must know that the schemas do not say. */
51
+ readonly description?: string;
52
+ /**
53
+ * How the caller is verified. A list when the route takes more than one — `requireAuth()` accepts a
54
+ * bearer token or a session, so most authenticated routes declare both.
55
+ */
56
+ readonly verification: VerificationStrategy | readonly VerificationStrategy[];
57
+ /** Scopes the caller's credential must carry, beyond being verified. */
58
+ readonly scopes?: readonly string[];
59
+ /**
60
+ * Every success response, by status. A Zod schema is a JSON body described by the schema itself; a
61
+ * {@link RouteResponse} covers everything else. Errors are not listed: every route can answer with the
62
+ * one `PithyError` body, and the contract says so once.
63
+ */
64
+ readonly responses: Readonly<Record<number, z.ZodType | RouteResponse>>;
65
+ /** For an `app.all` route only: the methods it is documented under. */
66
+ readonly methods?: readonly DocumentedMethod[];
67
+ }
68
+
69
+ /** Declare what a route is for, how its caller is verified, and what it returns. See {@link RouteSpec}. */
70
+ export function describeRoute(spec: RouteSpec): MiddlewareHandler {
71
+ const middleware: MiddlewareHandler = async (_c, next) => {
72
+ await next();
73
+ };
74
+ return Object.assign(middleware, { [ROUTE_SPEC]: spec });
75
+ }
76
+
77
+ /** The spec a {@link describeRoute} middleware carries, or `undefined` for any other handler. */
78
+ export function routeSpecOf(handler: unknown): RouteSpec | undefined {
79
+ if (typeof handler !== "function") return undefined;
80
+ return (handler as { [ROUTE_SPEC]?: RouteSpec })[ROUTE_SPEC];
81
+ }
@@ -45,7 +45,7 @@ export type SameOriginGate = MiddlewareHandler<PithyHonoEnv>;
45
45
  * Require this request to come from an origin the Worker trusts — the CSRF gate a cookie-authenticated
46
46
  * mutating route wears, beside the strategy that says who the caller is.
47
47
  *
48
- * app.post("/organizations", requireAuth(), requireSameOrigin(), zValidator(…), handler)
48
+ * app.post("/organizations", requireAuth(), requireSameOrigin(), validate(…), handler)
49
49
  *
50
50
  * A bearer request carries no ambient credential and is CSRF-exempt; that exemption belongs to the
51
51
  * published gate, since the capability that owns the credential model is the one that knows.
@@ -377,8 +377,8 @@ export async function verifySignedWebhook(
377
377
  }
378
378
 
379
379
  /**
380
- * The `signed-webhook` gate, as a route wears it: `app.post(path, requireSignedWebhook({…}), zValidator("json",
381
- * Body, validationHook), handler)`.
380
+ * The `signed-webhook` gate, as a route wears it: `app.post(path, requireSignedWebhook({…}), validate("json",
381
+ * Body), handler)`.
382
382
  *
383
383
  * ## Why the header is read before anything else
384
384
  *
@@ -402,7 +402,7 @@ export async function verifySignedWebhook(
402
402
  * `c.req.text()` is not.
403
403
  *
404
404
  * `c.req.text()` rather than `c.req.raw.text()`, and the distinction is load-bearing. Hono caches its own
405
- * body reads and serves `c.req.json()` out of the cached text, so a `zValidator("json", …)` after this gate
405
+ * body reads and serves `c.req.json()` out of the cached text, so a `validate("json", …)` after this gate
406
406
  * parses identical bytes. Reading `c.req.raw` would consume the stream and leave the validator with nothing.
407
407
  */
408
408
  export function requireSignedWebhook(options: SignedWebhookGuardOptions): MiddlewareHandler<PithyHonoEnv> {
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import type { SocketVersions } from "./subprotocol";
6
+ import type { VerificationStrategy } from "./verification";
7
+
8
+ /**
9
+ * **A WebSocket, described for the AsyncAPI contract.** The socket's counterpart of `describeRoute`.
10
+ *
11
+ * OpenAPI cannot describe a socket: after the upgrade there are no requests, only frames each way. So a
12
+ * capability that serves one declares it on its `sockets` contribution, built from its resolved config —
13
+ * the path it mounted, how a caller is verified, the versions it serves, and a Zod schema for every frame
14
+ * each side may send. `../asyncapi/document.ts` reads the declarations into an AsyncAPI document, the way
15
+ * `../openapi/document.ts` reads the route table into OpenAPI.
16
+ *
17
+ * The schemas are the ones the socket parses with, never a copy: a frame the Worker receives is parsed with
18
+ * the schema declared here, so the document and the socket cannot disagree about what is accepted.
19
+ */
20
+
21
+ /** One frame one side of a socket may send. */
22
+ export interface SocketMessage {
23
+ /** The frame's name, unique within its socket: `state`, `error`, `action`. */
24
+ readonly name: string;
25
+ /** What the frame is, in one sentence. */
26
+ readonly summary: string;
27
+ /** The frame's JSON body. Absent for a plain-text frame. */
28
+ readonly schema?: z.ZodType;
29
+ /** A plain-text frame's exact text — `ping`, `pong`. The one exception to JSON frames, stated per frame. */
30
+ readonly text?: string;
31
+ }
32
+
33
+ /** One WebSocket a capability serves. */
34
+ export interface SocketChannel {
35
+ /** A stable name for the socket, unique in the Worker: `multiplayerSession`, `matchmakingPresence`. */
36
+ readonly name: string;
37
+ /** The upgrade path, as Hono registered it: `/multiplayer/sessions/:id/socket`. */
38
+ readonly path: string;
39
+ /** What the socket is for, in one sentence. */
40
+ readonly summary: string;
41
+ /** Anything a client author must know that the schemas do not say. */
42
+ readonly description?: string;
43
+ /** How the upgrade request is verified. */
44
+ readonly verification: VerificationStrategy | readonly VerificationStrategy[];
45
+ /** The path parameters' schema — the same one the upgrade route validates them with. */
46
+ readonly params?: z.ZodType;
47
+ /** The subprotocol versions this socket serves. */
48
+ readonly versions: SocketVersions;
49
+ /** Every frame the Worker sends. */
50
+ readonly send: readonly SocketMessage[];
51
+ /** Every frame the Worker accepts. Anything else is answered with a `validation/invalid_input` error frame. */
52
+ readonly receive: readonly SocketMessage[];
53
+ /** Further named schemas a client needs, such as each configured game's `action` and `view`. */
54
+ readonly schemas?: Readonly<Record<string, z.ZodType>>;
55
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "../error/pithyError";
5
+
6
+ /**
7
+ * **A socket's version is negotiated at the upgrade, and the Worker serves two.**
8
+ *
9
+ * An app store client lags its Worker by months — the same version boundary an admin response crosses
10
+ * (CLAUDE.md §HTTP). So a socket's frames change the way a response does: a field added to a frame is
11
+ * optional, and a client ignores a `type` and a key it does not know. A change no client could ignore is a
12
+ * new subprotocol, offered in `Sec-WebSocket-Protocol` (`pithy.multiplayer.v1`), and the Worker keeps
13
+ * serving the one before it, so a client released before the change keeps playing.
14
+ *
15
+ * The first versioned protocol's previous version is the unversioned one: a client that offers no
16
+ * subprotocol at all, which is every client written before versions existed. `previous: null` names it.
17
+ */
18
+
19
+ /** The header a client offers subprotocols in, and the server answers with the one it chose. */
20
+ export const SUBPROTOCOL_HEADER = "Sec-WebSocket-Protocol";
21
+
22
+ /** The versions one socket serves: the current one, and the one before it — `null` when that is unversioned. */
23
+ export interface SocketVersions {
24
+ readonly current: string;
25
+ readonly previous: string | null;
26
+ }
27
+
28
+ /**
29
+ * The version this upgrade speaks: the current one when the client offers it, else the previous one when
30
+ * the client offers that, else `null` for an unversioned client where the previous version is unversioned.
31
+ * A client that offers only versions this Worker does not serve is refused with a 400 naming the ones it
32
+ * does — a socket that opened anyway would speak frames the client cannot read.
33
+ */
34
+ export function negotiateSubprotocol(offered: string | null | undefined, versions: SocketVersions): string | null {
35
+ const asked = (offered ?? "")
36
+ .split(",")
37
+ .map((protocol) => protocol.trim())
38
+ .filter((protocol) => protocol.length > 0);
39
+ if (asked.length === 0 && versions.previous === null) return null;
40
+ if (asked.includes(versions.current)) return versions.current;
41
+ if (versions.previous !== null && asked.includes(versions.previous)) return versions.previous;
42
+ const served = [versions.current, ...(versions.previous === null ? [] : [versions.previous])].join(", ");
43
+ throw new ValidationError({
44
+ message: `This socket speaks ${served}${versions.previous === null ? ", or no subprotocol" : ""}. The client offered ${asked.length === 0 ? "none" : asked.join(", ")}.`,
45
+ action: `Offer ${versions.current} in ${SUBPROTOCOL_HEADER}.`,
46
+ });
47
+ }
@@ -1,6 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { zValidator } from "@hono/zod-validator";
5
+ import type { ValidationTargets } from "hono";
4
6
  import type { z } from "zod";
5
7
  import { fromZodError } from "../error/pithyError";
6
8
 
@@ -10,15 +12,21 @@ import { fromZodError } from "../error/pithyError";
10
12
  * mandatory, and both belong on the route line — reading a route tells you what it takes without
11
13
  * opening the handler.
12
14
  *
13
- * Routes use `zValidator(target, Schema, validationHook)` from `@hono/zod-validator`. The library
14
- * owns the middleware and the `c.req.valid(target)` typing; this file owns exactly one thing —
15
- * what happens when the parse fails. Without the hook, zod-validator answers with its own JSON
16
- * body, which is neither a `PithyError` payload nor routed through `pithyErrorHandler`. With it,
17
- * a malformed request is indistinguishable from every other failure the app can produce.
15
+ * Routes use `validate(target, Schema)`. `@hono/zod-validator` owns the middleware and the
16
+ * `c.req.valid(target)` typing; this file owns two things — what happens when the parse fails, and
17
+ * leaving the schema readable on the middleware afterwards.
18
18
  *
19
- * The hook is deliberately not a wrapper around `zValidator`. A wrapper would be a second name for
20
- * a library we do not re-export (CLAUDE.md §Toolchain), and it would hide the target from the route
21
- * line — the one thing the migration exists to make visible.
19
+ * The failure: without {@link validationHook}, zod-validator answers with its own JSON body, which is
20
+ * neither a `PithyError` payload nor routed through `pithyErrorHandler`. With it, a malformed request
21
+ * is indistinguishable from every other failure the app can produce.
22
+ *
23
+ * The schema: `zValidator` returns the same anonymous closure whatever it validates, so once a route is
24
+ * registered nothing can tell which schema it holds. That was tolerable while the only reader was the
25
+ * route line. The OpenAPI contract (`../openapi/document.ts`) is a second reader, and it reads the
26
+ * composed app, so the schema has to survive registration. `validate` is `zValidator` with the hook
27
+ * applied and the `{ target, schema }` pair attached under {@link REQUEST_CONTRACT}. It was once kept
28
+ * out of here on purpose, as a second name for a library; it earns the name now by carrying the one
29
+ * thing the library drops. The target stays the first argument, so the route line reads as before.
22
30
  */
23
31
 
24
32
  /**
@@ -39,3 +47,37 @@ export function validationHook(outcome: ValidationOutcome): void {
39
47
  if (outcome.success) return;
40
48
  throw fromZodError(outcome.error as z.ZodError);
41
49
  }
50
+
51
+ /** The key a {@link validate} middleware carries its contract under. Global, so two copies of core agree. */
52
+ export const REQUEST_CONTRACT = Symbol.for("pithy.requestContract");
53
+
54
+ /** What one validator declares: the part of the request it reads, and the schema that part must satisfy. */
55
+ export interface RequestContract {
56
+ /** The request part, as Hono names it: `json`, `query`, `param`, `header`, `cookie` or `form`. */
57
+ readonly target: keyof ValidationTargets;
58
+ /** The schema the part is parsed with. Its input side is what a caller sends. */
59
+ readonly schema: z.ZodType;
60
+ }
61
+
62
+ /**
63
+ * Declare what a route accepts in one part of the request, and fail a malformed one as a
64
+ * `validation/invalid_input` 400. The handler reads the parsed value with `c.req.valid(target)`.
65
+ *
66
+ * ```ts
67
+ * app.post(path, requireAuth(), validate("param", BoardParam), validate("json", SubmitScoreBody), handler);
68
+ * ```
69
+ */
70
+ export function validate<Target extends keyof ValidationTargets, Schema extends z.ZodType>(
71
+ target: Target,
72
+ schema: Schema,
73
+ ) {
74
+ const middleware = zValidator(target, schema, validationHook);
75
+ const contract: RequestContract = { target, schema };
76
+ return Object.assign(middleware, { [REQUEST_CONTRACT]: contract });
77
+ }
78
+
79
+ /** The contract a {@link validate} middleware carries, or `undefined` for any other handler. */
80
+ export function requestContractOf(handler: unknown): RequestContract | undefined {
81
+ if (typeof handler !== "function") return undefined;
82
+ return (handler as { [REQUEST_CONTRACT]?: RequestContract })[REQUEST_CONTRACT];
83
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import type { D1Database } from "@cloudflare/workers-types";
5
5
  import type { DatabaseIntrospector, DatabaseMetadataOptions, SchemaMetadata, TableMetadata } from "kysely";
6
- import { CamelCasePlugin, Kysely } from "kysely";
6
+ import { CamelCasePlugin, Kysely, sql } from "kysely";
7
7
  import { D1Dialect } from "kysely-d1";
8
8
 
9
9
  /**
@@ -42,8 +42,17 @@ export const MIGRATION_LOCK_TABLE = "pithy_migrations_lock";
42
42
  */
43
43
  export const MIGRATION_OWNER_TABLE = "pithy_migrations_owner";
44
44
 
45
+ /**
46
+ * Which run applied each migration — one row per applied migration, written by the runner from the
47
+ * `MigrationResult[]` Kysely hands back (`./groups`). Beside the ledger for the same reason the owner
48
+ * stamp is: Kysely creates `pithy_migrations` and its insert sets `name` and `timestamp` only, so there
49
+ * is nowhere in it to put a group — and a group is not a migration, so a rollback or a full reset must
50
+ * not remove the table. A reversed group's own rows go; the table stays.
51
+ */
52
+ export const MIGRATION_GROUP_TABLE = "pithy_migrations_groups";
53
+
45
54
  /** Pithy's bookkeeping tables — hidden by the introspector the way Kysely hides its own defaults. */
46
- const INTERNAL_TABLES = [MIGRATION_TABLE, MIGRATION_LOCK_TABLE, MIGRATION_OWNER_TABLE];
55
+ const INTERNAL_TABLES = [MIGRATION_TABLE, MIGRATION_LOCK_TABLE, MIGRATION_OWNER_TABLE, MIGRATION_GROUP_TABLE];
47
56
 
48
57
  /** Table names from `sqlite_master` only — no pragma joins, no column metadata. */
49
58
  class D1Introspector implements DatabaseIntrospector {
@@ -83,3 +92,31 @@ export class D1MigrationDialect extends D1Dialect {
83
92
  export function migrationKysely<DB = unknown>(database: D1Database): Kysely<DB> {
84
93
  return new Kysely<DB>({ dialect: new D1MigrationDialect({ database }), plugins: [new CamelCasePlugin()] });
85
94
  }
95
+
96
+ /**
97
+ * The applied migrations, **in the order Kysely steps down from them** — oldest first, tip last.
98
+ *
99
+ * Kysely's `Migrator` sorts the ledger by `timestamp`, breaks a tie on the name, and reverses that list to
100
+ * decide what the next `migrateDown()` reverses. So anything that asks *is this the top of the chain* has
101
+ * to read the same order: concluding it from the order some other table was written in answers a different
102
+ * question, and the answer only differs in the cases that matter (`./groups`, #694).
103
+ *
104
+ * Empty when the ledger table does not exist — a database nothing has migrated. Existence is checked
105
+ * against `sqlite_master`, a plain select D1 permits, rather than by catching the read's error, so a
106
+ * genuine read failure surfaces instead of being read as "nothing applied".
107
+ */
108
+ export async function appliedMigrationChain<DB>(db: Kysely<DB>): Promise<string[]> {
109
+ const present = await sql<{
110
+ name: string;
111
+ }>`select name from sqlite_master where type = 'table' and name = ${MIGRATION_TABLE}`.execute(db);
112
+ if (present.rows.length === 0) return [];
113
+ const { rows } = await sql<{ name: string; timestamp: string }>`select name, timestamp from ${sql.table(
114
+ MIGRATION_TABLE,
115
+ )}`.execute(db);
116
+ return [...rows]
117
+ .sort((left, right) => {
118
+ const elapsed = new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime();
119
+ return elapsed === 0 ? left.name.localeCompare(right.name) : elapsed;
120
+ })
121
+ .map((row) => row.name);
122
+ }