@kurtbruns/kestrel 1.14.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,14 @@ 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
+
13
21
  ## [1.14.0] - 2026-10-22
14
22
 
15
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.
@@ -617,7 +625,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
617
625
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
618
626
  - This changelog and a semantic-versioning release process.
619
627
 
620
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.14.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
621
630
  [1.14.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.14.0
622
631
  [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
623
632
  [1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
package/README.md CHANGED
@@ -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
@@ -7,7 +7,11 @@
7
7
  * use one implementation.
8
8
  *
9
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.
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.
11
15
  * Seed and reset work only against a local dev server, whose dev routes exist nowhere else.
12
16
  */
13
17
  import { readFileSync } from "node:fs";
@@ -60,13 +64,13 @@ switch (command) {
60
64
  await (await import("../cli/reset.mjs")).resetCommand(argv);
61
65
  break;
62
66
  case "login":
63
- await (await import("../cli/login.mjs")).loginCommand(argv);
67
+ await (await import("../packages/kestrel/lib/login.mjs")).loginCommand(argv);
64
68
  break;
65
69
  case "logout":
66
- await (await import("../cli/login.mjs")).logoutCommand(argv);
70
+ await (await import("../packages/kestrel/lib/login.mjs")).logoutCommand(argv);
67
71
  break;
68
72
  case "api":
69
- await (await import("../cli/api.mjs")).apiCommand(argv);
73
+ await (await import("../packages/kestrel/lib/api.mjs")).apiCommand(argv);
70
74
  break;
71
75
  case "version":
72
76
  case "--version":
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)), "..");
@@ -1,4 +1,4 @@
1
- // @kurtbruns/kestrel 1.14.0: the Worker, built by scripts/build-package.mjs.
1
+ // @kurtbruns/kestrel 1.15.0: the Worker, built by scripts/build-package.mjs.
2
2
 
3
3
  // shared/archive_url.ts
4
4
  function archivePostUrl(origin, basePath, slug) {
@@ -934,10 +934,10 @@ var SUBSCRIBER_ACTIONS = {
934
934
 
935
935
  // src/generated/version.ts
936
936
  var BUILD_INFO = {
937
- version: "1.14.0",
938
- sha: "bcfc453",
939
- tag: "v1.14.0",
940
- buildTime: "2026-10-22T21:27:52.391Z",
937
+ version: "1.15.0",
938
+ sha: "57b846b",
939
+ tag: "v1.15.0",
940
+ buildTime: "2026-10-23T14:03:58.360Z",
941
941
  repoUrl: "https://github.com/kurtbruns/kestrel"
942
942
  };
943
943
 
@@ -10477,7 +10477,7 @@ async function reset(c) {
10477
10477
  import { Lexer } from "marked";
10478
10478
 
10479
10479
  // docs/get-started/02-deploy.md
10480
- var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. It fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
10480
+ var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. It fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
10481
10481
 
10482
10482
  // docs/get-started/03-access.md
10483
10483
  var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, the in-app docs, and the MCP server Claude Desktop and claude.ai connect to (`/api/mcp`) |\n\n Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Select **Create**.\n\nCloudflare\'s own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\nThe app accepts only two kinds of policy on this application. One is an Allow policy on people\'s emails, like this one. The other is a Service Auth policy on a service token, from [Automate with a service token](../guides/02-automate.md). A login through any other kind, such as a certificate, a device check, or an identity provider that gives no email, is refused with a `401`, and the editor says so.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com","client":null},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. The app needs both values, so while only one is stored, `/health` answers `500` naming the other, and the public pages say the newsletter is temporarily unavailable. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address. If the editor says Access let you in but didn\'t say who you are, your login carried no email: sign out, and sign in through the `Publishers` policy.\n\nNext, [connect Amazon SES](04-ses.md).\n';
@@ -10525,7 +10525,7 @@ var write_a_post_default = "# Write and send a post\n\nWrite in Markdown, schedu
10525
10525
  var troubleshooting_default = "# Troubleshooting\n\nWhen a send needs you, its page and the dashboard's health block say so in a red or amber line. Red needs you to act, and amber is worth a look. Find the line's words below for what it means and what to do.\n\n**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](06-troubleshooting.md#notifications), below, lists what you're told.\n\n## A send is waiting or held\n\nA problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. \"No one has been marked unsent because of this\" means this problem: readers whose own email failed earlier stay unsent.\n\n- **\"The provider is refusing the account\".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or your instance, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.\n- **\"The provider is refusing this send\".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs your instance asks AWS Support why, quoting SES's words.\n- **\"No email provider is connected\".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.\n- **\"The provider is unavailable\".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.\n- **\"The provider keeps refusing for its sending rate\".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs your instance sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.\n- **\"Nothing has gone out for \u2026\".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes. The send carries on from where it stopped once it runs again, mailing no one twice.\n- **\"The fire time passed \u2026 and the send has not started\".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes.\n- **\"The provider never answered for \u2026\".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve\u2026** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.\n\n## Before a send\n\n- **A test says \"The provider refused it\".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).\n- **A test says \"The provider didn't answer, so the test may have arrived\".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.\n- **\"The template or publication details changed this email's look after your last test\".** Send a new test of the post, since your last one no longer shows what goes out.\n- **The daily database writes warning.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.\n- **\"Workers Free suits a list of up to about 1,000 subscribers\".** Your list is past what the free plan suits. Upgrade to Workers Paid ([As your list grows](../guides/09-as-your-list-grows.md)). If the account is on Workers Paid already, set `SUBREQUEST_BUDGET` so Kestrel knows, and the notice goes.\n\n## Reading a send's record\n\nA send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:\n\n| Count | What it means |\n| --- | --- |\n| **Delivered** | Your provider confirmed the email reached the reader's mail server. |\n| **Bounced** | The receiving server turned the email away. |\n| **Complained** | The reader marked the email as spam. |\n| **Unsent** | Kestrel couldn't hand the email to your provider. |\n\nDelivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.\n\n**\"No delivery receipt has arrived for any of \u2026\".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.\n\n**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.\n\n### What SES refuses, and the fix\n\nWhen SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs your instance, in the SES console or its settings.\n\n| SES says | What it means | The fix |\n| --- | --- | --- |\n| Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |\n| Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region your instance sends from. |\n| Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |\n| Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |\n| Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |\n| Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |\n| Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |\n| An access key, signature, or permission error | Your instance's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |\n| A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |\n| Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |\n| Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |\n| The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |\n| The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |\n\nA server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve\u2026**, as for \"The provider never answered for \u2026\".\n\n## A reader says they never got a post\n\nUnder **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined, any bounce or block on the address with the provider's words or yours, and why they left, if they did. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## Bounces and complaints\n\nKestrel acts on these for you, so a bad address can't keep hurting your sending reputation:\n\n- **A hard bounce** means the address can't receive mail. Kestrel never mails it again.\n- **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.\n- **A complaint** means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.\n\nUnder **Subscribers**, a bounced address keeps its status and carries a label saying why, and its page, opened from its row, gives your email provider's words for the bounce and when it happened. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.\n\n### Lifting a suppression\n\nLift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. In a subscriber's \u22EF menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it: they come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.\n\n## An import is refused\n\nKestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.\n\n- **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.\n- **A column or value the format doesn't take,** named with its row and column. Fix it in the file, or move a column of your old service's into `evidence`.\n- **Too many rows** for one import. Split the file.\n- **Too large.** An import takes up to 10 MB.\n\n[The file format](01-your-list.md#the-file-format) describes what each column takes.\n\n## Claude can't connect\n\n[Connect Claude](08-connect-claude.md) covers connecting it. When that fails, find what Claude or the dashboard says:\n\n- **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.\n- `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Managed OAuth is off. Turn it on, as the same section describes.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If `kestrel login` names `access_unidentified` after the `401`, or `kestrel api` prints `reason: access_unidentified`, it's the next problem: both then say that Access sent neither an email address nor a service token. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- **\"Access let you in, but didn't say who you are\".** The editor shows this, and the API refuses with a `401` whose `reason` is `access_unidentified`, when your login carried neither an email nor a service token. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the `Publishers` policy. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds.\n- **Claude's changes read \"via the API\".** A request made with your sign-in named no client, such as one from Claude calling the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api`, once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading \"a service token\" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.\n- A script gets a `401` whose `reason` is `access_unidentified`. Access let the script in through a policy Kestrel doesn't accept. It needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails were accepted, unsent, and skipped\n- when your provider refuses your account or a send, or no provider is connected\n- when the SES sandbox refuses a signup's confirmation, at most once a day\n- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time\n- when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)\n\nEach links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.\n\nBy default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.\n";
10526
10526
 
10527
10527
  // docs/publishing/08-connect-claude.md
10528
- var connect_claude_default = "# Connect Claude\n\nConnecting Claude to Kestrel lets you publish by asking Claude. It drafts, tests, and schedules posts as you, and its changes show as yours, via Claude.\n\n**Your Kestrel instance has to allow this first,** as [Allow Claude to connect](../guides/01-allow-claude.md) describes.\n\nHow you connect depends on where you talk to Claude:\n\n- **In Claude Desktop or claude.ai,** add Kestrel as a connector.\n- **In Claude Code,** add Kestrel as a connector, or sign in with the Kestrel CLI to use the API directly.\n\n## Claude Desktop or claude.ai\n\n1. In Claude, go to **Customize \u2192 Connectors**, select **Add**, then **Add custom connector**.\n1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.\n1. Under **OAuth client**, choose **Register automatically**.\n1. Select **Add**, then sign in the way you do for the dashboard, and approve the sign-in.\n\nOn a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings \u2192 Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.\n\nTo check it works, ask Claude what your publication is called.\n\n## Claude Code\n\nClaude Code can use the same connector as Claude Desktop and claude.ai. To reach the whole API instead, images included, sign Claude Code in with the Kestrel CLI:\n\n1. Run this, with your publication's address:\n\n ```bash\n npx -p @kurtbruns/kestrel kestrel login https://newsletter.example.com\n ```\n\n1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.\n\nThe sign-in stays on your computer. Opened in your instance's repository, Claude Code then calls Kestrel's API as you through `kestrel api`, the way `gh` calls GitHub, following your instance's instructions for Claude. Its changes show as yours, via Claude Code.\n\n## How long a sign-in lasts\n\nThe sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.\n\nTo disconnect, remove the connector under **Customize \u2192 Connectors**. To sign out of the Kestrel CLI, run `npx -p @kurtbruns/kestrel kestrel logout`.\n\n## Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague, and ask it what it can do: it knows its tools, and reads these pages from Kestrel. For example:\n\n- \"Proofread my draft titled 'First frost' and schedule it to go out Friday at 7am.\"\n- \"Add jack@example.com to the list.\"\n- \"Import the subscribers in this CSV from my old newsletter.\"\n\nWhatever Claude is asked, Kestrel holds these for any client:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **What goes out is exactly the copy that was scheduled.** A scheduled post is locked.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send,** in the dashboard.\n\nChanges Claude makes show in the dashboard as yours, such as \"Scheduled by you@example.com via Claude\".\n";
10528
+ var connect_claude_default = "# Connect Claude\n\nConnecting Claude to Kestrel lets you publish by asking Claude. It drafts, tests, and schedules posts as you, and its changes show as yours, via Claude.\n\n**Your Kestrel instance has to allow this first,** as [Allow Claude to connect](../guides/01-allow-claude.md) describes.\n\nHow you connect depends on where you talk to Claude:\n\n- **In Claude Desktop or claude.ai,** add Kestrel as a connector.\n- **In Claude Code,** add Kestrel as a connector, or sign in with the Kestrel CLI to use the API directly.\n\n## Claude Desktop or claude.ai\n\n1. In Claude, go to **Customize \u2192 Connectors**, select **Add**, then **Add custom connector**.\n1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.\n1. Under **OAuth client**, choose **Register automatically**.\n1. Select **Add**, then sign in the way you do for the dashboard, and approve the sign-in.\n\nOn a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings \u2192 Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.\n\nTo check it works, ask Claude what your publication is called.\n\n## Claude Code\n\nClaude Code can use the same connector as Claude Desktop and claude.ai. To reach the whole API instead, images included, sign Claude Code in with the Kestrel CLI:\n\n1. Run this, with your publication's address:\n\n ```bash\n npx kestrel login https://newsletter.example.com\n ```\n\n1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.\n\nThe sign-in stays on your computer. Opened in your instance's repository, Claude Code then calls Kestrel's API as you through `kestrel api`, the way `gh` calls GitHub, following your instance's instructions for Claude. Its changes show as yours, via Claude Code.\n\n## How long a sign-in lasts\n\nThe sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.\n\nTo disconnect, remove the connector under **Customize \u2192 Connectors**. To sign out of the Kestrel CLI, run `npx kestrel logout`.\n\n## Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague, and ask it what it can do: it knows its tools, and reads these pages from Kestrel. For example:\n\n- \"Proofread my draft titled 'First frost' and schedule it to go out Friday at 7am.\"\n- \"Add jack@example.com to the list.\"\n- \"Import the subscribers in this CSV from my old newsletter.\"\n\nWhatever Claude is asked, Kestrel holds these for any client:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **What goes out is exactly the copy that was scheduled.** A scheduled post is locked.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send,** in the dashboard.\n\nChanges Claude makes show in the dashboard as yours, such as \"Scheduled by you@example.com via Claude\".\n";
10529
10529
 
10530
10530
  // docs/publishing/09-share-your-subscribe-link.md
10531
10531
  var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication's subscribe page is at `/subscribe` on your publication's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings \u2192 Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.\n\n**Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).\n\n## How a reader joins\n\nA reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.\n\nEvery address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:\n\n- **Already subscribed, bounced, or blocked:** no email goes out.\n- **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.\n- **Unsubscribed earlier, for any reason, a complaint or an erasure included:** they get a confirmation, and confirming puts them back on your list. This form is the only way back for someone who complained or was erased.\n\n## The confirmation email\n\nChange its subject, message, button label, and reassurance line under **Settings \u2192 Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.\n\n## Put the form on your own site\n\n**Settings \u2192 Ways to subscribe \u2192 Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.\n\n**Settings \u2192 Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).\n";
@@ -11918,7 +11918,7 @@ function llmsText({
11918
11918
  "",
11919
11919
  "## Behind Cloudflare Access",
11920
11920
  "",
11921
- "Sign in as the person you work for, through Cloudflare Access in their browser. An MCP client connects to `/api/mcp` and signs in with OAuth. In this instance's repository, `npx -p @kurtbruns/kestrel kestrel login <this instance's address>` signs Claude Code in instead, and `npx -p @kurtbruns/kestrel kestrel api <method> <path>` then calls these routes as them. A script that runs with no one present sends a Cloudflare Access service token with every request instead, as the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers. Without a credential, the request is refused.",
11921
+ "Sign in as the person you work for, through Cloudflare Access in their browser. An MCP client connects to `/api/mcp` and signs in with OAuth. `npx kestrel login <this instance's address>` signs Claude Code in instead, and `npx kestrel api <method> <path>` then calls these routes as them. A script that runs with no one present sends a Cloudflare Access service token with every request instead, as the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers. Without a credential, the request is refused.",
11922
11922
  "",
11923
11923
  ...tier("admin"),
11924
11924
  "",
@@ -37,7 +37,7 @@ Your instance's repository holds its configuration, its database migrations, and
37
37
  ```bash
38
38
  git clone https://github.com/YOUR_USERNAME/newsletter.git
39
39
  cd newsletter
40
- npx @kurtbruns/kestrel init
40
+ npx kestrel init
41
41
  ```
42
42
 
43
43
  This writes the instance's files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release's database migrations, and a starting point for Claude Code in `.claude/`. The instance's `README.md` lists its commands.
@@ -27,7 +27,7 @@ Claude Code can use the same connector as Claude Desktop and claude.ai. To reach
27
27
  1. Run this, with your publication's address:
28
28
 
29
29
  ```bash
30
- npx -p @kurtbruns/kestrel kestrel login https://newsletter.example.com
30
+ npx kestrel login https://newsletter.example.com
31
31
  ```
32
32
 
33
33
  1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.
@@ -38,7 +38,7 @@ The sign-in stays on your computer. Opened in your instance's repository, Claude
38
38
 
39
39
  The sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.
40
40
 
41
- To disconnect, remove the connector under **Customize → Connectors**. To sign out of the Kestrel CLI, run `npx -p @kurtbruns/kestrel kestrel logout`.
41
+ To disconnect, remove the connector under **Customize → Connectors**. To sign out of the Kestrel CLI, run `npx kestrel logout`.
42
42
 
43
43
  ## Working with Claude
44
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.14.0",
3
+ "version": "1.15.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",
@@ -30,6 +30,7 @@
30
30
  "dist/public/",
31
31
  "bin/",
32
32
  "cli/",
33
+ "packages/kestrel/lib/",
33
34
  "template/",
34
35
  "demo/",
35
36
  "migrations/",
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * What the API is comes from the instance itself: `--list` and `help` read its OpenAPI
19
19
  * document through the same sign-in, as `-f` and `-F` do to build a body and refuse a field
20
- * the route doesn't take (cli/openapi.mjs). It is read fresh each time it is needed, not
20
+ * the route doesn't take (packages/kestrel/lib/openapi.mjs). It is read fresh each time it is needed, not
21
21
  * cached, so it can never describe a release the instance no longer runs.
22
22
  */
23
23
  import { readFileSync } from "node:fs";
@@ -8,9 +8,13 @@
8
8
  * is actually serving. The token comes from the dev-only `/api/dev/token` route, which
9
9
  * exists only on a local dev server (fake transport, no Access, a loopback origin).
10
10
  *
11
+ * It lives in the client package, beside `kestrel api`, because `kestrel api --local` finds and
12
+ * signs in to the dev server the same way; the instance package's seed and reset import it
13
+ * from the copy of the client it ships, so the two never disagree about the record.
14
+ *
11
15
  * Seed and reset wipe the database of the server they reach, so a target they find for
12
16
  * themselves must prove it is this directory's: the port `kestrel dev` recorded here, and a
13
- * server there that reports the run ID recorded with it (cli/dev-server.mjs). A target the
17
+ * server there that reports the run ID recorded with it (packages/kestrel/lib/dev-server.mjs). A target the
14
18
  * caller names, a port or URL argument, is their choice and is used as given. `PORT` names
15
19
  * nothing here: a shell can carry it without the caller meaning anything by it, and a
16
20
  * target that wipes a database has to be named on purpose.
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The record of this directory's dev server, shared between `kestrel dev` (cli/dev.mjs) and
2
+ * The record of this directory's dev server, shared between `kestrel dev` (cli/dev.mjs in @kurtbruns/kestrel) and
3
3
  * the seed and reset commands: the port it bound and the ID of that run.
4
4
  *
5
5
  * Why this exists: seed and reset wipe the database of whichever server they reach, and more
@@ -12,7 +12,7 @@
12
12
  * stopped can name a port another directory's server now holds. So `kestrel dev` makes up an ID
13
13
  * for each run, records it here with the port, and hands it to the Worker (the `DEV_RUN_ID`
14
14
  * var), which reports it beside the dev token. Seed and reset with no target given go only to
15
- * the recorded port, and only when the server there reports this record's ID (cli/dev-api.mjs).
15
+ * the recorded port, and only when the server there reports this record's ID (packages/kestrel/lib/dev-api.mjs).
16
16
  *
17
17
  * Scope: the file lives under `.wrangler/`, which is gitignored and per-directory (each worktree
18
18
  * has its own `.wrangler/state`), so the record is scoped to this directory and never committed.
@@ -0,0 +1,118 @@
1
+ /*
2
+ * How the client hands an instance command to the instance package, so `kestrel <command>`
3
+ * stays one entry point although the commands live in two packages.
4
+ *
5
+ * `init`, `dev`, `seed`, `reset`, `sync-migrations`, and `check-context` act on an instance's
6
+ * repository and belong to the release it runs, so they live in `@kurtbruns/kestrel`, which
7
+ * each instance installs and pins. The client finds that install from the directory it runs
8
+ * in, the way Node resolves an import from there, and runs its `kestrel` command with the same
9
+ * arguments. `init` alone runs where nothing is installed yet: it fetches the instance package
10
+ * at the client's own version through npx, so `npx kestrel@X.Y.Z init` writes an X.Y.Z
11
+ * instance. Plain Node, no dependency, as the whole client is.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { readFileSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { constants } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+
19
+ /** The package that holds the instance commands. */
20
+ export const INSTANCE_PACKAGE = "@kurtbruns/kestrel";
21
+
22
+ /** The commands that act on an instance's repository, and so run from the instance package. */
23
+ export const INSTANCE_COMMANDS = [
24
+ "init",
25
+ "dev",
26
+ "sync-migrations",
27
+ "check-context",
28
+ "seed",
29
+ "reset",
30
+ ];
31
+
32
+ /**
33
+ * The instance package as installed for `dir` (or a directory above it): `{ root, bin }`, with
34
+ * `bin` the path of its `kestrel` script, or undefined when the install has none; or undefined
35
+ * when no install is found.
36
+ */
37
+ export function findInstance(dir = process.cwd()) {
38
+ let manifest;
39
+ try {
40
+ manifest = createRequire(join(dir, "package.json")).resolve(`${INSTANCE_PACKAGE}/package.json`);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ const root = dirname(manifest);
45
+ const { bin } = JSON.parse(readFileSync(manifest, "utf8"));
46
+ const script = typeof bin === "string" ? bin : bin?.kestrel;
47
+ return { root, bin: script ? join(root, script) : undefined };
48
+ }
49
+
50
+ /** One argument for cmd.exe, which splits on spaces and reads its own metacharacters. */
51
+ function quoteForCmd(arg) {
52
+ return /^[\w@=./:\\-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
53
+ }
54
+
55
+ /**
56
+ * How to run instance `command`, as `{ cmd, args, shell }`, or `{ refuse }` with words for the
57
+ * person when there is no install to run it from. `version` is the client's, which `init`
58
+ * fetches when nothing is installed. `KESTREL_INSTANCE_PACKAGE` names another package spec to
59
+ * fetch, such as a packed tarball, which the package check uses before a release exists.
60
+ */
61
+ export function handoff(
62
+ command,
63
+ argv,
64
+ { dir = process.cwd(), version, env = process.env, platform = process.platform } = {},
65
+ ) {
66
+ const found = findInstance(dir);
67
+ if (found?.bin) {
68
+ return { cmd: process.execPath, args: [found.bin, command, ...argv], shell: false };
69
+ }
70
+ if (found) {
71
+ return {
72
+ refuse: `the ${INSTANCE_PACKAGE} installed at ${found.root} has no kestrel command, so it can't run \`kestrel ${command}\`. Reinstall it with \`npm install\`.`,
73
+ };
74
+ }
75
+ if (command === "init") {
76
+ const spec = env.KESTREL_INSTANCE_PACKAGE || `${INSTANCE_PACKAGE}@${version}`;
77
+ const args = ["--yes", `--package=${spec}`, "--", "kestrel", command, ...argv];
78
+ // npx is a .cmd script on Windows, which Node spawns only through a shell, and a shell
79
+ // splits what it is given: so it gets one command line, each argument quoted for cmd.exe.
80
+ if (platform === "win32") {
81
+ return { cmd: ["npx.cmd", ...args].map(quoteForCmd).join(" "), args: [], shell: true };
82
+ }
83
+ return { cmd: "npx", args, shell: false };
84
+ }
85
+ return {
86
+ refuse: `\`kestrel ${command}\` runs in an instance's repository, with ${INSTANCE_PACKAGE} installed, and none is installed here. Run it in your instance's repository after \`npm install\`, or write a new instance with \`npx kestrel init\`.`,
87
+ };
88
+ }
89
+
90
+ // The signals passed on to the child when they are sent to this process alone.
91
+ const FORWARDED = ["SIGTERM", "SIGHUP", "SIGQUIT"];
92
+
93
+ /**
94
+ * Run instance `command` through the instance package, with the terminal attached, and exit
95
+ * as it does. A Ctrl-C reaches the child from the terminal itself, so this process only waits
96
+ * for it to finish (`kestrel dev` stops its server on the way out); another signal sent to
97
+ * this process alone is passed on. A child ended by a signal exits 128 plus its number, as a
98
+ * shell reports it.
99
+ */
100
+ export function runInstanceCommand(command, argv, version) {
101
+ const plan = handoff(command, argv, { version });
102
+ if (plan.refuse) {
103
+ console.error(`kestrel: ${plan.refuse}`);
104
+ process.exit(1);
105
+ }
106
+ const child = spawn(plan.cmd, plan.args, { stdio: "inherit", shell: plan.shell });
107
+ process.on("SIGINT", () => {});
108
+ for (const signal of FORWARDED) {
109
+ process.on(signal, () => child.kill(signal));
110
+ }
111
+ child.on("error", (err) => {
112
+ console.error(`kestrel: could not run ${INSTANCE_PACKAGE}: ${err.message}`);
113
+ process.exit(1);
114
+ });
115
+ child.on("exit", (code, signal) => {
116
+ process.exit(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 0));
117
+ });
118
+ }
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * The person approves the sign-in in their browser, through the same Access policy the
6
6
  * editor's sign-in passes, so Claude Code working through `kestrel api` afterwards is that
7
- * person, not a token of its own (SPEC §11). The OAuth steps are cli/oauth.mjs, and where
8
- * the result is kept is cli/credentials.mjs.
7
+ * person, not a token of its own (SPEC §11). The OAuth steps are packages/kestrel/lib/oauth.mjs, and where
8
+ * the result is kept is packages/kestrel/lib/credentials.mjs.
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
@@ -11,7 +11,7 @@
11
11
  * Access has nothing to say about it. Then: dynamic client registration (RFC 7591) for a
12
12
  * public client on a loopback redirect (RFC 8252), the authorization code flow with PKCE
13
13
  * (S256), and the protected resource named in every token request (RFC 8707). Plain Node,
14
- * no dependency, as everything in cli/ is.
14
+ * no dependency, as the whole client is.
15
15
  */
16
16
  import { createHash, randomBytes } from "node:crypto";
17
17
  import { createServer } from "node:http";
@@ -22,8 +22,8 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
22
22
  | `npm run seed`, `npm run reset` | Loads the demo publication into the local app, or empties it |
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
- | `npx -p @kurtbruns/kestrel kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
26
- | `npx -p @kurtbruns/kestrel 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 |
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 |
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
 
File without changes
File without changes