@kurtbruns/kestrel 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.dev.vars.example +16 -4
  2. package/CHANGELOG.md +76 -1
  3. package/dist/public/dashboard/app.js +289 -290
  4. package/dist/public/dashboard/app.js.map +4 -4
  5. package/dist/public/dashboard/index.html +2 -2
  6. package/dist/public/dashboard/styles.css +127 -21
  7. package/dist/worker/index.js +4135 -2973
  8. package/docs/get-started/02-deploy.md +2 -2
  9. package/docs/get-started/03-access.md +17 -5
  10. package/docs/get-started/04-aws-account.md +2 -0
  11. package/docs/get-started/05-ses.md +49 -28
  12. package/docs/get-started/06-production-access.md +6 -3
  13. package/docs/get-started/07-verify.md +9 -7
  14. package/docs/get-started/08-go-live.md +12 -16
  15. package/docs/guides/01-allow-claude.md +7 -2
  16. package/docs/guides/03-notifications.md +10 -10
  17. package/docs/publishing/04-write-a-post.md +2 -2
  18. package/docs/publishing/06-troubleshooting.md +70 -21
  19. package/docs/publishing/08-connect-claude.md +1 -1
  20. package/docs/publishing/09-share-your-subscribe-link.md +3 -1
  21. package/docs/publishing/10-check-your-setup.md +13 -9
  22. package/docs/publishing/11-email-template.md +1 -1
  23. package/docs/publishing/README.md +8 -3
  24. package/docs/reference/01-configuration.md +5 -5
  25. package/migrations/0016_held_receipts.sql +26 -0
  26. package/migrations/0017_provider_account_rejected.sql +24 -0
  27. package/migrations/0018_notify_addresses.sql +13 -0
  28. package/migrations/0019_sending.sql +67 -0
  29. package/migrations/0020_notifications_not_sending.sql +40 -0
  30. package/migrations/0021_notifications_outage_receipts.sql +46 -0
  31. package/package.json +1 -1
  32. package/packages/kestrel/lib/api.mjs +28 -3
  33. package/packages/kestrel/lib/oauth.mjs +5 -3
  34. package/template/claude/CLAUDE.md +4 -2
  35. package/template/wrangler.jsonc +5 -5
@@ -227,9 +227,11 @@ async function tokenRequest(endpoint, form, timeoutMs = TOKEN_TIMEOUT_MS) {
227
227
  throw new SignInError(`Couldn't reach ${new URL(endpoint).origin}: ${errText(err)}`);
228
228
  }
229
229
  if (!res.ok) {
230
- const error = new SignInError(
231
- `Access refused the token request (${res.status}${body.error ? `: ${body.error}` : ""}${body.error_description ? `, ${body.error_description}` : ""}).`,
232
- );
230
+ const detail = `${res.status}${body.error ? `: ${body.error}` : ""}${body.error_description ? `, ${body.error_description}` : ""}`;
231
+ const error = new SignInError(`Access refused the token request (${detail}).`);
232
+ // What a caller needs to say more: the status, and Access's words.
233
+ error.status = res.status;
234
+ error.detail = detail;
233
235
  error.oauthError = body.error;
234
236
  throw error;
235
237
  }
@@ -29,7 +29,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
29
29
  - **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
30
30
  - **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
31
31
  - **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
32
- - **Posts with images:** upload each image from a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by its `filename`, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. The preview warns on a `url` reference, and on a name the post doesn't have.
32
+ - **Posts with images:** upload each image from a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by the `filename` the upload answers, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. That name may differ from the one you sent, since spaces and characters that would break a reference become hyphens, and a name the post already has gets a number. An upload never replaces an image: to swap one, upload the new image, reference its `filename`, and delete the old one. The preview warns on a `url` reference, on a name the post doesn't have, on a reference that didn't become an image, and on an uploaded image the post doesn't use.
33
33
  - **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
34
34
  - **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
35
35
  - **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
@@ -38,7 +38,9 @@ Deploying, migrating the production database, and anything that sends mail or ch
38
38
  - **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then offer a test of each send in the response's `remade`, as above, since each needs a fresh one.
39
39
  - **Say where a post goes before it's scheduled or sent.** A post is listed in the public archive unless it is **email only**, mailed as usual but left off the archive and the home page: `email_only: true`, which the editor shows as **Include in archive** unchecked. Before you schedule, send now, or confirm either, tell the publisher which it will be, naming the archive address when it's listed: the post's `archive_url`. Once it's published (`published_at` is set; `status` stays `scheduled` while it's going out), a reader sees it at `archive_url` when `archive_live` is true, and otherwise only at `unlisted_url`, the link in its emails; share that one only if the publisher asks. Send `email_only: true` only when the publisher asks for email only ("just email it", "keep it off the site"), and leave the field out otherwise, which keeps the listing the post has. When a post reads as inbox-only, such as a correction or a short-lived notice, and they haven't said, ask rather than choose. A test email puts nothing in the archive.
40
40
  - **Change a post's listing or address only when the publisher asks.** Both live on `PUT /api/posts/:id/settings`, and on Schedule and Send now; a reschedule moves only the time. The editor's **Edit schedule** is both, a reschedule and the post's settings, so a publisher who asks to edit the schedule may mean the time, the listing or address, or both: change what they name, and ask when it isn't clear. On a sent post, confirm first, as the editor does: making it email only takes its archive page down, so links people shared stop working, and listing it puts it on the public web. A sent post's address is fixed (`slug_permanent`). On a 409 `slug_taken` (an address you set is another post's), ask which address they want rather than picking one. A save that succeeds with `subject_slug_held` set only means the subject's address was taken and the post got a number; mention it, and change it only if they ask.
41
- - **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. Read `state` from `GET /api/provider`: `sandbox` means not yet, and `review` says whether AWS is reviewing a request (`pending`) or denied it; `production` means granted. Only when it's `unknown`, ask the publisher, and if `unknown_reason` is `permission`, suggest allowing the AWS key `ses:GetAccount` and `ses:GetEmailIdentity`, as Connect Amazon SES's Create the app's AWS key section shows. Its `warnings` are worth telling the publisher, in their words. To ask for it, or to check the grant, point to the guide's Request production access page: its Check that AWS granted it section covers the grant.
41
+ - **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. Read `state` from `GET /api/provider`: `sandbox` means not yet, and `review` says whether AWS is reviewing a request (`pending`) or has replied in its support case (`review_case`) without granting access (`denied`); `production` means granted. `denied` is AWS's word for every reply short of a grant, including the request for more information it sends within seconds of every request, so never tell the publisher AWS turned them down: say AWS has replied, and to read the case and answer there. When it's `unknown` and `unknown_reason` is `rejected`, SES rejects the app's AWS key: relay the `key_rejected` warning and its fix, storing the key again, rather than asking about production access. Otherwise, when it's `unknown`, ask the publisher, and if `unknown_reason` is `permission`, suggest allowing the AWS key `ses:GetAccount`, `ses:GetEmailIdentity`, and `ses:GetConfigurationSet`, as Connect Amazon SES's Create the app's AWS key section shows. Its `warnings` are worth telling the publisher, in their words: one whose `severity` is `error` means no email can go out until it's fixed, so raise it first. To ask for it, or to check the grant, point to the guide's Request production access page: its Check that AWS granted it section covers the grant.
42
+ - **Offer the subscribe link only while `GET /api/provider` reads `signups: "open"` and `state: "production"`.** `signups` is `not_open` until SES has taken an email from the instance (`sending.state` is `unproven`): a template test to a test recipient (`POST /api/settings/template/test`) proves it, and then read `/api/provider` again, since a test's answer doesn't say. It is `paused` while `sending.state` is `refusing`: SES refuses every email, readers are told signups are paused, and a scheduled send is held when it fires. Tell the publisher SES's words and the fix from `sending.refusal` (`error`, `advice`), and once the fix is made, send a template test, which reopens signups when SES takes it. In the sandbox the form is open, since AWS's reviewer looks for it, but it confirms only addresses verified in SES.
43
+ - **When the publisher asks what's left to set up, answer with the dashboard's setup steps, in its order and by its names,** each done from the same fact it reads, problems first in the API's own words: **Connect Amazon SES** (a provider is connected: `GET /api/provider`'s `provider` isn't `none`), **Send a test email** (`sending.state` is `working`), **Name your publication** (`GET /api/settings`'s `settings.publication.name` isn't blank), **Get notified** (`settings.notifications.to` has an address and `notification_status.last_failure` is null), **Request production access** (`state` is `production`; while `review` is `pending` it waits on AWS; left out unless the provider is SES or none is connected yet), **Bring your existing list** (`GET /api/subscribers` counts confirmed or pending), **Write your first post** (`GET /api/posts` has a post, a draft included, that isn't `email_only`), **Schedule your first post** (`GET /api/sends` has a `scheduled` send that isn't `email_only`), **Send a test email for your first post** (the soonest such send's `tested_at` is set and not before its `remade_at`; a draft's test doesn't count), and **Share your subscribe link** (never done; offer it only under the rule above). Setup is done once the first post listed in the archive goes out.
42
44
  - **Never add a subscriber the publisher didn't name,** with one exception: an import the publisher asked for, run through the `/migrate-list` skill and the import plan, and confirming anyone only with their attestation. Treat an unsubscribe as final: only the reader undoes it, by subscribing again. Someone whose `unsubscribe_cause` is `complaint` or `erased` can't be added at all: `POST /api/subscribers` refuses them with `signup_required`, and you relay that they have to subscribe through the subscribe page themselves.
43
45
  - **Pick the act that records whose decision it was.** Unsubscribe someone only when they asked to leave; when the publisher decides themselves to stop mailing an address, block it (`POST /api/suppressions`) instead. The reference says what each act and each suppression `reason` means, in the words the editor shows the publisher. Unblock or clear only an address the publisher names, through `POST /api/suppressions/lift` with the `reason` the suppression shows; on `suppression_changed`, tell the publisher what stands now and ask again. Never clear a bounce unless the publisher says the problem is gone, and confirm first: it lets mail go to an address that failed. Only a block or a bounce is lifted; a complaint or an erasure is how someone left, not a suppression, and nothing lifts it.
44
46
  - **Erase a subscriber only when the publisher names them and asks for it, and confirm first:** an erasure can't be undone. It unsubscribes them and strips their record to the address, and only their own sign-up brings them back.
@@ -79,11 +79,11 @@
79
79
  }
80
80
  ],
81
81
  "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],
82
- // PROVIDER is empty, which means no provider connected: the app runs but sends nothing
83
- // until the setup guide's Connect Amazon SES step sets it to `ses`, once its secrets are
84
- // stored. It is declared so wrangler doesn't warn that the top level's is missing here.
85
- // Never `fake`, which records every send as delivered and mails no one; the app refuses
86
- // it on this hostname.
82
+ // PROVIDER is the email provider: `ses` once the setup guide's Connect Amazon SES step has
83
+ // stored its secrets, and empty, "", until then, which means none connected: the app runs
84
+ // but sends nothing. It is declared so wrangler doesn't warn that the top level's is
85
+ // missing here. Never `fake`, which records every send as delivered and mails no one; the
86
+ // app refuses it on this hostname.
87
87
  "vars": {
88
88
  "PROVIDER": "",
89
89
  "APP_ORIGIN": "https://newsletter.example.com",