@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.
- package/CHANGELOG.md +35 -1
- package/README.md +1 -1
- package/bin/kestrel.mjs +17 -2
- package/cli/api.mjs +462 -0
- package/cli/credentials.mjs +192 -0
- package/cli/dev-api.mjs +16 -10
- package/cli/login.mjs +155 -0
- package/cli/oauth.mjs +368 -0
- package/cli/openapi.mjs +569 -0
- package/dist/public/dashboard/app.js +290 -285
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +74 -40
- package/dist/worker/index.js +2723 -1192
- package/docs/README.md +3 -2
- package/docs/get-started/02-deploy.md +17 -20
- package/docs/get-started/03-access.md +10 -8
- package/docs/get-started/06-go-live.md +2 -1
- package/docs/guides/01-allow-claude.md +68 -0
- package/docs/guides/02-automate.md +88 -0
- package/docs/guides/09-as-your-list-grows.md +2 -2
- package/docs/publishing/01-your-list.md +3 -3
- package/docs/publishing/03-name-your-publication.md +1 -1
- package/docs/publishing/04-write-a-post.md +15 -52
- package/docs/publishing/06-troubleshooting.md +20 -9
- package/docs/publishing/08-connect-claude.md +60 -0
- package/docs/publishing/09-share-your-subscribe-link.md +1 -1
- package/docs/publishing/10-check-your-setup.md +9 -16
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +2 -2
- package/docs/reference/01-configuration.md +132 -85
- package/docs/reference/02-sending-domain-dns.md +2 -2
- package/migrations/0013_who_acted_client.sql +20 -0
- package/migrations/0014_send_acts.sql +25 -0
- package/package.json +1 -1
- package/template/README.md +2 -0
- package/template/claude/CLAUDE.md +8 -5
- package/docs/guides/01-connect-claude.md +0 -101
- package/docs/publishing/07-working-with-claude.md +0 -29
package/cli/oauth.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The OAuth client `kestrel login` and `kestrel api` share, for an instance behind
|
|
3
|
+
* Cloudflare Access with Managed OAuth on (SPEC §11). Access is the authorization server:
|
|
4
|
+
* the app never sees this token, only the signed assertion Access forwards in its place, so
|
|
5
|
+
* a person signed in here is the same principal as in the editor.
|
|
6
|
+
*
|
|
7
|
+
* Everything is discovered from the instance itself. A request to a protected path with no
|
|
8
|
+
* credential is answered 401 by Access, whose `WWW-Authenticate` names the protected
|
|
9
|
+
* resource's metadata (RFC 9728), which names the authorization server, whose own metadata
|
|
10
|
+
* (RFC 8414) names its endpoints. The path asked is `/api/whoami`, because `/` is public and
|
|
11
|
+
* Access has nothing to say about it. Then: dynamic client registration (RFC 7591) for a
|
|
12
|
+
* public client on a loopback redirect (RFC 8252), the authorization code flow with PKCE
|
|
13
|
+
* (S256), and the protected resource named in every token request (RFC 8707). Plain Node,
|
|
14
|
+
* no dependency, as everything in cli/ is.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
|
|
19
|
+
/** The path discovery and the sign-in's check ask: protected, and it answers who signed in. */
|
|
20
|
+
export const PROTECTED_PATH = "/api/whoami";
|
|
21
|
+
|
|
22
|
+
/** The name `kestrel api` gives its requests, unless told another (SPEC §11, Who acted). */
|
|
23
|
+
export const DEFAULT_CLIENT = "claude-code";
|
|
24
|
+
|
|
25
|
+
// How long before its expiry an access token is refreshed rather than sent.
|
|
26
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
/** Thrown with words for the person: what went wrong and what to do. */
|
|
29
|
+
export class SignInError extends Error {}
|
|
30
|
+
|
|
31
|
+
/** Whether an origin is this computer (a local dev server), where Access never stands. */
|
|
32
|
+
export function isLoopback(origin) {
|
|
33
|
+
const { hostname } = new URL(origin);
|
|
34
|
+
return (
|
|
35
|
+
hostname === "localhost" ||
|
|
36
|
+
hostname.endsWith(".localhost") ||
|
|
37
|
+
hostname === "127.0.0.1" ||
|
|
38
|
+
hostname === "[::1]"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The origin an instance URL names, which a sign-in is stored under. A path, query, or
|
|
44
|
+
* trailing slash is dropped, so `https://news.example.com/dashboard/` and the bare host are
|
|
45
|
+
* one instance. A host given without a scheme means https; plain http is refused for
|
|
46
|
+
* anything but this computer, since a token would cross the network in the clear.
|
|
47
|
+
*/
|
|
48
|
+
export function instanceOrigin(url) {
|
|
49
|
+
const text = String(url ?? "").trim();
|
|
50
|
+
if (!text) {
|
|
51
|
+
throw new SignInError("Name the instance, such as https://news.example.com.");
|
|
52
|
+
}
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new SignInError(`"${text}" isn't a URL.`);
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback(parsed.origin))) {
|
|
60
|
+
throw new SignInError(`${parsed.origin} isn't https, and a sign-in only travels over https.`);
|
|
61
|
+
}
|
|
62
|
+
return parsed.origin;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `resource_metadata` a `WWW-Authenticate` header names (RFC 9728), or undefined. */
|
|
66
|
+
export function resourceMetadataUrl(header) {
|
|
67
|
+
const match = /resource_metadata="([^"]+)"/.exec(header ?? "");
|
|
68
|
+
return match ? match[1] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A PKCE verifier and its S256 challenge. */
|
|
72
|
+
export function pkcePair() {
|
|
73
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
74
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
75
|
+
return { verifier, challenge };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a stored sign-in's access token should be refreshed before it is sent. */
|
|
79
|
+
export function needsRefresh(entry, now = Date.now()) {
|
|
80
|
+
return !entry.access_token || !(entry.expires_at > now + REFRESH_MARGIN_MS);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The stored fields a token response sets: the access token, when it expires, and the
|
|
85
|
+
* refresh token, kept from before when the server didn't send a new one.
|
|
86
|
+
*/
|
|
87
|
+
export function tokenFields(body, previous = {}, now = Date.now()) {
|
|
88
|
+
if (!body || typeof body.access_token !== "string") {
|
|
89
|
+
throw new SignInError("Access answered without an access token.");
|
|
90
|
+
}
|
|
91
|
+
const lifetime = Number(body.expires_in);
|
|
92
|
+
return {
|
|
93
|
+
access_token: body.access_token,
|
|
94
|
+
// No expires_in means none was promised: refresh after a minute rather than trust it long.
|
|
95
|
+
expires_at: now + (Number.isFinite(lifetime) && lifetime > 0 ? lifetime : 60) * 1000,
|
|
96
|
+
refresh_token:
|
|
97
|
+
typeof body.refresh_token === "string" ? body.refresh_token : previous.refresh_token,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function getJson(url, what) {
|
|
102
|
+
let res;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(url, { headers: { accept: "application/json" } });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new SignInError(`Couldn't reach ${new URL(url).origin} for ${what}: ${errText(err)}`);
|
|
107
|
+
}
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new SignInError(`${what} answered ${res.status} at ${url}.`);
|
|
110
|
+
}
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What a sign-in to `origin` needs to know, found from the instance as described above:
|
|
116
|
+
* `{ resource, issuer, authorization_endpoint, token_endpoint, registration_endpoint,
|
|
117
|
+
* revocation_endpoint }`. Throws a SignInError that says what to turn on when the instance
|
|
118
|
+
* offers no OAuth sign-in.
|
|
119
|
+
*/
|
|
120
|
+
export async function discover(origin) {
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(`${origin}${PROTECTED_PATH}`, {
|
|
124
|
+
headers: { accept: "application/json" },
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new SignInError(`Couldn't reach ${origin}: ${errText(err)}`);
|
|
129
|
+
}
|
|
130
|
+
const metadataUrl = resourceMetadataUrl(res.headers.get("www-authenticate"));
|
|
131
|
+
if (res.status !== 401 || !metadataUrl) {
|
|
132
|
+
throw new SignInError(
|
|
133
|
+
`${origin} doesn't offer a sign-in for programs (it answered ${res.status} with no OAuth metadata).`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const resource = await getJson(metadataUrl, "the protected resource's metadata");
|
|
137
|
+
const issuer = resource.authorization_servers?.[0];
|
|
138
|
+
if (typeof resource.resource !== "string" || typeof issuer !== "string") {
|
|
139
|
+
throw new SignInError(`The metadata at ${metadataUrl} names no authorization server.`);
|
|
140
|
+
}
|
|
141
|
+
const server = await getJson(
|
|
142
|
+
`${issuer}/.well-known/oauth-authorization-server`,
|
|
143
|
+
"the authorization server's metadata",
|
|
144
|
+
);
|
|
145
|
+
if (!server.authorization_endpoint || !server.token_endpoint) {
|
|
146
|
+
throw new SignInError(`${issuer} publishes no authorization or token endpoint.`);
|
|
147
|
+
}
|
|
148
|
+
if (!server.registration_endpoint) {
|
|
149
|
+
throw new SignInError(
|
|
150
|
+
`${issuer} offers no client registration. Turn on Managed OAuth for the instance's Access application, as the guide Allow Claude to connect says.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
resource: resource.resource,
|
|
155
|
+
issuer,
|
|
156
|
+
authorization_endpoint: server.authorization_endpoint,
|
|
157
|
+
token_endpoint: server.token_endpoint,
|
|
158
|
+
registration_endpoint: server.registration_endpoint,
|
|
159
|
+
revocation_endpoint: server.revocation_endpoint,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Register a public client for this computer's loopback redirect; answers its client_id. */
|
|
164
|
+
export async function registerClient(registrationEndpoint, redirectUri) {
|
|
165
|
+
const res = await fetch(registrationEndpoint, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
client_name: "Kestrel CLI",
|
|
170
|
+
redirect_uris: [redirectUri],
|
|
171
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
172
|
+
response_types: ["code"],
|
|
173
|
+
token_endpoint_auth_method: "none",
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
const body = await res.json().catch(() => ({}));
|
|
177
|
+
if (!res.ok || typeof body.client_id !== "string") {
|
|
178
|
+
throw new SignInError(
|
|
179
|
+
`Access refused to register this computer's sign-in (${res.status}${body.error_description ? `: ${body.error_description}` : body.error ? `: ${body.error}` : ""}). In the Access application's Managed OAuth settings, turn on Allow loopback clients.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return body.client_id;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The URL the browser opens to sign in. */
|
|
186
|
+
export function authorizeUrl({ endpoint, clientId, redirectUri, challenge, state, resource }) {
|
|
187
|
+
const url = new URL(endpoint);
|
|
188
|
+
url.search = new URLSearchParams({
|
|
189
|
+
response_type: "code",
|
|
190
|
+
client_id: clientId,
|
|
191
|
+
redirect_uri: redirectUri,
|
|
192
|
+
code_challenge: challenge,
|
|
193
|
+
code_challenge_method: "S256",
|
|
194
|
+
state,
|
|
195
|
+
resource,
|
|
196
|
+
}).toString();
|
|
197
|
+
return url.toString();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** How long a token request may take. Under the credentials lock's staleness rule (30 s) and a waiter's patience (25 s), so a refresh that holds the lock never outlasts either. */
|
|
201
|
+
export const TOKEN_TIMEOUT_MS = 10_000;
|
|
202
|
+
|
|
203
|
+
/** POST a form to the token endpoint, answering its JSON, or throwing with its words. */
|
|
204
|
+
async function tokenRequest(endpoint, form, timeoutMs = TOKEN_TIMEOUT_MS) {
|
|
205
|
+
let res;
|
|
206
|
+
let body;
|
|
207
|
+
try {
|
|
208
|
+
res = await fetch(endpoint, {
|
|
209
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
212
|
+
body: new URLSearchParams(form).toString(),
|
|
213
|
+
});
|
|
214
|
+
// The timeout covers reading the body too; only an unreadable body is let go.
|
|
215
|
+
body = await res.json().catch((err) => {
|
|
216
|
+
if (err?.name === "TimeoutError") {
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
return {};
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err?.name === "TimeoutError") {
|
|
223
|
+
throw new SignInError(
|
|
224
|
+
`${new URL(endpoint).origin} didn't answer the token request within ${timeoutMs / 1000} seconds. Try again.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
throw new SignInError(`Couldn't reach ${new URL(endpoint).origin}: ${errText(err)}`);
|
|
228
|
+
}
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
const error = new SignInError(
|
|
231
|
+
`Access refused the token request (${res.status}${body.error ? `: ${body.error}` : ""}${body.error_description ? `, ${body.error_description}` : ""}).`,
|
|
232
|
+
);
|
|
233
|
+
error.oauthError = body.error;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
return body;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Trade the browser's authorization code for tokens. */
|
|
240
|
+
export function exchangeCode(entry, { code, verifier }, timeoutMs) {
|
|
241
|
+
return tokenRequest(
|
|
242
|
+
entry.token_endpoint,
|
|
243
|
+
{
|
|
244
|
+
grant_type: "authorization_code",
|
|
245
|
+
code,
|
|
246
|
+
code_verifier: verifier,
|
|
247
|
+
client_id: entry.client_id,
|
|
248
|
+
redirect_uri: entry.redirect_uri,
|
|
249
|
+
resource: entry.resource,
|
|
250
|
+
},
|
|
251
|
+
timeoutMs,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A new access token from the stored refresh token. Access checks its policies again. */
|
|
256
|
+
export function refreshTokens(entry, timeoutMs) {
|
|
257
|
+
if (!entry.refresh_token) {
|
|
258
|
+
throw new SignInError("This sign-in can't be renewed. Run `npx kestrel login <url>` again.");
|
|
259
|
+
}
|
|
260
|
+
return tokenRequest(
|
|
261
|
+
entry.token_endpoint,
|
|
262
|
+
{
|
|
263
|
+
grant_type: "refresh_token",
|
|
264
|
+
refresh_token: entry.refresh_token,
|
|
265
|
+
client_id: entry.client_id,
|
|
266
|
+
resource: entry.resource,
|
|
267
|
+
},
|
|
268
|
+
timeoutMs,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Revoke the stored refresh token, best effort: forgetting it locally is what logout owes. */
|
|
273
|
+
export async function revokeTokens(entry) {
|
|
274
|
+
if (!entry.revocation_endpoint || !entry.refresh_token) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(entry.revocation_endpoint, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
281
|
+
body: new URLSearchParams({
|
|
282
|
+
token: entry.refresh_token,
|
|
283
|
+
token_type_hint: "refresh_token",
|
|
284
|
+
client_id: entry.client_id,
|
|
285
|
+
}).toString(),
|
|
286
|
+
});
|
|
287
|
+
return res.ok;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const DONE_PAGE = (heading, line) =>
|
|
294
|
+
`<!doctype html><meta charset="utf-8"><title>Kestrel</title><body style="font:16px system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem"><h1 style="font-size:1.25rem">${heading}</h1><p>${line}</p></body>`;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Listen on this computer's loopback for the browser's return from the sign-in. Tries
|
|
298
|
+
* `port` first, so a stored client's redirect still matches, and any free port when that
|
|
299
|
+
* one is taken. Answers `{ redirectUri, code }`, where `code` resolves to the authorization
|
|
300
|
+
* code once a request carrying `state` arrives, and rejects on Access's refusal or after
|
|
301
|
+
* `timeoutMs`.
|
|
302
|
+
*/
|
|
303
|
+
export async function listenForCode({ port, state, timeoutMs = 5 * 60_000 }) {
|
|
304
|
+
let settle;
|
|
305
|
+
const code = new Promise((resolve, reject) => {
|
|
306
|
+
settle = { resolve, reject };
|
|
307
|
+
});
|
|
308
|
+
const server = createServer((req, res) => {
|
|
309
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
310
|
+
if (url.pathname !== "/callback") {
|
|
311
|
+
res.writeHead(404).end();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// A request that doesn't carry this sign-in's state isn't its answer, whatever it says.
|
|
315
|
+
if (url.searchParams.get("state") !== state) {
|
|
316
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
317
|
+
res.end(DONE_PAGE("That sign-in link is stale", "Run kestrel login again."));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const error = url.searchParams.get("error");
|
|
321
|
+
const got = url.searchParams.get("code");
|
|
322
|
+
res.writeHead(error || !got ? 400 : 200, { "content-type": "text/html; charset=utf-8" });
|
|
323
|
+
if (error || !got) {
|
|
324
|
+
res.end(DONE_PAGE("Sign-in refused", "Go back to the terminal to see why."));
|
|
325
|
+
const description = url.searchParams.get("error_description");
|
|
326
|
+
settle.reject(
|
|
327
|
+
new SignInError(
|
|
328
|
+
`The sign-in was refused (${error ?? "no code"}${description ? `: ${description}` : ""}).`,
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
res.end(DONE_PAGE("Signed in to Kestrel", "You can close this tab."));
|
|
333
|
+
settle.resolve(got);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
const bound = await new Promise((resolve, reject) => {
|
|
337
|
+
const tryPort = (p, fallback) => {
|
|
338
|
+
server.once("error", (err) => {
|
|
339
|
+
if (fallback && err.code === "EADDRINUSE") {
|
|
340
|
+
tryPort(0, false);
|
|
341
|
+
} else {
|
|
342
|
+
reject(err);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
server.listen(p, "127.0.0.1", () => resolve(server.address().port));
|
|
346
|
+
};
|
|
347
|
+
tryPort(port ?? 0, Boolean(port));
|
|
348
|
+
});
|
|
349
|
+
const timer = setTimeout(
|
|
350
|
+
() =>
|
|
351
|
+
settle.reject(
|
|
352
|
+
new SignInError("No sign-in came back in time. Run `npx kestrel login <url>` again."),
|
|
353
|
+
),
|
|
354
|
+
timeoutMs,
|
|
355
|
+
);
|
|
356
|
+
const close = () => {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
server.close();
|
|
359
|
+
server.closeAllConnections?.();
|
|
360
|
+
};
|
|
361
|
+
code.then(close, close);
|
|
362
|
+
return { redirectUri: `http://127.0.0.1:${bound}/callback`, code, close };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function errText(err) {
|
|
366
|
+
const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : "";
|
|
367
|
+
return `${err instanceof Error ? err.message : String(err)}${cause}`;
|
|
368
|
+
}
|