@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.2",
3
+ "version": "0.6.4-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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 before the vault card", () => {
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
- // Placed prominently before the vault card in document order.
199
- expect(html.indexOf('data-testid="get-started-card"')).toBeLessThan(
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(h.db, h.userId, reg1.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
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(h.db, h.userId, reg3.client.clientId, ["a", "c"], new Date("2026-04-15T00:00:00Z"));
253
- recordGrant(h.db, h.userId, reg2.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
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, ["vault:default:read", "vault:default:write"]);
271
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"])).toBe(true);
272
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(true);
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(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"])).toBe(false);
288
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(false);
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", () => {
@@ -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
- ${startedCard}
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 near the top of the page (after any banners, before the vault card)
235
- * because "what do I actually do with this?" is the friend's first question
236
- * the connect details below answer "how", this answers "what next".
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 { color: #a8a29a; }
1057
- .vault-name strong, .mcp-connect-label, .mcp-method-title { color: #f0ece4; }
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,
@@ -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 `grants.user_id` are NOT NULL with a
605
- * non-cascading FK. Both are deleted before the users row drops.
604
+ * - `sessions.user_id`, `grants.user_id`, and `auth_codes.user_id` are
605
+ * NOT NULL with a non-cascading (RESTRICT) FK. All three are deleted
606
+ * before the users row drops — auth_codes are ephemeral OAuth codes
607
+ * (60s TTL, no audit value), so a hard-delete is correct (hub#559).
606
608
  * - `user_vaults.user_id` has `ON DELETE CASCADE` (migration v10), so
607
609
  * vault assignments are dropped automatically when the parent row
608
610
  * goes. No explicit cleanup needed.
@@ -630,10 +632,16 @@ export function deleteUser(db: Database, userId: string): boolean {
630
632
  db.prepare(
631
633
  "UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
632
634
  ).run(row.username, userId);
633
- // 2. Drop sessions + grants. Both have non-cascading FKs on user_id;
634
- // leaving rows behind would RESTRICT the users delete below.
635
+ // 2. Drop sessions + grants + auth_codes. All have NOT-NULL, non-cascading
636
+ // (RESTRICT) FKs on user_id; leaving rows behind blocks the users delete
637
+ // below with SQLITE_CONSTRAINT_FOREIGNKEY. auth_codes are short-lived
638
+ // (60s TTL) OAuth authorization codes with no audit value — hard-delete,
639
+ // same as sessions. (Omitting this 500'd a real delete of a user who had
640
+ // completed an OAuth authorize: the code row outlived its TTL but still
641
+ // pinned the FK. hub#559.)
635
642
  db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
636
643
  db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
644
+ db.prepare("DELETE FROM auth_codes WHERE user_id = ?").run(userId);
637
645
  // 3. Drop the user row itself.
638
646
  db.prepare("DELETE FROM users WHERE id = ?").run(userId);
639
647
  })();