@kurtbruns/kestrel 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +1 -1
  3. package/bin/kestrel.mjs +17 -2
  4. package/cli/api.mjs +462 -0
  5. package/cli/credentials.mjs +192 -0
  6. package/cli/dev-api.mjs +16 -10
  7. package/cli/login.mjs +155 -0
  8. package/cli/oauth.mjs +368 -0
  9. package/cli/openapi.mjs +569 -0
  10. package/dist/public/dashboard/app.js +290 -285
  11. package/dist/public/dashboard/app.js.map +4 -4
  12. package/dist/public/dashboard/index.html +2 -2
  13. package/dist/public/dashboard/styles.css +74 -40
  14. package/dist/worker/index.js +2723 -1192
  15. package/docs/README.md +3 -2
  16. package/docs/get-started/02-deploy.md +17 -20
  17. package/docs/get-started/03-access.md +10 -8
  18. package/docs/get-started/06-go-live.md +2 -1
  19. package/docs/guides/01-allow-claude.md +68 -0
  20. package/docs/guides/02-automate.md +88 -0
  21. package/docs/guides/09-as-your-list-grows.md +2 -2
  22. package/docs/publishing/01-your-list.md +3 -3
  23. package/docs/publishing/03-name-your-publication.md +1 -1
  24. package/docs/publishing/04-write-a-post.md +15 -52
  25. package/docs/publishing/06-troubleshooting.md +20 -9
  26. package/docs/publishing/08-connect-claude.md +60 -0
  27. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  28. package/docs/publishing/10-check-your-setup.md +9 -16
  29. package/docs/publishing/11-email-template.md +1 -1
  30. package/docs/publishing/README.md +2 -2
  31. package/docs/reference/01-configuration.md +132 -85
  32. package/docs/reference/02-sending-domain-dns.md +2 -2
  33. package/migrations/0013_who_acted_client.sql +20 -0
  34. package/migrations/0014_send_acts.sql +25 -0
  35. package/package.json +1 -1
  36. package/template/README.md +2 -0
  37. package/template/claude/CLAUDE.md +8 -5
  38. package/docs/guides/01-connect-claude.md +0 -101
  39. package/docs/publishing/07-working-with-claude.md +0 -29
package/CHANGELOG.md CHANGED
@@ -10,6 +10,39 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.14.0] - 2026-10-22
14
+
15
+ **Upgrading from 1.13.0.** Two database migrations, which `npm run sync-migrations` brings in: apply them before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0013_who_acted_client` records the client each revision and import came through. No client was recorded before it, and nothing tells a person's change in the editor from one through another program, so every revision and import a person made is read as made in the editor, and those rows are rewritten once, which counts against the day's database writes on Workers Free like any other write; a service token's read as naming no client. `0014_send_acts` adds a record of every act on a send: who scheduled it, moved it, canceled it, or resolved it, and when. It rewrites nothing: a send made before it reads as acted on by no one recorded.
16
+
17
+ **Before you deploy, check your Access policies.** Your Access application must let people in only through an Allow policy on emails, and programs only through a Service Auth policy on a service token: a login through any other kind of policy is now refused, and a search of your Worker's logs for `auth.unidentified` shows any that would be.
18
+
19
+ Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. To let Claude connect as a person, turn on Managed OAuth for the Access application, with its localhost and loopback clients allowed and `https://claude.ai/api/mcp/auth_callback` among its Allowed redirect URIs, as the new Allow Claude to connect guide describes; the `api` path already covers the new MCP server. Then, in your instance's repository, run `npx -p @kurtbruns/kestrel kestrel login <your instance's address>`, take out the service token's two values from `.claude/settings.local.json`, and revoke the token, or keep it for a script.
20
+
21
+ ### Added
22
+
23
+ - Kestrel records the client each change came through, beside the person or service token who made it (SPEC §11, Who acted). A client names itself on each request in a `Kestrel-Client` header, one short lowercase word (the editor sends `editor`, Claude Code `claude-code`); a malformed name is a 400 naming the header, and a request that names none reads as made through the API. `GET /api/whoami` answers `client`, a revision and a post `author_client`, and an import's record `client` in its `principal`; a service token's is always null. Where the editor names who made a change, a person through another client reads with it, such as "you@example.com via Claude Code" or "you@example.com via the API", and a person in the editor by their address alone.
24
+ - A send records every act on it, a schedule or Send now, a reschedule, a cancel, and a Resolve, with who took it (a person with the client they came through, or a service token by its name) and when (SPEC §6, §11). The send view answers them as `scheduled_by`, `rescheduled_by`, `canceled_by`, and `resolved_by`. The send's page shows them, and the scheduled banner names the latest when it wasn't you in the editor, such as "Scheduled by you@example.com via Claude Code", so a send Claude or automation scheduled never reads as one you scheduled yourself.
25
+ - `kestrel login <url>` signs Claude Code in to a deployed instance as you, through Cloudflare Access's Managed OAuth, which you approve in your browser, and `kestrel api <method> <path>` calls the API as you, naming Claude Code as the client, so what Claude changes shows as yours, via Claude Code (SPEC §11). The sign-in is kept in your user config, never in the instance's repository, and renews itself until the Access application's grant session ends; a token request gives up after 10 seconds instead of hanging, and `kestrel logout` forgets it. Outside the instance's repository, run it as `npx -p @kurtbruns/kestrel kestrel …`, since an unrelated npm package is called `kestrel`. `kestrel api --local` calls this folder's dev server as its stand-in person, the same way. `--client` names a script of your own instead, and never the editor, which `kestrel api` refuses. A refusal that signing in again can't fix, an Access login that named no one (`access_unidentified`), says what to change in Access instead. It needs Managed OAuth on for the instance's Access application, as the new Allow Claude to connect guide describes.
26
+ - `kestrel api` describes the API it calls, read from the instance's own OpenAPI document so it matches the release that instance runs: `kestrel api --list [resource]` lists the routes, and `kestrel api help <method> <path>` describes one, its parameters, body fields, answer, and refusals with their next moves. `-f name=value` (a string) and `-F name=value` (typed by the route, or `@file` for a file's text) build a JSON body, or the query string on a GET and for a name the route takes only as a query parameter, and a field the route doesn't take is refused before the request; a body field can't be combined with `--data` or `--form`. A refusal prints its code, message, field, and next move on stderr, and the raw body on stdout. The OpenAPI document names the next move on each refusal any route can give.
27
+ - Claude Desktop and claude.ai can work with an instance as a custom connector, signed in as you through Cloudflare Access's Managed OAuth, at `https://<your instance>/api/mcp` (SPEC §11). It's an MCP server in the Worker with a short set of tools for publishing in a chat: search and read posts, sends, and subscribers; preview a post's email; save a draft; send a test; schedule, reschedule, or cancel a send; import a list; unsubscribe someone; and `kestrel_api_read` for most other reads, never a write or a bulk export. Each tool acts through the API's own routes, so it's refused as the route is, a refusal coming back to Claude with its next move, and none confirms anything for you: an import's attestation waits for you to accept it in the chat, and an import that would grow a scheduled send is yours to finish in the dashboard. Its calls always name their client `claude`, so what Claude changes shows as yours, "via Claude". The publisher's docs come with it, for Claude to read. Erasure, uploads, and the logo stay in the dashboard. On connecting, Claude is told to offer a test once a post is scheduled and to send tests only to your test recipients or an address you give as yours. The publishing docs' Connect Claude page covers adding it in Claude Desktop or claude.ai.
28
+ - The connector's tools also change a post's archive listing or address, delete a draft, add a subscriber by double opt-in, block an address or lift a block or a bounce, resolve a stuck send with the answer you pick, change your settings and email template, and test your notifications (SPEC §11). Claude confirms with you before it deletes a draft, changes a sent post's listing, clears a bounce, saves a new test recipient or notifications address, or re-makes a scheduled send's email.
29
+ - `GET /api/posts/:id/preview.json` answers `text`, the email's plain-text part from the same render as the preview, so a client that can't show the page can read what goes out.
30
+
31
+ ### Changed
32
+
33
+ - The Configuration reference opens with the whole `production` block, then lists each setting once, by name, with whether it's required, its default, and what the app refuses, in place of summary tables and a separate list of refusals.
34
+ - The Docs tab's code blocks are quieter cards with a Copy icon beside the code, which turns to a check when the copy lands, and shell commands and JSON are highlighted. Inline code in the docs is maroon on a darker chip. The copy icon is redrawn everywhere with its two pages evenly offset.
35
+ - The browser tab now names the screen you're on, such as "Subscribers - Kestrel", a post or send by its subject, and a doc by its title, instead of "Kestrel" on every screen, so tabs, bookmarks, and history entries say where they lead. The web version's tab adds "(web version)" after the subject, so it reads apart from the editor's.
36
+ - Every admin `401` now carries a `message`, as every other refusal does, and the API reference and OpenAPI document describe its `reason`, which says when signing in again can't help (SPEC §11).
37
+ - The scheduled banner is quieter: "No test of this email has been sent yet" is now a muted "Not tested yet" beside Send test email, the button that settles it (SPEC §8).
38
+ - A service token now means automation, never Claude: the editor shows its changes as "a service token" (and an import's as "a service token", not "Claude, for you"), the publisher's email about an import a service token commits says a service token made it rather than Claude, and the dashboard says Claude is connected once a post's current revision came through Claude Code or the connector. Service tokens keep working as before. The setup guide's Connect Claude page is now two guides: Allow Claude to connect, which turns on Managed OAuth so a publisher can connect Claude as themselves, and Automate with a service token, the service-token steps. A new Connect Claude page in the publishing docs walks a publisher through adding the connector in Claude Desktop or claude.ai, or signing Claude Code in with the Kestrel CLI, and ends with what to ask Claude and the lines the app holds whatever it's asked, in place of the Working with Claude page. `/llms.txt` and the API reference point an agent to `kestrel login` first (SPEC §11).
39
+ - The operator's Claude context (`template/claude/CLAUDE.md`) has Claude call the API through `kestrel api` instead of curl with a service token's headers, deployed and locally, and run `kestrel login` with the person when they aren't signed in, so Claude's work is recorded as the person's, via Claude Code, rather than a service token's. It also tells Claude never to use a service token in its environment for the person's work, and shows it `kestrel api --list`, `kestrel api help`, and `-f`/`-F`, so it learns a route's body from the instance instead of building JSON by hand. For safety, it also tells Claude never to pass `kestrel api --client`, so a change it makes can't read as made in the editor, where some steps only you may take, and to send a test only to your test recipients or an address you give as yours, asking before any other. It has Claude offer a test once a post is scheduled, since only a test of the scheduled email settles "Not tested yet", and cancel, fix, and schedule again if the test shows a problem. When the instance offers no sign-in for programs, it walks the person through the new Allow Claude to connect guide. Run `npm run check-context` to bring it in.
40
+
41
+ ### Breaking
42
+
43
+ - A Cloudflare Access login that is neither a person (with an email) nor a service token (with its Client ID) is refused with a `401` whose `reason` is `access_unidentified`, where it was admitted as an anonymous service token with full access. The app accepts only the two kinds of Access policy the setup guide describes, an Allow policy on emails and a Service Auth policy on a service token, so another kind added to the application, such as a certificate or device rule, can no longer grant the admin surface to a caller nobody can name. The editor explains the refusal in place of a sign-in, naming those two kinds and offering Sign out, and each refusal logs `auth.unidentified` with the names of the claims it carried (SPEC §11).
44
+ - A commit through the API that would grow a scheduled send's audience is refused `sends_acknowledgement_required` unless it comes through the editor, whoever signed in: a script of yours that commits such an import with your own sign-in now has you finish it in the dashboard, as Claude does (SPEC §7, §11).
45
+
13
46
  ## [1.13.0] - 2026-10-19
14
47
 
15
48
  **Upgrading from 1.12.0.** One database migration, which `npm run sync-migrations` brings in: apply it before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0012_unsubscribe_cause` records why each subscriber left and leaves the suppressions only bounces and blocks. Every unsubscribe before it reads as the reader's own, since the record never said otherwise. Each complaint becomes an unsubscribe by a complaint, and an address it held with no subscriber is recorded as one, unsubscribed and holding only the address. Each erasure's marker becomes a subscriber unsubscribed by an erasure, holding only the address and when, except that someone who signed up again after it stays pending, read as having left by the erasure until they confirm. An erasure that found a bounce or a block already on an address left no trace of itself, so that address keeps its suppression alone. It rebuilds the subscribers and suppressions tables, copying every row once, which counts against the day's database writes on Workers Free like any other write. Deploy straight after applying it: until this release is serving, the previous one fails to record a reader's unsubscribe, a confirmation from someone who once left, an erasure, and a complaint report, which the new tables refuse. SES retries a refused report, so complaints arrive once the deploy lands; a reader whose unsubscribe failed meanwhile has to unsubscribe again. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. Several API changes can break a script of yours, each listed under Breaking; the table gives every renamed route, field, value, and sort key, old to new. A request that still sends an old name is refused with a 400 naming it, never answered as if it had been read: every API route now refuses a body field, a query parameter, or a filter value it doesn't take, so check a script's requests against the API reference before you deploy.
@@ -584,7 +617,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
584
617
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
585
618
  - This changelog and a semantic-versioning release process.
586
619
 
587
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.13.0...HEAD
620
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.14.0...HEAD
621
+ [1.14.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.14.0
588
622
  [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
589
623
  [1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
590
624
  [1.11.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.11.0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kestrel is an open-source newsletter app you run yourself, on Cloudflare and Amazon SES. You own the list, the proof each reader opted in, the record of every send, and a permanent archive of every post.
4
4
 
5
- You write in Markdown, preview the exact email, and schedule it with a window to cancel. Bring your list from another service, and take it out whenever you like. Work in the editor, or ask Claude to do it through the same API.
5
+ You write in Markdown, preview the exact email, and schedule it with a window to cancel. Bring your list from another service, and take it out whenever you like. Work in the editor, or ask Claude to do it through the same API, from Claude Code, Claude Desktop, or claude.ai.
6
6
 
7
7
  See [getkestrel.dev](https://getkestrel.dev) to learn more about Kestrel.
8
8
 
package/bin/kestrel.mjs CHANGED
@@ -6,8 +6,9 @@
6
6
  * bringing a release's migrations and Claude context in, and the demo. This repository's npm scripts run the same commands, so both
7
7
  * use one implementation.
8
8
  *
9
- * Every command acts on the directory it runs in. Seed and reset work only against a local
10
- * dev server, whose dev routes exist nowhere else.
9
+ * Every command acts on the directory it runs in, except the sign-in: login, logout, and api
10
+ * act as the person at this computer, whose sign-ins are theirs rather than a folder's.
11
+ * Seed and reset work only against a local dev server, whose dev routes exist nowhere else.
11
12
  */
12
13
  import { readFileSync } from "node:fs";
13
14
  import { dirname, join } from "node:path";
@@ -29,6 +30,11 @@ Usage: kestrel <command> [options]
29
30
  seed [--size <n>] [port|url] load the demo publication into the local dev server
30
31
  reset [port|url] return the local dev server to a fresh install
31
32
 
33
+ login <url> [--no-browser] sign in to a deployed instance as you, in your browser
34
+ logout [url] sign out of it on this computer
35
+ api <method> <path> [opts] call the API as you, via Claude Code (kestrel api for options)
36
+ api --list | help <m> <path> list the instance's routes, or describe one
37
+
32
38
  help, --help show this
33
39
  version, --version print the installed version`;
34
40
 
@@ -53,6 +59,15 @@ switch (command) {
53
59
  case "reset":
54
60
  await (await import("../cli/reset.mjs")).resetCommand(argv);
55
61
  break;
62
+ case "login":
63
+ await (await import("../cli/login.mjs")).loginCommand(argv);
64
+ break;
65
+ case "logout":
66
+ await (await import("../cli/login.mjs")).logoutCommand(argv);
67
+ break;
68
+ case "api":
69
+ await (await import("../cli/api.mjs")).apiCommand(argv);
70
+ break;
56
71
  case "version":
57
72
  case "--version":
58
73
  case "-v":
package/cli/api.mjs ADDED
@@ -0,0 +1,462 @@
1
+ /*
2
+ * `kestrel api`: call an instance's API as the person signed in, naming the program the
3
+ * request came through, the way Claude Code reaches Kestrel (SPEC §11, Who acted).
4
+ *
5
+ * Deployed, it sends the access token `kestrel login` stored, refreshing it from the
6
+ * refresh token when it is about to expire or Access has turned it away, and storing what
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).
13
+ *
14
+ * It prints the answer's body as it came, so a script or Claude reads a refusal whole; a
15
+ * non-2xx answer also prints its status, and a refusal's code, message, field, and next move,
16
+ * on stderr and exits 1, as curl --fail-with-body.
17
+ *
18
+ * What the API is comes from the instance itself: `--list` and `help` read its OpenAPI
19
+ * document through the same sign-in, as `-f` and `-F` do to build a body and refuse a field
20
+ * the route doesn't take (cli/openapi.mjs). It is read fresh each time it is needed, not
21
+ * cached, so it can never describe a release the instance no longer runs.
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import { basename } from "node:path";
25
+ import { readSignIn, readSignIns, withCredentialsLock, writeSignInHeld } from "./credentials.mjs";
26
+ import { devTarget } from "./dev-api.mjs";
27
+ import {
28
+ DEFAULT_CLIENT,
29
+ instanceOrigin,
30
+ isLoopback,
31
+ needsRefresh,
32
+ refreshTokens,
33
+ SignInError,
34
+ tokenFields,
35
+ } from "./oauth.mjs";
36
+ import {
37
+ buildFields,
38
+ describeRefusal,
39
+ describeRoute,
40
+ findOperation,
41
+ listRoutes,
42
+ } from "./openapi.mjs";
43
+
44
+ const USAGE = `Usage: kestrel api <method> <path> [options]
45
+ kestrel api --list [resource] the instance's routes, or one resource's
46
+ kestrel api help <method> <path> one route: its fields, refusals, and answer
47
+
48
+ -f, --raw-field <name=value> a body field, as a string; repeatable. On a GET, or for a name
49
+ the route takes only as one, a query parameter
50
+ -F, --field <name=value> a body field typed by the route's schema (true, 3, null, JSON),
51
+ or name=@file for a file's text. name.sub nests; name[] lists
52
+ --data <json|@file|-> the request body: inline, from a file, or from stdin
53
+ --type <media type> the body's type (default application/json)
54
+ --form <name=value> a multipart field, repeatable; name=@file attaches a file
55
+ --url <url> the instance (default: KESTREL_URL, else your one sign-in)
56
+ --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
59
+
60
+ The routes, fields, and refusals come from the instance's own /api/openapi.json, so they
61
+ match the release it runs. A field the route doesn't take is refused before the request.`;
62
+
63
+ // The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11).
64
+ const EDITOR_CLIENT = "editor";
65
+
66
+ // The server's rule for a client name (shared/who_acted.ts), mirrored since the CLI is plain
67
+ // Node; a spec holds the two equal. Checking it here, after the trim fetch applies to a header
68
+ // value, means the name refused is the one the server would have read.
69
+ export const CLIENT_NAME_MAX = 32;
70
+ export const CLIENT_NAME_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
71
+
72
+ const ONE_WAY = "Send the body one way: --data, --form, or -f and -F fields, not two.";
73
+
74
+ // Options that take a value, by the name they are stored under.
75
+ const VALUED = {
76
+ data: "data",
77
+ type: "type",
78
+ url: "url",
79
+ client: "client",
80
+ form: "forms",
81
+ "raw-field": "fields",
82
+ f: "fields",
83
+ field: "fields",
84
+ F: "fields",
85
+ };
86
+
87
+ /**
88
+ * Read `kestrel api`'s argv. A call is a method and a path, `--form`, `-f`, and `-F`
89
+ * repeatable; `--list [resource]`, `help`, and `help <resource>` list the routes, and
90
+ * `help <method> <path>` describes one. The body is sent one way: `--data`, `--form`, or
91
+ * fields, never two at once, since merging them would leave a reader guessing which won; a
92
+ * field that is a query parameter may go beside `--data` or `--form`.
93
+ */
94
+ export function parseApiArgs(argv) {
95
+ const opts = { forms: [], fields: [], local: false, list: false };
96
+ const positional = [];
97
+ for (let i = 0; i < argv.length; i++) {
98
+ const arg = argv[i];
99
+ const eq = /^--([^=]+)=(.*)$/s.exec(arg);
100
+ const short = /^-([A-Za-z])$/.exec(arg);
101
+ const name = eq ? eq[1] : short ? short[1] : arg.startsWith("--") ? arg.slice(2) : undefined;
102
+ if (name === undefined) {
103
+ positional.push(arg);
104
+ continue;
105
+ }
106
+ if (name === "local" || name === "list") {
107
+ // `--list=sends` names its resource as `--list sends` does; --local names nothing.
108
+ if (eq && name === "local") {
109
+ throw new SignInError(`--local takes no value; to name a server, use --url <url>.`);
110
+ }
111
+ if (eq) {
112
+ positional.push(eq[2]);
113
+ }
114
+ opts[name] = true;
115
+ continue;
116
+ }
117
+ const key = VALUED[name];
118
+ const flag = short ? `-${name}` : `--${name}`;
119
+ if (!key) {
120
+ throw new SignInError(`unknown option ${flag}\n\n${USAGE}`);
121
+ }
122
+ const value = eq ? eq[2] : argv[++i];
123
+ if (value === undefined) {
124
+ throw new SignInError(`${flag} needs a value\n\n${USAGE}`);
125
+ }
126
+ if (key === "fields") {
127
+ opts.fields.push({ typed: name === "F" || name === "field", text: value });
128
+ } else if (key === "forms") {
129
+ opts.forms.push(value);
130
+ } else {
131
+ opts[key] = value;
132
+ }
133
+ }
134
+ if (opts.client !== undefined) {
135
+ opts.client = opts.client.trim();
136
+ if (
137
+ opts.client.length > CLIENT_NAME_MAX ||
138
+ !new RegExp(CLIENT_NAME_PATTERN).test(opts.client)
139
+ ) {
140
+ throw new SignInError(
141
+ `--client takes one short lowercase word, its parts joined by hyphens (my-script), up to ${CLIENT_NAME_MAX} characters.`,
142
+ );
143
+ }
144
+ if (opts.client === EDITOR_CLIENT) {
145
+ throw new SignInError(
146
+ "--client editor is refused: only the editor names itself the editor, since some acts only a person in the editor may confirm. Name your own program, or leave --client out.",
147
+ );
148
+ }
149
+ }
150
+ const { list, ...rest } = opts;
151
+ const bodies = [opts.data !== undefined, opts.forms.length > 0, opts.fields.length > 0];
152
+ const help = positional[0] === "help";
153
+ if (list || help) {
154
+ const args = help ? positional.slice(1) : positional;
155
+ if (bodies.some(Boolean)) {
156
+ throw new SignInError("--list and help send nothing, so they take no body or fields.");
157
+ }
158
+ if (args.length === 2 && help) {
159
+ const [method, path] = args;
160
+ return { mode: "help", method: method.toUpperCase(), path, ...rest };
161
+ }
162
+ if (args.length > 1) {
163
+ throw new SignInError(USAGE);
164
+ }
165
+ return { mode: "list", resource: args[0], ...rest };
166
+ }
167
+ const [method, path, ...extra] = positional;
168
+ if (!method || !path || extra.length) {
169
+ throw new SignInError(USAGE);
170
+ }
171
+ if (!path.startsWith("/")) {
172
+ throw new SignInError(`The path starts with a slash, as in /api/posts.\n\n${USAGE}`);
173
+ }
174
+ // Fields may go with --data or --form only as query parameters, which apiCommand checks
175
+ // once it knows where each field goes.
176
+ if (bodies[0] && bodies[1]) {
177
+ throw new SignInError(ONE_WAY);
178
+ }
179
+ return { mode: "call", method: method.toUpperCase(), path, ...rest };
180
+ }
181
+
182
+ /**
183
+ * Which instance a call goes to: `--url`, then `KESTREL_URL`, then the one instance this
184
+ * person is signed in to. Several sign-ins and nothing naming one is a refusal, not a guess.
185
+ */
186
+ export function chooseInstance({ url, env = process.env, signIns }) {
187
+ if (url) {
188
+ return instanceOrigin(url);
189
+ }
190
+ if (env.KESTREL_URL) {
191
+ return instanceOrigin(env.KESTREL_URL);
192
+ }
193
+ const origins = Object.keys(signIns);
194
+ if (origins.length === 1) {
195
+ return origins[0];
196
+ }
197
+ if (origins.length === 0) {
198
+ throw new SignInError(
199
+ "You aren't signed in to an instance. Run `npx kestrel login <url>` first, or --local for this directory's dev server.",
200
+ );
201
+ }
202
+ throw new SignInError(
203
+ `You're signed in to ${origins.length} instances (${origins.join(", ")}). Name one with --url or KESTREL_URL.`,
204
+ );
205
+ }
206
+
207
+ /** The request body and its headers, from --data, --type, and --form. */
208
+ async function requestBody({ data, type, forms }) {
209
+ if (forms.length) {
210
+ const form = new FormData();
211
+ for (const field of forms) {
212
+ const at = field.indexOf("=");
213
+ if (at < 1) {
214
+ throw new SignInError(`--form takes name=value or name=@file, not "${field}".`);
215
+ }
216
+ const name = field.slice(0, at);
217
+ const value = field.slice(at + 1);
218
+ if (value.startsWith("@")) {
219
+ const file = value.slice(1);
220
+ form.append(name, new Blob([readFileSync(file)]), basename(file));
221
+ } else {
222
+ form.append(name, value);
223
+ }
224
+ }
225
+ return { body: form, headers: {} };
226
+ }
227
+ if (data === undefined) {
228
+ return { body: undefined, headers: {} };
229
+ }
230
+ let body;
231
+ if (data === "-") {
232
+ const chunks = [];
233
+ for await (const chunk of process.stdin) {
234
+ chunks.push(chunk);
235
+ }
236
+ body = Buffer.concat(chunks);
237
+ } else if (data.startsWith("@")) {
238
+ body = readFileSync(data.slice(1));
239
+ } else {
240
+ body = data;
241
+ }
242
+ return { body, headers: { "content-type": type ?? "application/json" } };
243
+ }
244
+
245
+ /**
246
+ * A usable access token for `origin`'s stored sign-in, refreshed first when it is about to
247
+ * expire, or when `force` says Access just turned it away. The refresh runs under the
248
+ * credentials lock, and a call that waited there uses the token the other stored, so one
249
+ * refresh token is never spent twice. A grant that has ended leaves the person to sign in
250
+ * again.
251
+ */
252
+ export async function accessToken(origin, { force = false, env = process.env } = {}) {
253
+ const ended = `Your sign-in to ${origin} has ended. Run \`npx kestrel login ${origin}\` again.`;
254
+ const entry = readSignIn(origin, env);
255
+ if (!entry) {
256
+ throw new SignInError(
257
+ `You aren't signed in to ${origin}. Run \`npx kestrel login ${origin}\`.`,
258
+ );
259
+ }
260
+ if (!force && !needsRefresh(entry)) {
261
+ return entry.access_token;
262
+ }
263
+ // The token Access just refused, when it did: a stored token equal to it is no use.
264
+ const refused = force ? entry.access_token : undefined;
265
+ return withCredentialsLock(async () => {
266
+ const now = readSignIn(origin, env);
267
+ if (!now) {
268
+ throw new SignInError(
269
+ `You aren't signed in to ${origin}. Run \`npx kestrel login ${origin}\`.`,
270
+ );
271
+ }
272
+ if (!needsRefresh(now) && now.access_token !== refused) {
273
+ return now.access_token;
274
+ }
275
+ if (!now.refresh_token) {
276
+ throw new SignInError(ended);
277
+ }
278
+ try {
279
+ const fields = tokenFields(await refreshTokens(now), now);
280
+ writeSignInHeld(origin, { ...now, ...fields }, env);
281
+ return fields.access_token;
282
+ } catch (err) {
283
+ if (err instanceof SignInError && err.oauthError === "invalid_grant") {
284
+ throw new SignInError(ended);
285
+ }
286
+ throw err;
287
+ }
288
+ }, env);
289
+ }
290
+
291
+ /**
292
+ * Call `origin`'s API as the signed-in person: one retry after a refresh when Access answers
293
+ * 401, since a token can be revoked or shortened before its stated expiry. Answers the
294
+ * Response.
295
+ */
296
+ export async function signedInFetch(origin, path, { method = "GET", body, headers = {}, client }) {
297
+ const send = async (token) =>
298
+ fetch(`${origin}${path}`, {
299
+ method,
300
+ body,
301
+ redirect: "manual",
302
+ headers: {
303
+ accept: "application/json",
304
+ ...headers,
305
+ "kestrel-client": client,
306
+ authorization: `Bearer ${token}`,
307
+ },
308
+ });
309
+ let res = await send(await accessToken(origin));
310
+ // Access's own refusal names its OAuth metadata; the app's 401 doesn't, and a fresh token
311
+ // wouldn't change it. A streamed body (stdin) can't be sent twice, but it is read whole.
312
+ if (res.status === 401 && /resource_metadata=/.test(res.headers.get("www-authenticate") ?? "")) {
313
+ res = await send(await accessToken(origin, { force: true }));
314
+ }
315
+ return res;
316
+ }
317
+
318
+ /**
319
+ * How a run reaches its instance: a function that sends one request there and answers the
320
+ * Response. A loopback address, given or in KESTREL_URL, is a dev server, which has no sign-in:
321
+ * its own human token is minted as the editor's is (SPEC §10). Anything else is the stored
322
+ * sign-in, refreshed as it needs.
323
+ */
324
+ async function openTarget(args, client) {
325
+ const named = args.url || process.env.KESTREL_URL || undefined;
326
+ if (args.local || (named && isLoopback(instanceOrigin(named)))) {
327
+ const { base, token } = await devTarget(
328
+ args.local ? undefined : instanceOrigin(named),
329
+ "api",
330
+ "human",
331
+ );
332
+ return (path, { method = "GET", body, headers = {} } = {}) =>
333
+ fetch(`${base}${path}`, {
334
+ method,
335
+ body,
336
+ headers: {
337
+ accept: "application/json",
338
+ ...headers,
339
+ "kestrel-client": client,
340
+ authorization: `Bearer ${token}`,
341
+ },
342
+ });
343
+ }
344
+ const origin = chooseInstance({ url: args.url, signIns: readSignIns() });
345
+ return (path, { method = "GET", body, headers = {} } = {}) =>
346
+ signedInFetch(origin, path, { method, body, headers, client });
347
+ }
348
+
349
+ /** The instance's OpenAPI document, read through `call`, or a refusal saying why there is none. */
350
+ export async function readOpenApi(call) {
351
+ const res = await call("/api/openapi.json");
352
+ const text = await res.text();
353
+ if (res.status === 404) {
354
+ throw new SignInError(
355
+ "This instance serves no /api/openapi.json, so its routes can't be listed or checked: it runs a release older than this CLI's. Send a body with --data instead.",
356
+ );
357
+ }
358
+ let doc;
359
+ try {
360
+ doc = JSON.parse(text);
361
+ } catch {
362
+ doc = undefined;
363
+ }
364
+ if (!res.ok || !doc?.paths) {
365
+ const said = typeof doc?.message === "string" ? `: ${doc.message}` : "";
366
+ throw new SignInError(
367
+ `Couldn't read the instance's /api/openapi.json (HTTP ${res.status})${said}`,
368
+ );
369
+ }
370
+ return doc;
371
+ }
372
+
373
+ /** A call's path with `query`'s pairs appended to whatever query string it already has. */
374
+ function withQuery(path, query) {
375
+ if (!query.length) {
376
+ return path;
377
+ }
378
+ const params = new URLSearchParams(query);
379
+ return `${path}${path.includes("?") ? "&" : "?"}${params}`;
380
+ }
381
+
382
+ /**
383
+ * `kestrel api`: list the routes, describe one, or call one. A call prints the answer's body
384
+ * on stdout as it came; on a non-2xx answer it also prints the status and, for a refusal, its
385
+ * code, message, field, and next move on stderr, and exits 1.
386
+ */
387
+ export async function apiCommand(argv) {
388
+ let res;
389
+ let doc;
390
+ let found;
391
+ let call;
392
+ try {
393
+ const args = parseApiArgs(argv);
394
+ const client = args.client ?? DEFAULT_CLIENT;
395
+ call = await openTarget(args, client);
396
+ if (args.mode === "list") {
397
+ console.log(listRoutes(await readOpenApi(call), args.resource));
398
+ return;
399
+ }
400
+ if (args.mode === "help") {
401
+ console.log(describeRoute(await readOpenApi(call), args.method, args.path));
402
+ return;
403
+ }
404
+ let path = args.path;
405
+ let { body, headers } = await requestBody(args);
406
+ if (args.fields.length) {
407
+ doc = await readOpenApi(call);
408
+ found = findOperation(doc, args.method, path);
409
+ if (!found) {
410
+ throw new SignInError(
411
+ `This instance has no route ${args.method} ${path.split("?")[0]}, so its fields can't be checked. \`kestrel api --list\` lists them.`,
412
+ );
413
+ }
414
+ const built = buildFields(doc, found, args.fields);
415
+ path = withQuery(path, built.query);
416
+ if (built.body && body !== undefined) {
417
+ throw new SignInError(ONE_WAY);
418
+ }
419
+ if (built.body) {
420
+ body = JSON.stringify(built.body);
421
+ headers = { "content-type": "application/json" };
422
+ }
423
+ }
424
+ res = await call(path, { method: args.method, body, headers });
425
+ } catch (err) {
426
+ console.error(`kestrel api: ${err instanceof Error ? err.message : String(err)}`);
427
+ process.exit(1);
428
+ }
429
+ const text = await res.text();
430
+ if (text) {
431
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
432
+ }
433
+ if (!res.ok) {
434
+ console.error(`kestrel api: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`);
435
+ for (const line of (await refusalLines(text, { doc, found, call })) ?? []) {
436
+ console.error(` ${line}`);
437
+ }
438
+ process.exit(1);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * A refusal's readable lines, its next move from the instance's document (read now if the
444
+ * call didn't already), or undefined when the body isn't a refusal. A document that can't be
445
+ * read only leaves the move out: the refusal itself is already printed.
446
+ */
447
+ async function refusalLines(text, { doc, found, call }) {
448
+ let body;
449
+ try {
450
+ body = JSON.parse(text);
451
+ } catch {
452
+ return undefined;
453
+ }
454
+ if (typeof body?.error !== "string") {
455
+ return undefined;
456
+ }
457
+ let known = doc;
458
+ if (!known) {
459
+ known = await readOpenApi(call).catch(() => undefined);
460
+ }
461
+ return describeRefusal(body, { doc: known, op: found?.op });
462
+ }