@monster-orange/den 0.4.4 → 0.4.6
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 +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a's routes: Sign in with GitHub (the signed-out page), the owner's
|
|
3
|
+
* link (Settings › Access, `web owner …`), and Pair a device.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/github/signin start a sign-in (sign-in free; an
|
|
6
|
+
* allowed Origin, no CSRF token)
|
|
7
|
+
* GET /api/github/signin/<id> its outcome; approved by the owner:
|
|
8
|
+
* the session cookie, once
|
|
9
|
+
* GET /api/web/owner who the owner is
|
|
10
|
+
* POST /api/web/owner/link start linking the owner (shell-grade)
|
|
11
|
+
* GET /api/web/owner/link/<id> its code and outcome
|
|
12
|
+
* POST /api/web/owner/unlink unlink the owner (shell-grade)
|
|
13
|
+
* GET /api/web/pair the edge hostnames a link can be for
|
|
14
|
+
* POST /api/web/pair { host } a one-time sign-in link for one
|
|
15
|
+
*
|
|
16
|
+
* The token GitHub hands back is used once, for `GET /user`, and dropped
|
|
17
|
+
* (device-flows.ts). Nothing here logs a code, a token or a cookie.
|
|
18
|
+
*/
|
|
19
|
+
import type * as http from "http";
|
|
20
|
+
import { githubPrincipal } from "../../shared/github-device.js";
|
|
21
|
+
import {
|
|
22
|
+
clearWebOwner,
|
|
23
|
+
readWebOwner,
|
|
24
|
+
writeWebOwner,
|
|
25
|
+
} from "../../shared/web-owner.js";
|
|
26
|
+
import type { DeviceFlows, FlowView } from "./device-flows.js";
|
|
27
|
+
|
|
28
|
+
export interface GithubRouteDeps {
|
|
29
|
+
flows: DeviceFlows;
|
|
30
|
+
/** The configured client id ("" when none). */
|
|
31
|
+
clientId: () => string;
|
|
32
|
+
/** The app issued an expiring token (its token expiry is on): a misconfiguration. */
|
|
33
|
+
tokensExpire?: () => boolean;
|
|
34
|
+
cli: string;
|
|
35
|
+
sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
|
|
36
|
+
readBody: (req: http.IncomingMessage) => Promise<string>;
|
|
37
|
+
/** A new browser session for this request; its Set-Cookie line. */
|
|
38
|
+
signIn: (
|
|
39
|
+
req: http.IncomingMessage,
|
|
40
|
+
via: "github" | "link",
|
|
41
|
+
login?: string,
|
|
42
|
+
) => string;
|
|
43
|
+
/** The edge hostnames that route here, and the URL a browser uses for each. */
|
|
44
|
+
edgeRoutes: () => { host: string; url: string }[];
|
|
45
|
+
/** A one-time sign-in link at `origin` (60 s), or null with no secret. */
|
|
46
|
+
mintLink: (origin: string) => { url: string; expiresAt: number } | null;
|
|
47
|
+
log: (line: string) => void;
|
|
48
|
+
/** Where the owner file lives (the server's state dir). */
|
|
49
|
+
stateDir: () => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SIGNIN_PATH = /^\/api\/github\/signin\/([A-Za-z0-9_-]{16,64})$/;
|
|
53
|
+
const LINK_PATH = /^\/api\/web\/owner\/link\/([A-Za-z0-9_-]{16,64})$/;
|
|
54
|
+
|
|
55
|
+
/** What an identity means for a flow: the owner's sign-in, or the owner's link. */
|
|
56
|
+
export function decideFlow(
|
|
57
|
+
stateDir: () => string,
|
|
58
|
+
log: (line: string) => void,
|
|
59
|
+
): (
|
|
60
|
+
purpose: "signin" | "link",
|
|
61
|
+
identity: { id: number; login: string },
|
|
62
|
+
) => "ok" | "refused" {
|
|
63
|
+
return (purpose, identity) => {
|
|
64
|
+
const owner = readWebOwner(stateDir());
|
|
65
|
+
if (purpose === "signin") {
|
|
66
|
+
const ok = owner?.owner === githubPrincipal(identity.id);
|
|
67
|
+
log(
|
|
68
|
+
ok
|
|
69
|
+
? ` ✓ signed in with GitHub (@${identity.login})`
|
|
70
|
+
: ` ✗ GitHub sign-in refused: @${identity.login} isn't this Den's owner`,
|
|
71
|
+
);
|
|
72
|
+
return ok ? "ok" : "refused";
|
|
73
|
+
}
|
|
74
|
+
if (owner) return "refused";
|
|
75
|
+
writeWebOwner(identity, stateDir());
|
|
76
|
+
log(` ✓ owner linked: @${identity.login}`);
|
|
77
|
+
return "ok";
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A flow as the page sees it: the code and where to enter it, while it waits. */
|
|
82
|
+
function flowJson(v: FlowView): Record<string, unknown> {
|
|
83
|
+
return {
|
|
84
|
+
id: v.id,
|
|
85
|
+
state: v.state,
|
|
86
|
+
...(v.state === "pending"
|
|
87
|
+
? {
|
|
88
|
+
userCode: v.userCode,
|
|
89
|
+
verificationUri: v.verificationUri,
|
|
90
|
+
expiresAt: new Date(v.expiresAt).toISOString(),
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
...(v.login ? { login: v.login } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function finishedMessage(v: FlowView, cli: string): string | undefined {
|
|
98
|
+
switch (v.state) {
|
|
99
|
+
case "refused":
|
|
100
|
+
return v.purpose === "signin"
|
|
101
|
+
? `@${v.login} isn't allowed on this Den. Ask the owner to add you.`
|
|
102
|
+
: `An owner is linked already; \`${cli} web owner unlink\` first.`;
|
|
103
|
+
case "expired":
|
|
104
|
+
return "The code expired before it was approved. Start again.";
|
|
105
|
+
case "denied":
|
|
106
|
+
return "The request was cancelled on GitHub.";
|
|
107
|
+
case "error":
|
|
108
|
+
return v.message ?? "GitHub couldn't be reached.";
|
|
109
|
+
default:
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function startFlow(
|
|
115
|
+
deps: GithubRouteDeps,
|
|
116
|
+
res: http.ServerResponse,
|
|
117
|
+
purpose: "signin" | "link",
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
let r;
|
|
120
|
+
try {
|
|
121
|
+
r = await deps.flows.start(purpose);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return deps.sendJson(
|
|
124
|
+
res,
|
|
125
|
+
{ ok: false, code: "github_unreachable", error: (e as Error).message },
|
|
126
|
+
502,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if ("error" in r)
|
|
130
|
+
return r.error === "busy"
|
|
131
|
+
? deps.sendJson(
|
|
132
|
+
res,
|
|
133
|
+
{
|
|
134
|
+
ok: false,
|
|
135
|
+
code: "busy",
|
|
136
|
+
error:
|
|
137
|
+
"Too many GitHub sign-ins are in progress. Try again in a few minutes.",
|
|
138
|
+
},
|
|
139
|
+
429,
|
|
140
|
+
)
|
|
141
|
+
: deps.sendJson(
|
|
142
|
+
res,
|
|
143
|
+
{
|
|
144
|
+
ok: false,
|
|
145
|
+
code: "no_client_id",
|
|
146
|
+
error: `Sign in with GitHub needs a GitHub App client id: set web.github_client_id in config/local/user.yml and restart (\`${deps.cli} web restart\`).`,
|
|
147
|
+
},
|
|
148
|
+
409,
|
|
149
|
+
);
|
|
150
|
+
return deps.sendJson(res, { ok: true, ...flowJson(r) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether `pathname` is one of these routes' (index.ts dispatches them here). */
|
|
154
|
+
export function isGithubRoute(pathname: string): boolean {
|
|
155
|
+
return /^\/api\/(github\/|web\/owner(\/|$)|web\/pair$)/.test(pathname);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Handles a SEC-2a route; false when `pathname` is none of them. */
|
|
159
|
+
export async function handleGithubRoutes(
|
|
160
|
+
req: http.IncomingMessage,
|
|
161
|
+
res: http.ServerResponse,
|
|
162
|
+
pathname: string,
|
|
163
|
+
deps: GithubRouteDeps,
|
|
164
|
+
): Promise<boolean> {
|
|
165
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
166
|
+
res.setHeader("Cache-Control", "no-store");
|
|
167
|
+
|
|
168
|
+
// ── Sign in with GitHub ──────────────────────────────────────────────
|
|
169
|
+
if (pathname === "/api/github/signin" && method === "POST") {
|
|
170
|
+
if (!readWebOwner(deps.stateDir())) {
|
|
171
|
+
deps.sendJson(
|
|
172
|
+
res,
|
|
173
|
+
{
|
|
174
|
+
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\`.`,
|
|
177
|
+
},
|
|
178
|
+
409,
|
|
179
|
+
);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
await startFlow(deps, res, "signin");
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const s = SIGNIN_PATH.exec(pathname);
|
|
186
|
+
if (s && (method === "GET" || method === "HEAD")) {
|
|
187
|
+
const v = deps.flows.get(s[1], "signin");
|
|
188
|
+
if (!v) {
|
|
189
|
+
deps.sendJson(
|
|
190
|
+
res,
|
|
191
|
+
{
|
|
192
|
+
ok: false,
|
|
193
|
+
state: "gone",
|
|
194
|
+
code: "not_found",
|
|
195
|
+
error: "That sign-in ended. Start again.",
|
|
196
|
+
},
|
|
197
|
+
404,
|
|
198
|
+
);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (v.state === "ok") {
|
|
202
|
+
if (!deps.flows.claim(v.id, "signin")) {
|
|
203
|
+
deps.sendJson(res, { ok: false, state: "gone" }, 404);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
res.setHeader("Set-Cookie", deps.signIn(req, "github", v.login));
|
|
207
|
+
deps.sendJson(res, { ok: true, state: "ok", login: v.login });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
const message = finishedMessage(v, deps.cli);
|
|
211
|
+
deps.sendJson(res, {
|
|
212
|
+
ok: v.state === "pending",
|
|
213
|
+
...flowJson(v),
|
|
214
|
+
...(message ? { error: message } : {}),
|
|
215
|
+
});
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── The owner ────────────────────────────────────────────────────────
|
|
220
|
+
if (pathname === "/api/web/owner" && method === "GET") {
|
|
221
|
+
const o = readWebOwner(deps.stateDir());
|
|
222
|
+
deps.sendJson(res, {
|
|
223
|
+
owner: o ? { id: o.owner, login: o.login, linkedAt: o.linkedAt } : null,
|
|
224
|
+
clientId: deps.clientId() !== "",
|
|
225
|
+
// The app's "Expire user authorization tokens" is on (seen since start).
|
|
226
|
+
tokenExpiry: deps.tokensExpire?.() ?? false,
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (pathname === "/api/web/owner/link" && method === "POST") {
|
|
231
|
+
const o = readWebOwner(deps.stateDir());
|
|
232
|
+
if (o) {
|
|
233
|
+
deps.sendJson(
|
|
234
|
+
res,
|
|
235
|
+
{
|
|
236
|
+
ok: false,
|
|
237
|
+
code: "already_linked",
|
|
238
|
+
error: `This Den's owner is @${o.login} already; unlink first (\`${deps.cli} web owner unlink\`).`,
|
|
239
|
+
},
|
|
240
|
+
409,
|
|
241
|
+
);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
await startFlow(deps, res, "link");
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
const l = LINK_PATH.exec(pathname);
|
|
248
|
+
if (l && (method === "GET" || method === "HEAD")) {
|
|
249
|
+
const v = deps.flows.get(l[1], "link");
|
|
250
|
+
if (!v) {
|
|
251
|
+
deps.sendJson(res, { ok: false, state: "gone", code: "not_found" }, 404);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (v.state === "ok") deps.flows.claim(v.id, "link");
|
|
255
|
+
const message = finishedMessage(v, deps.cli);
|
|
256
|
+
deps.sendJson(res, {
|
|
257
|
+
ok: v.state === "pending" || v.state === "ok",
|
|
258
|
+
...flowJson(v),
|
|
259
|
+
...(message ? { error: message } : {}),
|
|
260
|
+
});
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (pathname === "/api/web/owner/unlink" && method === "POST") {
|
|
264
|
+
const was = clearWebOwner(deps.stateDir());
|
|
265
|
+
if (was) deps.log(` owner unlinked (was @${was.login})`);
|
|
266
|
+
deps.sendJson(res, { ok: true, was: was ? was.login : null });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Pair a device ────────────────────────────────────────────────────
|
|
271
|
+
if (pathname === "/api/web/pair" && method === "GET") {
|
|
272
|
+
deps.sendJson(res, { hosts: deps.edgeRoutes().map((r) => r.host) });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
if (pathname === "/api/web/pair" && method === "POST") {
|
|
276
|
+
let body: { host?: unknown } = {};
|
|
277
|
+
try {
|
|
278
|
+
body = (JSON.parse((await deps.readBody(req)) || "{}") ??
|
|
279
|
+
{}) as typeof body;
|
|
280
|
+
} catch {
|
|
281
|
+
deps.sendJson(
|
|
282
|
+
res,
|
|
283
|
+
{ ok: false, error: "the body must be JSON: { host }" },
|
|
284
|
+
400,
|
|
285
|
+
);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
const want = String(body.host ?? "")
|
|
289
|
+
.trim()
|
|
290
|
+
.toLowerCase();
|
|
291
|
+
const routes = deps.edgeRoutes();
|
|
292
|
+
const hit = routes.find((r) => r.host === want);
|
|
293
|
+
if (!hit) {
|
|
294
|
+
deps.sendJson(
|
|
295
|
+
res,
|
|
296
|
+
{
|
|
297
|
+
ok: false,
|
|
298
|
+
code: "unknown_host",
|
|
299
|
+
error: routes.length
|
|
300
|
+
? `${want || "(none)"} isn't an edge hostname that routes here (${routes.map((r) => r.host).join(", ")}).`
|
|
301
|
+
: "No edge hostname routes to this server: add one to edge.routes or edge.aliases, then `edge up`.",
|
|
302
|
+
},
|
|
303
|
+
400,
|
|
304
|
+
);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
const link = deps.mintLink(hit.url);
|
|
308
|
+
if (!link) {
|
|
309
|
+
deps.sendJson(res, { ok: false, error: "web.secret is missing" }, 500);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
deps.sendJson(res, {
|
|
313
|
+
ok: true,
|
|
314
|
+
host: hit.host,
|
|
315
|
+
url: link.url,
|
|
316
|
+
expiresAt: new Date(link.expiresAt).toISOString(),
|
|
317
|
+
});
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
@@ -298,6 +298,16 @@ export interface ErrorPageData {
|
|
|
298
298
|
cmd: string;
|
|
299
299
|
localhost?: string;
|
|
300
300
|
expired?: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* SEC-2a: Sign in with GitHub. `state` ready, or why it can't be offered
|
|
303
|
+
* (no client id configured, no owner linked); `owner` the command that
|
|
304
|
+
* links one; `message` what to set while no client id is configured.
|
|
305
|
+
*/
|
|
306
|
+
github?: {
|
|
307
|
+
state: "ready" | "no_client_id" | "no_owner";
|
|
308
|
+
owner: string;
|
|
309
|
+
message?: string;
|
|
310
|
+
};
|
|
301
311
|
};
|
|
302
312
|
}
|
|
303
313
|
|