feat(crypto): roll places + locationLogs into the encryption registry

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 17:41:00 +02:00
parent e8de377cfe
commit d3a1f00072
4 changed files with 51 additions and 7 deletions

View file

@ -241,6 +241,29 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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

View file

@ -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<LocalPlace>('places').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlace);
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords<LocalPlace>('places', visible);
return decrypted.map(toPlace);
});
}
@ -55,7 +58,8 @@ export function useLocationLogs(placeId?: string) {
let query = db.table<LocalLocationLog>('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<LocalLocationLog>('locationLogs', filtered);
return decrypted.map(toLocationLog);
});
}

View file

@ -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<Place> & Record<string, unknown>) {
@ -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) {

View file

@ -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<LocalPlace>('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