@kurtbruns/kestrel 1.18.0 → 1.19.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.
@@ -0,0 +1,26 @@
1
+ -- 0016_held_receipts: receipts that name a message the record has not yet learned (SPEC §10).
2
+ --
3
+ -- The send loop writes a recipient's provider message id when the provider answers the batch,
4
+ -- and the provider can report a bounce, complaint, or delivery for that message before then. A
5
+ -- receipt that names a message no delivery row holds yet, for an address with a recipient still
6
+ -- in flight, is kept here (`provider_id`, the address, the event and its words, when it arrived,
7
+ -- and `delivery_id`, the in-flight recipient it waits for) and applied to the record by the
8
+ -- sweep once a row holds that id, or once the publisher resolves that recipient as sent; it
9
+ -- lands on no other row. One that has waited a day is discarded. The list
10
+ -- already acted on it when it arrived, so nothing here suppresses or unsubscribes anyone.
11
+ --
12
+ -- What can't be recovered: nothing. The table starts empty.
13
+
14
+ CREATE TABLE held_receipts (
15
+ id INTEGER PRIMARY KEY,
16
+ provider_id TEXT NOT NULL,
17
+ email TEXT NOT NULL,
18
+ event TEXT NOT NULL CHECK (event IN ('delivered', 'bounced', 'complained')),
19
+ detail TEXT,
20
+ hard INTEGER CHECK (hard IN (0, 1)),
21
+ at INTEGER NOT NULL,
22
+ delivery_id INTEGER NOT NULL
23
+ ) STRICT;
24
+
25
+ -- Erasure discards an address's held receipts by address.
26
+ CREATE INDEX idx_held_receipts_email ON held_receipts (email);
@@ -0,0 +1,24 @@
1
+ -- 0017_provider_account_rejected: an account check whose key SES rejects (SPEC §13).
2
+ --
3
+ -- The account check used to file a key SES doesn't know, a secret that doesn't match, and an
4
+ -- account not signed up for SES as `unreachable`, the same as no answer, so a dead key read
5
+ -- as "can't tell" and was found only by sending. It is now its own outcome, `rejected`.
6
+ -- `provider_account.outcome`'s CHECK doesn't admit it and a STRICT table's CHECK can't be
7
+ -- altered, so the table is rebuilt with `rejected` added, its one row carried over.
8
+ --
9
+ -- What can't be recovered: nothing. The row is copied as it was.
10
+
11
+ CREATE TABLE provider_account_next (
12
+ id INTEGER PRIMARY KEY CHECK (id = 1),
13
+ attempted_at INTEGER NOT NULL,
14
+ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'permission', 'rejected', 'unreachable')),
15
+ checked_at INTEGER,
16
+ answer TEXT CHECK (answer IS NULL OR json_valid(answer)),
17
+ CHECK ((checked_at IS NULL) = (answer IS NULL))
18
+ ) STRICT;
19
+
20
+ INSERT INTO provider_account_next (id, attempted_at, outcome, checked_at, answer)
21
+ SELECT id, attempted_at, outcome, checked_at, answer FROM provider_account;
22
+
23
+ DROP TABLE provider_account;
24
+ ALTER TABLE provider_account_next RENAME TO provider_account;
@@ -0,0 +1,13 @@
1
+ -- 0018_notify_addresses: notifications go to one or more addresses (SPEC §8).
2
+ --
3
+ -- Each address is its own delivery: `sent_to` holds the addresses a notification has reached,
4
+ -- as a JSON array, so a try after one address failed goes only to the ones still missing it,
5
+ -- never again to one that got it. Every row starts with none: one still pending reached no
6
+ -- address, since it had only the one, and one that is closed is never tried again.
7
+ --
8
+ -- The saved address itself needs no migration: the settings blob is read through code, which
9
+ -- takes a stored single address as a list of that one (`src/db/settings.ts`).
10
+ --
11
+ -- What can't be recovered: nothing. The column is added to every row.
12
+
13
+ ALTER TABLE notifications ADD COLUMN sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to));
@@ -0,0 +1,67 @@
1
+ -- 0019_sending: whether email is getting out (SPEC §13).
2
+ --
3
+ -- One row, written whenever the provider answers a test, a confirmation, a list send, or a
4
+ -- notification, and by the account check: `unproven` until the provider takes an email,
5
+ -- `working` while the last one it was asked to take it took, `refusing` while it refuses the
6
+ -- account for a reason that won't clear on its own. `since` is when it entered that state.
7
+ -- The last email taken (`last_accepted_at`, and what it was, `last_accepted_by`) is kept
8
+ -- across a refusal, so the editor can say when the provider last took one. A refusal keeps
9
+ -- its cause, what found it, and the provider's words, never a reader's address. Every write
10
+ -- carries when the provider gave its answer (`observed_at` keeps the newest applied), and none
11
+ -- replaces a newer one: a sweep that met a refusal before the operator's fix can't undo the
12
+ -- test that proved it.
13
+ --
14
+ -- `check_contradicted` lists the causes the account check found that an email the provider
15
+ -- took has since contradicted: a reading that stays wrong while email goes out, such as a
16
+ -- sender verified through another account's sending authorization, pauses signups once and
17
+ -- not again. A clean reading empties it.
18
+ --
19
+ -- The row starts as `working` when the app's own records show email went out: a send the
20
+ -- provider took a recipient of, or a subscriber who confirmed through a link this instance
21
+ -- mailed. So an instance that was live before this release keeps its signups open. A test
22
+ -- leaves no record and so counts for nothing here; the next one sets it. A delivered
23
+ -- notification counts for nothing either, since it may have gone through Cloudflare's email
24
+ -- rather than the provider. The provider's own 24-hour count is never read: it is zero on a
25
+ -- quiet day.
26
+ --
27
+ -- What can't be recovered: nothing. The table is new.
28
+
29
+ CREATE TABLE sending (
30
+ id INTEGER PRIMARY KEY CHECK (id = 1),
31
+ state TEXT NOT NULL CHECK (state IN ('unproven', 'working', 'refusing')),
32
+ since INTEGER NOT NULL,
33
+ observed_at INTEGER NOT NULL,
34
+ last_accepted_at INTEGER,
35
+ last_accepted_by TEXT CHECK (last_accepted_by IN ('test', 'confirmation', 'list', 'notification')),
36
+ refusal_cause TEXT
37
+ CHECK (refusal_cause IN ('credentials', 'opt_in', 'configuration', 'sending_pool', 'sender',
38
+ 'mail_from', 'paused', 'set_paused', 'suspended', 'unsupported')),
39
+ refusal_found_by TEXT
40
+ CHECK (refusal_found_by IN ('test', 'confirmation', 'list', 'notification', 'account_check')),
41
+ refusal_error TEXT,
42
+ refusal_at INTEGER,
43
+ check_contradicted TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(check_contradicted)),
44
+ CHECK ((last_accepted_at IS NULL) = (last_accepted_by IS NULL)),
45
+ CHECK ((state = 'refusing') = (refusal_cause IS NOT NULL)),
46
+ CHECK ((refusal_cause IS NULL) = (refusal_found_by IS NULL)),
47
+ CHECK ((refusal_cause IS NULL) = (refusal_at IS NULL)),
48
+ CHECK (state <> 'working' OR last_accepted_at IS NOT NULL)
49
+ ) STRICT;
50
+
51
+ INSERT INTO sending (id, state, since, observed_at, last_accepted_at, last_accepted_by)
52
+ SELECT 1,
53
+ CASE WHEN at IS NULL THEN 'unproven' ELSE 'working' END,
54
+ COALESCE(at, CAST(unixepoch('subsec') * 1000 AS INTEGER)),
55
+ COALESCE(at, 0),
56
+ at,
57
+ CASE WHEN at IS NULL THEN NULL ELSE by END
58
+ FROM (
59
+ SELECT at, by FROM (
60
+ SELECT MAX(updated_at) AS at, 'list' AS by FROM deliveries WHERE status = 'accepted'
61
+ UNION ALL
62
+ SELECT MAX(confirm_sent_at), 'confirmation' FROM subscribers
63
+ WHERE status = 'confirmed' AND confirm_sent_at IS NOT NULL AND import_id IS NULL
64
+ )
65
+ ORDER BY at IS NULL, at DESC
66
+ LIMIT 1
67
+ );
@@ -0,0 +1,40 @@
1
+ -- 0020_notifications_not_sending: tell the publisher when email isn't going out (SPEC §8).
2
+ --
3
+ -- When the provider refuses the account outside a send, or the account check finds a refusal
4
+ -- before anything is sent, nobody is there to see it, so the publisher hears of it through the
5
+ -- notifications they already get, as a `not_sending` notice with no send and no import, at
6
+ -- most one a UTC day: its episode is the day's start, and the partial unique index keeps one
7
+ -- row a day, as the `sandbox` notice does. `notifications.kind`'s CHECK doesn't admit it and a
8
+ -- STRICT table's CHECK can't be altered, so the table is rebuilt with `not_sending` added,
9
+ -- every row and column carried over, `sent_to` and its check included, so a notification that
10
+ -- has reached some of its addresses never mails them again.
11
+ --
12
+ -- What can't be recovered: nothing. Every row is copied as it was.
13
+
14
+ CREATE TABLE notifications_next (
15
+ send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and an account notice
16
+ import_id TEXT REFERENCES imports (id), -- set only for an import notice
17
+ kind TEXT NOT NULL
18
+ CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'test', 'import', 'sandbox', 'not_sending')),
19
+ episode INTEGER NOT NULL DEFAULT 0,
20
+ status TEXT NOT NULL DEFAULT 'pending'
21
+ CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
22
+ attempts INTEGER NOT NULL DEFAULT 0,
23
+ error TEXT,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to)),
27
+ UNIQUE (send_id, kind, episode),
28
+ UNIQUE (import_id),
29
+ CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox', 'not_sending'))),
30
+ CHECK ((import_id IS NULL) = (kind <> 'import'))
31
+ ) STRICT;
32
+
33
+ INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to)
34
+ SELECT send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to FROM notifications;
35
+
36
+ DROP TABLE notifications;
37
+ ALTER TABLE notifications_next RENAME TO notifications;
38
+ CREATE INDEX idx_notifications_status ON notifications (status, created_at);
39
+ CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
40
+ CREATE UNIQUE INDEX idx_notifications_not_sending_day ON notifications (episode) WHERE kind = 'not_sending';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.18.0",
3
+ "version": "1.19.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",
@@ -52,7 +52,7 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
52
52
  --data <json|@file|-> the request body: inline, from a file, or from stdin
53
53
  --type <media type> the body's type (default application/json)
54
54
  --form <name=value> a multipart field, repeatable; name=@file attaches a file
55
- --url <url> the instance (default: KESTREL_URL, else your one sign-in)
55
+ --url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
56
56
  --local this directory's dev server, started by kestrel dev
57
57
  --client <name> your own script's Kestrel-Client name (default ${DEFAULT_CLIENT});
58
58
  never editor, which only the editor names itself
@@ -182,8 +182,15 @@ export function parseApiArgs(argv) {
182
182
  /**
183
183
  * Which instance a call goes to: `--url`, then `KESTREL_URL`, then the one instance this
184
184
  * person is signed in to. Several sign-ins and nothing naming one is a refusal, not a guess.
185
+ * The one sign-in is named on stderr through `say`, since nothing in the command named it and
186
+ * a person in another instance's folder should see the mismatch; stdout stays the answer's.
185
187
  */
186
- export function chooseInstance({ url, env = process.env, signIns }) {
188
+ export function chooseInstance({
189
+ url,
190
+ env = process.env,
191
+ signIns,
192
+ say = (line) => process.stderr.write(`${line}\n`),
193
+ }) {
187
194
  if (url) {
188
195
  return instanceOrigin(url);
189
196
  }
@@ -192,6 +199,9 @@ export function chooseInstance({ url, env = process.env, signIns }) {
192
199
  }
193
200
  const origins = Object.keys(signIns);
194
201
  if (origins.length === 1) {
202
+ say(
203
+ `kestrel api: calling ${origins[0]}, the only instance you're signed in to. Pass --url to call a different one.`,
204
+ );
195
205
  return origins[0];
196
206
  }
197
207
  if (origins.length === 0) {
@@ -280,9 +290,24 @@ export async function accessToken(origin, { force = false, env = process.env } =
280
290
  writeSignInHeld(origin, { ...now, ...fields }, env);
281
291
  return fields.access_token;
282
292
  } catch (err) {
283
- if (err instanceof SignInError && err.oauthError === "invalid_grant") {
293
+ if (!(err instanceof SignInError)) {
294
+ throw err;
295
+ }
296
+ if (err.oauthError === "invalid_grant") {
284
297
  throw new SignInError(ended);
285
298
  }
299
+ // The token endpoint saved at sign-in wasn't found; signing in again discovers where the
300
+ // instance signs in now. It says what happened, not why: a renamed team domain and a
301
+ // moved endpoint both answer 404.
302
+ if (err.status === 404) {
303
+ const team = new URL(now.token_endpoint).host;
304
+ throw new SignInError(
305
+ `Couldn't renew your sign-in to ${origin}: its Access team, ${team}, answered 404 (not found). Run \`npx kestrel login ${origin}\` again.`,
306
+ );
307
+ }
308
+ if (err.status) {
309
+ throw new SignInError(`Access refused the token request for ${origin} (${err.detail}).`);
310
+ }
286
311
  throw err;
287
312
  }
288
313
  }, env);
@@ -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
  }
@@ -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",