@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.
- 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/capability/capability.d.ts +7 -0
- 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/settings.d.ts +10 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +5 -3
- 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 +2275 -0
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
- package/dist/cli/commandCatalog.generated.js +2390 -0
- package/dist/cli/commandFacts.d.ts +164 -0
- package/dist/cli/commandFacts.d.ts.map +1 -0
- package/dist/cli/commandFacts.js +215 -0
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +4 -3
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +11 -1
- package/dist/controlPlane/http/routes.d.ts +1 -1
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +34 -7
- package/dist/createBackend.d.ts +7 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +34 -4
- 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 +40 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +80 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +26 -0
- 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/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/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.js +2 -2
- package/dist/logger/record.d.ts +23 -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/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/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/naming/domains.d.ts +30 -0
- package/dist/naming/domains.d.ts.map +1 -1
- package/dist/naming/domains.js +83 -4
- package/dist/naming/environment.d.ts.map +1 -1
- package/dist/naming/environment.js +2 -1
- 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.map +1 -1
- package/dist/naming/provisionScope.js +2 -1
- 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/devLogin.d.ts +33 -0
- package/dist/seed/devLogin.d.ts.map +1 -1
- package/dist/seed/devLogin.js +28 -1
- 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/workflow/dispatchRoute.d.ts.map +1 -1
- package/dist/workflow/dispatchRoute.js +12 -6
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +2 -3
- 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 +3 -3
- package/src/asyncapi/document.ts +164 -0
- package/src/capability/capability.ts +7 -0
- package/src/capability/composition.ts +2 -1
- package/src/capability/settings.ts +19 -2
- package/src/cli/command.ts +865 -0
- package/src/cli/commandCatalog.generated.ts +2533 -0
- package/src/cli/commandFacts.ts +219 -0
- package/src/controlPlane/error/errors.ts +10 -3
- package/src/controlPlane/http/guard.ts +14 -2
- package/src/controlPlane/http/routes.ts +57 -9
- package/src/createBackend.ts +35 -2
- package/src/entitlement/require.ts +1 -1
- package/src/error/cause.ts +18 -181
- package/src/error/payload.ts +59 -0
- package/src/error/pithyError.ts +76 -0
- package/src/error/unresolved.ts +853 -0
- package/src/error/wire.ts +40 -0
- 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/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/migrations/bookkeeping.ts +39 -2
- package/src/migrations/groups.ts +295 -0
- package/src/migrations/migrateStep.ts +19 -0
- package/src/migrations/owner.ts +10 -6
- package/src/migrations/retained.ts +2 -2
- package/src/migrations/runner.ts +214 -22
- package/src/naming/domains.ts +97 -16
- package/src/naming/environment.ts +2 -1
- package/src/naming/packageName.ts +20 -0
- package/src/naming/provisionScope.ts +2 -1
- package/src/openapi/document.ts +412 -0
- package/src/openapi/routeGroups.ts +25 -0
- package/src/seed/devLogin.ts +31 -0
- package/src/text/comments.ts +56 -4
- package/src/text/literals.ts +294 -0
- package/src/version.generated.ts +1 -1
- package/src/workflow/dispatchRoute.ts +13 -6
- package/src/workflow/faults.ts +5 -3
- package/src/workflow/hostEnv.ts +9 -1
- package/src/workflow/loopback.ts +2 -1
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Hono } from "hono";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CONTROL_PLANE_HEADER } from "../controlPlane/wire";
|
|
7
|
+
import { ErrorBody } from "../error/wire";
|
|
8
|
+
import { pathParams } from "../http/routeContract";
|
|
9
|
+
import { type DocumentedMethod, type RouteResponse, type RouteSpec, routeSpecOf } from "../http/routeSpec";
|
|
10
|
+
import { type RequestContract, requestContractOf } from "../http/validation";
|
|
11
|
+
import type { VerificationStrategy } from "../http/verification";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **The OpenAPI contract, read off a composed app.**
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is written by hand. A route's request schemas come from its `validate` middleware, its
|
|
17
|
+
* summary, verification and responses from its `describeRoute`, and both are found by walking
|
|
18
|
+
* `app.routes` — the same table Hono dispatches from. So the document describes exactly what the Worker
|
|
19
|
+
* serves, the adopter's own routes included, and a schema edit reaches it with no second step.
|
|
20
|
+
*
|
|
21
|
+
* Zod is already the source of truth for every request and response, and every field carries a
|
|
22
|
+
* `.describe()` (CLAUDE.md §Zod). `z.toJSONSchema` keeps those descriptions, which is what lets a client
|
|
23
|
+
* generated in Swift or Kotlin carry the same documentation as the TypeScript one.
|
|
24
|
+
*
|
|
25
|
+
* OpenAPI 3.1, whose schema dialect is JSON Schema 2020-12 — the dialect `z.toJSONSchema` writes — so
|
|
26
|
+
* every schema crosses unchanged.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The OpenAPI version this module writes. */
|
|
30
|
+
export const OPENAPI_VERSION = "3.1.0";
|
|
31
|
+
|
|
32
|
+
/** One entry of a Hono app's route table: a method, a path and one handler. */
|
|
33
|
+
export type RouterRoute = Hono["routes"][number];
|
|
34
|
+
|
|
35
|
+
/** One capability's routes, as registered by its `routes` hook. Becomes one tag. */
|
|
36
|
+
export interface RouteGroup {
|
|
37
|
+
/** The tag, normally the capability's name. */
|
|
38
|
+
readonly tag: string;
|
|
39
|
+
/** What the capability does, for the tag's description. */
|
|
40
|
+
readonly description?: string;
|
|
41
|
+
/** The routes it registered, in registration order. */
|
|
42
|
+
readonly routes: readonly RouterRoute[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The document's `info` block. */
|
|
46
|
+
export interface ContractInfo {
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly version: string;
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A JSON Schema object, as `z.toJSONSchema` writes one. */
|
|
53
|
+
export type JsonSchema = Record<string, unknown>;
|
|
54
|
+
|
|
55
|
+
/** The subset of an OpenAPI 3.1 document this module writes. */
|
|
56
|
+
export interface OpenApiDocument {
|
|
57
|
+
openapi: string;
|
|
58
|
+
info: ContractInfo;
|
|
59
|
+
tags: { name: string; description?: string }[];
|
|
60
|
+
paths: Record<string, Record<string, unknown>>;
|
|
61
|
+
components: {
|
|
62
|
+
schemas: Record<string, JsonSchema>;
|
|
63
|
+
responses: Record<string, unknown>;
|
|
64
|
+
securitySchemes: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A route that registered no `describeRoute`, so the contract can say nothing true about it. */
|
|
69
|
+
export interface UndescribedRoute {
|
|
70
|
+
readonly method: string;
|
|
71
|
+
readonly path: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How each verification strategy is presented on the wire. `signed-webhook` and `public` have no scheme. */
|
|
75
|
+
const SECURITY_SCHEMES: Record<string, unknown> = {
|
|
76
|
+
bearer: {
|
|
77
|
+
type: "http",
|
|
78
|
+
scheme: "bearer",
|
|
79
|
+
description: "A short-lived access token from `@pithy-sh/auth`, sent as `Authorization: Bearer <token>`.",
|
|
80
|
+
},
|
|
81
|
+
session: {
|
|
82
|
+
type: "apiKey",
|
|
83
|
+
in: "cookie",
|
|
84
|
+
name: "better-auth.session_token",
|
|
85
|
+
description:
|
|
86
|
+
"A browser session cookie from `@pithy-sh/auth`, named `__Secure-better-auth.session_token` over HTTPS. A state-changing request also needs its `Origin` to be one the Worker trusts.",
|
|
87
|
+
},
|
|
88
|
+
controlPlane: {
|
|
89
|
+
type: "apiKey",
|
|
90
|
+
in: "header",
|
|
91
|
+
name: CONTROL_PLANE_HEADER,
|
|
92
|
+
description: "A customer-issued, scoped control-plane credential. Default-denied.",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The `security` requirement for each strategy that has one. */
|
|
97
|
+
const REQUIREMENT: Partial<Record<VerificationStrategy, Record<string, string[]>>> = {
|
|
98
|
+
bearer: { bearer: [] },
|
|
99
|
+
session: { session: [] },
|
|
100
|
+
"control-plane": { controlPlane: [] },
|
|
101
|
+
public: {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const ERROR_RESPONSE = "Error";
|
|
105
|
+
|
|
106
|
+
/** Hono's `/a/:id{[0-9]+}/*` as OpenAPI's `/a/{id}/{path}`. */
|
|
107
|
+
export function openApiPath(path: string): string {
|
|
108
|
+
return path
|
|
109
|
+
.replace(/:([A-Za-z0-9_]+)(\{[^}]*\})?\??/g, "{$1}")
|
|
110
|
+
.replace(/\/\*$/, "/{path}")
|
|
111
|
+
.replace(/\*/g, "{path}");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `get` + `/leaderboard/{board}/top` → `getLeaderboardByBoardTop`. A parameter reads as `By<Name>`, so
|
|
116
|
+
* `/items/{id}` and `/items/id` do not meet. Stable, but not unique on its own — `/a-b` and `/a/b` still
|
|
117
|
+
* share one — so {@link openApiDocument} numbers a repeat rather than writing an invalid document.
|
|
118
|
+
*/
|
|
119
|
+
export function operationId(method: string, path: string): string {
|
|
120
|
+
const words = path
|
|
121
|
+
.split("/")
|
|
122
|
+
.flatMap((segment) => {
|
|
123
|
+
const param = /^\{(.+)\}$/.exec(segment);
|
|
124
|
+
return param ? ["by", ...(param[1] ?? "").split(/[^A-Za-z0-9]+/)] : segment.split(/[^A-Za-z0-9]+/);
|
|
125
|
+
})
|
|
126
|
+
.filter((word) => word.length > 0)
|
|
127
|
+
.map((word) => `${word[0]?.toUpperCase()}${word.slice(1)}`);
|
|
128
|
+
return `${method.toLowerCase()}${words.join("")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A Zod schema as JSON Schema, without the `$schema` key OpenAPI does not want inside a document. */
|
|
132
|
+
export function jsonSchema(schema: z.ZodType, io: "input" | "output"): JsonSchema {
|
|
133
|
+
const { $schema: _dialect, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: "any" }) as JsonSchema;
|
|
134
|
+
return rest;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One route's handlers, grouped by method and path the way Hono stores them: one entry per handler. */
|
|
138
|
+
interface Operation {
|
|
139
|
+
readonly method: string;
|
|
140
|
+
readonly path: string;
|
|
141
|
+
readonly handlers: unknown[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function operationsOf(routes: readonly RouterRoute[]): Operation[] {
|
|
145
|
+
const byKey = new Map<string, Operation>();
|
|
146
|
+
for (const route of routes) {
|
|
147
|
+
const key = `${route.method} ${route.path}`;
|
|
148
|
+
const entry = byKey.get(key) ?? { method: route.method, path: route.path, handlers: [] };
|
|
149
|
+
entry.handlers.push(route.handler);
|
|
150
|
+
byKey.set(key, entry);
|
|
151
|
+
}
|
|
152
|
+
return [...byKey.values()];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An `ALL` entry with no spec is `app.use` — middleware, not a route — because Hono records both as
|
|
157
|
+
* `ALL`. An `app.all` route that forgot its `describeRoute` is therefore invisible to the coverage
|
|
158
|
+
* check, which is the one blind spot here; the kit has one such route and it is described.
|
|
159
|
+
*/
|
|
160
|
+
function isMiddlewareOnly(operation: Operation): boolean {
|
|
161
|
+
return operation.method === "ALL" && !operation.handlers.some((handler) => routeSpecOf(handler));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every route in these groups that registered no `describeRoute`. Empty is the contract being met. */
|
|
165
|
+
export function undescribedRoutes(groups: readonly RouteGroup[]): UndescribedRoute[] {
|
|
166
|
+
return groups.flatMap((group) =>
|
|
167
|
+
operationsOf(group.routes)
|
|
168
|
+
.filter((operation) => !isMiddlewareOnly(operation))
|
|
169
|
+
.filter((operation) => !operation.handlers.some((handler) => routeSpecOf(handler)))
|
|
170
|
+
.map(({ method, path }) => ({ method, path })),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The parameters one validator contributes: one per property of its object schema. */
|
|
175
|
+
function parametersOf(contract: RequestContract): Record<string, unknown>[] {
|
|
176
|
+
const location = { param: "path", query: "query", header: "header", cookie: "cookie" }[contract.target as string];
|
|
177
|
+
if (location === undefined) return [];
|
|
178
|
+
const schema = jsonSchema(contract.schema, "input");
|
|
179
|
+
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
|
|
180
|
+
const required = new Set((schema.required ?? []) as string[]);
|
|
181
|
+
return Object.entries(properties).map(([name, property]) => {
|
|
182
|
+
const { description, ...rest } = property;
|
|
183
|
+
return {
|
|
184
|
+
name,
|
|
185
|
+
in: location,
|
|
186
|
+
required: location === "path" || required.has(name),
|
|
187
|
+
...(typeof description === "string" ? { description } : {}),
|
|
188
|
+
schema: rest,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The request body one validator contributes, if it reads the body. */
|
|
194
|
+
function requestBodyOf(contract: RequestContract): Record<string, unknown> | undefined {
|
|
195
|
+
const media = { json: "application/json", form: "multipart/form-data" }[contract.target as string];
|
|
196
|
+
if (media === undefined) return undefined;
|
|
197
|
+
const schema = jsonSchema(contract.schema, "input");
|
|
198
|
+
return {
|
|
199
|
+
required: !contract.schema.safeParse(undefined).success,
|
|
200
|
+
...(typeof schema.description === "string" ? { description: schema.description } : {}),
|
|
201
|
+
content: { [media]: { schema } },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isZodSchema(value: z.ZodType | RouteResponse): value is z.ZodType {
|
|
206
|
+
return value instanceof z.ZodType;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One `responses` entry. A bare schema is a JSON body; a {@link RouteResponse} says what it is. */
|
|
210
|
+
function responseOf(declared: z.ZodType | RouteResponse): Record<string, unknown> {
|
|
211
|
+
if (isZodSchema(declared)) {
|
|
212
|
+
const schema = jsonSchema(declared, "input");
|
|
213
|
+
return {
|
|
214
|
+
description: typeof schema.description === "string" ? schema.description : "Success.",
|
|
215
|
+
content: { "application/json": { schema } },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const media = declared.contentType ?? (declared.schema ? "application/json" : undefined);
|
|
219
|
+
return {
|
|
220
|
+
description: declared.description,
|
|
221
|
+
...(media === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { content: { [media]: { schema: declared.schema ? jsonSchema(declared.schema, "input") : {} } } }),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function strategiesOf(spec: RouteSpec): readonly VerificationStrategy[] {
|
|
228
|
+
return typeof spec.verification === "string" ? [spec.verification] : spec.verification;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One OpenAPI operation for a described route. */
|
|
232
|
+
function operationOf(tag: string, method: string, operation: Operation, spec: RouteSpec): Record<string, unknown> {
|
|
233
|
+
const contracts = operation.handlers.flatMap((handler) => {
|
|
234
|
+
const contract = requestContractOf(handler);
|
|
235
|
+
return contract ? [contract] : [];
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const parameters = contracts.flatMap(parametersOf);
|
|
239
|
+
// A `:segment` with no param validator still has to be declared, or the document is invalid. It is
|
|
240
|
+
// declared bare — no description — which is exactly what the field-description sweep then reports.
|
|
241
|
+
for (const name of pathParams(operation.path)) {
|
|
242
|
+
if (!parameters.some((parameter) => parameter.in === "path" && parameter.name === name)) {
|
|
243
|
+
parameters.push({ name, in: "path", required: true, schema: { type: "string" } });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (/\*/.test(operation.path)) {
|
|
247
|
+
parameters.push({
|
|
248
|
+
name: "path",
|
|
249
|
+
in: "path",
|
|
250
|
+
required: true,
|
|
251
|
+
description: "The rest of the path, passed through to the handler.",
|
|
252
|
+
schema: { type: "string" },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const body = contracts.map(requestBodyOf).find((candidate) => candidate !== undefined);
|
|
257
|
+
const strategies = strategiesOf(spec);
|
|
258
|
+
const security = strategies.flatMap((strategy) => {
|
|
259
|
+
const requirement = REQUIREMENT[strategy];
|
|
260
|
+
return requirement === undefined ? [] : [requirement];
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const responses: Record<string, unknown> = {};
|
|
264
|
+
for (const [status, declared] of Object.entries(spec.responses)) responses[status] = responseOf(declared);
|
|
265
|
+
responses.default = { $ref: `#/components/responses/${ERROR_RESPONSE}` };
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
tags: [tag],
|
|
269
|
+
operationId: operationId(method, openApiPath(operation.path)),
|
|
270
|
+
summary: spec.summary,
|
|
271
|
+
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
272
|
+
...(parameters.length === 0 ? {} : { parameters }),
|
|
273
|
+
...(body === undefined ? {} : { requestBody: body }),
|
|
274
|
+
responses,
|
|
275
|
+
...(strategies.length === 0 ? {} : { security, "x-pithy-verification": strategies }),
|
|
276
|
+
...(spec.scopes === undefined || spec.scopes.length === 0 ? {} : { "x-pithy-scopes": spec.scopes }),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The methods an operation is documented under: its own, or for `app.all` the ones its spec names. */
|
|
281
|
+
function methodsOf(operation: Operation, spec: RouteSpec): readonly DocumentedMethod[] {
|
|
282
|
+
if (operation.method !== "ALL") return [operation.method.toLowerCase() as DocumentedMethod];
|
|
283
|
+
return spec.methods ?? ["get", "post"];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** How {@link openApiDocument} treats a route with no `describeRoute`. */
|
|
287
|
+
export interface DocumentOptions {
|
|
288
|
+
/**
|
|
289
|
+
* `omit` (the default) leaves it out, which is right for the kit: {@link undescribedRoutes} keeps that
|
|
290
|
+
* set empty, so anything missing is a bug the gate names. `include` writes what the validators alone
|
|
291
|
+
* can say — the path, its parameters and body — marked `x-pithy-undescribed`, which is right for an
|
|
292
|
+
* adopter's own routes: a client generated from the contract still reaches them, and the mark says
|
|
293
|
+
* which ones to describe.
|
|
294
|
+
*/
|
|
295
|
+
readonly undescribed?: "omit" | "include";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The spec an undescribed route is written with under `undescribed: "include"`. It claims nothing. */
|
|
299
|
+
const UNDESCRIBED: RouteSpec = {
|
|
300
|
+
summary: "Not described. Add describeRoute to this route.",
|
|
301
|
+
verification: [],
|
|
302
|
+
responses: {},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The OpenAPI document for these route groups. Undescribed routes are left out rather than guessed at,
|
|
307
|
+
* unless {@link DocumentOptions.undescribed} says otherwise; {@link undescribedRoutes} is the check that
|
|
308
|
+
* keeps that set empty.
|
|
309
|
+
*/
|
|
310
|
+
export function openApiDocument(
|
|
311
|
+
info: ContractInfo,
|
|
312
|
+
groups: readonly RouteGroup[],
|
|
313
|
+
options: DocumentOptions = {},
|
|
314
|
+
): OpenApiDocument {
|
|
315
|
+
const paths: OpenApiDocument["paths"] = {};
|
|
316
|
+
const tags: OpenApiDocument["tags"] = [];
|
|
317
|
+
const ids = new Map<string, number>();
|
|
318
|
+
/** An operation id no earlier operation took: the derived one, or it with the next free number. */
|
|
319
|
+
const unique = (id: string): string => {
|
|
320
|
+
const seen = ids.get(id) ?? 0;
|
|
321
|
+
ids.set(id, seen + 1);
|
|
322
|
+
return seen === 0 ? id : `${id}${seen + 1}`;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
for (const group of groups) {
|
|
326
|
+
let documented = false;
|
|
327
|
+
for (const operation of operationsOf(group.routes)) {
|
|
328
|
+
if (isMiddlewareOnly(operation)) continue;
|
|
329
|
+
const declared = operation.handlers.map(routeSpecOf).find((candidate) => candidate !== undefined);
|
|
330
|
+
if (declared === undefined && options.undescribed !== "include") continue;
|
|
331
|
+
const spec = declared ?? UNDESCRIBED;
|
|
332
|
+
documented = true;
|
|
333
|
+
const path = openApiPath(operation.path);
|
|
334
|
+
const item = paths[path] ?? {};
|
|
335
|
+
for (const method of methodsOf(operation, spec)) {
|
|
336
|
+
const derived = operationOf(group.tag, method, operation, spec);
|
|
337
|
+
const written = { ...derived, operationId: unique(String(derived.operationId)) };
|
|
338
|
+
item[method] = declared === undefined ? { ...written, "x-pithy-undescribed": true } : written;
|
|
339
|
+
}
|
|
340
|
+
paths[path] = item;
|
|
341
|
+
}
|
|
342
|
+
if (documented) tags.push({ name: group.tag, ...(group.description ? { description: group.description } : {}) });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
openapi: OPENAPI_VERSION,
|
|
347
|
+
info,
|
|
348
|
+
tags,
|
|
349
|
+
paths,
|
|
350
|
+
components: {
|
|
351
|
+
schemas: { [ERROR_RESPONSE]: jsonSchema(ErrorBody, "input") },
|
|
352
|
+
responses: {
|
|
353
|
+
[ERROR_RESPONSE]: {
|
|
354
|
+
description: "Any failure. The status is the error's own; the body is always this shape.",
|
|
355
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${ERROR_RESPONSE}` } } },
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
securitySchemes: SECURITY_SCHEMES,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One schema property somewhere in the document that carries no `description`. */
|
|
364
|
+
export interface UndescribedField {
|
|
365
|
+
/** A JSON pointer to the property, e.g. `/paths/~1leaderboard/get/responses/200/…/properties/rank`. */
|
|
366
|
+
readonly pointer: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const escapePointer = (segment: string) => segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Every object property and every parameter in the document without a description. The kit holds every
|
|
373
|
+
* Zod field to `.describe()`, and this is how that rule is checked on the wire shape a client actually
|
|
374
|
+
* generates from, rather than on the source it came from.
|
|
375
|
+
*/
|
|
376
|
+
export function undescribedFields(document: OpenApiDocument): UndescribedField[] {
|
|
377
|
+
return undescribedFieldsIn(document.paths, "/paths");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The same sweep over any part of a contract document, from a JSON pointer: every object property and every
|
|
382
|
+
* OpenAPI parameter below `node` without a description. The AsyncAPI document runs it over its messages.
|
|
383
|
+
*/
|
|
384
|
+
export function undescribedFieldsIn(node: unknown, at: string): UndescribedField[] {
|
|
385
|
+
const found: UndescribedField[] = [];
|
|
386
|
+
const visit = (node: unknown, pointer: string): void => {
|
|
387
|
+
if (Array.isArray(node)) {
|
|
388
|
+
node.forEach((child, index) => {
|
|
389
|
+
visit(child, `${pointer}/${index}`);
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (node === null || typeof node !== "object") return;
|
|
394
|
+
const record = node as Record<string, unknown>;
|
|
395
|
+
if (typeof record.in === "string" && typeof record.name === "string" && typeof record.description !== "string") {
|
|
396
|
+
found.push({ pointer });
|
|
397
|
+
}
|
|
398
|
+
const properties = record.properties;
|
|
399
|
+
if (properties !== null && typeof properties === "object" && !Array.isArray(properties)) {
|
|
400
|
+
for (const [name, property] of Object.entries(properties as Record<string, unknown>)) {
|
|
401
|
+
const described =
|
|
402
|
+
property !== null &&
|
|
403
|
+
typeof property === "object" &&
|
|
404
|
+
typeof (property as Record<string, unknown>).description === "string";
|
|
405
|
+
if (!described) found.push({ pointer: `${pointer}/properties/${escapePointer(name)}` });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const [key, child] of Object.entries(record)) visit(child, `${pointer}/${escapePointer(key)}`);
|
|
409
|
+
};
|
|
410
|
+
visit(node, at);
|
|
411
|
+
return found;
|
|
412
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { RouteGroup } from "./document";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which capability registered which routes, recorded by `createBackend` as it mounts them.
|
|
8
|
+
*
|
|
9
|
+
* Every capability registers onto the one shared app with absolute paths, so the finished route table
|
|
10
|
+
* says nothing about who owns a route. `createBackend` is the one place that knows — it calls each
|
|
11
|
+
* `routes` hook in turn — so it notes the slice of `app.routes` each hook added, and the contract tags
|
|
12
|
+
* each operation with the capability that owns it. Held beside the app rather than on it, so the app a
|
|
13
|
+
* Worker exports is the plain Hono instance it always was.
|
|
14
|
+
*/
|
|
15
|
+
const recorded = new WeakMap<object, readonly RouteGroup[]>();
|
|
16
|
+
|
|
17
|
+
/** Note the route groups of an assembled app. Called by `createBackend` and nothing else. */
|
|
18
|
+
export function recordRouteGroups(app: object, groups: readonly RouteGroup[]): void {
|
|
19
|
+
recorded.set(app, groups);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The route groups `createBackend` recorded for this app, or `undefined` for an app it did not assemble. */
|
|
23
|
+
export function routeGroupsOf(app: object): readonly RouteGroup[] | undefined {
|
|
24
|
+
return recorded.get(app);
|
|
25
|
+
}
|
package/src/seed/devLogin.ts
CHANGED
|
@@ -71,3 +71,34 @@ export const DevLogin = z
|
|
|
71
71
|
})
|
|
72
72
|
.describe("A seeded dev login: the signed claim for one seeded user, written to `logs/dev-login.json`.");
|
|
73
73
|
export type DevLogin = z.output<typeof DevLogin>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Every seeded dev login, keyed by `userId` — what `logs/dev-login.json` holds.
|
|
77
|
+
*
|
|
78
|
+
* **One entry per user the seed creates — `#667`.** It used to hold exactly one, named by a per-machine
|
|
79
|
+
* `dev.json`, so being a second seeded user meant reseeding or minting a claim by hand. Both are slow
|
|
80
|
+
* enough that people stop doing it, and the screens only one user can reach stop getting looked at. A
|
|
81
|
+
* claim is a signature over a user id and an expiry, so N of them cost no extra seeded rows and a few
|
|
82
|
+
* hundred bytes each.
|
|
83
|
+
*
|
|
84
|
+
* The key is the `userId`, which each entry also carries: the key is how the record is addressed and the
|
|
85
|
+
* field is what an entry means on its own, once something has picked one out of here.
|
|
86
|
+
*
|
|
87
|
+
* **Order is the file's, and for integer-like ids the language's.** `pithy dev` offers the identities in the
|
|
88
|
+
* order they are read, which is insertion order — except that JS enumerates canonical array-index keys
|
|
89
|
+
* first, in ascending numeric order, so a project whose user ids are stringified integers gets them
|
|
90
|
+
* numerically however the seed wrote them. Nothing depends on the order being the seed's; it is an offer
|
|
91
|
+
* order, not an identity.
|
|
92
|
+
*
|
|
93
|
+
* **The whole file is still one live credential, and the three properties that made one acceptable are
|
|
94
|
+
* what make N acceptable** — `logs/` is gitignored by the starter template, the artifact is written
|
|
95
|
+
* `0600`, and the writer refuses a symlink at the target.
|
|
96
|
+
*
|
|
97
|
+
* **Nothing migrates.** The single-entry file this replaced has strings where an entry belongs, so it does
|
|
98
|
+
* not parse, and every reader already answers "no dev login" to a file that does not — the banner stays
|
|
99
|
+
* quiet until the next `pithy seed`. The artifact is gitignored and regenerated, so that is the plan.
|
|
100
|
+
*/
|
|
101
|
+
export const DevLogins = z
|
|
102
|
+
.record(z.string(), DevLogin)
|
|
103
|
+
.describe("Every seeded dev login, keyed by user id, as `logs/dev-login.json` holds them.");
|
|
104
|
+
export type DevLogins = z.output<typeof DevLogins>;
|
package/src/text/comments.ts
CHANGED
|
@@ -84,9 +84,19 @@ export function blankCommentsAndStrings(source: string): string {
|
|
|
84
84
|
|
|
85
85
|
/** The one walk behind both: comments always blanked, a quoted run kept or blanked inside its delimiters. */
|
|
86
86
|
function blank(source: string, strings: boolean): string {
|
|
87
|
+
return walkCode(source, 0, strings, false).out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk code from `start`. Inside an interpolation (`nested`), the `}` that closes it ends the walk, and is
|
|
92
|
+
* included. A template is walked by {@link walkTemplate}, which walks each of its interpolations as code in turn
|
|
93
|
+
* — so a comment inside `${…}` is blanked like any other, and a template inside one closes nothing (#711).
|
|
94
|
+
*/
|
|
95
|
+
function walkCode(source: string, start: number, strings: boolean, nested: boolean): { out: string; end: number } {
|
|
87
96
|
let out = "";
|
|
88
97
|
let previous = "";
|
|
89
|
-
let
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let index = start;
|
|
90
100
|
while (index < source.length) {
|
|
91
101
|
const char = source[index] as string;
|
|
92
102
|
const pair = source.slice(index, index + 2);
|
|
@@ -96,8 +106,15 @@ function blank(source: string, strings: boolean): string {
|
|
|
96
106
|
index = stop;
|
|
97
107
|
continue;
|
|
98
108
|
}
|
|
99
|
-
if (char ===
|
|
100
|
-
const
|
|
109
|
+
if (char === "`") {
|
|
110
|
+
const walked = walkTemplate(source, index, strings);
|
|
111
|
+
out += walked.out;
|
|
112
|
+
index = walked.end;
|
|
113
|
+
previous = char;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (char === '"' || char === "'") {
|
|
117
|
+
const stop = endOfQuoted(source, index, char, true);
|
|
101
118
|
const run = source.slice(index, stop);
|
|
102
119
|
out += strings ? blankInside(run, char) : run;
|
|
103
120
|
index = stop;
|
|
@@ -111,11 +128,46 @@ function blank(source: string, strings: boolean): string {
|
|
|
111
128
|
previous = "/";
|
|
112
129
|
continue;
|
|
113
130
|
}
|
|
131
|
+
if (char === "{") depth += 1;
|
|
132
|
+
if (char === "}") {
|
|
133
|
+
if (nested && depth === 0) return { out: `${out}}`, end: index + 1 };
|
|
134
|
+
depth -= 1;
|
|
135
|
+
}
|
|
114
136
|
out += char;
|
|
115
137
|
if (!/\s/.test(char)) previous = char;
|
|
116
138
|
index += 1;
|
|
117
139
|
}
|
|
118
|
-
return out;
|
|
140
|
+
return { out, end: index };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Walk one template literal from its opening backtick. Its text is kept, or blanked whole — `${}` included — when
|
|
145
|
+
* `strings` asks; each interpolation is walked as code, so its comments are blanked and its own templates close
|
|
146
|
+
* nothing. Returns the text and the index past the closing backtick.
|
|
147
|
+
*/
|
|
148
|
+
function walkTemplate(source: string, start: number, strings: boolean): { out: string; end: number } {
|
|
149
|
+
let out = "`";
|
|
150
|
+
let index = start + 1;
|
|
151
|
+
while (index < source.length) {
|
|
152
|
+
const char = source[index] as string;
|
|
153
|
+
if (char === "\\") {
|
|
154
|
+
const escaped = source.slice(index, index + 2);
|
|
155
|
+
out += strings ? escaped.replace(/[^\n]/g, " ") : escaped;
|
|
156
|
+
index += 2;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (char === "`") return { out: `${out}\``, end: index + 1 };
|
|
160
|
+
if (char === "$" && source[index + 1] === "{") {
|
|
161
|
+
const walked = walkCode(source, index + 2, strings, true);
|
|
162
|
+
const text = `\${${walked.out}`;
|
|
163
|
+
out += strings ? text.replace(/[^\n]/g, " ") : text;
|
|
164
|
+
index = walked.end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
out += strings && char !== "\n" ? " " : char;
|
|
168
|
+
index += 1;
|
|
169
|
+
}
|
|
170
|
+
return { out, end: index };
|
|
119
171
|
}
|
|
120
172
|
|
|
121
173
|
/** A quoted run with everything between its delimiters blanked, newlines kept. An unterminated run has no closer. */
|