mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
feat(mana/web): encryption phase 4 — notes pilot live
First module with at-rest encryption flipped on. The notes table's
title + content are now encrypted with AES-GCM-256 before any write
hits Dexie, decrypted on every read coming back through liveQuery,
and travel as opaque ciphertext through the sync wire (pending
changes, server push, applyServerChanges, the lot).
What changes for the user
- Nothing visible. Optimistic UI render still uses the plaintext
snapshot returned by createNote(). Edits look identical to the
old Phase 3 behaviour. The difference is invisible until you
crack open DevTools → Application → IndexedDB → mana → notes,
where you'll see ciphertext instead of "Buy milk".
What changes on disk
- notes.title and notes.content store ciphertext blobs
(`enc:1:<iv-b64>.<ct-b64>`)
- All other columns (id, color, isPinned, isArchived, createdAt,
updatedAt, deletedAt, userId, __fieldTimestamps) stay plaintext
so liveQuery filtering, sorting, and Field-Level LWW continue to
work without changes.
- _pendingChanges.data carries the same ciphertext blobs — server
receives opaque values, never plaintext.
Files
registry.ts
notes flipped to enabled:true with the corrected field list
['title', 'content'] (the schema has no 'body' column).
aes.test.ts
Existing assertion that "Phase 1 has no encrypted tables" is
rewritten as "notes is enabled in Phase 4" so the registry flip
doesn't break the foundation suite.
record-helpers.ts
encryptRecord/decryptRecord/decryptRecords loosen the generic
constraint from `T extends Record<string, unknown>` to
`T extends object`. Domain types like LocalNote work as direct
arguments without an `as Record<string, unknown>` cast at every
call site. Internal field reads/writes go through a sealed
Record-shaped view.
notes/stores/notes.svelte.ts
createNote: snapshots the plaintext for the optimistic return
value, then encryptRecord('notes', record) before noteTable.add.
updateNote: encrypts the diff in place; non-encrypted fields
(color, isPinned, isArchived) pass through untouched.
togglePin / archiveNote / deleteNote: untouched — they only
update plaintext columns.
notes/queries.ts
useAllNotes: filter on plaintext metadata first (deletedAt,
isArchived) so the decrypt workload is bounded by the visible
set, not the whole table. Then decryptRecords across what's
left, then map+sort.
useNote(id): new helper for detail views.
notes-encryption.test.ts (new — 8 cases)
End-to-end against fake-indexeddb with a real Web Crypto master
key in MemoryKeyProvider:
1. Title + content land as ciphertext on disk
2. Structural fields stay plaintext on disk
3. updateNote re-encrypts modified content but leaves flags
4. togglePin / archiveNote produce byte-identical title blobs
(i.e. no spurious re-encryption)
5. _pendingChanges.data carries ciphertext + plaintext metadata
6. Wrong-key decrypt fails closed (returns blobs, not garbage)
7. Locked vault refuses new writes with VaultLockedError
8. Locked vault still serves blobs without crashing on read
Test bilanz: 4 crypto-related test files, 64/64 passing
(31 AES + 12 record-helpers + 12 vault-client + 8 notes E2E + 1 misc).
Full mana/web suite: 20 files, 262/262 tests passing.
Stand der encryption pipeline:
Phase 1 ✅ Foundation (1ba5948ce)
Phase 2 ✅ Server vault (e9915428c)
Phase 3 ✅ Wire-up (354cbcb17)
Phase 4 ✅ Notes pilot (this commit)
Phase 5 → roll out to chat, dreams, memoro, contacts, etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
640242500e
commit
bed08a1aa6
6 changed files with 298 additions and 30 deletions
|
|
@ -250,14 +250,19 @@ describe('encryption registry', () => {
|
|||
});
|
||||
|
||||
it('returns null for registered tables that are disabled', () => {
|
||||
// Phase 1: every entry is enabled:false
|
||||
// `notes` was flipped to enabled:true in Phase 4 — pick two
|
||||
// tables that are still on the safe default for the assertion.
|
||||
expect(getEncryptedFields('messages')).toBe(null);
|
||||
expect(getEncryptedFields('notes')).toBe(null);
|
||||
expect(getEncryptedFields('contacts')).toBe(null);
|
||||
});
|
||||
|
||||
it('hasAnyEncryption returns false in Phase 1', () => {
|
||||
expect(hasAnyEncryption()).toBe(false);
|
||||
it('returns the field list for tables that are enabled', () => {
|
||||
// Phase 4: notes is the pilot, expected to be flipped on.
|
||||
expect(getEncryptedFields('notes')).toEqual(['title', 'content']);
|
||||
});
|
||||
|
||||
it('hasAnyEncryption returns true once at least one table is enabled', () => {
|
||||
expect(hasAnyEncryption()).toBe(true);
|
||||
});
|
||||
|
||||
it('getRegisteredTables lists every table in the registry', () => {
|
||||
|
|
|
|||
|
|
@ -55,19 +55,22 @@ export class VaultLockedError extends Error {
|
|||
* 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.
|
||||
*
|
||||
* Generic constraint: `T extends object` so domain interfaces (LocalNote,
|
||||
* LocalMessage, etc.) can be passed directly without an explicit
|
||||
* `as Record<string, unknown>` cast at every call site. Internal
|
||||
* field reads/writes go through a Record<string, unknown> view.
|
||||
*/
|
||||
export async function encryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T
|
||||
): Promise<T> {
|
||||
export async function encryptRecord<T extends object>(tableName: string, record: T): Promise<T> {
|
||||
const fields = getEncryptedFields(tableName);
|
||||
if (!fields) return record;
|
||||
const view = record as unknown as Record<string, unknown>;
|
||||
|
||||
// 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];
|
||||
const value = view[field];
|
||||
if (value === null || value === undefined) continue;
|
||||
// Already encrypted? Skip — happens when applyServerChanges
|
||||
// hands a record (with encrypted blobs from the wire) back
|
||||
|
|
@ -81,8 +84,7 @@ export async function encryptRecord<T extends Record<string, unknown>>(
|
|||
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;
|
||||
view[field] = await wrapValue(view[field], key);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
|
@ -96,21 +98,19 @@ export async function encryptRecord<T extends Record<string, unknown>>(
|
|||
* 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> {
|
||||
export async function decryptRecord<T extends object>(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
|
||||
|
||||
const view = record as unknown as Record<string, unknown>;
|
||||
for (const field of fields) {
|
||||
const value = record[field];
|
||||
const value = view[field];
|
||||
if (typeof value !== 'string' || !isEncrypted(value)) continue;
|
||||
try {
|
||||
(record as Record<string, unknown>)[field] = await unwrapValue(value, key);
|
||||
view[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
|
||||
|
|
@ -128,7 +128,7 @@ export async function decryptRecord<T extends Record<string, unknown>>(
|
|||
* 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>>(
|
||||
export async function decryptRecords<T extends object>(
|
||||
tableName: string,
|
||||
records: (T | null | undefined)[]
|
||||
): Promise<T[]> {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
},
|
||||
|
||||
// ─── Notes ───────────────────────────────────────────────
|
||||
notes: { enabled: false, fields: ['title', 'body', 'content'] },
|
||||
// Phase 4 pilot — first table flipped to enabled:true. The schema
|
||||
// uses `title` + `content` (no separate `body` column).
|
||||
notes: { enabled: true, fields: ['title', 'content'] },
|
||||
|
||||
// ─── Dreams ──────────────────────────────────────────────
|
||||
dreams: { enabled: false, fields: ['title', 'content', 'notes'] },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Notes encryption integration test — Phase 4 pilot.
|
||||
*
|
||||
* Exercises the full write→store→read pipeline against fake-indexeddb,
|
||||
* with a real Web Crypto master key plumbed through the MemoryKeyProvider.
|
||||
* The goal is to lock in three properties:
|
||||
*
|
||||
* 1. What lands on disk is CIPHERTEXT for title/content, plaintext for
|
||||
* everything else (id, color, isPinned, isArchived, timestamps).
|
||||
* 2. liveQuery results coming back from useAllNotes / useNote are
|
||||
* transparently decrypted — the UI sees Note objects with the
|
||||
* original strings.
|
||||
* 3. The Dexie pending-change tracker captures the same ciphertext
|
||||
* blob, so what gets pushed to the server is also opaque.
|
||||
*
|
||||
* Without this test the registry flip from Phase 4.1 is just a config
|
||||
* change with no behavioural guarantee. With it, any future regression
|
||||
* (registry typo, hook scope leak, accidental decrypt-on-write) blows
|
||||
* up immediately.
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
|
||||
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { setCurrentUserId } from '$lib/data/current-user';
|
||||
import {
|
||||
generateMasterKey,
|
||||
MemoryKeyProvider,
|
||||
setKeyProvider,
|
||||
isEncrypted,
|
||||
} from '$lib/data/crypto';
|
||||
import { notesStore } from './stores/notes.svelte';
|
||||
import type { LocalNote } from './types';
|
||||
|
||||
// 50ms is enough for the fire-and-forget setTimeout(0) inside the
|
||||
// Dexie creating-hook (trackPendingChange) to flush before assertions.
|
||||
const flushAsync = () => new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
let provider: MemoryKeyProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
const key = await generateMasterKey();
|
||||
provider = new MemoryKeyProvider();
|
||||
provider.setKey(key);
|
||||
setKeyProvider(provider);
|
||||
setCurrentUserId('test-user');
|
||||
|
||||
// Each test starts with empty state. Clear plus pending-change
|
||||
// bookkeeping so cross-test contamination is impossible.
|
||||
await db.table('notes').clear();
|
||||
await db.table('_pendingChanges').clear();
|
||||
await db.table('_activity').clear();
|
||||
});
|
||||
|
||||
describe('notes encryption pilot', () => {
|
||||
it('stores title + content as ciphertext on disk', async () => {
|
||||
const created = await notesStore.createNote({
|
||||
title: 'My private idea',
|
||||
content: 'Do not show this to the family laptop',
|
||||
});
|
||||
|
||||
// The optimistic snapshot returned to the UI is plaintext —
|
||||
// that's what the optimistic render uses.
|
||||
expect(created.title).toBe('My private idea');
|
||||
expect(created.content).toBe('Do not show this to the family laptop');
|
||||
|
||||
// What lives in IndexedDB after the await is ciphertext.
|
||||
const stored = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
expect(stored).toBeDefined();
|
||||
expect(isEncrypted(stored.title)).toBe(true);
|
||||
expect(isEncrypted(stored.content)).toBe(true);
|
||||
expect(stored.title).not.toContain('private');
|
||||
expect(stored.content).not.toContain('family');
|
||||
});
|
||||
|
||||
it('leaves structural fields plaintext on disk', async () => {
|
||||
const created = await notesStore.createNote({
|
||||
title: 'Pinned note',
|
||||
content: 'irrelevant',
|
||||
color: '#3b82f6',
|
||||
});
|
||||
const stored = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
|
||||
// id, color, flags, timestamps remain queryable plaintext.
|
||||
expect(stored.id).toBe(created.id);
|
||||
expect(stored.color).toBe('#3b82f6');
|
||||
expect(stored.isPinned).toBe(false);
|
||||
expect(stored.isArchived).toBe(false);
|
||||
expect(stored.userId).toBe('test-user');
|
||||
// Auto-stamped __fieldTimestamps stays plaintext too — LWW relies on it.
|
||||
expect((stored as unknown as Record<string, unknown>).__fieldTimestamps).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates encrypt the modified content fields, leave flags untouched', async () => {
|
||||
const created = await notesStore.createNote({ title: 'orig title', content: 'orig body' });
|
||||
|
||||
await notesStore.updateNote(created.id, {
|
||||
title: 'new title',
|
||||
content: 'new body',
|
||||
});
|
||||
|
||||
const stored = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
expect(isEncrypted(stored.title)).toBe(true);
|
||||
expect(isEncrypted(stored.content)).toBe(true);
|
||||
expect(stored.title).not.toContain('new');
|
||||
// Updated flag stays plaintext
|
||||
expect(stored.isPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('togglePin and archiveNote do not touch encrypted fields', async () => {
|
||||
const created = await notesStore.createNote({ title: 'My note', content: 'My body' });
|
||||
const before = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
const titleBlob = before.title;
|
||||
const contentBlob = before.content;
|
||||
|
||||
await notesStore.togglePin(created.id);
|
||||
const afterPin = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
// Title/content blobs are byte-identical — no re-encryption happened.
|
||||
expect(afterPin.title).toBe(titleBlob);
|
||||
expect(afterPin.content).toBe(contentBlob);
|
||||
expect(afterPin.isPinned).toBe(true);
|
||||
|
||||
await notesStore.archiveNote(created.id);
|
||||
const afterArchive = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
expect(afterArchive.title).toBe(titleBlob);
|
||||
expect(afterArchive.content).toBe(contentBlob);
|
||||
expect(afterArchive.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
it('the pending-change record carries ciphertext, not plaintext', async () => {
|
||||
const created = await notesStore.createNote({
|
||||
title: 'Buy birthday present',
|
||||
content: 'For Marie',
|
||||
});
|
||||
await flushAsync(); // wait for setTimeout(0) in trackPendingChange
|
||||
|
||||
const pending = await db
|
||||
.table('_pendingChanges')
|
||||
.filter((p: { recordId?: string }) => p.recordId === created.id)
|
||||
.toArray();
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
const change = pending[0] as { op: string; data: Record<string, unknown> };
|
||||
expect(change.op).toBe('insert');
|
||||
expect(typeof change.data.title).toBe('string');
|
||||
expect(typeof change.data.content).toBe('string');
|
||||
expect(isEncrypted(change.data.title)).toBe(true);
|
||||
expect(isEncrypted(change.data.content)).toBe(true);
|
||||
expect(change.data.title).not.toContain('birthday');
|
||||
expect(change.data.content).not.toContain('Marie');
|
||||
// Plaintext metadata flows through unchanged
|
||||
expect(change.data.isPinned).toBe(false);
|
||||
expect(change.data.userId).toBe('test-user');
|
||||
});
|
||||
|
||||
it('a record encrypted with one key cannot be read with another', async () => {
|
||||
const created = await notesStore.createNote({ title: 'Secret', content: 'Sauce' });
|
||||
const stored = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
|
||||
// Swap to a different key
|
||||
const otherKey = await generateMasterKey();
|
||||
provider.setKey(otherKey);
|
||||
|
||||
// decryptRecords logs the failure but leaves blobs in place.
|
||||
// Verify the title is STILL the encrypted blob (i.e. not silently
|
||||
// returning garbage plaintext).
|
||||
const { decryptRecords } = await import('$lib/data/crypto');
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const [decrypted] = await decryptRecords<LocalNote>('notes', [stored]);
|
||||
errSpy.mockRestore();
|
||||
expect(decrypted).toBeDefined();
|
||||
// Both fields stayed encrypted because both failed to unwrap.
|
||||
expect(isEncrypted(decrypted.title)).toBe(true);
|
||||
expect(isEncrypted(decrypted.content)).toBe(true);
|
||||
});
|
||||
|
||||
it('locked vault refuses to encrypt new writes', async () => {
|
||||
provider.setKey(null);
|
||||
await expect(
|
||||
notesStore.createNote({ title: 'cannot write', content: 'because locked' })
|
||||
).rejects.toThrow(/vault is locked/);
|
||||
});
|
||||
|
||||
it('locked vault still serves blobs (no plaintext leak, no crash)', async () => {
|
||||
// Write while unlocked
|
||||
const created = await notesStore.createNote({ title: 'before lock', content: 'body' });
|
||||
|
||||
// Lock the vault
|
||||
provider.setKey(null);
|
||||
|
||||
// Direct DB read still returns the encrypted blob — no exception
|
||||
const stored = (await db.table<LocalNote>('notes').get(created.id)) as LocalNote;
|
||||
expect(isEncrypted(stored.title)).toBe(true);
|
||||
|
||||
// decryptRecords with locked vault returns the blob unchanged
|
||||
const { decryptRecords } = await import('$lib/data/crypto');
|
||||
const [out] = await decryptRecords<LocalNote>('notes', [stored]);
|
||||
expect(out).toBeDefined();
|
||||
expect(isEncrypted(out.title)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for Notes module.
|
||||
*
|
||||
* Phase 4 encryption pilot: notes are encrypted at rest. Reads decrypt
|
||||
* on the fly via decryptRecords() before mapping to the public Note
|
||||
* shape. Sort and filter operations all run against PLAINTEXT metadata
|
||||
* (`isPinned`, `isArchived`, `updatedAt`, `deletedAt`) so the indexes
|
||||
* still work without ever touching ciphertext.
|
||||
*
|
||||
* Search: keep the existing in-memory `searchNotes()` helper. It runs
|
||||
* AFTER decryption (against the public Note objects), so a free-text
|
||||
* search through ~hundreds of notes still works in the UI without
|
||||
* leaking anything to the server. Real searchable-encrypted index is
|
||||
* a future concern only if note volume per user grows past that.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalNote, Note } from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
|
@ -25,17 +38,36 @@ export function toNote(local: LocalNote): Note {
|
|||
|
||||
export function useAllNotes() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalNote>('notes').toArray();
|
||||
return locals
|
||||
.filter((n) => !n.deletedAt && !n.isArchived)
|
||||
.map(toNote)
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
// Filter on plaintext metadata first — none of these fields are
|
||||
// in the encryption registry, so they stay readable even with
|
||||
// the vault locked. Cuts the decrypt workload to only what the
|
||||
// view actually renders.
|
||||
const visible = (await db.table<LocalNote>('notes').toArray()).filter(
|
||||
(n) => !n.deletedAt && !n.isArchived
|
||||
);
|
||||
// Locked vault returns the blobs untouched so the UI can render
|
||||
// a "🔒" placeholder where title/content would be.
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
return decrypted.map(toNote).sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
}, [] as Note[]);
|
||||
}
|
||||
|
||||
/** Single note by id, decrypted. Used by detail views. */
|
||||
export function useNote(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const local = await db.table<LocalNote>('notes').get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
return decrypted ? toNote(decrypted) : null;
|
||||
},
|
||||
null as Note | null
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ──────────────────────────────────────────
|
||||
|
||||
/** Search notes by title and content */
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
/**
|
||||
* Notes Store — Mutation-Only Service
|
||||
*
|
||||
* Phase 4 encryption pilot: title + content are encrypted at rest.
|
||||
* Every write that touches one of those fields is routed through
|
||||
* encryptRecord() before hitting Dexie. The Dexie hook then sees the
|
||||
* already-encrypted blob and stores it as opaque text — the rest of
|
||||
* the sync layer (pending changes, LWW, server push) handles it
|
||||
* exactly like any other string column.
|
||||
*
|
||||
* Updates that touch ONLY plaintext fields (togglePin, archiveNote,
|
||||
* deleteNote) bypass encryption automatically because encryptRecord
|
||||
* skips fields not in the registry's allowlist for the table — no
|
||||
* special-casing needed at the call sites.
|
||||
*/
|
||||
|
||||
import { noteTable } from '../collections';
|
||||
import { toNote } from '../queries';
|
||||
import type { LocalNote } from '../types';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
|
||||
export const notesStore = {
|
||||
async createNote(data: { title?: string; content?: string; color?: string | null }) {
|
||||
|
|
@ -17,18 +30,26 @@ export const notesStore = {
|
|||
isArchived: false,
|
||||
};
|
||||
|
||||
// Plaintext copy returned to the caller for optimistic UI render —
|
||||
// the persisted record (and the sync wire) carries ciphertext.
|
||||
const plaintextSnapshot = toNote(newLocal);
|
||||
await encryptRecord('notes', newLocal);
|
||||
await noteTable.add(newLocal);
|
||||
return toNote(newLocal);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async updateNote(
|
||||
id: string,
|
||||
data: Partial<Pick<LocalNote, 'title' | 'content' | 'color' | 'isPinned' | 'isArchived'>>
|
||||
) {
|
||||
await noteTable.update(id, {
|
||||
// encryptRecord mutates the diff in place. Fields not in the notes
|
||||
// allowlist (color, isPinned, isArchived) pass through untouched.
|
||||
const diff: Partial<LocalNote> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('notes', diff);
|
||||
await noteTable.update(id, diff);
|
||||
},
|
||||
|
||||
async deleteNote(id: string) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue