mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
feat(mana/web): encryption phase 3 — vault client + record helpers + layout wire-up
Adds the client-side wire-up that lets browsers fetch their master key
from the mana-auth server vault and use it to encrypt/decrypt configured
record fields. Still a no-op at the user-visible level until Phase 4
flips registry entries to enabled:true on a per-table basis.
vault-client.ts
Browser HTTP client for the three Phase 2 endpoints. Built around a
factory that takes (authUrl, getToken) and returns { unlock, lock,
refetch, rotate, getState }. Reuses the active MemoryKeyProvider if
one is already installed, otherwise registers a fresh one.
unlock() flow:
1. Short-circuits if already unlocked.
2. GET /api/v1/me/encryption-vault/key with Bearer token.
3. On 404 + code:'VAULT_NOT_INITIALISED', auto-fires POST /init so
the user is bootstrapped on first login per device.
4. Imports the returned base64 bytes via importMasterKey() into a
non-extractable CryptoKey, pushes it into MemoryKeyProvider.
5. Zeroes the raw byte buffer once imported (best-effort heap hygiene).
Network layer: 3-attempt retry loop with full-jitter exponential
backoff (500ms→8s), retries only on 0/408/429/5xx. 4xx surfaces
immediately so auth/permission errors don't stall the UI for seconds.
Error categorisation: 401/403→auth, network→network, 5xx→server,
rest→unknown. Returned as VaultUnlockState so callers can render
intent ("please re-login" vs "we're trying again" vs "the server
is having a moment").
record-helpers.ts
encryptRecord(tableName, record):
- Looks up the registry, returns unchanged if the table is not
configured or registry entry is disabled.
- Builds a work list of fields that need encryption (skipping
null/undefined and already-encrypted blobs — the latter makes
the helper idempotent on a re-emit from liveQuery).
- Throws VaultLockedError on the first call that needs the key
but finds the vault locked. Module stores let it bubble; the
UI surfaces "you need to unlock" toast.
decryptRecord(tableName, record):
- Mirror of encryptRecord. Locked-vault behaviour is to LEAVE the
blobs in place (rather than throw) so views can still render
structural fields and show a "🔒" placeholder where content
used to be.
- Per-field decrypt failure (corrupt blob, wrong key) is caught,
logged, and the field stays encrypted. The rest of the record
decrypts normally — one bad blob doesn't kill the whole read.
decryptRecords: array variant that skips null/undefined entries.
Layout integration (+layout.svelte)
- createVaultClient is constructed once at module init, reused
across all auth-state changes.
- The existing $effect on authStore.user gets a new branch:
- userId set + hasAnyEncryption() → vaultClient.unlock()
- userId cleared → vaultClient.lock()
- hasAnyEncryption() guards the network round-trip: while every
table is enabled:false (Phase 3 default), no fetch happens at all.
Phase 4 enables tables one by one and the unlock kicks in
automatically.
Tests
- record-helpers.test.ts: 12 cases — encrypt skips non-listed fields,
null/undefined pass-through, idempotent on already-encrypted,
table-not-in-registry no-op, VaultLockedError on missing key,
decrypt roundtrip, locked-vault returns blobs unchanged, per-field
failure logged + others continue, JSON.stringify/parse roundtrip
survives the sync wire.
- vault-client.test.ts: 12 cases — happy path GET /key, idempotent
second unlock, 404 → auto /init, generic 404 does NOT trigger
/init, 401/403 → auth error, fetch throw → network error, no
token → auth error without network call, lock() clears key,
refetch() re-pulls, rotate() POSTs and installs.
Verified: 7 test files, 110/110 src/lib/data/ tests passing
(31 AES + 12 record-helpers + 12 vault-client + 20 sync + 6 activity
+ 19 recurrence + 10 misc helpers).
Phase 4 (next): pilot the notes module — flip its registry entry to
enabled:true, wrap the notes store add/update to call encryptRecord,
wrap the notes queries to call decryptRecord, add a settings page
showing lock state and a manual rotate button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5aeaf5e7f
commit
354cbcb176
6 changed files with 840 additions and 0 deletions
|
|
@ -43,3 +43,12 @@ export {
|
||||||
hasAnyEncryption,
|
hasAnyEncryption,
|
||||||
getRegisteredTables,
|
getRegisteredTables,
|
||||||
} from './registry';
|
} from './registry';
|
||||||
|
|
||||||
|
export { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type VaultClient,
|
||||||
|
type VaultClientOptions,
|
||||||
|
type VaultUnlockState,
|
||||||
|
createVaultClient,
|
||||||
|
} from './vault-client';
|
||||||
|
|
|
||||||
190
apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts
Normal file
190
apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* Tests for encryptRecord / decryptRecord roundtrip behaviour.
|
||||||
|
*
|
||||||
|
* Uses real Web Crypto from the Node 20+ runtime — no Dexie or
|
||||||
|
* IndexedDB needed. The registry is mutated in place via vi.spyOn so
|
||||||
|
* we can flip a test table to enabled:true without affecting the
|
||||||
|
* production defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers';
|
||||||
|
import { generateMasterKey, isEncrypted } from './aes';
|
||||||
|
import { MemoryKeyProvider, setKeyProvider } from './key-provider';
|
||||||
|
import * as registry from './registry';
|
||||||
|
|
||||||
|
let key: CryptoKey;
|
||||||
|
let provider: MemoryKeyProvider;
|
||||||
|
|
||||||
|
const TEST_TABLE = 'notes';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
key = await generateMasterKey();
|
||||||
|
provider = new MemoryKeyProvider();
|
||||||
|
provider.setKey(key);
|
||||||
|
setKeyProvider(provider);
|
||||||
|
|
||||||
|
// Pretend the notes table is enabled with title + body fields.
|
||||||
|
vi.spyOn(registry, 'getEncryptedFields').mockImplementation((tableName: string) => {
|
||||||
|
if (tableName === TEST_TABLE) return ['title', 'body'];
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
provider.setKey(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encryptRecord', () => {
|
||||||
|
it('encrypts only the configured fields, leaves the rest plaintext', async () => {
|
||||||
|
const record = {
|
||||||
|
id: 'note-1',
|
||||||
|
title: 'Buy milk',
|
||||||
|
body: 'and eggs and bread',
|
||||||
|
createdAt: '2026-04-07T10:00:00Z',
|
||||||
|
isPinned: false,
|
||||||
|
};
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
|
||||||
|
expect(record.id).toBe('note-1');
|
||||||
|
expect(record.createdAt).toBe('2026-04-07T10:00:00Z');
|
||||||
|
expect(record.isPinned).toBe(false);
|
||||||
|
|
||||||
|
expect(isEncrypted(record.title)).toBe(true);
|
||||||
|
expect(isEncrypted(record.body)).toBe(true);
|
||||||
|
expect(record.title).not.toBe('Buy milk');
|
||||||
|
expect(record.body).not.toBe('and eggs and bread');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fields that are null or undefined', async () => {
|
||||||
|
const record = { id: 'n', title: null, body: undefined as unknown as string };
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
expect(record.title).toBe(null);
|
||||||
|
expect(record.body).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-encrypted fields (idempotent on second call)', async () => {
|
||||||
|
const record = { id: 'n', title: 'first', body: 'second' };
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
const encryptedTitle = record.title;
|
||||||
|
const encryptedBody = record.body;
|
||||||
|
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
expect(record.title).toBe(encryptedTitle);
|
||||||
|
expect(record.body).toBe(encryptedBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unchanged when the table is not in the registry', async () => {
|
||||||
|
const record = { id: 'x', title: 'plain', body: 'plain' };
|
||||||
|
await encryptRecord('not_encrypted_table', record);
|
||||||
|
expect(record.title).toBe('plain');
|
||||||
|
expect(record.body).toBe('plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws VaultLockedError when no key is available', async () => {
|
||||||
|
provider.setKey(null);
|
||||||
|
const record = { id: 'n', title: 'secret', body: 'also secret' };
|
||||||
|
await expect(encryptRecord(TEST_TABLE, record)).rejects.toThrow(VaultLockedError);
|
||||||
|
// Record was not partially mutated
|
||||||
|
expect(record.title).toBe('secret');
|
||||||
|
expect(record.body).toBe('also secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the vault is locked but no fields need encryption', async () => {
|
||||||
|
provider.setKey(null);
|
||||||
|
const record = { id: 'n', title: null, body: undefined as unknown as string };
|
||||||
|
await expect(encryptRecord(TEST_TABLE, record)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptRecord', () => {
|
||||||
|
it('decrypts the configured fields back to their plaintext', async () => {
|
||||||
|
const original = { id: 'n', title: 'Buy milk', body: 'plus eggs', createdAt: 'now' };
|
||||||
|
// We can't pass the same object to encrypt + decrypt without
|
||||||
|
// reading the encrypted values first, so encrypt in place then
|
||||||
|
// decrypt the same reference.
|
||||||
|
await encryptRecord(TEST_TABLE, original);
|
||||||
|
expect(isEncrypted(original.title)).toBe(true);
|
||||||
|
|
||||||
|
await decryptRecord(TEST_TABLE, original);
|
||||||
|
expect(original.title).toBe('Buy milk');
|
||||||
|
expect(original.body).toBe('plus eggs');
|
||||||
|
expect(original.createdAt).toBe('now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves blobs in place when the vault is locked', async () => {
|
||||||
|
const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' };
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
const encryptedTitle = record.title;
|
||||||
|
const encryptedBody = record.body;
|
||||||
|
|
||||||
|
provider.setKey(null);
|
||||||
|
await decryptRecord(TEST_TABLE, record);
|
||||||
|
expect(record.title).toBe(encryptedTitle);
|
||||||
|
expect(record.body).toBe(encryptedBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs and continues on per-field decrypt failure', async () => {
|
||||||
|
const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' };
|
||||||
|
await encryptRecord(TEST_TABLE, record);
|
||||||
|
|
||||||
|
// Tamper with the title's auth tag
|
||||||
|
const lastChar = (record.title as string).charAt((record.title as string).length - 1);
|
||||||
|
const swap = lastChar === 'A' ? 'B' : 'A';
|
||||||
|
record.title = (record.title as string).slice(0, -1) + swap;
|
||||||
|
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
await decryptRecord(TEST_TABLE, record);
|
||||||
|
expect(errSpy).toHaveBeenCalledOnce();
|
||||||
|
// Body still decrypts
|
||||||
|
expect(record.body).toBe('plus eggs');
|
||||||
|
// Title stays as-is (corrupt blob)
|
||||||
|
expect(isEncrypted(record.title)).toBe(true);
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through plaintext fields untouched', async () => {
|
||||||
|
const record = { id: 'n', title: 'plain string', body: null };
|
||||||
|
await decryptRecord(TEST_TABLE, record);
|
||||||
|
expect(record.title).toBe('plain string');
|
||||||
|
expect(record.body).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptRecords', () => {
|
||||||
|
it('decrypts an array of records', async () => {
|
||||||
|
const records = [
|
||||||
|
{ id: 'a', title: 'A title', body: 'A body' },
|
||||||
|
{ id: 'b', title: 'B title', body: 'B body' },
|
||||||
|
];
|
||||||
|
await Promise.all(records.map((r) => encryptRecord(TEST_TABLE, r)));
|
||||||
|
const decrypted = await decryptRecords(TEST_TABLE, records);
|
||||||
|
expect(decrypted).toHaveLength(2);
|
||||||
|
expect(decrypted[0].title).toBe('A title');
|
||||||
|
expect(decrypted[1].title).toBe('B title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips null/undefined entries from getMany-style results', async () => {
|
||||||
|
const r = { id: 'a', title: 'A', body: 'B' };
|
||||||
|
await encryptRecord(TEST_TABLE, r);
|
||||||
|
const decrypted = await decryptRecords(TEST_TABLE, [r, null, undefined]);
|
||||||
|
expect(decrypted).toHaveLength(1);
|
||||||
|
expect(decrypted[0].title).toBe('A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypt → write → read → decrypt full cycle', () => {
|
||||||
|
it('survives a JSON.stringify/parse roundtrip in between (sync wire)', async () => {
|
||||||
|
const original = { id: 'n', title: 'Sensitive', body: 'Even more sensitive' };
|
||||||
|
await encryptRecord(TEST_TABLE, original);
|
||||||
|
|
||||||
|
// Simulate sending to the server and getting it back
|
||||||
|
const onTheWire = JSON.stringify(original);
|
||||||
|
const fromServer = JSON.parse(onTheWire) as typeof original;
|
||||||
|
|
||||||
|
await decryptRecord(TEST_TABLE, fromServer);
|
||||||
|
expect(fromServer.title).toBe('Sensitive');
|
||||||
|
expect(fromServer.body).toBe('Even more sensitive');
|
||||||
|
});
|
||||||
|
});
|
||||||
143
apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts
Normal file
143
apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* Record-level encrypt/decrypt helpers driven by the registry.
|
||||||
|
*
|
||||||
|
* Module stores call `encryptRecord(tableName, record)` before
|
||||||
|
* `table.add(record)` / `table.update(id, record)`. Module read paths
|
||||||
|
* call `decryptRecord(tableName, record)` after `table.get(id)` /
|
||||||
|
* `liveQuery(...)`.
|
||||||
|
*
|
||||||
|
* Why explicit calls instead of transparent Dexie hooks?
|
||||||
|
* - Dexie hooks are synchronous; Web Crypto is async. Wrapping the
|
||||||
|
* hook in a sync polyfill (stablelib) would add ~10 KB and slow
|
||||||
|
* every write down by ~3x. Explicit async calls keep the hot path
|
||||||
|
* on native crypto.
|
||||||
|
* - Pilot rollout is easier: notes can adopt encryption without
|
||||||
|
* touching todo, calendar, etc.
|
||||||
|
* - Tests are simpler: no need to mock Dexie hook timing.
|
||||||
|
*
|
||||||
|
* What happens if the vault is locked?
|
||||||
|
* - encryptRecord throws `VaultLockedError`. Module stores let it
|
||||||
|
* bubble; the UI layer turns it into a "you need to unlock first"
|
||||||
|
* toast. The user-visible write fails fast — no plaintext sneaks
|
||||||
|
* through.
|
||||||
|
* - decryptRecord returns the record unchanged (encrypted blobs stay
|
||||||
|
* opaque), so views can still render the structural fields and
|
||||||
|
* show a "🔒 locked" placeholder where the encrypted ones used to
|
||||||
|
* be.
|
||||||
|
*
|
||||||
|
* What about server-applied changes?
|
||||||
|
* - applyServerChanges in sync.ts treats encrypted fields as opaque
|
||||||
|
* strings. The server stores blobs, the client stores blobs. The
|
||||||
|
* decrypt happens at READ time via decryptRecord, not at apply
|
||||||
|
* time. This means LWW continues to compare timestamps (plaintext
|
||||||
|
* metadata) without ever touching the ciphertext.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { wrapValue, unwrapValue, isEncrypted } from './aes';
|
||||||
|
import { getActiveKey, isVaultUnlocked } from './key-provider';
|
||||||
|
import { getEncryptedFields } from './registry';
|
||||||
|
|
||||||
|
/** Thrown by encryptRecord when no key is available. Module stores
|
||||||
|
* catch this to surface "vault locked" UI. */
|
||||||
|
export class VaultLockedError extends Error {
|
||||||
|
constructor(public tableName: string) {
|
||||||
|
super(`mana-crypto: vault is locked, cannot encrypt fields for table '${tableName}'`);
|
||||||
|
this.name = 'VaultLockedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts the configured fields of `record` in place. Returns the
|
||||||
|
* same record reference for chaining. No-op if the table is not in
|
||||||
|
* the registry, the registry entry is disabled, or every configured
|
||||||
|
* field is null/undefined.
|
||||||
|
*
|
||||||
|
* Throws VaultLockedError if at least one field would need encryption
|
||||||
|
* but no master key is available. Callers can pre-check with
|
||||||
|
* `isVaultUnlocked()` to surface a friendlier error.
|
||||||
|
*/
|
||||||
|
export async function encryptRecord<T extends Record<string, unknown>>(
|
||||||
|
tableName: string,
|
||||||
|
record: T
|
||||||
|
): Promise<T> {
|
||||||
|
const fields = getEncryptedFields(tableName);
|
||||||
|
if (!fields) return record;
|
||||||
|
|
||||||
|
// Build the work list first so we don't half-encrypt a record on
|
||||||
|
// vault-locked failure mid-loop.
|
||||||
|
const todo: string[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = record[field];
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
// Already encrypted? Skip — happens when applyServerChanges
|
||||||
|
// hands a record (with encrypted blobs from the wire) back
|
||||||
|
// through the same code path on a re-emit / liveQuery refresh.
|
||||||
|
if (typeof value === 'string' && isEncrypted(value)) continue;
|
||||||
|
todo.push(field);
|
||||||
|
}
|
||||||
|
if (todo.length === 0) return record;
|
||||||
|
|
||||||
|
const key = getActiveKey();
|
||||||
|
if (!key) throw new VaultLockedError(tableName);
|
||||||
|
|
||||||
|
for (const field of todo) {
|
||||||
|
const wrapped = await wrapValue(record[field], key);
|
||||||
|
(record as Record<string, unknown>)[field] = wrapped;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts the configured fields of `record` in place. Returns the
|
||||||
|
* same record reference. No-op if the table is not in the registry,
|
||||||
|
* the registry entry is disabled, or every encrypted field is missing.
|
||||||
|
*
|
||||||
|
* Locked-vault behaviour: leaves encrypted blobs in place rather than
|
||||||
|
* throwing. Views are expected to handle the blob → "🔒" rendering
|
||||||
|
* themselves. Plaintext fields (id, timestamps, status) stay readable.
|
||||||
|
*/
|
||||||
|
export async function decryptRecord<T extends Record<string, unknown>>(
|
||||||
|
tableName: string,
|
||||||
|
record: T
|
||||||
|
): Promise<T> {
|
||||||
|
const fields = getEncryptedFields(tableName);
|
||||||
|
if (!fields) return record;
|
||||||
|
|
||||||
|
const key = getActiveKey();
|
||||||
|
if (!key) return record; // locked: leave blobs as-is
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value !== 'string' || !isEncrypted(value)) continue;
|
||||||
|
try {
|
||||||
|
(record as Record<string, unknown>)[field] = await unwrapValue(value, key);
|
||||||
|
} catch (err) {
|
||||||
|
// Don't kill the read just because one field is corrupt or
|
||||||
|
// keyed to a previous master. Log + leave the blob in place
|
||||||
|
// so the UI can show a "decryption failed" marker.
|
||||||
|
console.error(
|
||||||
|
`[mana-crypto] decrypt failed for ${tableName}.${field}: ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience for liveQuery / array results — applies decryptRecord
|
||||||
|
* to every entry and returns a fresh array of the same length. Skips
|
||||||
|
* null entries (e.g. from `getMany([id1, id2])` when one is missing).
|
||||||
|
*/
|
||||||
|
export async function decryptRecords<T extends Record<string, unknown>>(
|
||||||
|
tableName: string,
|
||||||
|
records: (T | null | undefined)[]
|
||||||
|
): Promise<T[]> {
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const r of records) {
|
||||||
|
if (r) out.push(await decryptRecord(tableName, r));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-export so callers don't need to reach into key-provider.ts. */
|
||||||
|
export { isVaultUnlocked };
|
||||||
217
apps/mana/apps/web/src/lib/data/crypto/vault-client.test.ts
Normal file
217
apps/mana/apps/web/src/lib/data/crypto/vault-client.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
/**
|
||||||
|
* Tests for the browser-side vault client.
|
||||||
|
*
|
||||||
|
* Mocks `globalThis.fetch` to simulate the various HTTP responses
|
||||||
|
* mana-auth can return. The actual MemoryKeyProvider gets a real
|
||||||
|
* Web Crypto key imported via the response, so the test also
|
||||||
|
* verifies that the import path doesn't blow up on the bytes the
|
||||||
|
* server sends.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { createVaultClient } from './vault-client';
|
||||||
|
import { MemoryKeyProvider, setKeyProvider, getActiveKey, isVaultUnlocked } from './key-provider';
|
||||||
|
import { generateMasterKey, exportMasterKey } from './aes';
|
||||||
|
|
||||||
|
let realFetch: typeof fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
realFetch = globalThis.fetch;
|
||||||
|
// Reset to a fresh provider for each test
|
||||||
|
setKeyProvider(new MemoryKeyProvider());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helper: builds a base64 string the server would return for a key. */
|
||||||
|
async function freshKeyBase64(): Promise<string> {
|
||||||
|
const key = await generateMasterKey();
|
||||||
|
const raw = await exportMasterKey(key);
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < raw.length; i++) bin += String.fromCharCode(raw[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input.toString();
|
||||||
|
return handler(url, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createVaultClient.unlock — happy path', () => {
|
||||||
|
it('fetches /key on first unlock and installs the master key', async () => {
|
||||||
|
const masterKey = await freshKeyBase64();
|
||||||
|
const calls: string[] = [];
|
||||||
|
mockFetch((url) => {
|
||||||
|
calls.push(url);
|
||||||
|
return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createVaultClient({
|
||||||
|
authUrl: 'http://localhost:3001',
|
||||||
|
getToken: () => 'test-jwt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'unlocked' });
|
||||||
|
expect(isVaultUnlocked()).toBe(true);
|
||||||
|
expect(getActiveKey()).not.toBe(null);
|
||||||
|
expect(calls).toEqual(['http://localhost:3001/api/v1/me/encryption-vault/key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent when already unlocked', async () => {
|
||||||
|
const masterKey = await freshKeyBase64();
|
||||||
|
mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 }));
|
||||||
|
|
||||||
|
const client = createVaultClient({
|
||||||
|
authUrl: 'http://localhost:3001',
|
||||||
|
getToken: () => 'test-jwt',
|
||||||
|
});
|
||||||
|
await client.unlock();
|
||||||
|
const fetchCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||||
|
const second = await client.unlock();
|
||||||
|
expect(second).toEqual({ status: 'unlocked' });
|
||||||
|
// Second call short-circuits without hitting the network
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(fetchCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createVaultClient.unlock — auto-init on 404', () => {
|
||||||
|
it('calls /init when /key returns VAULT_NOT_INITIALISED', async () => {
|
||||||
|
const masterKey = await freshKeyBase64();
|
||||||
|
const calls: string[] = [];
|
||||||
|
mockFetch((url) => {
|
||||||
|
calls.push(url);
|
||||||
|
if (url.endsWith('/key')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'vault not initialised', code: 'VAULT_NOT_INITIALISED' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith('/init')) {
|
||||||
|
return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('', { status: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createVaultClient({
|
||||||
|
authUrl: 'http://localhost:3001',
|
||||||
|
getToken: () => 'test-jwt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'unlocked' });
|
||||||
|
expect(calls).toEqual([
|
||||||
|
'http://localhost:3001/api/v1/me/encryption-vault/key',
|
||||||
|
'http://localhost:3001/api/v1/me/encryption-vault/init',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call /init on a generic 404 without the code', async () => {
|
||||||
|
mockFetch(() => new Response(JSON.stringify({ error: 'not found' }), { status: 404 }));
|
||||||
|
|
||||||
|
const client = createVaultClient({
|
||||||
|
authUrl: 'http://localhost:3001',
|
||||||
|
getToken: () => 'test-jwt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state.status).toBe('error');
|
||||||
|
// Only the /key call happened — no /init follow-up
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createVaultClient.unlock — error categorisation', () => {
|
||||||
|
it('reports auth error on 401', async () => {
|
||||||
|
mockFetch(() => new Response('', { status: 401 }));
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'error', reason: 'auth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports auth error on 403', async () => {
|
||||||
|
mockFetch(() => new Response('', { status: 403 }));
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'error', reason: 'auth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports network error when fetch throws', async () => {
|
||||||
|
mockFetch(() => {
|
||||||
|
throw new Error('Failed to fetch');
|
||||||
|
});
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'error', reason: 'network' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports auth error when no token is available', async () => {
|
||||||
|
mockFetch(() => new Response('', { status: 200 }));
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => null });
|
||||||
|
const state = await client.unlock();
|
||||||
|
expect(state).toEqual({ status: 'error', reason: 'auth' });
|
||||||
|
// No fetch call should have happened
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createVaultClient.lock', () => {
|
||||||
|
it('clears the active key', async () => {
|
||||||
|
const masterKey = await freshKeyBase64();
|
||||||
|
mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 }));
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
await client.unlock();
|
||||||
|
expect(isVaultUnlocked()).toBe(true);
|
||||||
|
|
||||||
|
client.lock();
|
||||||
|
expect(isVaultUnlocked()).toBe(false);
|
||||||
|
expect(getActiveKey()).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createVaultClient.refetch', () => {
|
||||||
|
it('clears and re-fetches the master key', async () => {
|
||||||
|
const k1 = await freshKeyBase64();
|
||||||
|
const k2 = await freshKeyBase64();
|
||||||
|
let call = 0;
|
||||||
|
mockFetch(() => {
|
||||||
|
const body = call++ === 0 ? k1 : k2;
|
||||||
|
return new Response(JSON.stringify({ masterKey: body }), { status: 200 });
|
||||||
|
});
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
await client.unlock();
|
||||||
|
const before = getActiveKey();
|
||||||
|
await client.refetch();
|
||||||
|
const after = getActiveKey();
|
||||||
|
expect(before).not.toBe(null);
|
||||||
|
expect(after).not.toBe(null);
|
||||||
|
// Two underlying fetches happened
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createVaultClient.rotate', () => {
|
||||||
|
it('POSTs /rotate and installs the new key', async () => {
|
||||||
|
const masterKey = await freshKeyBase64();
|
||||||
|
const calls: { url: string; method?: string }[] = [];
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
calls.push({ url, method: init?.method });
|
||||||
|
return new Response(JSON.stringify({ masterKey }), { status: 200 });
|
||||||
|
});
|
||||||
|
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
|
||||||
|
const state = await client.rotate();
|
||||||
|
expect(state).toEqual({ status: 'unlocked' });
|
||||||
|
expect(calls[0].method).toBe('POST');
|
||||||
|
expect(calls[0].url).toContain('/rotate');
|
||||||
|
});
|
||||||
|
});
|
||||||
258
apps/mana/apps/web/src/lib/data/crypto/vault-client.ts
Normal file
258
apps/mana/apps/web/src/lib/data/crypto/vault-client.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Browser-side client for the mana-auth encryption vault.
|
||||||
|
*
|
||||||
|
* Talks to the three endpoints introduced in Phase 2:
|
||||||
|
* POST /api/v1/me/encryption-vault/init — bootstrap a fresh master key
|
||||||
|
* GET /api/v1/me/encryption-vault/key — fetch the active master key
|
||||||
|
* POST /api/v1/me/encryption-vault/rotate — destructive rotation
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - `unlock(token)` is the standard login path. Calls GET /key first; if
|
||||||
|
* the server returns 404 (`VAULT_NOT_INITIALISED`), automatically
|
||||||
|
* follows up with POST /init so the user is bootstrapped on first
|
||||||
|
* login per device. The recovered raw bytes are imported into a
|
||||||
|
* non-extractable CryptoKey and pushed into the active KeyProvider.
|
||||||
|
* - `lock()` clears the in-memory key. Called from the layout on logout.
|
||||||
|
* - All network calls go through a small retry loop (3 attempts,
|
||||||
|
* exponential backoff with jitter) so transient 5xx / network blips
|
||||||
|
* don't immediately leave the vault locked. 4xx is treated as
|
||||||
|
* permanent and surfaces immediately.
|
||||||
|
*
|
||||||
|
* Read-only mode on persistent failure:
|
||||||
|
* If the vault cannot be unlocked after retries, the active key
|
||||||
|
* provider stays locked. Sync-tracked tables that are flagged
|
||||||
|
* `enabled: true` in the registry will refuse to write (the
|
||||||
|
* wrapValue helper throws on a missing key) — caller code surfaces
|
||||||
|
* that as a "vault locked" toast. Plaintext tables continue to work.
|
||||||
|
*
|
||||||
|
* NOT in this module:
|
||||||
|
* - Token acquisition. The caller passes the JWT in. The vault client
|
||||||
|
* stays decoupled from the auth store so it can be tested with a
|
||||||
|
* mock fetch.
|
||||||
|
* - The MemoryKeyProvider instance — wired in by the layout/boot path
|
||||||
|
* via setKeyProvider(). This module just talks to the configured
|
||||||
|
* provider via setActiveProvider() at construction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { importMasterKey } from './aes';
|
||||||
|
import { MemoryKeyProvider, setKeyProvider, getKeyProvider } from './key-provider';
|
||||||
|
|
||||||
|
const RETRY_MAX_ATTEMPTS = 3;
|
||||||
|
const RETRY_BASE_DELAY_MS = 500;
|
||||||
|
const RETRY_MAX_DELAY_MS = 8000;
|
||||||
|
|
||||||
|
/** HTTP status codes that warrant a retry — transient server / network. */
|
||||||
|
function isRetriableStatus(status: number): boolean {
|
||||||
|
return status === 0 || status === 408 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function backoffDelay(attempt: number): number {
|
||||||
|
const exp = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt);
|
||||||
|
return Math.floor(Math.random() * exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VaultUnlockState =
|
||||||
|
| { status: 'unlocked' }
|
||||||
|
| { status: 'locked' }
|
||||||
|
| { status: 'error'; reason: 'auth' | 'network' | 'server' | 'unknown' };
|
||||||
|
|
||||||
|
export interface VaultClientOptions {
|
||||||
|
/** Base URL of mana-auth, e.g. 'https://auth.mana.how'. */
|
||||||
|
authUrl: string;
|
||||||
|
/** Function returning the current JWT, or null if signed out. */
|
||||||
|
getToken: () => Promise<string | null> | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultClient {
|
||||||
|
/** Unlocks the in-memory key provider by fetching from the server.
|
||||||
|
* On first call per device, automatically initialises the vault. */
|
||||||
|
unlock(): Promise<VaultUnlockState>;
|
||||||
|
/** Clears the in-memory key — call on logout. */
|
||||||
|
lock(): void;
|
||||||
|
/** Forces a fresh fetch even if the provider is already unlocked.
|
||||||
|
* Used by the rotate flow + tests. */
|
||||||
|
refetch(): Promise<VaultUnlockState>;
|
||||||
|
/** Triggers POST /rotate. Caller is responsible for re-encryption. */
|
||||||
|
rotate(): Promise<VaultUnlockState>;
|
||||||
|
/** Current snapshot of the unlock state. */
|
||||||
|
getState(): VaultUnlockState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a vault client and installs a MemoryKeyProvider as the active
|
||||||
|
* provider. Idempotent: calling twice returns a fresh client but reuses
|
||||||
|
* the same provider, so the new client picks up an already-unlocked key.
|
||||||
|
*/
|
||||||
|
export function createVaultClient(options: VaultClientOptions): VaultClient {
|
||||||
|
const { authUrl, getToken } = options;
|
||||||
|
|
||||||
|
// Reuse the existing MemoryKeyProvider if one is already installed —
|
||||||
|
// otherwise create + register a fresh one.
|
||||||
|
let provider: MemoryKeyProvider;
|
||||||
|
const existing = getKeyProvider();
|
||||||
|
if (existing instanceof MemoryKeyProvider) {
|
||||||
|
provider = existing;
|
||||||
|
} else {
|
||||||
|
provider = new MemoryKeyProvider();
|
||||||
|
setKeyProvider(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: VaultUnlockState = provider.isUnlocked()
|
||||||
|
? { status: 'unlocked' }
|
||||||
|
: { status: 'locked' };
|
||||||
|
|
||||||
|
// ─── Internal: HTTP with retry ───────────────────────────
|
||||||
|
async function fetchVault(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit
|
||||||
|
): Promise<
|
||||||
|
| { ok: true; data: { masterKey: string } }
|
||||||
|
| { ok: false; status: number; body?: { error?: string; code?: string } }
|
||||||
|
> {
|
||||||
|
let lastStatus = 0;
|
||||||
|
for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${authUrl}${path}`, init);
|
||||||
|
} catch {
|
||||||
|
lastStatus = 0; // network error
|
||||||
|
if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { masterKey: string };
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastStatus = res.status;
|
||||||
|
if (!isRetriableStatus(res.status)) {
|
||||||
|
const body = await res.json().catch(() => undefined);
|
||||||
|
return { ok: false, status: res.status, body };
|
||||||
|
}
|
||||||
|
if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt));
|
||||||
|
}
|
||||||
|
return { ok: false, status: lastStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(token: string): RequestInit {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMasterKey(masterKeyB64: string): Promise<void> {
|
||||||
|
const raw = base64ToBytes(masterKeyB64);
|
||||||
|
const cryptoKey = await importMasterKey(raw);
|
||||||
|
provider.setKey(cryptoKey);
|
||||||
|
// Best-effort: zero the raw bytes once they're imported. Doesn't
|
||||||
|
// guarantee the JS heap is wiped (the underlying ArrayBuffer is
|
||||||
|
// owned by the GC), but at least our reference goes away.
|
||||||
|
raw.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function categorise(status: number): Promise<VaultUnlockState> {
|
||||||
|
if (status === 401 || status === 403) return { status: 'error', reason: 'auth' };
|
||||||
|
if (status === 0) return { status: 'error', reason: 'network' };
|
||||||
|
if (status >= 500) return { status: 'error', reason: 'server' };
|
||||||
|
return { status: 'error', reason: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public methods ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function unlock(): Promise<VaultUnlockState> {
|
||||||
|
if (provider.isUnlocked()) {
|
||||||
|
state = { status: 'unlocked' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) {
|
||||||
|
state = { status: 'error', reason: 'auth' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try GET /key first.
|
||||||
|
const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', {
|
||||||
|
method: 'GET',
|
||||||
|
...authHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fetchRes.ok) {
|
||||||
|
await applyMasterKey(fetchRes.data.masterKey);
|
||||||
|
state = { status: 'unlocked' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 with VAULT_NOT_INITIALISED → bootstrap by calling /init.
|
||||||
|
if (fetchRes.status === 404 && fetchRes.body?.code === 'VAULT_NOT_INITIALISED') {
|
||||||
|
const initRes = await fetchVault('/api/v1/me/encryption-vault/init', {
|
||||||
|
method: 'POST',
|
||||||
|
...authHeaders(token),
|
||||||
|
});
|
||||||
|
if (initRes.ok) {
|
||||||
|
await applyMasterKey(initRes.data.masterKey);
|
||||||
|
state = { status: 'unlocked' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state = await categorise(initRes.status);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = await categorise(fetchRes.status);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock(): void {
|
||||||
|
provider.setKey(null);
|
||||||
|
state = { status: 'locked' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refetch(): Promise<VaultUnlockState> {
|
||||||
|
provider.setKey(null);
|
||||||
|
return unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotate(): Promise<VaultUnlockState> {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) {
|
||||||
|
state = { status: 'error', reason: 'auth' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const res = await fetchVault('/api/v1/me/encryption-vault/rotate', {
|
||||||
|
method: 'POST',
|
||||||
|
...authHeaders(token),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await applyMasterKey(res.data.masterKey);
|
||||||
|
state = { status: 'unlocked' };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state = await categorise(res.status);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(): VaultUnlockState {
|
||||||
|
// Reconcile in case the provider was locked from somewhere else.
|
||||||
|
if (!provider.isUnlocked() && state.status === 'unlocked') {
|
||||||
|
state = { status: 'locked' };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unlock, lock, refetch, rotate, getState };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { setCurrentUserId } from '$lib/data/current-user';
|
import { setCurrentUserId } from '$lib/data/current-user';
|
||||||
import { migrateGuestDataToUser } from '$lib/data/guest-migration';
|
import { migrateGuestDataToUser } from '$lib/data/guest-migration';
|
||||||
import { installDataLayerListeners } from '$lib/data/data-layer-listeners';
|
import { installDataLayerListeners } from '$lib/data/data-layer-listeners';
|
||||||
|
import { createVaultClient, hasAnyEncryption } from '$lib/data/crypto';
|
||||||
|
import { getManaAuthUrl } from '$lib/api/config';
|
||||||
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
||||||
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
||||||
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
|
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
|
||||||
|
|
@ -19,6 +21,14 @@
|
||||||
// initialisation, which previously caused effect_update_depth_exceeded.
|
// initialisation, which previously caused effect_update_depth_exceeded.
|
||||||
let lastUserId: string | null | undefined = undefined;
|
let lastUserId: string | null | undefined = undefined;
|
||||||
|
|
||||||
|
// Vault client is constructed lazily on the first auth-state change so
|
||||||
|
// the import path stays free of side-effects during SSR. Reused across
|
||||||
|
// all subsequent unlock/lock calls.
|
||||||
|
const vaultClient = createVaultClient({
|
||||||
|
authUrl: getManaAuthUrl(),
|
||||||
|
getToken: () => authStore.getAccessToken(),
|
||||||
|
});
|
||||||
|
|
||||||
// Push the active user id into the data layer whenever auth state changes.
|
// Push the active user id into the data layer whenever auth state changes.
|
||||||
// The Dexie creating-hook reads this to auto-stamp `userId` on every record,
|
// The Dexie creating-hook reads this to auto-stamp `userId` on every record,
|
||||||
// so module stores never need to know who the current user is.
|
// so module stores never need to know who the current user is.
|
||||||
|
|
@ -40,6 +50,19 @@
|
||||||
console.error('[mana] guest → user migration failed:', err);
|
console.error('[mana] guest → user migration failed:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encryption vault: unlock when authenticated, lock when not.
|
||||||
|
// Skip the network round-trip entirely while no table is encrypted —
|
||||||
|
// hasAnyEncryption() flips to true once Phase 3 enables a pilot.
|
||||||
|
if (userId && hasAnyEncryption()) {
|
||||||
|
vaultClient.unlock().then((state) => {
|
||||||
|
if (state.status !== 'unlocked') {
|
||||||
|
console.warn('[mana] encryption vault unlock failed:', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (!userId) {
|
||||||
|
vaultClient.lock();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue