@openparachute/hub 0.6.4-rc.1 → 0.6.4-rc.3

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.6.4-rc.1",
3
+ "version": "0.6.4-rc.3",
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": {
@@ -558,8 +558,13 @@ describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
558
558
  const [r1, r2] = await Promise.all([mk("alice", a), mk("bob", b)]);
559
559
 
560
560
  const statuses = [r1.status, r2.status].sort();
561
- // Exactly one 302 (success) and one 410 (the loser's used-path).
562
- expect(statuses).toEqual([302, 410]);
561
+ // Exactly one 302 (success). The loser is rejected either at the
562
+ // FIX-1 existing-vault gate (409, it saw the name already created) or at
563
+ // the consume-inside-tx race (410, both raced past the existence check
564
+ // then one lost the invite-consume). Both are correct single-account
565
+ // outcomes; the only invariant is "exactly one success, one rejection".
566
+ expect(statuses[0]).toBe(302);
567
+ expect(statuses[1] === 409 || statuses[1] === 410).toBe(true);
563
568
  // EXACTLY one account was created from the invite — no orphan row.
564
569
  expect(userCount(harness.db) - before).toBe(1);
565
570
  const aliceExists = getUserByUsernameCI(harness.db, "alice") !== null;
@@ -607,3 +612,269 @@ describe("POST /account/setup/<token> — account-only invite (N3)", () => {
607
612
  expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
608
613
  });
609
614
  });
615
+
616
+ /**
617
+ * Add a pre-existing vault (someone else's) directly to services.json so
618
+ * `provisionVault`'s existence check finds it WITHOUT a shell-out. Mirrors the
619
+ * shape the stub writes.
620
+ */
621
+ function seedExistingVault(name: string): void {
622
+ const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
623
+ services: { name: string; paths: string[] }[];
624
+ };
625
+ const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
626
+ if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
627
+ vaultSvc.paths.push(`/vault/${name}`);
628
+ writeFileSync(harness.manifestPath, JSON.stringify(manifest));
629
+ }
630
+ }
631
+
632
+ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection (FIX-1)", () => {
633
+ test("HEADLINE: invitee picks an EXISTING vault name → rejected, no account, owner unchanged", async () => {
634
+ // An owner already holds "shared-vault".
635
+ const owner = await createUser(harness.db, "owner", "owner-strong-password-1", {
636
+ assignedVaults: ["shared-vault"],
637
+ role: "write",
638
+ });
639
+ seedExistingVault("shared-vault");
640
+
641
+ // Unpinned invite: the invitee gets to type a vault name.
642
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
643
+ allowMulti: true,
644
+ });
645
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // vault_name null
646
+ const before = userCount(harness.db);
647
+ const { token: csrfToken, cookieFragment } = csrfPair();
648
+ const stub = makeStubRunCommand();
649
+ const res = await handleAccountSetupPost(
650
+ postReq(
651
+ rawToken,
652
+ {
653
+ [CSRF_FIELD_NAME]: csrfToken,
654
+ username: "intruder",
655
+ password: "intruder-strong-password-1",
656
+ password_confirm: "intruder-strong-password-1",
657
+ vault_name: "shared-vault", // collides with the owner's vault
658
+ },
659
+ cookieFragment,
660
+ ),
661
+ rawToken,
662
+ deps(stub.run),
663
+ );
664
+
665
+ // Rejected with a re-rendered form carrying the "already exists" error.
666
+ expect(res.status).toBe(409);
667
+ const html = await res.text();
668
+ expect(html).toContain("already exists");
669
+ expect(html).toContain("Choose a different name");
670
+
671
+ // NO account created.
672
+ expect(getUserByUsernameCI(harness.db, "intruder")).toBeNull();
673
+ expect(userCount(harness.db) - before).toBe(0);
674
+ // The invite is NOT consumed — the invitee can retry with a new name.
675
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
676
+ // The pre-existing vault's owner/assignment is UNCHANGED.
677
+ expect(vaultVerbsForUserVault(harness.db, owner.id, "shared-vault")).toEqual([
678
+ "read",
679
+ "write",
680
+ "admin",
681
+ ]);
682
+ // No NEW user got authority over it.
683
+ const intruder = getUserByUsernameCI(harness.db, "intruder");
684
+ expect(intruder).toBeNull();
685
+ });
686
+
687
+ test("pinned EXISTING vault name (provision_vault=true) → rejected, no account", async () => {
688
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
689
+ assignedVaults: ["taken"],
690
+ role: "write",
691
+ });
692
+ seedExistingVault("taken");
693
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
694
+ allowMulti: true,
695
+ });
696
+ // Admin pins an existing name with provision_vault=true (the redeem must
697
+ // still freshly CREATE — a pre-existing pinned name is rejected too).
698
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "taken" });
699
+ const before = userCount(harness.db);
700
+ const { token: csrfToken, cookieFragment } = csrfPair();
701
+ const res = await handleAccountSetupPost(
702
+ postReq(
703
+ rawToken,
704
+ {
705
+ [CSRF_FIELD_NAME]: csrfToken,
706
+ username: "newbie",
707
+ password: "newbie-strong-password-1",
708
+ password_confirm: "newbie-strong-password-1",
709
+ },
710
+ cookieFragment,
711
+ ),
712
+ rawToken,
713
+ deps(makeStubRunCommand().run),
714
+ );
715
+ expect(res.status).toBe(409);
716
+ expect(getUserByUsernameCI(harness.db, "newbie")).toBeNull();
717
+ expect(userCount(harness.db) - before).toBe(0);
718
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
719
+ });
720
+
721
+ test("concurrent redeem on a FRESH name → exactly one account, the other rejected", async () => {
722
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
723
+ // Pinned to a fresh name so both redeems target the SAME new vault.
724
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "freshvault" });
725
+ const before = userCount(harness.db);
726
+ const a = csrfPair();
727
+ const b = csrfPair();
728
+ const mk = (uname: string, csrf: { token: string; cookieFragment: string }) =>
729
+ handleAccountSetupPost(
730
+ postReq(
731
+ rawToken,
732
+ {
733
+ [CSRF_FIELD_NAME]: csrf.token,
734
+ username: uname,
735
+ password: `${uname}-strong-password-1`,
736
+ password_confirm: `${uname}-strong-password-1`,
737
+ },
738
+ csrf.cookieFragment,
739
+ ),
740
+ rawToken,
741
+ deps(makeStubRunCommand().run),
742
+ );
743
+ const [r1, r2] = await Promise.all([mk("ann", a), mk("ben", b)]);
744
+ const statuses = [r1.status, r2.status].sort();
745
+ // Exactly one success; the other rejected (409 existing-vault gate or 410
746
+ // consume race — see the N2 test for why both are correct).
747
+ expect(statuses[0]).toBe(302);
748
+ expect(statuses[1] === 409 || statuses[1] === 410).toBe(true);
749
+ expect(userCount(harness.db) - before).toBe(1);
750
+ });
751
+
752
+ test("shared-vault invite that slipped through (provision_vault=false + pinned name) → rejected at redeem", async () => {
753
+ // Defense in depth: the admin API rejects creating this shape, but if one
754
+ // exists in the DB the redeem must still refuse to assign the existing vault.
755
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
756
+ assignedVaults: ["legacy"],
757
+ role: "write",
758
+ });
759
+ seedExistingVault("legacy");
760
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
761
+ allowMulti: true,
762
+ });
763
+ const { rawToken } = issueInvite(harness.db, {
764
+ createdBy: admin.id,
765
+ vaultName: "legacy",
766
+ provisionVault: false,
767
+ });
768
+ const before = userCount(harness.db);
769
+ const { token: csrfToken, cookieFragment } = csrfPair();
770
+ const stub = makeStubRunCommand();
771
+ const res = await handleAccountSetupPost(
772
+ postReq(
773
+ rawToken,
774
+ {
775
+ [CSRF_FIELD_NAME]: csrfToken,
776
+ username: "wouldbe",
777
+ password: "wouldbe-strong-password-1",
778
+ password_confirm: "wouldbe-strong-password-1",
779
+ },
780
+ cookieFragment,
781
+ ),
782
+ rawToken,
783
+ deps(stub.run),
784
+ );
785
+ expect(res.status).toBe(400);
786
+ expect(getUserByUsernameCI(harness.db, "wouldbe")).toBeNull();
787
+ expect(userCount(harness.db) - before).toBe(0);
788
+ // No vault shell-out — rejected before provisioning.
789
+ expect(stub.calls.length).toBe(0);
790
+ // Invite NOT consumed.
791
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
792
+ });
793
+ });
794
+
795
+ describe("POST /account/setup/<token> — vault name defaults to username (FIX-2)", () => {
796
+ test("blank vault_name → vault created NAMED AFTER the username", async () => {
797
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
798
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // unpinned
799
+ const { token: csrfToken, cookieFragment } = csrfPair();
800
+ const stub = makeStubRunCommand();
801
+ const res = await handleAccountSetupPost(
802
+ postReq(
803
+ rawToken,
804
+ {
805
+ [CSRF_FIELD_NAME]: csrfToken,
806
+ username: "dana",
807
+ password: "dana-strong-password-12",
808
+ password_confirm: "dana-strong-password-12",
809
+ vault_name: "", // blank → defaults to username
810
+ },
811
+ cookieFragment,
812
+ ),
813
+ rawToken,
814
+ deps(stub.run),
815
+ );
816
+ expect(res.status).toBe(302);
817
+ const user = getUserByUsernameCI(harness.db, "dana");
818
+ expect(user?.assignedVaults).toEqual(["dana"]);
819
+ // The shell-out created a vault named "dana".
820
+ expect(stub.calls.some((c) => c[1] === "create" && c[2] === "dana")).toBe(true);
821
+ });
822
+
823
+ test("vault_name field OMITTED entirely → still defaults to username", async () => {
824
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
825
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
826
+ const { token: csrfToken, cookieFragment } = csrfPair();
827
+ const stub = makeStubRunCommand();
828
+ const res = await handleAccountSetupPost(
829
+ postReq(
830
+ rawToken,
831
+ {
832
+ [CSRF_FIELD_NAME]: csrfToken,
833
+ username: "ezra",
834
+ password: "ezra-strong-password-12",
835
+ password_confirm: "ezra-strong-password-12",
836
+ },
837
+ cookieFragment,
838
+ ),
839
+ rawToken,
840
+ deps(stub.run),
841
+ );
842
+ expect(res.status).toBe(302);
843
+ expect(getUserByUsernameCI(harness.db, "ezra")?.assignedVaults).toEqual(["ezra"]);
844
+ });
845
+
846
+ test("blank vault_name but the username collides with an EXISTING vault → rejected (FIX-1 still applies)", async () => {
847
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
848
+ assignedVaults: ["fiona"],
849
+ role: "write",
850
+ });
851
+ seedExistingVault("fiona");
852
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
853
+ allowMulti: true,
854
+ });
855
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
856
+ const before = userCount(harness.db);
857
+ const { token: csrfToken, cookieFragment } = csrfPair();
858
+ const res = await handleAccountSetupPost(
859
+ postReq(
860
+ rawToken,
861
+ {
862
+ [CSRF_FIELD_NAME]: csrfToken,
863
+ username: "fiona", // username derives a vault that already exists
864
+ password: "fiona-strong-password-1",
865
+ password_confirm: "fiona-strong-password-1",
866
+ vault_name: "",
867
+ },
868
+ cookieFragment,
869
+ ),
870
+ rawToken,
871
+ deps(makeStubRunCommand().run),
872
+ );
873
+ expect(res.status).toBe(409);
874
+ const html = await res.text();
875
+ expect(html).toContain("already exists");
876
+ expect(getUserByUsernameCI(harness.db, "fiona")).toBeNull();
877
+ expect(userCount(harness.db) - before).toBe(0);
878
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
879
+ });
880
+ });
@@ -120,6 +120,43 @@ describe("POST /api/invites", () => {
120
120
  );
121
121
  expect(res.status).toBe(400);
122
122
  });
123
+
124
+ test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
125
+ // Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
126
+ // as owner-admin is a cross-tenant breach; shared-vault invites aren't
127
+ // supported yet, so the create handler refuses this combination outright.
128
+ const bearer = await makeAdminBearer();
129
+ const res = await handleCreateInvite(
130
+ withBearer("/api/invites", bearer, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
134
+ }),
135
+ deps(),
136
+ );
137
+ expect(res.status).toBe(400);
138
+ const body = (await res.json()) as { error: string; error_description: string };
139
+ expect(body.error).toBe("invalid_request");
140
+ expect(body.error_description).toContain("shared-vault");
141
+ });
142
+
143
+ test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
144
+ const bearer = await makeAdminBearer();
145
+ const res = await handleCreateInvite(
146
+ withBearer("/api/invites", bearer, {
147
+ method: "POST",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({ provision_vault: false }),
150
+ }),
151
+ deps(),
152
+ );
153
+ expect(res.status).toBe(201);
154
+ const body = (await res.json()) as {
155
+ invite: { provision_vault: boolean; vault_name: string | null };
156
+ };
157
+ expect(body.invite.provision_vault).toBe(false);
158
+ expect(body.invite.vault_name).toBeNull();
159
+ });
123
160
  });
124
161
 
125
162
  describe("GET /api/invites", () => {
@@ -304,6 +304,45 @@ describe("deleteUser", () => {
304
304
  cleanup();
305
305
  }
306
306
  });
307
+
308
+ test("deletes a user holding an auth_codes row (hub#559 — OAuth-authorize FK regression)", async () => {
309
+ // A user who completed an OAuth authorize has an `auth_codes` row whose
310
+ // NOT-NULL, non-cascading FK to users(id) outlives its 60s TTL. Before the
311
+ // fix, that pinned the FK and `DELETE FROM users` threw
312
+ // SQLITE_CONSTRAINT_FOREIGNKEY → a 500 on the admin "delete user" action.
313
+ const { db, cleanup } = makeDb();
314
+ try {
315
+ const u = await createUser(db, "ag", "ag-strong-passphrase");
316
+ // auth_codes.client_id FKs to clients — seed a minimal client first.
317
+ db.prepare(
318
+ "INSERT INTO clients (client_id, redirect_uris, scopes, registered_at) VALUES (?, ?, ?, ?)",
319
+ ).run("client-x", "https://app.example/cb", "vault:default:read", "2026-06-04T00:00:00.000Z");
320
+ db.prepare(
321
+ `INSERT INTO auth_codes
322
+ (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at, created_at)
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
324
+ ).run(
325
+ "dead-code",
326
+ "client-x",
327
+ u.id,
328
+ "https://app.example/cb",
329
+ "vault:default:read",
330
+ "challenge",
331
+ "S256",
332
+ "2026-06-04T00:00:00.000Z", // long-expired
333
+ "2026-06-04T00:00:00.000Z", // already used
334
+ "2026-06-04T00:00:00.000Z",
335
+ );
336
+ expect(deleteUser(db, u.id)).toBe(true);
337
+ expect(getUserById(db, u.id)).toBeNull();
338
+ // The dead auth_code is gone too (hard-deleted with the user).
339
+ expect(db.query("SELECT COUNT(*) c FROM auth_codes WHERE user_id = ?").get(u.id)).toEqual({
340
+ c: 0,
341
+ });
342
+ } finally {
343
+ cleanup();
344
+ }
345
+ });
307
346
  });
308
347
 
309
348
  describe("validateUsername", () => {
@@ -14,7 +14,8 @@
14
14
  * Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
15
15
  * 1. lookup + validate the invite (not-found/expired/used/revoked)
16
16
  * 2. validate username/password (+ vault name)
17
- * 3. provision the vault (idempotent-safe)
17
+ * 3. provision the vault (must FRESHLY CREATE — reject a pre-existing name;
18
+ * attaching the new user to someone else's vault is a cross-tenant breach)
18
19
  * 4. createUser (the commit point)
19
20
  * 5. consumeInvite — stamp used_at + redeemed_user_id ONLY AFTER (4) commits
20
21
  * 6. createSession + cookie + 302
@@ -250,20 +251,38 @@ export async function handleAccountSetupPost(
250
251
  if (invite.vaultName !== null) {
251
252
  vaultName = invite.vaultName;
252
253
  } else {
253
- const chosen = String(form.get("vault_name") ?? "").trim();
254
+ // Unpinned name: the invitee names their own vault. The field is
255
+ // OPTIONAL (no-JS server-side default) — a blank submission defaults
256
+ // the vault name to the chosen username. Either way the resolved name
257
+ // runs through the full validator; a username that isn't a valid vault
258
+ // name (e.g. too long, disallowed chars) re-renders asking for an
259
+ // explicit vault name with the validator's error.
260
+ const submitted = String(form.get("vault_name") ?? "").trim();
261
+ const chosen = submitted === "" ? username : submitted;
254
262
  const v = validateVaultName(chosen);
255
263
  if (!v.ok) {
256
- return rerender(400, v.error, chosen);
264
+ // Echo the resolved name only if the invitee typed one; a blank
265
+ // (username-derived) failure shouldn't pre-fill the vault box.
266
+ return rerender(400, v.error, submitted === "" ? undefined : submitted);
257
267
  }
258
268
  vaultName = v.name;
259
269
  }
260
270
  } else if (invite.vaultName !== null) {
261
- // Account-only invite that assigns an existing vault.
262
- vaultName = invite.vaultName;
271
+ // UNSUPPORTED shared-vault case: an account-only invite (provision_vault
272
+ // =false) that pins an EXISTING vault would assign the new user owner-
273
+ // admin on a pre-existing vault — a cross-tenant breach, and the owner-
274
+ // vs-shared role split isn't built. The admin API rejects creating such
275
+ // an invite (defense in depth); reject here too in case one slipped
276
+ // through. The legit account-only invite (vaultName === null) is unaffected.
277
+ return rerender(
278
+ 400,
279
+ "This invite is not supported (shared-vault invites aren't available yet). Ask your hub operator for a new one.",
280
+ );
263
281
  }
264
282
 
265
- // (3) Provision the vault (idempotent-safe). Routed through the SAME
266
- // createVault path the wizard/SPA use, so the new vault gets the §3
283
+ // (3) Provision the vault must FRESHLY CREATE it (see the security
284
+ // invariant on the `!provisioned.created` check below). Routed through the
285
+ // SAME createVault path the wizard/SPA use, so the new vault gets the §3
267
286
  // internal-live-mirror default for free. Done BEFORE createUser so a
268
287
  // provisioning failure doesn't leave a vault-less account; the invite is
269
288
  // still unconsumed at this point, so the invitee can retry.
@@ -283,6 +302,26 @@ export async function handleAccountSetupPost(
283
302
  invite.vaultName === null ? (vaultName ?? undefined) : undefined,
284
303
  );
285
304
  }
305
+ // SECURITY INVARIANT: an invite redeem may grant access ONLY to a vault
306
+ // that was FRESHLY CREATED during this redeem. `provisionVault` returns
307
+ // `created:true` (HTTP 201) for a new vault, `created:false` (HTTP 200)
308
+ // when the name ALREADY EXISTS — in which case it hands back SOMEONE
309
+ // ELSE'S vault. Attaching the new user there as owner would be a cross-
310
+ // tenant breach. Reject any non-created (already-existing) result; the
311
+ // invitee must choose a different, unused name. This also closes the
312
+ // concurrent-redeem race on a new name: first redeem 201, second 200 →
313
+ // second rejected.
314
+ if (!provisioned.created) {
315
+ return rerender(
316
+ 409,
317
+ `A vault named "${vaultName}" already exists. Choose a different name.`,
318
+ // Echo the typed name only if the invitee chose it explicitly; a
319
+ // username-derived collision shouldn't pre-fill the vault box.
320
+ invite.vaultName === null && String(form.get("vault_name") ?? "").trim() !== ""
321
+ ? vaultName
322
+ : undefined,
323
+ );
324
+ }
286
325
  }
287
326
 
288
327
  // (4) Create the user + consume the invite ATOMICALLY — the COMMIT POINT.
@@ -196,18 +196,45 @@ export function renderInviteSetup(props: InviteSetupProps): string {
196
196
  </label>`;
197
197
  } else {
198
198
  const vaultAttr = vaultName ? ` value="${escapeAttr(vaultName)}"` : "";
199
+ // OPTIONAL (not `required`): a blank submission defaults the vault name
200
+ // to the chosen username, resolved server-side (this form is no-JS, so
201
+ // the server is the source of truth). The inline script below is a
202
+ // progressive-enhancement pre-fill only.
199
203
  vaultRow = `
200
204
  <label class="field">
201
205
  <span class="field-label">Name your vault</span>
202
- <input type="text" name="vault_name" autocomplete="off"
203
- required minlength="2" maxlength="32"
206
+ <input type="text" name="vault_name" id="vault_name" autocomplete="off"
207
+ minlength="2" maxlength="32"
204
208
  pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
205
- spellcheck="false" autocapitalize="off"${vaultAttr} />
206
- <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars)</span>
209
+ spellcheck="false" autocapitalize="off"
210
+ placeholder="defaults to your username"${vaultAttr} />
211
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars). Leave blank to use your username.</span>
207
212
  </label>`;
208
213
  }
209
214
  }
210
215
 
216
+ // Progressive enhancement ONLY: mirror the username into the (empty) vault
217
+ // field as the user types, so they see the default before submitting. The
218
+ // server applies the same default with no JS (the field is optional), so
219
+ // this is purely cosmetic — it stops mirroring the moment the user edits the
220
+ // vault field directly. Shown only for the unpinned-provision case.
221
+ const vaultPrefillScript =
222
+ provisionVault && pinnedVaultName === null
223
+ ? `
224
+ <script>
225
+ (function () {
226
+ var u = document.getElementById("username");
227
+ var v = document.getElementById("vault_name");
228
+ if (!u || !v) return;
229
+ var dirty = v.value !== "";
230
+ v.addEventListener("input", function () { dirty = true; });
231
+ u.addEventListener("input", function () {
232
+ if (!dirty) v.value = u.value;
233
+ });
234
+ })();
235
+ </script>`
236
+ : "";
237
+
211
238
  const body = `
212
239
  <div class="card">
213
240
  <div class="card-header">
@@ -222,7 +249,7 @@ export function renderInviteSetup(props: InviteSetupProps): string {
222
249
  ${renderCsrfHiddenInput(csrfToken)}
223
250
  <label class="field">
224
251
  <span class="field-label">Username</span>
225
- <input type="text" name="username" autocomplete="username" autofocus
252
+ <input type="text" name="username" id="username" autocomplete="username" autofocus
226
253
  required minlength="2" maxlength="32"
227
254
  pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
228
255
  spellcheck="false" autocapitalize="off"${usernameAttr} />
@@ -242,7 +269,7 @@ export function renderInviteSetup(props: InviteSetupProps): string {
242
269
  ${vaultRow}
243
270
  <button type="submit" class="btn btn-primary">Create my account</button>
244
271
  </form>
245
- </div>`;
272
+ </div>${vaultPrefillScript}`;
246
273
  return baseDocument("Claim your invite — Parachute", body);
247
274
  }
248
275
 
@@ -19,7 +19,6 @@
19
19
  import type { Database } from "bun:sqlite";
20
20
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
21
21
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
22
- import { SERVICES_MANIFEST_PATH } from "./config.ts";
23
22
  import {
24
23
  DEFAULT_INVITE_TTL_SECONDS,
25
24
  type Invite,
@@ -31,7 +30,6 @@ import {
31
30
  revokeInvite,
32
31
  } from "./invites.ts";
33
32
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
34
- import { listVaultNamesFromPath } from "./vault-names.ts";
35
33
 
36
34
  export interface ApiInvitesDeps {
37
35
  db: Database;
@@ -266,19 +264,19 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
266
264
  if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
267
265
  const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } = parsed.body;
268
266
 
269
- // A pinned vault_name with provision_vault=false means "assign an existing
270
- // vault" validate it exists. (provision_vault=true with a pinned name
271
- // provisions THAT name; provision_vault=false without a name is account-only.)
267
+ // SECURITY: a pinned vault_name with provision_vault=false would assign the
268
+ // redeeming user to a PRE-EXISTING vault as owner-admin — a cross-tenant
269
+ // breach, since the owner-vs-shared role split isn't built. Shared-vault
270
+ // invites aren't supported yet, so reject this combination outright (defense
271
+ // in depth — the redeem path rejects it too). The supported shapes are:
272
+ // provision_vault=true (+ optional pinned name → provisions THAT name), or
273
+ // provision_vault=false with NO name (account-only, assignedVaults=[]).
272
274
  if (vaultName !== null && !provisionVault) {
273
- const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
274
- const known = new Set(listVaultNamesFromPath(manifestPath));
275
- if (!known.has(vaultName)) {
276
- return jsonError(
277
- 400,
278
- "vault_not_found",
279
- `vault "${vaultName}" is not registered in services.json`,
280
- );
281
- }
275
+ return jsonError(
276
+ 400,
277
+ "invalid_request",
278
+ "shared-vault invites (provision_vault=false with a vault_name) aren't supported yet — omit vault_name for an account-only invite, or set provision_vault=true to provision a new vault",
279
+ );
282
280
  }
283
281
 
284
282
  const issued = issueInvite(deps.db, {
package/src/users.ts CHANGED
@@ -601,8 +601,10 @@ export async function resetUserPassword(
601
601
  * parent users row. The audit trail survives via the `subject`
602
602
  * column we backfill from the username plus the existing
603
603
  * `created_at`, `scopes`, `client_id`, `revoked_at` fields.
604
- * - `sessions.user_id` and `grants.user_id` are NOT NULL with a
605
- * non-cascading FK. Both are deleted before the users row drops.
604
+ * - `sessions.user_id`, `grants.user_id`, and `auth_codes.user_id` are
605
+ * NOT NULL with a non-cascading (RESTRICT) FK. All three are deleted
606
+ * before the users row drops — auth_codes are ephemeral OAuth codes
607
+ * (60s TTL, no audit value), so a hard-delete is correct (hub#559).
606
608
  * - `user_vaults.user_id` has `ON DELETE CASCADE` (migration v10), so
607
609
  * vault assignments are dropped automatically when the parent row
608
610
  * goes. No explicit cleanup needed.
@@ -630,10 +632,16 @@ export function deleteUser(db: Database, userId: string): boolean {
630
632
  db.prepare(
631
633
  "UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
632
634
  ).run(row.username, userId);
633
- // 2. Drop sessions + grants. Both have non-cascading FKs on user_id;
634
- // leaving rows behind would RESTRICT the users delete below.
635
+ // 2. Drop sessions + grants + auth_codes. All have NOT-NULL, non-cascading
636
+ // (RESTRICT) FKs on user_id; leaving rows behind blocks the users delete
637
+ // below with SQLITE_CONSTRAINT_FOREIGNKEY. auth_codes are short-lived
638
+ // (60s TTL) OAuth authorization codes with no audit value — hard-delete,
639
+ // same as sessions. (Omitting this 500'd a real delete of a user who had
640
+ // completed an OAuth authorize: the code row outlived its TTL but still
641
+ // pinned the FK. hub#559.)
635
642
  db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
636
643
  db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
644
+ db.prepare("DELETE FROM auth_codes WHERE user_id = ?").run(userId);
637
645
  // 3. Drop the user row itself.
638
646
  db.prepare("DELETE FROM users WHERE id = ?").run(userId);
639
647
  })();