@openparachute/hub 0.6.4-rc.2 → 0.6.4-rc.4
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-home-ui.test.ts +123 -3
- package/src/__tests__/grants.test.ts +98 -8
- package/src/__tests__/users.test.ts +39 -0
- package/src/account-home-ui.ts +226 -6
- package/src/account-vault-token.ts +2 -0
- package/src/api-account.ts +8 -0
- package/src/grants.ts +25 -0
- package/src/users.ts +12 -4
package/package.json
CHANGED
|
@@ -175,7 +175,7 @@ describe("renderAccountHome", () => {
|
|
|
175
175
|
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
test("get-started card — links to the two onboarding prompts, placed
|
|
178
|
+
test("get-started card — links to the two onboarding prompts, placed AFTER the vault card", () => {
|
|
179
179
|
const html = renderAccountHome({
|
|
180
180
|
username: "alice",
|
|
181
181
|
assignedVaults: ["alice"],
|
|
@@ -195,8 +195,10 @@ describe("renderAccountHome", () => {
|
|
|
195
195
|
expect(html).toContain('data-testid="starter-surface-build"');
|
|
196
196
|
// External links open safely.
|
|
197
197
|
expect(html).toContain('rel="noopener"');
|
|
198
|
-
//
|
|
199
|
-
|
|
198
|
+
// Connect-before-prompts: the prompts are only useful once connected, so
|
|
199
|
+
// they now sit AFTER the vault card in document order (and after the
|
|
200
|
+
// onboarding checklist, which leads the page).
|
|
201
|
+
expect(html.indexOf('data-testid="get-started-card"')).toBeGreaterThan(
|
|
200
202
|
html.indexOf('data-testid="vault-card"'),
|
|
201
203
|
);
|
|
202
204
|
});
|
|
@@ -529,4 +531,122 @@ describe("renderAccountHome", () => {
|
|
|
529
531
|
// Error render must NOT also show a token.
|
|
530
532
|
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
531
533
|
});
|
|
534
|
+
|
|
535
|
+
// --- first-run onboarding checklist --------------------------------------
|
|
536
|
+
|
|
537
|
+
test("onboarding checklist — renders 3 steps with the correct /mcp endpoint (not connected)", () => {
|
|
538
|
+
const html = renderAccountHome({
|
|
539
|
+
username: "alice",
|
|
540
|
+
assignedVaults: ["alice"],
|
|
541
|
+
passwordChanged: true,
|
|
542
|
+
hubOrigin: HUB_ORIGIN,
|
|
543
|
+
isFirstAdmin: false,
|
|
544
|
+
csrfToken: CSRF,
|
|
545
|
+
twoFactorEnabled: false,
|
|
546
|
+
connectedVault: false,
|
|
547
|
+
});
|
|
548
|
+
expect(html).toContain('data-testid="onboarding-checklist"');
|
|
549
|
+
expect(html).toContain('data-connected="false"');
|
|
550
|
+
// All three numbered steps render.
|
|
551
|
+
expect(html).toContain('data-testid="onboarding-step-1"');
|
|
552
|
+
expect(html).toContain('data-testid="onboarding-step-2"');
|
|
553
|
+
expect(html).toContain('data-testid="onboarding-step-3"');
|
|
554
|
+
expect(html).toContain("Your account is ready");
|
|
555
|
+
expect(html).toContain("Connect your AI");
|
|
556
|
+
expect(html).toContain("Set up your vault");
|
|
557
|
+
// Step ② shows the canonical /vault/<name>/mcp endpoint inline — the /mcp
|
|
558
|
+
// suffix is load-bearing (only it returns the WWW-Authenticate header).
|
|
559
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
560
|
+
expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/alice\/mcp</);
|
|
561
|
+
// Both connect methods are inline in step ②.
|
|
562
|
+
expect(html).toContain('data-testid="onboarding-mcp-add-command"');
|
|
563
|
+
expect(html).toContain("Add custom connector");
|
|
564
|
+
expect(html).toContain("claude mcp add");
|
|
565
|
+
// Step ③ links the vault-setup starter prompt.
|
|
566
|
+
expect(html).toContain('data-testid="onboarding-vault-setup-link"');
|
|
567
|
+
expect(html).toContain("https://parachute.computer/onboarding/vault-setup/");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("onboarding checklist — condenses to 'you're connected' when a grant exists", () => {
|
|
571
|
+
const html = renderAccountHome({
|
|
572
|
+
username: "alice",
|
|
573
|
+
assignedVaults: ["alice"],
|
|
574
|
+
passwordChanged: true,
|
|
575
|
+
hubOrigin: HUB_ORIGIN,
|
|
576
|
+
isFirstAdmin: false,
|
|
577
|
+
csrfToken: CSRF,
|
|
578
|
+
twoFactorEnabled: false,
|
|
579
|
+
connectedVault: true,
|
|
580
|
+
});
|
|
581
|
+
// Still the same section, but in its condensed done-state.
|
|
582
|
+
expect(html).toContain('data-testid="onboarding-checklist"');
|
|
583
|
+
expect(html).toContain('data-connected="true"');
|
|
584
|
+
expect(html).toContain('data-testid="onboarding-done-line"');
|
|
585
|
+
expect(html).toContain("You're connected");
|
|
586
|
+
// The full 3-step list is gone (no nagging) — but the vault card below
|
|
587
|
+
// remains the working surface.
|
|
588
|
+
expect(html).not.toContain('data-testid="onboarding-step-2"');
|
|
589
|
+
expect(html).toContain('data-testid="vault-card"');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("onboarding checklist — leads the page: BEFORE the vault card and the starter prompts", () => {
|
|
593
|
+
const html = renderAccountHome({
|
|
594
|
+
username: "alice",
|
|
595
|
+
assignedVaults: ["alice"],
|
|
596
|
+
passwordChanged: true,
|
|
597
|
+
hubOrigin: HUB_ORIGIN,
|
|
598
|
+
isFirstAdmin: false,
|
|
599
|
+
csrfToken: CSRF,
|
|
600
|
+
twoFactorEnabled: false,
|
|
601
|
+
connectedVault: false,
|
|
602
|
+
});
|
|
603
|
+
const checklistIdx = html.indexOf('data-testid="onboarding-checklist"');
|
|
604
|
+
const vaultIdx = html.indexOf('data-testid="vault-card"');
|
|
605
|
+
const promptsIdx = html.indexOf('data-testid="get-started-card"');
|
|
606
|
+
// Net first-run order: checklist (connect) → vault details → prompts.
|
|
607
|
+
expect(checklistIdx).toBeGreaterThanOrEqual(0);
|
|
608
|
+
expect(checklistIdx).toBeLessThan(vaultIdx);
|
|
609
|
+
expect(vaultIdx).toBeLessThan(promptsIdx);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("onboarding checklist — absent on the admin and no-vault branches", () => {
|
|
613
|
+
const admin = renderAccountHome({
|
|
614
|
+
username: "admin",
|
|
615
|
+
assignedVaults: [],
|
|
616
|
+
passwordChanged: true,
|
|
617
|
+
hubOrigin: HUB_ORIGIN,
|
|
618
|
+
isFirstAdmin: true,
|
|
619
|
+
csrfToken: CSRF,
|
|
620
|
+
twoFactorEnabled: false,
|
|
621
|
+
});
|
|
622
|
+
expect(admin).not.toContain('data-testid="onboarding-checklist"');
|
|
623
|
+
|
|
624
|
+
const noVault = renderAccountHome({
|
|
625
|
+
username: "ghost",
|
|
626
|
+
assignedVaults: [],
|
|
627
|
+
passwordChanged: true,
|
|
628
|
+
hubOrigin: HUB_ORIGIN,
|
|
629
|
+
isFirstAdmin: false,
|
|
630
|
+
csrfToken: CSRF,
|
|
631
|
+
twoFactorEnabled: false,
|
|
632
|
+
});
|
|
633
|
+
expect(noVault).not.toContain('data-testid="onboarding-checklist"');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("onboarding checklist — multi-vault uses the first vault for the connect step", () => {
|
|
637
|
+
const html = renderAccountHome({
|
|
638
|
+
username: "alice",
|
|
639
|
+
assignedVaults: ["personal", "family"],
|
|
640
|
+
passwordChanged: true,
|
|
641
|
+
hubOrigin: HUB_ORIGIN,
|
|
642
|
+
isFirstAdmin: false,
|
|
643
|
+
csrfToken: CSRF,
|
|
644
|
+
twoFactorEnabled: false,
|
|
645
|
+
connectedVault: false,
|
|
646
|
+
});
|
|
647
|
+
// The checklist's connect step references the first/primary vault; the
|
|
648
|
+
// per-vault tiles below still list every vault.
|
|
649
|
+
expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/personal\/mcp</);
|
|
650
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`); // still present in the vault tiles
|
|
651
|
+
});
|
|
532
652
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
listGrantsForUser,
|
|
12
12
|
recordGrant,
|
|
13
13
|
revokeGrant,
|
|
14
|
+
userHasVaultGrant,
|
|
14
15
|
} from "../grants.ts";
|
|
15
16
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
17
|
import { createUser } from "../users.ts";
|
|
@@ -177,7 +178,13 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
177
178
|
redirectUris: ["https://app.example/cb"],
|
|
178
179
|
clientName: "claude-code",
|
|
179
180
|
});
|
|
180
|
-
recordGrant(
|
|
181
|
+
recordGrant(
|
|
182
|
+
h.db,
|
|
183
|
+
h.userId,
|
|
184
|
+
reg1.client.clientId,
|
|
185
|
+
["a", "b"],
|
|
186
|
+
new Date("2026-04-10T00:00:00Z"),
|
|
187
|
+
);
|
|
181
188
|
// Second DCR: same client_name="claude-code", fresh client_id, no grant yet
|
|
182
189
|
const reg2 = registerClient(h.db, {
|
|
183
190
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -249,8 +256,20 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
249
256
|
clientName: "claude-code",
|
|
250
257
|
});
|
|
251
258
|
recordGrant(h.db, h.userId, reg1.client.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
|
|
252
|
-
recordGrant(
|
|
253
|
-
|
|
259
|
+
recordGrant(
|
|
260
|
+
h.db,
|
|
261
|
+
h.userId,
|
|
262
|
+
reg3.client.clientId,
|
|
263
|
+
["a", "c"],
|
|
264
|
+
new Date("2026-04-15T00:00:00Z"),
|
|
265
|
+
);
|
|
266
|
+
recordGrant(
|
|
267
|
+
h.db,
|
|
268
|
+
h.userId,
|
|
269
|
+
reg2.client.clientId,
|
|
270
|
+
["a", "b"],
|
|
271
|
+
new Date("2026-04-10T00:00:00Z"),
|
|
272
|
+
);
|
|
254
273
|
const grant = findGrantByClientName(h.db, h.userId, "claude-code");
|
|
255
274
|
// Most recent = reg3's grant (2026-04-15)
|
|
256
275
|
expect(grant?.clientId).toBe(reg3.client.clientId);
|
|
@@ -267,9 +286,19 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
267
286
|
redirectUris: ["https://app.example/cb"],
|
|
268
287
|
clientName: "claude-code",
|
|
269
288
|
});
|
|
270
|
-
recordGrant(h.db, h.userId, reg.client.clientId, [
|
|
271
|
-
|
|
272
|
-
|
|
289
|
+
recordGrant(h.db, h.userId, reg.client.clientId, [
|
|
290
|
+
"vault:default:read",
|
|
291
|
+
"vault:default:write",
|
|
292
|
+
]);
|
|
293
|
+
expect(
|
|
294
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"]),
|
|
295
|
+
).toBe(true);
|
|
296
|
+
expect(
|
|
297
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
|
|
298
|
+
"vault:default:read",
|
|
299
|
+
"vault:default:write",
|
|
300
|
+
]),
|
|
301
|
+
).toBe(true);
|
|
273
302
|
} finally {
|
|
274
303
|
h.cleanup();
|
|
275
304
|
}
|
|
@@ -284,8 +313,15 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
284
313
|
});
|
|
285
314
|
recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read"]);
|
|
286
315
|
// Asking for write — not previously granted
|
|
287
|
-
expect(
|
|
288
|
-
|
|
316
|
+
expect(
|
|
317
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"]),
|
|
318
|
+
).toBe(false);
|
|
319
|
+
expect(
|
|
320
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
|
|
321
|
+
"vault:default:read",
|
|
322
|
+
"vault:default:write",
|
|
323
|
+
]),
|
|
324
|
+
).toBe(false);
|
|
289
325
|
} finally {
|
|
290
326
|
h.cleanup();
|
|
291
327
|
}
|
|
@@ -304,4 +340,58 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
304
340
|
h.cleanup();
|
|
305
341
|
}
|
|
306
342
|
});
|
|
343
|
+
|
|
344
|
+
// --- userHasVaultGrant (onboarding "has connected an AI?" signal) --------
|
|
345
|
+
|
|
346
|
+
test("userHasVaultGrant: false when the user has no grants at all", async () => {
|
|
347
|
+
const h = await harness();
|
|
348
|
+
try {
|
|
349
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
350
|
+
} finally {
|
|
351
|
+
h.cleanup();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("userHasVaultGrant: true when a grant's scopes touch the vault", async () => {
|
|
356
|
+
const h = await harness();
|
|
357
|
+
try {
|
|
358
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:default:read", "vault:default:write"]);
|
|
359
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(true);
|
|
360
|
+
} finally {
|
|
361
|
+
h.cleanup();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("userHasVaultGrant: false when the grant touches a DIFFERENT vault", async () => {
|
|
366
|
+
const h = await harness();
|
|
367
|
+
try {
|
|
368
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:work:read"]);
|
|
369
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
370
|
+
expect(userHasVaultGrant(h.db, h.userId, "work")).toBe(true);
|
|
371
|
+
} finally {
|
|
372
|
+
h.cleanup();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("userHasVaultGrant: non-vault scopes don't count as a connection", async () => {
|
|
377
|
+
const h = await harness();
|
|
378
|
+
try {
|
|
379
|
+
recordGrant(h.db, h.userId, h.clientId, ["parachute:host:auth", "vault:read"]);
|
|
380
|
+
// `vault:read` (no name segment) is a generic scope, not vault:<name>:.
|
|
381
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
382
|
+
} finally {
|
|
383
|
+
h.cleanup();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("userHasVaultGrant: prefix isn't substring-fooled (vault:default-2 ≠ default)", async () => {
|
|
388
|
+
const h = await harness();
|
|
389
|
+
try {
|
|
390
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:default-2:read"]);
|
|
391
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
392
|
+
expect(userHasVaultGrant(h.db, h.userId, "default-2")).toBe(true);
|
|
393
|
+
} finally {
|
|
394
|
+
h.cleanup();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
307
397
|
});
|
|
@@ -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", () => {
|
package/src/account-home-ui.ts
CHANGED
|
@@ -151,6 +151,17 @@ export interface RenderAccountHomeOpts {
|
|
|
151
151
|
* on the normal GET render.
|
|
152
152
|
*/
|
|
153
153
|
mintError?: string;
|
|
154
|
+
/**
|
|
155
|
+
* Whether this user has already connected an AI to (any of) their assigned
|
|
156
|
+
* vault(s) — true when a `grants` row touches one of their vaults (see
|
|
157
|
+
* `userHasVaultGrant`). Drives the first-run onboarding checklist: when
|
|
158
|
+
* `false`, the checklist leads with the hero "Connect your AI" step (inline
|
|
159
|
+
* endpoint + both methods); when `true`, the checklist condenses to a quiet
|
|
160
|
+
* "you're connected" line so it stops nagging returning users. The full vault
|
|
161
|
+
* card below remains the working surface either way. Omitted (defaults to
|
|
162
|
+
* `false`) on the admin / no-vault branches, where no checklist is shown.
|
|
163
|
+
*/
|
|
164
|
+
connectedVault?: boolean;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
/**
|
|
@@ -192,6 +203,22 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
|
192
203
|
const hasNoVault = !isFirstAdmin && assignedVaults.length === 0;
|
|
193
204
|
const startedCard = hasNoVault ? "" : renderGetStartedCard();
|
|
194
205
|
|
|
206
|
+
// First-run onboarding checklist — the lead surface for a friend with at
|
|
207
|
+
// least one assigned vault. Walks them through the obvious path: account
|
|
208
|
+
// ready → connect your AI (the hero step, inline endpoint + both methods) →
|
|
209
|
+
// set up your vault. Once connected (a grant touches one of their vaults) it
|
|
210
|
+
// condenses to a quiet "you're connected" line so it stops nagging. Shown
|
|
211
|
+
// only on the assigned-vault branch — the admin + no-vault branches have no
|
|
212
|
+
// single "your vault" to connect, so the checklist would be misleading there.
|
|
213
|
+
const checklist =
|
|
214
|
+
assignedVaults.length > 0
|
|
215
|
+
? renderOnboardingChecklist({
|
|
216
|
+
primaryVault: assignedVaults[0] as string,
|
|
217
|
+
trimmedOrigin,
|
|
218
|
+
connected: opts.connectedVault ?? false,
|
|
219
|
+
})
|
|
220
|
+
: "";
|
|
221
|
+
|
|
195
222
|
const vaultCard = renderVaultCard({
|
|
196
223
|
assignedVaults,
|
|
197
224
|
trimmedOrigin,
|
|
@@ -216,13 +243,120 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
|
216
243
|
</div>
|
|
217
244
|
${mintedBanner}
|
|
218
245
|
${mintErrorBanner}
|
|
219
|
-
${
|
|
246
|
+
${checklist}
|
|
220
247
|
${vaultCard}
|
|
248
|
+
${startedCard}
|
|
221
249
|
${accountCard}
|
|
222
250
|
</div>${COPY_SCRIPT}`;
|
|
223
251
|
return baseDocument(`${username} — Parachute`, body);
|
|
224
252
|
}
|
|
225
253
|
|
|
254
|
+
interface OnboardingChecklistOpts {
|
|
255
|
+
/**
|
|
256
|
+
* The vault the checklist's "Connect your AI" step shows the endpoint for.
|
|
257
|
+
* For the common single-vault case this is their only vault; for the rare
|
|
258
|
+
* multi-vault case it's the first/primary one (the per-vault tiles below
|
|
259
|
+
* still list every vault). Already validated/escaped at render time.
|
|
260
|
+
*/
|
|
261
|
+
primaryVault: string;
|
|
262
|
+
trimmedOrigin: string;
|
|
263
|
+
/** Whether they've already connected an AI (a grant touches a vault). */
|
|
264
|
+
connected: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The first-run "Get set up" checklist — the lead surface on `/account/` for a
|
|
269
|
+
* friend with an assigned vault. Three numbered steps give a non-technical
|
|
270
|
+
* person an obvious path:
|
|
271
|
+
*
|
|
272
|
+
* ① Your account is ready — always done (they're signed in, password set).
|
|
273
|
+
* ② Connect your AI — the hero. Inline endpoint (`/vault/<name>/mcp`)
|
|
274
|
+
* with a copy button + the two short connect
|
|
275
|
+
* methods (Claude.ai connector, Claude Code
|
|
276
|
+
* `claude mcp add`). Marked done when `connected`.
|
|
277
|
+
* ③ Set up your vault — links the vault-setup starter prompt.
|
|
278
|
+
*
|
|
279
|
+
* When `connected` is true the whole thing condenses to a quiet "✓ You're
|
|
280
|
+
* connected" line so it doesn't nag returning users — the full vault card below
|
|
281
|
+
* stays the working surface either way.
|
|
282
|
+
*
|
|
283
|
+
* Server-rendered, no-JS-required: the copy button is progressive enhancement
|
|
284
|
+
* (the endpoint stays selectable text without it), matching the rest of the page.
|
|
285
|
+
*/
|
|
286
|
+
function renderOnboardingChecklist(opts: OnboardingChecklistOpts): string {
|
|
287
|
+
const { primaryVault, trimmedOrigin, connected } = opts;
|
|
288
|
+
const safeVault = escapeHtml(primaryVault);
|
|
289
|
+
const endpoint = accountMcpEndpoint(trimmedOrigin, primaryVault);
|
|
290
|
+
const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, primaryVault);
|
|
291
|
+
const safeEndpoint = escapeHtml(endpoint);
|
|
292
|
+
const safeAddCmd = escapeHtml(addCmd);
|
|
293
|
+
|
|
294
|
+
// Condensed state — they've connected, so the checklist shrinks to a single
|
|
295
|
+
// reassuring line. The vault card below remains the place to actually work.
|
|
296
|
+
if (connected) {
|
|
297
|
+
return `
|
|
298
|
+
<section class="section onboarding onboarding-done" data-testid="onboarding-checklist"
|
|
299
|
+
data-connected="true">
|
|
300
|
+
<p class="onboarding-done-line" data-testid="onboarding-done-line">
|
|
301
|
+
<span class="onboarding-check" aria-hidden="true">✓</span>
|
|
302
|
+
You're connected — here's your vault.</p>
|
|
303
|
+
</section>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return `
|
|
307
|
+
<section class="section onboarding" data-testid="onboarding-checklist"
|
|
308
|
+
data-connected="false">
|
|
309
|
+
<h2>Get set up</h2>
|
|
310
|
+
<p class="onboarding-intro">Three quick steps to start using your vault with your AI.</p>
|
|
311
|
+
<ol class="onboarding-steps">
|
|
312
|
+
<li class="onboarding-step onboarding-step-done" data-testid="onboarding-step-1">
|
|
313
|
+
<span class="onboarding-num onboarding-num-done" aria-hidden="true">✓</span>
|
|
314
|
+
<div class="onboarding-step-body">
|
|
315
|
+
<p class="onboarding-step-title">Your account is ready</p>
|
|
316
|
+
<p class="onboarding-step-sub">You're signed in and your password is set. Nothing to
|
|
317
|
+
do here.</p>
|
|
318
|
+
</div>
|
|
319
|
+
</li>
|
|
320
|
+
|
|
321
|
+
<li class="onboarding-step onboarding-step-hero" data-testid="onboarding-step-2">
|
|
322
|
+
<span class="onboarding-num" aria-hidden="true">2</span>
|
|
323
|
+
<div class="onboarding-step-body">
|
|
324
|
+
<p class="onboarding-step-title">Connect your AI</p>
|
|
325
|
+
<p class="onboarding-step-sub">Point Claude (or another AI) at your vault using this
|
|
326
|
+
address — no token to copy, you'll sign in and approve the first time:</p>
|
|
327
|
+
<div class="copy-row">
|
|
328
|
+
<code data-testid="onboarding-mcp-endpoint">${safeEndpoint}</code>
|
|
329
|
+
<button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
|
|
330
|
+
data-testid="copy-onboarding-endpoint">Copy</button>
|
|
331
|
+
</div>
|
|
332
|
+
<p class="onboarding-method"><strong>Claude.ai (web):</strong> open
|
|
333
|
+
Settings → Connectors → Add custom connector, and paste the address above.</p>
|
|
334
|
+
<p class="onboarding-method"><strong>Claude Code (terminal):</strong> run this command:</p>
|
|
335
|
+
<div class="copy-row">
|
|
336
|
+
<code data-testid="onboarding-mcp-add-command">${safeAddCmd}</code>
|
|
337
|
+
<button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
|
|
338
|
+
data-testid="copy-onboarding-add-command">Copy</button>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</li>
|
|
342
|
+
|
|
343
|
+
<li class="onboarding-step" data-testid="onboarding-step-3">
|
|
344
|
+
<span class="onboarding-num" aria-hidden="true">3</span>
|
|
345
|
+
<div class="onboarding-step-body">
|
|
346
|
+
<p class="onboarding-step-title">Set up your vault</p>
|
|
347
|
+
<p class="onboarding-step-sub">Open a new Claude chat and paste the
|
|
348
|
+
<a href="https://parachute.computer/onboarding/vault-setup/" target="_blank"
|
|
349
|
+
rel="noopener" data-testid="onboarding-vault-setup-link">vault-setup prompt</a> —
|
|
350
|
+
your AI interviews you and structures your vault around how you think.</p>
|
|
351
|
+
</div>
|
|
352
|
+
</li>
|
|
353
|
+
</ol>
|
|
354
|
+
<p class="onboarding-foot" data-testid="onboarding-foot">Your vault is
|
|
355
|
+
<code>${safeVault}</code>. Full connect details, Notes, backup, and access tokens are
|
|
356
|
+
just below.</p>
|
|
357
|
+
</section>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
226
360
|
/**
|
|
227
361
|
* The "Get started with your AI" card — the real first stop for a friend
|
|
228
362
|
* landing on `/account/`. Mirrors the operator setup-wizard's
|
|
@@ -231,9 +365,9 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
|
231
365
|
* live on parachute.computer rather than embedded here so they iterate
|
|
232
366
|
* without a hub release; this card just links.
|
|
233
367
|
*
|
|
234
|
-
* Placed
|
|
235
|
-
*
|
|
236
|
-
*
|
|
368
|
+
* Placed AFTER the connect/vault card (connect-before-prompts): the prompts are
|
|
369
|
+
* only useful once the vault is connected, so the page leads with the connect
|
|
370
|
+
* checklist + vault details, and these "what next" prompts sit below them.
|
|
237
371
|
*/
|
|
238
372
|
function renderGetStartedCard(): string {
|
|
239
373
|
return `
|
|
@@ -748,6 +882,87 @@ const STYLES = `
|
|
|
748
882
|
.starter-grid { grid-template-columns: 1fr; }
|
|
749
883
|
}
|
|
750
884
|
|
|
885
|
+
.onboarding-intro { color: ${PALETTE.fgMuted}; font-size: 0.95rem; margin: 0 0 0.4rem; }
|
|
886
|
+
.onboarding-steps {
|
|
887
|
+
list-style: none;
|
|
888
|
+
margin: 0.75rem 0 0.4rem;
|
|
889
|
+
padding: 0;
|
|
890
|
+
display: flex;
|
|
891
|
+
flex-direction: column;
|
|
892
|
+
gap: 0.85rem;
|
|
893
|
+
}
|
|
894
|
+
.onboarding-step {
|
|
895
|
+
display: flex;
|
|
896
|
+
align-items: flex-start;
|
|
897
|
+
gap: 0.7rem;
|
|
898
|
+
}
|
|
899
|
+
.onboarding-num {
|
|
900
|
+
flex: 0 0 auto;
|
|
901
|
+
width: 1.5rem;
|
|
902
|
+
height: 1.5rem;
|
|
903
|
+
border-radius: 999px;
|
|
904
|
+
background: ${PALETTE.accent};
|
|
905
|
+
color: ${PALETTE.cardBg};
|
|
906
|
+
font-size: 0.85rem;
|
|
907
|
+
font-weight: 600;
|
|
908
|
+
display: inline-flex;
|
|
909
|
+
align-items: center;
|
|
910
|
+
justify-content: center;
|
|
911
|
+
margin-top: 0.1rem;
|
|
912
|
+
}
|
|
913
|
+
.onboarding-num-done {
|
|
914
|
+
background: ${PALETTE.successSoft};
|
|
915
|
+
color: ${PALETTE.success};
|
|
916
|
+
border: 1px solid ${PALETTE.success};
|
|
917
|
+
}
|
|
918
|
+
.onboarding-step-body { flex: 1 1 auto; min-width: 0; }
|
|
919
|
+
.onboarding-step-title {
|
|
920
|
+
font-weight: 600;
|
|
921
|
+
font-size: 0.95rem;
|
|
922
|
+
color: ${PALETTE.fg};
|
|
923
|
+
margin: 0 0 0.15rem;
|
|
924
|
+
}
|
|
925
|
+
.onboarding-step-sub {
|
|
926
|
+
font-size: 0.85rem;
|
|
927
|
+
color: ${PALETTE.fgMuted};
|
|
928
|
+
margin: 0 0 0.4rem;
|
|
929
|
+
}
|
|
930
|
+
.onboarding-step-done .onboarding-step-title { color: ${PALETTE.fgMuted}; font-weight: 500; }
|
|
931
|
+
.onboarding-method {
|
|
932
|
+
font-size: 0.85rem;
|
|
933
|
+
color: ${PALETTE.fgMuted};
|
|
934
|
+
margin: 0.5rem 0 0.3rem;
|
|
935
|
+
}
|
|
936
|
+
.onboarding-method strong { color: ${PALETTE.fg}; }
|
|
937
|
+
.onboarding-step .copy-row { margin: 0.35rem 0; }
|
|
938
|
+
.onboarding-foot {
|
|
939
|
+
font-size: 0.82rem;
|
|
940
|
+
color: ${PALETTE.fgMuted};
|
|
941
|
+
margin: 0.6rem 0 0;
|
|
942
|
+
}
|
|
943
|
+
.onboarding-done-line {
|
|
944
|
+
display: flex;
|
|
945
|
+
align-items: center;
|
|
946
|
+
gap: 0.5rem;
|
|
947
|
+
font-size: 1rem;
|
|
948
|
+
font-weight: 500;
|
|
949
|
+
color: ${PALETTE.fg};
|
|
950
|
+
margin: 0;
|
|
951
|
+
}
|
|
952
|
+
.onboarding-check {
|
|
953
|
+
flex: 0 0 auto;
|
|
954
|
+
width: 1.4rem;
|
|
955
|
+
height: 1.4rem;
|
|
956
|
+
border-radius: 999px;
|
|
957
|
+
background: ${PALETTE.successSoft};
|
|
958
|
+
color: ${PALETTE.success};
|
|
959
|
+
border: 1px solid ${PALETTE.success};
|
|
960
|
+
font-size: 0.85rem;
|
|
961
|
+
display: inline-flex;
|
|
962
|
+
align-items: center;
|
|
963
|
+
justify-content: center;
|
|
964
|
+
}
|
|
965
|
+
|
|
751
966
|
.account-security {
|
|
752
967
|
margin: 0.9rem 0 0;
|
|
753
968
|
padding-top: 0.6rem;
|
|
@@ -1053,8 +1268,13 @@ const STYLES = `
|
|
|
1053
1268
|
h1, h2 { color: #f0ece4; }
|
|
1054
1269
|
.subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint,
|
|
1055
1270
|
.mcp-connect-intro, .mcp-method-sub, .mcp-method-note,
|
|
1056
|
-
.vault-notes-cta-sub, .vault-usage
|
|
1057
|
-
.
|
|
1271
|
+
.vault-notes-cta-sub, .vault-usage,
|
|
1272
|
+
.onboarding-intro, .onboarding-step-sub, .onboarding-method,
|
|
1273
|
+
.onboarding-foot { color: #a8a29a; }
|
|
1274
|
+
.vault-name strong, .mcp-connect-label, .mcp-method-title,
|
|
1275
|
+
.onboarding-step-title, .onboarding-method strong,
|
|
1276
|
+
.onboarding-done-line { color: #f0ece4; }
|
|
1277
|
+
.onboarding-step-done .onboarding-step-title { color: #a8a29a; }
|
|
1058
1278
|
code { background: #1f1c18; color: #e8e4dc; }
|
|
1059
1279
|
.copy-row code { background: transparent; }
|
|
1060
1280
|
.section { border-top-color: #3a362f; }
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
} from "./account-home-ui.ts";
|
|
70
70
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
71
71
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
72
|
+
import { userHasVaultGrant } from "./grants.ts";
|
|
72
73
|
import { inferAudience } from "./jwt-audience.ts";
|
|
73
74
|
import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
|
|
74
75
|
import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
|
|
@@ -189,6 +190,7 @@ export async function handleAccountVaultTokenPost(
|
|
|
189
190
|
csrfToken: csrf.token,
|
|
190
191
|
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
191
192
|
mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
|
|
193
|
+
connectedVault: user.assignedVaults.some((v) => userHasVaultGrant(deps.db, user.id, v)),
|
|
192
194
|
...extras,
|
|
193
195
|
}),
|
|
194
196
|
status,
|
package/src/api-account.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
|
|
|
53
53
|
import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
|
|
54
54
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
55
55
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
56
|
+
import { userHasVaultGrant } from "./grants.ts";
|
|
56
57
|
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
57
58
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
58
59
|
import { findActiveSession } from "./sessions.ts";
|
|
@@ -553,6 +554,12 @@ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps):
|
|
|
553
554
|
);
|
|
554
555
|
}
|
|
555
556
|
|
|
557
|
+
// "Has this user connected an AI to any of their vaults yet?" — drives the
|
|
558
|
+
// onboarding checklist's "Connect your AI" step (done/condensed when true).
|
|
559
|
+
// A grant row only lands after the user clicks through an OAuth consent for a
|
|
560
|
+
// client wired to one of their vaults.
|
|
561
|
+
const connectedVault = user.assignedVaults.some((v) => userHasVaultGrant(deps.db, user.id, v));
|
|
562
|
+
|
|
556
563
|
return htmlResponse(
|
|
557
564
|
renderAccountHome({
|
|
558
565
|
username: user.username,
|
|
@@ -564,6 +571,7 @@ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps):
|
|
|
564
571
|
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
565
572
|
mintableVerbs,
|
|
566
573
|
usageStats,
|
|
574
|
+
connectedVault,
|
|
567
575
|
}),
|
|
568
576
|
200,
|
|
569
577
|
extra,
|
package/src/grants.ts
CHANGED
|
@@ -188,6 +188,31 @@ export function isCoveredByGrantForClientName(
|
|
|
188
188
|
return true;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
const VAULT_SCOPE_PREFIX_RE = /^vault:([^:]+):/;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* True when the user has approved at least one OAuth client whose granted
|
|
195
|
+
* scopes touch `vaultName` (any `vault:<name>:<verb>` scope). This is the
|
|
196
|
+
* "has this person actually connected an AI to this vault yet?" signal — the
|
|
197
|
+
* `/account/` onboarding checklist uses it to mark the "Connect your AI" step
|
|
198
|
+
* done (a grant row only lands once the user has clicked through the consent
|
|
199
|
+
* screen for a client wired to this vault).
|
|
200
|
+
*
|
|
201
|
+
* Mirrors the per-grant vault filter in `admin-grants.ts`; kept here so the
|
|
202
|
+
* detection lives next to the rest of the grants helpers and can be unit-tested
|
|
203
|
+
* without the admin route harness.
|
|
204
|
+
*/
|
|
205
|
+
export function userHasVaultGrant(db: Database, userId: string, vaultName: string): boolean {
|
|
206
|
+
const grants = listGrantsForUser(db, userId);
|
|
207
|
+
for (const g of grants) {
|
|
208
|
+
for (const s of g.scopes) {
|
|
209
|
+
const m = s.match(VAULT_SCOPE_PREFIX_RE);
|
|
210
|
+
if (m && m[1] === vaultName) return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
191
216
|
/** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
|
|
192
217
|
export function listGrantsForUser(db: Database, userId: string): Grant[] {
|
|
193
218
|
const rows = 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 `
|
|
605
|
-
* non-cascading FK.
|
|
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.
|
|
634
|
-
// leaving rows behind
|
|
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
|
})();
|