@openparachute/hub 0.5.10-rc.9 → 0.5.10
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__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -665,21 +665,111 @@ function renderMcpTile(
|
|
|
665
665
|
const bareCmd = `claude mcp add --transport http parachute-${vaultName} ${hubOrigin}/vault/${vaultName}/mcp`;
|
|
666
666
|
if (mintedToken) {
|
|
667
667
|
// The token contents are surfaced once + then forgotten by the
|
|
668
|
-
// server (single-use hub_setting).
|
|
669
|
-
//
|
|
670
|
-
//
|
|
668
|
+
// server (single-use hub_setting). Two visible variants of the
|
|
669
|
+
// command live in the DOM:
|
|
670
|
+
//
|
|
671
|
+
// * `pre#mcp-cmd` — what the operator sees. The Bearer token is
|
|
672
|
+
// replaced with a fixed-width row of • so shoulder-surfers,
|
|
673
|
+
// screencasts, and over-the-shoulder photos don't capture
|
|
674
|
+
// credentials by default. This is the "discoverable but not
|
|
675
|
+
// shoulder-surf-able" framing Aaron asked for.
|
|
676
|
+
// * `script#mcp-cmd-real` (type=text/plain) — the real command
|
|
677
|
+
// with the live token, stashed in a non-rendering script tag.
|
|
678
|
+
// The Copy + Show handlers read from this so the operator's
|
|
679
|
+
// terminal paste still gets the real header without the page
|
|
680
|
+
// ever painting the token.
|
|
681
|
+
//
|
|
682
|
+
// The view-source threat model is unchanged from rc.9 — the token
|
|
683
|
+
// is part of the response body either way. The improvement is
|
|
684
|
+
// *visibly hidden by default*, which is what an over-the-shoulder
|
|
685
|
+
// observer needs (and what existing screencasts of the wizard
|
|
686
|
+
// currently leak).
|
|
687
|
+
//
|
|
688
|
+
// Show toggles textContent between masked + real and flips a
|
|
689
|
+
// data-state attribute so a screencast / pair-programming session
|
|
690
|
+
// can briefly reveal-and-rehide without the operator losing the
|
|
691
|
+
// line of sight on which mode they're in. Auto-hide after 10s so
|
|
692
|
+
// a forgotten reveal doesn't leak the token into a subsequent
|
|
693
|
+
// recording.
|
|
671
694
|
const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
|
|
695
|
+
// Clamp the dot count to a 8–40 range so very-short or very-long
|
|
696
|
+
// tokens don't render comically — token format is fixed-width
|
|
697
|
+
// (JTI-derived), so this is purely visual.
|
|
698
|
+
const maskedToken = "•".repeat(Math.max(8, Math.min(40, mintedToken.length)));
|
|
699
|
+
const maskedCmd = `${bareCmd} --header "Authorization: Bearer ${maskedToken}"`;
|
|
700
|
+
// The real command rides in a hidden <script type="application/json">
|
|
701
|
+
// block as a JSON-encoded string. <script> element content is parsed
|
|
702
|
+
// as raw text (no entity references), so HTML escaping would put
|
|
703
|
+
// literal `"` into the string — and Copy would paste that into
|
|
704
|
+
// the operator's terminal. JSON encoding (with `</` escaped so the
|
|
705
|
+
// sequence can't prematurely close the tag) round-trips safely:
|
|
706
|
+
// textContent returns the JSON, JSON.parse decodes back to the
|
|
707
|
+
// exact bytes of the original command. Caught while smoke-testing
|
|
708
|
+
// the rc.11 reveal/copy UX — pre-fix, the copied command included
|
|
709
|
+
// `"` placeholders that broke shell parsing.
|
|
710
|
+
const fullCmdJson = JSON.stringify(fullCmd).replace(/<\//g, "<\\/");
|
|
672
711
|
return `<div class="done-tile">
|
|
673
712
|
<h2>Connect Claude Code (MCP)</h2>
|
|
674
713
|
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
675
|
-
<div class="mcp-cmd-wrap">
|
|
676
|
-
<pre id="mcp-cmd">${escapeHtml(
|
|
677
|
-
<
|
|
678
|
-
|
|
714
|
+
<div class="mcp-cmd-wrap" data-state="masked">
|
|
715
|
+
<pre id="mcp-cmd">${escapeHtml(maskedCmd)}</pre>
|
|
716
|
+
<script type="application/json" id="mcp-cmd-real">${fullCmdJson}</script>
|
|
717
|
+
<div class="mcp-cmd-actions">
|
|
718
|
+
<button type="button" class="btn btn-mcp-aux" id="mcp-cmd-show">Show token</button>
|
|
719
|
+
<button type="button" class="btn btn-copy" id="mcp-cmd-copy">Copy</button>
|
|
720
|
+
</div>
|
|
679
721
|
</div>
|
|
680
722
|
<p class="fine">We minted this token for your first MCP connection.
|
|
681
|
-
It's
|
|
682
|
-
|
|
723
|
+
It's masked above so it's safe to leave open on screen; Copy
|
|
724
|
+
copies the real command. It's a full-scope operator token tied
|
|
725
|
+
to your admin account; manage and revoke tokens at
|
|
726
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
|
|
727
|
+
<script>
|
|
728
|
+
(function () {
|
|
729
|
+
var wrap = document.querySelector('.mcp-cmd-wrap[data-state]');
|
|
730
|
+
var pre = document.getElementById('mcp-cmd');
|
|
731
|
+
var real = document.getElementById('mcp-cmd-real');
|
|
732
|
+
var copyBtn = document.getElementById('mcp-cmd-copy');
|
|
733
|
+
var showBtn = document.getElementById('mcp-cmd-show');
|
|
734
|
+
if (!wrap || !pre || !real || !copyBtn || !showBtn) return;
|
|
735
|
+
var realCmd;
|
|
736
|
+
try { realCmd = JSON.parse(real.textContent || '""'); }
|
|
737
|
+
catch (e) { realCmd = ''; }
|
|
738
|
+
var maskedCmd = pre.textContent || '';
|
|
739
|
+
var revealTimer = null;
|
|
740
|
+
function setMasked() {
|
|
741
|
+
pre.textContent = maskedCmd;
|
|
742
|
+
wrap.setAttribute('data-state', 'masked');
|
|
743
|
+
showBtn.textContent = 'Show token';
|
|
744
|
+
if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
|
|
745
|
+
}
|
|
746
|
+
function setRevealed() {
|
|
747
|
+
pre.textContent = realCmd;
|
|
748
|
+
wrap.setAttribute('data-state', 'revealed');
|
|
749
|
+
showBtn.textContent = 'Hide token';
|
|
750
|
+
// Auto-hide after 10s so a stray reveal doesn't leak the
|
|
751
|
+
// token into a screencast capture that started after the
|
|
752
|
+
// click.
|
|
753
|
+
if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
|
|
754
|
+
revealTimer = setTimeout(setMasked, 10000);
|
|
755
|
+
}
|
|
756
|
+
showBtn.addEventListener('click', function () {
|
|
757
|
+
if (wrap.getAttribute('data-state') === 'masked') setRevealed();
|
|
758
|
+
else setMasked();
|
|
759
|
+
});
|
|
760
|
+
copyBtn.addEventListener('click', function () {
|
|
761
|
+
// Copy ALWAYS pulls from the real command — the operator's
|
|
762
|
+
// terminal needs the live token regardless of whether the
|
|
763
|
+
// page is currently masked. This is the load-bearing path:
|
|
764
|
+
// the visible mask is a UX nicety; the clipboard must
|
|
765
|
+
// carry the real header.
|
|
766
|
+
navigator.clipboard.writeText(realCmd).then(function () {
|
|
767
|
+
copyBtn.textContent = 'Copied ✓';
|
|
768
|
+
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
})();
|
|
772
|
+
</script>
|
|
683
773
|
</div>`;
|
|
684
774
|
}
|
|
685
775
|
return `<div class="done-tile">
|
|
@@ -999,7 +1089,12 @@ export async function handleSetupAccountPost(
|
|
|
999
1089
|
return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
|
|
1000
1090
|
}
|
|
1001
1091
|
try {
|
|
1002
|
-
|
|
1092
|
+
// Wizard-admin chose their password through this very form; skip the
|
|
1093
|
+
// multi-user-Phase-1 force-change-password redirect by landing
|
|
1094
|
+
// `password_changed=true`. `assignedVault` stays null — admin posture
|
|
1095
|
+
// (the wizard never asks the first admin to pin themselves to a
|
|
1096
|
+
// single vault; that's a non-admin user pattern).
|
|
1097
|
+
const user = await createUser(deps.db, username, password, { passwordChanged: true });
|
|
1003
1098
|
const session = createSession(deps.db, { userId: user.id });
|
|
1004
1099
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1005
1100
|
secure: isHttpsRequest(req),
|
|
@@ -1701,9 +1796,15 @@ const STYLES = `
|
|
|
1701
1796
|
.btn-secondary:hover {
|
|
1702
1797
|
background: ${PALETTE.accentSoft};
|
|
1703
1798
|
}
|
|
1704
|
-
/* Copy
|
|
1705
|
-
vertical sizing so
|
|
1706
|
-
full text wrap on the pre keeps the snippet readable
|
|
1799
|
+
/* Copy + Show buttons ride the right edge of the MCP command pre.
|
|
1800
|
+
Compact vertical sizing so they don't dwarf the snippet on narrow
|
|
1801
|
+
widths; full text wrap on the pre keeps the snippet readable
|
|
1802
|
+
behind them. The Show button toggles the visible mask on the
|
|
1803
|
+
auto-minted Bearer token (rc.11 — discoverable
|
|
1804
|
+
but not shoulder-surf-able). Both buttons share a small flex
|
|
1805
|
+
container so they stack predictably on the wrap; layout-wise we
|
|
1806
|
+
keep the right-edge padding on .mcp-cmd-wrap pre so the buttons
|
|
1807
|
+
never overlap the command text. */
|
|
1707
1808
|
.mcp-cmd-wrap {
|
|
1708
1809
|
position: relative;
|
|
1709
1810
|
margin: 0.5rem 0;
|
|
@@ -1712,17 +1813,21 @@ const STYLES = `
|
|
|
1712
1813
|
background: ${PALETTE.bg};
|
|
1713
1814
|
border: 1px solid ${PALETTE.borderLight};
|
|
1714
1815
|
border-radius: 6px;
|
|
1715
|
-
padding: 0.5rem
|
|
1816
|
+
padding: 0.5rem 8.5rem 0.5rem 0.75rem;
|
|
1716
1817
|
overflow-x: auto;
|
|
1717
1818
|
font-size: 0.82rem;
|
|
1718
1819
|
margin: 0;
|
|
1719
1820
|
white-space: pre-wrap;
|
|
1720
1821
|
word-break: break-all;
|
|
1721
1822
|
}
|
|
1722
|
-
.
|
|
1823
|
+
.mcp-cmd-actions {
|
|
1723
1824
|
position: absolute;
|
|
1724
1825
|
top: 0.35rem;
|
|
1725
1826
|
right: 0.35rem;
|
|
1827
|
+
display: flex;
|
|
1828
|
+
gap: 0.3rem;
|
|
1829
|
+
}
|
|
1830
|
+
.btn-copy, .btn-mcp-aux {
|
|
1726
1831
|
padding: 0.25rem 0.6rem;
|
|
1727
1832
|
font-size: 0.78rem;
|
|
1728
1833
|
min-height: auto;
|
|
@@ -1731,11 +1836,24 @@ const STYLES = `
|
|
|
1731
1836
|
border: 1px solid ${PALETTE.border};
|
|
1732
1837
|
border-radius: 4px;
|
|
1733
1838
|
cursor: pointer;
|
|
1839
|
+
font: inherit;
|
|
1840
|
+
font-size: 0.78rem;
|
|
1734
1841
|
}
|
|
1735
|
-
.btn-copy:hover {
|
|
1842
|
+
.btn-copy:hover, .btn-mcp-aux:hover {
|
|
1736
1843
|
border-color: ${PALETTE.accent};
|
|
1737
1844
|
color: ${PALETTE.accent};
|
|
1738
1845
|
}
|
|
1846
|
+
.mcp-cmd-wrap[data-state="revealed"] pre {
|
|
1847
|
+
/* Subtle visual cue that the token is currently visible — a warm
|
|
1848
|
+
border so the operator notices on a screencast even at low
|
|
1849
|
+
resolution. */
|
|
1850
|
+
border-color: #d4a017;
|
|
1851
|
+
background: rgba(212, 160, 23, 0.04);
|
|
1852
|
+
}
|
|
1853
|
+
.mcp-cmd-wrap[data-state="revealed"] .btn-mcp-aux {
|
|
1854
|
+
border-color: #d4a017;
|
|
1855
|
+
color: #6b4a00;
|
|
1856
|
+
}
|
|
1739
1857
|
/* Install-tile section (hub#272 Item B). Lives above the .done-grid;
|
|
1740
1858
|
primary "what's next?" surface. Tiles render in a responsive grid
|
|
1741
1859
|
that collapses to one column on narrow viewports. */
|
package/src/users.ts
CHANGED
|
@@ -25,6 +25,25 @@ export interface User {
|
|
|
25
25
|
passwordHash: string;
|
|
26
26
|
createdAt: string;
|
|
27
27
|
updatedAt: string;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the user has changed their password since account creation.
|
|
30
|
+
* `false` means the user signed up with an admin-typed default password
|
|
31
|
+
* and the force-change-password flow at sign-in time should redirect
|
|
32
|
+
* them to `/account/change-password`. The wizard's first admin and env-
|
|
33
|
+
* seeded admins land as `true` (they chose their own password). Stored
|
|
34
|
+
* as `users.password_changed INTEGER 0|1` (added in migration v8).
|
|
35
|
+
*/
|
|
36
|
+
passwordChanged: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The vault instance name this user is pinned to (Phase 1 multi-user is
|
|
39
|
+
* single-vault-per-user). `null` means "no per-vault restriction" — the
|
|
40
|
+
* default for admin accounts, where the OAuth issuer mints tokens for
|
|
41
|
+
* any requested vault. Non-null pins the issuer to narrow scopes to
|
|
42
|
+
* `vault:<assigned_vault>:<verb>`. No FK; vault names resolve through
|
|
43
|
+
* `services.json` at mint time. Stored as `users.assigned_vault TEXT`
|
|
44
|
+
* (added in migration v8).
|
|
45
|
+
*/
|
|
46
|
+
assignedVault: string | null;
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
export class SingleUserModeError extends Error {
|
|
@@ -56,6 +75,8 @@ interface Row {
|
|
|
56
75
|
password_hash: string;
|
|
57
76
|
created_at: string;
|
|
58
77
|
updated_at: string;
|
|
78
|
+
password_changed: number;
|
|
79
|
+
assigned_vault: string | null;
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
function rowToUser(r: Row): User {
|
|
@@ -65,6 +86,8 @@ function rowToUser(r: Row): User {
|
|
|
65
86
|
passwordHash: r.password_hash,
|
|
66
87
|
createdAt: r.created_at,
|
|
67
88
|
updatedAt: r.updated_at,
|
|
89
|
+
passwordChanged: r.password_changed === 1,
|
|
90
|
+
assignedVault: r.assigned_vault,
|
|
68
91
|
};
|
|
69
92
|
}
|
|
70
93
|
|
|
@@ -72,6 +95,23 @@ export interface CreateUserOpts {
|
|
|
72
95
|
/** Allow creating an additional user when one already exists. Off by default. */
|
|
73
96
|
allowMulti?: boolean;
|
|
74
97
|
now?: () => Date;
|
|
98
|
+
/**
|
|
99
|
+
* Whether the new user has already chosen their password. Default `false`
|
|
100
|
+
* — the admin-creates-user path (PR 2) lands new accounts with the bit
|
|
101
|
+
* unset so the user is force-redirected to change it on first sign-in
|
|
102
|
+
* (PR 3). The wizard's first-admin path and env-seeded admin path pass
|
|
103
|
+
* `true` (they chose their own password through the wizard form / env
|
|
104
|
+
* vars; no force-change needed).
|
|
105
|
+
*/
|
|
106
|
+
passwordChanged?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Vault instance name to pin the user to (Phase 1 single-vault). `null`
|
|
109
|
+
* (default) means "no restriction" — admin posture. The OAuth issuer
|
|
110
|
+
* (PR 4) reads this at mint time to narrow scopes. No validation here:
|
|
111
|
+
* the API endpoint (PR 2) is responsible for checking against
|
|
112
|
+
* `services.json` before passing through.
|
|
113
|
+
*/
|
|
114
|
+
assignedVault?: string | null;
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
export async function createUser(
|
|
@@ -87,10 +127,14 @@ export async function createUser(
|
|
|
87
127
|
const id = randomUUID();
|
|
88
128
|
const passwordHash = await argonHash(password);
|
|
89
129
|
const stamp = (opts.now?.() ?? new Date()).toISOString();
|
|
130
|
+
const passwordChanged = opts.passwordChanged === true ? 1 : 0;
|
|
131
|
+
const assignedVault = opts.assignedVault ?? null;
|
|
90
132
|
try {
|
|
91
133
|
db.prepare(
|
|
92
|
-
|
|
93
|
-
|
|
134
|
+
`INSERT INTO users
|
|
135
|
+
(id, username, password_hash, created_at, updated_at, password_changed, assigned_vault)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
137
|
+
).run(id, username, passwordHash, stamp, stamp, passwordChanged, assignedVault);
|
|
94
138
|
} catch (err) {
|
|
95
139
|
const msg = err instanceof Error ? err.message : String(err);
|
|
96
140
|
if (msg.includes("UNIQUE") && msg.includes("users.username")) {
|
|
@@ -98,7 +142,15 @@ export async function createUser(
|
|
|
98
142
|
}
|
|
99
143
|
throw err;
|
|
100
144
|
}
|
|
101
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
username,
|
|
148
|
+
passwordHash,
|
|
149
|
+
createdAt: stamp,
|
|
150
|
+
updatedAt: stamp,
|
|
151
|
+
passwordChanged: passwordChanged === 1,
|
|
152
|
+
assignedVault,
|
|
153
|
+
};
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
export function getUserByUsername(db: Database, username: string): User | null {
|
|
@@ -106,6 +158,22 @@ export function getUserByUsername(db: Database, username: string): User | null {
|
|
|
106
158
|
return row ? rowToUser(row) : null;
|
|
107
159
|
}
|
|
108
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Case-insensitive username lookup. Username validation already pins
|
|
163
|
+
* the canonical form to lowercase (`[a-z0-9_-]`), so the only way a
|
|
164
|
+
* mixed-case lookup ever fires is a defense-in-depth check at the
|
|
165
|
+
* admin-create-user boundary — a future loosening of the validator
|
|
166
|
+
* (or a hand-edited row) wouldn't accidentally allow `Bob` to land
|
|
167
|
+
* alongside an existing `bob`. SQLite's `COLLATE NOCASE` does the work
|
|
168
|
+
* with no schema change.
|
|
169
|
+
*/
|
|
170
|
+
export function getUserByUsernameCI(db: Database, username: string): User | null {
|
|
171
|
+
const row = db
|
|
172
|
+
.query<Row, [string]>("SELECT * FROM users WHERE username = ? COLLATE NOCASE")
|
|
173
|
+
.get(username);
|
|
174
|
+
return row ? rowToUser(row) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
109
177
|
export function getUserById(db: Database, id: string): User | null {
|
|
110
178
|
const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(id);
|
|
111
179
|
return row ? rowToUser(row) : null;
|
|
@@ -142,3 +210,142 @@ export async function setPassword(
|
|
|
142
210
|
.run(passwordHash, stamp, userId);
|
|
143
211
|
if (result.changes === 0) throw new UserNotFoundError(userId);
|
|
144
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Hard-delete a user row and clean up FK-dependent rows.
|
|
216
|
+
*
|
|
217
|
+
* Schema reality at v8:
|
|
218
|
+
* - `tokens.user_id` is nullable (made nullable in migration v6). The
|
|
219
|
+
* plan from the design doc is "tokens stay with `revoked_at` set so
|
|
220
|
+
* the audit trail of 'this user existed and held these tokens'
|
|
221
|
+
* survives." But the FK is RESTRICT-on-delete, so we need to null
|
|
222
|
+
* out `tokens.user_id` after revoking to actually delete the
|
|
223
|
+
* parent users row. The audit trail survives via the `subject`
|
|
224
|
+
* column we backfill from the username plus the existing
|
|
225
|
+
* `created_at`, `scopes`, `client_id`, `revoked_at` fields.
|
|
226
|
+
* - `sessions.user_id` and `grants.user_id` are NOT NULL with a
|
|
227
|
+
* non-cascading FK. Both are deleted before the users row drops.
|
|
228
|
+
*
|
|
229
|
+
* Returns false when no user matches the id (idempotent — the API
|
|
230
|
+
* layer translates that to 404). Returns true on a successful delete.
|
|
231
|
+
*
|
|
232
|
+
* Caller is responsible for the first-admin-undeletable check; this
|
|
233
|
+
* helper enforces no policy beyond the schema hygiene.
|
|
234
|
+
*/
|
|
235
|
+
export function deleteUser(db: Database, userId: string): boolean {
|
|
236
|
+
const row = db.query<Row, [string]>("SELECT * FROM users WHERE id = ?").get(userId);
|
|
237
|
+
if (!row) return false;
|
|
238
|
+
const now = new Date().toISOString();
|
|
239
|
+
db.transaction(() => {
|
|
240
|
+
// 1. Revoke + retain tokens for audit. Mark every un-revoked token
|
|
241
|
+
// revoked, then null out user_id on every token (revoked or
|
|
242
|
+
// not) so the FK doesn't block the users delete. Backfill
|
|
243
|
+
// `subject` with the username so the audit trail isn't anchored
|
|
244
|
+
// to a primary key that just vanished.
|
|
245
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(
|
|
246
|
+
now,
|
|
247
|
+
userId,
|
|
248
|
+
);
|
|
249
|
+
db.prepare(
|
|
250
|
+
"UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
|
|
251
|
+
).run(row.username, userId);
|
|
252
|
+
// 2. Drop sessions + grants. Both have non-cascading FKs on user_id;
|
|
253
|
+
// leaving rows behind would RESTRICT the users delete below.
|
|
254
|
+
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
|
255
|
+
db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
|
|
256
|
+
// 3. Drop the user row itself.
|
|
257
|
+
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
|
258
|
+
})();
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Username validation (multi-user Phase 1, design 2026-05-20-multi-user-phase-1.md §4).
|
|
264
|
+
*
|
|
265
|
+
* Rules — settled with Aaron pre-PR-1:
|
|
266
|
+
* * Charset: `[a-z0-9_-]` (lowercase letters, digits, underscore, hyphen).
|
|
267
|
+
* Lowercase-only sidesteps "Bob vs bob" case-folding bugs across every
|
|
268
|
+
* downstream surface (URLs, log lines, the admin SPA's row keys).
|
|
269
|
+
* * Length: 2-32 chars inclusive. Hard floor on 1-char names (no `a`,
|
|
270
|
+
* `b`, …) because those are too easy to typo into someone else's
|
|
271
|
+
* account; hard ceiling on 32 because URL paths and log lines stay
|
|
272
|
+
* scannable. (Same shape vault-side scope verbs use.)
|
|
273
|
+
* * Reserved list (case-insensitive): admin, root, system, setup,
|
|
274
|
+
* parachute, hub. Keeps URL-shaped surfaces safe (Phase 2 may add
|
|
275
|
+
* `/users/<username>` paths; reserving the namespace now is cheap).
|
|
276
|
+
* Regex already pins lowercase, but the case-folded check is defense
|
|
277
|
+
* in depth: if a future loosening lets capitals through, the reserved
|
|
278
|
+
* check still triggers on `Admin`, `ROOT`, etc.
|
|
279
|
+
*
|
|
280
|
+
* Discriminated-union return: callers branch on `valid` rather than
|
|
281
|
+
* throwing. PR 2's `POST /api/users` returns a 400 with the `reason`
|
|
282
|
+
* surfaced in the response body.
|
|
283
|
+
*/
|
|
284
|
+
export const USERNAME_RESERVED = ["admin", "root", "system", "setup", "parachute", "hub"] as const;
|
|
285
|
+
|
|
286
|
+
const USERNAME_REGEX = /^[a-z0-9_-]+$/;
|
|
287
|
+
const USERNAME_MIN_LEN = 2;
|
|
288
|
+
const USERNAME_MAX_LEN = 32;
|
|
289
|
+
|
|
290
|
+
export type ValidateUsernameResult =
|
|
291
|
+
| { valid: true; name: string }
|
|
292
|
+
| { valid: false; reason: "format" | "length" | "reserved" };
|
|
293
|
+
|
|
294
|
+
export function validateUsername(name: string): ValidateUsernameResult {
|
|
295
|
+
// Length check first — a 0-char string fails the regex on emptiness but
|
|
296
|
+
// "length" is the more honest diagnostic.
|
|
297
|
+
if (name.length < USERNAME_MIN_LEN || name.length > USERNAME_MAX_LEN) {
|
|
298
|
+
return { valid: false, reason: "length" };
|
|
299
|
+
}
|
|
300
|
+
// The regex deliberately allows leading/trailing `_` and `-` (so
|
|
301
|
+
// `_-_`, `--alice`, `-foo`, `bar_` all pass the format gate). Stricter
|
|
302
|
+
// rules can land later if real-world users hit confusion. Vault's
|
|
303
|
+
// parallel username validator has the same shape — cross-repo parity
|
|
304
|
+
// matters more than aesthetic edge-case rejection here.
|
|
305
|
+
if (!USERNAME_REGEX.test(name)) {
|
|
306
|
+
return { valid: false, reason: "format" };
|
|
307
|
+
}
|
|
308
|
+
// Reserved-words check is case-insensitive even though the regex already
|
|
309
|
+
// pins lowercase — see comment above.
|
|
310
|
+
const lower = name.toLowerCase();
|
|
311
|
+
if (USERNAME_RESERVED.some((r) => r === lower)) {
|
|
312
|
+
return { valid: false, reason: "reserved" };
|
|
313
|
+
}
|
|
314
|
+
return { valid: true, name };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Password validation (multi-user Phase 1, design §5).
|
|
319
|
+
*
|
|
320
|
+
* Single rule: minimum 12 characters. No complexity classes — modern
|
|
321
|
+
* guidance (NIST 800-63B) prefers passphrase length over forced-symbol
|
|
322
|
+
* mixes, and Aaron settled on 12 as the floor pre-PR-1. No max length
|
|
323
|
+
* (argon2id absorbs whatever the user submits).
|
|
324
|
+
*
|
|
325
|
+
* Same discriminated-union shape as `validateUsername` — PR 2's create-
|
|
326
|
+
* user / reset-password endpoints (and PR 3's `/account/change-password`
|
|
327
|
+
* form) wire the `reason` into the response.
|
|
328
|
+
*/
|
|
329
|
+
export const PASSWORD_MIN_LEN = 12;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Upper bound for incoming password bodies. Not enforced inside
|
|
333
|
+
* `validatePassword` itself — the validator's contract is "length floor,
|
|
334
|
+
* no complexity rules" and adding a ceiling would muddy it. Exposed as
|
|
335
|
+
* a constant so PR 2's `POST /api/users` (and PR 3's change-password
|
|
336
|
+
* form) can cap incoming bodies before argon2id touches them. Defense
|
|
337
|
+
* against a CPU-DoS shape where an unauthenticated POST submits a
|
|
338
|
+
* megabyte password and forces a long argon2id hash. 256 chars is
|
|
339
|
+
* comfortably above any human-chosen passphrase (Diceware 8-word
|
|
340
|
+
* passphrases run ~55 chars).
|
|
341
|
+
*/
|
|
342
|
+
export const PASSWORD_MAX_LEN = 256;
|
|
343
|
+
|
|
344
|
+
export type ValidatePasswordResult = { valid: true } | { valid: false; reason: "too_short" };
|
|
345
|
+
|
|
346
|
+
export function validatePassword(password: string): ValidatePasswordResult {
|
|
347
|
+
if (password.length < PASSWORD_MIN_LEN) {
|
|
348
|
+
return { valid: false, reason: "too_short" };
|
|
349
|
+
}
|
|
350
|
+
return { valid: true };
|
|
351
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-name list — the single source of truth for "which vault instances are
|
|
3
|
+
* registered on this hub right now."
|
|
4
|
+
*
|
|
5
|
+
* Multi-user Phase 1, PR 4 of 5 (design
|
|
6
|
+
* [`parachute.computer/design/2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/)).
|
|
7
|
+
* Consolidates the two pre-PR-4 copies that read services.json and emitted
|
|
8
|
+
* vault names — one was private inside `oauth-handlers.ts` (used by the
|
|
9
|
+
* consent vault picker + post-consent narrowing), the other was private
|
|
10
|
+
* inside `api-users.ts` (used by `GET /api/users/vaults` for the admin SPA's
|
|
11
|
+
* assigned-vault dropdown + `POST /api/users` validation). PR 4 wires a third
|
|
12
|
+
* caller — server-side defense in `handleConsentSubmit` refusing mints
|
|
13
|
+
* whose picked vault disagrees with the user's `assigned_vault` — so the
|
|
14
|
+
* two private copies became three, and a duplicated read-and-derive helper
|
|
15
|
+
* for "what vaults exist" is exactly the shape that needs a single owner.
|
|
16
|
+
*
|
|
17
|
+
* Lives next to `well-known.ts` (which already owns `isVaultEntry` +
|
|
18
|
+
* `vaultInstanceNameFor`) rather than inside it: well-known's role is the
|
|
19
|
+
* `/.well-known/parachute.json` document shape, and a free-floating list
|
|
20
|
+
* helper would muddy that file's surface. Standalone module keeps the
|
|
21
|
+
* focused-purpose contract.
|
|
22
|
+
*
|
|
23
|
+
* Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
|
|
24
|
+
* with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
|
|
25
|
+
* (`parachute-vault-work`) by delegating each (name, path) pair to
|
|
26
|
+
* `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
|
|
27
|
+
* the helper's manifest-suffix fallback (hub#143).
|
|
28
|
+
*/
|
|
29
|
+
import { type ServicesManifest, readManifest } from "./services-manifest.ts";
|
|
30
|
+
import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Emit each vault instance's name from an in-memory manifest. Sorted output
|
|
34
|
+
* keeps callers (consent picker dropdown, admin SPA dropdown, server-side
|
|
35
|
+
* defense lookup) deterministic without each having to wrap in their own
|
|
36
|
+
* `.sort()`.
|
|
37
|
+
*/
|
|
38
|
+
export function listVaultNames(manifest: ServicesManifest): string[] {
|
|
39
|
+
const names = new Set<string>();
|
|
40
|
+
for (const svc of manifest.services) {
|
|
41
|
+
if (!isVaultEntry(svc)) continue;
|
|
42
|
+
const paths = svc.paths.length > 0 ? svc.paths : [undefined];
|
|
43
|
+
for (const path of paths) {
|
|
44
|
+
names.add(vaultInstanceNameFor(svc.name, path));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return Array.from(names).sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read-from-disk convenience for callers that already have a manifest path
|
|
52
|
+
* (e.g. `/api/users/vaults` reading the live `services.json`). Equivalent to
|
|
53
|
+
* `listVaultNames(readManifest(manifestPath))`.
|
|
54
|
+
*/
|
|
55
|
+
export function listVaultNamesFromPath(manifestPath: string): string[] {
|
|
56
|
+
return listVaultNames(readManifest(manifestPath));
|
|
57
|
+
}
|