@pithy-sh/core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/asyncapi/document.d.ts +46 -0
- package/dist/asyncapi/document.d.ts.map +1 -0
- package/dist/asyncapi/document.js +115 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +105 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/composition.d.ts.map +1 -1
- package/dist/capability/composition.js +5 -1
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/settings.d.ts +96 -4
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +85 -4
- package/dist/cli/command.d.ts +364 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +418 -0
- package/dist/cli/commandCatalog.generated.d.ts +2437 -0
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
- package/dist/cli/commandCatalog.generated.js +2564 -0
- package/dist/cli/commandFacts.d.ts +164 -0
- package/dist/cli/commandFacts.d.ts.map +1 -0
- package/dist/cli/commandFacts.js +216 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +12 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +47 -0
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +55 -1
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +4 -3
- package/dist/controlPlane/http/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +28 -2
- package/dist/controlPlane/http/handlers.d.ts +60 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +31 -2
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +147 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +61 -4
- package/dist/controlPlane/http/routes.d.ts +2 -1
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +50 -9
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +13 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -3
- package/dist/createBackend.d.ts +7 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +41 -6
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/entitlement/require.js +1 -1
- package/dist/error/cause.d.ts +16 -116
- package/dist/error/cause.d.ts.map +1 -1
- package/dist/error/cause.js +19 -153
- package/dist/error/http.d.ts +184 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +368 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +126 -4
- package/dist/error/pithyError.d.ts +44 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +56 -1
- package/dist/error/unresolved.d.ts +265 -0
- package/dist/error/unresolved.d.ts.map +1 -0
- package/dist/error/unresolved.js +599 -0
- package/dist/error/wire.d.ts +20 -0
- package/dist/error/wire.d.ts.map +1 -0
- package/dist/error/wire.js +19 -0
- package/dist/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/oidcWebhook.d.ts +3 -3
- package/dist/http/oidcWebhook.js +3 -3
- package/dist/http/routeContract.js +1 -1
- package/dist/http/routeSpec.d.ts +66 -0
- package/dist/http/routeSpec.d.ts.map +1 -0
- package/dist/http/routeSpec.js +40 -0
- package/dist/http/sameOrigin.d.ts +1 -1
- package/dist/http/sameOrigin.js +1 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/http/signedWebhook.d.ts +3 -3
- package/dist/http/signedWebhook.js +3 -3
- package/dist/http/socket.d.ts +52 -0
- package/dist/http/socket.d.ts.map +1 -0
- package/dist/http/socket.js +2 -0
- package/dist/http/subprotocol.d.ts +29 -0
- package/dist/http/subprotocol.d.ts.map +1 -0
- package/dist/http/subprotocol.js +37 -0
- package/dist/http/validation.d.ts +40 -8
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +25 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/logger/record.d.ts +107 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/bookkeeping.d.ts +21 -0
- package/dist/migrations/bookkeeping.d.ts.map +1 -1
- package/dist/migrations/bookkeeping.js +32 -3
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/groups.d.ts +140 -0
- package/dist/migrations/groups.d.ts.map +1 -0
- package/dist/migrations/groups.js +191 -0
- package/dist/migrations/migrateStep.d.ts +13 -0
- package/dist/migrations/migrateStep.d.ts.map +1 -0
- package/dist/migrations/migrateStep.js +20 -0
- package/dist/migrations/owner.d.ts +7 -0
- package/dist/migrations/owner.d.ts.map +1 -1
- package/dist/migrations/owner.js +11 -7
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/retained.js +2 -2
- package/dist/migrations/runner.d.ts +76 -5
- package/dist/migrations/runner.d.ts.map +1 -1
- package/dist/migrations/runner.js +130 -19
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/domains.d.ts +17 -0
- package/dist/naming/domains.d.ts.map +1 -1
- package/dist/naming/domains.js +62 -4
- package/dist/naming/environment.d.ts.map +1 -1
- package/dist/naming/environment.js +2 -1
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/packageName.d.ts +17 -0
- package/dist/naming/packageName.d.ts.map +1 -0
- package/dist/naming/packageName.js +22 -0
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +10 -4
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- package/dist/openapi/document.d.ts +106 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/document.js +314 -0
- package/dist/openapi/routeGroups.d.ts +8 -0
- package/dist/openapi/routeGroups.d.ts.map +1 -0
- package/dist/openapi/routeGroups.js +23 -0
- package/dist/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/text/comments.js +66 -4
- package/dist/text/literals.d.ts +43 -0
- package/dist/text/literals.d.ts.map +1 -0
- package/dist/text/literals.js +293 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.d.ts.map +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/dispatchRoute.d.ts.map +1 -1
- package/dist/workflow/dispatchRoute.js +12 -6
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +24 -18
- package/dist/workflow/hostEnv.d.ts +1 -0
- package/dist/workflow/hostEnv.d.ts.map +1 -1
- package/dist/workflow/hostEnv.js +4 -2
- package/dist/workflow/loopback.d.ts.map +1 -1
- package/dist/workflow/loopback.js +2 -1
- package/package.json +4 -4
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/asyncapi/document.ts +164 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +106 -29
- package/src/capability/composition.ts +2 -1
- package/src/capability/settings.ts +165 -4
- package/src/cli/command.ts +865 -0
- package/src/cli/commandCatalog.generated.ts +2723 -0
- package/src/cli/commandFacts.ts +220 -0
- package/src/controlPlane/capability.ts +25 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/error/errors.ts +10 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +35 -2
- package/src/controlPlane/http/handlers.ts +41 -0
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +140 -2
- package/src/controlPlane/http/routes.ts +82 -10
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +14 -2
- package/src/createBackend.ts +59 -8
- package/src/createEntrypoint.ts +9 -3
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/entitlement/require.ts +1 -1
- package/src/error/cause.ts +18 -181
- package/src/error/payload.ts +270 -4
- package/src/error/pithyError.ts +76 -0
- package/src/error/unresolved.ts +853 -0
- package/src/error/wire.ts +40 -0
- package/src/http/authContext.ts +13 -1
- package/src/http/oidcWebhook.ts +3 -3
- package/src/http/routeContract.ts +1 -1
- package/src/http/routeSpec.ts +81 -0
- package/src/http/sameOrigin.ts +1 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/http/signedWebhook.ts +3 -3
- package/src/http/socket.ts +55 -0
- package/src/http/subprotocol.ts +47 -0
- package/src/http/validation.ts +50 -8
- package/src/index.ts +1 -0
- package/src/migrations/bookkeeping.ts +39 -2
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/groups.ts +295 -0
- package/src/migrations/migrateStep.ts +19 -0
- package/src/migrations/owner.ts +10 -6
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/retained.ts +2 -2
- package/src/migrations/runner.ts +214 -22
- package/src/migrations/state.ts +94 -0
- package/src/naming/domains.ts +75 -16
- package/src/naming/environment.ts +2 -1
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/packageName.ts +20 -0
- package/src/naming/provisionScope.ts +21 -2
- package/src/naming/resourceNames.ts +52 -5
- package/src/openapi/document.ts +412 -0
- package/src/openapi/routeGroups.ts +25 -0
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/text/comments.ts +56 -4
- package/src/text/literals.ts +294 -0
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/dispatchRoute.ts +13 -6
- package/src/workflow/faults.ts +56 -27
- package/src/workflow/hostEnv.ts +9 -1
- 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>;
|
package/src/http/authContext.ts
CHANGED
|
@@ -15,12 +15,24 @@ import { Locale } from "../i18n/locale";
|
|
|
15
15
|
* stored choice outrank their device's `Accept-Language`. That link read `c.var.locale` at first,
|
|
16
16
|
* which nothing ever wrote, so it silently contributed nothing and the chain degraded to
|
|
17
17
|
* `param → cookie → header → default` while the docs said otherwise.
|
|
18
|
+
*
|
|
19
|
+
* **`scopes` answers one question: did this request come from the adopter's own trusted backend, or from a
|
|
20
|
+
* device?** The name reads like a grant to the user, and it is not one. Tenancy roles went to `c.var.acting`
|
|
21
|
+
* with `@pithy-sh/organization`; management access is the control plane's, with its own credential and its
|
|
22
|
+
* own scope type. What is left is an attestation about the caller, which `@pithy-sh/auth`'s `resolveScopes`
|
|
23
|
+
* makes on every request from something the request proves — a service binding, a verified client
|
|
24
|
+
* certificate, a short-lived token bound to this origin — and never from anything it merely says (#560).
|
|
18
25
|
*/
|
|
19
26
|
export const AuthContext = z
|
|
20
27
|
.object({
|
|
21
28
|
userId: z.string().describe("ID of the authenticated user (populated by @pithy-sh/auth)."),
|
|
22
29
|
sessionId: z.string().describe("ID of the active session this request belongs to."),
|
|
23
|
-
scopes: z
|
|
30
|
+
scopes: z
|
|
31
|
+
.array(z.string())
|
|
32
|
+
.default([])
|
|
33
|
+
.describe(
|
|
34
|
+
"Attestations about the caller, not permissions granted to the user: what this request's caller was established to be — the adopter's own trusted backend, say — by the `resolveScopes` seam on `@pithy-sh/auth`, resolved per request and never cached. Empty when no resolver is configured. Tenancy roles are not here; they are `c.var.acting`.",
|
|
35
|
+
),
|
|
24
36
|
locale: Locale.nullish().describe(
|
|
25
37
|
"The reader's own stored language, from `pithy_auth_users.locale`; null or absent when they have never chosen.",
|
|
26
38
|
),
|
package/src/http/oidcWebhook.ts
CHANGED
|
@@ -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({…}),
|
|
495
|
-
*
|
|
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 `
|
|
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 `
|
|
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
|
+
}
|
package/src/http/sameOrigin.ts
CHANGED
|
@@ -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(),
|
|
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.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import type { PithyHonoEnv, ScopeGate } from "../capability/capability";
|
|
6
|
+
import { routeSpecOf } from "./routeSpec";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **A capability's scope gates, read off its own routes rather than listed beside them (#560).**
|
|
10
|
+
*
|
|
11
|
+
* `Capability.scopeGates` is what `pithy doctor` names when nothing composed fills `AuthContext.scopes`.
|
|
12
|
+
* A list written out by hand next to the routes is a second copy of something the routes already say: every
|
|
13
|
+
* gated route carries `describeRoute({ scopes })`, because that is how the OpenAPI contract learns it. So the
|
|
14
|
+
* registrar is run against a throwaway app and the specs are read back — the same walk `openApiDocument`
|
|
15
|
+
* makes over a composed app, and a route whose scope changes changes what this returns with it.
|
|
16
|
+
*
|
|
17
|
+
* **Only `bearer` and `session` routes.** Those are the strategies `requireAuth()` takes, so a scope on one
|
|
18
|
+
* is an `AuthContext` scope. A `control-plane` route also names scopes, and they are a different credential
|
|
19
|
+
* entirely — a signed management token with its own type, never `c.var.auth` — so naming one here would tell
|
|
20
|
+
* an adopter to configure a resolver that could never open it.
|
|
21
|
+
*/
|
|
22
|
+
export function scopeGatesOf(register: (app: Hono<PithyHonoEnv>) => void): ScopeGate[] {
|
|
23
|
+
const app = new Hono<PithyHonoEnv>();
|
|
24
|
+
register(app);
|
|
25
|
+
const gates: ScopeGate[] = [];
|
|
26
|
+
for (const route of app.routes) {
|
|
27
|
+
const spec = routeSpecOf(route.handler);
|
|
28
|
+
if (!spec?.scopes || spec.scopes.length === 0) continue;
|
|
29
|
+
const verification = [spec.verification].flat();
|
|
30
|
+
if (!verification.some((strategy) => strategy === "bearer" || strategy === "session")) continue;
|
|
31
|
+
for (const scope of spec.scopes) gates.push({ scope, route: `${route.method} ${route.path}` });
|
|
32
|
+
}
|
|
33
|
+
return gates;
|
|
34
|
+
}
|
|
@@ -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({…}),
|
|
381
|
-
* Body
|
|
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 `
|
|
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
|
+
}
|
package/src/http/validation.ts
CHANGED
|
@@ -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 `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
20
|
-
* a
|
|
21
|
-
*
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -101,6 +101,7 @@ export {
|
|
|
101
101
|
ControlPlaneScope,
|
|
102
102
|
KEYS_ROTATE_SCOPE,
|
|
103
103
|
MANIFEST_READ_SCOPE,
|
|
104
|
+
MIGRATIONS_READ_SCOPE,
|
|
104
105
|
SEAM_SCOPES,
|
|
105
106
|
} from "./controlPlane/scope/scope";
|
|
106
107
|
export { exportPublicJwk, type MintControlPlaneToken, mintControlPlaneToken } from "./controlPlane/token/mint";
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MigrationProvider } from "kysely/migration";
|
|
5
|
+
import type { Capability } from "../capability/capability";
|
|
6
|
+
import type { DatabaseSpec } from "../data/databases";
|
|
7
|
+
import { InternalError } from "../error/pithyError";
|
|
8
|
+
import { createMigrationRegistry, type NamespacedMigrations } from "./registry";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **Where a capability's migrations become a registry's sets — one function, composed twice (#729).**
|
|
12
|
+
*
|
|
13
|
+
* `pithy migrate` and `pithy doctor` compose these sets from the project on disk; the Worker composes them
|
|
14
|
+
* from the capabilities it was built with, to answer `GET {basePath}/migrations`. It lived in the CLI until
|
|
15
|
+
* the Worker needed it too. It reads nothing but `Capability.databases`, so it belongs in core — and if
|
|
16
|
+
* the two sides composed with two functions, `pithy doctor` and the route could disagree about one
|
|
17
|
+
* environment with nothing to say which was wrong.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A retained declaration must name a table this slice declares, beside the migrations that create it.
|
|
22
|
+
*
|
|
23
|
+
* A misspelled key would count a table that does not exist, find no rows, and let the `down` through —
|
|
24
|
+
* the refusal quietly off for the one table it was written for. And retention on a slice with no
|
|
25
|
+
* migrations has no `down` to be recorded on, so nothing would ever enforce it. Both are an author's mistake,
|
|
26
|
+
* caught where the declaration is read rather than discovered when a vault is empty (#588).
|
|
27
|
+
*/
|
|
28
|
+
function assertRetainedDeclared(capability: string, database: string, spec: DatabaseSpec): void {
|
|
29
|
+
const retained = spec.retained ?? [];
|
|
30
|
+
if (retained.length === 0) return;
|
|
31
|
+
const unknown = retained.filter((table) => !(table in spec.tables));
|
|
32
|
+
if (unknown.length > 0) {
|
|
33
|
+
throw new InternalError({
|
|
34
|
+
message: `Capability "${capability}" retains ${unknown.map((table) => `"${table}"`).join(", ")} in database "${database}", which its tables do not declare.`,
|
|
35
|
+
action: "Name tables from that database spec's tables in retained.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!spec.migrations || Object.keys(spec.migrations).length === 0) {
|
|
39
|
+
throw new InternalError({
|
|
40
|
+
message: `Capability "${capability}" retains tables in database "${database}" but ships no migrations for it.`,
|
|
41
|
+
action: "Declare retained beside the migrations that create those tables.",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every capability's per-database migration set (`DatabaseSpec.migrations`, namespaced by capability
|
|
48
|
+
* name), in composition order — the raw material {@link buildRegistryFromCapabilities}, the CLI's
|
|
49
|
+
* fan-out runner and the control-plane seam all build on. The runner needs the sets rather than a
|
|
50
|
+
* finished provider because two Workers can be bound to one physical D1: their sets merge into a single
|
|
51
|
+
* ordered provider so that database migrates once, with every Worker's capabilities in it. A Worker
|
|
52
|
+
* composes only its own, which is why the seam's `undeclared` is relative to that Worker. Migrations
|
|
53
|
+
* without a `migrationOrder` fail here, attributed to their capability.
|
|
54
|
+
*/
|
|
55
|
+
export function collectMigrationSets(capabilities: Capability[]): NamespacedMigrations[] {
|
|
56
|
+
const sets: NamespacedMigrations[] = [];
|
|
57
|
+
for (const capability of capabilities) {
|
|
58
|
+
for (const [database, spec] of Object.entries(capability.databases ?? {})) {
|
|
59
|
+
assertRetainedDeclared(capability.name, database, spec);
|
|
60
|
+
if (!spec.migrations) continue;
|
|
61
|
+
if (spec.migrationOrder === undefined) {
|
|
62
|
+
throw new InternalError({
|
|
63
|
+
message: `Capability "${capability.name}" has migrations for database "${database}" but no migrationOrder.`,
|
|
64
|
+
action: "Set migrationOrder on that database spec (core low, app high).",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
sets.push({
|
|
68
|
+
database,
|
|
69
|
+
namespace: capability.name,
|
|
70
|
+
order: spec.migrationOrder,
|
|
71
|
+
migrations: spec.migrations,
|
|
72
|
+
...(spec.retained && spec.retained.length > 0 ? { retained: spec.retained } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return sets;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compose every capability's per-database migration sets (`DatabaseSpec.migrations`,
|
|
81
|
+
* namespaced by capability name) into one ordered provider per database. A database
|
|
82
|
+
* nobody migrates gets no provider; migrations without a `migrationOrder` fail here,
|
|
83
|
+
* attributed to their capability.
|
|
84
|
+
*/
|
|
85
|
+
export function buildRegistryFromCapabilities(capabilities: Capability[]): Record<string, MigrationProvider> {
|
|
86
|
+
return createMigrationRegistry(collectMigrationSets(capabilities));
|
|
87
|
+
}
|