@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.9

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.
@@ -33,6 +33,11 @@
33
33
  * ownership gate the cloud twin runs per-vault is trivially satisfied here.
34
34
  */
35
35
  import type { Database } from "bun:sqlite";
36
+ import {
37
+ type AccountBootstrap,
38
+ type ParachuteAccountDescriptor,
39
+ validateVaultScopes,
40
+ } from "@openparachute/door-contract";
36
41
  import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "./account-home-ui.ts";
37
42
  import {
38
43
  type AdminAuthContext,
@@ -42,6 +47,7 @@ import {
42
47
  } from "./admin-auth.ts";
43
48
  import { HOST_ADMIN_SCOPE, provisionVault } from "./admin-vaults.ts";
44
49
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
50
+ import { activePublicSignupPath } from "./invites.ts";
45
51
  import { inferAudience } from "./jwt-audience.ts";
46
52
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
47
53
  import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "./scope-explanations.ts";
@@ -102,10 +108,14 @@ export interface AccountApiDeps {
102
108
  // Shared helpers
103
109
  // ---------------------------------------------------------------------------
104
110
 
105
- function json(status: number, body: unknown): Response {
111
+ function json(status: number, body: unknown, extraHeaders?: Record<string, string>): Response {
106
112
  return new Response(JSON.stringify(body), {
107
113
  status,
108
- headers: { "content-type": "application/json", "cache-control": "no-store" },
114
+ headers: {
115
+ "content-type": "application/json",
116
+ "cache-control": "no-store",
117
+ ...extraHeaders,
118
+ },
109
119
  });
110
120
  }
111
121
 
@@ -200,43 +210,69 @@ function servicesBlock(meta: VaultMeta): Record<string, { url: string; version:
200
210
  // ---------------------------------------------------------------------------
201
211
 
202
212
  /**
203
- * The self-host door descriptor. Public, no auth it is what lets the app
204
- * render honestly per door (D2): `billing:false` + `plans:[]` means the app
205
- * shows no billing/upgrade UI on self-host; `caps_writable:true` means the
206
- * operator can PUT caps freely (the cloud twin is plan-derived → false).
213
+ * The self-host door descriptor — the canonical `ParachuteAccountDescriptor`
214
+ * (door-contract 0.4.0) both doors serve, so a client (the app) branches its
215
+ * front door without hardcoding per-door shapes. Public, no auth, wildcard
216
+ * CORS (the app pulls it cross-origin).
217
+ *
218
+ * `features`/`caps_writable` are hub EXTRAS beyond the shared contract — the
219
+ * shared conformance checker (`checkAccountDescriptor`) walks expected keys
220
+ * only, so these ride along without breaking cross-door conformance.
221
+ * `billing:false` + `plans:[]` (Q7, parked) mean the app shows no
222
+ * billing/upgrade UI on self-host; `caps_writable:true` means the operator
223
+ * can PUT caps freely (the cloud twin is plan-derived → false).
207
224
  *
208
- * `import`/`export` describe the self-host DOOR's portability capability
209
- * (which it has). The `/account/vaults/<name>/export` + `/account/vaults/import`
210
- * REST endpoints are a follow-on to this PR (H2 ships the vault-lifecycle +
211
- * caps core); the flags stay true because the door supports portability.
225
+ * `signup_path` is conditional (Q2): present only while an active multi-use
226
+ * public invite exists (`activePublicSignupPath`, invites.ts) an operator-
227
+ * shared link is otherwise the only way in, so the app must not render a
228
+ * "create account" affordance when there is nowhere for it to go.
212
229
  */
213
- export function handleAccountCapabilities(req: Request, deps: { issuer: string }): Response {
230
+ export function handleAccountCapabilities(
231
+ req: Request,
232
+ deps: { db: Database; issuer: string; now?: () => Date },
233
+ ): Response {
214
234
  if (req.method !== "GET") return methodNotAllowed("GET");
215
- const descriptor = {
216
- door: "self-host",
217
- issuer: deps.issuer.replace(/\/$/, ""),
218
- account_token: { endpoint: "/account/token", method: "POST", scheme: "cookie" },
235
+ const issuer = deps.issuer.replace(/\/$/, "");
236
+ const now = deps.now ? deps.now() : new Date();
237
+ const signupPath = activePublicSignupPath(deps.db, now);
238
+ const descriptor: ParachuteAccountDescriptor & {
219
239
  features: {
220
- vault_create: true,
221
- vault_delete: true,
222
- import: true,
223
- export: true,
224
- billing: false,
225
- plans: [] as string[],
226
- modules: true,
227
- expose: true,
228
- },
240
+ modules: boolean;
241
+ expose: boolean;
242
+ import: boolean;
243
+ export: boolean;
244
+ billing: boolean;
245
+ };
246
+ caps_writable: boolean;
247
+ } = {
248
+ issuer,
249
+ door: "hub",
250
+ account_endpoint: `${issuer}/account`,
251
+ auth: { methods: ["password"], signin_path: "/login" },
252
+ ...(signupPath ? { signup_path: signupPath } : {}),
253
+ vault_url_template: `${issuer}/vault/{name}`,
254
+ capabilities: { vault_create: true, vault_rename: false, vault_delete: true },
255
+ plans: [],
256
+ // Hub EXTRAS (kept — see the doc comment above).
257
+ features: { modules: true, expose: true, import: true, export: true, billing: false },
229
258
  caps_writable: true,
230
- limits: { vaults_max: null as number | null },
231
259
  };
232
- return json(200, descriptor);
260
+ return json(200, descriptor, {
261
+ "access-control-allow-origin": "*",
262
+ "access-control-allow-methods": "GET, OPTIONS",
263
+ });
233
264
  }
234
265
 
235
266
  // ---------------------------------------------------------------------------
236
267
  // GET /account — account bootstrap
237
268
  // ---------------------------------------------------------------------------
238
269
 
239
- /** `{ account_id, email, door }`. On self-host the account id is `self`. */
270
+ /**
271
+ * The contract's `AccountBootstrap` — `{ id, email?, door }`. On self-host the
272
+ * account id is the sentinel `self`; `email` is present only when the
273
+ * operator row has one (`users.email` is nullable-by-history, migration
274
+ * v15 — the door-contract type models it as optional, not nullable).
275
+ */
240
276
  export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Promise<Response> {
241
277
  if (req.method !== "GET") return methodNotAllowed("GET");
242
278
  let ctx: AdminAuthContext;
@@ -246,11 +282,12 @@ export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Pro
246
282
  return adminAuthErrorResponse(err);
247
283
  }
248
284
  const user = getUserById(deps.db, ctx.sub);
249
- return json(200, {
250
- account_id: "self",
251
- email: user?.email ?? null,
252
- door: "self-host",
253
- });
285
+ const body: AccountBootstrap = {
286
+ id: "self",
287
+ door: "hub",
288
+ ...(user?.email ? { email: user.email } : {}),
289
+ };
290
+ return json(200, body);
254
291
  }
255
292
 
256
293
  // ---------------------------------------------------------------------------
@@ -369,6 +406,19 @@ export async function handleAccountCreateVault(
369
406
  const error = provisioned.status === 400 ? "invalid_name" : "server_error";
370
407
  return json(provisioned.status, { error, message: provisioned.message });
371
408
  }
409
+ // Q6 (hub-parity P2): this facade no longer answers 200-idempotent on an
410
+ // existing name — it converges on cloud's exact 409 `vault_taken` shape.
411
+ // `provisionVault` itself is UNCHANGED (still idempotent for its other
412
+ // caller, the invite-redeem flow, which doesn't route through this
413
+ // facade) — only this facade's wire answer changes. A scripted consumer
414
+ // that relied on 200-on-existing must follow up with
415
+ // `POST /account/vaults/<name>/token` to get a usable token.
416
+ if (!provisioned.created) {
417
+ return json(409, {
418
+ error: "vault_taken",
419
+ message: "That vault name is already taken.",
420
+ });
421
+ }
372
422
 
373
423
  const entry = provisioned.entry;
374
424
  const meta: VaultMeta = { name: entry.name, url: entry.url, version: entry.version };
@@ -387,25 +437,30 @@ export async function handleAccountCreateVault(
387
437
  : {}),
388
438
  services: servicesBlock(meta),
389
439
  };
390
- // 201 on a fresh create; 200 on an idempotent re-POST of an existing vault
391
- // (no fresh token — `vault_token` is "" and the app mints via /token).
392
- return json(provisioned.created ? 201 : 200, body);
440
+ return json(201, body);
393
441
  }
394
442
 
395
443
  // ---------------------------------------------------------------------------
396
444
  // POST /account/vaults/<name>/token — per-vault token mint
397
445
  // ---------------------------------------------------------------------------
398
446
 
399
- const MINTABLE_VERBS = new Set(["read", "write", "admin"]);
400
-
401
447
  interface ScopesBody {
402
448
  ok: true;
403
449
  scopes: string[];
404
450
  }
405
451
 
406
452
  /**
407
- * Parse + validate the requested `scopes`. Each must be `vault:<name>:<verb>`
408
- * for THIS vault with a mintable verb. Omitted / empty → default read+write.
453
+ * Parse + validate the requested `scopes`. The JSON-parse tolerance (optional
454
+ * body, optional content-type, swallow a malformed body) stays LOCAL it's
455
+ * HTTP plumbing the shared validator knows nothing about (it's pure, no
456
+ * `Request`). The scope-SHAPE logic (array check, per-entry
457
+ * `vault:<name>:<verb>` grammar, empty/absent → default read+write) is the
458
+ * shared `validateVaultScopes` (door-contract 0.4.0) — the ONE implementation
459
+ * cloud's twin also imports, replacing the two hand-synced copies. Its reason
460
+ * taxonomy (`invalid_request` | `invalid_scope`) was built byte-exact with
461
+ * this function's prior behavior (see vault-scopes.ts's doc comment), so this
462
+ * swap is a behavioral no-op for the hub — verified by rerunning this file's
463
+ * existing test cases unchanged (account-api.test.ts).
409
464
  */
410
465
  async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesBody | BodyErr> {
411
466
  const defaultScopes = [`vault:${vaultName}:read`, `vault:${vaultName}:write`];
@@ -422,34 +477,24 @@ async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesB
422
477
  }
423
478
  if (!raw || typeof raw !== "object") return { ok: true, scopes: defaultScopes };
424
479
  const requested = (raw as Record<string, unknown>).scopes;
425
- if (requested === undefined || requested === null) return { ok: true, scopes: defaultScopes };
426
- if (!Array.isArray(requested) || requested.some((s) => typeof s !== "string")) {
427
- return {
428
- ok: false,
429
- status: 400,
430
- error: "invalid_request",
431
- message: '"scopes" must be an array of strings',
432
- };
433
- }
434
- const scopes = requested as string[];
435
- if (scopes.length === 0) return { ok: true, scopes: defaultScopes };
436
- for (const s of scopes) {
437
- const parts = s.split(":");
438
- if (
439
- parts.length !== 3 ||
440
- parts[0] !== "vault" ||
441
- parts[1] !== vaultName ||
442
- !MINTABLE_VERBS.has(parts[2] ?? "")
443
- ) {
444
- return {
445
- ok: false,
446
- status: 400,
447
- error: "invalid_scope",
448
- message: `scope "${s}" must be vault:${vaultName}:{read|write|admin}`,
449
- };
450
- }
480
+
481
+ const result = validateVaultScopes(requested, vaultName);
482
+ if (!result.ok) {
483
+ return result.reason === "invalid_request"
484
+ ? {
485
+ ok: false,
486
+ status: 400,
487
+ error: "invalid_request",
488
+ message: '"scopes" must be an array of strings',
489
+ }
490
+ : {
491
+ ok: false,
492
+ status: 400,
493
+ error: "invalid_scope",
494
+ message: `every scope must be vault:${vaultName}:{read|write|admin}`,
495
+ };
451
496
  }
452
- return { ok: true, scopes };
497
+ return { ok: true, scopes: result.scopes };
453
498
  }
454
499
 
455
500
  export async function handleAccountMintVaultToken(
@@ -32,6 +32,7 @@ import type { Database } from "bun:sqlite";
32
32
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
33
33
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
34
34
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
35
+ import { setSetting } from "./hub-settings.ts";
35
36
  import {
36
37
  DEFAULT_INVITE_TTL_SECONDS,
37
38
  type Invite,
@@ -543,6 +544,24 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
543
544
  vaultCapBytes: effectiveVaultCapBytes,
544
545
  ...(deps.now !== undefined ? { now: deps.now } : {}),
545
546
  });
547
+ // Q2 (hub-parity P2, the raw-token reality): a multi-use PROVISIONING link
548
+ // IS the public signup page (the operator mints it to broadcast, and each
549
+ // redeemer gets their OWN new vault) — persisting ITS raw token so the
550
+ // account descriptor can advertise `signup_path` does NOT weaken the
551
+ // hash-only posture of single-use friend invites, which never write this row.
552
+ // The `provisionVault` guard is load-bearing security, not cosmetic: a
553
+ // multi-use SHARED-vault invite (`provision_vault=false` + an existing
554
+ // vault_name — "many users join one team vault", coherent per the mint gates
555
+ // above) must NEVER be auto-published on the anonymous, wildcard-CORS
556
+ // descriptor, or its team-vault-write link leaks to anyone who fetches the
557
+ // exposed hub. Only multi-use + provisioning = genuine public signup (the
558
+ // same conjunction the vault-cap heuristic above already treats as such).
559
+ // The newest public link wins (overwrites any prior value);
560
+ // `activePublicSignupPath` (invites.ts) re-validates liveness on every read
561
+ // and lazily clears a revoked/exhausted/expired one.
562
+ if (maxUses > 1 && provisionVault) {
563
+ setSetting(deps.db, "public_signup_token", issued.rawToken, deps.now ?? (() => new Date()));
564
+ }
546
565
  const status = inviteStatus(issued.invite, now);
547
566
  return new Response(
548
567
  JSON.stringify({
@@ -82,7 +82,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
82
82
 
83
83
  /**
84
84
  * Resolve a known module to the display + install bootstrap data the admin SPA
85
- * renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
85
+ * renders. Reads from FIRST_PARTY_FALLBACKS (notes / app) first,
86
86
  * KNOWN_MODULES (vault / scribe / agent / surface) second.
87
87
  *
88
88
  * Returns `undefined` if the short is in neither table — a genuinely
@@ -1,3 +1,4 @@
1
+ import type { Database } from "bun:sqlite";
1
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { createConnection } from "node:net";
3
4
  import { dirname, join } from "node:path";
@@ -15,6 +16,8 @@ import {
15
16
  defaultSleep,
16
17
  readHubPort,
17
18
  } from "../hub-control.ts";
19
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import { resolveRootRedirectDetailed, setRootRedirect } from "../hub-settings.ts";
18
21
  import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
19
22
  import {
20
23
  type ModuleManifest,
@@ -408,6 +411,16 @@ export interface InstallOpts {
408
411
  * origin for stable assertions.
409
412
  */
410
413
  wellKnownOrigin?: string;
414
+ /**
415
+ * Test seam for the `app`-only set-if-unset root-redirect write (hub-parity
416
+ * P5). Production opens `openHubDb(hubDbPath(configDir))` fresh and closes
417
+ * it after the write; passing this injects an already-open DB (e.g. a
418
+ * tempdir `hub.db`) so a test never touches the real `~/.parachute/hub.db`.
419
+ * Passing this ALSO opts a tempdir-manifestPath test INTO the write path —
420
+ * mirrors `guidanceCtx`'s discriminant: without it, the production gate
421
+ * (`manifestPath === SERVICES_MANIFEST_PATH`) skips the write entirely.
422
+ */
423
+ rootRedirectDb?: Database;
411
424
  }
412
425
 
413
426
  async function defaultRunner(cmd: readonly string[]): Promise<number> {
@@ -1351,6 +1364,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
1351
1364
  }
1352
1365
  }
1353
1366
 
1367
+ // App-only: default the hub's bare `/` redirect to the app's front door on
1368
+ // first install (hub-parity P5, 2026-07-11) — SET-IF-UNSET ONLY, never
1369
+ // clobbering an operator's existing choice (`parachute hub
1370
+ // set-root-redirect` or the admin SPA PUT both still win on any later run,
1371
+ // and either can change it back). "Unset" means the resolved redirect is
1372
+ // still the built-in `/admin` DEFAULT — i.e. NEITHER the DB row NOR the
1373
+ // `PARACHUTE_HUB_ROOT_REDIRECT` env var (container deploys pin their landing
1374
+ // page there) has set it. Gating on `getRootRedirect(db) === null` would
1375
+ // miss the env tier and silently override an env-configured operator, since
1376
+ // the DB row we'd write wins over env on read (`resolveRootRedirectDetailed`
1377
+ // is DB-first). Gated on the same production-vs-test discriminant the
1378
+ // guidance probe below uses: a test driving install against a tempdir
1379
+ // manifestPath never opens the real `~/.parachute/hub.db` unless it opts in
1380
+ // via `opts.rootRedirectDb`.
1381
+ if (short === "app") {
1382
+ const dbProbeAllowed =
1383
+ opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1384
+ if (dbProbeAllowed) {
1385
+ const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
1386
+ try {
1387
+ if (resolveRootRedirectDetailed(db).source === "default") {
1388
+ setRootRedirect(db, "/app/");
1389
+ log("✓ The hub's front page (`/`) now opens the app at /app/.");
1390
+ log(" Change it any time: `parachute hub set-root-redirect <path>` or the admin SPA.");
1391
+ }
1392
+ } finally {
1393
+ if (!opts.rootRedirectDb) db.close();
1394
+ }
1395
+ }
1396
+ }
1397
+
1354
1398
  // Per-service install footer — canonical next-step URLs and configuration
1355
1399
  // hints. Vault prints its own (richer) footer from `parachute-vault init`
1356
1400
  // (PR #166), so the spec leaves vault out and we don't double up here.
@@ -110,7 +110,7 @@ function defaultAvailability(): InteractiveAvailability {
110
110
 
111
111
  /**
112
112
  * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
- * surface) regardless of tier — `installed` is true when the service
113
+ * surface / app) regardless of tier — `installed` is true when the service
114
114
  * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
115
  * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
116
  * notes); agent (`experimental`) is flagged exploratory in its blurb
@@ -121,7 +121,7 @@ function defaultAvailability(): InteractiveAvailability {
121
121
  * like any unknown row it neither blocks setup nor appears in the offer.
122
122
  *
123
123
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
124
- * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
124
+ * shorts (notes / app both carry a vendored manifest). KNOWN_MODULES shorts
125
125
  * (vault / scribe / agent / surface) ship `.parachute/module.json`
126
126
  * and self-register; pre-install we know manifestName + the urlForEntry quirk
127
127
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
@@ -175,8 +175,13 @@ export function isOfferable(choice: { short: string; installed: boolean }): bool
175
175
  const BLURBS: Record<string, string> = {
176
176
  vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
177
177
  surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
178
- // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
179
- app: "Parachute UI host auto-installs Notes on first boot (recommended over notes-daemon)",
178
+ // hub-parity P5 (2026-07-11): `app` is now the NEW super-surface front
179
+ // door (@openparachute/parachute-app)a real FIRST_PARTY_FALLBACKS
180
+ // entry, not a legacy survey row. It is UNRELATED to the pre-2026-05-27
181
+ // `app` package that renamed to `surface` (that blurb lives under the
182
+ // `surface` key above); the two just happen to share the short name
183
+ // across a rename.
184
+ app: "the super-surface front door — one app to sign in, browse your vault, and manage your hub/cloud",
180
185
  // notes is `deprecated` (not offered on a fresh setup) — this blurb only
181
186
  // renders if a legacy install surfaces it in the survey.
182
187
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
package/src/help.ts CHANGED
@@ -362,11 +362,11 @@ Exit codes:
362
362
 
363
363
  Example:
364
364
  $ parachute status
365
- SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
366
- parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
365
+ SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
366
+ parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
367
367
  → http://127.0.0.1:1940/vault/default/mcp
368
- parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
369
- → http://127.0.0.1:1946/surface/notes
368
+ parachute-surface 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
369
+ → http://127.0.0.1:1946/surface
370
370
  `;
371
371
  }
372
372
 
@@ -551,8 +551,9 @@ Examples:
551
551
  Module start commands (run by the supervisor under \`serve\`):
552
552
  vault parachute-vault serve
553
553
  scribe parachute-scribe serve
554
- app parachute-app serve
554
+ surface parachute-surface serve
555
555
  agent parachute-agent
556
+ app bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> --package @openparachute/parachute-app
556
557
  notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
557
558
  `;
558
559
  }
package/src/hub-server.ts CHANGED
@@ -2862,7 +2862,13 @@ export function hubFetch(
2862
2862
  if (req.method === "OPTIONS") {
2863
2863
  return new Response(null, { status: 204, headers: corsHeaders });
2864
2864
  }
2865
- const res = handleAccountCapabilities(req, { issuer: oauthDeps(req).issuer });
2865
+ if (!getDb) {
2866
+ return new Response('{"error":"account descriptor unavailable: db not configured"}', {
2867
+ status: 503,
2868
+ headers: { "content-type": "application/json", ...corsHeaders },
2869
+ });
2870
+ }
2871
+ const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
2866
2872
  const merged = new Headers(res.headers);
2867
2873
  for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
2868
2874
  return new Response(res.body, { status: res.status, headers: merged });
@@ -3960,9 +3966,10 @@ export function hubFetch(
3960
3966
  }
3961
3967
  return new Response("method not allowed", { status: 405 });
3962
3968
  }
3963
- // GET /account (Bearer) — account bootstrap {account_id,email,door}. Only
3964
- // intercepts when an Authorization header is present, so the cookie-authed
3965
- // HTML account home at `/account`/`/account/` below stays unaffected.
3969
+ // GET /account (Bearer) — account bootstrap {id,email,door} (the
3970
+ // door-contract's AccountBootstrap). Only intercepts when an
3971
+ // Authorization header is present, so the cookie-authed HTML account
3972
+ // home at `/account`/`/account/` below stays unaffected.
3966
3973
  if (
3967
3974
  (pathname === "/account" || pathname === "/account/") &&
3968
3975
  req.headers.get("authorization")
@@ -122,7 +122,21 @@ export type HubSettingKey =
122
122
  // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
123
  // not-yet-set-up hub still lands on setup, not a surface that can't work
124
124
  // yet.
125
- | "root_redirect";
125
+ | "root_redirect"
126
+ // hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
127
+ // `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
128
+ // can advertise `signup_path` without being able to reconstruct it from the
129
+ // `invites` table (which stores only `sha256(token)`, see invites.ts's
130
+ // module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
131
+ // after a successful multi-use `issueInvite`; read (and lazily cleared once
132
+ // stale) by `invites.ts`'s `activePublicSignupPath`.
133
+ //
134
+ // This does NOT weaken the hash-only posture of single-use friend invites
135
+ // (`max_uses === 1`, the default) — those NEVER write this row. A
136
+ // multi-use link is, by construction, the operator's deliberately PUBLIC
137
+ // signup page (minted to broadcast), so persisting its raw token is no
138
+ // more sensitive than the link itself already being handed out widely.
139
+ | "public_signup_token";
126
140
 
127
141
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
128
142
 
package/src/invites.ts CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Database } from "bun:sqlite";
59
59
  import { createHash, randomBytes } from "node:crypto";
60
+ import { deleteSetting, getSetting } from "./hub-settings.ts";
60
61
 
61
62
  /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
62
63
  export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
502
503
  .run(now.toISOString(), vaultName);
503
504
  return Number(res.changes);
504
505
  }
506
+
507
+ /**
508
+ * Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
509
+ * The hub cannot derive a redemption URL from the `invites` table (only
510
+ * `sha256(token)` is stored, never the raw value — see the module doc), so
511
+ * this reads back the ONE raw token persisted at mint time for a
512
+ * deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
513
+ * `handleCreateInvite`, which writes `hub_settings.public_signup_token`
514
+ * after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
515
+ *
516
+ * Returns `/account/setup/<token>` when the persisted invite is still
517
+ * `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
518
+ * (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
519
+ * miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
520
+ * shared-vault link) — and lazily clears the stale setting in that case. No
521
+ * separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
522
+ * all four terminal states from the invite's own columns, so every miss flows
523
+ * through this one status check.
524
+ *
525
+ * The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
526
+ * of `handleCreateInvite`'s write condition (which only persists for a
527
+ * multi-use provisioning link): even a hand-edited settings row pointing at a
528
+ * single-use OR a shared-vault (`provision_vault=false`) invite must never be
529
+ * advertised as public signup — a shared-vault link would leak team-vault write
530
+ * on the anonymous, wildcard-CORS descriptor.
531
+ */
532
+ export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
533
+ const raw = getSetting(db, "public_signup_token");
534
+ if (!raw) return null;
535
+ const invite = findInviteByRawToken(db, raw);
536
+ if (
537
+ !invite ||
538
+ inviteStatus(invite, now) !== "pending" ||
539
+ invite.maxUses <= 1 ||
540
+ !invite.provisionVault
541
+ ) {
542
+ deleteSetting(db, "public_signup_token");
543
+ return null;
544
+ }
545
+ return `/account/setup/${encodeURIComponent(raw)}`;
546
+ }