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[]); }