@kurtbruns/kestrel 1.19.0 → 1.21.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 +69 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/seed.mjs +2 -8
- package/dist/public/dashboard/app.js +307 -306
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +90 -51
- package/dist/worker/index.js +890 -559
- package/docs/get-started/02-deploy.md +3 -3
- package/docs/get-started/03-access.md +8 -4
- package/docs/get-started/04-aws-account.md +2 -0
- package/docs/get-started/05-ses.md +4 -2
- package/docs/get-started/07-verify.md +8 -6
- package/docs/get-started/08-go-live.md +12 -18
- package/docs/guides/01-allow-claude.md +7 -2
- package/docs/guides/03-notifications.md +17 -13
- package/docs/guides/07-upgrade.md +16 -2
- package/docs/publishing/03-name-your-publication.md +8 -0
- package/docs/publishing/04-write-a-post.md +2 -2
- package/docs/publishing/06-troubleshooting.md +52 -26
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +1 -1
- package/docs/reference/02-sending-domain-dns.md +3 -3
- package/migrations/0021_notifications_outage_receipts.sql +46 -0
- package/package.json +10 -10
- package/packages/kestrel/lib/api.mjs +19 -12
- package/packages/kestrel/lib/login.mjs +2 -2
- package/packages/kestrel/lib/media-types.mjs +34 -0
- package/packages/kestrel/lib/oauth.mjs +19 -2
- package/packages/kestrel/lib/openapi.mjs +1 -1
- package/template/README.md +1 -1
- package/template/claude/CLAUDE.md +1 -1
- package/template/claude/skills/upgrade/SKILL.md +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- 0021_notifications_outage_receipts: one outage, at most two emails, and a notice of silence (SPEC §8).
|
|
2
|
+
--
|
|
3
|
+
-- `not_sending` is now one notice for each stretch email is refusing rather than one a UTC day:
|
|
4
|
+
-- its episode is when the refusal began. A second kind, `not_sending_held`, keyed the same way,
|
|
5
|
+
-- is the one further email when a post is first held after that notice named none, so a unique
|
|
6
|
+
-- index on each kind's episode is what holds an outage to two. `no_receipts` is a send's notice
|
|
7
|
+
-- that the provider's receipts may not be reaching the app. The publisher is no longer told of
|
|
8
|
+
-- an import, so a pending `import` notice is closed as cleared rather than sent; the kind stays
|
|
9
|
+
-- admitted, with the column it reads, since the rows already recorded keep their history and the
|
|
10
|
+
-- settings surface may still show the latest. A STRICT table's CHECK can't be altered, so the
|
|
11
|
+
-- table is rebuilt, every row and column carried over.
|
|
12
|
+
--
|
|
13
|
+
-- What can't be recovered: nothing but the pending import notices, which were only ever going
|
|
14
|
+
-- to tell of an import already on the record.
|
|
15
|
+
|
|
16
|
+
CREATE TABLE notifications_next (
|
|
17
|
+
send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and an account notice
|
|
18
|
+
import_id TEXT REFERENCES imports (id), -- set only for an import notice
|
|
19
|
+
kind TEXT NOT NULL
|
|
20
|
+
CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'no_receipts', 'test', 'import', 'sandbox', 'not_sending', 'not_sending_held')),
|
|
21
|
+
episode INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
23
|
+
CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
|
|
24
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
error TEXT,
|
|
26
|
+
created_at INTEGER NOT NULL,
|
|
27
|
+
updated_at INTEGER NOT NULL,
|
|
28
|
+
sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to)),
|
|
29
|
+
UNIQUE (send_id, kind, episode),
|
|
30
|
+
UNIQUE (import_id),
|
|
31
|
+
CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox', 'not_sending', 'not_sending_held'))),
|
|
32
|
+
CHECK ((import_id IS NULL) = (kind <> 'import'))
|
|
33
|
+
) STRICT;
|
|
34
|
+
|
|
35
|
+
INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to)
|
|
36
|
+
SELECT send_id, import_id, kind, episode,
|
|
37
|
+
CASE WHEN kind = 'import' AND status = 'pending' THEN 'cleared' ELSE status END,
|
|
38
|
+
attempts, error, created_at, updated_at, sent_to
|
|
39
|
+
FROM notifications;
|
|
40
|
+
|
|
41
|
+
DROP TABLE notifications;
|
|
42
|
+
ALTER TABLE notifications_next RENAME TO notifications;
|
|
43
|
+
CREATE INDEX idx_notifications_status ON notifications (status, created_at);
|
|
44
|
+
CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
|
|
45
|
+
CREATE UNIQUE INDEX idx_notifications_not_sending ON notifications (episode) WHERE kind = 'not_sending';
|
|
46
|
+
CREATE UNIQUE INDEX idx_notifications_not_sending_held ON notifications (episode) WHERE kind = 'not_sending_held';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurtbruns/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,21 +68,21 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@css-inline/css-inline-wasm": "^0.21.2",
|
|
70
70
|
"aws4fetch": "^1.0.20",
|
|
71
|
-
"jose": "^6.2.
|
|
72
|
-
"marked": "^18.
|
|
71
|
+
"jose": "^6.2.14",
|
|
72
|
+
"marked": "^18.1.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
-
"wrangler": "^4.
|
|
75
|
+
"wrangler": "^4.156.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@biomejs/biome": "2.
|
|
79
|
-
"@cloudflare/vitest-plugin": "^1.4.
|
|
80
|
-
"@types/node": "^22.20.
|
|
81
|
-
"esbuild": "0.28.
|
|
82
|
-
"happy-dom": "^
|
|
78
|
+
"@biomejs/biome": "2.6.4",
|
|
79
|
+
"@cloudflare/vitest-plugin": "^1.4.11",
|
|
80
|
+
"@types/node": "^22.20.5",
|
|
81
|
+
"esbuild": "0.28.2",
|
|
82
|
+
"happy-dom": "^21.0.4",
|
|
83
83
|
"typescript": "~5.9.3",
|
|
84
84
|
"vitest": "^4.1.12",
|
|
85
|
-
"wrangler": "^4.
|
|
85
|
+
"wrangler": "^4.156.0"
|
|
86
86
|
},
|
|
87
87
|
"allowScripts": {
|
|
88
88
|
"esbuild": true,
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Deployed, it sends the access token `kestrel login` stored, refreshing it from the
|
|
6
6
|
* refresh token when it is about to expire or Access has turned it away, and storing what
|
|
7
7
|
* comes back. Locally there is no Access: the target is this directory's dev server, and
|
|
8
|
-
* the credential is the dev server's human token, so a local run is a person
|
|
9
|
-
*
|
|
10
|
-
* unless `--client` names another program, so a person's own script
|
|
11
|
-
* never names the editor: the server trusts the name it is sent (SPEC,
|
|
12
|
-
* and the editor's name lifts a step only a person in the editor may take
|
|
8
|
+
* the credential is the dev server's human token, so a local run is a person through a
|
|
9
|
+
* client just as a deployed one is. Every request names its client in `Kestrel-Client`:
|
|
10
|
+
* `defaultClient` says which, unless `--client` names another program, so a person's own script
|
|
11
|
+
* can name itself. It never names the editor: the server trusts the name it is sent (SPEC,
|
|
12
|
+
* Trusted, not guarded), and the editor's name lifts a step only a person in the editor may take
|
|
13
|
+
* (SPEC §7).
|
|
13
14
|
*
|
|
14
15
|
* It prints the answer's body as it came, so a script or Claude reads a refusal whole; a
|
|
15
16
|
* non-2xx answer also prints its status, and a refusal's code, message, field, and next move,
|
|
@@ -24,10 +25,13 @@ import { readFileSync } from "node:fs";
|
|
|
24
25
|
import { basename } from "node:path";
|
|
25
26
|
import { readSignIn, readSignIns, withCredentialsLock, writeSignInHeld } from "./credentials.mjs";
|
|
26
27
|
import { devTarget } from "./dev-api.mjs";
|
|
28
|
+
import { fileMediaType } from "./media-types.mjs";
|
|
27
29
|
import {
|
|
28
|
-
|
|
30
|
+
CLAUDE_CODE_CLIENT,
|
|
31
|
+
defaultClient,
|
|
29
32
|
instanceOrigin,
|
|
30
33
|
isLoopback,
|
|
34
|
+
KESTREL_CLI_CLIENT,
|
|
31
35
|
needsRefresh,
|
|
32
36
|
refreshTokens,
|
|
33
37
|
SignInError,
|
|
@@ -54,14 +58,16 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
|
|
|
54
58
|
--form <name=value> a multipart field, repeatable; name=@file attaches a file
|
|
55
59
|
--url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
|
|
56
60
|
--local this directory's dev server, started by kestrel dev
|
|
57
|
-
--client <name> your own script's Kestrel-Client name (default ${
|
|
58
|
-
|
|
61
|
+
--client <name> your own script's Kestrel-Client name (default ${CLAUDE_CODE_CLIENT}
|
|
62
|
+
when Claude Code runs this, otherwise ${KESTREL_CLI_CLIENT}); never
|
|
63
|
+
editor, which only the editor names itself
|
|
59
64
|
|
|
60
65
|
The routes, fields, and refusals come from the instance's own /api/openapi.json, so they
|
|
61
66
|
match the release it runs. A field the route doesn't take is refused before the request.`;
|
|
62
67
|
|
|
63
|
-
// The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11).
|
|
64
|
-
|
|
68
|
+
// The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11). Mirrored
|
|
69
|
+
// from shared/who_acted.ts, since the CLI is plain Node; a spec holds the two equal.
|
|
70
|
+
export const EDITOR_CLIENT = "editor";
|
|
65
71
|
|
|
66
72
|
// The server's rule for a client name (shared/who_acted.ts), mirrored since the CLI is plain
|
|
67
73
|
// Node; a spec holds the two equal. Checking it here, after the trim fetch applies to a header
|
|
@@ -227,7 +233,8 @@ async function requestBody({ data, type, forms }) {
|
|
|
227
233
|
const value = field.slice(at + 1);
|
|
228
234
|
if (value.startsWith("@")) {
|
|
229
235
|
const file = value.slice(1);
|
|
230
|
-
|
|
236
|
+
const type = fileMediaType(file);
|
|
237
|
+
form.append(name, new Blob([readFileSync(file)], { type }), basename(file));
|
|
231
238
|
} else {
|
|
232
239
|
form.append(name, value);
|
|
233
240
|
}
|
|
@@ -416,7 +423,7 @@ export async function apiCommand(argv) {
|
|
|
416
423
|
let call;
|
|
417
424
|
try {
|
|
418
425
|
const args = parseApiArgs(argv);
|
|
419
|
-
const client = args.client ??
|
|
426
|
+
const client = args.client ?? defaultClient();
|
|
420
427
|
call = await openTarget(args, client);
|
|
421
428
|
if (args.mode === "list") {
|
|
422
429
|
console.log(listRoutes(await readOpenApi(call), args.resource));
|
|
@@ -13,7 +13,7 @@ import { signedInFetch } from "./api.mjs";
|
|
|
13
13
|
import { credentialsFile, readSignIn, readSignIns, writeSignIn } from "./credentials.mjs";
|
|
14
14
|
import {
|
|
15
15
|
authorizeUrl,
|
|
16
|
-
|
|
16
|
+
defaultClient,
|
|
17
17
|
discover,
|
|
18
18
|
exchangeCode,
|
|
19
19
|
instanceOrigin,
|
|
@@ -86,7 +86,7 @@ export async function signIn(origin, { browser }) {
|
|
|
86
86
|
} finally {
|
|
87
87
|
listener.close();
|
|
88
88
|
}
|
|
89
|
-
const res = await signedInFetch(origin, PROTECTED_PATH, { client:
|
|
89
|
+
const res = await signedInFetch(origin, PROTECTED_PATH, { client: defaultClient() });
|
|
90
90
|
const body = await res.json().catch(() => null);
|
|
91
91
|
if (!res.ok) {
|
|
92
92
|
const move = unauthorizedMove(body);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A file's media type, read from its extension. An instance reads an upload's format from its
|
|
3
|
+
* bytes and holds the label to them, but this client also calls instances on older releases,
|
|
4
|
+
* which read only what the multipart part declares and refuse one with no type, so it states one:
|
|
5
|
+
* `kestrel api --form` does, and so does `kestrel seed`, which imports this rather than keep
|
|
6
|
+
* its own copy (the client may not import from cli/, but cli/ imports from the client).
|
|
7
|
+
*/
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The extensions of the raster formats an instance takes as an image. The Worker's list is
|
|
12
|
+
* src/lib/image_types.ts, mirrored since the client is plain Node; a spec holds the two equal.
|
|
13
|
+
*/
|
|
14
|
+
export const IMAGE_TYPE_BY_EXTENSION = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What a file goes as when its extension says nothing: a statement of no kind, not a guess. */
|
|
23
|
+
const UNKNOWN_MEDIA_TYPE = "application/octet-stream";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The media type a file's name says it is, in any case (a camera's `IMG_0042.JPG`), read just as
|
|
27
|
+
* the server reads a name's claim (shared/images.ts), so the two agree on a file called `.png`.
|
|
28
|
+
*/
|
|
29
|
+
export function fileMediaType(path) {
|
|
30
|
+
const name = basename(path);
|
|
31
|
+
return (
|
|
32
|
+
IMAGE_TYPE_BY_EXTENSION[name.slice(name.lastIndexOf(".")).toLowerCase()] ?? UNKNOWN_MEDIA_TYPE
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -19,8 +19,25 @@ import { createServer } from "node:http";
|
|
|
19
19
|
/** The path discovery and the sign-in's check ask: protected, and it answers who signed in. */
|
|
20
20
|
export const PROTECTED_PATH = "/api/whoami";
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// The names `kestrel api` gives itself (SPEC §11, Who acted). Mirrored from shared/who_acted.ts,
|
|
23
|
+
// since the client is plain Node; a spec holds them equal.
|
|
24
|
+
export const CLAUDE_CODE_CLIENT = "claude-code";
|
|
25
|
+
export const KESTREL_CLI_CLIENT = "kestrel-cli";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The name a request carries unless told another: Claude Code's when Claude Code ran this
|
|
29
|
+
* process, and the command's own otherwise, so what a person runs never reads as Claude's.
|
|
30
|
+
* CLAUDE_CODE_CHILD_SESSION is set only by Claude Code, on what its tools launch. CLAUDECODE
|
|
31
|
+
* won't do: IDE extensions also set it in their integrated terminals, where a person types.
|
|
32
|
+
*
|
|
33
|
+
* That makes the name truthful in the normal case, not proven, as with every client name. Claude
|
|
34
|
+
* Code older than v2.1.172 doesn't set the variable, so its calls read as the command's own; and
|
|
35
|
+
* a `screen` session or a background launcher Claude Code started passes it on, so what a person
|
|
36
|
+
* later runs inside one reads as Claude Code's.
|
|
37
|
+
*/
|
|
38
|
+
export function defaultClient() {
|
|
39
|
+
return process.env.CLAUDE_CODE_CHILD_SESSION === "1" ? CLAUDE_CODE_CLIENT : KESTREL_CLI_CLIENT;
|
|
40
|
+
}
|
|
24
41
|
|
|
25
42
|
// How long before its expiry an access token is refreshed rather than sent.
|
|
26
43
|
const REFRESH_MARGIN_MS = 60_000;
|
|
@@ -278,7 +278,7 @@ export function describeRoute(doc, method, path) {
|
|
|
278
278
|
}
|
|
279
279
|
const raw = types.filter((t) => t !== "application/json" && t !== "multipart/form-data");
|
|
280
280
|
if (raw.length) {
|
|
281
|
-
how.push(`the file itself, with --data @file --type ${raw
|
|
281
|
+
how.push(`the file itself, with --data @file --type <its own type> (${raw.join(", ")})`);
|
|
282
282
|
}
|
|
283
283
|
out.push(` Send it as ${how.join("; or ")}.`);
|
|
284
284
|
const example = op.requestBody.content["application/json"]?.example;
|
package/template/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
|
|
|
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
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
|
|
26
|
+
| `npx kestrel api <method> <path>` | Calls the API as you (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
|
|
27
27
|
| `npx wrangler deploy --env production` | Deploys the instance |
|
|
28
28
|
| `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
|
|
29
29
|
|
|
@@ -29,7 +29,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
|
|
|
29
29
|
- **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.
|
|
30
30
|
- **`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.
|
|
31
31
|
- **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.
|
|
32
|
-
- **Posts with images:** upload each image from
|
|
32
|
+
- **Posts with images:** upload each image from its original file, asking the person to save it into this folder (the Claude desktop app saves a pasted image to a temporary folder, converted and under a generated name, so it can be uploaded but isn't the file they had), then reference it in the Markdown by the `filename` the upload answers, with alt text, ``, never by the upload's `url`. That name may differ from the one you sent, since spaces and characters that would break a reference become hyphens, and a name the post already has gets a number. An upload never replaces an image: to swap one, upload the new image, reference its `filename`, and delete the old one. The preview warns on a `url` reference, on a name the post doesn't have, on a reference that didn't become an image, and on an uploaded image the post doesn't use.
|
|
33
33
|
- **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.
|
|
34
34
|
- **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.
|
|
35
35
|
- **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.
|
|
@@ -11,6 +11,7 @@ An upgrade moves the instance to a newer release of `@kurtbruns/kestrel`. The gu
|
|
|
11
11
|
## What's yours to judge
|
|
12
12
|
|
|
13
13
|
- **Which release, and what it asks.** The installed version is in `package.json`. Read every changelog section between it and the target (the release notes at `https://github.com/kurtbruns/kestrel/releases`, or the new `CHANGELOG.md` once installed). Name each **Breaking** entry and each step in an *Upgrading* paragraph, and say what it means for this instance.
|
|
14
|
+
- **Anything not yet committed.** If the page's `git status` lists anything, show the person and ask whether to commit it first, as its own commit. If they'd rather not, the upgrade waits until the repository is clean. Never let it go into the upgrade's commit: the page's **Go back** reverts that commit, and would undo it too.
|
|
14
15
|
- **The context check.** `npm run check-context` compares this repository's Claude files with the release's. Show the person what it reports. When Kestrel changed a file the person has edited, show what changed, merge it with their edits, and run the command it suggests to record the merge. A change marked as bearing on safety comes first.
|
|
15
16
|
- **The local try.** Ask the person to try what they care about on `npm run dev` before anything reaches production.
|
|
16
17
|
- **The checks.** Report each one's result. A failure stops the upgrade until it's fixed or the person decides.
|