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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.7",
3
+ "version": "0.7.7-rc.8",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { checkAccountDescriptor, validateVaultScopes } from "@openparachute/door-contract";
6
7
  import {
7
8
  handleAccountCapabilities,
8
9
  handleAccountCreateVault,
@@ -12,7 +13,10 @@ import {
12
13
  handleAccountRoot,
13
14
  handleAccountSetVaultCaps,
14
15
  } from "../account-api.ts";
16
+ import { handleCreateInvite } from "../api-invites.ts";
15
17
  import { hubDbPath, openHubDb } from "../hub-db.ts";
18
+ import { getSetting } from "../hub-settings.ts";
19
+ import { findInviteByRawToken, recordInviteRedemption, revokeInvite } from "../invites.ts";
16
20
  import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
17
21
  import { upsertService } from "../services-manifest.ts";
18
22
  import { rotateSigningKey } from "../signing-keys.ts";
@@ -115,31 +119,251 @@ function jsonReq(path: string, token: string, method: string, body?: unknown): R
115
119
  // GET /.well-known/parachute-account — the capabilities descriptor
116
120
  // ===========================================================================
117
121
  describe("handleAccountCapabilities", () => {
118
- test("descriptor is public and reports the self-host door honestly", async () => {
119
- const res = handleAccountCapabilities(new Request(`${ISSUER}/.well-known/parachute-account`), {
120
- issuer: `${ISSUER}/`,
121
- });
122
- expect(res.status).toBe(200);
123
- const body = (await res.json()) as Record<string, unknown>;
124
- expect(body.door).toBe("self-host");
125
- expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
126
- const features = body.features as Record<string, unknown>;
127
- expect(features.billing).toBe(false); // no billing on self-host
128
- expect(features.plans).toEqual([]);
129
- expect(features.vault_create).toBe(true);
130
- expect(features.vault_delete).toBe(true);
131
- expect(features.modules).toBe(true);
132
- expect(features.expose).toBe(true);
133
- expect(body.caps_writable).toBe(true);
134
- expect((body.limits as Record<string, unknown>).vaults_max).toBeNull();
122
+ test("descriptor is public, wildcard-CORS, and conforms to the shared door-contract canon", async () => {
123
+ const h = makeHarness();
124
+ try {
125
+ const res = handleAccountCapabilities(
126
+ new Request(`${ISSUER}/.well-known/parachute-account`),
127
+ { db: h.db, issuer: `${ISSUER}/` },
128
+ );
129
+ expect(res.status).toBe(200);
130
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
131
+ expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
132
+ const body = (await res.json()) as Record<string, unknown>;
133
+ // The shared conformance checker — empty issue list means the shape
134
+ // is byte-conformant with the canonical ParachuteAccountDescriptor.
135
+ expect(checkAccountDescriptor(body, { issuer: ISSUER, door: "hub" })).toEqual([]);
136
+ expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
137
+ expect(body.door).toBe("hub");
138
+ expect(body.account_endpoint).toBe(`${ISSUER}/account`);
139
+ expect(body.auth).toEqual({ methods: ["password"], signin_path: "/login" });
140
+ expect(body.vault_url_template).toContain("{name}");
141
+ expect(body.vault_url_template).toBe(`${ISSUER}/vault/{name}`);
142
+ expect(body.capabilities).toEqual({
143
+ vault_create: true,
144
+ vault_rename: false,
145
+ vault_delete: true,
146
+ });
147
+ expect(body.plans).toEqual([]);
148
+ expect(body.signup_path).toBeUndefined(); // fresh hub, no active public invite
149
+ // Hub extras — additive, outside the shared contract (conformance
150
+ // walks expected keys only, so these ride along without breaking it).
151
+ const features = body.features as Record<string, unknown>;
152
+ expect(features.billing).toBe(false);
153
+ expect(features.modules).toBe(true);
154
+ expect(features.expose).toBe(true);
155
+ expect(features.import).toBe(true);
156
+ expect(features.export).toBe(true);
157
+ expect(body.caps_writable).toBe(true);
158
+ } finally {
159
+ h.cleanup();
160
+ }
135
161
  });
136
162
 
137
163
  test("405 on non-GET", () => {
138
- const res = handleAccountCapabilities(
139
- new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
140
- { issuer: ISSUER },
141
- );
142
- expect(res.status).toBe(405);
164
+ const h = makeHarness();
165
+ try {
166
+ const res = handleAccountCapabilities(
167
+ new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
168
+ { db: h.db, issuer: ISSUER },
169
+ );
170
+ expect(res.status).toBe(405);
171
+ } finally {
172
+ h.cleanup();
173
+ }
174
+ });
175
+
176
+ // -------------------------------------------------------------------------
177
+ // Q2 — the conditional `signup_path`. Present only while an active
178
+ // multi-use (public-signup) invite exists; absent on a fresh hub and
179
+ // absent again once the invite is revoked / exhausted / expired (the
180
+ // setting is lazily cleared on the next read, invites.ts's
181
+ // activePublicSignupPath).
182
+ // -------------------------------------------------------------------------
183
+ test("signup_path appears after minting a max_uses:3 invite, disappears on revoke", async () => {
184
+ const h = makeHarness();
185
+ try {
186
+ const admin = await createUser(h.db, "operator", "any-password", {
187
+ allowMulti: true,
188
+ passwordChanged: true,
189
+ });
190
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
191
+ const mintRes = await handleCreateInvite(
192
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 3 }),
193
+ { db: h.db, issuer: ISSUER },
194
+ );
195
+ expect(mintRes.status).toBe(201);
196
+ const minted = (await mintRes.json()) as { token: string };
197
+
198
+ const afterMint = handleAccountCapabilities(
199
+ new Request(`${ISSUER}/.well-known/parachute-account`),
200
+ { db: h.db, issuer: ISSUER },
201
+ );
202
+ const afterMintBody = (await afterMint.json()) as Record<string, unknown>;
203
+ expect(afterMintBody.signup_path).toBe(`/account/setup/${minted.token}`);
204
+
205
+ const invite = findInviteByRawToken(h.db, minted.token);
206
+ expect(invite).not.toBeNull();
207
+ expect(revokeInvite(h.db, invite?.tokenHash ?? "")).toBe(true);
208
+
209
+ const afterRevoke = handleAccountCapabilities(
210
+ new Request(`${ISSUER}/.well-known/parachute-account`),
211
+ { db: h.db, issuer: ISSUER },
212
+ );
213
+ expect(((await afterRevoke.json()) as Record<string, unknown>).signup_path).toBeUndefined();
214
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined(); // lazily cleared
215
+ } finally {
216
+ h.cleanup();
217
+ }
218
+ });
219
+
220
+ test("signup_path disappears once the invite is exhausted (used_count == max_uses)", async () => {
221
+ const h = makeHarness();
222
+ try {
223
+ const admin = await createUser(h.db, "operator", "any-password", {
224
+ allowMulti: true,
225
+ passwordChanged: true,
226
+ });
227
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
228
+ const mintRes = await handleCreateInvite(
229
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 2 }),
230
+ { db: h.db, issuer: ISSUER },
231
+ );
232
+ const minted = (await mintRes.json()) as { token: string };
233
+ const invite = findInviteByRawToken(h.db, minted.token);
234
+ expect(invite).not.toBeNull();
235
+
236
+ // Redeem both seats directly (invites.ts core — the redeem HTTP flow
237
+ // itself is out of scope here). `redeemed_user_id` FKs to `users`, so
238
+ // reuse the admin row as the redeemer stand-in — the test only cares
239
+ // that TWO redemptions land, not who they belong to.
240
+ expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
241
+ expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
242
+
243
+ const res = handleAccountCapabilities(
244
+ new Request(`${ISSUER}/.well-known/parachute-account`),
245
+ { db: h.db, issuer: ISSUER },
246
+ );
247
+ expect(((await res.json()) as Record<string, unknown>).signup_path).toBeUndefined();
248
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
249
+ } finally {
250
+ h.cleanup();
251
+ }
252
+ });
253
+
254
+ test("signup_path disappears once the invite expires (injected clock)", async () => {
255
+ const h = makeHarness();
256
+ try {
257
+ const admin = await createUser(h.db, "operator", "any-password", {
258
+ allowMulti: true,
259
+ passwordChanged: true,
260
+ });
261
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
262
+ const mintTime = new Date("2026-01-01T00:00:00.000Z");
263
+ const mintRes = await handleCreateInvite(
264
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 5, expires_in: 60 }),
265
+ { db: h.db, issuer: ISSUER, now: () => mintTime },
266
+ );
267
+ expect(mintRes.status).toBe(201);
268
+
269
+ const stillLive = handleAccountCapabilities(
270
+ new Request(`${ISSUER}/.well-known/parachute-account`),
271
+ { db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 30_000) },
272
+ );
273
+ expect(((await stillLive.json()) as Record<string, unknown>).signup_path).toBeDefined();
274
+
275
+ const afterExpiry = handleAccountCapabilities(
276
+ new Request(`${ISSUER}/.well-known/parachute-account`),
277
+ { db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 120_000) },
278
+ );
279
+ expect(((await afterExpiry.json()) as Record<string, unknown>).signup_path).toBeUndefined();
280
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
281
+ } finally {
282
+ h.cleanup();
283
+ }
284
+ });
285
+
286
+ test("signup_path is NOT set for a multi-use NON-provisioning invite (B1: a team link must never leak on the public descriptor)", async () => {
287
+ const h = makeHarness();
288
+ try {
289
+ const admin = await createUser(h.db, "operator", "any-password", {
290
+ allowMulti: true,
291
+ passwordChanged: true,
292
+ });
293
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
294
+ // A multi-use link with provision_vault=false is a team invite (here
295
+ // account-only; a shared-vault one additionally pins an existing
296
+ // vault_name and would hand out team-vault WRITE) — NOT a public signup
297
+ // that gives each redeemer their own fresh vault. It must never be
298
+ // persisted to public_signup_token nor advertised via the anonymous,
299
+ // wildcard-CORS descriptor. Same `provisionVault` guard covers both the
300
+ // account-only and shared-vault non-provisioning shapes.
301
+ const mintRes = await handleCreateInvite(
302
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 3, provision_vault: false }),
303
+ { db: h.db, issuer: ISSUER },
304
+ );
305
+ expect(mintRes.status).toBe(201);
306
+ // The raw token was NOT persisted (the write-side guard)...
307
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
308
+ // ...and the public descriptor advertises no signup_path.
309
+ const desc = handleAccountCapabilities(
310
+ new Request(`${ISSUER}/.well-known/parachute-account`),
311
+ { db: h.db, issuer: ISSUER },
312
+ );
313
+ expect(((await desc.json()) as Record<string, unknown>).signup_path).toBeUndefined();
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+ });
319
+
320
+ // ===========================================================================
321
+ // The shared door-contract validateVaultScopes — behavioral no-op proof
322
+ // (hub-parity P2, item 5). These are the SAME fixtures the hub's prior local
323
+ // `parseScopesBody` scope-shape logic was exercised against (see the
324
+ // `handleAccountMintVaultToken` describe block below for the HTTP-level
325
+ // equivalents); asserting them directly against the shared validator proves
326
+ // the swap didn't change hub's wire behavior.
327
+ // ===========================================================================
328
+ describe("validateVaultScopes (shared door-contract validator) — hub's prior fixtures", () => {
329
+ test("undefined / null / [] all default to read+write for the named vault", () => {
330
+ for (const requested of [undefined, null, []]) {
331
+ expect(validateVaultScopes(requested, "field-notes")).toEqual({
332
+ ok: true,
333
+ scopes: ["vault:field-notes:read", "vault:field-notes:write"],
334
+ });
335
+ }
336
+ });
337
+
338
+ test("an explicit single-scope array is honored verbatim", () => {
339
+ expect(validateVaultScopes(["vault:field-notes:read"], "field-notes")).toEqual({
340
+ ok: true,
341
+ scopes: ["vault:field-notes:read"],
342
+ });
343
+ });
344
+
345
+ test("a scope naming another vault is invalid_scope", () => {
346
+ expect(validateVaultScopes(["vault:other:read"], "field-notes")).toEqual({
347
+ ok: false,
348
+ reason: "invalid_scope",
349
+ });
350
+ });
351
+
352
+ test("a non-array requested value is invalid_request", () => {
353
+ expect(validateVaultScopes("not-an-array", "field-notes")).toEqual({
354
+ ok: false,
355
+ reason: "invalid_request",
356
+ });
357
+ });
358
+
359
+ test("a mixed array with a non-string entry is invalid_request, even when a valid scope leads", () => {
360
+ // The whole-array pre-scan (byte-exact with hub's prior parseScopesBody):
361
+ // a non-string entry anywhere rejects the WHOLE request as
362
+ // invalid_request, never a positional invalid_scope.
363
+ expect(validateVaultScopes(["vault:field-notes:read", 123], "field-notes")).toEqual({
364
+ ok: false,
365
+ reason: "invalid_request",
366
+ });
143
367
  });
144
368
  });
145
369
 
@@ -327,6 +551,26 @@ describe("handleAccountCreateVault", () => {
327
551
  h.cleanup();
328
552
  }
329
553
  });
554
+
555
+ // Q6 (hub-parity P2): create-existing-name converges on 409 vault_taken —
556
+ // this facade no longer answers 200-idempotent. `provisionVault` itself
557
+ // stays idempotent (the invite-redeem caller doesn't route through here).
558
+ test("409 vault_taken on an existing vault name (Q6 — no longer 200-idempotent)", async () => {
559
+ const h = makeHarness(["beta", "personal"]);
560
+ try {
561
+ const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
562
+ const res = await handleAccountCreateVault(
563
+ jsonReq("/account/vaults", token, "POST", { name: "beta" }),
564
+ deps(h),
565
+ );
566
+ expect(res.status).toBe(409);
567
+ const body = (await res.json()) as { error: string; message: string };
568
+ expect(body.error).toBe("vault_taken");
569
+ expect(body.message).toBe("That vault name is already taken.");
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
330
574
  });
331
575
 
332
576
  // ===========================================================================
@@ -489,7 +733,7 @@ describe("account caps", () => {
489
733
  // GET /account — bootstrap
490
734
  // ===========================================================================
491
735
  describe("handleAccountRoot", () => {
492
- test("returns account_id=self, door=self-host, and the operator email", async () => {
736
+ test("returns the contract's AccountBootstrap — {id:self, door:hub, email}", async () => {
493
737
  const h = makeHarness();
494
738
  try {
495
739
  const user = await createUser(h.db, "operator", "any-password", {
@@ -500,10 +744,26 @@ describe("handleAccountRoot", () => {
500
744
  const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
501
745
  const res = await handleAccountRoot(withBearer("/account", token), deps(h));
502
746
  expect(res.status).toBe(200);
503
- const body = (await res.json()) as { account_id: string; email: string | null; door: string };
504
- expect(body.account_id).toBe("self");
505
- expect(body.door).toBe("self-host");
506
- expect(body.email).toBe("op@example.com");
747
+ const body = (await res.json()) as { id: string; email?: string; door: string };
748
+ expect(body).toEqual({ id: "self", door: "hub", email: "op@example.com" });
749
+ } finally {
750
+ h.cleanup();
751
+ }
752
+ });
753
+
754
+ test("omits email when the operator row has none", async () => {
755
+ const h = makeHarness();
756
+ try {
757
+ const user = await createUser(h.db, "operator", "any-password", {
758
+ allowMulti: true,
759
+ passwordChanged: true,
760
+ });
761
+ const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
762
+ const res = await handleAccountRoot(withBearer("/account", token), deps(h));
763
+ expect(res.status).toBe(200);
764
+ const body = (await res.json()) as { id: string; email?: string; door: string };
765
+ expect(body).toEqual({ id: "self", door: "hub" });
766
+ expect(body.email).toBeUndefined();
507
767
  } finally {
508
768
  h.cleanup();
509
769
  }
@@ -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({
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
+ }