@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
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Where `kestrel login` keeps a person's sign-ins, and how `kestrel api` reads them back.
3
+ *
4
+ * A sign-in is the person's, not an instance repository's: it is made in their browser,
5
+ * through Cloudflare Access, as them, and it reaches every instance folder or none. So it
6
+ * lives in their user config directory, never in the folder a command runs in, where a
7
+ * commit or a shared worktree could carry it off: `$XDG_CONFIG_HOME/kestrel/credentials.json`,
8
+ * `~/.config/kestrel/credentials.json` when that is unset, or `%APPDATA%\kestrel\` on
9
+ * Windows. `KESTREL_CONFIG_DIR` names another directory, which the suite uses.
10
+ *
11
+ * One JSON file, keyed by the instance's origin, readable by its owner alone (0600, in a
12
+ * 0700 directory), written whole through a temporary file and a rename, so a reader never
13
+ * sees half of it. Each entry holds what the OAuth client needs to go on without the
14
+ * browser: the authorization server's endpoints, the client registered for this computer,
15
+ * the access token with its expiry, and the refresh token, which lasts as long as the
16
+ * Access application's grant session. A file that exists but isn't one this release can
17
+ * read is refused, never rewritten, since rewriting it would drop every sign-in it holds.
18
+ *
19
+ * A refresh token may be single-use, and Claude Code runs commands side by side, so a refresh
20
+ * happens under a lock file beside the credentials: whoever waited reads the token the other
21
+ * stored rather than spending the same refresh token twice. Every write to the file happens
22
+ * under that lock too, since a write reads the whole file and puts it back, and two at once
23
+ * would lose one entry's change.
24
+ */
25
+ import { randomBytes } from "node:crypto";
26
+ import {
27
+ chmodSync,
28
+ closeSync,
29
+ mkdirSync,
30
+ openSync,
31
+ readFileSync,
32
+ renameSync,
33
+ rmSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { join } from "node:path";
39
+
40
+ const VERSION = 1;
41
+
42
+ /** The directory the sign-ins live in, from the environment as described above. */
43
+ export function configDir(env = process.env, platform = process.platform) {
44
+ if (env.KESTREL_CONFIG_DIR) {
45
+ return env.KESTREL_CONFIG_DIR;
46
+ }
47
+ if (platform === "win32" && env.APPDATA) {
48
+ return join(env.APPDATA, "kestrel");
49
+ }
50
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "kestrel");
51
+ }
52
+
53
+ /** The credentials file's path. */
54
+ export function credentialsFile(env = process.env) {
55
+ return join(configDir(env), "credentials.json");
56
+ }
57
+
58
+ /** Every stored sign-in, keyed by origin; empty when there is no file yet. Throws, naming
59
+ * the file, when it exists and can't be read as this release's. */
60
+ export function readSignIns(env = process.env) {
61
+ const file = credentialsFile(env);
62
+ let text;
63
+ try {
64
+ text = readFileSync(file, "utf8");
65
+ } catch (err) {
66
+ if (err?.code === "ENOENT") {
67
+ return {};
68
+ }
69
+ throw new Error(`Couldn't read the sign-ins in ${file}: ${err?.message ?? err}`);
70
+ }
71
+ let data;
72
+ try {
73
+ data = JSON.parse(text);
74
+ } catch {
75
+ data = undefined;
76
+ }
77
+ if (data?.version === VERSION && data.instances && typeof data.instances === "object") {
78
+ return data.instances;
79
+ }
80
+ throw new Error(
81
+ `${file} isn't a sign-ins file this release of Kestrel can read, so it was left as it is. Move it aside, then run \`npx kestrel login <url>\` again.`,
82
+ );
83
+ }
84
+
85
+ /** The stored sign-in for one origin, or undefined. */
86
+ export function readSignIn(origin, env = process.env) {
87
+ return readSignIns(env)[origin];
88
+ }
89
+
90
+ /**
91
+ * Store (or, with undefined, forget) the sign-in for one origin, leaving the others. Takes
92
+ * the credentials lock, so call it outside `withCredentialsLock`; inside one, use
93
+ * `writeSignInHeld`.
94
+ */
95
+ export function writeSignIn(origin, entry, env = process.env) {
96
+ return withCredentialsLock(() => writeSignInHeld(origin, entry, env), env);
97
+ }
98
+
99
+ /** `writeSignIn` for a caller already inside `withCredentialsLock`, which taking it again would deadlock. */
100
+ export function writeSignInHeld(origin, entry, env = process.env) {
101
+ const instances = { ...readSignIns(env) };
102
+ if (entry === undefined) {
103
+ delete instances[origin];
104
+ } else {
105
+ instances[origin] = entry;
106
+ }
107
+ const dir = configDir(env);
108
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
109
+ const file = credentialsFile(env);
110
+ const tmp = `${file}.${process.pid}.tmp`;
111
+ writeFileSync(tmp, `${JSON.stringify({ version: VERSION, instances }, null, 2)}\n`, {
112
+ mode: 0o600,
113
+ });
114
+ // The mode on create is masked by umask, so set it outright before the file takes its name.
115
+ chmodSync(tmp, 0o600);
116
+ renameSync(tmp, file);
117
+ }
118
+
119
+ // How long a lock is waited for, and when one left behind by a crashed process is taken over.
120
+ /** How long a waiter waits for the lock: past the longest a live holder takes (the token request's timeout), under the staleness rule. */
121
+ export const LOCK_WAIT_MS = 25_000;
122
+ /** A lock older than this is taken over; the token request's timeout stays under it, so a live holder never outlasts it. */
123
+ export const LOCK_STALE_MS = 30_000;
124
+
125
+ /**
126
+ * Run `fn` holding the credentials lock, so only one process refreshes at a time. A lock
127
+ * older than any refresh takes is a crashed process's, and is taken over. The lock file holds
128
+ * its owner's token, and the release removes the file only while it still holds that token,
129
+ * so a holder that outlived the staleness rule never deletes the lock of whoever took over.
130
+ */
131
+ export async function withCredentialsLock(fn, env = process.env) {
132
+ mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
133
+ const lock = `${credentialsFile(env)}.lock`;
134
+ const deadline = Date.now() + LOCK_WAIT_MS;
135
+ const token = `${process.pid}:${randomBytes(8).toString("hex")}`;
136
+ let held = false;
137
+ while (!held) {
138
+ try {
139
+ const fd = openSync(lock, "wx", 0o600);
140
+ try {
141
+ writeFileSync(fd, token);
142
+ } finally {
143
+ closeSync(fd);
144
+ }
145
+ held = true;
146
+ } catch (err) {
147
+ if (err?.code !== "EEXIST") {
148
+ throw err;
149
+ }
150
+ let age = 0;
151
+ let seen;
152
+ try {
153
+ seen = readFileSync(lock, "utf8");
154
+ age = Date.now() - statSync(lock).mtimeMs;
155
+ } catch {
156
+ continue; // released between the open and the read: try again
157
+ }
158
+ if (age > LOCK_STALE_MS) {
159
+ // Remove it only if it is still the lock we judged stale: another waiter may have
160
+ // taken it over since, and its fresh lock is not ours to delete.
161
+ let now;
162
+ try {
163
+ now = readFileSync(lock, "utf8");
164
+ } catch {
165
+ continue;
166
+ }
167
+ if (now === seen) {
168
+ rmSync(lock, { force: true });
169
+ }
170
+ } else if (Date.now() > deadline) {
171
+ throw new Error(
172
+ `Another kestrel command has held ${lock} for ${Math.round(LOCK_WAIT_MS / 1000)} seconds. It may still be running; try again in a minute. A lock left by a crashed command clears itself after ${Math.round(LOCK_STALE_MS / 1000)} seconds.`,
173
+ );
174
+ } else {
175
+ await new Promise((resolve) => setTimeout(resolve, 50));
176
+ }
177
+ }
178
+ }
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ let owner;
183
+ try {
184
+ owner = readFileSync(lock, "utf8");
185
+ } catch {
186
+ // already gone: nothing of ours to remove
187
+ }
188
+ if (owner === token) {
189
+ rmSync(lock, { force: true });
190
+ }
191
+ }
192
+ }
package/cli/dev-api.mjs CHANGED
@@ -68,12 +68,18 @@ export function ownsServer(target, reportedId) {
68
68
  /**
69
69
  * The dev server to act on and a local admin token for it, `{ base, token }`, or exit saying
70
70
  * why there is none to act on (see `chooseTarget`). `arg` is the command's port or URL
71
- * argument, if any; `tag` names the command, as in `npm run <tag>`.
71
+ * argument, if any; `tag` names the command, as in `npm run <tag>`. `kind` is the token's
72
+ * principal: `service` for a script, as seed and reset are, and `human` for a person working
73
+ * through a client, as `kestrel api` is (SPEC §11, Who acted).
72
74
  */
73
- export async function devTarget(arg, tag) {
75
+ export async function devTarget(arg, tag, kind = "service") {
74
76
  const target = chooseTarget({ arg, record: readDevServer() });
77
+ const naming =
78
+ tag === "api"
79
+ ? "`npx kestrel api --url http://localhost:<port> …`"
80
+ : `\`npm run ${tag} -- <port>\``;
75
81
  const deliberately = [
76
- `To act on a server deliberately, name its port: \`npm run ${tag} -- <port>\`.`,
82
+ `To act on a server deliberately, name its port: ${naming}.`,
77
83
  ...(process.env.PORT
78
84
  ? [
79
85
  `(PORT=${process.env.PORT} is set, but PORT doesn't name a target here: a shell can carry it unawares.)`,
@@ -88,7 +94,7 @@ export async function devTarget(arg, tag) {
88
94
  ...deliberately,
89
95
  );
90
96
  }
91
- const { token, runId } = await devToken(target.base, tag);
97
+ const { token, runId } = await devToken(target.base, tag, kind);
92
98
  if (!ownsServer(target, runId)) {
93
99
  fail(
94
100
  tag,
@@ -101,7 +107,7 @@ export async function devTarget(arg, tag) {
101
107
  );
102
108
  }
103
109
  if (target.explicit) {
104
- console.log(`[${tag}] acting on ${target.base}, as named`);
110
+ console.error(`[${tag}] acting on ${target.base}, as named`);
105
111
  }
106
112
  return { base: target.base, token };
107
113
  }
@@ -116,13 +122,13 @@ export function fail(tag, ...lines) {
116
122
  process.exit(1);
117
123
  }
118
124
 
119
- /** Mint a local admin token, a `service` principal as Claude is in production, with the run
120
- * ID the server reports (undefined when it reports none), as `{ token, runId }`, or exit
121
- * saying why the server can't give one. */
122
- async function devToken(base, tag) {
125
+ /** Mint a local admin token for a principal of `kind`, with the run ID the server reports
126
+ * (undefined when it reports none), as `{ token, runId }`, or exit saying why the server
127
+ * can't give one. */
128
+ async function devToken(base, tag, kind) {
123
129
  let res;
124
130
  try {
125
- res = await fetch(`${base}/api/dev/token?kind=service`);
131
+ res = await fetch(`${base}/api/dev/token?kind=${kind}`);
126
132
  } catch (err) {
127
133
  fail(
128
134
  tag,
package/cli/login.mjs ADDED
@@ -0,0 +1,155 @@
1
+ /*
2
+ * `kestrel login` and `kestrel logout`: sign this computer in to a deployed instance as the
3
+ * person at it, through Cloudflare Access's Managed OAuth, and forget that sign-in again.
4
+ *
5
+ * The person approves the sign-in in their browser, through the same Access policy the
6
+ * editor's sign-in passes, so Claude Code working through `kestrel api` afterwards is that
7
+ * person, not a token of its own (SPEC §11). The OAuth steps are cli/oauth.mjs, and where
8
+ * the result is kept is cli/credentials.mjs.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { signedInFetch } from "./api.mjs";
13
+ import { credentialsFile, readSignIn, readSignIns, writeSignIn } from "./credentials.mjs";
14
+ import {
15
+ authorizeUrl,
16
+ DEFAULT_CLIENT,
17
+ discover,
18
+ exchangeCode,
19
+ instanceOrigin,
20
+ isLoopback,
21
+ listenForCode,
22
+ PROTECTED_PATH,
23
+ pkcePair,
24
+ registerClient,
25
+ revokeTokens,
26
+ SignInError,
27
+ tokenFields,
28
+ } from "./oauth.mjs";
29
+ import { unauthorizedMove } from "./openapi.mjs";
30
+
31
+ /** Open `url` in the person's browser, best effort: the URL is printed either way. */
32
+ function openBrowser(url) {
33
+ const [command, args] =
34
+ process.platform === "darwin"
35
+ ? ["open", [url]]
36
+ : process.platform === "win32"
37
+ ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]]
38
+ : ["xdg-open", [url]];
39
+ try {
40
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
41
+ child.on("error", () => {});
42
+ child.unref();
43
+ } catch {
44
+ /* no browser to open: the printed URL still works */
45
+ }
46
+ }
47
+
48
+ /** Sign in to `origin`, store the result, and answer who it signed in as. */
49
+ export async function signIn(origin, { browser }) {
50
+ const found = await discover(origin);
51
+ const stored = readSignIn(origin);
52
+ // A client registered for this computer is reused while its redirect can be served again,
53
+ // so a sign-in doesn't leave a new registration in Access each time.
54
+ const reuse =
55
+ stored?.client_id && stored.registration_endpoint === found.registration_endpoint
56
+ ? stored
57
+ : undefined;
58
+ const state = randomBytes(16).toString("base64url");
59
+ const listener = await listenForCode({
60
+ port: reuse ? Number(new URL(reuse.redirect_uri).port) : undefined,
61
+ state,
62
+ });
63
+ try {
64
+ const clientId =
65
+ reuse && reuse.redirect_uri === listener.redirectUri
66
+ ? reuse.client_id
67
+ : await registerClient(found.registration_endpoint, listener.redirectUri);
68
+ const entry = { ...found, client_id: clientId, redirect_uri: listener.redirectUri };
69
+ const { verifier, challenge } = pkcePair();
70
+ const url = authorizeUrl({
71
+ endpoint: found.authorization_endpoint,
72
+ clientId,
73
+ redirectUri: listener.redirectUri,
74
+ challenge,
75
+ state,
76
+ resource: found.resource,
77
+ });
78
+ console.log(`Approve the sign-in to ${origin} in your browser.`);
79
+ console.log(`If it didn't open, go to:\n\n ${url}\n`);
80
+ if (browser) {
81
+ openBrowser(url);
82
+ }
83
+ const code = await listener.code;
84
+ const fields = tokenFields(await exchangeCode(entry, { code, verifier }));
85
+ await writeSignIn(origin, { ...entry, ...fields });
86
+ } finally {
87
+ listener.close();
88
+ }
89
+ const res = await signedInFetch(origin, PROTECTED_PATH, { client: DEFAULT_CLIENT });
90
+ const body = await res.json().catch(() => null);
91
+ if (!res.ok) {
92
+ const move = unauthorizedMove(body);
93
+ throw new SignInError(
94
+ move
95
+ ? `Signed in to Access, but ${origin}${PROTECTED_PATH} answered ${res.status} (${body.reason}). ${move}.`
96
+ : `Signed in to Access, but ${origin}${PROTECTED_PATH} answered ${res.status}${body?.message ? `: ${body.message}` : ""}.`,
97
+ );
98
+ }
99
+ return body?.principal;
100
+ }
101
+
102
+ /** `kestrel login <url> [--no-browser]`. */
103
+ export async function loginCommand(argv) {
104
+ const browser = !argv.includes("--no-browser");
105
+ const urls = argv.filter((a) => a !== "--no-browser");
106
+ try {
107
+ if (urls.length !== 1) {
108
+ throw new SignInError("Usage: kestrel login <url> [--no-browser]");
109
+ }
110
+ const origin = instanceOrigin(urls[0]);
111
+ if (isLoopback(origin)) {
112
+ throw new SignInError(
113
+ "A local dev server needs no sign-in: `npx kestrel api --local …` calls it as you.",
114
+ );
115
+ }
116
+ const principal = await signIn(origin, { browser });
117
+ const who = principal?.email ?? principal?.kind ?? "someone";
118
+ console.log(`Signed in to ${origin} as ${who}.`);
119
+ console.log(
120
+ `Stored in ${credentialsFile()}. \`npx kestrel api GET /api/posts\` calls it as you.`,
121
+ );
122
+ } catch (err) {
123
+ console.error(`kestrel login: ${err instanceof Error ? err.message : String(err)}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+
128
+ /** `kestrel logout [url]`: revoke and forget one sign-in, or the only one stored. */
129
+ export async function logoutCommand(argv) {
130
+ try {
131
+ const origins = Object.keys(readSignIns());
132
+ const origin = argv[0] ? instanceOrigin(argv[0]) : origins.length === 1 ? origins[0] : null;
133
+ if (!origin) {
134
+ throw new SignInError(
135
+ origins.length
136
+ ? `You're signed in to ${origins.join(", ")}. Name the one to sign out of.`
137
+ : "You aren't signed in to any instance.",
138
+ );
139
+ }
140
+ const entry = readSignIn(origin);
141
+ if (!entry) {
142
+ throw new SignInError(`You aren't signed in to ${origin}.`);
143
+ }
144
+ const revoked = await revokeTokens(entry);
145
+ await writeSignIn(origin, undefined);
146
+ console.log(
147
+ revoked
148
+ ? `Signed out of ${origin}, and Access revoked the sign-in.`
149
+ : `Signed out of ${origin} on this computer. Access didn't confirm revoking it; it ends with the Access application's grant session.`,
150
+ );
151
+ } catch (err) {
152
+ console.error(`kestrel logout: ${err instanceof Error ? err.message : String(err)}`);
153
+ process.exit(1);
154
+ }
155
+ }