diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 7cd7ed18f..087346b8e 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -241,6 +241,29 @@ export const ENCRYPTION_REGISTRY: Record = { // different storage layout. invItems: { enabled: true, fields: ['description'] }, + // ─── Places ────────────────────────────────────────────── + // Location data is GDPR-sensitive PII. The split between the two tables: + // - `places` holds user-named POIs. We encrypt the user-typed text + // (name/description/address) but leave lat/lng plaintext so the + // proximity matcher in tracking.svelte.ts can run without a vault + // unlock during background geolocation logging. lat/lng on a + // handful of saved POIs is far less sensitive than the full + // movement trail in locationLogs below. + // - `locationLogs` IS the movement trail — every coordinate gets + // encrypted. Indexed columns (timestamp, placeId, [placeId+timestamp]) + // stay plaintext for the time-range scans in the log view. + // `name` on `places` IS schema-indexed but no .where('name') call site + // exists (search filters in JS over the decrypted DTO array) — same + // rationale as files.name and plants.name above. + places: { enabled: true, fields: ['name', 'description', 'address'] }, + locationLogs: { + enabled: true, + fields: ['latitude', 'longitude', 'accuracy', 'altitude', 'speed', 'heading'], + }, + // `placeTags` is intentionally NOT in the registry — pure foreign-key + // join table (placeId / tagId), zero user-typed content. Same pattern + // as manaLinks. + // ─── TimeBlocks (cross-module hub) ─────────────────────── // Phase 7.1: encrypted alongside tasks + calendar.events + habits // because the consumer modules denormalize their title/description diff --git a/apps/mana/apps/web/src/lib/modules/places/queries.ts b/apps/mana/apps/web/src/lib/modules/places/queries.ts index 5f0bc09e8..8147a0913 100644 --- a/apps/mana/apps/web/src/lib/modules/places/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/places/queries.ts @@ -4,6 +4,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalPlace, LocalLocationLog, Place, LocationLog } from './types'; // ─── Type Converters ───────────────────────────────────── @@ -46,7 +47,9 @@ export function toLocationLog(local: LocalLocationLog): LocationLog { export function useAllPlaces() { return liveQuery(async () => { const locals = await db.table('places').toArray(); - return locals.filter((p) => !p.deletedAt).map(toPlace); + const visible = locals.filter((p) => !p.deletedAt); + const decrypted = await decryptRecords('places', visible); + return decrypted.map(toPlace); }); } @@ -55,7 +58,8 @@ export function useLocationLogs(placeId?: string) { let query = db.table('locationLogs').orderBy('timestamp').reverse(); const locals = await query.toArray(); const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals; - return filtered.map(toLocationLog); + const decrypted = await decryptRecords('locationLogs', filtered); + return decrypted.map(toLocationLog); }); } diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts index 719d83653..7261d73da 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -5,6 +5,7 @@ * This store only exposes mutations that write to IndexedDB. */ +import { encryptRecord } from '$lib/data/crypto'; import { placeTable } from '../collections'; import { toPlace } from '../queries'; import type { LocalPlace, Place, PlaceCategory } from '../types'; @@ -34,8 +35,12 @@ export const placesStore = { updatedAt: now, }; + // Snapshot the plaintext DTO before encryption mutates the record + // in place — same pattern as the notes/dreams/contacts stores. + const plaintextSnapshot = toPlace({ ...newLocal }); + await encryptRecord('places', newLocal); await placeTable.add(newLocal); - return toPlace(newLocal); + return plaintextSnapshot; }, async updatePlace(id: string, data: Partial & Record) { @@ -49,10 +54,15 @@ export const placesStore = { if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite; if (data.isArchived !== undefined) updateData.isArchived = data.isArchived; - await placeTable.update(id, { + const diff = { ...updateData, updatedAt: new Date().toISOString(), - }); + }; + // encryptRecord mutates the diff in place. Fields not in the + // places allowlist (lat/lng, isFavorite, isArchived, …) pass + // through untouched. + await encryptRecord('places', diff); + await placeTable.update(id, diff); }, async deletePlace(id: string) { diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts index f8883f98d..ac1f937ea 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -5,6 +5,7 @@ * entries to IndexedDB. Also detects proximity to known places. */ +import { decryptRecords, encryptRecord } from '$lib/data/crypto'; import { locationLogTable, placeTable } from '../collections'; import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; import type { LocalLocationLog, LocalPlace } from '../types'; @@ -106,9 +107,14 @@ async function logPosition(pos: GeolocationPosition) { const lat = pos.coords.latitude; const lng = pos.coords.longitude; - // Check proximity to known places + // Check proximity to known places. lat/lng on `places` stay plaintext + // (see registry.ts) so the proximity matcher works during background + // geolocation logging even before the vault is unlocked. We still + // decrypt so that nearest.name etc. is usable downstream. const allLocals = await placeTable.toArray(); - const places = allLocals.filter((p) => !p.deletedAt).map(toPlace); + const visible = allLocals.filter((p) => !p.deletedAt); + const decrypted = await decryptRecords('places', visible); + const places = decrypted.map(toPlace); const nearest = findNearestPlace(places, lat, lng); const log: LocalLocationLog = { @@ -125,6 +131,7 @@ async function logPosition(pos: GeolocationPosition) { updatedAt: new Date().toISOString(), }; + await encryptRecord('locationLogs', log); await locationLogTable.add(log); // Update visit count on the matched place