mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(crypto): vault status endpoint + settings page hydration
Closes the Phase 9 Milestone 4 known limitation where the settings
page always started in 'idle' state regardless of whether the user
had already enabled zero-knowledge mode. Adds a cheap server-side
status read + hydrates the page on mount.
Server side
-----------
New VaultStatus interface and getStatus(userId) method on
EncryptionVaultService — single SELECT against encryption_vaults,
no decryption, no audit logging (this gets called on every settings
page mount and we don't want to flood the audit log with read-only
metadata fetches). Returns sane defaults when the vault row doesn't
exist yet so the client can avoid a 404 dance.
GET /api/v1/me/encryption-vault/status →
{
vaultExists: boolean,
hasRecoveryWrap: boolean,
zeroKnowledge: boolean,
recoverySetAt: string | null
}
Client side
-----------
vault-client.ts gains a `getStatus()` method that bypasses the
fetchVault retry helper (status reads should be cheap and one-shot;
if they fail we let the caller fall back to defaults). Re-exports
VaultStatus + RecoveryCodeSetupResult from the crypto barrel.
settings/security/+page.svelte
------------------------------
onMount kicks off a getStatus() call. Two things change based on
the response:
1. If the server says zero_knowledge=true, jump zkSetupStep to
'enabled' so the page renders the active-state UI directly
instead of the setup flow.
2. New `hasRecoveryWrap` state tracks whether a wrap is stored,
even if ZK isn't active yet. The idle branch now has TWO
variants:
- hasRecoveryWrap=false: original "Recovery-Code einrichten"
single button (unchanged from milestone 4)
- hasRecoveryWrap=true: amber notice "you have a code stored
but ZK isn't active" with three buttons:
* "Zero-Knowledge jetzt aktivieren" (jumps straight to the
enable call)
* "Neuen Recovery-Code generieren" (rotates the wrap)
* "Recovery-Code entfernen" (with two-click confirmation,
calls DELETE /recovery-wrap)
This handles the previously-orphaned state where a user generated a
code, copied it to their password manager, but never confirmed the
final activation step. Without this branch, after a reload the
settings page would show "Setup" again and the call would fail
with "vault is already in zero-knowledge mode" — except it wouldn't,
because the vault wasn't actually in ZK yet, just had a recovery wrap
stored. Either way the state was confusing.
handleSetupRecoveryCode + handleClearRecoveryCode now keep
hasRecoveryWrap in sync after the round trip.
Fail-quiet on getStatus error: if the network/auth/server-side fetch
fails, the page stays at the idle default. The user can still run
the setup flow, and any inconsistencies surface via the usual
server-side error responses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56312ff579
commit
78d949d051
5 changed files with 205 additions and 10 deletions
|
|
@ -40,6 +40,18 @@ type AppContext = Context<{ Variables: { user: AuthUser } }>;
|
|||
export function createEncryptionVaultRoutes(vaultService: EncryptionVaultService) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// ─── GET /status ─────────────────────────────────────────
|
||||
// Cheap metadata read used by the settings page to hydrate the UI
|
||||
// after a reload. No decryption, no audit logging — pure SELECT.
|
||||
// Returns the same shape regardless of whether the vault row
|
||||
// exists yet, so the client can avoid a 404 dance for the
|
||||
// "vault not initialised" case.
|
||||
app.get('/status', async (c) => {
|
||||
const user = c.get('user');
|
||||
const status = await vaultService.getStatus(user.userId);
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
// ─── POST /init ──────────────────────────────────────────
|
||||
// Idempotent. First call creates a vault row; subsequent calls
|
||||
// return the existing master key. The client uses this on first
|
||||
|
|
|
|||
|
|
@ -74,6 +74,25 @@ export interface RecoveryWrapInput {
|
|||
recoveryIv: string;
|
||||
}
|
||||
|
||||
/** Snapshot of the vault row's high-level state, exposed via
|
||||
* GET /api/v1/me/encryption-vault/status. The settings page reads
|
||||
* this on mount to render the right UI section without having to
|
||||
* trigger a full unwrap of the master key. Cheap (single SELECT,
|
||||
* no decrypt). */
|
||||
export interface VaultStatus {
|
||||
/** True iff a vault row exists for this user. */
|
||||
vaultExists: boolean;
|
||||
/** True iff the user has a recovery wrap stored. Independent of
|
||||
* whether zero-knowledge is currently active. */
|
||||
hasRecoveryWrap: boolean;
|
||||
/** True iff zero-knowledge mode is active (server has no usable
|
||||
* KEK wrap, recovery wrap is the only way to unlock). */
|
||||
zeroKnowledge: boolean;
|
||||
/** ISO timestamp of when the recovery wrap was last set, or null
|
||||
* if never set. Useful for "last backup" hints in the UI. */
|
||||
recoverySetAt: string | null;
|
||||
}
|
||||
|
||||
export class EncryptionVaultService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
|
|
@ -262,6 +281,46 @@ export class EncryptionVaultService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap status read for UI rendering. No decryption, no audit
|
||||
* row (this gets called on every settings page mount and we don't
|
||||
* want to flood the audit log with read-only metadata fetches).
|
||||
*
|
||||
* Returns sane defaults when the vault row doesn't exist yet —
|
||||
* the page can render "vault not initialised" without needing a
|
||||
* separate code path.
|
||||
*/
|
||||
async getStatus(userId: string): Promise<VaultStatus> {
|
||||
return this.withUserScope(userId, async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
recoveryWrappedMk: encryptionVaults.recoveryWrappedMk,
|
||||
recoverySetAt: encryptionVaults.recoverySetAt,
|
||||
zeroKnowledge: encryptionVaults.zeroKnowledge,
|
||||
})
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
vaultExists: false,
|
||||
hasRecoveryWrap: false,
|
||||
zeroKnowledge: false,
|
||||
recoverySetAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
vaultExists: true,
|
||||
hasRecoveryWrap: row.recoveryWrappedMk !== null,
|
||||
zeroKnowledge: row.zeroKnowledge,
|
||||
recoverySetAt: row.recoverySetAt ? row.recoverySetAt.toISOString() : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 9: Recovery Wrap + Zero-Knowledge ─────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue