@openparachute/hub 0.6.3 → 0.6.4-rc.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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/admin-vaults.ts
CHANGED
|
@@ -59,6 +59,8 @@ import type { Database } from "bun:sqlite";
|
|
|
59
59
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
60
60
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
61
61
|
import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
62
|
+
import { enrichedPath } from "./spawn-path.ts";
|
|
63
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
62
64
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
63
65
|
|
|
64
66
|
/** Scope required to call POST /vaults. */
|
|
@@ -72,7 +74,12 @@ export const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
|
72
74
|
* the hub rejects them at the API edge before a vault under those names
|
|
73
75
|
* can register and capture the proxy path.
|
|
74
76
|
*/
|
|
75
|
-
|
|
77
|
+
// Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
|
|
78
|
+
// init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
|
|
79
|
+
// uppercased name through that vault would then lowercase or reject, drifting
|
|
80
|
+
// the hub's idea of the vault from vault's. The hub-only reservations (`new`,
|
|
81
|
+
// `assets`) shadow SPA routes and stay on top of vault's `list`.
|
|
82
|
+
const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
|
|
76
83
|
const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
|
|
77
84
|
|
|
78
85
|
export interface CreateVaultRequest {
|
|
@@ -164,7 +171,7 @@ async function parseBody(req: Request): Promise<ParseResult | ParseError> {
|
|
|
164
171
|
return {
|
|
165
172
|
ok: false,
|
|
166
173
|
status: 400,
|
|
167
|
-
message: "vault name must contain only letters, numbers, hyphens, and underscores",
|
|
174
|
+
message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
177
|
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
@@ -230,9 +237,13 @@ function buildEntry(
|
|
|
230
237
|
async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
|
|
231
238
|
// Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
|
|
232
239
|
// defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
|
|
240
|
+
// PATH enrichment (hub launchd-PATH regression): a launchd-managed hub bakes
|
|
241
|
+
// a minimal PATH, so this shell-out (vault ops) inherits that thin PATH too;
|
|
242
|
+
// enrich it with operator-tool dirs (`$HOME/.local/bin`, brew bin). See
|
|
243
|
+
// `spawn-path.ts`.
|
|
233
244
|
const proc = Bun.spawn([...cmd], {
|
|
234
245
|
stdio: ["ignore", "pipe", "pipe"],
|
|
235
|
-
env: process.env,
|
|
246
|
+
env: { ...process.env, PATH: enrichedPath() },
|
|
236
247
|
});
|
|
237
248
|
// Drain both pipes in parallel — leaving stderr unread can deadlock long
|
|
238
249
|
// installs once the OS pipe buffer fills (#97). Captured stderr is folded
|
|
@@ -268,10 +279,17 @@ async function orchestrate(
|
|
|
268
279
|
manifestPath: string,
|
|
269
280
|
name: string,
|
|
270
281
|
runCommand: (cmd: readonly string[]) => Promise<RunResult>,
|
|
282
|
+
opts: { noMirror?: boolean } = {},
|
|
271
283
|
): Promise<OrchestrateOk | OrchestrateError> {
|
|
272
284
|
const vaultRegistered = findService("parachute-vault", manifestPath) !== undefined;
|
|
285
|
+
// `--no-mirror` opts this create out of the default internal live mirror
|
|
286
|
+
// (§3 default_mirror knob). Only meaningful on the `create` branch — the
|
|
287
|
+
// bootstrap `install` path provisions the default vault and follows the
|
|
288
|
+
// server-wide knob.
|
|
273
289
|
const cmd = vaultRegistered
|
|
274
|
-
?
|
|
290
|
+
? opts.noMirror
|
|
291
|
+
? ["parachute-vault", "create", name, "--json", "--no-mirror"]
|
|
292
|
+
: ["parachute-vault", "create", name, "--json"]
|
|
275
293
|
: ["parachute", "install", "vault"];
|
|
276
294
|
let result: RunResult;
|
|
277
295
|
try {
|
|
@@ -320,6 +338,102 @@ async function orchestrate(
|
|
|
320
338
|
return { ok: true, createJson };
|
|
321
339
|
}
|
|
322
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Result of {@link provisionVault} — the programmatic vault-provisioning
|
|
343
|
+
* core shared by the authed HTTP handler and the invite-redeem path.
|
|
344
|
+
*
|
|
345
|
+
* - `created: true` — a fresh vault was provisioned this call. `entry`
|
|
346
|
+
* describes it; `createJson` (when present) carries the single-emit
|
|
347
|
+
* bootstrap creds.
|
|
348
|
+
* - `created: false` — the vault already existed (idempotent). `entry`
|
|
349
|
+
* describes the existing vault; no `createJson`.
|
|
350
|
+
*/
|
|
351
|
+
export type ProvisionVaultResult =
|
|
352
|
+
| {
|
|
353
|
+
ok: true;
|
|
354
|
+
created: boolean;
|
|
355
|
+
entry: WellKnownVaultEntry;
|
|
356
|
+
createJson: VaultCreateJson | null;
|
|
357
|
+
}
|
|
358
|
+
| { ok: false; status: number; message: string };
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Provision (or no-op if it exists) a vault by name — the auth-free core
|
|
362
|
+
* lifted out of {@link handleCreateVault} so the invite-redeem flow can
|
|
363
|
+
* provision a vault for a freshly-created account WITHOUT a host:admin
|
|
364
|
+
* bearer (the redeemer holds no admin authority; the invite IS the
|
|
365
|
+
* authorization). The HTTP handler keeps the host:admin gate in front of
|
|
366
|
+
* this; the invite path calls it directly after validating the invite.
|
|
367
|
+
*
|
|
368
|
+
* Idempotent-safe: if the vault already exists this returns
|
|
369
|
+
* `created:false` with the existing entry rather than re-running the CLI —
|
|
370
|
+
* a redeem retry (or a name collision) lands the user on the existing
|
|
371
|
+
* vault instead of erroring.
|
|
372
|
+
*
|
|
373
|
+
* Name validation matches `parachute-vault create`'s rules. Callers that
|
|
374
|
+
* accept an untrusted name (the invite redeemer naming their own vault)
|
|
375
|
+
* MUST pass it through here so the same regex + reserved-name gate the
|
|
376
|
+
* `/vaults` API edge enforces applies.
|
|
377
|
+
*/
|
|
378
|
+
export async function provisionVault(
|
|
379
|
+
name: string,
|
|
380
|
+
deps: {
|
|
381
|
+
issuer: string;
|
|
382
|
+
manifestPath?: string;
|
|
383
|
+
runCommand?: CreateVaultDeps["runCommand"];
|
|
384
|
+
/** `'off'` appends `--no-mirror`; anything else follows the server knob. */
|
|
385
|
+
defaultMirror?: string;
|
|
386
|
+
},
|
|
387
|
+
): Promise<ProvisionVaultResult> {
|
|
388
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
389
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
390
|
+
|
|
391
|
+
if (!VAULT_NAME_PATTERN.test(name)) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
status: 400,
|
|
395
|
+
message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
399
|
+
return { ok: false, status: 400, message: `"${name}" is a reserved vault name` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Idempotency: if the vault already exists, return the existing entry.
|
|
403
|
+
const existing = findExistingVault(manifestPath, name);
|
|
404
|
+
if (existing) {
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
created: false,
|
|
408
|
+
entry: buildEntry(name, existing.path, existing.version, deps.issuer),
|
|
409
|
+
createJson: null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = await orchestrate(manifestPath, name, runCommand, {
|
|
414
|
+
noMirror: deps.defaultMirror === "off",
|
|
415
|
+
});
|
|
416
|
+
if (!result.ok) {
|
|
417
|
+
return { ok: false, status: result.status, message: result.message };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Re-read services.json: the CLI just wrote it.
|
|
421
|
+
const created = findExistingVault(manifestPath, name);
|
|
422
|
+
if (!created) {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
status: 500,
|
|
426
|
+
message: `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
created: true,
|
|
432
|
+
entry: buildEntry(name, created.path, created.version, deps.issuer),
|
|
433
|
+
createJson: result.createJson,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
323
437
|
export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Promise<Response> {
|
|
324
438
|
if (req.method !== "POST") {
|
|
325
439
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -341,35 +455,29 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
341
455
|
}
|
|
342
456
|
const { name } = parsed.body;
|
|
343
457
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const result = await orchestrate(manifestPath, name, runCommand);
|
|
358
|
-
if (!result.ok) {
|
|
359
|
-
return jsonError(result.status, "server_error", result.message);
|
|
458
|
+
const provisioned = await provisionVault(name, {
|
|
459
|
+
issuer: deps.issuer,
|
|
460
|
+
manifestPath,
|
|
461
|
+
runCommand,
|
|
462
|
+
});
|
|
463
|
+
if (!provisioned.ok) {
|
|
464
|
+
// parseBody already enforced the name regex/reserved gate, so a
|
|
465
|
+
// provisionVault 400 here would be a redundant re-check; map any
|
|
466
|
+
// non-ok to its status. invalid_request for 400, server_error otherwise.
|
|
467
|
+
const error = provisioned.status === 400 ? "invalid_request" : "server_error";
|
|
468
|
+
return jsonError(provisioned.status, error, provisioned.message);
|
|
360
469
|
}
|
|
361
470
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
`vault "${name}" was provisioned but is not in services.json — manual recovery required`,
|
|
369
|
-
);
|
|
471
|
+
// Idempotent re-POST: existing vault → 200, no single-emit creds.
|
|
472
|
+
if (!provisioned.created) {
|
|
473
|
+
return new Response(JSON.stringify(provisioned.entry), {
|
|
474
|
+
status: 200,
|
|
475
|
+
headers: { "content-type": "application/json" },
|
|
476
|
+
});
|
|
370
477
|
}
|
|
371
478
|
|
|
372
|
-
const entry =
|
|
479
|
+
const entry = provisioned.entry;
|
|
480
|
+
const result = { createJson: provisioned.createJson };
|
|
373
481
|
// Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
|
|
374
482
|
// filesystem paths are single-emit at create time. We surface them here so
|
|
375
483
|
// the caller can immediately bootstrap a connection to the new vault.
|
package/src/api-account.ts
CHANGED
|
@@ -49,9 +49,12 @@ import type { Database } from "bun:sqlite";
|
|
|
49
49
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
50
50
|
import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
|
|
51
51
|
import { renderAccountHome } from "./account-home-ui.ts";
|
|
52
|
+
import { fetchVaultMirrorStatus, formatMirrorLine } from "./account-mirror.ts";
|
|
53
|
+
import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
|
|
52
54
|
import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
|
|
53
55
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
54
56
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
57
|
+
import { userHasExternalAiGrant } from "./grants.ts";
|
|
55
58
|
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
56
59
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
57
60
|
import { findActiveSession } from "./sessions.ts";
|
|
@@ -419,6 +422,16 @@ export async function handleAccountChangePasswordPost(
|
|
|
419
422
|
)
|
|
420
423
|
.run(passwordHash, stamp, user.id);
|
|
421
424
|
if (result.changes === 0) throw new UserNotFoundError(user.id);
|
|
425
|
+
// Revoke the user's still-active tokens on a self-service password change
|
|
426
|
+
// (item F / hub#469). The admin-reset path already revokes
|
|
427
|
+
// (`resetUserPassword`, users.ts); applying the same on self-change closes
|
|
428
|
+
// the "mint a token under the admin's temp password, then rotate but keep
|
|
429
|
+
// the token" gap — any token minted before the rotation dies with it. The
|
|
430
|
+
// user re-mints under their own (now-rotated) password if they need one.
|
|
431
|
+
// Same transaction as the hash write so the two are atomic.
|
|
432
|
+
deps.db
|
|
433
|
+
.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
|
|
434
|
+
.run(stamp, user.id);
|
|
422
435
|
})();
|
|
423
436
|
} catch (err) {
|
|
424
437
|
// The user row vanished between the session-resolve check above and
|
|
@@ -476,9 +489,31 @@ export async function handleAccountChangePasswordPost(
|
|
|
476
489
|
export interface AccountHomeDeps extends ApiAccountDeps {
|
|
477
490
|
/** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
|
|
478
491
|
hubOrigin: string;
|
|
492
|
+
/**
|
|
493
|
+
* Resolve a vault's loopback port from services.json, or `null` when no vault
|
|
494
|
+
* is mounted under that name. Threaded in by the route handler (which reads
|
|
495
|
+
* the manifest) so this module stays free of services.json plumbing. Absent
|
|
496
|
+
* in tests / older callers → usage surfacing is skipped (tiles render without
|
|
497
|
+
* the stat, same as a failed fetch).
|
|
498
|
+
*/
|
|
499
|
+
resolveVaultPort?: (vaultName: string) => number | null;
|
|
500
|
+
/**
|
|
501
|
+
* Fetch one vault's usage stat, or `null` on any failure. Defaults to the
|
|
502
|
+
* real `fetchVaultUsage` (mints a read token + hits the vault's loopback
|
|
503
|
+
* usage endpoint). Injectable so tests assert the render without a live
|
|
504
|
+
* vault.
|
|
505
|
+
*/
|
|
506
|
+
fetchUsage?: typeof fetchVaultUsage;
|
|
507
|
+
/**
|
|
508
|
+
* Fetch one vault's backup (mirror) status, or `null` on any failure.
|
|
509
|
+
* Defaults to the real `fetchVaultMirrorStatus` (mints an admin-scoped token +
|
|
510
|
+
* hits the vault's loopback `/.parachute/mirror` endpoint). Injectable so
|
|
511
|
+
* tests assert the render without a live vault.
|
|
512
|
+
*/
|
|
513
|
+
fetchMirror?: typeof fetchVaultMirrorStatus;
|
|
479
514
|
}
|
|
480
515
|
|
|
481
|
-
export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
|
|
516
|
+
export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Promise<Response> {
|
|
482
517
|
const session = findActiveSession(deps.db, req);
|
|
483
518
|
if (!session) {
|
|
484
519
|
return redirect(`/login?next=${encodeURIComponent("/account/")}`);
|
|
@@ -501,6 +536,84 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
501
536
|
const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
|
|
502
537
|
if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
|
|
503
538
|
}
|
|
539
|
+
|
|
540
|
+
// Per-vault usage stat ("X notes · Y MB"). Fetched concurrently across the
|
|
541
|
+
// user's assigned vaults via the read-scoped vault endpoint; each fetch is
|
|
542
|
+
// independently fault-tolerant (returns null → that tile renders without a
|
|
543
|
+
// stat). Skipped entirely when the route didn't wire a port resolver (tests /
|
|
544
|
+
// older callers). The READ scope means the user's own authority suffices.
|
|
545
|
+
const usageStats: Record<string, string> = {};
|
|
546
|
+
if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
|
|
547
|
+
const fetchUsage = deps.fetchUsage ?? fetchVaultUsage;
|
|
548
|
+
const resolvePort = deps.resolveVaultPort;
|
|
549
|
+
await Promise.all(
|
|
550
|
+
user.assignedVaults.map(async (vaultName) => {
|
|
551
|
+
const port = resolvePort(vaultName);
|
|
552
|
+
if (port === null) return;
|
|
553
|
+
const stat = await fetchUsage(vaultName, {
|
|
554
|
+
db: deps.db,
|
|
555
|
+
hubOrigin: deps.hubOrigin,
|
|
556
|
+
vaultPort: port,
|
|
557
|
+
userId: user.id,
|
|
558
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
559
|
+
});
|
|
560
|
+
if (stat) usageStats[vaultName] = formatUsageStat(stat);
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Per-vault backup (mirror) line ("Backed up — full version history" / "…
|
|
566
|
+
// + GitHub"). The vault's mirror endpoint is ADMIN-scoped, so we only fetch
|
|
567
|
+
// for vaults where this user holds the admin verb (the same gate the
|
|
568
|
+
// "Advanced vault settings ↗" deep-link uses). Each fetch is independently
|
|
569
|
+
// fault-tolerant (returns null → no backup line on that tile) and backup-off
|
|
570
|
+
// formats to null too (we never nag with a "not backed up" warning here).
|
|
571
|
+
// Skipped entirely when the route didn't wire a port resolver (tests / older
|
|
572
|
+
// callers).
|
|
573
|
+
const mirrorLines: Record<string, string> = {};
|
|
574
|
+
// Whether each vault's backup is already pushing to a remote (`auto_push`).
|
|
575
|
+
// Threaded to the renderer as a proper boolean so it gates the "Back up to
|
|
576
|
+
// GitHub ↗" action without re-deriving "are we pushing?" from the line string.
|
|
577
|
+
const mirrorPushing: Record<string, boolean> = {};
|
|
578
|
+
if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
|
|
579
|
+
const fetchMirror = deps.fetchMirror ?? fetchVaultMirrorStatus;
|
|
580
|
+
const resolvePort = deps.resolveVaultPort;
|
|
581
|
+
await Promise.all(
|
|
582
|
+
user.assignedVaults.map(async (vaultName) => {
|
|
583
|
+
if (!(mintableVerbs[vaultName] ?? []).includes("admin")) return;
|
|
584
|
+
const port = resolvePort(vaultName);
|
|
585
|
+
if (port === null) return;
|
|
586
|
+
const stat = await fetchMirror(vaultName, {
|
|
587
|
+
db: deps.db,
|
|
588
|
+
hubOrigin: deps.hubOrigin,
|
|
589
|
+
vaultPort: port,
|
|
590
|
+
userId: user.id,
|
|
591
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
592
|
+
});
|
|
593
|
+
if (stat) {
|
|
594
|
+
const line = formatMirrorLine(stat);
|
|
595
|
+
if (line) {
|
|
596
|
+
mirrorLines[vaultName] = line;
|
|
597
|
+
mirrorPushing[vaultName] = stat.backedUpToRemote;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor, …)
|
|
605
|
+
// was wired to a vault — NOT a first-party browser sign-in. This is the
|
|
606
|
+
// PRIMARY browser GET /account/ route — the exact page the field report
|
|
607
|
+
// describes — so it must use the same filtered check as the vault-token
|
|
608
|
+
// re-render (account-vault-token.ts:196): the old `userHasVaultGrant` lit
|
|
609
|
+
// "✓ You're connected" the moment the user opened Notes (a first-party OAuth
|
|
610
|
+
// client that writes a vault-scoped grant). `userHasExternalAiGrant` excludes
|
|
611
|
+
// first-party browser surfaces so the checklist only condenses on a real AI
|
|
612
|
+
// connection.
|
|
613
|
+
const connectedVault = user.assignedVaults.some((v) =>
|
|
614
|
+
userHasExternalAiGrant(deps.db, user.id, v),
|
|
615
|
+
);
|
|
616
|
+
|
|
504
617
|
return htmlResponse(
|
|
505
618
|
renderAccountHome({
|
|
506
619
|
username: user.username,
|
|
@@ -511,6 +624,10 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
511
624
|
csrfToken: csrf.token,
|
|
512
625
|
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
513
626
|
mintableVerbs,
|
|
627
|
+
usageStats,
|
|
628
|
+
mirrorLines,
|
|
629
|
+
mirrorPushing,
|
|
630
|
+
connectedVault,
|
|
514
631
|
}),
|
|
515
632
|
200,
|
|
516
633
|
extra,
|