@monster-orange/den 0.4.4 → 0.4.6

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 (57) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +24 -9
  3. package/docs/distributions.md +320 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +4 -3
  6. package/skills/install-den/SKILL.md +114 -0
  7. package/src/shared/agent-setup.ts +9 -0
  8. package/src/shared/auth/sudo-vault.ts +115 -11
  9. package/src/shared/cli-command.ts +186 -0
  10. package/src/shared/confirm-code.ts +34 -0
  11. package/src/shared/constants.ts +7 -0
  12. package/src/shared/den-config.ts +7 -0
  13. package/src/shared/dev-metrics/emit.ts +59 -2
  14. package/src/shared/edge/config.ts +39 -0
  15. package/src/shared/edge/render.ts +92 -18
  16. package/src/shared/edge/runtime.ts +57 -1
  17. package/src/shared/edge/types.ts +11 -1
  18. package/src/shared/github-app.ts +94 -0
  19. package/src/shared/github-device.ts +286 -0
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/shared/secrets/onepassword.ts +27 -4
  22. package/src/shared/web-owner.ts +91 -0
  23. package/src/shared/web-session.ts +135 -1
  24. package/src/ui/cli/create-main.ts +1 -1
  25. package/src/ui/cli/create.ts +263 -30
  26. package/src/ui/cli/doctor-command.ts +67 -4
  27. package/src/ui/cli/doctor-onboard.ts +291 -0
  28. package/src/ui/cli/flag-parser.ts +5 -1
  29. package/src/ui/cli/index.ts +246 -4
  30. package/src/ui/cli/json-out.ts +49 -0
  31. package/src/ui/cli/mcp-connect.ts +179 -15
  32. package/src/ui/cli/repo-command.ts +490 -0
  33. package/src/ui/cli/repo-detect.ts +192 -0
  34. package/src/ui/cli/web-command.ts +513 -33
  35. package/src/ui/mcp/connect.ts +134 -0
  36. package/src/ui/server/device-flows.ts +274 -0
  37. package/src/ui/server/edge-exposure.ts +16 -4
  38. package/src/ui/server/github-routes.ts +321 -0
  39. package/src/ui/server/http-errors.ts +10 -0
  40. package/src/ui/server/index.ts +463 -27
  41. package/src/ui/server/login-waits.ts +203 -0
  42. package/src/ui/server/pair-requests.ts +237 -0
  43. package/src/ui/server/pair-routes.ts +196 -0
  44. package/src/ui/server/public/error-page.js +452 -40
  45. package/src/ui/server/public/error.html +59 -0
  46. package/src/ui/server/public/i18n/es.js +74 -8
  47. package/src/ui/server/public/i18n/ja.js +74 -8
  48. package/src/ui/server/public/local-dev.html +16 -4
  49. package/src/ui/server/public/qr.js +317 -0
  50. package/src/ui/server/public/settings-page.js +10 -3
  51. package/src/ui/server/public/settings-registry.js +392 -1
  52. package/src/ui/server/public/shell.js +123 -10
  53. package/src/ui/server/public/topbar.js +7 -3
  54. package/src/ui/server/public/workspace-picker.js +1 -1
  55. package/src/ui/server/request-guard.ts +103 -36
  56. package/src/ui/server/route-classes.ts +102 -2
  57. package/src/ui/server/ui-assets.ts +2 -0
@@ -220,6 +220,97 @@ export const EDGE_ORG_LABEL = "den.edge.org";
220
220
  * `den.edge.org` (0.3.0-dev.40): edgeUp and `edge down` touch an existing
221
221
  * edge only when its root is theirs.
222
222
  */
223
+ /**
224
+ * The addresses the edge's entrypoints believe X-Forwarded-* headers from
225
+ * (SEC-2a, in place of `forwardedHeaders.insecure`): loopback and the
226
+ * Docker networks the edge and its own proxies run on (Docker's
227
+ * 172.16.0.0/12, Docker Desktop's VM network). From anyone else Traefik
228
+ * drops the client's headers and writes its own; either way the right-most
229
+ * X-Forwarded-For entry is the peer it saw, which is all Den believes.
230
+ */
231
+ export const EDGE_FORWARDED_TRUSTED_IPS: readonly string[] = [
232
+ "127.0.0.1/32",
233
+ "::1/128",
234
+ "172.16.0.0/12",
235
+ "192.168.65.0/24",
236
+ ];
237
+
238
+ /** An `args` entry that sets what Den owns: the entrypoints' header or PROXY trust. */
239
+ const OWNED_ARG =
240
+ /^--entrypoints\.[^.=]+\.(forwardedheaders\.(insecure|trustedips)|proxyprotocol\.)/i;
241
+
242
+ /** The `edge.args` entries Den drops (SEC-2a): forwarded-header or PROXY trust. */
243
+ export function droppedEdgeArgs(cfg: Pick<EdgeConfig, "args">): string[] {
244
+ return cfg.args.filter((a) => OWNED_ARG.test(a));
245
+ }
246
+
247
+ /** The edge entrypoints' trust (SEC-2a): forwarded headers, and the PROXY protocol when set. */
248
+ export function edgeTrustArgs(cfg: EdgeConfig): string[] {
249
+ const eps = ["web", ...(edgeHttps(cfg) ? ["websecure"] : [])];
250
+ const pp = cfg.proxyProtocolTrustedIps ?? [];
251
+ return eps.flatMap((ep) => [
252
+ `--entrypoints.${ep}.forwardedHeaders.trustedIPs=${EDGE_FORWARDED_TRUSTED_IPS.join(",")}`,
253
+ ...(pp.length
254
+ ? [`--entrypoints.${ep}.proxyProtocol.trustedIPs=${pp.join(",")}`]
255
+ : []),
256
+ ]);
257
+ }
258
+
259
+ /** The edge container's command (its Traefik static config). */
260
+ export function edgeCommand(cfg: EdgeConfig): string[] {
261
+ const https = edgeHttps(cfg);
262
+ const proxy = dockerProxyName(cfg.org);
263
+ const labels = cfg.dockerLabels;
264
+ return [
265
+ "--entrypoints.web.address=:80",
266
+ ...(https ? ["--entrypoints.websecure.address=:443"] : []),
267
+ ...edgeTrustArgs(cfg),
268
+ "--providers.file.directory=/etc/edge/dynamic",
269
+ "--providers.file.watch=true",
270
+ ...(labels
271
+ ? [
272
+ "--providers.docker=true",
273
+ `--providers.docker.endpoint=tcp://${proxy}:2375`,
274
+ "--providers.docker.exposedbydefault=false",
275
+ `--providers.docker.network=${labels.network}`,
276
+ ]
277
+ : []),
278
+ "--api.dashboard=true",
279
+ "--ping=true",
280
+ "--log.level=WARN",
281
+ ...cfg.args.filter((a) => !OWNED_ARG.test(a)),
282
+ ];
283
+ }
284
+
285
+ /**
286
+ * What changed between a running edge's command and the one `edge up`
287
+ * renders now, as words for its one-line notice; [] when nothing did (or
288
+ * the running one isn't known).
289
+ */
290
+ export function edgeCommandDrift(
291
+ running: readonly string[] | null,
292
+ rendered: readonly string[],
293
+ ): string[] {
294
+ if (!running) return [];
295
+ const a = new Set(running);
296
+ const b = new Set(rendered);
297
+ const changed = [
298
+ ...rendered.filter((x) => !a.has(x)),
299
+ ...running.filter((x) => !b.has(x)),
300
+ ];
301
+ if (!changed.length) return [];
302
+ const what = new Set<string>();
303
+ for (const c of changed)
304
+ what.add(
305
+ /forwardedheaders/i.test(c)
306
+ ? "forwarded-header trust"
307
+ : /proxyprotocol/i.test(c)
308
+ ? "PROXY protocol"
309
+ : "its command",
310
+ );
311
+ return [...what];
312
+ }
313
+
223
314
  export function renderEdgeCompose(
224
315
  cfg: EdgeConfig,
225
316
  ownerRoot: string = cfg.stateDir,
@@ -239,24 +330,7 @@ export function renderEdgeCompose(
239
330
  restart: "unless-stopped",
240
331
  labels: { [EDGE_ROOT_LABEL]: ownerRoot, [EDGE_ORG_LABEL]: cfg.org },
241
332
  ...(labels ? { depends_on: ["docker-proxy"] } : {}),
242
- command: [
243
- "--entrypoints.web.address=:80",
244
- ...(https ? ["--entrypoints.websecure.address=:443"] : []),
245
- "--providers.file.directory=/etc/edge/dynamic",
246
- "--providers.file.watch=true",
247
- ...(labels
248
- ? [
249
- "--providers.docker=true",
250
- `--providers.docker.endpoint=tcp://${proxy}:2375`,
251
- "--providers.docker.exposedbydefault=false",
252
- `--providers.docker.network=${labels.network}`,
253
- ]
254
- : []),
255
- "--api.dashboard=true",
256
- "--ping=true",
257
- "--log.level=WARN",
258
- ...cfg.args,
259
- ],
333
+ command: edgeCommand(cfg),
260
334
  ...(cfg.ports
261
335
  ? {
262
336
  ports: [
@@ -7,7 +7,10 @@ import * as net from "net";
7
7
  import * as path from "path";
8
8
  import { must, sh } from "./exec.js";
9
9
  import {
10
+ droppedEdgeArgs,
10
11
  EDGE_ROOT_LABEL,
12
+ edgeCommand,
13
+ edgeCommandDrift,
11
14
  edgeNames,
12
15
  renderEdgeCompose,
13
16
  renderEdgeDynamic,
@@ -226,9 +229,58 @@ export function assertEdgeOwnedHere(
226
229
  );
227
230
  }
228
231
 
232
+ /** A container's command (`docker inspect .Config.Cmd`), or null. */
233
+ export function runningEdgeCommand(name: string): string[] | null {
234
+ const r = sh("docker", [
235
+ "inspect",
236
+ "--type",
237
+ "container",
238
+ "-f",
239
+ "{{json .Config.Cmd}}",
240
+ name,
241
+ ]);
242
+ if (r.code !== 0) return null;
243
+ try {
244
+ const v: unknown = JSON.parse(r.out);
245
+ return Array.isArray(v) && v.every((x) => typeof x === "string")
246
+ ? (v as string[])
247
+ : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * What `edge up` says before it writes compose.yml (SEC-2a): the `edge.args`
255
+ * it drops (Den owns the entrypoints' trust), and, when the running edge's
256
+ * command differs from the new one, that it recreates the edge once and
257
+ * why. Pure: `running` is the running container's command, or null.
258
+ */
259
+ export function edgeUpNotices(
260
+ cfg: EdgeConfig,
261
+ running: readonly string[] | null,
262
+ ): string[] {
263
+ const out: string[] = [];
264
+ const dropped = droppedEdgeArgs(cfg);
265
+ if (dropped.length)
266
+ out.push(
267
+ `edge.args: ignoring ${dropped.join(", ")}: Den sets the entrypoints' ` +
268
+ "forwarded-header trust itself (its own networks and loopback), and " +
269
+ "PROXY protocol trust from edge.proxy_protocol.trusted_ips. Remove it " +
270
+ "from the config to silence this.",
271
+ );
272
+ const drift = edgeCommandDrift(running, edgeCommand(cfg));
273
+ if (drift.length)
274
+ out.push(
275
+ `${edgeNames(cfg.org).container}'s settings changed (${drift.join(", ")}): ` +
276
+ "recreating it once. Routes drop only while it restarts.",
277
+ );
278
+ return out;
279
+ }
280
+
229
281
  export async function edgeUp(
230
282
  cfg: EdgeConfig,
231
- opts: { takeOver?: boolean } = {},
283
+ opts: { takeOver?: boolean; log?: (line: string) => void } = {},
232
284
  ) {
233
285
  // Before anything is written or started: an edge of this name that
234
286
  // another state dir owns is never replaced.
@@ -261,6 +313,10 @@ export async function edgeUp(
261
313
  const included = syncIncluded(cfg);
262
314
  write(path.join(cfg.stateDir, "dynamic", GENERATED), renderEdgeDynamic(cfg));
263
315
  const compose = path.join(cfg.stateDir, "compose.yml");
316
+ // SEC-2a: say what changed before compose recreates the edge for it.
317
+ const log = opts.log ?? ((l: string) => console.log(l));
318
+ for (const line of edgeUpNotices(cfg, runningEdgeCommand(n.container)))
319
+ log(line);
264
320
  write(compose, renderEdgeCompose(cfg, realOrSelf(cfg.stateDir)));
265
321
  must("docker", ["compose", "-f", compose, "up", "-d", "--remove-orphans"]);
266
322
  return { certs, included };
@@ -73,6 +73,16 @@ export interface EdgeConfig {
73
73
  mounts: Record<string, string>;
74
74
  /** Extra external Docker networks the edge joins. */
75
75
  networks: string[];
76
- /** Extra Traefik static-config arguments. */
76
+ /**
77
+ * Extra Traefik static-config arguments. Den owns the entrypoints'
78
+ * forwarded-header and PROXY-protocol trust (SEC-2a): an argument that
79
+ * sets either is dropped, with a warning at `edge up`.
80
+ */
77
81
  args: string[];
82
+ /**
83
+ * `edge.proxy_protocol.trusted_ips` (SEC-2a): the front proxies (IPs or
84
+ * CIDRs) whose PROXY protocol header the entrypoints believe, so a TCP
85
+ * passthrough in front can pass on the real client. Empty: none.
86
+ */
87
+ proxyProtocolTrustedIps?: string[];
78
88
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SEC-2a: the MO "Den" GitHub App, as Den uses it (SEC-2 spec, "The MO Den
3
+ * GitHub App"). Den uses only its public client id, through the device flow,
4
+ * acting as the person: no client secret and no private key, ever. With the
5
+ * app's "Expire user authorization tokens" off, no refresh (which would need
6
+ * the secret) is ever needed, so Den never asks for `offline_access`.
7
+ *
8
+ * `web.github_client_id` (config/den.yml, or config/local/user.yml) replaces
9
+ * the default below; it is the one place the MO app's id lives.
10
+ */
11
+
12
+ /**
13
+ * The "Monster Orange Den" GitHub App's client id (owner Monster-Orange,
14
+ * slug monster-orange-den). Public by design: it ships in Den. A
15
+ * distribution sets its own app with `web.github_client_id`; an empty
16
+ * value there turns Sign in with GitHub off.
17
+ */
18
+ export const MO_DEN_GITHUB_CLIENT_ID = "Iv23liKL12gnN6WvRV5b";
19
+
20
+ /** What a person reads when the app issues expiring tokens (never the token). */
21
+ export function expiringTokenWarning(): string {
22
+ return (
23
+ "The GitHub App issues expiring user tokens (its answer carried expires_in " +
24
+ "or a refresh_token). Sign-in still works: Den uses the token once and never " +
25
+ "keeps it. To fix it, switch off \u201cExpire user authorization tokens\u201d in " +
26
+ "the app's settings (GitHub \u203a Settings \u203a Developer settings \u203a GitHub Apps)."
27
+ );
28
+ }
29
+
30
+ /** Where a person enters the device code. */
31
+ export const GITHUB_DEVICE_PAGE = "https://github.com/login/device";
32
+
33
+ /**
34
+ * Tests only: a fake GitHub on loopback (`http://localhost:<port>`), for the
35
+ * device-flow endpoints and `GET /user` alike. Ignored unless it is plain
36
+ * http on localhost or 127.0.0.1, so it can never point Den at another host.
37
+ */
38
+ export const GITHUB_TEST_BASE_ENV = "DEN_TEST_GITHUB_URL";
39
+
40
+ export interface GithubEndpoints {
41
+ deviceCode: string;
42
+ accessToken: string;
43
+ user: string;
44
+ /** A test's fake GitHub is in use (GITHUB_TEST_BASE_ENV). */
45
+ fake: boolean;
46
+ }
47
+
48
+ /** A loopback http base URL, without a trailing slash, or null. */
49
+ function loopbackBase(raw: string | undefined): string | null {
50
+ if (!raw) return null;
51
+ try {
52
+ const u = new URL(raw);
53
+ if (u.protocol !== "http:") return null;
54
+ if (u.hostname !== "localhost" && u.hostname !== "127.0.0.1") return null;
55
+ return u.origin;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** GitHub's device-flow and user endpoints (or a test's fake GitHub). */
62
+ export function githubEndpoints(
63
+ env: NodeJS.ProcessEnv = process.env,
64
+ ): GithubEndpoints {
65
+ const fake = loopbackBase(env[GITHUB_TEST_BASE_ENV]);
66
+ if (fake)
67
+ return {
68
+ deviceCode: `${fake}/login/device/code`,
69
+ accessToken: `${fake}/login/oauth/access_token`,
70
+ user: `${fake}/user`,
71
+ fake: true,
72
+ };
73
+ return {
74
+ deviceCode: "https://github.com/login/device/code",
75
+ accessToken: "https://github.com/login/oauth/access_token",
76
+ user: "https://api.github.com/user",
77
+ fake: false,
78
+ };
79
+ }
80
+
81
+ /** The client id in use: `web.github_client_id`, else the MO app's. */
82
+ export function githubClientId(configured: string | undefined): string {
83
+ return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
84
+ }
85
+
86
+ /** What a GitHub button says while no client id is configured. */
87
+ export function noClientIdMessage(cli: string): string {
88
+ return (
89
+ "Sign in with GitHub needs a GitHub App client id, and none is configured. " +
90
+ "Set web.github_client_id in config/local/user.yml (or the distribution's " +
91
+ `config), then restart the server (\`${cli} web restart\`). The app's setup ` +
92
+ "is in docs/distributions.md, “Signing in with GitHub”."
93
+ );
94
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * SEC-2a: GitHub's device flow, for a GitHub App with only its public client
3
+ * id (no secret), and `GET /user` for who approved it.
4
+ *
5
+ * 1. POST /login/device/code (client_id): a device code (kept here), a
6
+ * user code (shown to the person), how long both last, and the poll
7
+ * interval. No scope is asked for: identity needs none, and
8
+ * `offline_access` would force an expiring token.
9
+ * 2. POST /login/oauth/access_token (client_id, device_code, grant_type
10
+ * device_code) every `interval` seconds until it answers a token, or
11
+ * `authorization_pending` (keep going), `slow_down` (wait longer),
12
+ * `expired_token` or `access_denied` (stop).
13
+ * 3. GET /user with the token: its numeric `id` and `login`, nothing else.
14
+ * The caller drops the token right after.
15
+ *
16
+ * A token answer with `expires_in` or a `refresh_token` means the app has
17
+ * "Expire user authorization tokens" on: the result says so (`expiring`),
18
+ * for the caller's one warning; the sign-in still completes.
19
+ *
20
+ * Every call is one request on a fresh connection (no keep-alive), with a
21
+ * timeout. Nothing here logs; a token, device code or user code is never in
22
+ * an error message.
23
+ */
24
+ import * as http from "http";
25
+ import * as https from "https";
26
+ import { githubEndpoints, type GithubEndpoints } from "./github-app.js";
27
+
28
+ export interface DeviceCode {
29
+ deviceCode: string;
30
+ userCode: string;
31
+ /** Seconds the codes last. */
32
+ expiresIn: number;
33
+ /** Seconds between polls. */
34
+ interval: number;
35
+ }
36
+
37
+ export type PollResult =
38
+ | {
39
+ kind: "token";
40
+ token: string;
41
+ /**
42
+ * The answer carried `expires_in` or a `refresh_token`: the app has
43
+ * "Expire user authorization tokens" on, a misconfiguration (Den
44
+ * never refreshes; it uses the token once). Never the token itself.
45
+ */
46
+ expiring: boolean;
47
+ }
48
+ | { kind: "pending" }
49
+ | { kind: "slow_down"; interval?: number }
50
+ | { kind: "expired" }
51
+ | { kind: "denied" }
52
+ | { kind: "error"; message: string };
53
+
54
+ export interface GithubIdentity {
55
+ id: number;
56
+ login: string;
57
+ }
58
+
59
+ /** The three calls, swappable in a unit test. */
60
+ export interface GithubDeviceClient {
61
+ start(clientId: string): Promise<DeviceCode>;
62
+ poll(clientId: string, deviceCode: string): Promise<PollResult>;
63
+ user(token: string): Promise<GithubIdentity>;
64
+ }
65
+
66
+ const TIMEOUT_MS = 15_000;
67
+ const UA = "den-device-flow";
68
+
69
+ function request(
70
+ url: string,
71
+ opts: { method: string; headers: Record<string, string>; body?: string },
72
+ ): Promise<{ status: number; body: string }> {
73
+ return new Promise((resolve, reject) => {
74
+ const u = new URL(url);
75
+ const lib = u.protocol === "https:" ? https : http;
76
+ const req = lib.request(
77
+ u,
78
+ {
79
+ method: opts.method,
80
+ agent: false,
81
+ timeout: TIMEOUT_MS,
82
+ headers: {
83
+ "user-agent": UA,
84
+ connection: "close",
85
+ ...opts.headers,
86
+ ...(opts.body !== undefined
87
+ ? { "content-length": String(Buffer.byteLength(opts.body)) }
88
+ : {}),
89
+ },
90
+ },
91
+ (res) => {
92
+ let out = "";
93
+ res.setEncoding("utf-8");
94
+ res.on("data", (d: string) => (out += d));
95
+ res.on("end", () =>
96
+ resolve({ status: res.statusCode ?? 0, body: out }),
97
+ );
98
+ },
99
+ );
100
+ req.on("timeout", () => req.destroy(new Error("GitHub did not answer")));
101
+ req.on("error", (e) =>
102
+ reject(new Error(`GitHub unreachable: ${e.message}`)),
103
+ );
104
+ req.end(opts.body);
105
+ });
106
+ }
107
+
108
+ function parse(body: string): Record<string, unknown> {
109
+ try {
110
+ const v = JSON.parse(body) as unknown;
111
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
112
+ } catch {
113
+ return {};
114
+ }
115
+ }
116
+
117
+ const form = (o: Record<string, string>) => new URLSearchParams(o).toString();
118
+ const FORM_HEADERS = {
119
+ accept: "application/json",
120
+ "content-type": "application/x-www-form-urlencoded",
121
+ };
122
+
123
+ /** The real GitHub (or a test's fake one, githubEndpoints). */
124
+ export function githubDeviceClient(
125
+ ep: GithubEndpoints = githubEndpoints(),
126
+ ): GithubDeviceClient {
127
+ return {
128
+ async start(clientId) {
129
+ const r = await request(ep.deviceCode, {
130
+ method: "POST",
131
+ headers: FORM_HEADERS,
132
+ body: form({ client_id: clientId }),
133
+ });
134
+ const j = parse(r.body);
135
+ if (
136
+ r.status !== 200 ||
137
+ typeof j.device_code !== "string" ||
138
+ typeof j.user_code !== "string"
139
+ ) {
140
+ const why =
141
+ typeof j.error === "string"
142
+ ? j.error === "device_flow_disabled"
143
+ ? "the GitHub App's device flow is off (tick “Enable Device Flow”)"
144
+ : j.error === "unauthorized_client" ||
145
+ j.error === "incorrect_client_credentials"
146
+ ? "GitHub doesn't know this client id (web.github_client_id)"
147
+ : `GitHub answered ${j.error}`
148
+ : `GitHub answered ${r.status}`;
149
+ throw new Error(why);
150
+ }
151
+ return {
152
+ deviceCode: j.device_code,
153
+ userCode: j.user_code,
154
+ expiresIn: Number(j.expires_in) > 0 ? Number(j.expires_in) : 900,
155
+ interval: Number(j.interval) >= 0 ? Number(j.interval) : 5,
156
+ };
157
+ },
158
+ async poll(clientId, deviceCode) {
159
+ let r: { status: number; body: string };
160
+ try {
161
+ r = await request(ep.accessToken, {
162
+ method: "POST",
163
+ headers: FORM_HEADERS,
164
+ body: form({
165
+ client_id: clientId,
166
+ device_code: deviceCode,
167
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
168
+ }),
169
+ });
170
+ } catch (e) {
171
+ return { kind: "error", message: (e as Error).message };
172
+ }
173
+ const j = parse(r.body);
174
+ if (typeof j.access_token === "string" && j.access_token)
175
+ return {
176
+ kind: "token",
177
+ token: j.access_token,
178
+ expiring: j.expires_in !== undefined || j.refresh_token !== undefined,
179
+ };
180
+ switch (j.error) {
181
+ case "authorization_pending":
182
+ return { kind: "pending" };
183
+ case "slow_down":
184
+ return {
185
+ kind: "slow_down",
186
+ ...(Number(j.interval) > 0 ? { interval: Number(j.interval) } : {}),
187
+ };
188
+ case "expired_token":
189
+ return { kind: "expired" };
190
+ case "access_denied":
191
+ return { kind: "denied" };
192
+ default:
193
+ return {
194
+ kind: "error",
195
+ message:
196
+ typeof j.error === "string"
197
+ ? `GitHub answered ${j.error}`
198
+ : `GitHub answered ${r.status}`,
199
+ };
200
+ }
201
+ },
202
+ async user(token) {
203
+ const r = await request(ep.user, {
204
+ method: "GET",
205
+ headers: {
206
+ accept: "application/vnd.github+json",
207
+ authorization: `Bearer ${token}`,
208
+ "x-github-api-version": "2022-11-28",
209
+ },
210
+ });
211
+ const j = parse(r.body);
212
+ if (
213
+ r.status !== 200 ||
214
+ typeof j.id !== "number" ||
215
+ !Number.isSafeInteger(j.id) ||
216
+ typeof j.login !== "string"
217
+ )
218
+ throw new Error(`GitHub's /user answered ${r.status}`);
219
+ return { id: j.id, login: j.login };
220
+ },
221
+ };
222
+ }
223
+
224
+ /** `github:<numeric id>`: how Den names a GitHub account. */
225
+ export function githubPrincipal(id: number): `github:${number}` {
226
+ return `github:${id}`;
227
+ }
228
+
229
+ export type DeviceFlowOutcome =
230
+ | { kind: "ok"; identity: GithubIdentity; expiringToken?: boolean }
231
+ | { kind: "expired" }
232
+ | { kind: "denied" }
233
+ | { kind: "error"; message: string };
234
+
235
+ /**
236
+ * The whole flow in one call (the CLI's `web owner link`): start, show the
237
+ * code (`onCode`), poll at GitHub's interval (`sleep`, injected in a test),
238
+ * read the identity, drop the token. `minIntervalMs` is the floor between
239
+ * polls (GitHub's own interval is never shorter than it says).
240
+ */
241
+ export async function runDeviceFlow(opts: {
242
+ clientId: string;
243
+ client?: GithubDeviceClient;
244
+ onCode: (code: DeviceCode) => void;
245
+ sleep?: (ms: number) => Promise<void>;
246
+ now?: () => number;
247
+ minIntervalMs?: number;
248
+ }): Promise<DeviceFlowOutcome> {
249
+ const client = opts.client ?? githubDeviceClient();
250
+ const sleep =
251
+ opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
252
+ const now = opts.now ?? Date.now;
253
+ const code = await client.start(opts.clientId);
254
+ opts.onCode(code);
255
+ const deadline = now() + code.expiresIn * 1000;
256
+ const floor = opts.minIntervalMs ?? 1000;
257
+ let interval = Math.max(code.interval * 1000, floor);
258
+ let errors = 0;
259
+ for (;;) {
260
+ await sleep(interval);
261
+ if (now() >= deadline) return { kind: "expired" };
262
+ const r = await client.poll(opts.clientId, code.deviceCode);
263
+ if (r.kind === "token") {
264
+ // The token lives only for this one call.
265
+ const identity = await client.user(r.token);
266
+ return {
267
+ kind: "ok",
268
+ identity,
269
+ ...(r.expiring ? { expiringToken: true } : {}),
270
+ };
271
+ }
272
+ if (r.kind === "pending") continue;
273
+ if (r.kind === "slow_down") {
274
+ interval = Math.max(
275
+ r.interval !== undefined ? r.interval * 1000 : interval + 5000,
276
+ floor,
277
+ );
278
+ continue;
279
+ }
280
+ if (r.kind === "error") {
281
+ if (++errors >= 5) return { kind: "error", message: r.message };
282
+ continue;
283
+ }
284
+ return r;
285
+ }
286
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Picking a free localhost port for a new distribution's web server
3
+ * (`create`, ON-2), so a second Den on one machine doesn't collide with the
4
+ * first on DEFAULT_PORT.
5
+ *
6
+ * "In use" is checked without talking to whoever holds the port: a TCP
7
+ * connect that is closed as soon as it opens (no byte is sent), then a
8
+ * bind. Either one finding something there means in use.
9
+ */
10
+ import * as net from "net";
11
+ import { RESERVED_DEN_PORTS } from "./constants.js";
12
+
13
+ /** Whether something holds `port` on localhost. Never sends a byte. */
14
+ export function portInUse(port: number, host = "localhost"): Promise<boolean> {
15
+ return new Promise((resolve) => {
16
+ const sock = net.connect({ host, port });
17
+ const done = (inUse: boolean | null) => {
18
+ sock.destroy();
19
+ if (inUse !== null) return resolve(inUse);
20
+ // Nothing accepted a connection: can we bind it ourselves?
21
+ const srv = net.createServer();
22
+ srv.once("error", () => resolve(true));
23
+ srv.listen({ port, host, exclusive: true }, () =>
24
+ srv.close(() => resolve(false)),
25
+ );
26
+ };
27
+ sock.setTimeout(1_000);
28
+ sock.once("connect", () => done(true));
29
+ // Something there that doesn't complete a handshake: in use.
30
+ sock.once("timeout", () => done(true));
31
+ sock.once("error", (e: NodeJS.ErrnoException) =>
32
+ done(e.code === "ECONNREFUSED" ? null : true),
33
+ );
34
+ });
35
+ }
36
+
37
+ export interface PickPortOptions {
38
+ /** The port wanted (the default); the search goes up from here. */
39
+ start: number;
40
+ /** Never chosen. */
41
+ reserved?: readonly number[];
42
+ /** How many ports above `start` to try. */
43
+ span?: number;
44
+ inUse?: (port: number) => Promise<boolean>;
45
+ }
46
+
47
+ /**
48
+ * `start` when it is free, else the first free port above it that is not
49
+ * reserved; null when none in the span is free.
50
+ */
51
+ export async function pickFreePort(o: PickPortOptions): Promise<number | null> {
52
+ const reserved = o.reserved ?? RESERVED_DEN_PORTS;
53
+ const inUse = o.inUse ?? ((p: number) => portInUse(p));
54
+ const span = o.span ?? 100;
55
+ for (let p = o.start; p <= Math.min(o.start + span, 65535); p++) {
56
+ if (reserved.includes(p)) continue;
57
+ if (!(await inUse(p))) return p;
58
+ }
59
+ return null;
60
+ }