@monster-orange/den 0.4.4 → 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 (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -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
+ }
@@ -52,11 +52,34 @@ export class OnePasswordProvider implements SecretsProvider {
52
52
  }
53
53
  }
54
54
 
55
+ /** Overwrite the field; an item that isn't there yet is created (a Password item). */
55
56
  async set(ref: SecretRef, value: string): Promise<void> {
56
- const args = ["item", "edit", ref.item];
57
- if (ref.vault) args.push("--vault", ref.vault);
58
- args.push(`${ref.field}[password]=${value}`);
59
- await execFileAsync("op", args, { timeout: this.timeout });
57
+ const vault = ref.vault ? ["--vault", ref.vault] : [];
58
+ const field = `${ref.field}[password]=${value}`;
59
+ try {
60
+ await execFileAsync("op", ["item", "edit", ref.item, ...vault, field], {
61
+ timeout: this.timeout,
62
+ });
63
+ } catch (err) {
64
+ try {
65
+ await execFileAsync(
66
+ "op",
67
+ [
68
+ "item",
69
+ "create",
70
+ "--category",
71
+ "password",
72
+ "--title",
73
+ ref.item,
74
+ ...vault,
75
+ field,
76
+ ],
77
+ { timeout: this.timeout },
78
+ );
79
+ } catch {
80
+ throw err;
81
+ }
82
+ }
60
83
  }
61
84
 
62
85
  async preflight(): Promise<PreflightResult> {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * SEC-2a: the owner's GitHub identity. `<state dir>/web-owner.json`, mode
3
+ * 0600: `{ v: 1, owner: "github:<numeric id>", login, linkedAt }`. The id
4
+ * is what sign-in compares; the login is for display only (a login can be
5
+ * renamed, the id can't). No token is ever stored: the device flow's token
6
+ * is dropped right after `GET /user`.
7
+ *
8
+ * Written by `<cli> web owner link` and Settings › Access; removed by
9
+ * `web owner unlink`. Re-read whenever it changes on disk.
10
+ */
11
+ import { randomBytes } from "crypto";
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { getStateDir } from "./paths.js";
15
+
16
+ export const WEB_OWNER_FILE = "web-owner.json";
17
+
18
+ export interface WebOwner {
19
+ /** `github:<numeric id>`. */
20
+ owner: `github:${number}`;
21
+ /** The GitHub login at link time, for display. */
22
+ login: string;
23
+ /** ISO time it was linked. */
24
+ linkedAt: string;
25
+ }
26
+
27
+ export function webOwnerPath(stateDir: string = getStateDir()): string {
28
+ return path.join(stateDir, WEB_OWNER_FILE);
29
+ }
30
+
31
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
32
+
33
+ /** The linked owner, or null (none, or a file that isn't one). */
34
+ export function readWebOwner(
35
+ stateDir: string = getStateDir(),
36
+ ): WebOwner | null {
37
+ let raw: unknown;
38
+ try {
39
+ raw = JSON.parse(fs.readFileSync(webOwnerPath(stateDir), "utf-8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ const o = raw as Partial<WebOwner> | null;
44
+ if (
45
+ !o ||
46
+ typeof o.owner !== "string" ||
47
+ !/^github:[1-9][0-9]{0,15}$/.test(o.owner) ||
48
+ typeof o.login !== "string" ||
49
+ !LOGIN.test(o.login)
50
+ )
51
+ return null;
52
+ return {
53
+ owner: o.owner,
54
+ login: o.login,
55
+ linkedAt: typeof o.linkedAt === "string" ? o.linkedAt : "",
56
+ };
57
+ }
58
+
59
+ /** Link `id` (`login` for display) as the owner: atomic, 0600. */
60
+ export function writeWebOwner(
61
+ identity: { id: number; login: string },
62
+ stateDir: string = getStateDir(),
63
+ now: Date = new Date(),
64
+ ): WebOwner {
65
+ if (!Number.isSafeInteger(identity.id) || identity.id <= 0)
66
+ throw new Error("a GitHub account id is a positive integer");
67
+ if (!LOGIN.test(identity.login))
68
+ throw new Error("that is not a GitHub login");
69
+ const rec: WebOwner = {
70
+ owner: `github:${identity.id}`,
71
+ login: identity.login,
72
+ linkedAt: now.toISOString(),
73
+ };
74
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
75
+ const file = webOwnerPath(stateDir);
76
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
77
+ fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, ...rec })}\n`, {
78
+ mode: 0o600,
79
+ });
80
+ fs.renameSync(tmp, file);
81
+ return rec;
82
+ }
83
+
84
+ /** Unlink the owner; the one it was, or null when none was linked. */
85
+ export function clearWebOwner(
86
+ stateDir: string = getStateDir(),
87
+ ): WebOwner | null {
88
+ const was = readWebOwner(stateDir);
89
+ fs.rmSync(webOwnerPath(stateDir), { force: true });
90
+ return was;
91
+ }