@monster-orange/den 0.4.19 → 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.
- package/CHANGELOG.md +122 -0
- package/README.md +7 -7
- package/docs/distributions.md +121 -13
- package/package.json +1 -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/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +108 -17
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/service-command.ts +16 -0
- package/src/ui/cli/web-command.ts +74 -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 +148 -13
- 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 +11 -1
- package/src/ui/server/public/i18n/ja.js +11 -1
- package/src/ui/server/public/settings-registry.js +23 -1
- package/src/ui/server/public/shell.js +11 -1
- package/src/ui/server/route-classes.ts +7 -0
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ownerGithubLine,
|
|
3
|
+
recordOwnerGithub,
|
|
4
|
+
} from "../../shared/owner-github.js";
|
|
1
5
|
import chalk from "chalk";
|
|
2
6
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
3
7
|
import * as fs from "fs";
|
|
@@ -27,6 +31,7 @@ import {
|
|
|
27
31
|
ignoredConfigWarning,
|
|
28
32
|
localhostOrigin,
|
|
29
33
|
loginLink,
|
|
34
|
+
LOGIN_CODE_TTL_MS,
|
|
30
35
|
mintLoginCode,
|
|
31
36
|
parseOrigin,
|
|
32
37
|
readWebAuthConfig,
|
|
@@ -1182,7 +1187,7 @@ export function displayWebHelp(): void {
|
|
|
1182
1187
|
" open Start if needed, and sign this browser in (a new tab)",
|
|
1183
1188
|
);
|
|
1184
1189
|
console.log(
|
|
1185
|
-
" login Print a one-time sign-in link (
|
|
1190
|
+
" login Print a one-time sign-in link (5 min; web.login_link_ttl) and its code, then wait until it's used",
|
|
1186
1191
|
);
|
|
1187
1192
|
console.log(" sessions List the signed-in browsers");
|
|
1188
1193
|
console.log(
|
|
@@ -1330,6 +1335,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1330
1335
|
return;
|
|
1331
1336
|
}
|
|
1332
1337
|
|
|
1338
|
+
// D17: whoever starts it can connect their GitHub account from any
|
|
1339
|
+
// browser (web.owner_github, from gh), before the server reads it.
|
|
1340
|
+
if (options.action === "up" || options.action === "restart") {
|
|
1341
|
+
const line = ownerGithubLine(recordOwnerGithub(), product().cli);
|
|
1342
|
+
if (line) console.log(chalk.dim(`\n ${line}`));
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1333
1345
|
if (options.action === "restart") {
|
|
1334
1346
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1335
1347
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
@@ -1411,7 +1423,8 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1411
1423
|
return;
|
|
1412
1424
|
}
|
|
1413
1425
|
|
|
1414
|
-
// SEC-1b: what opens is a sign-in link (localhost, one-time,
|
|
1426
|
+
// SEC-1b: what opens is a sign-in link (localhost, one-time, D16:
|
|
1427
|
+
// web.login_link_ttl). It
|
|
1415
1428
|
// is printed only when the browser couldn't be opened: the one line the
|
|
1416
1429
|
// person then needs.
|
|
1417
1430
|
// Inside an agent's session: the plain page, never a sign-in link.
|
|
@@ -1453,7 +1466,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1453
1466
|
} else {
|
|
1454
1467
|
console.log(chalk.yellow("\n Could not open a browser automatically."));
|
|
1455
1468
|
}
|
|
1456
|
-
console.log(
|
|
1469
|
+
console.log(
|
|
1470
|
+
` Sign in (once, within ${ttlText(loginLinkTtl())}): ${signIn()}\n`,
|
|
1471
|
+
);
|
|
1457
1472
|
}
|
|
1458
1473
|
|
|
1459
1474
|
// ── SEC-1b: sign-in (`web login`, `web sessions`, `web logout`) ─────────────
|
|
@@ -1467,7 +1482,11 @@ export function freshLoginLink(
|
|
|
1467
1482
|
): string {
|
|
1468
1483
|
const refused = personGrantRefusal(process.env);
|
|
1469
1484
|
if (refused) throw new Error(refused);
|
|
1470
|
-
return loginLink(
|
|
1485
|
+
return loginLink(
|
|
1486
|
+
origin,
|
|
1487
|
+
mintLoginCode(ensureWebSecret(stateDir), Date.now(), loginLinkTtl()),
|
|
1488
|
+
next,
|
|
1489
|
+
);
|
|
1471
1490
|
}
|
|
1472
1491
|
|
|
1473
1492
|
/**
|
|
@@ -1616,7 +1635,8 @@ function parseLoginArgs(
|
|
|
1616
1635
|
|
|
1617
1636
|
const LOGIN_HELP = () =>
|
|
1618
1637
|
LOGIN_USAGE() +
|
|
1619
|
-
" Prints a one-time sign-in link (open it within
|
|
1638
|
+
" Prints a one-time sign-in link (open it within web.login_link_ttl, 5 minutes\n" +
|
|
1639
|
+
" unless set) for one browser, and a\n" +
|
|
1620
1640
|
" code the page behind the link asks for, then waits until the link is used:\n" +
|
|
1621
1641
|
' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
|
|
1622
1642
|
' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
|
|
@@ -1655,12 +1675,36 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
|
1655
1675
|
io.stateDir ?? getStateDir(),
|
|
1656
1676
|
);
|
|
1657
1677
|
io.err(
|
|
1658
|
-
`${product().cli}: open this link within
|
|
1678
|
+
`${product().cli}: open this link within ${ttlText(loginLinkTtl())}; it signs in one browser, once.\n`,
|
|
1659
1679
|
);
|
|
1660
1680
|
io.out(`${link}\n`);
|
|
1661
1681
|
return 0;
|
|
1662
1682
|
}
|
|
1663
1683
|
|
|
1684
|
+
/** D16: web.login_link_ttl, read now (LOGIN_CODE_TTL_MS when unreadable). */
|
|
1685
|
+
export function loginLinkTtl(): number {
|
|
1686
|
+
try {
|
|
1687
|
+
return readWebAuthConfig(getDistRoot(), product().configFile)
|
|
1688
|
+
.loginLinkTtlMs;
|
|
1689
|
+
} catch {
|
|
1690
|
+
return LOGIN_CODE_TTL_MS;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** "5 minutes", "90 seconds", "1 minute". */
|
|
1695
|
+
export function ttlText(ms: number): string {
|
|
1696
|
+
const s = Math.round(ms / 1000);
|
|
1697
|
+
if (s % 60 !== 0) return `${s} seconds`;
|
|
1698
|
+
const m = s / 60;
|
|
1699
|
+
return m === 1 ? "1 minute" : `${m} minutes`;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** D16: the countdown's text, "4:59 left". */
|
|
1703
|
+
export function countdownLeft(ms: number): string {
|
|
1704
|
+
const s = Math.max(0, Math.ceil(ms / 1000));
|
|
1705
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} left`;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1664
1708
|
/** "10:42" in this machine's time. */
|
|
1665
1709
|
function clockTime(iso: string): string {
|
|
1666
1710
|
const d = new Date(iso);
|
|
@@ -1680,7 +1724,7 @@ function clockTime(iso: string): string {
|
|
|
1680
1724
|
export async function cmdWebLoginWait(
|
|
1681
1725
|
args: string[],
|
|
1682
1726
|
io: CliIo = stdIo,
|
|
1683
|
-
opts: { pollMs?: number; maxWaitMs?: number } = {},
|
|
1727
|
+
opts: { pollMs?: number; maxWaitMs?: number; countdown?: boolean } = {},
|
|
1684
1728
|
): Promise<number> {
|
|
1685
1729
|
if (isHelp(args)) {
|
|
1686
1730
|
io.out(LOGIN_HELP());
|
|
@@ -1701,7 +1745,8 @@ export async function cmdWebLoginWait(
|
|
|
1701
1745
|
const stateDir = io.stateDir ?? getStateDir();
|
|
1702
1746
|
const secret = ensureWebSecret(stateDir);
|
|
1703
1747
|
const now = Date.now();
|
|
1704
|
-
const
|
|
1748
|
+
const ttl = loginLinkTtl();
|
|
1749
|
+
const loginCode = mintLoginCode(secret, now, ttl);
|
|
1705
1750
|
const checked = checkLoginCode(loginCode, secret, now);
|
|
1706
1751
|
if (!checked.ok) {
|
|
1707
1752
|
io.err(`${product().cli}: could not make a sign-in link\n`);
|
|
@@ -1722,7 +1767,7 @@ export async function cmdWebLoginWait(
|
|
|
1722
1767
|
io.err(
|
|
1723
1768
|
`${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
|
|
1724
1769
|
"doesn't wait and the link asks for no code (as --no-wait). Start the server " +
|
|
1725
|
-
`(\`${product().cli} web up\`), then open this link within
|
|
1770
|
+
`(\`${product().cli} web up\`), then open this link within ${ttlText(ttl)}; it signs in one browser, once.\n`,
|
|
1726
1771
|
);
|
|
1727
1772
|
io.out(`${link}\n`);
|
|
1728
1773
|
return 0;
|
|
@@ -1733,15 +1778,23 @@ export async function cmdWebLoginWait(
|
|
|
1733
1778
|
return 1;
|
|
1734
1779
|
}
|
|
1735
1780
|
io.err(
|
|
1736
|
-
`${product().cli}: open this link within
|
|
1781
|
+
`${product().cli}: open this link within ${ttlText(ttl)}, in the browser to sign in. ` +
|
|
1737
1782
|
`It asks for this code: ${confirm}\n`,
|
|
1738
1783
|
);
|
|
1739
1784
|
io.out(`${link}\n`);
|
|
1740
|
-
|
|
1785
|
+
// D16: on a terminal, the time left, in place.
|
|
1786
|
+
const tty = opts.countdown ?? (io === stdIo && !!process.stderr.isTTY);
|
|
1787
|
+
const waiting = `${product().cli}: waiting for the link to be used…`;
|
|
1788
|
+
const tick = () =>
|
|
1789
|
+
io.err(`\r${waiting} ${countdownLeft(checked.expiresAt - Date.now())} `);
|
|
1790
|
+
if (tty) tick();
|
|
1791
|
+
else io.err(`${waiting}\n`);
|
|
1741
1792
|
const pollMs = opts.pollMs ?? 1000;
|
|
1742
|
-
|
|
1793
|
+
// The link's own time, then the 5 minutes an opened link has for its code.
|
|
1794
|
+
const deadline = Date.now() + (opts.maxWaitMs ?? ttl + 6 * 60_000);
|
|
1743
1795
|
for (;;) {
|
|
1744
1796
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
1797
|
+
if (tty) tick();
|
|
1745
1798
|
const r = await serverCall(
|
|
1746
1799
|
stateDir,
|
|
1747
1800
|
a.port,
|
|
@@ -1754,6 +1807,15 @@ export async function cmdWebLoginWait(
|
|
|
1754
1807
|
client?: string;
|
|
1755
1808
|
usedAt?: string;
|
|
1756
1809
|
};
|
|
1810
|
+
// The countdown's line ends before the outcome's.
|
|
1811
|
+
const over =
|
|
1812
|
+
!r ||
|
|
1813
|
+
j.state === "used" ||
|
|
1814
|
+
j.state === "refused" ||
|
|
1815
|
+
j.state === "expired" ||
|
|
1816
|
+
r.status === 404 ||
|
|
1817
|
+
Date.now() > deadline;
|
|
1818
|
+
if (tty && over) io.err("\n");
|
|
1757
1819
|
if (r && j.state === "used") {
|
|
1758
1820
|
io.out(
|
|
1759
1821
|
`Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
|
|
@@ -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);
|