@monster-orange/den 0.4.20 → 0.4.22

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 (46) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +167 -19
  4. package/package.json +1 -1
  5. package/service/install.sh +213 -63
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/runtime.ts +6 -0
  9. package/src/shared/agents/session-name.ts +21 -1
  10. package/src/shared/attest-code.ts +125 -0
  11. package/src/shared/claim-code.ts +6 -2
  12. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  13. package/src/shared/github-app.ts +4 -0
  14. package/src/shared/github-device.ts +38 -1
  15. package/src/shared/launchd-session.ts +92 -0
  16. package/src/shared/owner-github.ts +100 -0
  17. package/src/shared/product.ts +45 -5
  18. package/src/shared/profile.ts +26 -2
  19. package/src/shared/sessions/client.ts +24 -0
  20. package/src/shared/sessions/host.ts +114 -5
  21. package/src/shared/sessions/types.ts +5 -0
  22. package/src/shared/web-session.ts +112 -6
  23. package/src/ui/cli/agent-ops.ts +20 -2
  24. package/src/ui/cli/agents-command.ts +32 -1
  25. package/src/ui/cli/create.ts +92 -6
  26. package/src/ui/cli/doctor-command.ts +126 -15
  27. package/src/ui/cli/doctor-owner.ts +122 -0
  28. package/src/ui/cli/doctor-where.ts +203 -0
  29. package/src/ui/cli/index.ts +1 -1
  30. package/src/ui/cli/service-command.ts +233 -11
  31. package/src/ui/cli/sessions-command.ts +46 -12
  32. package/src/ui/cli/web-command.ts +78 -12
  33. package/src/ui/server/device-flows.ts +45 -10
  34. package/src/ui/server/github-routes.ts +169 -16
  35. package/src/ui/server/index.ts +147 -12
  36. package/src/ui/server/public/agents-page.js +103 -6
  37. package/src/ui/server/public/agents.html +30 -0
  38. package/src/ui/server/public/error-page.js +115 -33
  39. package/src/ui/server/public/error.html +16 -0
  40. package/src/ui/server/public/header.js +5 -7
  41. package/src/ui/server/public/i18n/es.js +24 -1
  42. package/src/ui/server/public/i18n/ja.js +24 -1
  43. package/src/ui/server/public/settings-registry.js +1 -1
  44. package/src/ui/server/public/shell.js +24 -1
  45. package/src/ui/server/route-classes.ts +7 -0
  46. package/src/ui/server/session-routes.ts +4 -0
@@ -22,8 +22,29 @@ import { GITHUB_DEVICE_PAGE } from "../../shared/github-app.js";
22
22
  import type {
23
23
  GithubDeviceClient,
24
24
  GithubIdentity,
25
+ OrgMembership,
25
26
  } from "../../shared/github-device.js";
26
27
 
28
+ /** What `decide` may ask while the token is still held (D17). */
29
+ export interface DecideContext {
30
+ /**
31
+ * D17: the sign-in started with this machine's proof ("This is my
32
+ * computer", attest-code.ts).
33
+ */
34
+ local: boolean;
35
+ /** Whether the approving account is a member of `org`. */
36
+ orgMember(org: string): Promise<OrgMembership>;
37
+ }
38
+
39
+ /** What an identity means: ok, or refused (with why, for the page). */
40
+ export type Decision = "ok" | "refused" | { refused: string };
41
+
42
+ export type Decide = (
43
+ purpose: FlowPurpose,
44
+ identity: GithubIdentity,
45
+ ctx: DecideContext,
46
+ ) => Decision | Promise<Decision>;
47
+
27
48
  export type FlowPurpose = "signin" | "link";
28
49
  export type FlowState =
29
50
  | "pending"
@@ -43,7 +64,7 @@ export interface FlowView {
43
64
  expiresAt: number;
44
65
  /** ok or refused: the GitHub login that approved it. */
45
66
  login?: string;
46
- /** error: what went wrong (no secret in it). */
67
+ /** error: what went wrong; refused: why, when the decision said (no secret in it). */
47
68
  message?: string;
48
69
  }
49
70
 
@@ -53,6 +74,8 @@ interface Flow extends FlowView {
53
74
  errors: number;
54
75
  /** ok: who approved it (the id, for the caller). */
55
76
  identity?: GithubIdentity;
77
+ /** D17: started with this machine's proof. */
78
+ local: boolean;
56
79
  /** When a finished flow is forgotten. */
57
80
  forgetAt?: number;
58
81
  cancel?: () => void;
@@ -81,18 +104,12 @@ export class DeviceFlows {
81
104
  private readonly max: number;
82
105
  private readonly minIntervalMs: number;
83
106
  private readonly onExpiringToken?: () => void;
84
- private readonly decide: (
85
- purpose: FlowPurpose,
86
- identity: GithubIdentity,
87
- ) => "ok" | "refused";
107
+ private readonly decide: Decide;
88
108
 
89
109
  constructor(opts: {
90
110
  client: GithubDeviceClient;
91
111
  clientId: () => string;
92
- decide: (
93
- purpose: FlowPurpose,
94
- identity: GithubIdentity,
95
- ) => "ok" | "refused";
112
+ decide: Decide;
96
113
  now?: () => number;
97
114
  schedule?: Schedule;
98
115
  max?: number;
@@ -126,6 +143,7 @@ export class DeviceFlows {
126
143
  */
127
144
  async start(
128
145
  purpose: FlowPurpose,
146
+ meta: { local?: boolean } = {},
129
147
  ): Promise<FlowView | { error: "busy" | "no_client_id" }> {
130
148
  const clientId = this.clientId();
131
149
  if (!clientId) return { error: "no_client_id" };
@@ -142,6 +160,7 @@ export class DeviceFlows {
142
160
  deviceCode: "",
143
161
  intervalMs: this.minIntervalMs,
144
162
  errors: 0,
163
+ local: meta.local === true,
145
164
  };
146
165
  this.flows.set(id, placeholder);
147
166
  let code;
@@ -240,10 +259,26 @@ export class DeviceFlows {
240
259
  } catch (e) {
241
260
  return this.finish(f, "error", { message: (e as Error).message });
242
261
  }
243
- const outcome = this.decide(f.purpose, identity);
262
+ let decision: Decision;
263
+ try {
264
+ // The token is still only for this flow's own calls, then dropped.
265
+ const token = r.token;
266
+ decision = await this.decide(f.purpose, identity, {
267
+ local: f.local,
268
+ orgMember: (org) =>
269
+ this.client.orgMember(token, org, identity.login),
270
+ });
271
+ } catch (e) {
272
+ return this.finish(f, "error", { message: (e as Error).message });
273
+ }
274
+ if (this.flows.get(id) !== f || f.state !== "pending") return;
275
+ const outcome = typeof decision === "string" ? decision : "refused";
244
276
  return this.finish(f, outcome, {
245
277
  login: identity.login,
246
278
  ...(outcome === "ok" ? { identity } : {}),
279
+ ...(typeof decision === "object"
280
+ ? { message: decision.refused }
281
+ : {}),
247
282
  });
248
283
  }
249
284
  }
@@ -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,
@@ -909,6 +918,7 @@ import {
909
918
 
910
919
  import { product } from "../../shared/product.js";
911
920
  import { readBrand } from "../../shared/brand.js";
921
+ import { launchdSession } from "../../shared/launchd-session.js";
912
922
  /** config `web.port` (user > team), falling back to the built-in default. */
913
923
  function readWebPortSafe(): number {
914
924
  try {
@@ -1053,6 +1063,89 @@ const WEB_AUTH: WebAuthConfig = (() => {
1053
1063
  }
1054
1064
  })();
1055
1065
 
1066
+ /** web's sign-in config read now (Settings may change it), else at start. */
1067
+ function webAuthNow(): WebAuthConfig {
1068
+ try {
1069
+ return readWebAuthConfig(getDistRoot(), product().configFile);
1070
+ } catch {
1071
+ return WEB_AUTH;
1072
+ }
1073
+ }
1074
+
1075
+ /** D17: who may claim this Den while it has no owner (read now). */
1076
+ function claimRulesNow(): ClaimRules {
1077
+ const w = webAuthNow();
1078
+ return {
1079
+ ...(w.ownerGithub ? { ownerGithub: w.ownerGithub } : {}),
1080
+ ...(w.allowedGithubOrg ? { allowedGithubOrg: w.allowedGithubOrg } : {}),
1081
+ // Where it is, or would go: the distribution config.
1082
+ allowedGithubOrgFile: w.allowedGithubOrgFile ?? product().configFile,
1083
+ };
1084
+ }
1085
+
1086
+ /** D17: the spent "This is my computer" codes (attest-code.ts). */
1087
+ const ATTEST_NONCES = new ClaimNonces(getStateDir(), ATTEST_NONCES_FILE);
1088
+
1089
+ /** The request's Host without its port, lowercase. */
1090
+ function hostNameOf(req: http.IncomingMessage): string {
1091
+ return String(req.headers.host ?? "")
1092
+ .toLowerCase()
1093
+ .replace(/:\d+$/, "");
1094
+ }
1095
+
1096
+ /**
1097
+ * D17: spend an attest code for this request's host: one of this server's
1098
+ * edge hostnames, the code minted for it, unused. True once.
1099
+ */
1100
+ function spendAttest(req: http.IncomingMessage, code: string): boolean {
1101
+ const secret = webSecret.current();
1102
+ const host = hostNameOf(req);
1103
+ if (!secret || !denEdgeHosts().includes(host)) return false;
1104
+ const c = checkAttestCode(code, secret, host);
1105
+ return c.ok && ATTEST_NONCES.consume(c.nonce, c.expiresAt);
1106
+ }
1107
+
1108
+ /**
1109
+ * `GET /attest?return=<edge URL>` (D17, fix A): only on localhost (only a
1110
+ * browser on this machine reaches it there). A 303 back to `return`, one of
1111
+ * this server's own edge hosts, with a short-lived code in the fragment.
1112
+ * Anything else: an error page, and no code.
1113
+ */
1114
+ function handleAttest(
1115
+ req: http.IncomingMessage,
1116
+ res: http.ServerResponse,
1117
+ url: URL,
1118
+ guard: GuardResult,
1119
+ requestId: string,
1120
+ ): void {
1121
+ res.setHeader("Cache-Control", "no-store");
1122
+ res.setHeader("Referrer-Policy", "no-referrer");
1123
+ const refuse = (status: 400 | 403, message: string) =>
1124
+ sendErrorPage(req, res, {
1125
+ status,
1126
+ path: "/attest",
1127
+ message,
1128
+ code: status === 403 ? "not_this_computer" : "bad_return",
1129
+ requestId,
1130
+ });
1131
+ if (!guard.ok || guard.via !== "loopback" || guard.beyond)
1132
+ return refuse(
1133
+ 403,
1134
+ "This works only on the computer this Den runs on, at localhost.",
1135
+ );
1136
+ const ret = attestReturn(url.searchParams.get("return"), denEdgeHosts());
1137
+ const secret = webSecret.current();
1138
+ if (!ret || !secret)
1139
+ return refuse(
1140
+ 400,
1141
+ "That address isn't one of this Den's own. Open this Den at its own address and try again.",
1142
+ );
1143
+ res.writeHead(303, {
1144
+ Location: attestRedirect(ret, mintAttestCode(secret, ret.host)),
1145
+ });
1146
+ res.end();
1147
+ }
1148
+
1056
1149
  /**
1057
1150
  * The loopback names, with the port: localhost (canonical), 127.0.0.1 and
1058
1151
  * [::1]; the product's own name when /etc/hosts maps it to 127.0.0.1 (the
@@ -1198,7 +1291,11 @@ const GITHUB = githubEndpoints();
1198
1291
  const DEVICE_FLOWS = new DeviceFlows({
1199
1292
  client: githubDeviceClient(GITHUB),
1200
1293
  clientId: () => GITHUB_CLIENT_ID,
1201
- decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
1294
+ decide: decideFlow(
1295
+ getStateDir,
1296
+ (l) => serverLog(l, "lifecycle"),
1297
+ () => claimRulesNow(),
1298
+ ),
1202
1299
  // A test's fake GitHub may answer interval 0; the real one never does.
1203
1300
  minIntervalMs: GITHUB.fake ? 50 : 1000,
1204
1301
  onExpiringToken: noteExpiringToken,
@@ -1361,16 +1458,36 @@ function denEdgeRoutes(): { host: string; url: string }[] {
1361
1458
  */
1362
1459
  function githubSignin():
1363
1460
  | { state: "no_client_id" | "no_owner" }
1364
- | { state: "ready"; ownerLogin: string } {
1461
+ | { state: "ready"; ownerLogin: string }
1462
+ | {
1463
+ state: "claim";
1464
+ org?: string;
1465
+ ownerGithub?: string;
1466
+ /** "This is my computer"'s start, on localhost (fix A). */
1467
+ attest?: string;
1468
+ message: string;
1469
+ } {
1365
1470
  if (!GITHUB_CLIENT_ID) return { state: "no_client_id" };
1366
1471
  const owner = readWebOwner(getStateDir());
1367
- return owner
1368
- ? { state: "ready", ownerLogin: owner.login }
1369
- : { state: "no_owner" };
1472
+ if (owner) return { state: "ready", ownerLogin: owner.login };
1473
+ // D17: no owner yet: who may claim it, and how.
1474
+ const r = claimRulesNow();
1475
+ if (!r.allowedGithubOrg && !r.ownerGithub) return { state: "no_owner" };
1476
+ return {
1477
+ state: "claim",
1478
+ ...(r.allowedGithubOrg
1479
+ ? {
1480
+ org: r.allowedGithubOrg,
1481
+ attest: `${localhostOrigin(PORT)}/attest`,
1482
+ }
1483
+ : {}),
1484
+ ...(r.ownerGithub ? { ownerGithub: r.ownerGithub } : {}),
1485
+ message: unclaimedSigninReason(null, r),
1486
+ };
1370
1487
  }
1371
1488
 
1372
1489
  /**
1373
- * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1490
+ * 0.4.3: why the edge is reachable beyond this machine (shared/edge/exposure.ts),
1374
1491
  * once at start; null until known, or when Den can't tell.
1375
1492
  */
1376
1493
  let EDGE_EXPOSED: string | null = null;
@@ -1487,7 +1604,16 @@ function sendSignedOut(
1487
1604
  ...(gh.state === "ready" ? { ownerLogin: gh.ownerLogin } : {}),
1488
1605
  learnMore: GITHUB_SIGNIN_DOCS,
1489
1606
  ...(GITHUB_CLIENT_ID
1490
- ? {}
1607
+ ? gh.state === "no_owner"
1608
+ ? { message: noRuleMessage(claimRulesNow()) }
1609
+ : gh.state === "claim"
1610
+ ? {
1611
+ message: gh.message,
1612
+ ...(gh.org ? { org: gh.org } : {}),
1613
+ ...(gh.ownerGithub ? { ownerGithub: gh.ownerGithub } : {}),
1614
+ ...(gh.attest ? { attest: gh.attest } : {}),
1615
+ }
1616
+ : {}
1491
1617
  : { message: noClientIdMessage(product().cli) }),
1492
1618
  },
1493
1619
  ...(opts.expired ? { expired: true } : {}),
@@ -1922,6 +2048,9 @@ async function handleRequest(
1922
2048
  (req.method === "GET" || req.method === "HEAD" || req.method === "POST")
1923
2049
  )
1924
2050
  return handleAuth(req, res, url, requestId);
2051
+ // D17: "This is my computer" (fix A), on localhost only.
2052
+ if (pathname === "/attest" && (req.method === "GET" || req.method === "HEAD"))
2053
+ return handleAttest(req, res, url, guard, requestId);
1925
2054
  // SEC-2a: `web login` registers its link's wait, then polls it
1926
2055
  // (shell-grade: the CLI's bearer).
1927
2056
  if (pathname === "/api/web/logins" && req.method === "POST") {
@@ -1935,7 +2064,7 @@ async function handleRequest(
1935
2064
  const id =
1936
2065
  Number.isFinite(exp) &&
1937
2066
  exp > Date.now() &&
1938
- exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
2067
+ exp - Date.now() <= LOGIN_CODE_MAX_TTL_MS + 5_000
1939
2068
  ? LOGIN_WAITS.register(
1940
2069
  String(body.nonce ?? ""),
1941
2070
  String(body.code ?? ""),
@@ -2044,13 +2173,16 @@ async function handleRequest(
2044
2173
  const secret = webSecret.current();
2045
2174
  if (!secret) return null;
2046
2175
  const now = Date.now();
2176
+ const ttl = webAuthNow().loginLinkTtlMs;
2047
2177
  return {
2048
- url: loginLink(origin, mintLoginCode(secret, now)),
2049
- expiresAt: now + LOGIN_CODE_TTL_MS,
2178
+ url: loginLink(origin, mintLoginCode(secret, now, ttl)),
2179
+ expiresAt: now + ttl,
2050
2180
  };
2051
2181
  },
2052
2182
  log: (l) => serverLog(l, "lifecycle"),
2053
2183
  stateDir: getStateDir,
2184
+ claimRules: claimRulesNow,
2185
+ spendAttest,
2054
2186
  }))
2055
2187
  )
2056
2188
  return;
@@ -4291,6 +4423,9 @@ async function handleRequest(
4291
4423
  // another distribution's server (see cli/server-identity.ts).
4292
4424
  root: ROOT,
4293
4425
  product: product().name,
4426
+ // D20: the launchd session it runs in (Aqua ends at logout); doctor
4427
+ // shows it. null off macOS.
4428
+ launchd: launchdSession(),
4294
4429
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
4295
4430
  uptimeMs: Date.now() - SERVER_STARTED_AT,
4296
4431
  stale,