@monster-orange/den 0.4.25 → 0.4.27

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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * HUB-1.7: Connect to Hub. A person connects this install from Settings ›
3
+ * Agents › Hub; nobody copies a token or edits config.
4
+ *
5
+ * 1. POST /api/hub/connect { url? } (signed in, CSRF): a PKCE verifier
6
+ * and a single-use state are made and kept here (10 min); the answer
7
+ * is Hub's /connect URL, and the page goes there.
8
+ * 2. Hub signs the person in; they pick a company and approve. Hub sends
9
+ * the browser to <this Den>/hub/callback?code&state (or error=…).
10
+ * 3. GET /hub/callback (sign-in free: the session cookie is SameSite
11
+ * Strict, so a redirect from Hub doesn't carry it; the single-use state
12
+ * and the verifier only this server holds are what protect it) trades
13
+ * the code for the install's token server to server (POST
14
+ * /connect/token), stores the token and points hub.token at it, then
15
+ * answers a page that moves on to Settings by script, a same-site
16
+ * navigation that carries the cookie again.
17
+ * 4. POST /api/hub/disconnect revokes the token on Hub, then removes it
18
+ * here and clears hub.token.
19
+ *
20
+ * Where the token goes (SEC-0: the install's, never Den's): the install's
21
+ * own secrets store when it keeps secrets (1Password, Doppler: a new item
22
+ * or key, like the saved admin password), else a file Den keeps for this
23
+ * install, <state dir>/hub/token (mode 600, its folder 700). Either way the
24
+ * value travels on stdin or into the file, never in argv, and never to the
25
+ * browser, a log or an API answer.
26
+ */
27
+ import * as fs from "fs";
28
+ import * as os from "os";
29
+ import * as path from "path";
30
+ import {
31
+ hubExchange,
32
+ HubCallError,
33
+ hubRevoke,
34
+ hubUrlProblem,
35
+ newHubState,
36
+ newPkce,
37
+ readHubConfig,
38
+ readHubToken,
39
+ type HubConfig,
40
+ type HubGrant,
41
+ } from "../../shared/hub.js";
42
+ import { readUserConfig, writeUserConfig } from "../../shared/den-config.js";
43
+ import { getStateDir } from "../../shared/paths.js";
44
+ import { envKey, product } from "../../shared/product.js";
45
+ import {
46
+ getSecretsProvider,
47
+ readSecretsConfig,
48
+ secretsStatus,
49
+ type SecretRef,
50
+ } from "../../shared/secrets/index.js";
51
+
52
+ /** How long a started connect waits for Hub to send the person back. */
53
+ export const CONNECT_TTL_MS = 10 * 60_000;
54
+ /** At most this many connects waiting at once (oldest dropped first). */
55
+ const MAX_PENDING = 16;
56
+
57
+ interface Pending {
58
+ verifier: string;
59
+ hubUrl: string;
60
+ redirectUri: string;
61
+ at: number;
62
+ }
63
+
64
+ /** What hub.token becomes after a connect: a store ref, or a file source. */
65
+ export type StoredToken =
66
+ | { kind: "ref"; ref: Partial<SecretRef>; where: string }
67
+ | { kind: "file"; source: string; where: string };
68
+
69
+ export interface HubConnectDeps {
70
+ now: () => number;
71
+ /** hub.* as configured now (a distribution may preset hub.url). */
72
+ config: () => HubConfig | null;
73
+ exchange: typeof hubExchange;
74
+ revoke: typeof hubRevoke;
75
+ /** Keep the token for this install; what hub.token should say. */
76
+ store: (token: string) => Promise<StoredToken>;
77
+ /** Remove a token Connect stored (best effort). */
78
+ forget: (cfg: HubConfig) => Promise<void>;
79
+ readToken: (cfg: HubConfig) => Promise<string | null>;
80
+ readUser: () => Record<string, unknown>;
81
+ writeUser: (u: Record<string, unknown>) => void;
82
+ /** This install's label on Hub (1–40 characters). */
83
+ installLabel: () => string;
84
+ }
85
+
86
+ /** `<cli> on <machine>`: how Hub lists this install (Hub's label rule). */
87
+ export function defaultInstallLabel(
88
+ cli = product().cli,
89
+ host = os.hostname(),
90
+ ): string {
91
+ const machine = host.split(".")[0] || "this machine";
92
+ // Hub's rule: 1–40 of [A-Za-z0-9 ._-], starting with a letter or digit.
93
+ const label = `${cli} on ${machine}`
94
+ .replace(/[^A-Za-z0-9 ._-]/g, "-")
95
+ .replace(/^[^A-Za-z0-9]+/, "")
96
+ .slice(0, 40)
97
+ .trimEnd();
98
+ return label || "den";
99
+ }
100
+
101
+ /** The file Den keeps an install's Hub token in, when its store can't. */
102
+ export function hubTokenFile(stateDir = getStateDir()): string {
103
+ return path.join(stateDir, "hub", "token");
104
+ }
105
+
106
+ /** Write the token to its 0600 file (folder 0700), replacing any old one. */
107
+ function writeTokenFile(file: string, token: string): void {
108
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
109
+ fs.chmodSync(path.dirname(file), 0o700);
110
+ const tmp = `${file}.${process.pid}.tmp`;
111
+ fs.writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
112
+ fs.chmodSync(tmp, 0o600);
113
+ fs.renameSync(tmp, file);
114
+ }
115
+
116
+ /**
117
+ * The install's store, when it keeps secrets: a 1Password item or a Doppler
118
+ * key, named from the distribution (never Den's own vault). Null for env
119
+ * (gone at restart) and none, and for a backend nobody chose.
120
+ */
121
+ export function storeRefFor(backend: string): Partial<SecretRef> | null {
122
+ switch (backend) {
123
+ case "1password":
124
+ return { item: `${product().title} Hub token`, field: "token" };
125
+ case "doppler":
126
+ return { key: envKey("HUB_TOKEN") };
127
+ default:
128
+ return null;
129
+ }
130
+ }
131
+
132
+ async function defaultStore(token: string): Promise<StoredToken> {
133
+ const st = secretsStatus(readSecretsConfig(), process.env);
134
+ const ref =
135
+ !st.implicit && st.detect.available ? storeRefFor(st.backend) : null;
136
+ if (ref) {
137
+ const full: SecretRef = {
138
+ vault: ref.vault ?? "",
139
+ item: ref.item ?? "",
140
+ field: ref.field ?? ref.key ?? "",
141
+ ...(ref.key ? { key: ref.key } : {}),
142
+ };
143
+ try {
144
+ await getSecretsProvider().set(full, token);
145
+ return {
146
+ kind: "ref",
147
+ ref,
148
+ where: ref.item
149
+ ? `${st.label}: ${ref.item}`
150
+ : `${st.label}: ${ref.key}`,
151
+ };
152
+ } catch {
153
+ // The store refused (signed out, locked): keep it in the file instead.
154
+ }
155
+ }
156
+ const file = hubTokenFile();
157
+ writeTokenFile(file, token);
158
+ return { kind: "file", source: `file:${file}`, where: file };
159
+ }
160
+
161
+ async function defaultForget(cfg: HubConfig): Promise<void> {
162
+ if (cfg.tokenSource?.kind === "file") {
163
+ // Only the file Connect made: never a path a distribution chose.
164
+ if (path.resolve(cfg.tokenSource.path) === path.resolve(hubTokenFile()))
165
+ fs.rmSync(hubTokenFile(), { force: true });
166
+ return;
167
+ }
168
+ if (cfg.tokenRef) {
169
+ // Stores have no delete here: blank the value (the token is revoked).
170
+ const st = secretsStatus(readSecretsConfig(), process.env);
171
+ const ours = storeRefFor(st.backend);
172
+ const same =
173
+ !!ours &&
174
+ (ours.item
175
+ ? cfg.tokenRef.item === ours.item
176
+ : cfg.tokenRef.key === ours.key);
177
+ if (same)
178
+ await getSecretsProvider()
179
+ .set(cfg.tokenRef, " ")
180
+ .catch(() => undefined);
181
+ }
182
+ }
183
+
184
+ export function defaultHubConnectDeps(): HubConnectDeps {
185
+ return {
186
+ now: () => Date.now(),
187
+ config: () => {
188
+ try {
189
+ return readHubConfig();
190
+ } catch {
191
+ return null;
192
+ }
193
+ },
194
+ exchange: hubExchange,
195
+ revoke: hubRevoke,
196
+ store: defaultStore,
197
+ forget: defaultForget,
198
+ readToken: (c) => readHubToken(c),
199
+ readUser: () => readUserConfig() as Record<string, unknown>,
200
+ writeUser: (u) => writeUserConfig(u as never),
201
+ installLabel: () => defaultInstallLabel(),
202
+ };
203
+ }
204
+
205
+ /** An origin Hub will send the person back to: https, or loopback http. */
206
+ export function callbackOriginProblem(origin: string): string | null {
207
+ let u: URL;
208
+ try {
209
+ u = new URL(origin);
210
+ } catch {
211
+ return "this page's address isn't one Hub can send you back to";
212
+ }
213
+ const loop = u.hostname === "localhost" || u.hostname === "127.0.0.1";
214
+ if (u.protocol === "https:" || (u.protocol === "http:" && loop)) return null;
215
+ return `Hub can only send you back to https or to localhost; open ${product().title} over https (its edge address) or on this machine, then Connect`;
216
+ }
217
+
218
+ export type ConnectResult =
219
+ | { ok: true; team?: string; where: string }
220
+ | { ok: false; reason: "denied" | "expired" | "failed"; message: string };
221
+
222
+ export class HubConnect {
223
+ private readonly pending = new Map<string, Pending>();
224
+ constructor(readonly deps: HubConnectDeps = defaultHubConnectDeps()) {}
225
+
226
+ private sweep(): void {
227
+ const now = this.deps.now();
228
+ for (const [k, v] of this.pending)
229
+ if (now - v.at > CONNECT_TTL_MS) this.pending.delete(k);
230
+ while (this.pending.size >= MAX_PENDING) {
231
+ const oldest = this.pending.keys().next().value;
232
+ if (oldest === undefined) break;
233
+ this.pending.delete(oldest);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Step 1: where to send the browser. `origin` is this Den as the browser
239
+ * sees it (the request's checked Origin); `url` a Hub the person typed,
240
+ * else hub.url.
241
+ */
242
+ start(
243
+ origin: string,
244
+ url?: unknown,
245
+ ): { ok: true; location: string } | { ok: false; error: string } {
246
+ const typed = typeof url === "string" ? url.trim().replace(/\/+$/, "") : "";
247
+ const hubUrl = typed || this.deps.config()?.url || "";
248
+ if (!hubUrl) return { ok: false, error: "type Hub's address first" };
249
+ const bad = hubUrlProblem(hubUrl);
250
+ if (bad)
251
+ return { ok: false, error: bad.replace("hub.url", "Hub's address") };
252
+ const badOrigin = callbackOriginProblem(origin);
253
+ if (badOrigin) return { ok: false, error: badOrigin };
254
+ this.sweep();
255
+ const state = newHubState();
256
+ const { verifier, challenge } = newPkce();
257
+ const redirectUri = `${new URL(origin).origin}/hub/callback`;
258
+ this.pending.set(state, {
259
+ verifier,
260
+ hubUrl,
261
+ redirectUri,
262
+ at: this.deps.now(),
263
+ });
264
+ const q = new URLSearchParams({
265
+ install: this.deps.installLabel(),
266
+ redirect_uri: redirectUri,
267
+ state,
268
+ code_challenge: challenge,
269
+ code_challenge_method: "S256",
270
+ });
271
+ return { ok: true, location: `${hubUrl}/connect?${q}` };
272
+ }
273
+
274
+ /**
275
+ * Steps 2–3: Hub sent the person back. The state is used once, whatever
276
+ * happens. On success the token is stored and hub.* written to user.yml.
277
+ * The result names what happened in words (never the token or the code).
278
+ */
279
+ async finish(q: URLSearchParams): Promise<ConnectResult> {
280
+ const r = await this.finishOnce(q);
281
+ this.last = {
282
+ ok: r.ok,
283
+ message: r.ok
284
+ ? `Connected to Hub${r.team ? ` (${r.team})` : ""}.`
285
+ : r.message,
286
+ at: this.deps.now(),
287
+ };
288
+ return r;
289
+ }
290
+
291
+ /**
292
+ * What the last Connect came to, in words, read once (Settings shows it
293
+ * after Hub's redirect; the callback page only flashes by). Never the
294
+ * token or the code.
295
+ */
296
+ lastResult(): { ok: boolean; message: string } | null {
297
+ const l = this.last;
298
+ this.last = null;
299
+ if (!l || this.deps.now() - l.at > CONNECT_TTL_MS) return null;
300
+ return { ok: l.ok, message: l.message };
301
+ }
302
+
303
+ private last: { ok: boolean; message: string; at: number } | null = null;
304
+
305
+ private async finishOnce(q: URLSearchParams): Promise<ConnectResult> {
306
+ const state = q.get("state") ?? "";
307
+ const p = this.pending.get(state);
308
+ this.pending.delete(state);
309
+ if (!p || this.deps.now() - p.at > CONNECT_TTL_MS)
310
+ return {
311
+ ok: false,
312
+ reason: "expired",
313
+ message: "That connect link is used or too old: press Connect again.",
314
+ };
315
+ if (q.get("error"))
316
+ return {
317
+ ok: false,
318
+ reason: "denied",
319
+ message:
320
+ q.get("error") === "access_denied"
321
+ ? "Not connected: the request was declined on Hub."
322
+ : "Not connected: Hub didn't finish the connect.",
323
+ };
324
+ const code = q.get("code") ?? "";
325
+ if (!code)
326
+ return { ok: false, reason: "failed", message: "Hub sent no code back." };
327
+ let grant: HubGrant;
328
+ try {
329
+ grant = await this.deps.exchange(p.hubUrl, {
330
+ code,
331
+ redirectUri: p.redirectUri,
332
+ verifier: p.verifier,
333
+ });
334
+ } catch (e) {
335
+ const m =
336
+ e instanceof HubCallError &&
337
+ e.kind === "refused" &&
338
+ e.code === "invalid_grant"
339
+ ? "Hub didn't accept the code (used, expired or for another address): press Connect again."
340
+ : `Hub didn't give a token: ${(e as Error).message}`;
341
+ return { ok: false, reason: "failed", message: m };
342
+ }
343
+ const hubUrl =
344
+ grant.hubUrl && !hubUrlProblem(grant.hubUrl) ? grant.hubUrl : p.hubUrl;
345
+ let stored: StoredToken;
346
+ try {
347
+ stored = await this.deps.store(grant.token);
348
+ } catch (e) {
349
+ // Don't leave a live token nobody can find: revoke it.
350
+ await this.deps.revoke(hubUrl, grant.token).catch(() => undefined);
351
+ return {
352
+ ok: false,
353
+ reason: "failed",
354
+ message: `Connected, but the token couldn't be kept here (${(e as Error).message}); it was revoked. Press Connect again.`,
355
+ };
356
+ }
357
+ const user = this.deps.readUser();
358
+ const hub = {
359
+ ...((user.hub as Record<string, unknown> | undefined) ?? {}),
360
+ };
361
+ hub.url = hubUrl;
362
+ hub.token = stored.kind === "ref" ? stored.ref : stored.source;
363
+ if (grant.mcpUrl && !hubUrlProblem(grant.mcpUrl))
364
+ hub.mcp_url = grant.mcpUrl;
365
+ else delete hub.mcp_url;
366
+ this.deps.writeUser({ ...user, hub });
367
+ return {
368
+ ok: true,
369
+ ...(grant.company?.name ? { team: grant.company.name } : {}),
370
+ where: stored.where,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Step 4: revoke on Hub, then remove it here and clear hub.token (hub.url
376
+ * stays, so Connect is one click). Hub unreachable: nothing is removed,
377
+ * so it can be tried again; `force` removes it here anyway and says Hub
378
+ * still has it.
379
+ */
380
+ async disconnect(
381
+ force = false,
382
+ ): Promise<
383
+ | { ok: true; revoked: boolean; message: string }
384
+ | { ok: false; error: string }
385
+ > {
386
+ const cfg = this.deps.config();
387
+ if (!cfg || (!cfg.tokenRef && !cfg.tokenSource))
388
+ return { ok: true, revoked: false, message: "Not connected." };
389
+ const token = await this.deps.readToken(cfg);
390
+ let revoked = false;
391
+ let note = "";
392
+ if (token) {
393
+ try {
394
+ revoked = await this.deps.revoke(cfg.url, token);
395
+ if (!revoked) note = " Hub had already revoked it.";
396
+ } catch (e) {
397
+ if (!force)
398
+ return {
399
+ ok: false,
400
+ error: `Hub couldn't revoke the token (${(e as Error).message}). Nothing was removed. Try again, or remove it here anyway.`,
401
+ };
402
+ note =
403
+ " Hub couldn't be reached, so the token still works there until an admin revokes this install.";
404
+ }
405
+ }
406
+ await this.deps.forget(cfg).catch(() => undefined);
407
+ const user = this.deps.readUser();
408
+ const hub = {
409
+ ...((user.hub as Record<string, unknown> | undefined) ?? {}),
410
+ };
411
+ delete hub.token;
412
+ delete hub.mcp_url;
413
+ if (Object.keys(hub).length) user.hub = hub;
414
+ else delete user.hub;
415
+ this.deps.writeUser(user);
416
+ return {
417
+ ok: true,
418
+ revoked,
419
+ message: `Disconnected.${revoked ? " Hub revoked this install's token." : ""}${note}`,
420
+ };
421
+ }
422
+ }
423
+
424
+ /** The page /hub/callback answers: it moves on to Settings by script. */
425
+ export function callbackPage(
426
+ title: string,
427
+ result: { ok: boolean; message: string; reason?: string },
428
+ ): string {
429
+ const next = `/settings?hub=${result.ok ? "connected" : (result.reason ?? "failed")}#agents.hub`;
430
+ const esc = (s: string) =>
431
+ s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
432
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${esc(title)}: Hub</title></head><body style="font:15px system-ui,sans-serif;margin:2rem;max-width:40rem"><p data-hub-result="${result.ok ? "connected" : (result.reason ?? "failed")}">${esc(result.message)}</p><p><a href="${next}">Back to Settings</a></p><script>location.replace(${JSON.stringify(next)})</script></body></html>`;
433
+ }
@@ -197,6 +197,8 @@
197
197
  "lock.note":
198
198
  "Den la usa solo para sudo. Los agentes no pueden introducirla.",
199
199
  "lock.saved": "Contraseña de administrador guardada para esta sesión",
200
+ "lock.savedTo":
201
+ "Contraseña de administrador guardada para esta sesión y en {s}",
200
202
  "lock.saveFailed":
201
203
  "Guardada para esta sesión, pero no se pudo guardar en {s}: {err}",
202
204
  "lock.wrong": "Contraseña incorrecta; inténtalo de nuevo",
@@ -186,6 +186,8 @@
186
186
  "lock.note":
187
187
  "Den はこれを sudo にだけ使います。エージェントは入力できません。",
188
188
  "lock.saved": "このセッションの間、管理者パスワードを保持します",
189
+ "lock.savedTo":
190
+ "このセッションの間、管理者パスワードを保持し、{s} に保存しました",
189
191
  "lock.saveFailed":
190
192
  "このセッションの間は保持しますが、{s} への保存に失敗しました: {err}",
191
193
  "lock.wrong": "パスワードが違います。もう一度お試しください",