@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/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,140 @@ 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.14.0] - 2026-10-22
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- `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.
|
|
26
|
+
- `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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- `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.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
- 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.
|
|
35
|
+
- 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.
|
|
36
|
+
- 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).
|
|
37
|
+
- 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).
|
|
38
|
+
- 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).
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
### Breaking
|
|
42
|
+
|
|
43
|
+
- 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).
|
|
44
|
+
- 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).
|
|
45
|
+
|
|
46
|
+
## [1.13.0] - 2026-10-19
|
|
47
|
+
|
|
48
|
+
**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.
|
|
49
|
+
|
|
50
|
+
| Where | Was | Now |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `GET`, `PUT /api/settings` | `settings.testRecipients` | `settings.test_recipients` |
|
|
53
|
+
| | `settings.emailTemplate` | `settings.email_template` |
|
|
54
|
+
| | `settings.confirmationEmail`, `.confirmationEmailDefault` | `settings.confirmation_email`, `.confirmation_email_default` |
|
|
55
|
+
| | `settings.confirmationEmail.buttonLabel` | `settings.confirmation_email.button_label` |
|
|
56
|
+
| | `deployment.fromAddress`, `sendingDomain`, `appOrigin`, `archiveOrigin`, `archiveBasePath`, `mediaPublicBase`, `awsRegion` | `deployment.from_address`, `sending_domain`, `app_origin`, `archive_origin`, `archive_base_path`, `media_public_base`, `aws_region` |
|
|
57
|
+
| | `deployment.accessConfigured`, `authMode`, `notifyChannel`, `notifyFrom`, `minLeadMs`, `notConnectedPreview`, `freePlanLimits` | `deployment.access_configured`, `auth_mode`, `notify_channel`, `notify_from`, `min_lead_ms`, `not_connected_preview`, `free_plan_limits` |
|
|
58
|
+
| | `inUse`, `inUse.identityFields` (value `logoUrl`) | `in_use`, `in_use.identity_fields` (value `logo_url`) |
|
|
59
|
+
| | `notificationStatus.lastSent`, `.lastFailure` | `notification_status.last_sent`, `.last_failure` |
|
|
60
|
+
| | a 400's `field`, such as `confirmationEmail.buttonLabel` | the new path, such as `confirmation_email.button_label` |
|
|
61
|
+
| `GET /api/settings` | `settings.publication.logoUrl` | `settings.publication.logo_url` |
|
|
62
|
+
| `GET /api/version`, `deployment.build` | `buildTime`, `repoUrl`, `commitUrl`, `tagUrl` | `build_time`, `repo_url`, `commit_url`, `tag_url` |
|
|
63
|
+
| `GET /api/whoami` | `principal.tokenId` | `principal.token_id` |
|
|
64
|
+
| `GET /api/dev/token` | `runId` | `run_id` |
|
|
65
|
+
| `GET /api/dev/outbox` | `messages[].providerId`, `.sentAt`, `notifications[].sentAt` | `provider_id`, `sent_at` |
|
|
66
|
+
| A send's `phase` | `backing-off`, `needs-attention` | `backing_off`, `needs_attention` |
|
|
67
|
+
| `GET /api/posts` `sort` | `updated`, `title`, `scheduled` | `updated_at`, `subject`, `send.fire_at` |
|
|
68
|
+
| `GET /api/sends` `sort` | `fire`, `recipients` | `fire_at`, `audience.count` |
|
|
69
|
+
| `GET /api/subscribers` `sort` | `joined`, `confirmed` | `created_at`, `confirmed_at` |
|
|
70
|
+
| `GET /api/sends/:id/deliveries` `sort` | `updated` | `event_at` |
|
|
71
|
+
| A post, every route | a list row's `fire_at`, `active_send_id`, `active_send_status` | `send`, a reference: `{ id, status, fire_at, rev }` (the whole send is `GET /api/sends/:id`) |
|
|
72
|
+
| | one post's `scheduled`, `sending`, `sent` | `send`, with its `status` |
|
|
73
|
+
| | one post's `author`, beside it | `post.author` |
|
|
74
|
+
| A subscriber, every route | a list row's `suppressed`, `suppression_reason`, `suppression_detail` | `suppression: { reason, detail, created_at }` or null |
|
|
75
|
+
| | `suppressed`, beside one subscriber | `subscriber.suppression` |
|
|
76
|
+
| The re-make: `remake_required`, `remake_too_close`, `in_use.sends`, `remade` | `{ id, post_id, subject, fire_at, remade_at }` | the send's full view, which carries those fields |
|
|
77
|
+
| A send's `counts` | `in_flight` | `dispatched` |
|
|
78
|
+
| `GET /api/sends/:id` `outcomes` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
|
|
79
|
+
| `GET /api/sends/:id/deliveries` `view` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
|
|
80
|
+
| Lifting a suppression | `DELETE /api/suppressions/:email`, answering `{ email, cleared }` | `POST /api/suppressions/lift` with `{ email, reason }`, answering `{ email, lifted }` (see Breaking) |
|
|
81
|
+
| Describing a post's rendered email | `POST /api/posts/:id/preview` | `GET /api/posts/:id/preview.json`, the same answer (see Breaking) |
|
|
82
|
+
| An import plan | `daily_writes_warning` | `workers_plan_advice` (a different meaning: see Breaking) |
|
|
83
|
+
| An import's outcomes: a plan's and a commit's `counts`, `rows`, and `differed`, the format's `outcomes`, a stored record | `create_confirmed`, `confirm_pending`, `mark_unsubscribed`, `create_unsubscribed`, `blocked_by_suppression`, `suppression_added` | `added_confirmed`, `confirmed_pending`, `unsubscribed`, `added_unsubscribed`, `kept_suppressed`, `suppressed` |
|
|
84
|
+
| A suppression's `reason`, everywhere | `complaint`, `import_complaint`, `erased` | none: the subscriber's `unsubscribe_cause`, `complaint` or `erased` (see Breaking) |
|
|
85
|
+
| `POST /api/subscribers` | `acknowledge_erasure`, and 409 `erasure_acknowledgement_required` with `erased_at` | none: an address that left by a complaint or an erasure is refused with 409 `signup_required`, carrying the `subscriber` |
|
|
86
|
+
| `POST /api/subscribers/:id/erase` | `{ erased: { email, deliveries, suppression } }`, and 404 on a repeat | `{ subscriber, deliveries }`, a repeat answering the same subscriber with `deliveries: 0` |
|
|
87
|
+
| `POST /api/suppressions/lift` | 409 `erasure_marker` | none: an erasure isn't a suppression |
|
|
88
|
+
| The import format, and the export | `kestrel-subscribers/1`, with `suppression` `complaint` or `erased` | `kestrel-subscribers/2`, with `unsubscribe_cause`; a version 1 file still reads |
|
|
89
|
+
| `GET /api/suppressions` | every suppression, newest first, cut off silently past 1,000 | 50 a page with `page`, as the other lists (see Breaking) |
|
|
90
|
+
| Every API route | a body field, query parameter, or filter value it doesn't take, ignored (`GET /api/posts?status=bogus` listed every post) | a 400 naming it in `field` (see Breaking) |
|
|
91
|
+
|
|
92
|
+
### Breaking
|
|
93
|
+
|
|
94
|
+
- One naming rule on the wire: every JSON field and enum value is snake_case, and a list's sort key is the name of the field it sorts by. The settings (its preferences, its deploy reflection, `in_use`, and `notification_status`), the build info, `whoami`'s token id, the dev routes' `run_id` and the dev outbox's `provider_id` and `sent_at`, the `backing_off` and `needs_attention` phases, and the sort keys of posts, sends, subscribers, and deliveries take their new names; the stored settings keep theirs, so no migration runs. The Upgrading paragraph lists every rename.
|
|
95
|
+
- An import's outcomes are renamed into one form, the past tense of what the import does to the address, and none says "blocked" any more, which in the editor and the API means only your own block: `create_confirmed` is now `added_confirmed`, `confirm_pending` is `confirmed_pending`, `mark_unsubscribed` is `unsubscribed`, `create_unsubscribed` is `added_unsubscribed`, `blocked_by_suppression` is `kept_suppressed`, and `suppression_added` (as an outcome, not the row's field of that name) is `suppressed`. They change in an import plan's and a commit's `counts`, `rows`, and `differed`, the format route's `outcomes`, and an import's record, where one committed before this release reads under the new names too. A script that matched an old name matches the new one (SPEC §7).
|
|
96
|
+
- A send's per-recipient buckets have one vocabulary, the statuses and events its delivery rows store: `counts.in_flight` is now `counts.dispatched`, and `outcomes.in_flight` and the deliveries' `view=in_flight`, which meant pending and dispatched together, are split into `pending` and `dispatched`, so one name never counts two things. A delivery row's `status`, `event`, and `bounce_kind` are now typed to their values (SPEC §12).
|
|
97
|
+
- A post reads the same on every route and refers to its send by one fixed reference, `send`: `{ id, status, fire_at, rev }` of its active send, else the one that sent it, or null, with the whole send one read away at `GET /api/sends/:id` and `rev` saying whether a copy of it is current. It carries `actions` too, what the server would take for it now (`edit`, `delete`, and, while an email provider is connected, `schedule` and `send_now`). They replace a list row's `fire_at`, `active_send_id`, and `active_send_status` and one post's `scheduled`, `sending`, and `sent`, and its `author` moves inside the post. The editor offers a post's controls from its `actions`. The posts list's `fire_at` sort is now `send.fire_at`.
|
|
98
|
+
- A subscriber carries its suppression inside it on every route, `suppression: { reason, detail, created_at }` or null, in place of `suppressed`, `suppression_reason`, and `suppression_detail`, which sat in list rows only, and `suppressed` beside one subscriber's answer.
|
|
99
|
+
- A refusal about a resource's state carries it as it stands: `post_not_draft` and `slug_permanent` the `post`, `settings_changed` and `conflict` the `settings`, an erasure's `run_in_progress` and an Add's `signup_required` the `subscriber`, `suppression_changed` the `suppression`, and every send refusal the `cursor` beside the `send`. The re-make's `sends`, `in_use.sends`, and `remade` are full send views, in place of a smaller reference.
|
|
100
|
+
- `DELETE /api/suppressions/:email` is replaced by `POST /api/suppressions/lift`, which takes `{ email, reason }` in the body, so a subscriber's address is no longer in a logged path, and lifts only the reason named: a different one standing now is refused with `suppression_changed`, carrying it. It lifts a block (`manual`) or a bounce (`bounce`, `import_bounce`), the only reasons a suppression has now; any other is a 400. It answers `{ email, lifted }` in place of `{ email, cleared }`. The old route answers 404.
|
|
101
|
+
- Each mark on an address has one owner (SPEC §7). The consent state is the person's and now keeps why they left, as a subscriber's `unsubscribe_cause`: `reader` (their link or one-click), `publisher` (Unsubscribe on request), `complaint`, `erased`, or `import` (a list that says they left, not why). When someone who left leaves again, the stronger cause stands: an erasure, then a complaint, then the first. The suppression is only the sender's side, so its `reason` is `bounce`, `import_bounce`, or `manual`, and `complaint`, `import_complaint`, and `erased` are gone from it: a complaint and an erasure are unsubscribes with that cause. A script that read a complaint or an erasure from a suppression reads the subscriber's `unsubscribe_cause`.
|
|
102
|
+
- Adding an address that left by a complaint or an erasure (`POST /api/subscribers`) is refused with 409 `signup_required`, carrying the `subscriber`, and sends nothing: only the person's own sign-up through the subscribe form brings them back. `acknowledge_erasure` and `erasure_acknowledgement_required` are gone, and so is `erasure_marker` on a lift. Adding anyone else who left sends a confirmation, as before (SPEC §7).
|
|
103
|
+
- Erasing a subscriber (`POST /api/subscribers/:id/erase`) answers `{ subscriber, deliveries }`, the subscriber as the erasure left them, in place of `{ erased: { email, deliveries, suppression } }`. A repeat answers the same subscriber with `deliveries: 0`, where it answered 404. An erased subscriber's `unsub_token`, `origin`, and `opt_in` are null, so a script that reads them handles null (SPEC §7).
|
|
104
|
+
- The import format is `kestrel-subscribers/2`, which the export now writes: an `unsubscribed` row says why the person left in `unsubscribe_cause` (`reader`, `publisher`, `complaint`, or `erased`), a row that doesn't say is recorded as having left elsewhere, and `suppression` takes only `bounce` or `manual`. A version 1 file still reads, its `complaint` or `erased` suppression read as an unsubscribed row with that cause, whatever its status, and a row naming no version may use either version's values. An erasure is recorded with the address alone. A row that erases someone already on the list does what **Erase** does, past sends' records included, and plans as a new outcome, `erased`, so a script that sums outcomes adds it; the commit answers 409 `run_in_progress` while a send is handing such a person to the provider. A cause on a row that isn't `unsubscribed` is refused with `invalid_field` (SPEC §7).
|
|
105
|
+
- An import plan's `daily_writes_warning` is now `workers_plan_advice`, and means something else: on the Workers Free plan's limits, the advice to move to Workers Paid when the import would leave more than 1,000 confirmed subscribers, rather than a count of the day's database writes. A script that read the old field reads the new one (SPEC §7).
|
|
106
|
+
- `GET /api/suppressions` pages like the other lists: it answers 50 suppressions at a time with `page`, takes `limit`, `offset`, `sort` (`created_at`, `email`, or `reason`), and `dir`, and filters by `reason` and an email contains-search (`search`). It answered up to 1,000 at once and silently left out any past them, which a list imported from another provider can reach on its first day. A script that read the whole list in one call follows `page` (SPEC §7).
|
|
107
|
+
- Every API route takes only what the API reference lists for it: a body field it doesn't name, a query parameter it doesn't list, or a value outside the ones a filter takes is refused with a 400 naming it in `field`, where most routes ignored it and answered as if it had been read. `GET /api/posts?status=bogus` listed every post and `GET /api/subscribers?suppressed=maybe` the whole list; both are now refused, and so is a parameter given twice, a `seed` or `size` the dev seed can't read, and a `kind` other than `human` or `service` on the dev token. A reader page, such as an archive link carrying a tracking parameter, still takes what it is sent.
|
|
108
|
+
- Describing a post's rendered email (where its preview is, its `subject`, `warnings`, and `frozen`) writes nothing, so it is a read: `POST /api/posts/:id/preview` is now `GET /api/posts/:id/preview.json`, with the same answer, and a `POST` to the old path is a 404. The rendered page itself stays at `GET /api/posts/:id/preview`.
|
|
109
|
+
|
|
110
|
+
### Added
|
|
111
|
+
|
|
112
|
+
- Each subscriber has a page of their own, opened from their row, their address, or **View subscriber** in the row's menu, in place of the View subscriber dialog and the list's How they joined column, which moves to it. It answers "did they get my post?": a line under the address says whether they receive your posts and, if not, why, then every send their address was in, newest first, with what the provider did and reported, each linking to its record. It also shows how they joined, with the import that brought them (its file, when and by whom, and the attestation's version), their suppression with the provider's or your words and since when, why they left (for a complaint, which send it was about), and the row menu's acts. The API adds `GET /api/subscribers/:id/deliveries`, a page at a time, `GET /api/imports/:id`, and `receives_posts` on every subscriber (SPEC §7, §8).
|
|
113
|
+
- Each instance serves its API as an OpenAPI 3.1 document at `GET /api/openapi.json`, behind Access like the reference, so a client generator or an API client such as Postman can import it. It is generated from the same registration as the reference, so it lists the same routes, objects, and refusals, and `/llms.txt` and the reference's index name it.
|
|
114
|
+
- Each instance serves a public `/llms.txt`, so an agent given only its address learns what it is and where to start: the publication's name, its archive and subscribe page, and the API reference and docs behind Access, with a link to Connect Claude to the API for a service token. It is generated from the routes the instance registers, so it always matches the release (SPEC §11).
|
|
115
|
+
- A scheduled post that no test has been sent for says so on the editor's scheduled banner until the first test lands, and the API carries it as an `untested` condition (`info`) on the send and in the feed, so Claude can tell whether the email was tested (SPEC §8).
|
|
116
|
+
- **Clear** in a subscriber's ⋯ menu lifts a bounce, after a dialog that says the address failed and to clear it only when you know its cause is gone, which before only the API could do (SPEC §7). The list's legend, under What you can do, and the API reference say when to use it and when to use **Unblock**, in the same words.
|
|
117
|
+
- The operator's Claude context lifts a suppression through `POST /api/suppressions/lift`, naming the reason it shows, and lifts only a block or a bounce. `npm run check-context` offers the change to an existing instance.
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- A reader who marks a post as spam is unsubscribed by it, as their own link would, instead of suppressed while still confirmed, and comes back only by subscribing again themselves. Every complaint does, whichever email it is about, a confirmation email included; one that reaches no subscriber changes nothing. Each is logged as `subscriber.complained` (SPEC §7, §12).
|
|
122
|
+
- **Erase** unsubscribes the person and keeps their subscriber, stripped to the address and when, in place of deleting it and leaving a do-not-contact marker. It works on someone who already unsubscribed, discards the unsubscribe link their old emails carry, and drops the words of a bounce or block kept on the address. A bounce or block no longer has to stand in for the erasure, so lifting one never frees an erased address (SPEC §7).
|
|
123
|
+
- On the Subscribers screen the **complaint** and **erased** flags are gone, since a complaint and an erasure are now ways of leaving: why an unsubscribed subscriber left shows on their page, not beside their status. **Unsubscribe** is offered on pending subscribers too, **Erase** isn't offered on someone already erased, and **Add subscriber** shows the server's refusal for someone who left by a complaint or an erasure, with no way past it, in place of asking whether an erased person asked to join again.
|
|
124
|
+
- The operator's Claude context: Claude relays `signup_required` as the person having to subscribe themselves, never lifts a complaint or an erasure, describes Erase as an unsubscribe that strips the record, and the migrate-list skill writes a source's complaints and erasures as an unsubscribed row's `unsubscribe_cause`, never as a suppression, and puts an import's `erased` rows to the publisher by name before committing, since they can't be undone. `npm run check-context` offers the change to an existing instance.
|
|
125
|
+
- The operator's Claude context names the default test recipients by their new field, `test_recipients`, so Claude reads them from the settings it now gets.
|
|
126
|
+
- The operator's Claude context tells Claude to act on a post from its `actions`, as on a send, so it never offers to schedule or send a post while no provider is connected, and that every refusal about a post, a send, a subscriber, or the settings carries it as it stands.
|
|
127
|
+
- The API reference no longer cites SPEC sections or invariant numbers ("(SPEC §6)", "(I3)"), which its readers, Claude included, couldn't follow; each rule was already said in its own words beside it.
|
|
128
|
+
- A deployment with no email provider connected now refuses scheduling, Send now, a test, a test notification through the provider, and a confirmation with 503 instead of 409, and the public subscribe route answers 503 too, as JSON and as its "not taking subscribers yet" page. The code (`provider_not_connected`) and message are unchanged, so a client that matches the code sees no difference, and one that matched the 409 status now sees 503: the status now says the deployment needs fixing, as the other refusals fixed outside the API do (SPEC §9).
|
|
129
|
+
- The API reference is reshaped so a person and Claude find a route by what it does and learn it without guessing. Each route leads with a name ("Schedule a post"), its method and path beneath, and lists its body fields with their types, what it returns, and the refusals it gives of its own, each with what to do next. Short topics come first (authentication, requests and what each status range means, errors with a summary of every status, the error body, and the refusals any route can give, paging, and acting safely; how to follow changes to sends is told on Follow changes to sends, the one route it concerns), and each resource opens with the object its routes return, described once field by field, so the send's conditions and full example no longer repeat across routes. In the editor the page reads as a reference, laid out as Stripe's and Mux's are: headings step down from tier to resource to route, every route and object is laid out in full with its examples and curl, each on a card of its own, beside its prose on a wide screen, the rail lists the routes of the resource you're in, the address follows you as you scroll, each section is a heading over unboxed rows, refusals and statuses a ledger by status, and the refusal table folds under Errors. The page moves to `#/api-reference`, an old `#/reference` link opening the same place there. Each route has its own address, named for what it does (`#/api-reference/cancel-send`), as do the topics, resources, and objects the contents and a route's returns link to, so those links open in a new tab and a jump moves focus to where it lands; a route's path has a copy beside it, and every heading is a link to its own address; the filter also finds a route by a refusal code it gives, the object it returns, or a URL pasted with its IDs filled in, and a topic by its title; and descriptions render their inline code and links. For Claude, `GET /api/reference.md` is now an index (the topics, and each route on one line), and `GET /api/reference/<resource>.md` holds one resource in full; `GET /api/reference` carries the new `title`, `body`, `returns`, and `errors` on each route, and `concepts`, `statuses`, `refusals`, and `objects`.
|
|
130
|
+
- The operator's Claude context reads the reference's index at the start of a conversation and a resource's reference before calling its routes, a quarter as much up front as the whole JSON reference it read before; it also says Resolve's choice is the publisher's, and `/migrate-list` reads `GET /api/reference/imports.md`. It now says a route's refusals each say what to do next, and the index's Errors holds only the refusals any route can give, and it points to the reference for how a save names its revision and what an act's answer carries rather than restating them, so the two can't drift apart. `npm run check-context` offers the change to an existing instance.
|
|
131
|
+
- Workers Free's line is drawn once and said the same everywhere: it suits a list of up to about 1,000 subscribers, and past about 5,000 one send can use up the day's database writes and stop the newsletter until midnight UTC. On Free's limits, the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import that would take it past; `GET /api/subscribers` carries the words as `workers_plan_advice`. A send's daily-writes warning now goes by its audience alone, over 5,000, and no longer counts the day's imports, which also no longer change a scheduled send. The docs move "about 5,000" to the pair (SPEC §7, §8).
|
|
132
|
+
- The operator's Claude context: the migrate-list skill relays an import plan's `workers_plan_advice`, recommending Workers Paid, in place of the old daily-writes warning, which had it offer to wait until midnight UTC between pieces. `npm run check-context` offers the change to an existing instance.
|
|
133
|
+
- The API reference says more of what a route answers and how to act on it. A send's conditions and its halt are objects of their own, linked from the send view's fields: the condition object lists each kind in a table (its severity, fields, meaning, and remedy, `missed` and `stuck` included), and the halt object each cause with one line, its fix left to the `advice` its condition carries; `GET /api/imports/format` describes its answer, `limits` included; the logo upload states its 512 KB cap; and the deliveries list, the deletes, `whoami`, the dev routes, the webhook, and media say what they answer. The index lists each resource once, an off-tier route marked on its line, and images and the SES webhook join Archive and Sends.
|
|
134
|
+
- The API reference and the operator's Claude context name the editor's words where the API's differ, so Claude and the publisher talk about the same thing: `email_only` is the editor's **Include in archive**, unchecked; **Edit schedule** is a reschedule and the post's settings together, so a publisher asking to edit the schedule may mean either or both; and Resolve's `unsent` and `accepted` are **Assume not sent** and **Assume sent**, which Claude now uses when it asks the publisher to choose. `npm run check-context` offers the context's change to an existing instance.
|
|
135
|
+
- Three API reference routes are retitled to match the editor: `POST /api/suppressions/lift` is "Unblock or clear an address", since it is both the editor's **Unblock** and its **Clear**, and its description covers both; `PUT /api/posts/:id/settings` is "Change a post's settings"; and `GET /` is "Show the home page", the name the editor and the docs already give the page at `/`, which SPEC, DESIGN, the reference, and the setup guide now use too.
|
|
136
|
+
- The operator's Claude context: the migrate-list skill relays each import outcome by the name the plan gives it, now that the names read as they stand, and says a `kept_…` outcome means the row's status isn't imported because the list's own record or a suppression here wins. `npm run check-context` offers the change to an existing instance.
|
|
137
|
+
- A send that needs your decision on deliveries no one can confirm now says so on its page ("Some deliveries can’t be confirmed either way, and wait on your decision.") instead of calling them stuck, which the API uses for something else. An import plan says an address is already suppressed here, rather than blocked, since in the editor blocked means only your own block.
|
|
138
|
+
- The API reference opens its Requests topic with the rules a client builds on: the reference is the API's whole contract, so what a route doesn't list isn't part of it; a read is a `GET`; an address goes in the body or a read's query, never a path; and the API changes only in a release, each break listed under Breaking, since the version number doesn't say.
|
|
139
|
+
|
|
140
|
+
### Fixed
|
|
141
|
+
|
|
142
|
+
- A send stuck awaiting Resolve that still carried an earlier refusal of the account is no longer emailed to the publisher as the provider refusing the account. Nothing retries such a send, so the editor and the API showed only that it waits for Resolve, and the email now agrees (SPEC §8).
|
|
143
|
+
- A send's cached answer (`If-None-Match` on `GET /api/sends/:id`) no longer keeps a client on a stale time to finish: the tag now changes when the dispatch rate or the expected finish does.
|
|
144
|
+
- The API reference documents every field a refusal carries, so Claude can act on one without guessing: `cursor_ahead` lists `field` and the `cursor` where the database stands, `slug_taken` and `slug_permanent` their `field`, Schedule and Send now list `slug_permanent`, an import's `unrecognized_format` its `column` and `allowed`, and the Errors topic says once that every refusal to fix the request names the `field` at fault. A refused image or logo upload and a template with no unsubscribe link now name their `field` too. The reference's objects match the wire: a subscriber's `confirm_token` may be null, the settings' `deployment` lists `access_configured`, `aws_region`, `notify_channel`, `notify_from`, and `simulation`, an import plan's `rows` lists `row`, `email`, and `suppression_added`, and List a send's deliveries lists the `page` it answers.
|
|
145
|
+
- The API reference's description of a send's `phase` said `needs_attention` covers a missed or stuck send, which stay `due` and `progressing` (their conditions say so), and that `backing_off` is only an outage or rate limit, when it also covers recipients waiting on a retry. It now also says which phases each `status` has, and that `status` is the one to filter and route on.
|
|
146
|
+
|
|
13
147
|
## [1.12.0] - 2026-10-16
|
|
14
148
|
|
|
15
149
|
**Upgrading from 1.11.0.** Five 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). `0007_sends_progress_at` adds a column recording when each send last handed anyone off, and writes no rows. `0008_notifications_sandbox` rebuilds the small notifications table to admit a new kind, copying its rows once. `0009_post_email_only` adds each post's listing, reading every existing post as listed in the archive, so the archive shows what it did before; it adds a column with a default and rewrites no rows. `0010_unlisted_links` gives every post its unlisted link, and records that every send made before it links its archive address, so those addresses keep answering whatever the post's listing; a send scheduled between the migration and the deploy counts as one made before. It writes each post row once. `0011_sends_render_warnings` adds a column keeping what the render found wrong with each scheduled email, reading every earlier send as having found nothing; it adds a column with a default and rewrites no rows. 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. An instance verified with an earlier release has its test post in the archive: open it under **Sent** and select **Remove from archive…**. Two API changes can break a script of yours, listed under Breaking: one that reads a send's halt `cause` reads new values, and one that sets a slug by hand handles a 409 `slug_taken` by choosing another, where it used to get a numbered slug.
|
|
@@ -483,7 +617,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
|
|
|
483
617
|
- A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
|
|
484
618
|
- This changelog and a semantic-versioning release process.
|
|
485
619
|
|
|
486
|
-
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.
|
|
620
|
+
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.14.0...HEAD
|
|
621
|
+
[1.14.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.14.0
|
|
622
|
+
[1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
|
|
487
623
|
[1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
|
|
488
624
|
[1.11.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.11.0
|
|
489
625
|
[1.10.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.10.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
|
|
package/bin/kestrel.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
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
|
|
10
|
-
*
|
|
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.
|
|
11
|
+
* Seed and reset work only against a local dev server, whose dev routes exist nowhere else.
|
|
11
12
|
*/
|
|
12
13
|
import { readFileSync } from "node:fs";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
@@ -29,6 +30,11 @@ Usage: kestrel <command> [options]
|
|
|
29
30
|
seed [--size <n>] [port|url] load the demo publication into the local dev server
|
|
30
31
|
reset [port|url] return the local dev server to a fresh install
|
|
31
32
|
|
|
33
|
+
login <url> [--no-browser] sign in to a deployed instance as you, in your browser
|
|
34
|
+
logout [url] sign out of it on this computer
|
|
35
|
+
api <method> <path> [opts] call the API as you, via Claude Code (kestrel api for options)
|
|
36
|
+
api --list | help <m> <path> list the instance's routes, or describe one
|
|
37
|
+
|
|
32
38
|
help, --help show this
|
|
33
39
|
version, --version print the installed version`;
|
|
34
40
|
|
|
@@ -53,6 +59,15 @@ switch (command) {
|
|
|
53
59
|
case "reset":
|
|
54
60
|
await (await import("../cli/reset.mjs")).resetCommand(argv);
|
|
55
61
|
break;
|
|
62
|
+
case "login":
|
|
63
|
+
await (await import("../cli/login.mjs")).loginCommand(argv);
|
|
64
|
+
break;
|
|
65
|
+
case "logout":
|
|
66
|
+
await (await import("../cli/login.mjs")).logoutCommand(argv);
|
|
67
|
+
break;
|
|
68
|
+
case "api":
|
|
69
|
+
await (await import("../cli/api.mjs")).apiCommand(argv);
|
|
70
|
+
break;
|
|
56
71
|
case "version":
|
|
57
72
|
case "--version":
|
|
58
73
|
case "-v":
|