@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +2 -2
  3. package/bin/kestrel.mjs +21 -2
  4. package/cli/dev.mjs +6 -2
  5. package/cli/init.mjs +2 -1
  6. package/cli/reset.mjs +1 -1
  7. package/cli/seed.mjs +3 -3
  8. package/dist/public/dashboard/app.js +290 -285
  9. package/dist/public/dashboard/app.js.map +4 -4
  10. package/dist/public/dashboard/index.html +2 -2
  11. package/dist/public/dashboard/styles.css +74 -40
  12. package/dist/worker/index.js +2723 -1192
  13. package/docs/README.md +3 -2
  14. package/docs/get-started/02-deploy.md +18 -21
  15. package/docs/get-started/03-access.md +10 -8
  16. package/docs/get-started/06-go-live.md +2 -1
  17. package/docs/guides/01-allow-claude.md +68 -0
  18. package/docs/guides/02-automate.md +88 -0
  19. package/docs/guides/09-as-your-list-grows.md +2 -2
  20. package/docs/publishing/01-your-list.md +3 -3
  21. package/docs/publishing/03-name-your-publication.md +1 -1
  22. package/docs/publishing/04-write-a-post.md +15 -52
  23. package/docs/publishing/06-troubleshooting.md +20 -9
  24. package/docs/publishing/08-connect-claude.md +60 -0
  25. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  26. package/docs/publishing/10-check-your-setup.md +9 -16
  27. package/docs/publishing/11-email-template.md +1 -1
  28. package/docs/publishing/README.md +2 -2
  29. package/docs/reference/01-configuration.md +132 -85
  30. package/docs/reference/02-sending-domain-dns.md +2 -2
  31. package/migrations/0013_who_acted_client.sql +20 -0
  32. package/migrations/0014_send_acts.sql +25 -0
  33. package/package.json +2 -1
  34. package/packages/kestrel/lib/api.mjs +462 -0
  35. package/packages/kestrel/lib/credentials.mjs +192 -0
  36. package/{cli → packages/kestrel/lib}/dev-api.mjs +21 -11
  37. package/{cli → packages/kestrel/lib}/dev-server.mjs +2 -2
  38. package/packages/kestrel/lib/handoff.mjs +118 -0
  39. package/packages/kestrel/lib/login.mjs +155 -0
  40. package/packages/kestrel/lib/oauth.mjs +368 -0
  41. package/packages/kestrel/lib/openapi.mjs +569 -0
  42. package/template/README.md +2 -0
  43. package/template/claude/CLAUDE.md +8 -5
  44. package/docs/guides/01-connect-claude.md +0 -101
  45. package/docs/publishing/07-working-with-claude.md +0 -29
package/CHANGELOG.md CHANGED
@@ -10,6 +10,47 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.15.0] - 2026-10-23
14
+
15
+ **Upgrading from 1.14.0.** Nothing to add: your instance keeps running every `kestrel` command, `login` and `api` included, from the `@kurtbruns/kestrel` it already installs, which ships the client too. Don't add `kestrel` as a dependency, since its `kestrel` command would collide with that one. `npx -p @kurtbruns/kestrel kestrel …` keeps working, and `npx kestrel …` now does the same from any folder.
16
+
17
+ ### Changed
18
+
19
+ - The Kestrel CLI runs from anywhere as `npx kestrel …`: the unscoped `kestrel` package on npm is now Kestrel's own client, so `npx kestrel login <url>`, `npx kestrel api …`, and `npx kestrel logout` work outside your instance's repository, without naming `@kurtbruns/kestrel`. `npx kestrel init` writes a new instance from the `@kurtbruns/kestrel` release of the same version, and `dev`, `seed`, `reset`, `sync-migrations`, and `check-context` run from the instance's own install, with the same names and flags. The docs, the instance's README, and `/llms.txt` use the short form again. `kestrel@0.0.1`, an unrelated package that held the name before, is deprecated.
20
+
21
+ ## [1.14.0] - 2026-10-22
22
+
23
+ **Upgrading from 1.13.0.** Two database migrations, which `npm run sync-migrations` brings in: apply them before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0013_who_acted_client` records the client each revision and import came through. No client was recorded before it, and nothing tells a person's change in the editor from one through another program, so every revision and import a person made is read as made in the editor, and those rows are rewritten once, which counts against the day's database writes on Workers Free like any other write; a service token's read as naming no client. `0014_send_acts` adds a record of every act on a send: who scheduled it, moved it, canceled it, or resolved it, and when. It rewrites nothing: a send made before it reads as acted on by no one recorded.
24
+
25
+ **Before you deploy, check your Access policies.** Your Access application must let people in only through an Allow policy on emails, and programs only through a Service Auth policy on a service token: a login through any other kind of policy is now refused, and a search of your Worker's logs for `auth.unidentified` shows any that would be.
26
+
27
+ Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. To let Claude connect as a person, turn on Managed OAuth for the Access application, with its localhost and loopback clients allowed and `https://claude.ai/api/mcp/auth_callback` among its Allowed redirect URIs, as the new Allow Claude to connect guide describes; the `api` path already covers the new MCP server. Then, in your instance's repository, run `npx -p @kurtbruns/kestrel kestrel login <your instance's address>`, take out the service token's two values from `.claude/settings.local.json`, and revoke the token, or keep it for a script.
28
+
29
+ ### Added
30
+
31
+ - Kestrel records the client each change came through, beside the person or service token who made it (SPEC §11, Who acted). A client names itself on each request in a `Kestrel-Client` header, one short lowercase word (the editor sends `editor`, Claude Code `claude-code`); a malformed name is a 400 naming the header, and a request that names none reads as made through the API. `GET /api/whoami` answers `client`, a revision and a post `author_client`, and an import's record `client` in its `principal`; a service token's is always null. Where the editor names who made a change, a person through another client reads with it, such as "you@example.com via Claude Code" or "you@example.com via the API", and a person in the editor by their address alone.
32
+ - A send records every act on it, a schedule or Send now, a reschedule, a cancel, and a Resolve, with who took it (a person with the client they came through, or a service token by its name) and when (SPEC §6, §11). The send view answers them as `scheduled_by`, `rescheduled_by`, `canceled_by`, and `resolved_by`. The send's page shows them, and the scheduled banner names the latest when it wasn't you in the editor, such as "Scheduled by you@example.com via Claude Code", so a send Claude or automation scheduled never reads as one you scheduled yourself.
33
+ - `kestrel login <url>` signs Claude Code in to a deployed instance as you, through Cloudflare Access's Managed OAuth, which you approve in your browser, and `kestrel api <method> <path>` calls the API as you, naming Claude Code as the client, so what Claude changes shows as yours, via Claude Code (SPEC §11). The sign-in is kept in your user config, never in the instance's repository, and renews itself until the Access application's grant session ends; a token request gives up after 10 seconds instead of hanging, and `kestrel logout` forgets it. Outside the instance's repository, run it as `npx -p @kurtbruns/kestrel kestrel …`, since an unrelated npm package is called `kestrel`. `kestrel api --local` calls this folder's dev server as its stand-in person, the same way. `--client` names a script of your own instead, and never the editor, which `kestrel api` refuses. A refusal that signing in again can't fix, an Access login that named no one (`access_unidentified`), says what to change in Access instead. It needs Managed OAuth on for the instance's Access application, as the new Allow Claude to connect guide describes.
34
+ - `kestrel api` describes the API it calls, read from the instance's own OpenAPI document so it matches the release that instance runs: `kestrel api --list [resource]` lists the routes, and `kestrel api help <method> <path>` describes one, its parameters, body fields, answer, and refusals with their next moves. `-f name=value` (a string) and `-F name=value` (typed by the route, or `@file` for a file's text) build a JSON body, or the query string on a GET and for a name the route takes only as a query parameter, and a field the route doesn't take is refused before the request; a body field can't be combined with `--data` or `--form`. A refusal prints its code, message, field, and next move on stderr, and the raw body on stdout. The OpenAPI document names the next move on each refusal any route can give.
35
+ - Claude Desktop and claude.ai can work with an instance as a custom connector, signed in as you through Cloudflare Access's Managed OAuth, at `https://<your instance>/api/mcp` (SPEC §11). It's an MCP server in the Worker with a short set of tools for publishing in a chat: search and read posts, sends, and subscribers; preview a post's email; save a draft; send a test; schedule, reschedule, or cancel a send; import a list; unsubscribe someone; and `kestrel_api_read` for most other reads, never a write or a bulk export. Each tool acts through the API's own routes, so it's refused as the route is, a refusal coming back to Claude with its next move, and none confirms anything for you: an import's attestation waits for you to accept it in the chat, and an import that would grow a scheduled send is yours to finish in the dashboard. Its calls always name their client `claude`, so what Claude changes shows as yours, "via Claude". The publisher's docs come with it, for Claude to read. Erasure, uploads, and the logo stay in the dashboard. On connecting, Claude is told to offer a test once a post is scheduled and to send tests only to your test recipients or an address you give as yours. The publishing docs' Connect Claude page covers adding it in Claude Desktop or claude.ai.
36
+ - The connector's tools also change a post's archive listing or address, delete a draft, add a subscriber by double opt-in, block an address or lift a block or a bounce, resolve a stuck send with the answer you pick, change your settings and email template, and test your notifications (SPEC §11). Claude confirms with you before it deletes a draft, changes a sent post's listing, clears a bounce, saves a new test recipient or notifications address, or re-makes a scheduled send's email.
37
+ - `GET /api/posts/:id/preview.json` answers `text`, the email's plain-text part from the same render as the preview, so a client that can't show the page can read what goes out.
38
+
39
+ ### Changed
40
+
41
+ - The Configuration reference opens with the whole `production` block, then lists each setting once, by name, with whether it's required, its default, and what the app refuses, in place of summary tables and a separate list of refusals.
42
+ - The Docs tab's code blocks are quieter cards with a Copy icon beside the code, which turns to a check when the copy lands, and shell commands and JSON are highlighted. Inline code in the docs is maroon on a darker chip. The copy icon is redrawn everywhere with its two pages evenly offset.
43
+ - The browser tab now names the screen you're on, such as "Subscribers - Kestrel", a post or send by its subject, and a doc by its title, instead of "Kestrel" on every screen, so tabs, bookmarks, and history entries say where they lead. The web version's tab adds "(web version)" after the subject, so it reads apart from the editor's.
44
+ - Every admin `401` now carries a `message`, as every other refusal does, and the API reference and OpenAPI document describe its `reason`, which says when signing in again can't help (SPEC §11).
45
+ - The scheduled banner is quieter: "No test of this email has been sent yet" is now a muted "Not tested yet" beside Send test email, the button that settles it (SPEC §8).
46
+ - A service token now means automation, never Claude: the editor shows its changes as "a service token" (and an import's as "a service token", not "Claude, for you"), the publisher's email about an import a service token commits says a service token made it rather than Claude, and the dashboard says Claude is connected once a post's current revision came through Claude Code or the connector. Service tokens keep working as before. The setup guide's Connect Claude page is now two guides: Allow Claude to connect, which turns on Managed OAuth so a publisher can connect Claude as themselves, and Automate with a service token, the service-token steps. A new Connect Claude page in the publishing docs walks a publisher through adding the connector in Claude Desktop or claude.ai, or signing Claude Code in with the Kestrel CLI, and ends with what to ask Claude and the lines the app holds whatever it's asked, in place of the Working with Claude page. `/llms.txt` and the API reference point an agent to `kestrel login` first (SPEC §11).
47
+ - The operator's Claude context (`template/claude/CLAUDE.md`) has Claude call the API through `kestrel api` instead of curl with a service token's headers, deployed and locally, and run `kestrel login` with the person when they aren't signed in, so Claude's work is recorded as the person's, via Claude Code, rather than a service token's. It also tells Claude never to use a service token in its environment for the person's work, and shows it `kestrel api --list`, `kestrel api help`, and `-f`/`-F`, so it learns a route's body from the instance instead of building JSON by hand. For safety, it also tells Claude never to pass `kestrel api --client`, so a change it makes can't read as made in the editor, where some steps only you may take, and to send a test only to your test recipients or an address you give as yours, asking before any other. It has Claude offer a test once a post is scheduled, since only a test of the scheduled email settles "Not tested yet", and cancel, fix, and schedule again if the test shows a problem. When the instance offers no sign-in for programs, it walks the person through the new Allow Claude to connect guide. Run `npm run check-context` to bring it in.
48
+
49
+ ### Breaking
50
+
51
+ - A Cloudflare Access login that is neither a person (with an email) nor a service token (with its Client ID) is refused with a `401` whose `reason` is `access_unidentified`, where it was admitted as an anonymous service token with full access. The app accepts only the two kinds of Access policy the setup guide describes, an Allow policy on emails and a Service Auth policy on a service token, so another kind added to the application, such as a certificate or device rule, can no longer grant the admin surface to a caller nobody can name. The editor explains the refusal in place of a sign-in, naming those two kinds and offering Sign out, and each refusal logs `auth.unidentified` with the names of the claims it carried (SPEC §11).
52
+ - A commit through the API that would grow a scheduled send's audience is refused `sends_acknowledgement_required` unless it comes through the editor, whoever signed in: a script of yours that commits such an import with your own sign-in now has you finish it in the dashboard, as Claude does (SPEC §7, §11).
53
+
13
54
  ## [1.13.0] - 2026-10-19
14
55
 
15
56
  **Upgrading from 1.12.0.** One database migration, which `npm run sync-migrations` brings in: apply it before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0012_unsubscribe_cause` records why each subscriber left and leaves the suppressions only bounces and blocks. Every unsubscribe before it reads as the reader's own, since the record never said otherwise. Each complaint becomes an unsubscribe by a complaint, and an address it held with no subscriber is recorded as one, unsubscribed and holding only the address. Each erasure's marker becomes a subscriber unsubscribed by an erasure, holding only the address and when, except that someone who signed up again after it stays pending, read as having left by the erasure until they confirm. An erasure that found a bounce or a block already on an address left no trace of itself, so that address keeps its suppression alone. It rebuilds the subscribers and suppressions tables, copying every row once, which counts against the day's database writes on Workers Free like any other write. Deploy straight after applying it: until this release is serving, the previous one fails to record a reader's unsubscribe, a confirmation from someone who once left, an erasure, and a complaint report, which the new tables refuse. SES retries a refused report, so complaints arrive once the deploy lands; a reader whose unsubscribe failed meanwhile has to unsubscribe again. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. Several API changes can break a script of yours, each listed under Breaking; the table gives every renamed route, field, value, and sort key, old to new. A request that still sends an old name is refused with a 400 naming it, never answered as if it had been read: every API route now refuses a body field, a query parameter, or a filter value it doesn't take, so check a script's requests against the API reference before you deploy.
@@ -584,7 +625,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
584
625
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
585
626
  - This changelog and a semantic-versioning release process.
586
627
 
587
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.13.0...HEAD
628
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.15.0...HEAD
629
+ [1.15.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.15.0
630
+ [1.14.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.14.0
588
631
  [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
589
632
  [1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
590
633
  [1.11.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.11.0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kestrel is an open-source newsletter app you run yourself, on Cloudflare and Amazon SES. You own the list, the proof each reader opted in, the record of every send, and a permanent archive of every post.
4
4
 
5
- You write in Markdown, preview the exact email, and schedule it with a window to cancel. Bring your list from another service, and take it out whenever you like. Work in the editor, or ask Claude to do it through the same API.
5
+ You write in Markdown, preview the exact email, and schedule it with a window to cancel. Bring your list from another service, and take it out whenever you like. Work in the editor, or ask Claude to do it through the same API, from Claude Code, Claude Desktop, or claude.ai.
6
6
 
7
7
  See [getkestrel.dev](https://getkestrel.dev) to learn more about Kestrel.
8
8
 
@@ -13,7 +13,7 @@ You can run Kestrel on your own computer to look around, with [Node.js](https://
13
13
  In an empty folder, write a new instance and install it:
14
14
 
15
15
  ```bash
16
- npx @kurtbruns/kestrel init
16
+ npx kestrel init
17
17
  npm install
18
18
  ```
19
19
 
package/bin/kestrel.mjs CHANGED
@@ -6,8 +6,13 @@
6
6
  * bringing a release's migrations and Claude context in, and the demo. This repository's npm scripts run the same commands, so both
7
7
  * use one implementation.
8
8
  *
9
- * Every command acts on the directory it runs in. Seed and reset work only against a local
10
- * dev server, whose dev routes exist nowhere else.
9
+ * Every command acts on the directory it runs in, except the sign-in: login, logout, and api
10
+ * act as the person at this computer, whose sign-ins are theirs rather than a folder's. Those
11
+ * three are Kestrel's client, published on its own as the unscoped `kestrel` (packages/kestrel),
12
+ * which runs from anywhere and hands the instance commands back to this package. This package
13
+ * ships the client's source too, so an instance runs every command from its own install and
14
+ * never needs the client as a second dependency, whose `kestrel` would collide with this one.
15
+ * Seed and reset work only against a local dev server, whose dev routes exist nowhere else.
11
16
  */
12
17
  import { readFileSync } from "node:fs";
13
18
  import { dirname, join } from "node:path";
@@ -29,6 +34,11 @@ Usage: kestrel <command> [options]
29
34
  seed [--size <n>] [port|url] load the demo publication into the local dev server
30
35
  reset [port|url] return the local dev server to a fresh install
31
36
 
37
+ login <url> [--no-browser] sign in to a deployed instance as you, in your browser
38
+ logout [url] sign out of it on this computer
39
+ api <method> <path> [opts] call the API as you, via Claude Code (kestrel api for options)
40
+ api --list | help <m> <path> list the instance's routes, or describe one
41
+
32
42
  help, --help show this
33
43
  version, --version print the installed version`;
34
44
 
@@ -53,6 +63,15 @@ switch (command) {
53
63
  case "reset":
54
64
  await (await import("../cli/reset.mjs")).resetCommand(argv);
55
65
  break;
66
+ case "login":
67
+ await (await import("../packages/kestrel/lib/login.mjs")).loginCommand(argv);
68
+ break;
69
+ case "logout":
70
+ await (await import("../packages/kestrel/lib/login.mjs")).logoutCommand(argv);
71
+ break;
72
+ case "api":
73
+ await (await import("../packages/kestrel/lib/api.mjs")).apiCommand(argv);
74
+ break;
56
75
  case "version":
57
76
  case "--version":
58
77
  case "-v":
package/cli/dev.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * `--port`. This reads PORT and forwards it as `--port`. With no PORT it prefers 8787 and
16
16
  * falls back to a free port, so a second server still starts. The resolved port drives the
17
17
  * origin overrides, so the absolute URLs an email carries point at the server actually bound,
18
- * and is recorded with an ID made up for this run (cli/dev-server.mjs), which the Worker is
18
+ * and is recorded with an ID made up for this run (packages/kestrel/lib/dev-server.mjs), which the Worker is
19
19
  * handed too, so seed and reset find this server and can tell it from another directory's.
20
20
  *
21
21
  * A fresh local database. A new checkout or worktree has an empty `.wrangler/state`, so every
@@ -37,7 +37,11 @@ import { existsSync, readFileSync } from "node:fs";
37
37
  import { createServer } from "node:net";
38
38
  import { join } from "node:path";
39
39
  import { parseEnv } from "node:util";
40
- import { clearDevServer, newDevRunId, writeDevServer } from "./dev-server.mjs";
40
+ import {
41
+ clearDevServer,
42
+ newDevRunId,
43
+ writeDevServer,
44
+ } from "../packages/kestrel/lib/dev-server.mjs";
41
45
  import { startSweepTicker } from "./sweep-ticker.mjs";
42
46
  import { captureWrangler, runWrangler, spawnWrangler } from "./wrangler.mjs";
43
47
 
package/cli/init.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  * `kestrel init [dir]`: write a new instance repository from the scaffold this release ships.
3
3
  *
4
- * Run as `npx @kurtbruns/kestrel init` in an empty repository. The scaffold lives in
4
+ * Run as `npx kestrel init` in an empty repository: the client (packages/kestrel) fetches this
5
+ * package at its own version and hands init to it. The scaffold lives in
5
6
  * `template/` and ships in the package, so a new instance always matches the release that
6
7
  * creates it. It writes the instance's package.json (pinning this exact release), its
7
8
  * wrangler config and one-line Worker entry, `.dev.vars.example`, the Claude context with its
package/cli/reset.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * settings), so this needs the dev server up. It shows the first-run dashboard and setup
7
7
  * checklist an operator meets when they deploy. The target is found as for seed.
8
8
  */
9
- import { callApi, devTarget, fail } from "./dev-api.mjs";
9
+ import { callApi, devTarget, fail } from "../packages/kestrel/lib/dev-api.mjs";
10
10
 
11
11
  /** `kestrel reset [port|url]`. */
12
12
  export async function resetCommand(argv) {
package/cli/seed.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  *
12
12
  * With no target given, it goes only to the dev server `kestrel dev` recorded in this
13
13
  * directory, and only when that server reports the run ID recorded with it, so it never wipes
14
- * another directory's database (cli/dev-api.mjs, cli/dev-server.mjs); with none recorded it
14
+ * another directory's database (packages/kestrel/lib/dev-api.mjs, packages/kestrel/lib/dev-server.mjs); with none recorded it
15
15
  * refuses rather than guess a port. A URL or port argument names a server deliberately, and
16
- * is used as given; `PORT` names nothing here (cli/dev-api.mjs says why). Scale the demo list
16
+ * is used as given; `PORT` names nothing here (packages/kestrel/lib/dev-api.mjs says why). Scale the demo list
17
17
  * with `--size` (100 / 1k / 10k / 100k, an approximate target) and pin the seeded PRNG with
18
18
  * `--seed`. Without `--size`, the curated story-shaped list (~155 subscribers) loads.
19
19
  */
@@ -21,7 +21,7 @@ import { existsSync } from "node:fs";
21
21
  import { readdir, readFile } from "node:fs/promises";
22
22
  import { dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import { devTarget, fail, parseArgs } from "./dev-api.mjs";
24
+ import { devTarget, fail, parseArgs } from "../packages/kestrel/lib/dev-api.mjs";
25
25
 
26
26
  // The demo ships beside the CLI, in this repository and in the package.
27
27
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");