From d3a1f0007233b99fc9ad1f0df642d48faa595037 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 17:41:00 +0200 Subject: [PATCH] feat(crypto): roll places + locationLogs into the encryption registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 follow-up. Places carries GDPR-sensitive PII so it gets the same treatment as the rest of Phase 7+8, with one deliberate carve-out: - `places` encrypts the user-typed surface (name / description / address) but leaves lat/lng plaintext so the proximity matcher in tracking.svelte.ts can run during background geolocation logging without a vault unlock. The trade-off is documented inline in registry.ts: a handful of named POIs is much less sensitive than the full movement trail. - `locationLogs` IS the movement trail, so every coordinate field (latitude, longitude, accuracy, altitude, speed, heading) is encrypted. Indexed columns (timestamp, placeId, [placeId+timestamp]) stay plaintext for the time-range scans in the log view. - `placeTags` stays out of the registry — pure FK join table, no user content, same pattern as manaLinks. queries.useAllPlaces / useLocationLogs now decrypt before mapping to the DTO. placesStore.create/update snapshot the plaintext DTO before encryptRecord mutates the local in place — same pattern as notes/dreams/contacts. trackingStore.logPosition decrypts the place set before running the nearest-place match (the lat/lng carve-out means this still works pre-unlock, but downstream consumers want the decrypted name). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/registry.ts | 23 +++++++++++++++++++ .../web/src/lib/modules/places/queries.ts | 8 +++++-- .../modules/places/stores/places.svelte.ts | 16 ++++++++++--- .../modules/places/stores/tracking.svelte.ts | 11 +++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) 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