@monster-orange/den 0.4.20 → 0.4.21

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.
@@ -23,7 +23,62 @@ import {
23
23
  readWebOwner,
24
24
  writeWebOwner,
25
25
  } from "../../shared/web-owner.js";
26
- import type { DeviceFlows, FlowView } from "./device-flows.js";
26
+ import type {
27
+ Decide,
28
+ Decision,
29
+ DeviceFlows,
30
+ FlowView,
31
+ } from "./device-flows.js";
32
+
33
+ /**
34
+ * D17: who may claim a Den that has no owner yet.
35
+ * - `ownerGithub` (web.owner_github, recorded from `gh` where the
36
+ * instance was started): that login, from any machine.
37
+ * - `allowedGithubOrg` (web.allowed_github_org, the distribution's): a
38
+ * member of the org, only with this machine's proof (a session, which
39
+ * on a fresh Den only "This is my computer" or a terminal gives): the
40
+ * owner link, never the signed-out sign-in.
41
+ * Anyone else is refused, with why. Neither set: nobody claims from the
42
+ * browser; the page names the settings.
43
+ */
44
+ export interface ClaimRules {
45
+ ownerGithub?: string;
46
+ allowedGithubOrg?: string;
47
+ /** The file web.allowed_github_org is in (for the messages). */
48
+ allowedGithubOrgFile?: string;
49
+ }
50
+
51
+ const sameLogin = (a: string, b: string | undefined) =>
52
+ !!b && a.toLowerCase() === b.toLowerCase();
53
+
54
+ /** Why a signed-out GitHub sign-in can't claim a Den with no owner. */
55
+ export function unclaimedSigninReason(
56
+ login: string | null,
57
+ r: ClaimRules,
58
+ ): string {
59
+ const who = login ? `@${login}` : "This account";
60
+ if (r.allowedGithubOrg)
61
+ return (
62
+ `${who} can't claim this Den from here: it has no owner yet. ` +
63
+ `A member of the ${r.allowedGithubOrg} GitHub org claims it on the computer it runs on: ` +
64
+ `open this page there and choose This is my computer.` +
65
+ (r.ownerGithub
66
+ ? ` From another computer, only @${r.ownerGithub} (web.owner_github) can.`
67
+ : "")
68
+ );
69
+ if (r.ownerGithub)
70
+ return `${who} isn't the account this Den was started by (@${r.ownerGithub}, web.owner_github). Sign in as @${r.ownerGithub}.`;
71
+ return noRuleMessage(r);
72
+ }
73
+
74
+ /** Neither rule is set: the settings that would let someone claim it. */
75
+ export function noRuleMessage(r: ClaimRules): string {
76
+ return (
77
+ "This Den has no owner yet, and nothing says who may claim it. " +
78
+ `Set web.allowed_github_org (your GitHub org) in ${r.allowedGithubOrgFile ?? "the distribution config"}, ` +
79
+ "or web.owner_github (your GitHub login) in config/local/user.yml on the computer it runs on."
80
+ );
81
+ }
27
82
 
28
83
  export interface GithubRouteDeps {
29
84
  flows: DeviceFlows;
@@ -47,23 +102,76 @@ export interface GithubRouteDeps {
47
102
  log: (line: string) => void;
48
103
  /** Where the owner file lives (the server's state dir). */
49
104
  stateDir: () => string;
105
+ /** D17: who may claim a Den with no owner (read now). */
106
+ claimRules?: () => ClaimRules;
107
+ /**
108
+ * D17: spend a "This is my computer" code (attest-code.ts) for this
109
+ * request's host: true once, for a valid one.
110
+ */
111
+ spendAttest?: (req: http.IncomingMessage, code: string) => boolean;
50
112
  }
51
113
 
52
114
  const SIGNIN_PATH = /^\/api\/github\/signin\/([A-Za-z0-9_-]{16,64})$/;
53
115
  const LINK_PATH = /^\/api\/web\/owner\/link\/([A-Za-z0-9_-]{16,64})$/;
54
116
 
55
- /** What an identity means for a flow: the owner's sign-in, or the owner's link. */
117
+ /**
118
+ * What an identity means for a flow: the owner's sign-in, or the owner's
119
+ * link; with no owner yet, a claim by ClaimRules (D17).
120
+ */
56
121
  export function decideFlow(
57
122
  stateDir: () => string,
58
123
  log: (line: string) => void,
59
- ): (
60
- purpose: "signin" | "link",
61
- identity: { id: number; login: string },
62
- ) => "ok" | "refused" {
63
- return (purpose, identity) => {
124
+ rules: () => ClaimRules = () => ({}),
125
+ ): Decide {
126
+ /** Link `identity` as the owner unless one appeared meanwhile. */
127
+ const claim = (
128
+ identity: { id: number; login: string },
129
+ how: string,
130
+ ): "ok" | "refused" => {
131
+ const now = readWebOwner(stateDir());
132
+ if (now)
133
+ return now.owner === githubPrincipal(identity.id) ? "ok" : "refused";
134
+ writeWebOwner(identity, stateDir());
135
+ log(` ✓ owner linked: @${identity.login} (${how})`);
136
+ return "ok";
137
+ };
138
+ /** A member of `org` claims it; anyone else is refused, with why. */
139
+ const memberClaim = async (
140
+ identity: { id: number; login: string },
141
+ org: string,
142
+ ctx: { orgMember(org: string): Promise<string> },
143
+ ): Promise<Decision> => {
144
+ const m = await ctx.orgMember(org);
145
+ if (m === "member") return claim(identity, `member of ${org}`);
146
+ log(
147
+ ` ✗ owner claim refused: @${identity.login} ${m === "not_member" ? "isn't a member of" : "has no readable membership in"} ${org}`,
148
+ );
149
+ return {
150
+ refused:
151
+ m === "not_member"
152
+ ? `@${identity.login} isn't a member of the ${org} GitHub org (web.allowed_github_org), so it can't own this Den.`
153
+ : `Den couldn't tell whether @${identity.login} is a member of ${org}: the membership is private, and the GitHub App can't read it. ` +
154
+ `Either make your membership public (github.com/orgs/${org}/people), or have an ${org} org admin grant the GitHub App Members (read) access. ` +
155
+ `Or set web.owner_github: ${identity.login} in config/local/user.yml on this computer (\`gh auth login\` before \`web up\` does it for you).`,
156
+ };
157
+ };
158
+ return async (purpose, identity, ctx) => {
64
159
  const owner = readWebOwner(stateDir());
160
+ const r = rules();
161
+ const named = sameLogin(identity.login, r.ownerGithub);
65
162
  if (purpose === "signin") {
66
- const ok = owner?.owner === githubPrincipal(identity.id);
163
+ if (!owner) {
164
+ // D17: the login web.owner_github names claims it from anywhere;
165
+ // a member of the org, only with this machine's proof.
166
+ if (named) return claim(identity, "web.owner_github");
167
+ if (r.allowedGithubOrg && ctx.local)
168
+ return memberClaim(identity, r.allowedGithubOrg, ctx);
169
+ log(
170
+ ` ✗ GitHub sign-in refused: @${identity.login} can't claim this Den (no owner yet)`,
171
+ );
172
+ return { refused: unclaimedSigninReason(identity.login, r) };
173
+ }
174
+ const ok = owner.owner === githubPrincipal(identity.id);
67
175
  log(
68
176
  ok
69
177
  ? ` ✓ signed in with GitHub (@${identity.login})`
@@ -72,9 +180,19 @@ export function decideFlow(
72
180
  return ok ? "ok" : "refused";
73
181
  }
74
182
  if (owner) return "refused";
75
- writeWebOwner(identity, stateDir());
76
- log(` ✓ owner linked: @${identity.login}`);
77
- return "ok";
183
+ // The link: a signed-in session (this machine's proof on a fresh Den).
184
+ if (named) return claim(identity, "web.owner_github");
185
+ if (r.allowedGithubOrg)
186
+ return memberClaim(identity, r.allowedGithubOrg, ctx);
187
+ if (r.ownerGithub) {
188
+ log(
189
+ ` ✗ owner link refused: @${identity.login} isn't web.owner_github (@${r.ownerGithub})`,
190
+ );
191
+ return {
192
+ refused: `@${identity.login} isn't the account this Den was started by (@${r.ownerGithub}, web.owner_github). Link @${r.ownerGithub}, or change web.owner_github in config/local/user.yml.`,
193
+ };
194
+ }
195
+ return claim(identity, "linked in Settings");
78
196
  };
79
197
  }
80
198
 
@@ -97,6 +215,7 @@ function flowJson(v: FlowView): Record<string, unknown> {
97
215
  function finishedMessage(v: FlowView, cli: string): string | undefined {
98
216
  switch (v.state) {
99
217
  case "refused":
218
+ if (v.message) return v.message;
100
219
  return v.purpose === "signin"
101
220
  ? `@${v.login} isn't the owner of this Den. Only the owner's account can use Sign in with GitHub. Use Approve this computer instead.`
102
221
  : `An owner is linked already; \`${cli} web owner unlink\` first.`;
@@ -115,10 +234,11 @@ async function startFlow(
115
234
  deps: GithubRouteDeps,
116
235
  res: http.ServerResponse,
117
236
  purpose: "signin" | "link",
237
+ meta: { local?: boolean } = {},
118
238
  ): Promise<void> {
119
239
  let r;
120
240
  try {
121
- r = await deps.flows.start(purpose);
241
+ r = await deps.flows.start(purpose, meta);
122
242
  } catch (e) {
123
243
  return deps.sendJson(
124
244
  res,
@@ -167,19 +287,52 @@ export async function handleGithubRoutes(
167
287
 
168
288
  // ── Sign in with GitHub ──────────────────────────────────────────────
169
289
  if (pathname === "/api/github/signin" && method === "POST") {
170
- if (!readWebOwner(deps.stateDir())) {
290
+ // D17: "This is my computer"'s code, if the page has one (the body's
291
+ // `attest`): spent here, once, for this host.
292
+ let body: { attest?: unknown } = {};
293
+ try {
294
+ body = (JSON.parse((await deps.readBody(req)) || "{}") ??
295
+ {}) as typeof body;
296
+ } catch {
297
+ body = {};
298
+ }
299
+ const attest = typeof body.attest === "string" ? body.attest : "";
300
+ const local = !!attest && !!deps.spendAttest?.(req, attest);
301
+ if (attest && !local) {
302
+ deps.sendJson(
303
+ res,
304
+ {
305
+ ok: false,
306
+ code: "attest_failed",
307
+ error:
308
+ "This browser isn't on the computer this Den runs on, or its proof expired. Try This is my computer again, on that computer.",
309
+ },
310
+ 403,
311
+ );
312
+ return true;
313
+ }
314
+ // D17: with no owner, web.owner_github's login may claim it from
315
+ // anywhere; a member of web.allowed_github_org, with this machine's proof.
316
+ const rules = deps.claimRules?.() ?? {};
317
+ if (
318
+ !readWebOwner(deps.stateDir()) &&
319
+ !rules.ownerGithub &&
320
+ !(local && rules.allowedGithubOrg)
321
+ ) {
171
322
  deps.sendJson(
172
323
  res,
173
324
  {
174
325
  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\`.`,
326
+ code: rules.allowedGithubOrg ? "claim_here" : "no_owner",
327
+ error: rules.allowedGithubOrg
328
+ ? unclaimedSigninReason(null, rules)
329
+ : `${noRuleMessage(rules)} Or, on that computer, run \`${deps.cli} web owner link\`.`,
177
330
  },
178
331
  409,
179
332
  );
180
333
  return true;
181
334
  }
182
- await startFlow(deps, res, "signin");
335
+ await startFlow(deps, res, "signin", { local });
183
336
  return true;
184
337
  }
185
338
  const s = SIGNIN_PATH.exec(pathname);
@@ -229,7 +229,7 @@ import {
229
229
  ignoredConfigWarning,
230
230
  localhostOrigin,
231
231
  loginLink,
232
- LOGIN_CODE_TTL_MS,
232
+ LOGIN_CODE_MAX_TTL_MS,
233
233
  mintLoginCode,
234
234
  readWebAuthConfig,
235
235
  safeNext,
@@ -243,6 +243,14 @@ import {
243
243
  import { serveUiAsset } from "./ui-assets.js";
244
244
  import { PERSON_GRANT_ROUTES, personGrantAllowed } from "./approvals.js";
245
245
  import { ClaimNonces, checkClaimCode } from "../../shared/claim-code.js";
246
+ import {
247
+ ATTEST_NONCES_FILE,
248
+ attestRedirect,
249
+ attestReturn,
250
+ checkAttestCode,
251
+ mintAttestCode,
252
+ } from "../../shared/attest-code.js";
253
+ import type { ClaimRules } from "./github-routes.js";
246
254
  import {
247
255
  expiringTokenWarning,
248
256
  githubClientId,
@@ -252,6 +260,7 @@ import {
252
260
  } from "../../shared/github-app.js";
253
261
  import { githubDeviceClient } from "../../shared/github-device.js";
254
262
  import { readWebOwner } from "../../shared/web-owner.js";
263
+ import { noRuleMessage, unclaimedSigninReason } from "./github-routes.js";
255
264
  import { DeviceFlows } from "./device-flows.js";
256
265
  import {
257
266
  decideFlow,
@@ -897,7 +906,7 @@ import {
897
906
  edgeExposedMessage,
898
907
  edgeExposure,
899
908
  edgeNetworkSubnets,
900
- } from "./edge-exposure.js";
909
+ } from "../../shared/edge/exposure.js";
901
910
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
902
911
  import {
903
912
  linksFor,
@@ -1053,6 +1062,89 @@ const WEB_AUTH: WebAuthConfig = (() => {
1053
1062
  }
1054
1063
  })();
1055
1064
 
1065
+ /** web's sign-in config read now (Settings may change it), else at start. */
1066
+ function webAuthNow(): WebAuthConfig {
1067
+ try {
1068
+ return readWebAuthConfig(getDistRoot(), product().configFile);
1069
+ } catch {
1070
+ return WEB_AUTH;
1071
+ }
1072
+ }
1073
+
1074
+ /** D17: who may claim this Den while it has no owner (read now). */
1075
+ function claimRulesNow(): ClaimRules {
1076
+ const w = webAuthNow();
1077
+ return {
1078
+ ...(w.ownerGithub ? { ownerGithub: w.ownerGithub } : {}),
1079
+ ...(w.allowedGithubOrg ? { allowedGithubOrg: w.allowedGithubOrg } : {}),
1080
+ // Where it is, or would go: the distribution config.
1081
+ allowedGithubOrgFile: w.allowedGithubOrgFile ?? product().configFile,
1082
+ };
1083
+ }
1084
+
1085
+ /** D17: the spent "This is my computer" codes (attest-code.ts). */
1086
+ const ATTEST_NONCES = new ClaimNonces(getStateDir(), ATTEST_NONCES_FILE);
1087
+
1088
+ /** The request's Host without its port, lowercase. */
1089
+ function hostNameOf(req: http.IncomingMessage): string {
1090
+ return String(req.headers.host ?? "")
1091
+ .toLowerCase()
1092
+ .replace(/:\d+$/, "");
1093
+ }
1094
+
1095
+ /**
1096
+ * D17: spend an attest code for this request's host: one of this server's
1097
+ * edge hostnames, the code minted for it, unused. True once.
1098
+ */
1099
+ function spendAttest(req: http.IncomingMessage, code: string): boolean {
1100
+ const secret = webSecret.current();
1101
+ const host = hostNameOf(req);
1102
+ if (!secret || !denEdgeHosts().includes(host)) return false;
1103
+ const c = checkAttestCode(code, secret, host);
1104
+ return c.ok && ATTEST_NONCES.consume(c.nonce, c.expiresAt);
1105
+ }
1106
+
1107
+ /**
1108
+ * `GET /attest?return=<edge URL>` (D17, fix A): only on localhost (only a
1109
+ * browser on this machine reaches it there). A 303 back to `return`, one of
1110
+ * this server's own edge hosts, with a short-lived code in the fragment.
1111
+ * Anything else: an error page, and no code.
1112
+ */
1113
+ function handleAttest(
1114
+ req: http.IncomingMessage,
1115
+ res: http.ServerResponse,
1116
+ url: URL,
1117
+ guard: GuardResult,
1118
+ requestId: string,
1119
+ ): void {
1120
+ res.setHeader("Cache-Control", "no-store");
1121
+ res.setHeader("Referrer-Policy", "no-referrer");
1122
+ const refuse = (status: 400 | 403, message: string) =>
1123
+ sendErrorPage(req, res, {
1124
+ status,
1125
+ path: "/attest",
1126
+ message,
1127
+ code: status === 403 ? "not_this_computer" : "bad_return",
1128
+ requestId,
1129
+ });
1130
+ if (!guard.ok || guard.via !== "loopback" || guard.beyond)
1131
+ return refuse(
1132
+ 403,
1133
+ "This works only on the computer this Den runs on, at localhost.",
1134
+ );
1135
+ const ret = attestReturn(url.searchParams.get("return"), denEdgeHosts());
1136
+ const secret = webSecret.current();
1137
+ if (!ret || !secret)
1138
+ return refuse(
1139
+ 400,
1140
+ "That address isn't one of this Den's own. Open this Den at its own address and try again.",
1141
+ );
1142
+ res.writeHead(303, {
1143
+ Location: attestRedirect(ret, mintAttestCode(secret, ret.host)),
1144
+ });
1145
+ res.end();
1146
+ }
1147
+
1056
1148
  /**
1057
1149
  * The loopback names, with the port: localhost (canonical), 127.0.0.1 and
1058
1150
  * [::1]; the product's own name when /etc/hosts maps it to 127.0.0.1 (the
@@ -1198,7 +1290,11 @@ const GITHUB = githubEndpoints();
1198
1290
  const DEVICE_FLOWS = new DeviceFlows({
1199
1291
  client: githubDeviceClient(GITHUB),
1200
1292
  clientId: () => GITHUB_CLIENT_ID,
1201
- decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
1293
+ decide: decideFlow(
1294
+ getStateDir,
1295
+ (l) => serverLog(l, "lifecycle"),
1296
+ () => claimRulesNow(),
1297
+ ),
1202
1298
  // A test's fake GitHub may answer interval 0; the real one never does.
1203
1299
  minIntervalMs: GITHUB.fake ? 50 : 1000,
1204
1300
  onExpiringToken: noteExpiringToken,
@@ -1361,16 +1457,36 @@ function denEdgeRoutes(): { host: string; url: string }[] {
1361
1457
  */
1362
1458
  function githubSignin():
1363
1459
  | { state: "no_client_id" | "no_owner" }
1364
- | { state: "ready"; ownerLogin: string } {
1460
+ | { state: "ready"; ownerLogin: string }
1461
+ | {
1462
+ state: "claim";
1463
+ org?: string;
1464
+ ownerGithub?: string;
1465
+ /** "This is my computer"'s start, on localhost (fix A). */
1466
+ attest?: string;
1467
+ message: string;
1468
+ } {
1365
1469
  if (!GITHUB_CLIENT_ID) return { state: "no_client_id" };
1366
1470
  const owner = readWebOwner(getStateDir());
1367
- return owner
1368
- ? { state: "ready", ownerLogin: owner.login }
1369
- : { state: "no_owner" };
1471
+ if (owner) return { state: "ready", ownerLogin: owner.login };
1472
+ // D17: no owner yet: who may claim it, and how.
1473
+ const r = claimRulesNow();
1474
+ if (!r.allowedGithubOrg && !r.ownerGithub) return { state: "no_owner" };
1475
+ return {
1476
+ state: "claim",
1477
+ ...(r.allowedGithubOrg
1478
+ ? {
1479
+ org: r.allowedGithubOrg,
1480
+ attest: `${localhostOrigin(PORT)}/attest`,
1481
+ }
1482
+ : {}),
1483
+ ...(r.ownerGithub ? { ownerGithub: r.ownerGithub } : {}),
1484
+ message: unclaimedSigninReason(null, r),
1485
+ };
1370
1486
  }
1371
1487
 
1372
1488
  /**
1373
- * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1489
+ * 0.4.3: why the edge is reachable beyond this machine (shared/edge/exposure.ts),
1374
1490
  * once at start; null until known, or when Den can't tell.
1375
1491
  */
1376
1492
  let EDGE_EXPOSED: string | null = null;
@@ -1487,7 +1603,16 @@ function sendSignedOut(
1487
1603
  ...(gh.state === "ready" ? { ownerLogin: gh.ownerLogin } : {}),
1488
1604
  learnMore: GITHUB_SIGNIN_DOCS,
1489
1605
  ...(GITHUB_CLIENT_ID
1490
- ? {}
1606
+ ? gh.state === "no_owner"
1607
+ ? { message: noRuleMessage(claimRulesNow()) }
1608
+ : gh.state === "claim"
1609
+ ? {
1610
+ message: gh.message,
1611
+ ...(gh.org ? { org: gh.org } : {}),
1612
+ ...(gh.ownerGithub ? { ownerGithub: gh.ownerGithub } : {}),
1613
+ ...(gh.attest ? { attest: gh.attest } : {}),
1614
+ }
1615
+ : {}
1491
1616
  : { message: noClientIdMessage(product().cli) }),
1492
1617
  },
1493
1618
  ...(opts.expired ? { expired: true } : {}),
@@ -1922,6 +2047,9 @@ async function handleRequest(
1922
2047
  (req.method === "GET" || req.method === "HEAD" || req.method === "POST")
1923
2048
  )
1924
2049
  return handleAuth(req, res, url, requestId);
2050
+ // D17: "This is my computer" (fix A), on localhost only.
2051
+ if (pathname === "/attest" && (req.method === "GET" || req.method === "HEAD"))
2052
+ return handleAttest(req, res, url, guard, requestId);
1925
2053
  // SEC-2a: `web login` registers its link's wait, then polls it
1926
2054
  // (shell-grade: the CLI's bearer).
1927
2055
  if (pathname === "/api/web/logins" && req.method === "POST") {
@@ -1935,7 +2063,7 @@ async function handleRequest(
1935
2063
  const id =
1936
2064
  Number.isFinite(exp) &&
1937
2065
  exp > Date.now() &&
1938
- exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
2066
+ exp - Date.now() <= LOGIN_CODE_MAX_TTL_MS + 5_000
1939
2067
  ? LOGIN_WAITS.register(
1940
2068
  String(body.nonce ?? ""),
1941
2069
  String(body.code ?? ""),
@@ -2044,13 +2172,16 @@ async function handleRequest(
2044
2172
  const secret = webSecret.current();
2045
2173
  if (!secret) return null;
2046
2174
  const now = Date.now();
2175
+ const ttl = webAuthNow().loginLinkTtlMs;
2047
2176
  return {
2048
- url: loginLink(origin, mintLoginCode(secret, now)),
2049
- expiresAt: now + LOGIN_CODE_TTL_MS,
2177
+ url: loginLink(origin, mintLoginCode(secret, now, ttl)),
2178
+ expiresAt: now + ttl,
2050
2179
  };
2051
2180
  },
2052
2181
  log: (l) => serverLog(l, "lifecycle"),
2053
2182
  stateDir: getStateDir,
2183
+ claimRules: claimRulesNow,
2184
+ spendAttest,
2054
2185
  }))
2055
2186
  )
2056
2187
  return;