@openparachute/hub 0.6.4-rc.1 → 0.6.4-rc.2
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 +1 -1
- package/src/__tests__/account-setup.test.ts +273 -2
- package/src/__tests__/api-invites.test.ts +37 -0
- package/src/account-setup.ts +46 -7
- package/src/admin-login-ui.ts +33 -6
- package/src/api-invites.ts +12 -14
package/package.json
CHANGED
|
@@ -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)
|
|
562
|
-
|
|
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", () => {
|
package/src/account-setup.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
262
|
-
|
|
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
|
|
266
|
-
//
|
|
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.
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
206
|
-
|
|
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
|
|
package/src/api-invites.ts
CHANGED
|
@@ -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
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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, {
|