@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -0,0 +1,274 @@
1
+ /**
2
+ * SEC-2a: the device flows the server runs for the browser. The page asks
3
+ * for one (Sign in with GitHub on the signed-out page, or Link your GitHub
4
+ * account in Settings › Access); the server starts it with GitHub, polls
5
+ * GitHub itself at GitHub's interval, and the page polls the server for the
6
+ * outcome by the flow's id (a random handle only the page that started it
7
+ * holds).
8
+ *
9
+ * - The device code never leaves the server; the user code goes to that
10
+ * page only. The token lives for one `GET /user` call and is dropped.
11
+ * - At most `max` flows (default 3) are pending at once: another start is
12
+ * refused (`busy`) until one ends. A pending flow ends at GitHub's
13
+ * expiry; a finished one is forgotten a minute later, or when the page
14
+ * claims it.
15
+ * - What an identity means is the caller's (`decide`): for a sign-in,
16
+ * whether it is the owner; for a link, storing it as the owner.
17
+ *
18
+ * Clock and timers are injected, so a test drives every step.
19
+ */
20
+ import { randomBytes } from "crypto";
21
+ import { GITHUB_DEVICE_PAGE } from "../../shared/github-app.js";
22
+ import type {
23
+ GithubDeviceClient,
24
+ GithubIdentity,
25
+ } from "../../shared/github-device.js";
26
+
27
+ export type FlowPurpose = "signin" | "link";
28
+ export type FlowState =
29
+ | "pending"
30
+ | "ok"
31
+ | "refused"
32
+ | "expired"
33
+ | "denied"
34
+ | "error";
35
+
36
+ /** What the page is told about a flow (never the device code). */
37
+ export interface FlowView {
38
+ id: string;
39
+ purpose: FlowPurpose;
40
+ state: FlowState;
41
+ userCode: string;
42
+ verificationUri: string;
43
+ expiresAt: number;
44
+ /** ok or refused: the GitHub login that approved it. */
45
+ login?: string;
46
+ /** error: what went wrong (no secret in it). */
47
+ message?: string;
48
+ }
49
+
50
+ interface Flow extends FlowView {
51
+ deviceCode: string;
52
+ intervalMs: number;
53
+ errors: number;
54
+ /** ok: who approved it (the id, for the caller). */
55
+ identity?: GithubIdentity;
56
+ /** When a finished flow is forgotten. */
57
+ forgetAt?: number;
58
+ cancel?: () => void;
59
+ }
60
+
61
+ export type Schedule = (fn: () => void, ms: number) => () => void;
62
+
63
+ const defaultSchedule: Schedule = (fn, ms) => {
64
+ const t = setTimeout(fn, ms);
65
+ t.unref?.();
66
+ return () => clearTimeout(t);
67
+ };
68
+
69
+ /** How long a finished flow waits for its page to claim it. */
70
+ export const FINISHED_KEEP_MS = 60_000;
71
+ /** The most pending flows at once (SEC-2a: "rate-limit concurrent device flows"). */
72
+ export const MAX_PENDING_FLOWS = 3;
73
+ const MAX_ERRORS = 5;
74
+
75
+ export class DeviceFlows {
76
+ private readonly flows = new Map<string, Flow>();
77
+ private readonly client: GithubDeviceClient;
78
+ private readonly clientId: () => string;
79
+ private readonly now: () => number;
80
+ private readonly schedule: Schedule;
81
+ private readonly max: number;
82
+ private readonly minIntervalMs: number;
83
+ private readonly onExpiringToken?: () => void;
84
+ private readonly decide: (
85
+ purpose: FlowPurpose,
86
+ identity: GithubIdentity,
87
+ ) => "ok" | "refused";
88
+
89
+ constructor(opts: {
90
+ client: GithubDeviceClient;
91
+ clientId: () => string;
92
+ decide: (
93
+ purpose: FlowPurpose,
94
+ identity: GithubIdentity,
95
+ ) => "ok" | "refused";
96
+ now?: () => number;
97
+ schedule?: Schedule;
98
+ max?: number;
99
+ /** The floor between polls (GitHub's interval is never shortened). */
100
+ minIntervalMs?: number;
101
+ /**
102
+ * A token answer carried expires_in or a refresh_token: the app has
103
+ * token expiry on (a misconfiguration). Called with no token.
104
+ */
105
+ onExpiringToken?: () => void;
106
+ }) {
107
+ this.onExpiringToken = opts.onExpiringToken;
108
+ this.client = opts.client;
109
+ this.clientId = opts.clientId;
110
+ this.decide = opts.decide;
111
+ this.now = opts.now ?? Date.now;
112
+ this.schedule = opts.schedule ?? defaultSchedule;
113
+ this.max = opts.max ?? MAX_PENDING_FLOWS;
114
+ this.minIntervalMs = opts.minIntervalMs ?? 1000;
115
+ }
116
+
117
+ /** Pending flows now. */
118
+ pending(): number {
119
+ this.sweep();
120
+ return [...this.flows.values()].filter((f) => f.state === "pending").length;
121
+ }
122
+
123
+ /**
124
+ * Start a flow with GitHub. `busy` past the cap; `no_client_id` when none
125
+ * is configured; a GitHub failure throws (its message names no secret).
126
+ */
127
+ async start(
128
+ purpose: FlowPurpose,
129
+ ): Promise<FlowView | { error: "busy" | "no_client_id" }> {
130
+ const clientId = this.clientId();
131
+ if (!clientId) return { error: "no_client_id" };
132
+ if (this.pending() >= this.max) return { error: "busy" };
133
+ // Held as pending while GitHub answers, so a burst can't pass the cap.
134
+ const id = randomBytes(24).toString("base64url");
135
+ const placeholder: Flow = {
136
+ id,
137
+ purpose,
138
+ state: "pending",
139
+ userCode: "",
140
+ verificationUri: GITHUB_DEVICE_PAGE,
141
+ expiresAt: this.now() + 60_000,
142
+ deviceCode: "",
143
+ intervalMs: this.minIntervalMs,
144
+ errors: 0,
145
+ };
146
+ this.flows.set(id, placeholder);
147
+ let code;
148
+ try {
149
+ code = await this.client.start(clientId);
150
+ } catch (e) {
151
+ this.flows.delete(id);
152
+ throw e;
153
+ }
154
+ const flow: Flow = {
155
+ ...placeholder,
156
+ userCode: code.userCode,
157
+ deviceCode: code.deviceCode,
158
+ expiresAt: this.now() + code.expiresIn * 1000,
159
+ intervalMs: Math.max(code.interval * 1000, this.minIntervalMs),
160
+ };
161
+ this.flows.set(id, flow);
162
+ this.arm(flow);
163
+ return view(flow);
164
+ }
165
+
166
+ /** A flow's view, or null when there is none (or it was forgotten). */
167
+ get(id: string, purpose?: FlowPurpose): FlowView | null {
168
+ this.sweep();
169
+ const f = this.flows.get(id);
170
+ if (!f || (purpose && f.purpose !== purpose)) return null;
171
+ return view(f);
172
+ }
173
+
174
+ /**
175
+ * The identity of a finished, approved flow, once: the flow is forgotten,
176
+ * so a second claim gets null (a sign-in makes one session).
177
+ */
178
+ claim(id: string, purpose: FlowPurpose): GithubIdentity | null {
179
+ this.sweep();
180
+ const f = this.flows.get(id);
181
+ if (!f || f.purpose !== purpose || f.state !== "ok" || !f.identity)
182
+ return null;
183
+ this.flows.delete(id);
184
+ return f.identity;
185
+ }
186
+
187
+ /** Stop every flow (the server is stopping). */
188
+ stopAll(): void {
189
+ for (const f of this.flows.values()) f.cancel?.();
190
+ this.flows.clear();
191
+ }
192
+
193
+ private arm(f: Flow): void {
194
+ f.cancel = this.schedule(() => void this.tick(f.id), f.intervalMs);
195
+ }
196
+
197
+ private finish(
198
+ f: Flow,
199
+ state: Exclude<FlowState, "pending">,
200
+ extra: Partial<Flow> = {},
201
+ ): void {
202
+ Object.assign(f, extra, {
203
+ state,
204
+ deviceCode: "",
205
+ forgetAt: this.now() + FINISHED_KEEP_MS,
206
+ });
207
+ f.cancel?.();
208
+ f.cancel = undefined;
209
+ }
210
+
211
+ private async tick(id: string): Promise<void> {
212
+ const f = this.flows.get(id);
213
+ if (!f || f.state !== "pending") return;
214
+ if (this.now() >= f.expiresAt) return this.finish(f, "expired");
215
+ const r = await this.client.poll(this.clientId(), f.deviceCode);
216
+ if (this.flows.get(id) !== f || f.state !== "pending") return;
217
+ switch (r.kind) {
218
+ case "pending":
219
+ return this.arm(f);
220
+ case "slow_down":
221
+ f.intervalMs = Math.max(
222
+ r.interval !== undefined ? r.interval * 1000 : f.intervalMs + 5000,
223
+ this.minIntervalMs,
224
+ );
225
+ return this.arm(f);
226
+ case "expired":
227
+ return this.finish(f, "expired");
228
+ case "denied":
229
+ return this.finish(f, "denied");
230
+ case "error":
231
+ if (++f.errors >= MAX_ERRORS)
232
+ return this.finish(f, "error", { message: r.message });
233
+ return this.arm(f);
234
+ case "token": {
235
+ if (r.expiring) this.onExpiringToken?.();
236
+ let identity: GithubIdentity;
237
+ try {
238
+ // The token is used for this one call and dropped with `r`.
239
+ identity = await this.client.user(r.token);
240
+ } catch (e) {
241
+ return this.finish(f, "error", { message: (e as Error).message });
242
+ }
243
+ const outcome = this.decide(f.purpose, identity);
244
+ return this.finish(f, outcome, {
245
+ login: identity.login,
246
+ ...(outcome === "ok" ? { identity } : {}),
247
+ });
248
+ }
249
+ }
250
+ }
251
+
252
+ /** Expire pending flows past their time; forget old finished ones. */
253
+ private sweep(): void {
254
+ const now = this.now();
255
+ for (const [id, f] of this.flows) {
256
+ if (f.state === "pending" && f.deviceCode && now >= f.expiresAt)
257
+ this.finish(f, "expired");
258
+ if (f.forgetAt !== undefined && now >= f.forgetAt) this.flows.delete(id);
259
+ }
260
+ }
261
+ }
262
+
263
+ function view(f: Flow): FlowView {
264
+ return {
265
+ id: f.id,
266
+ purpose: f.purpose,
267
+ state: f.state,
268
+ userCode: f.userCode,
269
+ verificationUri: f.verificationUri,
270
+ expiresAt: f.expiresAt,
271
+ ...(f.login !== undefined ? { login: f.login } : {}),
272
+ ...(f.message !== undefined ? { message: f.message } : {}),
273
+ };
274
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The edge counts as local (0.4.3): with no sign-in through the edge, who
3
- * can reach the edge can use terminals. When Den can tell the edge is
2
+ * The edge counts as local (0.4.3) when it shows the client is this
3
+ * machine; since 0.4.5 any other client signs in (unless
4
+ * web.edge_unattested: local). When Den can tell the edge is
4
5
  * reachable beyond this machine it says so, once in web.log and in a
5
6
  * banner on every page (the shell, dismissible per browser).
6
7
  *
@@ -16,8 +17,19 @@ import { execFile } from "child_process";
16
17
  import { promises as dns } from "dns";
17
18
  import { isLoopbackAddress } from "./request-guard.js";
18
19
 
19
- export const EDGE_EXPOSED_MESSAGE =
20
- "Your edge is reachable from other machines; anyone who can reach it can use terminals. Auth is coming.";
20
+ /**
21
+ * The warning, by `web.edge_unattested` (SEC-2a): with `signin` (the
22
+ * default) a browser the edge can't place as this machine signs in first;
23
+ * with `local` it is let in, as in 0.4.3.
24
+ */
25
+ export function edgeExposedMessage(unattested: "local" | "signin"): string {
26
+ return unattested === "local"
27
+ ? "Your edge is reachable from other machines, and web.edge_unattested is local: anyone it can't place can use terminals. Remove the key to have them sign in."
28
+ : "Your edge is reachable from other machines. A browser it can't place as this one signs in first (Sign in with GitHub, or a pairing link).";
29
+ }
30
+
31
+ /** 0.4.5's default wording (web.edge_unattested: signin). */
32
+ export const EDGE_EXPOSED_MESSAGE = edgeExposedMessage("signin");
21
33
 
22
34
  export interface ExposureDeps {
23
35
  /** Every address a hostname resolves to (none when it doesn't). */
@@ -0,0 +1,321 @@
1
+ /**
2
+ * SEC-2a's routes: Sign in with GitHub (the signed-out page), the owner's
3
+ * link (Settings › Access, `web owner …`), and Pair a device.
4
+ *
5
+ * POST /api/github/signin start a sign-in (sign-in free; an
6
+ * allowed Origin, no CSRF token)
7
+ * GET /api/github/signin/<id> its outcome; approved by the owner:
8
+ * the session cookie, once
9
+ * GET /api/web/owner who the owner is
10
+ * POST /api/web/owner/link start linking the owner (shell-grade)
11
+ * GET /api/web/owner/link/<id> its code and outcome
12
+ * POST /api/web/owner/unlink unlink the owner (shell-grade)
13
+ * GET /api/web/pair the edge hostnames a link can be for
14
+ * POST /api/web/pair { host } a one-time sign-in link for one
15
+ *
16
+ * The token GitHub hands back is used once, for `GET /user`, and dropped
17
+ * (device-flows.ts). Nothing here logs a code, a token or a cookie.
18
+ */
19
+ import type * as http from "http";
20
+ import { githubPrincipal } from "../../shared/github-device.js";
21
+ import {
22
+ clearWebOwner,
23
+ readWebOwner,
24
+ writeWebOwner,
25
+ } from "../../shared/web-owner.js";
26
+ import type { DeviceFlows, FlowView } from "./device-flows.js";
27
+
28
+ export interface GithubRouteDeps {
29
+ flows: DeviceFlows;
30
+ /** The configured client id ("" when none). */
31
+ clientId: () => string;
32
+ /** The app issued an expiring token (its token expiry is on): a misconfiguration. */
33
+ tokensExpire?: () => boolean;
34
+ cli: string;
35
+ sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
36
+ readBody: (req: http.IncomingMessage) => Promise<string>;
37
+ /** A new browser session for this request; its Set-Cookie line. */
38
+ signIn: (
39
+ req: http.IncomingMessage,
40
+ via: "github" | "link",
41
+ login?: string,
42
+ ) => string;
43
+ /** The edge hostnames that route here, and the URL a browser uses for each. */
44
+ edgeRoutes: () => { host: string; url: string }[];
45
+ /** A one-time sign-in link at `origin` (60 s), or null with no secret. */
46
+ mintLink: (origin: string) => { url: string; expiresAt: number } | null;
47
+ log: (line: string) => void;
48
+ /** Where the owner file lives (the server's state dir). */
49
+ stateDir: () => string;
50
+ }
51
+
52
+ const SIGNIN_PATH = /^\/api\/github\/signin\/([A-Za-z0-9_-]{16,64})$/;
53
+ const LINK_PATH = /^\/api\/web\/owner\/link\/([A-Za-z0-9_-]{16,64})$/;
54
+
55
+ /** What an identity means for a flow: the owner's sign-in, or the owner's link. */
56
+ export function decideFlow(
57
+ stateDir: () => string,
58
+ log: (line: string) => void,
59
+ ): (
60
+ purpose: "signin" | "link",
61
+ identity: { id: number; login: string },
62
+ ) => "ok" | "refused" {
63
+ return (purpose, identity) => {
64
+ const owner = readWebOwner(stateDir());
65
+ if (purpose === "signin") {
66
+ const ok = owner?.owner === githubPrincipal(identity.id);
67
+ log(
68
+ ok
69
+ ? ` ✓ signed in with GitHub (@${identity.login})`
70
+ : ` ✗ GitHub sign-in refused: @${identity.login} isn't this Den's owner`,
71
+ );
72
+ return ok ? "ok" : "refused";
73
+ }
74
+ if (owner) return "refused";
75
+ writeWebOwner(identity, stateDir());
76
+ log(` ✓ owner linked: @${identity.login}`);
77
+ return "ok";
78
+ };
79
+ }
80
+
81
+ /** A flow as the page sees it: the code and where to enter it, while it waits. */
82
+ function flowJson(v: FlowView): Record<string, unknown> {
83
+ return {
84
+ id: v.id,
85
+ state: v.state,
86
+ ...(v.state === "pending"
87
+ ? {
88
+ userCode: v.userCode,
89
+ verificationUri: v.verificationUri,
90
+ expiresAt: new Date(v.expiresAt).toISOString(),
91
+ }
92
+ : {}),
93
+ ...(v.login ? { login: v.login } : {}),
94
+ };
95
+ }
96
+
97
+ function finishedMessage(v: FlowView, cli: string): string | undefined {
98
+ switch (v.state) {
99
+ case "refused":
100
+ return v.purpose === "signin"
101
+ ? `@${v.login} isn't allowed on this Den. Ask the owner to add you.`
102
+ : `An owner is linked already; \`${cli} web owner unlink\` first.`;
103
+ case "expired":
104
+ return "The code expired before it was approved. Start again.";
105
+ case "denied":
106
+ return "The request was cancelled on GitHub.";
107
+ case "error":
108
+ return v.message ?? "GitHub couldn't be reached.";
109
+ default:
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ async function startFlow(
115
+ deps: GithubRouteDeps,
116
+ res: http.ServerResponse,
117
+ purpose: "signin" | "link",
118
+ ): Promise<void> {
119
+ let r;
120
+ try {
121
+ r = await deps.flows.start(purpose);
122
+ } catch (e) {
123
+ return deps.sendJson(
124
+ res,
125
+ { ok: false, code: "github_unreachable", error: (e as Error).message },
126
+ 502,
127
+ );
128
+ }
129
+ if ("error" in r)
130
+ return r.error === "busy"
131
+ ? deps.sendJson(
132
+ res,
133
+ {
134
+ ok: false,
135
+ code: "busy",
136
+ error:
137
+ "Too many GitHub sign-ins are in progress. Try again in a few minutes.",
138
+ },
139
+ 429,
140
+ )
141
+ : deps.sendJson(
142
+ res,
143
+ {
144
+ ok: false,
145
+ code: "no_client_id",
146
+ error: `Sign in with GitHub needs a GitHub App client id: set web.github_client_id in config/local/user.yml and restart (\`${deps.cli} web restart\`).`,
147
+ },
148
+ 409,
149
+ );
150
+ return deps.sendJson(res, { ok: true, ...flowJson(r) });
151
+ }
152
+
153
+ /** Whether `pathname` is one of these routes' (index.ts dispatches them here). */
154
+ export function isGithubRoute(pathname: string): boolean {
155
+ return /^\/api\/(github\/|web\/owner(\/|$)|web\/pair$)/.test(pathname);
156
+ }
157
+
158
+ /** Handles a SEC-2a route; false when `pathname` is none of them. */
159
+ export async function handleGithubRoutes(
160
+ req: http.IncomingMessage,
161
+ res: http.ServerResponse,
162
+ pathname: string,
163
+ deps: GithubRouteDeps,
164
+ ): Promise<boolean> {
165
+ const method = (req.method ?? "GET").toUpperCase();
166
+ res.setHeader("Cache-Control", "no-store");
167
+
168
+ // ── Sign in with GitHub ──────────────────────────────────────────────
169
+ if (pathname === "/api/github/signin" && method === "POST") {
170
+ if (!readWebOwner(deps.stateDir())) {
171
+ deps.sendJson(
172
+ res,
173
+ {
174
+ ok: false,
175
+ code: "no_owner",
176
+ error: `This Den has no owner yet. On the machine it runs on, run \`${deps.cli} web owner link\`.`,
177
+ },
178
+ 409,
179
+ );
180
+ return true;
181
+ }
182
+ await startFlow(deps, res, "signin");
183
+ return true;
184
+ }
185
+ const s = SIGNIN_PATH.exec(pathname);
186
+ if (s && (method === "GET" || method === "HEAD")) {
187
+ const v = deps.flows.get(s[1], "signin");
188
+ if (!v) {
189
+ deps.sendJson(
190
+ res,
191
+ {
192
+ ok: false,
193
+ state: "gone",
194
+ code: "not_found",
195
+ error: "That sign-in ended. Start again.",
196
+ },
197
+ 404,
198
+ );
199
+ return true;
200
+ }
201
+ if (v.state === "ok") {
202
+ if (!deps.flows.claim(v.id, "signin")) {
203
+ deps.sendJson(res, { ok: false, state: "gone" }, 404);
204
+ return true;
205
+ }
206
+ res.setHeader("Set-Cookie", deps.signIn(req, "github", v.login));
207
+ deps.sendJson(res, { ok: true, state: "ok", login: v.login });
208
+ return true;
209
+ }
210
+ const message = finishedMessage(v, deps.cli);
211
+ deps.sendJson(res, {
212
+ ok: v.state === "pending",
213
+ ...flowJson(v),
214
+ ...(message ? { error: message } : {}),
215
+ });
216
+ return true;
217
+ }
218
+
219
+ // ── The owner ────────────────────────────────────────────────────────
220
+ if (pathname === "/api/web/owner" && method === "GET") {
221
+ const o = readWebOwner(deps.stateDir());
222
+ deps.sendJson(res, {
223
+ owner: o ? { id: o.owner, login: o.login, linkedAt: o.linkedAt } : null,
224
+ clientId: deps.clientId() !== "",
225
+ // The app's "Expire user authorization tokens" is on (seen since start).
226
+ tokenExpiry: deps.tokensExpire?.() ?? false,
227
+ });
228
+ return true;
229
+ }
230
+ if (pathname === "/api/web/owner/link" && method === "POST") {
231
+ const o = readWebOwner(deps.stateDir());
232
+ if (o) {
233
+ deps.sendJson(
234
+ res,
235
+ {
236
+ ok: false,
237
+ code: "already_linked",
238
+ error: `This Den's owner is @${o.login} already; unlink first (\`${deps.cli} web owner unlink\`).`,
239
+ },
240
+ 409,
241
+ );
242
+ return true;
243
+ }
244
+ await startFlow(deps, res, "link");
245
+ return true;
246
+ }
247
+ const l = LINK_PATH.exec(pathname);
248
+ if (l && (method === "GET" || method === "HEAD")) {
249
+ const v = deps.flows.get(l[1], "link");
250
+ if (!v) {
251
+ deps.sendJson(res, { ok: false, state: "gone", code: "not_found" }, 404);
252
+ return true;
253
+ }
254
+ if (v.state === "ok") deps.flows.claim(v.id, "link");
255
+ const message = finishedMessage(v, deps.cli);
256
+ deps.sendJson(res, {
257
+ ok: v.state === "pending" || v.state === "ok",
258
+ ...flowJson(v),
259
+ ...(message ? { error: message } : {}),
260
+ });
261
+ return true;
262
+ }
263
+ if (pathname === "/api/web/owner/unlink" && method === "POST") {
264
+ const was = clearWebOwner(deps.stateDir());
265
+ if (was) deps.log(` owner unlinked (was @${was.login})`);
266
+ deps.sendJson(res, { ok: true, was: was ? was.login : null });
267
+ return true;
268
+ }
269
+
270
+ // ── Pair a device ────────────────────────────────────────────────────
271
+ if (pathname === "/api/web/pair" && method === "GET") {
272
+ deps.sendJson(res, { hosts: deps.edgeRoutes().map((r) => r.host) });
273
+ return true;
274
+ }
275
+ if (pathname === "/api/web/pair" && method === "POST") {
276
+ let body: { host?: unknown } = {};
277
+ try {
278
+ body = (JSON.parse((await deps.readBody(req)) || "{}") ??
279
+ {}) as typeof body;
280
+ } catch {
281
+ deps.sendJson(
282
+ res,
283
+ { ok: false, error: "the body must be JSON: { host }" },
284
+ 400,
285
+ );
286
+ return true;
287
+ }
288
+ const want = String(body.host ?? "")
289
+ .trim()
290
+ .toLowerCase();
291
+ const routes = deps.edgeRoutes();
292
+ const hit = routes.find((r) => r.host === want);
293
+ if (!hit) {
294
+ deps.sendJson(
295
+ res,
296
+ {
297
+ ok: false,
298
+ code: "unknown_host",
299
+ error: routes.length
300
+ ? `${want || "(none)"} isn't an edge hostname that routes here (${routes.map((r) => r.host).join(", ")}).`
301
+ : "No edge hostname routes to this server: add one to edge.routes or edge.aliases, then `edge up`.",
302
+ },
303
+ 400,
304
+ );
305
+ return true;
306
+ }
307
+ const link = deps.mintLink(hit.url);
308
+ if (!link) {
309
+ deps.sendJson(res, { ok: false, error: "web.secret is missing" }, 500);
310
+ return true;
311
+ }
312
+ deps.sendJson(res, {
313
+ ok: true,
314
+ host: hit.host,
315
+ url: link.url,
316
+ expiresAt: new Date(link.expiresAt).toISOString(),
317
+ });
318
+ return true;
319
+ }
320
+ return false;
321
+ }
@@ -298,6 +298,16 @@ export interface ErrorPageData {
298
298
  cmd: string;
299
299
  localhost?: string;
300
300
  expired?: boolean;
301
+ /**
302
+ * SEC-2a: Sign in with GitHub. `state` ready, or why it can't be offered
303
+ * (no client id configured, no owner linked); `owner` the command that
304
+ * links one; `message` what to set while no client id is configured.
305
+ */
306
+ github?: {
307
+ state: "ready" | "no_client_id" | "no_owner";
308
+ owner: string;
309
+ message?: string;
310
+ };
301
311
  };
302
312
  }
303
313