my-durable 0.0.3 → 0.0.5

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 (31) hide show
  1. package/README.md +8 -6
  2. package/apps/dpi-backend/src/config.ts +20 -3
  3. package/apps/dpi-backend/src/main.ts +2 -0
  4. package/apps/dpi-backend/src/pairing.ts +42 -11
  5. package/apps/dpi-backend/src/routes.ts +11 -3
  6. package/apps/dpi-playground/README.md +7 -0
  7. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
  8. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +77 -0
  9. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  10. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +3 -0
  11. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
  13. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
  17. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
  18. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
  19. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
  20. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
  21. package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
  22. package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
  23. package/apps/dpi-tui/src/app.ts +25 -1
  24. package/apps/dpi-tui/src/main.ts +95 -51
  25. package/apps/dpi-tui/src/pickers.ts +98 -0
  26. package/apps/dpi-tui/src/welcome.ts +394 -0
  27. package/bin/dpi.mjs +155 -0
  28. package/package.json +2 -2
  29. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
  30. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
  31. package/bin/md.mjs +0 -88
@@ -12,8 +12,17 @@ import { DeviceMonitor } from "./monitor.js";
12
12
  import { StreamMux } from "./mux.js";
13
13
  import { proxy } from "./proxy.js";
14
14
  import { guard, requireTls } from "./guard.js";
15
- import { ipKey, isTrustedProxy } from "./client-ip.js";
15
+ import { ipKey, isTrustedProxy, viaTls } from "./client-ip.js";
16
16
  import { Audit, denialLogging } from "./audit.js";
17
+ import {
18
+ ADMIN_COOKIE,
19
+ LOGIN_RATE,
20
+ SESSION_TTL_MS,
21
+ bootstrapAdminToken,
22
+ requireAdmin,
23
+ signSession,
24
+ tokenEquals,
25
+ } from "./admin-auth.js";
17
26
  import { body as failureBody, describeFailure, errorMiddleware, safe } from "./http-errors.js";
18
27
  import { TokenBuckets } from "./limiter.js";
19
28
  import { PairingService } from "./pairing.js";
@@ -56,6 +65,9 @@ const requireTlsEnabled = tlsRequired();
56
65
 
57
66
  const registry = new NetworkRegistry();
58
67
  registry.clearTunnelState(); // live tunnel state is RAM-only; never resurrect from DB
68
+ // The operator's own credential: minted at first boot into DPI_HOME/admin.token (0600),
69
+ // overridable via DPI_ADMIN_TOKEN. A set-but-short token refuses to boot (admin-auth.ts).
70
+ const adminToken = bootstrapAdminToken();
59
71
  const configFile = seedFromConfig(registry, appDir);
60
72
  const monitor = new DeviceMonitor(registry);
61
73
  monitor.start();
@@ -108,6 +120,15 @@ function auditFloorFromEnv(): { minFreeBytes?: number } {
108
120
  }
109
121
  const audit = new Audit(registry, auditFloorFromEnv());
110
122
  audit.startSweeper();
123
+ if (adminToken.created)
124
+ audit.append({
125
+ actor: "hub",
126
+ action: "admin.token_created",
127
+ outcome: "ok",
128
+ detail: { file: adminToken.path },
129
+ });
130
+ /** Every admin route requires this: the dpi_admin cookie or the raw token as a bearer. */
131
+ const adminAuth = requireAdmin(adminToken.token);
111
132
  // Everything registered below needs proof of TLS; /healthz (above) stays reachable for supervisors.
112
133
  if (requireTlsEnabled) app.use(denialLogging(audit), requireTls());
113
134
 
@@ -181,7 +202,11 @@ const pairErr = (
181
202
  // PairingService enforces its own per-IP code/verify buckets + fail-window
182
203
  // here. Host-allowlist + denial logging still apply.
183
204
  const pairGuard = [denialLogging(audit), guard(allowedHosts)] as const;
184
- app.post("/api/pair/code", ...pairGuard, express.json({ limit: "16kb" }), (req, res) => {
205
+ // The operator side of pairing (mint/state/approve/cancel) is admin surface:
206
+ // it needs the admin cookie or bearer too. pair/start + pair/verify stay public
207
+ // — their bearer is the pairing code, presented by the DEVICE, not the operator.
208
+ const adminPairGuard = [denialLogging(audit), guard(allowedHosts), adminAuth] as const;
209
+ app.post("/api/pair/code", ...adminPairGuard, express.json({ limit: "16kb" }), (req, res) => {
185
210
  const r = pairing.createCode(req.ip ?? "unknown");
186
211
  return r.ok ? res.status(201).json(r.value) : pairErr(r, res);
187
212
  });
@@ -194,31 +219,76 @@ app.post("/api/pair/verify", ...pairGuard, express.json({ limit: "16kb" }), asyn
194
219
  if (r.ok) return res.status(r.status).json(r.value);
195
220
  pairErr(r, res);
196
221
  });
197
- app.get("/api/pair/codes/:codeId", ...pairGuard, (req, res) => {
222
+ app.get("/api/pair/codes/:codeId", ...adminPairGuard, (req, res) => {
198
223
  const r = pairing.status(String(req.params.codeId));
199
224
  return r.ok ? res.json(r.value) : pairErr(r, res);
200
225
  });
201
226
  app.post(
202
227
  "/api/pair/codes/:codeId/approve",
203
- ...pairGuard,
228
+ ...adminPairGuard,
204
229
  express.json({ limit: "16kb" }),
205
230
  (req, res) => {
206
231
  const r = pairing.approve(String(req.params.codeId), req.body);
207
232
  return r.ok ? res.json(r.value) : pairErr(r, res);
208
233
  },
209
234
  );
210
- app.post("/api/pair/codes/:codeId/cancel", ...pairGuard, (req, res) => {
235
+ app.post("/api/pair/codes/:codeId/cancel", ...adminPairGuard, (req, res) => {
211
236
  const r = pairing.cancel(String(req.params.codeId));
212
237
  return r.ok ? res.json(r.value) : pairErr(r, res);
213
238
  });
214
239
 
215
- // Admin /api surface: guarded, rate-bucketed per real client IP, 256 KiB JSON.
240
+ // Admin login: POST /api/session {token} → the dpi_admin cookie. Own per-IP bucket
241
+ // (5/min burst 5) — a failed guess must never be retried at line rate. Audited as
242
+ // admin.login ok|denied with the client address as the actor and no token material.
243
+ const loginBuckets = new TokenBuckets({
244
+ capacity: LOGIN_RATE.capacity,
245
+ perMinute: LOGIN_RATE.perMinute,
246
+ maxKeys: 10_000,
247
+ idleTtlMs: 15 * 60_000,
248
+ });
249
+ setInterval(() => loginBuckets.sweep(), 60_000).unref();
250
+ app.post(
251
+ "/api/session",
252
+ denialLogging(audit),
253
+ guard(allowedHosts),
254
+ (req, res, next) => {
255
+ const taken = loginBuckets.take(ipKey(req.ip ?? req.socket.remoteAddress ?? "?"));
256
+ if (taken.ok) return void next();
257
+ res.set("Retry-After", String(Math.max(1, Math.ceil(taken.retryAfterMs / 1000))));
258
+ res.status(429).json(err("rate_limited", "Too many login attempts; slow down"));
259
+ },
260
+ express.json({ limit: "16kb" }),
261
+ (req, res) => {
262
+ const presented = typeof req.body?.token === "string" ? req.body.token : "";
263
+ const ip = req.ip ?? "unknown";
264
+ if (!presented || !tokenEquals(presented, adminToken.token)) {
265
+ audit.denied("admin.login", ip, { reason: "bad_token" });
266
+ return void res.status(401).json(err("unauthorized", "Missing or invalid admin token"));
267
+ }
268
+ const exp = Date.now() + SESSION_TTL_MS;
269
+ audit.append({ actor: `ip:${ipKey(ip)}`, action: "admin.login", outcome: "ok" });
270
+ // Secure only when the request arrived via the TLS proxy: on plain loopback
271
+ // (local development, tests) the flag would just break the cookie.
272
+ res.cookie(ADMIN_COOKIE, signSession(adminToken.token, exp), {
273
+ httpOnly: true,
274
+ sameSite: "strict",
275
+ secure: viaTls(req),
276
+ path: "/",
277
+ maxAge: SESSION_TTL_MS,
278
+ });
279
+ res.json({ ok: true, exp });
280
+ },
281
+ );
282
+
283
+ // Admin /api surface: guarded, rate-bucketed per real client IP, 256 KiB JSON,
284
+ // and behind the admin credential (cookie or bearer).
216
285
  app.use(
217
286
  "/api",
218
287
  denialLogging(audit),
219
288
  guard(allowedHosts),
220
289
  apiRate,
221
290
  express.json({ limit: "256kb" }),
291
+ adminAuth,
222
292
  );
223
293
  app.post("/api/pair/resolve", (req, res) => {
224
294
  const r = pairing.resolveCode(req.body);
@@ -435,6 +505,9 @@ server.listen(port, host, () => {
435
505
  console.log(
436
506
  "TLS required: only requests the loopback TLS proxy marks X-Forwarded-Proto: https are served",
437
507
  );
508
+ console.log(
509
+ `admin: login with the token in ${adminToken.path} (POST /api/session sets the browser cookie)`,
510
+ );
438
511
  if (host !== "127.0.0.1" && host !== "localhost")
439
512
  console.warn(
440
513
  `WARNING: the hub is listening on ${host}. Anyone who can reach it can drive every device it holds tokens for.`,
@@ -39,6 +39,9 @@ import {
39
39
  PairRequest as PairRequestSchema,
40
40
  PairResponse as PairResponseSchema,
41
41
  checkPairingDto,
42
+ encodePairOffer,
43
+ isPairCode,
44
+ normalizePairCode,
42
45
  pairingSigningPayload,
43
46
  } from "../../../../dpi-sdk/src/protocol/pairing.js";
44
47
  import type { PairCodeIssue, TokenIssue } from "../../../../dpi-sdk/src/protocol/pairing.js";
@@ -52,7 +55,18 @@ import type { PairCodeState } from "../../../../dpi-sdk/src/protocol/pairing.js"
52
55
  const CODE_TTL_MS = 300_000;
53
56
  const NONCE_TTL_MS = 60_000;
54
57
  const RECEIPT_GRACE_MS = 120_000;
55
- const CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
58
+
59
+ /**
60
+ * A code as a human types it (`abcd-2345-ef67`) is canonicalised before the strict wire
61
+ * schema sees it: only the code field is rewritten, so unknown fields are still rejected.
62
+ */
63
+ function withCanonicalCode(body: unknown): unknown {
64
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
65
+ const raw = (body as { code?: unknown }).code;
66
+ if (typeof raw !== "string") return body;
67
+ const code = normalizePairCode(raw);
68
+ return code && code !== raw ? { ...(body as Record<string, unknown>), code } : body;
69
+ }
56
70
 
57
71
  export type PairingFailure = {
58
72
  ok: false;
@@ -191,6 +205,21 @@ export class PairingService {
191
205
  return { ok: true, value };
192
206
  }
193
207
 
208
+ /**
209
+ * v2 offer link, minted from the SAME values the v1 pairUrl uses (same code,
210
+ * same expiry): the whole handoff — hub origin, hubId, code, expiry — rides
211
+ * the #offer= fragment, which no server or CDN ever sees in a log.
212
+ */
213
+ private offerUrlFor(code: string, expiresAt: number): string {
214
+ return `${this.deps.hubOrigin}/pair#offer=${encodePairOffer({
215
+ v: 2,
216
+ hub: this.deps.hubOrigin,
217
+ id: this.deps.hubId,
218
+ code,
219
+ exp: expiresAt,
220
+ })}`;
221
+ }
222
+
194
223
  /** POST /api/pair/code (admin) — mint a fresh single-use code. */
195
224
  createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
196
225
  const now = this.now();
@@ -200,7 +229,9 @@ export class PairingService {
200
229
  return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
201
230
  let code = "";
202
231
  do {
203
- code = [...randomBytes(8)].map((b) => CODE_ALPHABET[b % 32]).join("");
232
+ // A code is a bearer secret authorising one enrollment: canonical UUID v4 = 122 random bits.
233
+ // The re-mint guard is kept: it costs nothing and makes "two live codes share a string" impossible.
234
+ code = randomUUID();
204
235
  } while (this.byCode.has(code));
205
236
  const entry: CodeEntry = {
206
237
  codeId: randomUUID(),
@@ -225,18 +256,19 @@ export class PairingService {
225
256
  expiresAt: entry.expiresAt,
226
257
  hubOrigin: this.deps.hubOrigin,
227
258
  pairUrl: `${this.deps.hubOrigin}/pair?code=${code}&v=1`,
259
+ offerUrl: this.offerUrlFor(code, entry.expiresAt),
228
260
  });
229
261
  }
230
262
 
231
263
  /** Admin handoff lookup. Reuses a code; never creates an enrollment or approves it. */
232
264
  resolveCode(body: unknown): { ok: true; value: PairCodeIssue } | PairingFailure {
233
- const code = (body as { code?: unknown } | null)?.code;
265
+ const raw = (body as { code?: unknown } | null)?.code;
266
+ const code = typeof raw === "string" ? normalizePairCode(raw) : undefined;
234
267
  if (
235
268
  !body ||
236
269
  typeof body !== "object" ||
237
270
  Object.keys(body).length !== 1 ||
238
- typeof code !== "string" ||
239
- !/^[A-Z2-7]{8}$/.test(code)
271
+ !isPairCode(code)
240
272
  )
241
273
  return this.fail(400, "code_invalid", "Invalid pairing handoff");
242
274
  const codeId = this.byCode.get(code);
@@ -252,6 +284,7 @@ export class PairingService {
252
284
  expiresAt: entry.expiresAt,
253
285
  hubOrigin: this.deps.hubOrigin,
254
286
  pairUrl: `${this.deps.hubOrigin}/pair?code=${entry.code}&v=1`,
287
+ offerUrl: this.offerUrlFor(entry.code, entry.expiresAt),
255
288
  });
256
289
  }
257
290
 
@@ -272,11 +305,13 @@ export class PairingService {
272
305
  return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
273
306
  const taken = this.startLimiter.take(ip);
274
307
  if (!taken.ok) return this.rateLimited(taken, "Too many pairing starts; slow down");
275
- if (!checkPairingDto(PairRequestSchema, body)) {
308
+ // A grouped or lower-case code from a client is the same code: canonicalise it first.
309
+ const request = withCanonicalCode(body);
310
+ if (!checkPairingDto(PairRequestSchema, request)) {
276
311
  this.noteFailure(ip);
277
312
  return this.fail(400, "invalid", "pair.request body failed schema validation");
278
313
  }
279
- const req = body as {
314
+ const req = request as {
280
315
  requestId: string;
281
316
  code: string;
282
317
  deviceUid: string;
@@ -72,6 +72,17 @@ export const RECONNECT_BACKOFF_S = { initS: 1, capS: 15 } as const;
72
72
  /** Monitor probe backoff: the same curve with a 30 s cap. */
73
73
  export const MONITOR_BACKOFF_S = { initS: 1, capS: 30 } as const;
74
74
 
75
+ /**
76
+ * Pairing codes are minted as canonical lower-case UUID v4 (122 random bits): the code is a
77
+ * bearer secret that authorises one enrollment, so 40/60-bit base32 was too short.
78
+ * `PAIR_CODE_LENGTH` and `PAIR_CODE_LEGACY_LENGTH` are the base32 lengths PARSING still accepts
79
+ * for links already in flight; the hub never mints them anymore.
80
+ */
81
+ export const PAIR_CODE_UUID_LENGTH = 36;
82
+ /** Legacy base32 code length (60 bits), accepted when parsing only. */
83
+ export const PAIR_CODE_LENGTH = 12;
84
+ /** Oldest legacy base32 code length (40 bits), accepted when parsing only. */
85
+ export const PAIR_CODE_LEGACY_LENGTH = 8;
75
86
  /** Pairing code TTL by hub clock. 5 min. */
76
87
  export const PAIR_CODE_TTL_MS = 300_000;
77
88
  /** Challenge nonce TTL. 60 s. */
@@ -82,6 +93,12 @@ export const PAIR_RECEIPT_GRACE_MS = 120_000;
82
93
  export const PAIR_SIGNATURE_FAIL_LIMIT = 3;
83
94
  /** Clock-skew tolerance for challenges accepted by the device. ±60 s. */
84
95
  export const PAIR_CLOCK_SKEW_MS = 60_000;
96
+ /** Pair-offer decode window: exp may sit this far in the PAST (clock skew). 30 s. */
97
+ export const PAIR_OFFER_SKEW_MS = 30_000;
98
+ /** Pair-offer decode window: exp may sit at most this far in the FUTURE. 10 min. */
99
+ export const PAIR_OFFER_MAX_AHEAD_MS = 600_000;
100
+ /** Decoded pair-offer JSON byte cap — minted offers are ~180 bytes; anything bigger is not ours. */
101
+ export const PAIR_OFFER_MAX_JSON_BYTES = 1_024;
85
102
 
86
103
  /** Rate limits (per real-IP, hub-side token buckets). */
87
104
  export const PAIR_RATE_LIMITS = {
@@ -10,6 +10,14 @@
10
10
  */
11
11
  import { Type, type Static } from "@sinclair/typebox";
12
12
  import { Value } from "@sinclair/typebox/value";
13
+ import {
14
+ PAIR_CODE_LENGTH,
15
+ PAIR_CODE_LEGACY_LENGTH,
16
+ PAIR_CODE_UUID_LENGTH,
17
+ PAIR_OFFER_MAX_AHEAD_MS,
18
+ PAIR_OFFER_MAX_JSON_BYTES,
19
+ PAIR_OFFER_SKEW_MS,
20
+ } from "./limits.js";
13
21
 
14
22
  const Strict = { additionalProperties: false } as const;
15
23
 
@@ -29,7 +37,57 @@ const DeviceId = Type.String({
29
37
  const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
30
38
  const Nonce = Type.String({ pattern: "^[A-Za-z0-9_-]{43}$", minLength: 43, maxLength: 43 });
31
39
  const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
32
- const PairCode = Type.String({ pattern: "^[A-Z2-7]{8}$", minLength: 8, maxLength: 8 });
40
+ /**
41
+ * Canonical lower-case UUID v4: any hex is NOT a code, so the version nibble must be 4 and the
42
+ * variant nibble 8/9/a/b.
43
+ */
44
+ const UUID_V4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
45
+ /**
46
+ * One pattern for every accepted code shape: the minted UUID v4, plus the 12- and 8-char base32
47
+ * forms that links already in flight still carry. `$` alone can match before a final newline, so
48
+ * the pattern ends with an explicit lookahead — the length clamp below cannot catch a newline
49
+ * when min and max differ.
50
+ */
51
+ export const PAIR_CODE_PATTERN = `^(?:${UUID_V4}|[A-Z2-7]{12}|[A-Z2-7]{8})(?![\\s\\S])`;
52
+ const PairCode = Type.String({
53
+ pattern: PAIR_CODE_PATTERN,
54
+ minLength: PAIR_CODE_LEGACY_LENGTH,
55
+ maxLength: PAIR_CODE_UUID_LENGTH,
56
+ });
57
+
58
+ const PAIR_CODE_RE = new RegExp(PAIR_CODE_PATTERN);
59
+ /** Shape test for the UUID family — used to pick the normalisation rule, never to accept on its own. */
60
+ const UUID_SHAPED_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
61
+
62
+ /**
63
+ * Exact-shape check for a pairing code, with NO trimming: a value with a leading/trailing space
64
+ * or a newline is not a code. Consumers that accept a typed value use `normalizePairCode`.
65
+ */
66
+ export function isPairCode(value: unknown): value is string {
67
+ if (typeof value !== "string" || value.length > PAIR_CODE_UUID_LENGTH) return false;
68
+ return PAIR_CODE_RE.test(value);
69
+ }
70
+
71
+ /**
72
+ * Canonicalise a code a human typed or pasted. The two families canonicalise differently, so the
73
+ * shape decides first:
74
+ * - UUID v4 (`A1B2…-E5F6-4789-ABCD-EFF012345678`): hyphens are structure — only the case changes.
75
+ * - base32 (`abcd-2345-ef67`, `ABCD 2345 EF67`): hyphens/spaces are group separators — they go
76
+ * and the result is upper case.
77
+ * Anything that does not fit either family exactly (a hyphen-less hex run, a 10/11/13-char run,
78
+ * non-alphabet characters, a truncated group like `ABCD-2345`) is refused.
79
+ */
80
+ export function normalizePairCode(raw: string): string | undefined {
81
+ if (typeof raw !== "string") return undefined;
82
+ if (UUID_SHAPED_RE.test(raw)) {
83
+ const lower = raw.toLowerCase();
84
+ return isPairCode(lower) ? lower : undefined; // version/variant nibbles must be v4
85
+ }
86
+ const compact = raw.replace(/[\s-]+/g, "").toUpperCase();
87
+ if (!isPairCode(compact)) return undefined;
88
+ if (/[\s-]/.test(raw) && compact.length !== PAIR_CODE_LENGTH) return undefined;
89
+ return compact;
90
+ }
33
91
 
34
92
  export const PairRequest = Type.Object(
35
93
  {
@@ -163,6 +221,8 @@ export const PairCodeIssue = Type.Object(
163
221
  expiresAt: Type.Integer(),
164
222
  hubOrigin: Type.String({ maxLength: 2048 }),
165
223
  pairUrl: Type.String({ maxLength: 4096 }),
224
+ /** v2 offer link: the whole handoff rides the #offer= fragment (never a query). */
225
+ offerUrl: Type.Optional(Type.String({ maxLength: 2048 })),
166
226
  },
167
227
  Strict,
168
228
  );
@@ -270,6 +330,146 @@ export function parseHubOrigin(
270
330
  return { ok: true, origin: url.origin, tunnelUrl: `${url.origin}/api/tunnel` };
271
331
  }
272
332
 
333
+ /**
334
+ * v2 pairing offer — ONE artifact carrying identity, trust anchor and transport:
335
+ * hub origin + hubId + code + expiry, base64url-encoded into the URL FRAGMENT
336
+ * (`<hubOrigin>/pair#offer=…`). A fragment never leaves the client, so the
337
+ * code cannot reach server/CDN query logs, and the origin travels inside the
338
+ * link (no --hub needed on the device).
339
+ */
340
+ export interface PairOffer {
341
+ v: 2;
342
+ /** Canonical HTTPS hub origin (the transport the device pairs against). */
343
+ hub: string;
344
+ /** hubId trust anchor (deployment id), a canonical UUID. */
345
+ id: string;
346
+ /** The single-use pairing code — canonical UUID v4, the only minted family. */
347
+ code: string;
348
+ /** Expiry, hub epoch ms. */
349
+ exp: number;
350
+ }
351
+
352
+ /** base64url (no padding) of UTF-8 bytes — browser-safe, no Node builtins. */
353
+ function offerEncode(bytes: Uint8Array): string {
354
+ let binary = "";
355
+ for (const b of bytes) binary += String.fromCharCode(b);
356
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
357
+ }
358
+
359
+ function offerDecode(raw: string): Uint8Array | undefined {
360
+ if (raw === "" || !/^[A-Za-z0-9_-]+$/.test(raw)) return undefined;
361
+ try {
362
+ const binary = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
363
+ const bytes = new Uint8Array(binary.length);
364
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
365
+ return bytes;
366
+ } catch {
367
+ return undefined;
368
+ }
369
+ }
370
+
371
+ const OFFER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
372
+ /** The minted family only: offer links carry a UUID v4 code, never legacy base32. */
373
+ const OFFER_CODE_RE = new RegExp(`^${UUID_V4}$`);
374
+
375
+ /**
376
+ * Encode an offer to the exact fragment payload: canonical JSON (field order
377
+ * v,hub,id,code,exp — byte-lean and deterministic), base64url, no padding.
378
+ * Pure encoder: it does not validate; callers mint from live hub values.
379
+ */
380
+ export function encodePairOffer(offer: PairOffer): string {
381
+ return offerEncode(
382
+ new TextEncoder().encode(
383
+ JSON.stringify({ v: offer.v, hub: offer.hub, id: offer.id, code: offer.code, exp: offer.exp }),
384
+ ),
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Structural decode (freshness-blind): exactly the five known fields, v===2,
390
+ * canonical HTTPS origin (byte-exact, reusing the parseHubOrigin rules),
391
+ * canonical UUIDs for id and code, safe-integer exp. Unknown or malformed
392
+ * input → undefined, never a throw. Exported so a caller can tell an EXPIRED
393
+ * offer apart from junk (decodePairOffer refuses both).
394
+ */
395
+ export function readPairOffer(raw: string): PairOffer | undefined {
396
+ const bytes = offerDecode(raw);
397
+ if (!bytes || bytes.length > PAIR_OFFER_MAX_JSON_BYTES) return undefined;
398
+ let json: unknown;
399
+ try {
400
+ json = JSON.parse(new TextDecoder().decode(bytes));
401
+ } catch {
402
+ return undefined;
403
+ }
404
+ if (!json || typeof json !== "object" || Array.isArray(json)) return undefined;
405
+ const o = json as Record<string, unknown>;
406
+ if (Object.keys(o).length !== 5 || o.v !== 2) return undefined; // unknown/missing fields refuse
407
+ if (typeof o.hub !== "string") return undefined;
408
+ const origin = parseHubOrigin(o.hub);
409
+ if (!origin.ok || origin.origin !== o.hub) return undefined; // canonical form, byte-exact
410
+ if (typeof o.id !== "string" || !OFFER_UUID_RE.test(o.id)) return undefined;
411
+ if (typeof o.code !== "string" || !OFFER_CODE_RE.test(o.code)) return undefined;
412
+ if (typeof o.exp !== "number" || !Number.isSafeInteger(o.exp)) return undefined;
413
+ return { v: 2, hub: o.hub, id: o.id, code: o.code, exp: o.exp };
414
+ }
415
+
416
+ /**
417
+ * Strict decode: the structural rules above PLUS freshness — exp is in the
418
+ * future (30 s clock skew allowed) and no further ahead than 10 minutes.
419
+ * Unknown/malformed/stale → undefined, never a throw.
420
+ */
421
+ export function decodePairOffer(raw: string, now: number = Date.now()): PairOffer | undefined {
422
+ const offer = readPairOffer(raw);
423
+ if (!offer) return undefined;
424
+ if (offer.exp < now - PAIR_OFFER_SKEW_MS) return undefined;
425
+ if (offer.exp > now + PAIR_OFFER_MAX_AHEAD_MS) return undefined;
426
+ return offer;
427
+ }
428
+
429
+ export interface ParsedPairOfferUrl {
430
+ offer: PairOffer;
431
+ /** The URL minus its fragment (`<origin>/pair`) — safe to keep in history. */
432
+ rest: string;
433
+ }
434
+
435
+ /**
436
+ * The raw `#offer=` payload of a URL, when the fragment is exactly one
437
+ * unpadded base64url run — undefined otherwise. The cheap first look a caller
438
+ * uses to tell "this is an offer link" apart from a v1 pair URL.
439
+ */
440
+ export function pairOfferFragment(url: string): string | undefined {
441
+ let u: URL;
442
+ try {
443
+ u = new URL(url);
444
+ } catch {
445
+ return undefined;
446
+ }
447
+ if (!u.hash.startsWith("#offer=")) return undefined;
448
+ const payload = u.hash.slice("#offer=".length);
449
+ return /^[A-Za-z0-9_-]+$/.test(payload) ? payload : undefined;
450
+ }
451
+
452
+ /**
453
+ * Parse an offer LINK: only `<origin>/pair` (or `/pair/`) with the fragment
454
+ * `#offer=<payload>` and nothing else — a stray query or any other path →
455
+ * undefined. The offer's hub must equal the link's own origin (the hub minted
456
+ * this link); the fragment never travels server-side.
457
+ */
458
+ export function parsePairOfferUrl(url: string, now: number = Date.now()): ParsedPairOfferUrl | undefined {
459
+ let u: URL;
460
+ try {
461
+ u = new URL(url);
462
+ } catch {
463
+ return undefined;
464
+ }
465
+ if (!/^\/pair\/?$/.test(u.pathname)) return undefined;
466
+ if (u.username || u.password || u.search) return undefined; // the payload rides the fragment only
467
+ if (!u.hash.startsWith("#offer=")) return undefined;
468
+ const offer = decodePairOffer(u.hash.slice("#offer=".length), now);
469
+ if (!offer || offer.hub !== u.origin) return undefined;
470
+ return { offer, rest: `${u.origin}/pair` };
471
+ }
472
+
273
473
  /** Exact Ed25519 signing payload — element order is normative (03 §4.2). */
274
474
  export function pairingSigningPayload(parts: {
275
475
  hubOrigin: string;
@@ -26,7 +26,7 @@ import {
26
26
  } from "../../dpi-sdk/src/api.ts";
27
27
  import type { NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
28
28
  import { COMMANDS, runCommand } from "./commands.ts";
29
- import { pickModel, pickSession, Prompt } from "./pickers.ts";
29
+ import { pickModel, pickSession, Prompt, SecretPrompt } from "./pickers.ts";
30
30
  import { Line, spinner, TranscriptView } from "./render.ts";
31
31
  import { c, editorTheme } from "./theme.ts";
32
32
 
@@ -249,6 +249,11 @@ export class App {
249
249
  this.tui.requestRender();
250
250
  }
251
251
 
252
+ /** How many overlays are open (the welcome screen waits for panels to close). */
253
+ get overlayCount() {
254
+ return this.overlays.length;
255
+ }
256
+
252
257
  closeOverlay(exitIfNothing = false) {
253
258
  const overlay = this.overlays.pop();
254
259
  overlay?.handle.hide();
@@ -277,6 +282,25 @@ export class App {
277
282
  });
278
283
  }
279
284
 
285
+ /** Like prompt(), but the value is only ever rendered as bullets (tokens). */
286
+ promptSecret(label: string) {
287
+ return new Promise<string | undefined>((done) => {
288
+ this.overlay(
289
+ new SecretPrompt(
290
+ label,
291
+ (value) => {
292
+ this.closeOverlay();
293
+ done(value.trim());
294
+ },
295
+ () => {
296
+ this.closeOverlay();
297
+ done(undefined);
298
+ },
299
+ ),
300
+ );
301
+ });
302
+ }
303
+
280
304
  flash(text: string, error = false) {
281
305
  this.notice = { text, error, at: Date.now() };
282
306
  this.redraw(false);