mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
e8de377cfe
commit
d3a1f00072
4 changed files with 51 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue