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 13e201b06..355642a2b 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 @@ -41,21 +41,22 @@ export class ModuleNotInSpaceError extends Error { /** * Return the set of spaceId values a record must match to be considered - * "in scope" right now. Normally just the active space, but during the - * sentinel window (v28 upgrade ran, bootstrap hasn't reconciled yet) we - * also accept the user's personal sentinel so records written between - * v28 landing and the first bootstrap don't vanish from the UI. + * "in scope" right now. + * + * Lenient during boot: if the active space hasn't loaded yet, falls back + * to the user's personal sentinel so records stamped by the v28 + * migration still render. Returns `[]` only when truly unauthenticated + * — that yields an empty-filter (matches nothing), which is the safest + * thing a wrapper can do pre-login. */ export function getInScopeSpaceIds(): string[] { const active = getActiveSpaceId(); - if (!active) throw new ScopeNotReadyError(); const userId = getCurrentUserId(); - const ids = [active]; - if (userId) { - const sentinel = personalSpaceSentinel(userId); - if (!ids.includes(sentinel)) ids.push(sentinel); + const sentinel = userId ? personalSpaceSentinel(userId) : null; + if (active) { + return sentinel && sentinel !== active ? [active, sentinel] : [active]; } - return ids; + return sentinel ? [sentinel] : []; } /** diff --git a/apps/mana/apps/web/src/lib/data/scope/visibility.ts b/apps/mana/apps/web/src/lib/data/scope/visibility.ts index 737ba2067..1c607ccf5 100644 --- a/apps/mana/apps/web/src/lib/data/scope/visibility.ts +++ b/apps/mana/apps/web/src/lib/data/scope/visibility.ts @@ -25,13 +25,15 @@ export type Visibility = 'space' | 'private'; * sync engine is scope-aware. Until then this is the authoritative * check the UI uses to decide what to show. */ -export function applyVisibility( - records: T[] -): T[] { +export function applyVisibility(records: T[]): T[] { + // T is unconstrained so TypeScript infers it exactly as the input + // type; visibility/authorId are read via a duck-typed runtime check + // so any record shape works without forcing the constraint through. const me = getCurrentUserId(); return records.filter((r) => { - if (r.visibility !== 'private') return true; - return typeof r.authorId === 'string' && r.authorId === me; + const rec = r as { visibility?: unknown; authorId?: unknown }; + if (rec.visibility !== 'private') return true; + return typeof rec.authorId === 'string' && rec.authorId === me; }); } diff --git a/apps/mana/apps/web/src/lib/modules/calendar/queries.ts b/apps/mana/apps/web/src/lib/modules/calendar/queries.ts index aced344e8..2223259d2 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/queries.ts @@ -12,6 +12,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 { eventTagOps } from './stores/tags.svelte'; @@ -41,8 +42,9 @@ export function toCalendar(local: LocalCalendar): Calendar { /** All calendars, auto-updates on any change. */ export function useAllCalendars() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('calendars').toArray(); - return locals.filter((c) => !c.deletedAt).map(toCalendar); + const locals = await scopedForModule('calendar', 'calendars').toArray(); + const visible = applyVisibility(locals).filter((c) => !c.deletedAt); + return visible.map(toCalendar); }, [] as Calendar[]); } @@ -53,14 +55,19 @@ export function useAllCalendars() { export function useAllCalendarItems() { return useLiveQueryWithDefault(async () => { // Fetch all non-deleted timeBlocks (filter on plaintext deletedAt - // before paying the per-row decrypt cost) - const blocks = await db.table('timeBlocks').toArray(); - const visibleBlocks = blocks.filter((b) => !b.deletedAt); + // before paying the per-row decrypt cost). Scope filter narrows to + // the active space + visibility filter drops records the user isn't + // allowed to see inside a shared space. + const blocks = await scopedForModule( + 'calendar', + 'timeBlocks' + ).toArray(); + const visibleBlocks = applyVisibility(blocks).filter((b) => !b.deletedAt); const decryptedBlocks = await decryptRecords('timeBlocks', visibleBlocks); // Fetch all non-deleted events for joining with calendar-type blocks - const events = await db.table('events').toArray(); - const visibleEvents = events.filter((e) => !e.deletedAt); + const events = await scopedForModule('calendar', 'events').toArray(); + const visibleEvents = applyVisibility(events).filter((e) => !e.deletedAt); const decryptedEvents = await decryptRecords('events', visibleEvents); const eventsById = new Map(); for (const e of decryptedEvents) { diff --git a/docs/plans/spaces-foundation.md b/docs/plans/spaces-foundation.md index 61110aee6..ecb10e5b8 100644 --- a/docs/plans/spaces-foundation.md +++ b/docs/plans/spaces-foundation.md @@ -317,6 +317,28 @@ Via `SPACE_MODULES` in Nicht-Personal-Spaces gar nicht erst erreichbar. Kein Cod --- +## Bekannte Altlast: `spaceId`-Namenskollision + +Vier bestehende Dexie-Tabellen nutzen das Feld `spaceId` bereits für das +**ältere** Context-Space-Konzept (chat-/memoro-interne Kontext-Ordner, +nicht das neue Multi-Tenancy-Space): + +- `conversations` (chat) — `spaceId` → `contextSpaces.id` +- `documents` (context) — `spaceId` → `contextSpaces.id` +- `spaceMembers` (memoro) — `spaceId` → `contextSpaces.id` +- `memoSpaces` (memoro) — `spaceId` → `contextSpaces.id` + +Die v28-Migration hat diese Tabellen **nicht korrumpiert**, weil der +Stamping-Code nur fehlende `spaceId`-Felder setzt (`if undefined/null`). +Bestehende Records mit Context-Space-Referenzen sind unverändert. + +**Follow-up**: Rename `spaceId` → `contextSpaceId` auf diesen vier Tabellen ++ ihren Modulen + Dexie-v29-Migration, damit das Namensfeld eindeutig der +neuen Space-Primitive gehört. Bis dahin ist der Scope-Wrapper für diese +Tabellen nicht verwendbar — entweder Kollision erst fixen oder das +Wrapper-Filter per Modul-Ausnahme deaktivieren. Calendar, Todo, Notes etc. +sind nicht betroffen. + ## Offene Fragen - **Slug-Uniqueness-Kollision**: User Till mit `@till` kollidiert mit potentiellem Brand `@till`. Lösungsraum: Slugs global unique (einfach, aber Race um beliebte Namen) vs. Slug-Präfixe (`@user/till` vs. `@org/till` — hässlich). Vorschlag: global unique, First-Come-First-Served, User-Slug bei Signup aus E-Mail-Local-Part + Suffix bei Kollision.