@kurtbruns/kestrel 1.12.0 → 1.14.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 +137 -1
- package/README.md +1 -1
- package/bin/kestrel.mjs +17 -2
- package/cli/api.mjs +462 -0
- package/cli/credentials.mjs +192 -0
- package/cli/dev-api.mjs +17 -11
- package/cli/login.mjs +155 -0
- package/cli/oauth.mjs +368 -0
- package/cli/openapi.mjs +569 -0
- package/cli/seed.mjs +1 -1
- package/dist/public/dashboard/app.js +350 -295
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +466 -153
- package/dist/worker/index.js +10634 -5773
- 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/04-ses.md +2 -2
- package/docs/get-started/05-verify.md +4 -4
- package/docs/get-started/06-go-live.md +3 -2
- 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 +8 -4
- package/docs/publishing/01-your-list.md +38 -33
- package/docs/publishing/02-sending-rules.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 +30 -14
- package/docs/publishing/08-connect-claude.md +60 -0
- package/docs/publishing/09-share-your-subscribe-link.md +3 -3
- package/docs/publishing/10-check-your-setup.md +9 -16
- package/docs/publishing/11-email-template.md +3 -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/0012_unsubscribe_cause.sql +139 -0
- package/migrations/0013_who_acted_client.sql +20 -0
- package/migrations/0014_send_acts.sql +25 -0
- package/package.json +1 -1
- package/template/README.md +2 -0
- package/template/claude/CLAUDE.md +18 -14
- package/template/claude/skills/migrate-list/SKILL.md +5 -4
- package/docs/guides/01-connect-claude.md +0 -98
- package/docs/publishing/07-working-with-claude.md +0 -30
package/cli/openapi.mjs
ADDED
|
@@ -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/cli/seed.mjs
CHANGED
|
@@ -134,7 +134,7 @@ export async function seedCommand(argv) {
|
|
|
134
134
|
` posts: ${summary.posts.sent} sent, ${summary.posts.scheduled} scheduled, ${summary.posts.draft} draft`,
|
|
135
135
|
);
|
|
136
136
|
console.log(
|
|
137
|
-
` deliveries: ${summary.deliveries} • images written: ${summary.
|
|
137
|
+
` deliveries: ${summary.deliveries} • images written: ${summary.images_written} • logo written: ${summary.logo_written}`,
|
|
138
138
|
);
|
|
139
139
|
console.log("");
|
|
140
140
|
console.log(" view it:");
|