@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.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- 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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
61
|
-
identity
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
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:
|
|
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);
|
package/src/ui/server/index.ts
CHANGED
|
@@ -229,7 +229,7 @@ import {
|
|
|
229
229
|
ignoredConfigWarning,
|
|
230
230
|
localhostOrigin,
|
|
231
231
|
loginLink,
|
|
232
|
-
|
|
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 "
|
|
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(
|
|
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
|
-
|
|
1369
|
-
|
|
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
|
|
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() <=
|
|
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 +
|
|
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,
|