From 6d8637b83731e5b269d43a3fec26e674d959d20e Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 18:26:01 +0200 Subject: [PATCH] feat(spaces): migrate todo/notes/contacts to scoped-db + add scopedGet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more modules now use the scope wrapper. Pattern matches the calendar pilot: db.table('X').toArray() → scopedForModule('mod','X').toArray() db.table('X').orderBy('k').toArray → scopedForModule(...).sortBy('k') db.table('X').get(id) → scopedGet('X', id) Added scopedGet() to the scope barrel — a primary-key fetch with a post-read scope check so URL-manipulated deep links can't peek at records from another space. Dexie's fast-path index read still happens; the scope check is one field comparison on the single row. Modules migrated: - todo/queries.ts: useAllTasks, useAllBoardViews, useAllReminders, useAllProjects (4 queries; sortBy replaces orderBy-via-index) - notes/queries.ts: useAllNotes (list), useNote (by id via scopedGet) - contacts/queries.ts: useAllContacts goals module lives in companion/goals with a different layout (not a standard modules/*/queries.ts) — skipped this pass, will migrate in a targeted follow-up. Scope + visibility filters run BEFORE decrypt where possible so the vault-locked UI path stays cheap: plaintext spaceId + visibility + deletedAt metadata filters the decrypt workload before crypto gets invoked. Performance note: sortBy() is an in-memory O(n) sort. Fine for a user's task list, but if a hot path surfaces (e.g. a thousands-of-tasks view), we add a [spaceId+order] compound index in a follow-up Dexie version. Plan: docs/plans/spaces-foundation.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mana/apps/web/src/lib/data/scope/index.ts | 1 + .../apps/web/src/lib/data/scope/scoped-db.ts | 20 ++++++++++++++++ .../web/src/lib/modules/contacts/queries.ts | 6 ++--- .../apps/web/src/lib/modules/notes/queries.ts | 13 ++++++---- .../apps/web/src/lib/modules/todo/queries.ts | 24 ++++++++++++------- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts index e0ff3594a..bbaaca81e 100644 --- a/apps/mana/apps/web/src/lib/data/scope/index.ts +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -24,6 +24,7 @@ export { reconcileSentinels, personalSpaceSentinel } from './bootstrap'; export { scopedTable, scopedForModule, + scopedGet, assertModuleAllowed, getInScopeSpaceIds, ScopeNotReadyError, diff --git a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts index 355642a2b..43eb6cd66 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -105,3 +105,23 @@ export function scopedForModule( assertModuleAllowed(moduleId); return scopedTable(tableName); } + +/** + * Read a single record by primary key with a scope check. Returns undefined + * if the record doesn't exist OR if its spaceId isn't in the current + * in-scope set — i.e. the user manipulated a URL parameter and tried to + * peek at a record from a space they don't have active. + * + * Uses the Dexie primary-key fast path under the hood; the scope check + * is a single field comparison on the one row returned. + */ +export async function scopedGet(tableName: string, id: string | number): Promise { + const record = (await db.table(tableName).get(id)) as T | undefined; + if (!record) return undefined; + const rec = record as { spaceId?: unknown }; + const ids = getInScopeSpaceIds(); + if (typeof rec.spaceId !== 'string' || !ids.includes(rec.spaceId)) { + return undefined; + } + return record; +} diff --git a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts index 46edcda80..7cc942692 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts @@ -4,6 +4,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { scopedForModule, applyVisibility } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte'; import { contactTagOps } from './stores/tags.svelte'; @@ -53,9 +54,8 @@ export function toContact(local: LocalContact): Contact { export function useAllContacts() { return useLiveQueryWithDefault(async () => { - const visible = (await db.table('contacts').toArray()).filter( - (c) => !c.deletedAt - ); + const raw = await scopedForModule('contacts', 'contacts').toArray(); + const visible = applyVisibility(raw).filter((c) => !c.deletedAt); const decrypted = await decryptRecords('contacts', visible); const tagMap = await contactTagOps.getTagIdsForMany(decrypted.map((c) => c.id)); const scoped = filterBySceneScopeBatch(decrypted, (c) => c.id, tagMap); diff --git a/apps/mana/apps/web/src/lib/modules/notes/queries.ts b/apps/mana/apps/web/src/lib/modules/notes/queries.ts index 228c5f371..93f540e09 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/queries.ts @@ -16,6 +16,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { scopedForModule, scopedGet, applyVisibility } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte'; import { noteTagOps } from './stores/tags.svelte'; @@ -44,10 +45,10 @@ export function useAllNotes() { // 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('notes').toArray()).filter( - (n) => !n.deletedAt && !n.isArchived - ); + // view actually renders. Scope + visibility filters run before + // decrypt for the same reason. + const raw = await scopedForModule('notes', 'notes').toArray(); + const visible = applyVisibility(raw).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); @@ -64,7 +65,9 @@ export function useAllNotes() { export function useNote(id: string) { return useLiveQueryWithDefault( async () => { - const local = await db.table('notes').get(id); + // scopedGet returns undefined if the note belongs to another + // space — protects against URL-manipulated deep links. + const local = await scopedGet('notes', id); if (!local || local.deletedAt) return null; const [decrypted] = await decryptRecords('notes', [local]); return decrypted ? toNote(decrypted) : null; diff --git a/apps/mana/apps/web/src/lib/modules/todo/queries.ts b/apps/mana/apps/web/src/lib/modules/todo/queries.ts index 16c8164c2..b5254cb1f 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/queries.ts @@ -4,6 +4,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { scopedForModule, applyVisibility } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte'; import type { @@ -44,8 +45,11 @@ export function toTask(local: LocalTask): Task { export function useAllTasks() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('tasks').orderBy('order').toArray(); - const visible = locals.filter((t) => !t.deletedAt); + // Scope-first, then in-memory sort by `order`. sortBy is O(n) — fine + // for a user's own task list; if it ever becomes hot, add a + // [spaceId+order] compound index in a follow-up Dexie version. + const locals = await scopedForModule('todo', 'tasks').sortBy('order'); + const visible = applyVisibility(locals).filter((t) => !t.deletedAt); const decrypted = await decryptRecords('tasks', visible); // Batch tag lookup: 1 query instead of N const ids = decrypted.map((t) => t.id); @@ -68,22 +72,26 @@ export { useAllTags as useAllLabels } from '@mana/shared-stores'; export function useAllBoardViews() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('boardViews').orderBy('order').toArray(); - return locals.filter((v) => !v.deletedAt); + const locals = await scopedForModule('todo', 'boardViews').sortBy( + 'order' + ); + return applyVisibility(locals).filter((v) => !v.deletedAt); }, [] as LocalBoardView[]); } export function useAllReminders() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('reminders').toArray(); - return locals.filter((r) => !r.deletedAt); + const locals = await scopedForModule('todo', 'reminders').toArray(); + return applyVisibility(locals).filter((r) => !r.deletedAt); }, [] as LocalReminder[]); } export function useAllProjects() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('todoProjects').orderBy('order').toArray(); - return locals.filter((p) => !p.deletedAt); + const locals = await scopedForModule('todo', 'todoProjects').sortBy( + 'order' + ); + return applyVisibility(locals).filter((p) => !p.deletedAt); }, [] as LocalTodoProject[]); }