@kurtbruns/kestrel 1.20.0 → 1.21.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.
@@ -167,7 +167,7 @@ Create Kestrel's tables in the production database:
167
167
  npx wrangler d1 migrations apply DB --remote --env production
168
168
  ```
169
169
 
170
- It lists the migrations it's about to apply and asks you to confirm. It applies only the ones the database hasn't seen, so running it again is harmless.
170
+ It 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. It applies only the ones the database hasn't seen, so running it again is harmless.
171
171
 
172
172
  ## 6. Deploy
173
173
 
@@ -175,7 +175,7 @@ It lists the migrations it's about to apply and asks you to confirm. It applies
175
175
  npx wrangler deploy --env production
176
176
  ```
177
177
 
178
- This deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker's **Triggers** tab in the Cloudflare dashboard.
178
+ This deploys the release your instance runs, with your settings. Near its end, the output names the hostname the app now answers on, then its schedule and a version ID. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker's **Triggers** tab in the Cloudflare dashboard.
179
179
 
180
180
  Always name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker that answers nowhere and never sends. It takes the top level's `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:
181
181
 
@@ -51,7 +51,7 @@ The same form sets a custom MAIL FROM domain, so SPF also passes for your domain
51
51
 
52
52
  ## 2. Add a DMARC record
53
53
 
54
- DMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn't add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.
54
+ DMARC tells inboxes what to do with mail that fails its checks, and can ask them to send you reports. The `rua` tag that asks for them is optional, and the policy works without it. SES doesn't add this record, so you add it yourself. It starts in monitor mode, `p=none`, which blocks nothing, your own mail included. It only monitors: inboxes still deliver mail that fails, so it doesn't stop anyone sending as your domain.
55
55
 
56
56
  1. In the Cloudflare dashboard, open your domain's DNS records, and add a record:
57
57
 
@@ -309,7 +309,7 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
309
309
 
310
310
  1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher's inbox as the **Destination**, and save.
311
311
 
312
- 1. **(Optional)** Give your DMARC reports an inbox too. If the address in your DMARC record doesn't receive mail, add a rule that forwards `dmarc` the same way. Then change the record's `rua` to `mailto:dmarc@send.example.com`.
312
+ 1. **(Optional)** Forward your DMARC reports too, if you collect them and the address in your DMARC record doesn't receive mail. Add a rule that forwards `dmarc` the same way. Then change the record's `rua` to `mailto:dmarc@send.example.com`. Reports come about once a day from each inbox provider that received your mail. A day or two after your next email goes out, check that they arrive. Email Routing's **Activity log** shows each forward's status.
313
313
 
314
314
  1. Check that your From address receives mail. This now lists Cloudflare's three `route` mail servers:
315
315
 
@@ -102,7 +102,7 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
102
102
 
103
103
  1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, and `send.canceled` shows the one you canceled. Each line that records an action says who took it. Its `principal` is `human` or `service`, and its `client` names the program a person used, such as `editor`.
104
104
 
105
- 1. Filter on `level` equal to `error`. A healthy instance shows nothing.
105
+ 1. Filter on `level` equal to `error`. A healthy instance shows nothing. An occasional `d1.unavailable` line at `warn` is a brief database stall the next run makes up for, and needs nothing. The same line every minute means work isn't getting done, so if you set up alerts, alert on it repeating as well as on `error`.
106
106
 
107
107
  To follow one send, narrow the time range and filter on its `sendId`, since a long range may show only a sample. On the Free plan, Cloudflare stops keeping logs for the rest of the day once its daily allowance is used, as [its pricing page](https://developers.cloudflare.com/observability/pricing/) describes.
108
108
 
@@ -19,7 +19,7 @@ Cloudflare's email sends only from a domain set up with Email Routing. Use your
19
19
 
20
20
  1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.
21
21
 
22
- 1. Cloudflare adds MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.
22
+ 1. Cloudflare adds the DNS records it needs for `newsletter.example.com`. Wait until the new row reads **Status: Enabled** and **DNS records: Locked**.
23
23
 
24
24
  Avoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/05-ses.md#8-receive-replies-at-your-from-address) sets up, is fine, since it only receives.
25
25
 
@@ -27,27 +27,25 @@ Avoid turning on Email Routing for a domain that already receives mail elsewhere
27
27
 
28
28
  Cloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan. Notifications can go to several addresses, such as the publisher's and yours, and each one needs verifying.
29
29
 
30
- 1. Under **Email Routing**, go to **Destination Addresses**, and add each address saved for notifications.
30
+ 1. Under **Email Routing**, go to **Destination addresses**, and add each address saved for notifications that isn't already verified there.
31
31
 
32
- 1. Open the email Cloudflare sends to each address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
32
+ 1. Open the email Cloudflare sends to each one, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
33
33
 
34
- A verified address belongs to your Cloudflare account, so every environment in it can use it.
34
+ A verified address belongs to your Cloudflare account, so every environment in it can use it. If the publisher adds or changes a notifications address later, verify the new one too.
35
35
 
36
36
  ## 3. Add the binding and deploy
37
37
 
38
38
  A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.
39
39
 
40
- 1. In `wrangler.jsonc`, add the binding to the `production` block:
40
+ 1. In `wrangler.jsonc`, add the binding to `env.production`, beside `r2_buckets` and not inside `vars`:
41
41
 
42
42
  ```jsonc
43
- "send_email": [
44
- { "name": "NOTIFY", "allowed_destination_addresses": ["you@example.com"] } // ← the notifications addresses
45
- ],
43
+ "send_email": [{ "name": "NOTIFY" }],
46
44
  ```
47
45
 
48
- `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher adds or changes a notifications address later, verify the new one and add it here too.
46
+ Give the binding only its name. This file is committed to your repository, so keep the notifications addresses out of it.
49
47
 
50
- 1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:
48
+ 1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in `env.production.vars`:
51
49
 
52
50
  ```jsonc
53
51
  "NOTIFY_FROM": "Kestrel <alerts@newsletter.example.com>",
@@ -62,7 +60,8 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
62
60
  1. Commit the change, and push it:
63
61
 
64
62
  ```bash
65
- git commit -am "Send notifications through Cloudflare's email"
63
+ git add wrangler.jsonc
64
+ git commit -m "Send notifications through Cloudflare's email"
66
65
  git push
67
66
  ```
68
67
 
@@ -72,17 +71,22 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
72
71
  npx wrangler deploy --env production
73
72
  ```
74
73
 
74
+ The output lists the binding as `env.NOTIFY (unrestricted)`. Cloudflare still delivers only to the addresses you verified.
75
+
75
76
  ## Check it
76
77
 
77
78
  1. In the editor, open **Settings → Notifications**. **Sent through** reads "Cloudflare Email, separate from your newsletter's provider", and **From address** shows `Kestrel <kestrel@newsletter.example.com>`.
78
79
 
79
- 1. Select **Send a test notification**. It arrives at each notifications address, and **Last notification** shows it as delivered.
80
+ 1. Select **Send a test notification**. **Last notification** shows it as delivered.
81
+
82
+ 1. Check that the test arrives at each notifications address. If it's in the spam folder, mark it **Not spam**.
80
83
 
81
- If the test isn't delivered to an address, **Last notification** shows Cloudflare's reason, such as an address that isn't verified, after the address when there are several. The other addresses still get it. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.
84
+ 1. In Email Routing's **Activity log**, the test shows as **Delivered**. Go by this rather than Email Routing's summary, which [Cloudflare says](https://developers.cloudflare.com/email-service/platform/limits/) can list it as dropped even when it's delivered.
82
85
 
83
86
  ## What notifications don't cover
84
87
 
85
88
  - **The schedule stopping.** The same once-a-minute schedule that sends posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/07-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.
89
+ - **The database being down.** Noticing a problem and telling you both need the database. [Read the logs](../get-started/07-verify.md#7-read-the-logs) to check for `d1.unavailable` repeating.
86
90
  - **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings → Notifications**, and in the logs as `notify.failed`.
87
91
  - **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.
88
92
 
@@ -16,6 +16,14 @@ A release can ask you to change a setting or a record before it runs. The change
16
16
 
17
17
  Each step here changes only your instance's repository. Nothing reaches your deployed app until section 5.
18
18
 
19
+ 1. Start from a clean repository, so the upgrade's commit holds only the upgrade and going back undoes nothing else:
20
+
21
+ ```bash
22
+ git status
23
+ ```
24
+
25
+ If it lists anything, commit it first, as its own commit, or move it out of the repository.
26
+
19
27
  1. Install the release. Replace `X.Y.Z` with its version:
20
28
 
21
29
  ```bash
@@ -24,6 +32,12 @@ Each step here changes only your instance's repository. Nothing reaches your dep
24
32
 
25
33
  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.
26
34
 
35
+ 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`:
36
+
37
+ ```bash
38
+ npm install @kurtbruns/kestrel@X.Y.Z "wrangler@WRANGLER_RANGE"
39
+ ```
40
+
27
41
  1. Bring in the release's database migrations:
28
42
 
29
43
  ```bash
@@ -91,7 +105,7 @@ A release changes the database only by adding a migration. Applying it keeps eve
91
105
  npx wrangler d1 migrations apply DB --remote --env production
92
106
  ```
93
107
 
94
- It lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.
108
+ It 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.
95
109
 
96
110
  ## 5. Deploy
97
111
 
@@ -99,7 +113,7 @@ It lists the migrations it's about to apply, and asks you to confirm. When a rel
99
113
  npx wrangler deploy --env production
100
114
  ```
101
115
 
102
- The output ends with your app's hostname.
116
+ Near its end, the output names your app's hostname, then its schedule and a version ID.
103
117
 
104
118
  ## Check it
105
119
 
@@ -6,6 +6,14 @@ All three are optional, and you can change them at any time. How they appear in
6
6
 
7
7
  Write the tagline as one line that says what the newsletter covers and how often it goes out, such as "Monthly notes on the birds of the Hudson Valley." Until your first post, it's the only line on your home page that says what readers are signing up for. It's also what AWS sees if it reviews your site when you [request production access](../get-started/06-production-access.md).
8
8
 
9
+ ## Choose a logo
10
+
11
+ **Use a square PNG, JPEG, WebP, or GIF, at least 256 × 256 pixels, on a solid background.** Drop it on the logo under **Settings → Publication identity**, or choose **Upload**, which reads **Replace** once you have a logo. It saves right away, without **Save changes**. If that would change scheduled emails, Kestrel asks you to confirm first.
12
+
13
+ Kestrel makes a 256 × 256 PNG from your image before uploading it, so the original's file size doesn't matter. A wider or taller image is cropped to its center square, and a smaller one is scaled up and looks soft. An animated GIF keeps its first frame. Emails show the logo at 44 × 44 pixels, so a simple mark reads better than fine detail.
14
+
15
+ A transparent background shows whatever is behind the logo, and the built-in templates turn dark when a reader's mail app is in dark mode. A solid background keeps a dark mark visible there.
16
+
9
17
  ## When to provide a mailing address
10
18
 
11
19
  **If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
@@ -10,7 +10,7 @@ The editor takes Markdown, and Kestrel saves as you type. The same Markdown beco
10
10
 
11
11
  - A blank line starts a new paragraph.
12
12
  - The opening lines are the preview a mail client shows beside the subject.
13
- - Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. 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.
13
+ - 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.
14
14
 
15
15
  ## Schedule and send
16
16
 
@@ -102,6 +102,7 @@ Nothing on your list changes. Kestrel refuses a file it can't read with certaint
102
102
  - `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.
103
103
  - **"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.
104
104
  - **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.
105
+ - **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."
105
106
 
106
107
  If someone else set up Kestrel for you, pass this along to them.
107
108
 
@@ -52,14 +52,14 @@ In Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES repo
52
52
 
53
53
  ### DMARC: publish a policy and collect reports
54
54
 
55
- DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:
55
+ DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and can ask it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:
56
56
 
57
57
  ```
58
58
  _dmarc.send.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
59
59
  ```
60
60
 
61
- - `p=none` is monitor mode. Inboxes report on failing mail, but deliver it as usual.
62
- - `rua` is where reports go. They arrive as attachments, usually one a day from each inbox provider.
61
+ - `p=none` is monitor mode, and it only monitors. Inboxes deliver failing mail as usual, so it doesn't stop anyone sending as your domain. It's where to start because it blocks nothing, your own mail included.
62
+ - `rua` is where reports go. It's optional, and the policy works without it, though [Google's DMARC setup page](https://knowledge.workspace.google.com/admin/security/set-up-dmarc) recommends including it. Reports arrive as attachments, usually one a day from each inbox provider.
63
63
 
64
64
  Leave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.
65
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.20.0",
3
+ "version": "1.21.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",
@@ -68,21 +68,21 @@
68
68
  "dependencies": {
69
69
  "@css-inline/css-inline-wasm": "^0.21.2",
70
70
  "aws4fetch": "^1.0.20",
71
- "jose": "^6.2.12",
72
- "marked": "^18.0.12"
71
+ "jose": "^6.2.14",
72
+ "marked": "^18.1.3"
73
73
  },
74
74
  "peerDependencies": {
75
- "wrangler": "^4.152.0"
75
+ "wrangler": "^4.156.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@biomejs/biome": "2.5.6",
79
- "@cloudflare/vitest-plugin": "^1.4.7",
80
- "@types/node": "^22.20.1",
81
- "esbuild": "0.28.1",
82
- "happy-dom": "^20.14.5",
78
+ "@biomejs/biome": "2.6.4",
79
+ "@cloudflare/vitest-plugin": "^1.4.11",
80
+ "@types/node": "^22.20.5",
81
+ "esbuild": "0.28.2",
82
+ "happy-dom": "^21.0.4",
83
83
  "typescript": "~5.9.3",
84
84
  "vitest": "^4.1.12",
85
- "wrangler": "^4.152.0"
85
+ "wrangler": "^4.156.0"
86
86
  },
87
87
  "allowScripts": {
88
88
  "esbuild": true,
@@ -5,11 +5,12 @@
5
5
  * Deployed, it sends the access token `kestrel login` stored, refreshing it from the
6
6
  * refresh token when it is about to expire or Access has turned it away, and storing what
7
7
  * comes back. Locally there is no Access: the target is this directory's dev server, and
8
- * the credential is the dev server's human token, so a local run is a person via Claude
9
- * Code just as a deployed one is. Every request carries `Kestrel-Client: claude-code`
10
- * unless `--client` names another program, so a person's own script can name itself. It
11
- * never names the editor: the server trusts the name it is sent (SPEC, Trusted, not guarded),
12
- * and the editor's name lifts a step only a person in the editor may take (SPEC §7).
8
+ * the credential is the dev server's human token, so a local run is a person through a
9
+ * client just as a deployed one is. Every request names its client in `Kestrel-Client`:
10
+ * `defaultClient` says which, unless `--client` names another program, so a person's own script
11
+ * can name itself. It never names the editor: the server trusts the name it is sent (SPEC,
12
+ * Trusted, not guarded), and the editor's name lifts a step only a person in the editor may take
13
+ * (SPEC §7).
13
14
  *
14
15
  * It prints the answer's body as it came, so a script or Claude reads a refusal whole; a
15
16
  * non-2xx answer also prints its status, and a refusal's code, message, field, and next move,
@@ -24,10 +25,13 @@ import { readFileSync } from "node:fs";
24
25
  import { basename } from "node:path";
25
26
  import { readSignIn, readSignIns, withCredentialsLock, writeSignInHeld } from "./credentials.mjs";
26
27
  import { devTarget } from "./dev-api.mjs";
28
+ import { fileMediaType } from "./media-types.mjs";
27
29
  import {
28
- DEFAULT_CLIENT,
30
+ CLAUDE_CODE_CLIENT,
31
+ defaultClient,
29
32
  instanceOrigin,
30
33
  isLoopback,
34
+ KESTREL_CLI_CLIENT,
31
35
  needsRefresh,
32
36
  refreshTokens,
33
37
  SignInError,
@@ -54,14 +58,16 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
54
58
  --form <name=value> a multipart field, repeatable; name=@file attaches a file
55
59
  --url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
56
60
  --local this directory's dev server, started by kestrel dev
57
- --client <name> your own script's Kestrel-Client name (default ${DEFAULT_CLIENT});
58
- never editor, which only the editor names itself
61
+ --client <name> your own script's Kestrel-Client name (default ${CLAUDE_CODE_CLIENT}
62
+ when Claude Code runs this, otherwise ${KESTREL_CLI_CLIENT}); never
63
+ editor, which only the editor names itself
59
64
 
60
65
  The routes, fields, and refusals come from the instance's own /api/openapi.json, so they
61
66
  match the release it runs. A field the route doesn't take is refused before the request.`;
62
67
 
63
- // The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11).
64
- const EDITOR_CLIENT = "editor";
68
+ // The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11). Mirrored
69
+ // from shared/who_acted.ts, since the CLI is plain Node; a spec holds the two equal.
70
+ export const EDITOR_CLIENT = "editor";
65
71
 
66
72
  // The server's rule for a client name (shared/who_acted.ts), mirrored since the CLI is plain
67
73
  // Node; a spec holds the two equal. Checking it here, after the trim fetch applies to a header
@@ -227,7 +233,8 @@ async function requestBody({ data, type, forms }) {
227
233
  const value = field.slice(at + 1);
228
234
  if (value.startsWith("@")) {
229
235
  const file = value.slice(1);
230
- form.append(name, new Blob([readFileSync(file)]), basename(file));
236
+ const type = fileMediaType(file);
237
+ form.append(name, new Blob([readFileSync(file)], { type }), basename(file));
231
238
  } else {
232
239
  form.append(name, value);
233
240
  }
@@ -416,7 +423,7 @@ export async function apiCommand(argv) {
416
423
  let call;
417
424
  try {
418
425
  const args = parseApiArgs(argv);
419
- const client = args.client ?? DEFAULT_CLIENT;
426
+ const client = args.client ?? defaultClient();
420
427
  call = await openTarget(args, client);
421
428
  if (args.mode === "list") {
422
429
  console.log(listRoutes(await readOpenApi(call), args.resource));
@@ -13,7 +13,7 @@ import { signedInFetch } from "./api.mjs";
13
13
  import { credentialsFile, readSignIn, readSignIns, writeSignIn } from "./credentials.mjs";
14
14
  import {
15
15
  authorizeUrl,
16
- DEFAULT_CLIENT,
16
+ defaultClient,
17
17
  discover,
18
18
  exchangeCode,
19
19
  instanceOrigin,
@@ -86,7 +86,7 @@ export async function signIn(origin, { browser }) {
86
86
  } finally {
87
87
  listener.close();
88
88
  }
89
- const res = await signedInFetch(origin, PROTECTED_PATH, { client: DEFAULT_CLIENT });
89
+ const res = await signedInFetch(origin, PROTECTED_PATH, { client: defaultClient() });
90
90
  const body = await res.json().catch(() => null);
91
91
  if (!res.ok) {
92
92
  const move = unauthorizedMove(body);
@@ -0,0 +1,34 @@
1
+ /*
2
+ * A file's media type, read from its extension. An instance reads an upload's format from its
3
+ * bytes and holds the label to them, but this client also calls instances on older releases,
4
+ * which read only what the multipart part declares and refuse one with no type, so it states one:
5
+ * `kestrel api --form` does, and so does `kestrel seed`, which imports this rather than keep
6
+ * its own copy (the client may not import from cli/, but cli/ imports from the client).
7
+ */
8
+ import { basename } from "node:path";
9
+
10
+ /**
11
+ * The extensions of the raster formats an instance takes as an image. The Worker's list is
12
+ * src/lib/image_types.ts, mirrored since the client is plain Node; a spec holds the two equal.
13
+ */
14
+ export const IMAGE_TYPE_BY_EXTENSION = {
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".webp": "image/webp",
19
+ ".gif": "image/gif",
20
+ };
21
+
22
+ /** What a file goes as when its extension says nothing: a statement of no kind, not a guess. */
23
+ const UNKNOWN_MEDIA_TYPE = "application/octet-stream";
24
+
25
+ /**
26
+ * The media type a file's name says it is, in any case (a camera's `IMG_0042.JPG`), read just as
27
+ * the server reads a name's claim (shared/images.ts), so the two agree on a file called `.png`.
28
+ */
29
+ export function fileMediaType(path) {
30
+ const name = basename(path);
31
+ return (
32
+ IMAGE_TYPE_BY_EXTENSION[name.slice(name.lastIndexOf(".")).toLowerCase()] ?? UNKNOWN_MEDIA_TYPE
33
+ );
34
+ }
@@ -19,8 +19,25 @@ import { createServer } from "node:http";
19
19
  /** The path discovery and the sign-in's check ask: protected, and it answers who signed in. */
20
20
  export const PROTECTED_PATH = "/api/whoami";
21
21
 
22
- /** The name `kestrel api` gives its requests, unless told another (SPEC §11, Who acted). */
23
- export const DEFAULT_CLIENT = "claude-code";
22
+ // The names `kestrel api` gives itself (SPEC §11, Who acted). Mirrored from shared/who_acted.ts,
23
+ // since the client is plain Node; a spec holds them equal.
24
+ export const CLAUDE_CODE_CLIENT = "claude-code";
25
+ export const KESTREL_CLI_CLIENT = "kestrel-cli";
26
+
27
+ /**
28
+ * The name a request carries unless told another: Claude Code's when Claude Code ran this
29
+ * process, and the command's own otherwise, so what a person runs never reads as Claude's.
30
+ * CLAUDE_CODE_CHILD_SESSION is set only by Claude Code, on what its tools launch. CLAUDECODE
31
+ * won't do: IDE extensions also set it in their integrated terminals, where a person types.
32
+ *
33
+ * That makes the name truthful in the normal case, not proven, as with every client name. Claude
34
+ * Code older than v2.1.172 doesn't set the variable, so its calls read as the command's own; and
35
+ * a `screen` session or a background launcher Claude Code started passes it on, so what a person
36
+ * later runs inside one reads as Claude Code's.
37
+ */
38
+ export function defaultClient() {
39
+ return process.env.CLAUDE_CODE_CHILD_SESSION === "1" ? CLAUDE_CODE_CLIENT : KESTREL_CLI_CLIENT;
40
+ }
24
41
 
25
42
  // How long before its expiry an access token is refreshed rather than sent.
26
43
  const REFRESH_MARGIN_MS = 60_000;
@@ -278,7 +278,7 @@ export function describeRoute(doc, method, path) {
278
278
  }
279
279
  const raw = types.filter((t) => t !== "application/json" && t !== "multipart/form-data");
280
280
  if (raw.length) {
281
- how.push(`the file itself, with --data @file --type ${raw[0]}`);
281
+ how.push(`the file itself, with --data @file --type <its own type> (${raw.join(", ")})`);
282
282
  }
283
283
  out.push(` Send it as ${how.join("; or ")}.`);
284
284
  const example = op.requestBody.content["application/json"]?.example;
@@ -23,7 +23,7 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
23
23
  | `npm run sync-migrations` | Copies the installed release's migrations into `migrations/` |
24
24
  | `npm run check-context` | Compares the Claude files in `.claude/` with the installed release's |
25
25
  | `npx kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
26
- | `npx kestrel api <method> <path>` | Calls the API as you, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
26
+ | `npx kestrel api <method> <path>` | Calls the API as you (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
27
27
  | `npx wrangler deploy --env production` | Deploys the instance |
28
28
  | `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
29
29
 
@@ -29,7 +29,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
29
29
  - **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
30
30
  - **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
31
31
  - **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
32
- - **Posts with images:** upload each image from a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by the `filename` the upload answers, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. That name may differ from the one you sent, since spaces and characters that would break a reference become hyphens, and a name the post already has gets a number. An upload never replaces an image: to swap one, upload the new image, reference its `filename`, and delete the old one. The preview warns on a `url` reference, on a name the post doesn't have, on a reference that didn't become an image, and on an uploaded image the post doesn't use.
32
+ - **Posts with images:** upload each image from its original file, asking the person to save it into this folder (the Claude desktop app saves a pasted image to a temporary folder, converted and under a generated name, so it can be uploaded but isn't the file they had), then reference it in the Markdown by the `filename` the upload answers, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. That name may differ from the one you sent, since spaces and characters that would break a reference become hyphens, and a name the post already has gets a number. An upload never replaces an image: to swap one, upload the new image, reference its `filename`, and delete the old one. The preview warns on a `url` reference, on a name the post doesn't have, on a reference that didn't become an image, and on an uploaded image the post doesn't use.
33
33
  - **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
34
34
  - **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
35
35
  - **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
@@ -11,6 +11,7 @@ An upgrade moves the instance to a newer release of `@kurtbruns/kestrel`. The gu
11
11
  ## What's yours to judge
12
12
 
13
13
  - **Which release, and what it asks.** The installed version is in `package.json`. Read every changelog section between it and the target (the release notes at `https://github.com/kurtbruns/kestrel/releases`, or the new `CHANGELOG.md` once installed). Name each **Breaking** entry and each step in an *Upgrading* paragraph, and say what it means for this instance.
14
+ - **Anything not yet committed.** If the page's `git status` lists anything, show the person and ask whether to commit it first, as its own commit. If they'd rather not, the upgrade waits until the repository is clean. Never let it go into the upgrade's commit: the page's **Go back** reverts that commit, and would undo it too.
14
15
  - **The context check.** `npm run check-context` compares this repository's Claude files with the release's. Show the person what it reports. When Kestrel changed a file the person has edited, show what changed, merge it with their edits, and run the command it suggests to record the merge. A change marked as bearing on safety comes first.
15
16
  - **The local try.** Ask the person to try what they care about on `npm run dev` before anything reaches production.
16
17
  - **The checks.** Report each one's result. A failure stops the upgrade until it's fixed or the person decides.