@kurtbruns/kestrel 1.21.0 → 1.22.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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Kestrel</title>
7
7
  <link rel="icon" href="/favicon.svg">
8
- <link rel="stylesheet" href="./styles.css?v=aacdaf66">
8
+ <link rel="stylesheet" href="./styles.css?v=102ce128">
9
9
  <script>
10
10
  // Sidebar mode guard — set the width-based data-nav on <html> before first paint so the
11
11
  // sidebar renders in its final width with no flash. The collapse toggle's override is
@@ -116,6 +116,6 @@
116
116
  </section>
117
117
  <div id="toasts" role="status" aria-live="polite"></div>
118
118
 
119
- <script src="./app.js?v=e3dbd207"></script>
119
+ <script src="./app.js?v=867db6b1"></script>
120
120
  </body>
121
121
  </html>
@@ -4336,24 +4336,33 @@ button.api-filter-clear svg {
4336
4336
  outline: none;
4337
4337
  }
4338
4338
  /* A heading is a link to its own address, in the heading's own ink, with a link icon in the
4339
- margin to its left while it is hovered or focused, as GitHub's headings show theirs. */
4340
- a.api-self {
4339
+ margin to its left while it is hovered or focused, as GitHub's headings show theirs. The
4340
+ API reference's headings and the Settings sections' titles wear the same. The icon's padding
4341
+ runs its box up to the text, so moving between the two never leaves the link: no gap for the
4342
+ cursor to change in or the icon to vanish in. */
4343
+ a.api-self,
4344
+ a.set-sec-link {
4341
4345
  position: relative;
4342
4346
  color: inherit;
4343
4347
  text-decoration: none;
4344
4348
  }
4345
- a.api-self svg {
4349
+ a.api-self svg,
4350
+ a.set-sec-link svg {
4346
4351
  position: absolute;
4347
4352
  left: -20px;
4348
4353
  top: 50%;
4349
- width: 14px;
4354
+ box-sizing: border-box;
4355
+ width: 20px;
4350
4356
  height: 14px;
4357
+ padding-right: 6px;
4351
4358
  transform: translateY(-50%);
4352
- color: var(--muted);
4359
+ fill: var(--muted);
4353
4360
  opacity: 0;
4354
4361
  }
4355
4362
  a.api-self:hover svg,
4356
- a.api-self:focus-visible svg {
4363
+ a.api-self:focus-visible svg,
4364
+ a.set-sec-link:hover svg,
4365
+ a.set-sec-link:focus-visible svg {
4357
4366
  opacity: 1;
4358
4367
  }
4359
4368
  .api-op-call {
@@ -5135,6 +5144,13 @@ button.copied svg {
5135
5144
  .set-sec-head {
5136
5145
  scroll-margin-top: 60px;
5137
5146
  }
5147
+ /* The page's gutter here is narrower than the link icon's place in the margin. */
5148
+ a.set-sec-link svg {
5149
+ left: -15px;
5150
+ width: 15px;
5151
+ height: 12px;
5152
+ padding-right: 3px;
5153
+ }
5138
5154
  }
5139
5155
  .set-sec-title {
5140
5156
  margin: 0;
@@ -6253,9 +6269,8 @@ button.set-embed-copy svg {
6253
6269
  here — only safe container defaults. */
6254
6270
  /* The sample email renders in an isolated <iframe> — so the template's own <style>
6255
6271
  block applies exactly as it would in a mail client and can't leak into the
6256
- dashboard, and the reading measure is constrained to the email column
6257
- (~640px, matching the view-in-browser render). Its height is sized to content
6258
- from script. */
6272
+ dashboard, and the document inside draws the email's page and column. Its height
6273
+ is sized to content from script. */
6259
6274
  .set-email-frame {
6260
6275
  display: block;
6261
6276
  width: 100%;
@@ -6265,19 +6280,42 @@ button.set-embed-copy svg {
6265
6280
  }
6266
6281
 
6267
6282
  /* --- Template page: sample-email header actions, dropdown, variables --- */
6283
+ /* The bar stays one row at every width: the titles wrap beside the controls, and the toggle
6284
+ gives way in the two steps below, so narrowing the pane changes the layout only there. */
6285
+ .set-tpl-sample {
6286
+ container-type: inline-size;
6287
+ }
6288
+ /* A tablet's pane: the toggle keeps its use, since Phone still looks different, but only its
6289
+ icons, so the bar stays one row. */
6290
+ @container (max-width: 640px) {
6291
+ .set-tpl-sample .wtog-lbl {
6292
+ display: none;
6293
+ }
6294
+ }
6295
+ /* A pane this narrow is close to a phone's width already, so Desktop and Phone would look
6296
+ alike, and the toggle goes. */
6297
+ @container (max-width: 480px) {
6298
+ .set-tpl-sample .set-wtog {
6299
+ display: none;
6300
+ }
6301
+ .set-email-stage .set-email-frame.phone {
6302
+ max-width: 100%;
6303
+ }
6304
+ }
6268
6305
  .set-preview-titles {
6269
6306
  display: flex;
6307
+ flex: 1 1 0;
6270
6308
  flex-direction: column;
6271
6309
  gap: 1px;
6272
6310
  min-width: 0;
6273
6311
  }
6274
6312
  .set-preview-actions {
6275
6313
  display: flex;
6314
+ flex: 0 0 auto;
6276
6315
  align-items: center;
6277
6316
  gap: 8px;
6278
- flex-wrap: wrap;
6279
6317
  }
6280
- /* Preview width toggle (640 / 375) — proof both inbox measures; 640 is the default. */
6318
+ /* Preview width toggle — proof both inbox measures; Desktop is the default. */
6281
6319
  .set-wtog {
6282
6320
  display: inline-flex;
6283
6321
  gap: 2px;
@@ -6379,25 +6417,27 @@ button.set-embed-copy svg {
6379
6417
  .set-req-pill.bad .dot {
6380
6418
  background: var(--danger-fg);
6381
6419
  }
6382
- /* The sample email sits on a sunken stage so the (width-capped) email card reads as a
6383
- message on a surface; the width toggle sets its max-width. */
6420
+ /* The sample email fills its pane, as a post's Preview does, since an email arrives as a page
6421
+ and not as a card on a surface. So the stage shows only beside the width toggle's Phone, where
6422
+ its color says the frame ends there. --chip, since --sunken is all but the email's own page
6423
+ in dark mode. */
6384
6424
  .set-email-stage {
6385
6425
  display: flex;
6386
6426
  justify-content: center;
6387
- padding: 16px;
6388
- background: var(--sunken);
6427
+ background: var(--chip);
6389
6428
  }
6390
6429
  .set-email-stage .set-email-frame {
6391
6430
  width: 100%;
6392
- max-width: 640px;
6431
+ max-width: 100%;
6393
6432
  /* Floor the auto-sized frame at the default template's height, so emptying or
6394
6433
  shortening the template doesn't collapse the preview and yank the page scroll
6395
- (the frame can still grow taller for a longer template or the 375 measure). */
6396
- min-height: 392px;
6397
- border-radius: 6px;
6398
- box-shadow: 0 1px 4px rgba(15, 18, 25, 0.1);
6434
+ (the frame can still grow taller for a longer template or the Phone measure). */
6435
+ min-height: 452px;
6399
6436
  transition: max-width 0.16s ease;
6400
6437
  }
6438
+ .set-email-stage .set-email-frame.phone {
6439
+ max-width: 375px;
6440
+ }
6401
6441
  /* "Start from example": the trigger of a shared menu (openMenu). */
6402
6442
  .set-menu-btn {
6403
6443
  display: inline-flex;
@@ -6609,12 +6649,14 @@ button.set-embed-copy svg {
6609
6649
  .set-rp .set-wtog {
6610
6650
  flex: 0 0 auto;
6611
6651
  }
6612
- .set-rp .wtog-btn {
6652
+ .set-rp .wtog-btn,
6653
+ .set-tpl-sample .wtog-btn {
6613
6654
  display: inline-flex;
6614
6655
  align-items: center;
6615
6656
  gap: 5px;
6616
6657
  }
6617
- .set-rp .wtog-btn svg {
6658
+ .set-rp .wtog-btn svg,
6659
+ .set-tpl-sample .wtog-btn svg {
6618
6660
  width: 13px;
6619
6661
  height: 13px;
6620
6662
  }
@@ -1,4 +1,4 @@
1
- // @kurtbruns/kestrel 1.21.0: the Worker, built by scripts/build-package.mjs.
1
+ // @kurtbruns/kestrel 1.22.0: the Worker, built by scripts/build-package.mjs.
2
2
 
3
3
  // shared/archive_url.ts
4
4
  function archivePostUrl(origin, basePath, slug) {
@@ -993,10 +993,10 @@ var SUBSCRIBER_ACTIONS = {
993
993
 
994
994
  // src/generated/version.ts
995
995
  var BUILD_INFO = {
996
- version: "1.21.0",
997
- sha: "fd376da",
998
- tag: "v1.21.0",
999
- buildTime: "2026-11-06T22:01:54.830Z",
996
+ version: "1.22.0",
997
+ sha: "eaa6641",
998
+ tag: "v1.22.0",
999
+ buildTime: "2026-11-07T06:30:09.521Z",
1000
1000
  repoUrl: "https://github.com/kurtbruns/kestrel"
1001
1001
  };
1002
1002
 
@@ -5103,6 +5103,11 @@ var ERROR_BODY = [
5103
5103
  type: '"access_not_configured" | "access_unidentified"',
5104
5104
  description: "On `unauthorized` only, when signing in again can't help: `access_not_configured`, the deployment has no Cloudflare Access yet, so no request can sign in; `access_unidentified`, Access let the sign-in through but sent neither a person's email nor a service token, so its Access policies need changing. Any other 401 carries none."
5105
5105
  },
5106
+ {
5107
+ name: "dev_token_available",
5108
+ type: "boolean",
5109
+ description: "On `unauthorized` only, and only on a local dev server, where it is `true`: a client may mint a dev token and try again. Absent everywhere else, where no dev route exists."
5110
+ },
5106
5111
  {
5107
5112
  name: "retry_after",
5108
5113
  type: "epoch ms",
@@ -6471,7 +6476,11 @@ var requireAuth = async (c) => {
6471
6476
  const principal = await authenticate(c.req, c.env, c.config);
6472
6477
  if (!principal || principal === "access_unidentified") {
6473
6478
  const reason = principal ?? lockedReason(c.config);
6474
- const body = reason ? { error: "unauthorized", message: UNAUTHORIZED_MESSAGE[reason], reason } : { error: "unauthorized", message: SIGN_IN_MESSAGE };
6479
+ const body = reason ? { error: "unauthorized", message: UNAUTHORIZED_MESSAGE[reason], reason } : {
6480
+ error: "unauthorized",
6481
+ message: SIGN_IN_MESSAGE,
6482
+ ...c.config.devMode ? { dev_token_available: true } : {}
6483
+ };
6475
6484
  return json(body, 401, { "WWW-Authenticate": 'Bearer realm="kestrel"' });
6476
6485
  }
6477
6486
  const client = c.req.headers.get(CLIENT_HEADER);
@@ -7085,6 +7094,9 @@ function unavailablePage() {
7085
7094
  res.headers.set("retry-after", "300");
7086
7095
  return res;
7087
7096
  }
7097
+ function asksForHtml(req) {
7098
+ return (req.headers.get("accept") ?? "").includes("text/html");
7099
+ }
7088
7100
  function serverErrorPage() {
7089
7101
  return messagePage({
7090
7102
  identity: { name: "" },
@@ -7304,17 +7316,22 @@ function guard(access, res) {
7304
7316
  }
7305
7317
  var Router = class {
7306
7318
  /** `readerPage` says whether a path is one a reader opens in a browser, where an error
7307
- * nothing handled is answered with a page rather than JSON. */
7308
- constructor(readerPage2 = () => false) {
7319
+ * nothing handled is answered with a page rather than JSON; `notFoundPage` is what a browser
7320
+ * gets there for an address that names no page. */
7321
+ constructor(readerPage2 = () => false, notFoundPage) {
7309
7322
  this.readerPage = readerPage2;
7323
+ this.notFoundPage = notFoundPage;
7310
7324
  }
7311
7325
  readerPage;
7326
+ notFoundPage;
7312
7327
  compiled = [];
7313
7328
  /** Register a route from its manifest entry; the `access` tier decides the gate. */
7314
7329
  register(def) {
7315
7330
  const strict = def.access !== "webhook" && !this.readerPage(def.path) ? [refuseUndeclared(def)] : [];
7316
7331
  const middleware = [...gateFor(def), ...strict, ...def.middleware ?? []];
7317
- this.compiled.push({ def, pattern: new URLPattern({ pathname: def.path }), middleware });
7332
+ const slash = def.access === "public" && this.readerPage(def.path) && !def.path.endsWith("/");
7333
+ const pathname = slash ? `${def.path}{/}?` : def.path;
7334
+ this.compiled.push({ def, pattern: new URLPattern({ pathname }), middleware });
7318
7335
  return this;
7319
7336
  }
7320
7337
  /**
@@ -7351,10 +7368,10 @@ var Router = class {
7351
7368
  continue;
7352
7369
  }
7353
7370
  const { params, error: badParam } = decodePathParams(match.pathname.groups);
7354
- const c = { req, env, ctx, url, params, config };
7371
+ const c2 = { req, env, ctx, url, params, config };
7355
7372
  try {
7356
7373
  for (const mw of route2.middleware) {
7357
- const short = await mw(c);
7374
+ const short = await mw(c2);
7358
7375
  if (short) {
7359
7376
  return guard(route2.def.access, short);
7360
7377
  }
@@ -7362,16 +7379,35 @@ var Router = class {
7362
7379
  if (badParam) {
7363
7380
  throw badParam;
7364
7381
  }
7365
- return guard(route2.def.access, await route2.def.handler(c));
7382
+ return guard(route2.def.access, await route2.def.handler(c2));
7366
7383
  } catch (err) {
7367
7384
  if (!(err instanceof HttpError) && this.readerPage(url.pathname)) {
7368
7385
  logUncaught(err);
7369
7386
  return guard(route2.def.access, serverErrorPage());
7370
7387
  }
7371
- return guard(route2.def.access, toErrorResponse(err));
7388
+ const noPage = err === badParam || err instanceof HttpError && err.code === "not_found";
7389
+ const page = noPage ? await this.noSuchPage(c2) : void 0;
7390
+ return guard(route2.def.access, page ?? toErrorResponse(err));
7372
7391
  }
7373
7392
  }
7374
- return json({ error: "not_found" }, 404);
7393
+ const c = { req, env, ctx, url, params: {}, config };
7394
+ return await this.noSuchPage(c) ?? json({ error: "not_found" }, 404);
7395
+ }
7396
+ /**
7397
+ * The reader's page for an address that names none, when a browser asked for it on a reader
7398
+ * path (SPEC §5); `undefined` leaves the JSON, which the API's clients read and anything not
7399
+ * asking for a page may. A failure here is one nothing handled, so it gets that page.
7400
+ */
7401
+ async noSuchPage(c) {
7402
+ if (!this.notFoundPage || !this.readerPage(c.url.pathname) || !asksForHtml(c.req)) {
7403
+ return void 0;
7404
+ }
7405
+ try {
7406
+ return await this.notFoundPage(c);
7407
+ } catch (err) {
7408
+ logUncaught(err);
7409
+ return serverErrorPage();
7410
+ }
7375
7411
  }
7376
7412
  };
7377
7413
 
@@ -9160,6 +9196,16 @@ function nothingHereYetPage(c) {
9160
9196
  status: 404
9161
9197
  });
9162
9198
  }
9199
+ function noPageHerePage(c) {
9200
+ return readerMessage(c, {
9201
+ title: "No page here",
9202
+ eyebrow: "Not found",
9203
+ headline: "No page here",
9204
+ ledeHtml: "There\u2019s no page at this address.",
9205
+ afterHtml: archiveLink(archiveHomeUrl(c.config), "Browse the archive"),
9206
+ status: 404
9207
+ });
9208
+ }
9163
9209
  async function postPage(c, post, send) {
9164
9210
  const html = webVersion(send.rendered_html, {
9165
9211
  config: c.config,
@@ -9403,11 +9449,43 @@ function probeImageDimensions(b) {
9403
9449
  return { width: dv.getUint32(16), height: dv.getUint32(20) };
9404
9450
  }
9405
9451
  if (b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56) {
9406
- return { width: dv.getUint16(6, true), height: dv.getUint16(8, true) };
9452
+ const width = dv.getUint16(6, true);
9453
+ const height = dv.getUint16(8, true);
9454
+ return width && height ? { width, height } : null;
9407
9455
  }
9408
9456
  if (b[0] === 255 && b[1] === 216) {
9409
9457
  return probeJpeg(b, dv);
9410
9458
  }
9459
+ if (fourCc(b, 0) === "RIFF" && fourCc(b, 8) === "WEBP") {
9460
+ return probeWebp(b, dv);
9461
+ }
9462
+ return null;
9463
+ }
9464
+ function fourCc(b, offset) {
9465
+ return String.fromCharCode(...b.subarray(offset, offset + 4));
9466
+ }
9467
+ function probeWebp(b, dv) {
9468
+ const chunk3 = fourCc(b, 12);
9469
+ if (chunk3 === "VP8 ") {
9470
+ if (b.length < 30 || b[23] !== 157 || b[24] !== 1 || b[25] !== 42) {
9471
+ return null;
9472
+ }
9473
+ return { width: dv.getUint16(26, true) & 16383, height: dv.getUint16(28, true) & 16383 };
9474
+ }
9475
+ if (chunk3 === "VP8L") {
9476
+ if (b.length < 25 || b[20] !== 47) {
9477
+ return null;
9478
+ }
9479
+ const sizes = dv.getUint32(21, true);
9480
+ return { width: (sizes & 16383) + 1, height: (sizes >>> 14 & 16383) + 1 };
9481
+ }
9482
+ if (chunk3 === "VP8X") {
9483
+ if (b.length < 30) {
9484
+ return null;
9485
+ }
9486
+ const canvasSize = (at) => (dv.getUint16(at, true) | dv.getUint8(at + 2) << 16) + 1;
9487
+ return { width: canvasSize(24), height: canvasSize(27) };
9488
+ }
9411
9489
  return null;
9412
9490
  }
9413
9491
  function probeJpeg(b, dv) {
@@ -9419,7 +9497,9 @@ function probeJpeg(b, dv) {
9419
9497
  }
9420
9498
  const marker = b[off + 1];
9421
9499
  if (marker !== void 0 && marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
9422
- return { width: dv.getUint16(off + 7), height: dv.getUint16(off + 5) };
9500
+ const width = dv.getUint16(off + 7);
9501
+ const height = dv.getUint16(off + 5);
9502
+ return width > 0 && height === 0 ? null : { width, height };
9423
9503
  }
9424
9504
  const len = dv.getUint16(off + 2);
9425
9505
  if (len < 2) {
@@ -13364,7 +13444,7 @@ var notifications_default = "# Notifications through Cloudflare's email\n\nKestr
13364
13444
  var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form is public, so anyone can submit it, as often as they like. The app already sends any one address at most one confirmation every 15 minutes, and answers every address the same way. It can't tell one visitor from another, though. Cloudflare can, so a rule at Cloudflare caps how often each visitor submits.\n\nIn this guide, you add a rate-limiting rule for the subscribe page, and check that it blocks a burst of requests. Nothing changes in the app.\n\n## 1. Add a rate-limiting rule\n\nCloudflare's Free plan includes one rate-limiting rule, and the subscribe form is a good place to spend it.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), open your domain, `example.com`, and go to **Security rules**.\n\n1. Select **Create rule**, then **Rate limiting rules**.\n\n1. Fill in the rule:\n\n - **Rule name:** `Subscribe form`\n - **If incoming requests match:** **URI Path** equals `/subscribe`\n - **With the same characteristics:** IP\n - **When rate exceeds:** 5 requests per 10 seconds\n - **Then take action:** Block, with a duration of 10 seconds\n\n The rule's expression reads:\n\n ```\n (http.request.uri.path eq \"/subscribe\")\n ```\n\n1. Select **Deploy**.\n\nOn the Free plan, a rule can match only on the path. It counts loads of the form as well as submissions, and `/subscribe` on every hostname in your domain. At five requests in 10 seconds, neither gets in a real reader's way.\n\nA paid plan allows a longer window, which fits a form better. On Pro, try 10 requests a minute, blocked for 10 minutes. Pro can also limit the rule to the app's hostname, with `and http.host eq \"newsletter.example.com\"`. Business can limit it to submissions, with `and http.request.method eq \"POST\"`.\n\nThe rule covers only your domain's hostnames. The Worker's `workers.dev` addresses would skip it, which is why your production settings keep `workers_dev` and `preview_urls` set to `false`. Keep them that way.\n\n## Check it\n\n1. Request the subscribe page eight times in a row:\n\n ```bash\n for i in $(seq 1 8); do curl -s -o /dev/null -w \"%{http_code}\\n\" https://newsletter.example.com/subscribe; done\n ```\n\n The first requests answer `200`. Once you pass five within 10 seconds, the rest answer `429`, Cloudflare's block. The switch can come a request or two later.\n\n1. Wait 10 seconds, and open `https://newsletter.example.com/subscribe` in a browser. The form loads again.\n\n1. In the dashboard, **Security rules** lists `Subscribe form` as active.\n";
13365
13445
 
13366
13446
  // docs/guides/07-upgrade.md
13367
- var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Start from a clean repository, so the upgrade's commit holds only the upgrade and going back undoes nothing else:\n\n ```bash\n git status\n ```\n\n If it lists anything, commit it first, as its own commit, or move it out of the repository.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n If npm stops with `ERESOLVE` and names wrangler, your `package.json` pins a wrangler older than the release needs. The error's `peer wrangler@` line gives the range it needs. Install both together, with that range in place of `WRANGLER_RANGE`:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z \"wrangler@WRANGLER_RANGE\"\n ```\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info DB --env production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the 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. Run by Claude, or in any other shell that isn't interactive, it answers its own prompt and applies them without asking. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nNear its end, the output names your app's hostname, then its schedule and a version ID.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n If it answers `500` with `{\"error\":\"schema_behind\",\"missing\":[\u2026],\u2026}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/07-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/07-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore DB --env production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
13447
+ var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you first read what changed and check that your repository is clean, which changes nothing. Then you install the release, note where your database stands, apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Check before you change anything\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release. The upgrade also has to start from a clean repository.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n1. Check that your repository is clean, so the upgrade's commit holds only the upgrade and going back undoes nothing else:\n\n ```bash\n git status\n ```\n\n It has to say `nothing to commit, working tree clean`. If it lists anything, untracked files included, the upgrade waits until it doesn't.\n\nNothing has changed yet. If you aren't going on, stop here.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n If npm stops with `ERESOLVE` and names wrangler, your `package.json` pins a wrangler older than the release needs. The error's `peer wrangler@` line gives the range it needs. Install both together, with that range in place of `WRANGLER_RANGE`:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z \"wrangler@WRANGLER_RANGE\"\n ```\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info DB --env production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the 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. Run by Claude, or in any other shell that isn't interactive, it answers its own prompt and applies them without asking. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nNear its end, the output names your app's hostname, then its schedule and a version ID.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n If it answers `500` with `{\"error\":\"schema_behind\",\"missing\":[\u2026],\u2026}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/07-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/07-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore DB --env production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
13368
13448
 
13369
13449
  // docs/guides/09-as-your-list-grows.md
13370
13450
  var as_your_list_grows_default = "# As your list grows\n\nA new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits. Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. As the list grows, two limits start to matter: how fast a send goes out, and how many database writes the free plan allows a day.\n\nIn this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.\n\n## 1. Raise your SES limits\n\nSES caps how many emails your account sends a day, and how many a second. A send that hits the daily quota waits, emails you, and resumes on its own once the quota frees up. Raising your maximum send rate to 50 a second costs nothing, and it makes every send faster.\n\n1. Ask AWS for a higher daily quota and a maximum send rate of 50, as AWS's [Increasing your Amazon SES sending quotas](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-request-increase.html) describes.\n\n1. When AWS grants it, the app takes the new rate from your account within about ten minutes, and **Settings \u2192 Email sender** shows it under **Send rate**. If `SES_MAX_SEND_RATE` is set in `wrangler.jsonc`, the app keeps to the lower of the two, so remove it or raise it to match, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes.\n\n## 2. Upgrade to Workers Paid\n\nCloudflare's $5 Workers Paid plan helps in two ways, at any size of list.\n\n- **Faster sends.** On the free plan, Cloudflare caps how much one minute's work can do. A send then uses your SES rate for only a few seconds of each minute: about 56 emails a minute at a rate of 14. On Workers Paid, a send uses about 50 seconds of each minute, about 700 emails a minute at the same rate. If sends feel slow, upgrading speeds them up more than raising your SES rate does. [How long a send takes](#how-long-a-send-takes) compares them.\n- **No daily limit on database writes.** On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows 100,000 written rows a day, reset at midnight UTC. A send writes about 14 rows a recipient, receipts included, and an import about 7 for each new subscriber. So at 1,000 subscribers, an import of the whole list and two sends on one day use about a third of the day. Once the day's rows are spent, the whole app stops until midnight UTC, the SES webhook with it. That day's bounces and complaints are lost, and suppress or unsubscribe no one.\n\n It's recommended to upgrade from about 1,000 subscribers. Past that, the Dashboard and the Subscribers screen say so, and so does an import that would take the list past it. Past about 5,000, a send that could use up the day on its own also warns before it's scheduled. The app doesn't count the day's writes, so between the two, two large sends or several imports and a send on one UTC day can still use it up without a warning at that moment.\n\n1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.\n\n1. In `wrangler.jsonc`, add `\"SUBREQUEST_BUDGET\": \"10000\"` to the `production` block's `vars`. It tells the app it's on Workers Paid, so each minute sends far more. [Configuration](../reference/01-configuration.md#vars) describes `SUBREQUEST_BUDGET`.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Run on Workers Paid\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\nOver years, one more limit can matter: on the free plan, one [D1 database](https://developers.cloudflare.com/d1/platform/limits/) holds at most 500 MB, and every send keeps a row for each recipient in its record. Workers Paid raises it to 10 GB.\n\n## How long a send takes\n\n| Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |\n| --- | --- | --- | --- |\n| Free plan, a starting SES rate of 14 | about 18 minutes | about 1.5 hours | over the free plan's daily writes |\n| Free plan, SES rate raised to 50 | about 5 minutes | about 25 minutes | over the free plan's daily writes |\n| Workers Paid, SES rate of 14 | about 2 minutes | about 7 minutes | about 15 minutes |\n| Workers Paid, SES rate of 50 | under a minute | about 2 minutes | about 4 minutes |\n\n## Check it\n\n1. **Settings \u2192 Email sender** shows your new maximum send rate under **Send rate**, \"from your SES account\".\n\n1. On Workers Paid, the import screen under **Subscribers \u2192 Import** reads \"CSV or .txt, up to 10,000 rows.\"\n";
@@ -13412,7 +13492,7 @@ The From name and address your emails come from are set when Kestrel is deployed
13412
13492
  var write_a_post_default = "# Write and send a post\n\nWrite in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Write\n\nThe editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email, and warns under it about anything it found wrong.\n\n- A blank line starts a new paragraph.\n- The opening lines are the preview a mail client shows beside the subject.\n- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. A file named for a different format than it really is, like a JPEG called `photo.png`, is refused until you rename it. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.\n\n## Schedule and send\n\nChoose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.\n\n**Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings \u2192 Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/05-ses.md) sets up.\n\nTo change the post, choose **Cancel** on its banner, and it's a draft again. **Edit schedule** moves the time without unlocking it.\n\nAt the send time, the post goes to your confirmed subscribers, and its page goes up in your archive. Watch it go out under **Sent**. If a send needs you, its page says so, and [Troubleshooting](06-troubleshooting.md) covers each case.\n\n## Post settings\n\n**Post settings**, at the top right of the editor, holds two choices you can change until the post sends:\n\n- **Include in archive.** Uncheck it to send the post as email only, for a correction or a short notice. It stays off your archive and home page.\n- **Archive address.** It follows the subject until you type your own. It's fixed once the post sends.\n\nAfter a post sends, its page under **Sent** can take it out of your archive or put it back.\n";
13413
13493
 
13414
13494
  // docs/publishing/06-troubleshooting.md
13415
- var troubleshooting_default = "# Troubleshooting\n\nWhen something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened and what to do. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.\n\n**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.\n\n| Symptom | Where the fix is |\n| --- | --- |\n| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |\n| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |\n| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |\n| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |\n| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |\n| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |\n| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |\n| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |\n| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |\n| [An import is refused](#an-import-is-refused) | In your file |\n| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |\n\n## A send is held or waiting\n\nNothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, it's one of these:\n\n- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. When SES refuses your account, any other post that comes due meanwhile waits with it. When it refuses only this send's email, other posts go out as usual. While SES takes some readers and refuses the rest, as while a daily quota frees up, those it takes are sent. The line stays until SES takes everyone.\n- **SES keeps refusing for its sending rate.** Kestrel is sending faster than your SES account allows. **Settings \u2192 Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-ses.md) describes, and the send goes out at its next retry.\n- **A send missed its time, or nothing has gone out for a while.** The part of Kestrel that sends has stopped, or keeps failing on this send. Check the Worker's Cron Trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes, and read the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. If another send is going out at the same time, this one may only be waiting its turn.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Someone was mailed twice, or not at all\n\nKestrel never mails a reader twice on its own. When it can't tell whether your provider took some emails, it stops and asks you: the send's page shows **Resolve\u2026**. Choose **Assume not sent** unless you know otherwise, since it's the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it, such as from a reader who tells you so. Either choice mails no one.\n\nA reader recorded as **Unsent** is still on your list and gets your next post. That send never mails them again: their own email kept failing, SES won't take their address, or you chose **Assume not sent**.\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 and why they left, if they did. Any bounce or block on the address shows with the provider's words or yours. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## A send still reads Settling\n\nThat's normal. **Delivered** counts only the emails your provider confirmed reached the reader's mail server. Confirmations can lag by days, so the counts keep rising after a send finishes. Some are never confirmed at all, so a send can read **Settling** for good, as long as some receipts have come.\n\nWhen none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.\n\n## A test doesn't arrive\n\nWhile Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or [request production access](../get-started/06-production-access.md). When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.\n\n## Signups aren't open yet, or are paused\n\nYour subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.\n\n- \"Signups aren\u2019t open yet\" means SES hasn't sent an email for your publication yet. Send one from **Template \u2192 Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.\n- \"Signups are paused\" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn\u2019t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.\n\nKestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## A problem with Amazon SES\n\n**Settings \u2192 Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:\n\n- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.\n- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.\n- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.\n- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.\n\nThe app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## An address bounced or complained\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. Its page 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\nNothing on your list changes. Kestrel refuses a file it can't read with certainty, and names the row and column it couldn't. Fix the file and import it again. Convert a file in another format first, with Claude or by hand. Move a column of your old service's into `evidence`. Split a file of more than 10 MB, or with too many rows. [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. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** 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. If both are right, your instance's Access setup needs the fix.\n- Signing in shows `invalid_target`, or `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Your instance's Access setup needs the fix.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the \"Access let you in\" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before \"the only instance you're signed in to\", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.\n- **Claude's changes read \"via the API\".** Claude called 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 change reads \"via the Kestrel CLI\".** The person it names made it with `kestrel api` outside Claude Code, by hand or through a script, so there's nothing to fix. If Claude Code made it, update Claude Code to v2.1.172 or later: an older one reads this way, and the dashboard can stop saying \"Claude is connected.\"\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Where to look\n\nWhen a line doesn't settle it, these say the most about what happened:\n\n- The top of the dashboard: what needs someone now, in Kestrel's own words.\n- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.\n- **Settings \u2192 Email sender**: the state of your Amazon SES account, and your **Send rate**.\n- **Settings \u2192 Notifications**: **Last notification** says whether the last one was delivered, or why not.\n- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.\n- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails your provider accepted, and how late it went out if it did\n- when no one's email was accepted for a finished send, since it can't be sent again\n- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none\n- when your provider refuses a send or hits a limit, 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 doesn't go out on time\n- when your provider has sent no receipts for six hours after a send, once until a receipt arrives\n\nEach says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. 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";
13495
+ var troubleshooting_default = "# Troubleshooting\n\nWhen something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened and what to do. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.\n\n**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.\n\n| Symptom | Where the fix is |\n| --- | --- |\n| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |\n| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |\n| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |\n| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |\n| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |\n| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |\n| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |\n| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |\n| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |\n| [An import is refused](#an-import-is-refused) | In your file |\n| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |\n\n## A send is held or waiting\n\nNothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, it's one of these:\n\n- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. When SES refuses your account, any other post that comes due meanwhile waits with it. When it refuses only this send's email, other posts go out as usual. While SES takes some readers and refuses the rest, as while a daily quota frees up, those it takes are sent. The line stays until SES takes everyone.\n- **SES keeps refusing for its sending rate.** Kestrel is sending faster than your SES account allows. **Settings \u2192 Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-ses.md) describes, and the send goes out at its next retry.\n- **A send missed its time, or nothing has gone out for a while.** The part of Kestrel that sends has stopped, or keeps failing on this send. Check the Worker's Cron Trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes, and read the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. If another send is going out at the same time, this one may only be waiting its turn.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Someone was mailed twice, or not at all\n\nKestrel never mails a reader twice on its own. When it can't tell whether your provider took some emails, it stops and asks you: the send's page shows **Resolve\u2026**. Choose **Assume not sent** unless you know otherwise, since it's the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it, such as from a reader who tells you so. Either choice mails no one.\n\nA reader recorded as **Unsent** is still on your list and gets your next post. That send never mails them again: their own email kept failing, SES won't take their address, or you chose **Assume not sent**.\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 and why they left, if they did. Any bounce or block on the address shows with the provider's words or yours. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## A send still reads Settling\n\nThat's normal. **Delivered** counts only the emails your provider confirmed reached the reader's mail server. Confirmations can lag by days, so the counts keep rising after a send finishes. Some are never confirmed at all, so a send can read **Settling** for good, as long as some receipts have come.\n\nWhen none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.\n\n## A test doesn't arrive\n\nWhile Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or [request production access](../get-started/06-production-access.md). When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.\n\n## Signups aren't open yet, or are paused\n\nYour subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.\n\n- \"Signups aren\u2019t open yet\" means SES hasn't sent an email for your publication yet. Send one from **Template \u2192 Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.\n- \"Signups are paused\" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn\u2019t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.\n\nKestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## A problem with Amazon SES\n\n**Settings \u2192 Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:\n\n- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.\n- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.\n- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.\n- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.\n\nThe app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## An address bounced or complained\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. Its page 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\nNothing on your list changes. Kestrel refuses a file it can't read with certainty, and names the row and column it couldn't. Fix the file and import it again. Convert a file in another format first, with Claude or by hand. Move a column of your old service's into `evidence`. Split a file of more than 10 MB, or with too many rows. [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. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** 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. If both are right, your instance's Access setup needs the fix.\n- **Claude Code says Dynamic Client Registration was rejected.** `claude mcp login` fails with `Dynamic Client Registration rejected (HTTP 404)`, and a new session shows the connector as failed with the same words. Start `claude`, run `/mcp`, choose the connector, choose Authenticate, and sign in in the browser. The sign-in is stored, so later sessions connect. Nothing on your instance is wrong. `claude mcp login` looks for sign-in details at the standard well-known addresses before reading your instance's own answer, so it never reaches Access. The MCP specification has a client do that only as a fallback.\n- Signing in shows `invalid_target`, or `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Your instance's Access setup needs the fix.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the \"Access let you in\" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before \"the only instance you're signed in to\", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.\n- **Claude's changes read \"via the API\".** Claude called 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 change reads \"via the Kestrel CLI\".** The person it names made it with `kestrel api` outside Claude Code, by hand or through a script, so there's nothing to fix. If Claude Code made it, update Claude Code to v2.1.172 or later: an older one reads this way, and the dashboard can stop saying \"Claude is connected.\"\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Where to look\n\nWhen a line doesn't settle it, these say the most about what happened:\n\n- The top of the dashboard: what needs someone now, in Kestrel's own words.\n- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.\n- **Settings \u2192 Email sender**: the state of your Amazon SES account, and your **Send rate**.\n- **Settings \u2192 Notifications**: **Last notification** says whether the last one was delivered, or why not.\n- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.\n- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails your provider accepted, and how late it went out if it did\n- when no one's email was accepted for a finished send, since it can't be sent again\n- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none\n- when your provider refuses a send or hits a limit, 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 doesn't go out on time\n- when your provider has sent no receipts for six hours after a send, once until a receipt arrives\n\nEach says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. 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";
13416
13496
 
13417
13497
  // docs/publishing/08-connect-claude.md
13418
13498
  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 **Connect**. Sign in the way you do for the dashboard if asked, and choose **Allow**.\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";
@@ -14097,6 +14177,12 @@ async function uploadImage(c) {
14097
14177
  );
14098
14178
  }
14099
14179
  const dims = probeImageDimensions(new Uint8Array(bytes));
14180
+ if (dims && (dims.width === 0 || dims.height === 0)) {
14181
+ throw badRequest(
14182
+ `This file is a ${FORMAT_NAMES[contentType]}, but its header gives it no size, so it can\u2019t be shown. Export it again, or convert it.`,
14183
+ imageField
14184
+ );
14185
+ }
14100
14186
  let row = null;
14101
14187
  for (let n = 1; !row; n++) {
14102
14188
  const candidate = numberedName(filename, n);
@@ -15134,9 +15220,8 @@ function linkIsLive(row) {
15134
15220
 
15135
15221
  // src/routes/public.ts
15136
15222
  function wantsHtml(c) {
15137
- const accept = c.req.headers.get("accept") ?? "";
15138
15223
  const ct = c.req.headers.get("content-type") ?? "";
15139
- return accept.includes("text/html") || ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data");
15224
+ return asksForHtml(c.req) || ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data");
15140
15225
  }
15141
15226
  async function readEmail(c) {
15142
15227
  const ct = c.req.headers.get("content-type") ?? "";
@@ -15455,6 +15540,12 @@ var READER_JOURNEYS = [
15455
15540
  label: "Nothing here yet",
15456
15541
  offPath: true,
15457
15542
  at: { base: "archive", path: "/\u2026" }
15543
+ },
15544
+ {
15545
+ id: "no-page",
15546
+ label: "No page here",
15547
+ offPath: true,
15548
+ at: { base: "app", path: "/\u2026" }
15458
15549
  }
15459
15550
  ]
15460
15551
  }
@@ -15895,6 +15986,8 @@ async function previewOf(c, journey, state) {
15895
15986
  }
15896
15987
  case "reading/not-found":
15897
15988
  return nothingHereYetPage(c);
15989
+ case "reading/no-page":
15990
+ return noPageHerePage(c);
15898
15991
  }
15899
15992
  throw notFound("reader page");
15900
15993
  }
@@ -17427,7 +17520,7 @@ function createRouter({
17427
17520
  { name: "limit", description: "Page size (default 50, max 200)." },
17428
17521
  { name: "offset", description: "Rows to skip." }
17429
17522
  ];
17430
- const r = new Router(isReaderPath);
17523
+ const r = new Router(isReaderPath, noPageHerePage);
17431
17524
  const manifest = [
17432
17525
  // --- system ---
17433
17526
  {
@@ -18130,7 +18223,7 @@ function createRouter({
18130
18223
  name: "file",
18131
18224
  type: "file",
18132
18225
  required: true,
18133
- description: `The image: a PNG, JPEG, WebP, or GIF, at most ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB. Its format is read from its bytes, and a name or declared type that names another of these formats is refused.`
18226
+ description: `The image: a PNG, JPEG, WebP, or GIF, at most ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB. Its format is read from its bytes, and a name or declared type that names another of these formats is refused, as is a PNG or WebP whose header states a width or height of zero, or a JPEG whose header states a width of zero.`
18134
18227
  },
18135
18228
  {
18136
18229
  name: "filename",
@@ -19473,11 +19566,10 @@ function createRouter({
19473
19566
  // pages, and the emitted archive URLs always share one source. Self-contained by
19474
19567
  // default; an apex zone can additionally route <base>/* to this Worker (SPEC §11).
19475
19568
  // The index is registered before `:slug` so `/archive` resolves to the list, not
19476
- // a slug lookup; the optional trailing slash (`{/}?`) means `/archive` and
19477
- // `/archive/` both land on the index while `/archive/:slug` still serves posts.
19569
+ // a slug lookup.
19478
19570
  {
19479
19571
  method: "GET",
19480
- path: `${archiveBasePath}{/}?`,
19572
+ path: archiveBasePath,
19481
19573
  access: "public",
19482
19574
  resource: "archive",
19483
19575
  title: "Show the archive",