@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/bin/kestrel.mjs +1 -1
  3. package/cli/seed.mjs +2 -8
  4. package/dist/public/dashboard/app.js +307 -306
  5. package/dist/public/dashboard/app.js.map +4 -4
  6. package/dist/public/dashboard/index.html +2 -2
  7. package/dist/public/dashboard/styles.css +90 -51
  8. package/dist/worker/index.js +890 -559
  9. package/docs/get-started/02-deploy.md +3 -3
  10. package/docs/get-started/03-access.md +8 -4
  11. package/docs/get-started/04-aws-account.md +2 -0
  12. package/docs/get-started/05-ses.md +4 -2
  13. package/docs/get-started/07-verify.md +8 -6
  14. package/docs/get-started/08-go-live.md +12 -18
  15. package/docs/guides/01-allow-claude.md +7 -2
  16. package/docs/guides/03-notifications.md +17 -13
  17. package/docs/guides/07-upgrade.md +16 -2
  18. package/docs/publishing/03-name-your-publication.md +8 -0
  19. package/docs/publishing/04-write-a-post.md +2 -2
  20. package/docs/publishing/06-troubleshooting.md +52 -26
  21. package/docs/publishing/11-email-template.md +1 -1
  22. package/docs/publishing/README.md +1 -1
  23. package/docs/reference/02-sending-domain-dns.md +3 -3
  24. package/migrations/0021_notifications_outage_receipts.sql +46 -0
  25. package/package.json +10 -10
  26. package/packages/kestrel/lib/api.mjs +19 -12
  27. package/packages/kestrel/lib/login.mjs +2 -2
  28. package/packages/kestrel/lib/media-types.mjs +34 -0
  29. package/packages/kestrel/lib/oauth.mjs +19 -2
  30. package/packages/kestrel/lib/openapi.mjs +1 -1
  31. package/template/README.md +1 -1
  32. package/template/claude/CLAUDE.md +1 -1
  33. 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.19.0",
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.12",
72
- "marked": "^18.0.12"
71
+ "jose": "^6.2.14",
72
+ "marked": "^18.1.3"
73
73
  },
74
74
  "peerDependencies": {
75
- "wrangler": "^4.152.0"
75
+ "wrangler": "^4.156.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@biomejs/biome": "2.5.6",
79
- "@cloudflare/vitest-plugin": "^1.4.7",
80
- "@types/node": "^22.20.1",
81
- "esbuild": "0.28.1",
82
- "happy-dom": "^20.14.5",
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.152.0"
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 via Claude
9
- * Code just as a deployed one is. Every request carries `Kestrel-Client: claude-code`
10
- * unless `--client` names another program, so a person's own script can name itself. It
11
- * never names the editor: the server trusts the name it is sent (SPEC, Trusted, not guarded),
12
- * and the editor's name lifts a step only a person in the editor may take (SPEC §7).
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
- DEFAULT_CLIENT,
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 ${DEFAULT_CLIENT});
58
- never editor, which only the editor names itself
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
- const EDITOR_CLIENT = "editor";
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
- form.append(name, new Blob([readFileSync(file)]), basename(file));
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 ?? DEFAULT_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
- DEFAULT_CLIENT,
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: DEFAULT_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
- /** The name `kestrel api` gives its requests, unless told another (SPEC §11, Who acted). */
23
- export const DEFAULT_CLIENT = "claude-code";
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[0]}`);
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;
@@ -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, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
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 a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by its `filename`, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. The preview warns on a `url` reference, and on a name the post doesn't have.
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, `![a short description](cover.jpg)`, 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.