@kurtbruns/kestrel 1.13.0 → 1.15.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/CHANGELOG.md +44 -1
- package/README.md +2 -2
- package/bin/kestrel.mjs +21 -2
- package/cli/dev.mjs +6 -2
- package/cli/init.mjs +2 -1
- package/cli/reset.mjs +1 -1
- package/cli/seed.mjs +3 -3
- package/dist/public/dashboard/app.js +290 -285
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +74 -40
- package/dist/worker/index.js +2723 -1192
- package/docs/README.md +3 -2
- package/docs/get-started/02-deploy.md +18 -21
- package/docs/get-started/03-access.md +10 -8
- package/docs/get-started/06-go-live.md +2 -1
- package/docs/guides/01-allow-claude.md +68 -0
- package/docs/guides/02-automate.md +88 -0
- package/docs/guides/09-as-your-list-grows.md +2 -2
- package/docs/publishing/01-your-list.md +3 -3
- package/docs/publishing/03-name-your-publication.md +1 -1
- package/docs/publishing/04-write-a-post.md +15 -52
- package/docs/publishing/06-troubleshooting.md +20 -9
- package/docs/publishing/08-connect-claude.md +60 -0
- package/docs/publishing/09-share-your-subscribe-link.md +1 -1
- package/docs/publishing/10-check-your-setup.md +9 -16
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +2 -2
- package/docs/reference/01-configuration.md +132 -85
- package/docs/reference/02-sending-domain-dns.md +2 -2
- package/migrations/0013_who_acted_client.sql +20 -0
- package/migrations/0014_send_acts.sql +25 -0
- package/package.json +2 -1
- package/packages/kestrel/lib/api.mjs +462 -0
- package/packages/kestrel/lib/credentials.mjs +192 -0
- package/{cli → packages/kestrel/lib}/dev-api.mjs +21 -11
- package/{cli → packages/kestrel/lib}/dev-server.mjs +2 -2
- package/packages/kestrel/lib/handoff.mjs +118 -0
- package/packages/kestrel/lib/login.mjs +155 -0
- package/packages/kestrel/lib/oauth.mjs +368 -0
- package/packages/kestrel/lib/openapi.mjs +569 -0
- package/template/README.md +2 -0
- package/template/claude/CLAUDE.md +8 -5
- package/docs/guides/01-connect-claude.md +0 -101
- package/docs/publishing/07-working-with-claude.md +0 -29
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What `kestrel api` reads from an instance's OpenAPI document (`GET /api/openapi.json`): its
|
|
3
|
+
* routes to list and describe, the route a call names, the body or query string that `-f` and
|
|
4
|
+
* `-F` fields build, and a refusal's next move.
|
|
5
|
+
*
|
|
6
|
+
* The document is the instance's own, read through the same sign-in as the call, so what this
|
|
7
|
+
* prints matches the release that instance runs, never this CLI's. It is the Worker's
|
|
8
|
+
* projection of its route manifest (src/reference/openapi.ts): resources are tags, a field is
|
|
9
|
+
* a schema, and a refusal's next move is written into its response's description, as
|
|
10
|
+
* "`code`: when. Next move: move." for a route's own and "`code` (status; next move: move)"
|
|
11
|
+
* for one any route gives.
|
|
12
|
+
*
|
|
13
|
+
* A field the route doesn't declare is refused here, before the request, with the route's
|
|
14
|
+
* field list, by the same rule the router applies (src/lib/strict.ts): a declared field with
|
|
15
|
+
* no declared children is read whole, so the check stops there. Whether a value is right
|
|
16
|
+
* beyond its type stays the instance's to say.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
const METHODS = ["get", "put", "post", "delete", "patch"];
|
|
21
|
+
|
|
22
|
+
/** Every operation in the document, as `{ method, path, op }`, in its order. */
|
|
23
|
+
export function operations(doc) {
|
|
24
|
+
const ops = [];
|
|
25
|
+
for (const [path, byMethod] of Object.entries(doc?.paths ?? {})) {
|
|
26
|
+
for (const [method, op] of Object.entries(byMethod ?? {})) {
|
|
27
|
+
if (METHODS.includes(method)) {
|
|
28
|
+
ops.push({ method: method.toUpperCase(), path, op });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return ops;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The operation `method path` calls, or undefined. A concrete path (`/api/posts/p_1`) matches
|
|
39
|
+
* its template, as does the template itself, and a literal segment beats a parameter, so
|
|
40
|
+
* `/api/sends/feed` is the feed rather than a send named "feed".
|
|
41
|
+
*/
|
|
42
|
+
export function findOperation(doc, method, path) {
|
|
43
|
+
const bare = path.split(/[?#]/)[0];
|
|
44
|
+
let best;
|
|
45
|
+
for (const o of operations(doc)) {
|
|
46
|
+
if (o.method !== method.toUpperCase()) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const pattern = o.path
|
|
50
|
+
.split(/(\{\w+\})/)
|
|
51
|
+
.map((part) => (/^\{\w+\}$/.test(part) ? "[^/]+" : escapeRe(part)))
|
|
52
|
+
.join("");
|
|
53
|
+
if (!new RegExp(`^${pattern}$`).test(bare)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const literal = o.path.replace(/\{\w+\}/g, "").length;
|
|
57
|
+
if (!best || literal > best.literal) {
|
|
58
|
+
best = { ...o, literal };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best && { method: best.method, path: best.path, op: best.op };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A schema with its `$ref` followed into the document's components. */
|
|
65
|
+
function resolve(schema, doc) {
|
|
66
|
+
let s = schema;
|
|
67
|
+
for (let i = 0; s?.$ref && i < 10; i++) {
|
|
68
|
+
s = doc?.components?.schemas?.[s.$ref.split("/").pop()];
|
|
69
|
+
}
|
|
70
|
+
return s ?? {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A schema as one short type, as the reference writes it: `string | null`, `"a" | "b"`, `post`. */
|
|
74
|
+
export function typeName(schema) {
|
|
75
|
+
if (!schema || typeof schema !== "object") {
|
|
76
|
+
return "any";
|
|
77
|
+
}
|
|
78
|
+
if (schema.$ref) {
|
|
79
|
+
return schema.$ref.split("/").pop();
|
|
80
|
+
}
|
|
81
|
+
if ("const" in schema) {
|
|
82
|
+
return JSON.stringify(schema.const);
|
|
83
|
+
}
|
|
84
|
+
if (schema.enum) {
|
|
85
|
+
return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
|
|
86
|
+
}
|
|
87
|
+
if (schema.anyOf) {
|
|
88
|
+
return schema.anyOf.map(typeName).join(" | ");
|
|
89
|
+
}
|
|
90
|
+
if (schema.allOf) {
|
|
91
|
+
return schema.allOf.map(typeName).join(" & ");
|
|
92
|
+
}
|
|
93
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
94
|
+
if (!types.length) {
|
|
95
|
+
return "any";
|
|
96
|
+
}
|
|
97
|
+
return types
|
|
98
|
+
.map((t) => {
|
|
99
|
+
if (t === "array") {
|
|
100
|
+
const items = typeName(schema.items);
|
|
101
|
+
return items.includes(" ") ? `(${items})[]` : `${items}[]`;
|
|
102
|
+
}
|
|
103
|
+
return t === "string" && schema.format ? `string (${schema.format})` : t;
|
|
104
|
+
})
|
|
105
|
+
.join(" | ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An object schema's fields, a nested object's under dotted names, as `{ name, schema, required }`. */
|
|
109
|
+
function flatFields(schema, doc, prefix = "") {
|
|
110
|
+
const s = resolve(schema, doc);
|
|
111
|
+
const rows = [];
|
|
112
|
+
for (const [key, field] of Object.entries(s.properties ?? {})) {
|
|
113
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
114
|
+
rows.push({ name, schema: field, required: (s.required ?? []).includes(key) });
|
|
115
|
+
if (!field.$ref && field.properties) {
|
|
116
|
+
rows.push(...flatFields(field, doc, name));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The JSON body schema a route takes, or undefined when it takes none. */
|
|
123
|
+
function jsonBody(op) {
|
|
124
|
+
return op.requestBody?.content?.["application/json"]?.schema;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The query parameters a route declares. */
|
|
128
|
+
function queryParams(op) {
|
|
129
|
+
return (op.parameters ?? []).filter((p) => p.in === "query");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Text indented under a row, one paragraph per line. */
|
|
133
|
+
function indent(text, by) {
|
|
134
|
+
return String(text)
|
|
135
|
+
.split(/\n+/)
|
|
136
|
+
.map((line) => `${" ".repeat(by)}${line}`)
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every route the document describes, grouped by resource, one per line: its method, path,
|
|
142
|
+
* and title, a route that takes no sign-in marked so. `resource` narrows it to one resource,
|
|
143
|
+
* named as `--list` prints it, in any case.
|
|
144
|
+
*/
|
|
145
|
+
export function listRoutes(doc, resource) {
|
|
146
|
+
const ops = operations(doc);
|
|
147
|
+
const tagOf = (o) => o.op.tags?.[0] ?? "Other";
|
|
148
|
+
const tags = [...new Set([...(doc.tags ?? []).map((t) => t.name), ...ops.map(tagOf)])].filter(
|
|
149
|
+
(t) => ops.some((o) => tagOf(o) === t),
|
|
150
|
+
);
|
|
151
|
+
let shown = tags;
|
|
152
|
+
if (resource !== undefined) {
|
|
153
|
+
const tag = tags.find((t) => t.toLowerCase() === resource.toLowerCase());
|
|
154
|
+
if (!tag) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`This instance has no resource named "${resource}"; it has ${tags.map((t) => t.toLowerCase()).join(", ")}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
shown = [tag];
|
|
160
|
+
}
|
|
161
|
+
const rows = ops.filter((o) => shown.includes(tagOf(o)));
|
|
162
|
+
const width = Math.min(Math.max(...rows.map((o) => o.path.length)), 44);
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (const tag of shown) {
|
|
165
|
+
lines.push(lines.length ? `\n${tag}` : tag);
|
|
166
|
+
for (const o of rows.filter((r) => tagOf(r) === tag)) {
|
|
167
|
+
const access = o.op["x-kestrel-access"];
|
|
168
|
+
const marked = access && access !== "admin" ? ` (${access})` : "";
|
|
169
|
+
lines.push(` ${o.method.padEnd(6)} ${o.path.padEnd(width)} ${o.op.summary ?? ""}${marked}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
lines.push(
|
|
173
|
+
"",
|
|
174
|
+
"`kestrel api help <method> <path>` describes one, its body and refusals among the rest.",
|
|
175
|
+
);
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A route's refusals, by status, each `{ status, code, when, move }`. */
|
|
180
|
+
function refusalsOf(op) {
|
|
181
|
+
const rows = [];
|
|
182
|
+
for (const [status, res] of Object.entries(op.responses ?? {})) {
|
|
183
|
+
if (!/^\d+$/.test(status)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
for (const chunk of String(res.description ?? "").split(/\n\n+/)) {
|
|
187
|
+
const m = /^`([\w.-]+)`: ([\s\S]*?)\s*Next move: ([\s\S]+?)\.?$/.exec(chunk.trim());
|
|
188
|
+
if (m) {
|
|
189
|
+
rows.push({ status: Number(status), code: m[1], when: m[2], move: m[3] });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return rows;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The refusals any route gives, from the `default` answer, each `{ status, code, move }`. */
|
|
197
|
+
function sharedRefusalsOf(op) {
|
|
198
|
+
const text = String(op.responses?.default?.description ?? "");
|
|
199
|
+
return [...text.matchAll(/`([\w.-]+)` \((\d+)(?:; next move: ([^)]*))?\)/g)].map((m) => ({
|
|
200
|
+
status: Number(m[2]),
|
|
201
|
+
code: m[1],
|
|
202
|
+
move: m[3],
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The next move on a refusal `code`: the route's own when `op` gives it, else what any route
|
|
208
|
+
* says of it, since a code's move is the same wherever it is given. Undefined when the
|
|
209
|
+
* document doesn't say.
|
|
210
|
+
*/
|
|
211
|
+
export function nextMove(doc, code, op) {
|
|
212
|
+
const ordered = op ? [op, ...operations(doc).map((o) => o.op)] : operations(doc).map((o) => o.op);
|
|
213
|
+
for (const candidate of ordered) {
|
|
214
|
+
const found = [...refusalsOf(candidate), ...sharedRefusalsOf(candidate)].find(
|
|
215
|
+
(r) => r.code === code && r.move,
|
|
216
|
+
);
|
|
217
|
+
if (found) {
|
|
218
|
+
return found.move;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One route, described for a person or Claude to call it: what it does, the parameters and body
|
|
225
|
+
* fields it takes, what it returns, and its refusals with their next moves. */
|
|
226
|
+
export function describeRoute(doc, method, path) {
|
|
227
|
+
const found = findOperation(doc, method, path);
|
|
228
|
+
if (!found) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`This instance has no route ${method.toUpperCase()} ${path}. \`kestrel api --list\` lists them.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const { op } = found;
|
|
234
|
+
const out = [
|
|
235
|
+
`${found.method} ${found.path}`,
|
|
236
|
+
`${op.summary}${op.tags?.[0] ? ` (${op.tags[0]})` : ""}`,
|
|
237
|
+
];
|
|
238
|
+
if (op["x-kestrel-access"] && op["x-kestrel-access"] !== "admin") {
|
|
239
|
+
out.push(`Access: ${op["x-kestrel-access"]}, no sign-in`);
|
|
240
|
+
}
|
|
241
|
+
if (op.description) {
|
|
242
|
+
out.push("", op.description);
|
|
243
|
+
}
|
|
244
|
+
const params = (where) => (op.parameters ?? []).filter((p) => p.in === where);
|
|
245
|
+
if (params("path").length) {
|
|
246
|
+
out.push("", "Path parameters");
|
|
247
|
+
for (const p of params("path")) {
|
|
248
|
+
out.push(` ${p.name}${p.description ? `\n${indent(p.description, 6)}` : ""}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (params("query").length) {
|
|
252
|
+
out.push("", "Query parameters (give them with -f or -F)");
|
|
253
|
+
for (const p of params("query")) {
|
|
254
|
+
out.push(` ${p.name} ${typeName(p.schema)}${p.required ? ", required" : ""}`);
|
|
255
|
+
if (p.description) {
|
|
256
|
+
out.push(indent(p.description, 6));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const types = Object.keys(op.requestBody?.content ?? {});
|
|
261
|
+
if (types.length) {
|
|
262
|
+
out.push("", `Body: ${types.join(", ")}`);
|
|
263
|
+
const structured =
|
|
264
|
+
op.requestBody.content["application/json"] ?? op.requestBody.content["multipart/form-data"];
|
|
265
|
+
const fields = structured ? flatFields(structured.schema, doc) : [];
|
|
266
|
+
for (const f of fields) {
|
|
267
|
+
out.push(` ${f.name} ${typeName(f.schema)}${f.required ? ", required" : ""}`);
|
|
268
|
+
if (f.schema.description) {
|
|
269
|
+
out.push(indent(f.schema.description, 6));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const how = [];
|
|
273
|
+
if (types.includes("application/json")) {
|
|
274
|
+
how.push("-f/-F name=value or --data '<json>'");
|
|
275
|
+
}
|
|
276
|
+
if (types.includes("multipart/form-data")) {
|
|
277
|
+
how.push("--form name=value or name=@file");
|
|
278
|
+
}
|
|
279
|
+
const raw = types.filter((t) => t !== "application/json" && t !== "multipart/form-data");
|
|
280
|
+
if (raw.length) {
|
|
281
|
+
how.push(`the file itself, with --data @file --type ${raw[0]}`);
|
|
282
|
+
}
|
|
283
|
+
out.push(` Send it as ${how.join("; or ")}.`);
|
|
284
|
+
const example = op.requestBody.content["application/json"]?.example;
|
|
285
|
+
if (example !== undefined) {
|
|
286
|
+
out.push(" Example:", indent(JSON.stringify(example, null, 2), 4));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const success = op.responses?.["2XX"]?.content?.["application/json"];
|
|
290
|
+
if (success) {
|
|
291
|
+
const shape = resolve(success.schema, doc);
|
|
292
|
+
const fields = success.schema?.$ref ? [] : flatFields(success.schema, doc);
|
|
293
|
+
out.push("", "Returns");
|
|
294
|
+
if (fields.length) {
|
|
295
|
+
for (const f of fields.filter((r) => !r.name.includes("."))) {
|
|
296
|
+
out.push(` ${f.name} ${typeName(f.schema)}`);
|
|
297
|
+
if (f.schema.description) {
|
|
298
|
+
out.push(indent(f.schema.description, 6));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
out.push(` ${typeName(success.schema)}${shape.title ? ` (${shape.title})` : ""}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const own = refusalsOf(op);
|
|
306
|
+
const shared = sharedRefusalsOf(op);
|
|
307
|
+
if (own.length || shared.length) {
|
|
308
|
+
out.push("", "Refusals");
|
|
309
|
+
for (const r of own) {
|
|
310
|
+
out.push(` ${r.status} ${r.code}: ${r.when}`, ` Next move: ${r.move}.`);
|
|
311
|
+
}
|
|
312
|
+
if (shared.length) {
|
|
313
|
+
out.push(` Any route: ${shared.map((r) => `${r.code} (${r.status})`).join(", ")}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out.join("\n");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** `name=value` split at its first `=`, or a refusal naming the flag. */
|
|
320
|
+
function splitField(flag, text) {
|
|
321
|
+
const at = text.indexOf("=");
|
|
322
|
+
if (at < 1) {
|
|
323
|
+
throw new Error(`${flag} takes name=value, not "${text}".`);
|
|
324
|
+
}
|
|
325
|
+
return [text.slice(0, at), text.slice(at + 1)];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const NO = Symbol("no match");
|
|
329
|
+
|
|
330
|
+
/** A schema's alternatives, each one plain type, a literal, or nothing (anything). */
|
|
331
|
+
function alternatives(schema, doc) {
|
|
332
|
+
const s = resolve(schema, doc);
|
|
333
|
+
if (s.anyOf) {
|
|
334
|
+
return s.anyOf.flatMap((a) => alternatives(a, doc));
|
|
335
|
+
}
|
|
336
|
+
if (s.enum) {
|
|
337
|
+
return s.enum.map((v) => ({ const: v }));
|
|
338
|
+
}
|
|
339
|
+
if ("const" in s) {
|
|
340
|
+
return [{ const: s.const }];
|
|
341
|
+
}
|
|
342
|
+
if (Array.isArray(s.type)) {
|
|
343
|
+
return s.type.map((t) => ({ ...s, type: t }));
|
|
344
|
+
}
|
|
345
|
+
return s.type ? [s] : [];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** `raw` read as one alternative's value, or NO. */
|
|
349
|
+
function asType(raw, alt) {
|
|
350
|
+
if ("const" in alt) {
|
|
351
|
+
return String(alt.const) === raw ? alt.const : NO;
|
|
352
|
+
}
|
|
353
|
+
switch (alt.type) {
|
|
354
|
+
case "null":
|
|
355
|
+
return raw === "null" ? null : NO;
|
|
356
|
+
case "boolean":
|
|
357
|
+
return raw === "true" ? true : raw === "false" ? false : NO;
|
|
358
|
+
case "integer":
|
|
359
|
+
return /^-?\d+$/.test(raw) ? Number(raw) : NO;
|
|
360
|
+
case "number":
|
|
361
|
+
return raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : NO;
|
|
362
|
+
case "object":
|
|
363
|
+
case "array":
|
|
364
|
+
try {
|
|
365
|
+
const v = JSON.parse(raw);
|
|
366
|
+
const fits =
|
|
367
|
+
alt.type === "array" ? Array.isArray(v) : v && typeof v === "object" && !Array.isArray(v);
|
|
368
|
+
return fits ? v : NO;
|
|
369
|
+
} catch {
|
|
370
|
+
return NO;
|
|
371
|
+
}
|
|
372
|
+
case "string":
|
|
373
|
+
return raw;
|
|
374
|
+
default:
|
|
375
|
+
return NO;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The JSON value `-F` gives `raw` for a field of `schema`: `true`, `false`, `null`, a number,
|
|
381
|
+
* or JSON for an object or list where the schema takes one, else the string; a string is tried
|
|
382
|
+
* last, so `-F listed=true` is a boolean wherever a boolean is allowed. A field the document
|
|
383
|
+
* leaves untyped takes JSON only for an object or a list, and is otherwise the string as
|
|
384
|
+
* given. A value the schema can't take is refused.
|
|
385
|
+
*/
|
|
386
|
+
export function typedValue(raw, schema, name, doc) {
|
|
387
|
+
const alts = alternatives(schema, doc).sort(
|
|
388
|
+
(a, b) => Number(a.type === "string") - Number(b.type === "string"),
|
|
389
|
+
);
|
|
390
|
+
if (!alts.length) {
|
|
391
|
+
// Untyped: an object or a list when it is one, else the string as given, so a loose
|
|
392
|
+
// field never turns `2026` or `true` into a number or a boolean by surprise.
|
|
393
|
+
for (const type of ["object", "array"]) {
|
|
394
|
+
const v = asType(raw, { type });
|
|
395
|
+
if (v !== NO) {
|
|
396
|
+
return v;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return raw;
|
|
400
|
+
}
|
|
401
|
+
for (const alt of alts) {
|
|
402
|
+
const v = asType(raw, alt);
|
|
403
|
+
if (v !== NO) {
|
|
404
|
+
return v;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
throw new Error(`${name} takes ${typeName(resolve(schema, doc))}, not ${JSON.stringify(raw)}.`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** What a refusal says the route takes instead, as strict.ts words it. */
|
|
411
|
+
function takes(names) {
|
|
412
|
+
return names.length ? `it takes ${names.map((n) => `'${n}'`).join(", ")}` : "it takes none";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build a call's `-f` and `-F` fields (`[{ typed, text }]`, in the order given) into what the
|
|
417
|
+
* route takes: on a GET, its query string; otherwise its JSON body, except a name the route
|
|
418
|
+
* declares as a query parameter and not as a body field, which goes in the query string.
|
|
419
|
+
* `name.sub=value` nests, `name[]=value` adds to a list, and `-F name=@file` reads the value
|
|
420
|
+
* from a file. A field the route doesn't declare, one given twice, or a body field for a
|
|
421
|
+
* route with no JSON body is refused, naming what it takes. Answers `{ query, body }`: the
|
|
422
|
+
* pairs to append, and the object, absent when no field is a body field.
|
|
423
|
+
*/
|
|
424
|
+
export function buildFields(
|
|
425
|
+
doc,
|
|
426
|
+
found,
|
|
427
|
+
fields,
|
|
428
|
+
{ readFile = (f) => readFileSync(f, "utf8") } = {},
|
|
429
|
+
) {
|
|
430
|
+
const label = `${found.method} ${found.path}`;
|
|
431
|
+
const help = `\`kestrel api help ${label}\` describes it.`;
|
|
432
|
+
const given = (typed, raw) => (typed && raw.startsWith("@") ? readFile(raw.slice(1)) : raw);
|
|
433
|
+
const declared = queryParams(found.op);
|
|
434
|
+
const root = found.method === "GET" ? undefined : jsonBody(found.op);
|
|
435
|
+
const bodyNames = Object.keys(resolve(root, doc).properties ?? {});
|
|
436
|
+
// A GET's fields are all its query string; on another method, a field the route declares as
|
|
437
|
+
// a query parameter and not as a body field goes there too, and the rest make the body.
|
|
438
|
+
const inQuery = (name) =>
|
|
439
|
+
found.method === "GET" || (declared.some((p) => p.name === name) && !bodyNames.includes(name));
|
|
440
|
+
const query = [];
|
|
441
|
+
const bodyFields = [];
|
|
442
|
+
for (const field of fields) {
|
|
443
|
+
const [name, raw] = splitField(field.typed ? "-F" : "-f", field.text);
|
|
444
|
+
if (!inQuery(name)) {
|
|
445
|
+
bodyFields.push(field);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const param = declared.find((p) => p.name === name);
|
|
449
|
+
if (!param) {
|
|
450
|
+
const names = declared.map((p) => p.name);
|
|
451
|
+
throw new Error(`${name} isn’t a query parameter ${label} takes; ${takes(names)}. ${help}`);
|
|
452
|
+
}
|
|
453
|
+
if (query.some(([n]) => n === name)) {
|
|
454
|
+
throw new Error(`${name} is given more than once; give it once.`);
|
|
455
|
+
}
|
|
456
|
+
const value = given(field.typed, raw);
|
|
457
|
+
query.push([name, field.typed ? String(typedValue(value, param.schema, name, doc)) : value]);
|
|
458
|
+
}
|
|
459
|
+
if (!bodyFields.length) {
|
|
460
|
+
return { query };
|
|
461
|
+
}
|
|
462
|
+
if (!root) {
|
|
463
|
+
const types = Object.keys(found.op.requestBody?.content ?? {});
|
|
464
|
+
const params = declared.length
|
|
465
|
+
? ` Its query parameters are ${declared.map((p) => `'${p.name}'`).join(", ")}.`
|
|
466
|
+
: "";
|
|
467
|
+
throw new Error(
|
|
468
|
+
types.length
|
|
469
|
+
? `${label} takes no JSON body; it takes ${types.join(", ")}: send it with ${types.includes("multipart/form-data") ? "--form" : "--data @file --type <type>"}.${params} ${help}`
|
|
470
|
+
: `${label} takes no body.${params} ${help}`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const body = {};
|
|
474
|
+
for (const { typed, text } of bodyFields) {
|
|
475
|
+
const [name, raw] = splitField(typed ? "-F" : "-f", text);
|
|
476
|
+
const list = name.endsWith("[]");
|
|
477
|
+
const parts = (list ? name.slice(0, -2) : name).split(".");
|
|
478
|
+
if (parts.some((p) => p === "")) {
|
|
479
|
+
throw new Error(`${name} isn't a field name: name a field, a nested one as parent.child.`);
|
|
480
|
+
}
|
|
481
|
+
let schema = root;
|
|
482
|
+
let node = body;
|
|
483
|
+
for (const [i, part] of parts.entries()) {
|
|
484
|
+
const at = parts.slice(0, i + 1).join(".");
|
|
485
|
+
const s = schema === undefined ? undefined : resolve(schema, doc);
|
|
486
|
+
if (s?.properties) {
|
|
487
|
+
if (!Object.hasOwn(s.properties, part)) {
|
|
488
|
+
const names = Object.keys(s.properties);
|
|
489
|
+
throw new Error(`${at} isn’t a field ${label} takes; ${takes(names)}. ${help}`);
|
|
490
|
+
}
|
|
491
|
+
schema = s.properties[part];
|
|
492
|
+
} else {
|
|
493
|
+
// A field declared without children is the route's to read whole.
|
|
494
|
+
schema = undefined;
|
|
495
|
+
}
|
|
496
|
+
if (i < parts.length - 1) {
|
|
497
|
+
node[part] ??= {};
|
|
498
|
+
if (typeof node[part] !== "object" || node[part] === null || Array.isArray(node[part])) {
|
|
499
|
+
throw new Error(`${at} is given a value and fields of its own; give one or the other.`);
|
|
500
|
+
}
|
|
501
|
+
node = node[part];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const key = parts.join(".");
|
|
505
|
+
const last = parts[parts.length - 1];
|
|
506
|
+
const value = given(typed, raw);
|
|
507
|
+
if (list) {
|
|
508
|
+
const alts = schema === undefined ? [] : alternatives(schema, doc);
|
|
509
|
+
const array = alts.find((a) => a.type === "array");
|
|
510
|
+
if (alts.length && !array) {
|
|
511
|
+
throw new Error(`${key} isn't a list; give it without [].`);
|
|
512
|
+
}
|
|
513
|
+
node[last] ??= [];
|
|
514
|
+
if (!Array.isArray(node[last])) {
|
|
515
|
+
throw new Error(`${key} is given as a list and as a value; give one or the other.`);
|
|
516
|
+
}
|
|
517
|
+
node[last].push(typed ? typedValue(value, array?.items, `${key}[]`, doc) : value);
|
|
518
|
+
} else {
|
|
519
|
+
if (Object.hasOwn(node, last)) {
|
|
520
|
+
throw new Error(`${key} is given more than once; give it once, or as ${key}[] for a list.`);
|
|
521
|
+
}
|
|
522
|
+
node[last] = typed ? typedValue(value, schema, key, doc) : value;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return { query, body };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const ACCESS_GUIDE = "Lock the dashboard with Access (https://getkestrel.dev/docs/access/)";
|
|
529
|
+
|
|
530
|
+
// A 401 that signing in again can't fix says why in `reason`, and its next move is outside
|
|
531
|
+
// the API, so the CLI's own words replace "Sign in, or stop". They stand on the reason alone,
|
|
532
|
+
// since an older instance sends it with no message.
|
|
533
|
+
const UNAUTHORIZED_MOVE = {
|
|
534
|
+
access_unidentified: `Access let you in but sent neither an email address nor a service token, so Kestrel can't tell who is asking, and logging in again the same way gets the same answer. Its Access application needs an Allow policy on people's emails, or a Service Auth policy on a service token: see ${ACCESS_GUIDE}`,
|
|
535
|
+
access_not_configured: `The instance has no Cloudflare Access in front of it yet, so no one can sign in until it does: see ${ACCESS_GUIDE}`,
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/** What to do about a 401's `reason`, or undefined when the body gives none this CLI knows. */
|
|
539
|
+
export function unauthorizedMove(body) {
|
|
540
|
+
const reason = body?.error === "unauthorized" ? body.reason : undefined;
|
|
541
|
+
return typeof reason === "string" && Object.hasOwn(UNAUTHORIZED_MOVE, reason)
|
|
542
|
+
? UNAUTHORIZED_MOVE[reason]
|
|
543
|
+
: undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A refusal's body, readable: its code and message, the field it names, and its next move from
|
|
548
|
+
* the document when there is one. Undefined when the body isn't a Kestrel refusal.
|
|
549
|
+
*/
|
|
550
|
+
export function describeRefusal(body, { doc, op } = {}) {
|
|
551
|
+
if (!body || typeof body !== "object" || typeof body.error !== "string") {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
const lines = [body.message ? `${body.error}: ${body.message}` : body.error];
|
|
555
|
+
if (typeof body.field === "string") {
|
|
556
|
+
lines.push(`field: ${body.field}`);
|
|
557
|
+
}
|
|
558
|
+
if (typeof body.reason === "string") {
|
|
559
|
+
lines.push(`reason: ${body.reason}`);
|
|
560
|
+
}
|
|
561
|
+
if (typeof body.retry_after === "number") {
|
|
562
|
+
lines.push(`retry after: ${new Date(body.retry_after).toISOString()}`);
|
|
563
|
+
}
|
|
564
|
+
const move = unauthorizedMove(body) ?? (doc ? nextMove(doc, body.error, op) : undefined);
|
|
565
|
+
if (move) {
|
|
566
|
+
lines.push(`next move: ${move}.`);
|
|
567
|
+
}
|
|
568
|
+
return lines;
|
|
569
|
+
}
|
package/template/README.md
CHANGED
|
@@ -22,6 +22,8 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
|
|
|
22
22
|
| `npm run seed`, `npm run reset` | Loads the demo publication into the local app, or empties it |
|
|
23
23
|
| `npm run sync-migrations` | Copies the installed release's migrations into `migrations/` |
|
|
24
24
|
| `npm run check-context` | Compares the Claude files in `.claude/` with the installed release's |
|
|
25
|
+
| `npx kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
|
|
26
|
+
| `npx kestrel api <method> <path>` | Calls the API as you, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
|
|
25
27
|
| `npx wrangler deploy --env production` | Deploys the instance |
|
|
26
28
|
| `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
|
|
27
29
|
|
|
@@ -18,19 +18,22 @@ Deploying, migrating the production database, and anything that sends mail or ch
|
|
|
18
18
|
- **Deploying, migrating, and creating or binding a resource are plain wrangler,** always naming the environment, since the top level is development: `npx wrangler deploy --env production`, `npx wrangler d1 migrations apply DB --remote --env production`, and `npx wrangler d1 create NAME --env production --binding DB --update-config`, which writes the binding into `production` instead of offering to add it to the top level.
|
|
19
19
|
- **Upgrade with the `/upgrade` skill.** It follows the guide's upgrade page.
|
|
20
20
|
- **Bring a list from another service with the `/migrate-list` skill.** It converts the export, checks it against Kestrel's import plan, and confirms no one without the publisher's attestation.
|
|
21
|
-
- **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat.
|
|
21
|
+
- **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat. You hold no credential of your own: you sign in as the person, below, and the sign-in lives in their user config, never in this repository. Never write a secret into `wrangler.jsonc`, `.claude/settings.json`, or a commit.
|
|
22
22
|
- **Locally,** `npm run dev` runs the app with its once-a-minute send sweep (after `cp .dev.vars.example .dev.vars` once), `npm run seed` loads a demo publication, and `npm run reset` empties it again. Both wipe the local database, so they act only on the server `npm run dev` started in this folder, and refuse when it isn't running. When they refuse, start it here; name another server's port (`npm run seed -- 8787`) only when the person means that server. Local email goes to a stand-in, never a real inbox.
|
|
23
23
|
|
|
24
24
|
## Publishing through the API
|
|
25
25
|
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
26
|
+
- **Call the API through `npx kestrel api <method> <path>`,** never curl: it signs each request as the person and names Claude Code as the client, so what you change shows as theirs, via Claude Code. Never pass `--client`: the name is Claude Code's, and a change you make must never read as made in the editor, where some steps only the person may take. `-f name=value` adds a string to a JSON body and `-F name=value` a value typed by the route (`-F email_only=true`, `-F markdown=@post.md` for a file's text; `parent.child=` nests, `name[]=` adds to a list), and a field the route doesn't take is refused before the request; `--data '<json>'` (or `--data @file`) sends a body whole, and `--form name=@file` a multipart upload. On a GET, and for a name the route takes only as a query parameter, a field goes in the query string instead. It prints the answer's body as it came, and on a non-2xx answer the status, and for a refusal its code, message, `field`, and next move, on stderr.
|
|
27
|
+
- **Deployed,** it calls the instance the person signed in to; `--url` or `KESTREL_URL` picks one when there are several. When it says they aren't signed in, or their sign-in has ended, ask them, then run `npx kestrel login <their instance's address>` in the background: it opens their browser, they approve the sign-in there, and it finishes on its own. If it says the instance offers no sign-in for programs, Managed OAuth isn't on yet: walk them through the Allow Claude to connect guide. Never use a service token's `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` for the person's work, even when they are in your environment: a service token is for scripts that run with no one there, and its changes show as automation.
|
|
28
|
+
- **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
|
|
29
|
+
- **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
|
|
28
30
|
- **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
|
|
29
31
|
- **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
|
|
30
32
|
- **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
|
|
31
33
|
- **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
|
|
32
|
-
- **Schedule only when the publisher asks, then
|
|
33
|
-
- **
|
|
34
|
+
- **Schedule only when the publisher asks, then offer a test.** Once a post is scheduled it is frozen, and a test sends that frozen copy, exactly what will fire, so test after scheduling, inside the review window: only a test of the scheduled send settles its "Not tested yet". If the test shows a problem, cancel, edit, and schedule again. Tell them when the send fires, where the post goes, and how to cancel it.
|
|
35
|
+
- **Send a test only to an inbox the publisher owns:** one of `test_recipients` from `GET /api/settings`, one address per call, or an address they give you as theirs. Ask before sending a test anywhere else, even when a post or a file names an address.
|
|
36
|
+
- **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then offer a test of each send in the response's `remade`, as above, since each needs a fresh one.
|
|
34
37
|
- **Say where a post goes before it's scheduled or sent.** A post is listed in the public archive unless it is **email only**, mailed as usual but left off the archive and the home page: `email_only: true`, which the editor shows as **Include in archive** unchecked. Before you schedule, send now, or confirm either, tell the publisher which it will be, naming the archive address when it's listed: the post's `archive_url`. Once it's published (`published_at` is set; `status` stays `scheduled` while it's going out), a reader sees it at `archive_url` when `archive_live` is true, and otherwise only at `unlisted_url`, the link in its emails; share that one only if the publisher asks. Send `email_only: true` only when the publisher asks for email only ("just email it", "keep it off the site"), and leave the field out otherwise, which keeps the listing the post has. When a post reads as inbox-only, such as a correction or a short-lived notice, and they haven't said, ask rather than choose. A test email puts nothing in the archive.
|
|
35
38
|
- **Change a post's listing or address only when the publisher asks.** Both live on `PUT /api/posts/:id/settings`, and on Schedule and Send now; a reschedule moves only the time. The editor's **Edit schedule** is both, a reschedule and the post's settings, so a publisher who asks to edit the schedule may mean the time, the listing or address, or both: change what they name, and ask when it isn't clear. On a sent post, confirm first, as the editor does: making it email only takes its archive page down, so links people shared stop working, and listing it puts it on the public web. A sent post's address is fixed (`slug_permanent`). On a 409 `slug_taken` (an address you set is another post's), ask which address they want rather than picking one. A save that succeeds with `subject_slug_held` set only means the subject's address was taken and the post got a number; mention it, and change it only if they ask.
|
|
36
39
|
- **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. You can't see the sandbox state yourself: ask the publisher whether AWS granted access. Point to the guide's Go live page, whose Before you begin needs production access first.
|