diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts new file mode 100644 index 000000000..0e09a2f17 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -0,0 +1,159 @@ +/** + * Active Space — reactive source for the currently-selected Space. + * + * Every module that reads or writes data goes through this store (via the + * scope wrapper). On boot, it asks Better Auth which organization the + * session has active; if none is set, it auto-activates the user's + * personal space (we know it exists — signup hook guaranteed it). + * + * See docs/plans/spaces-foundation.md §5. + */ + +import type { SpaceType } from '@mana/shared-types'; +import { isSpaceType } from '@mana/shared-types'; + +export interface ActiveSpace { + id: string; + slug: string; + name: string; + type: SpaceType; + role: string; +} + +export type ActiveSpaceStatus = 'idle' | 'loading' | 'ready' | 'error'; + +let active = $state(null); +let status = $state('idle'); +let lastError = $state(null); + +export function getActiveSpace(): ActiveSpace | null { + return active; +} + +export function getActiveSpaceId(): string | null { + return active?.id ?? null; +} + +export function getActiveSpaceStatus(): ActiveSpaceStatus { + return status; +} + +export function getActiveSpaceError(): string | null { + return lastError; +} + +export function setActiveSpace(space: ActiveSpace | null): void { + active = space; + status = space ? 'ready' : 'idle'; + lastError = null; +} + +/** + * Resolve the user's active space from Better Auth. Idempotent — safe to + * call multiple times; successive calls short-circuit when `status === 'ready'`. + * + * Flow: + * 1. GET /api/auth/organization/get-active-member + * If it returns a member object → use it. + * 2. Otherwise GET /api/auth/organization/list, find the personal space + * by metadata.type === 'personal', POST /set-active to activate it. + * 3. Write the result into the reactive `active` state. + * + * Errors are captured in `lastError` and status flips to 'error'. Callers + * can retry by calling `loadActiveSpace({ force: true })`. + */ +export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise { + if (!opts.force && status === 'ready') return active; + if (status === 'loading') return active; // in-flight — don't double-fetch + status = 'loading'; + lastError = null; + + try { + const member = await fetchActiveMember(); + if (member) { + active = member; + status = 'ready'; + return member; + } + + // No active org on the session — activate the personal space. + const orgs = await fetchOrganizations(); + const personal = orgs.find((o) => o.type === 'personal'); + if (!personal) { + throw new Error('No personal space found — signup hook may not have run'); + } + await setActiveOnServer(personal.id); + active = { ...personal, role: 'owner' }; + status = 'ready'; + return active; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + status = 'error'; + return null; + } +} + +// ─── Better Auth REST calls ─────────────────────────────────────── + +interface RawOrg { + id: string; + slug?: string | null; + name: string; + metadata?: unknown; +} + +/** + * @internal — exposed for unit tests so they can swap fetchers. + */ +export const __endpoints = { + active: '/api/auth/organization/get-active-member', + list: '/api/auth/organization/list', + setActive: '/api/auth/organization/set-active', +}; + +async function fetchActiveMember(): Promise { + const res = await fetch(__endpoints.active, { credentials: 'include' }); + if (res.status === 404) return null; // no active org + if (!res.ok) throw new Error(`get-active-member failed: ${res.status}`); + const raw = (await res.json()) as { + role?: string; + organization?: RawOrg; + } | null; + if (!raw?.organization) return null; + return rawToActiveSpace(raw.organization, raw.role ?? 'member'); +} + +async function fetchOrganizations(): Promise { + const res = await fetch(__endpoints.list, { credentials: 'include' }); + if (!res.ok) throw new Error(`organization/list failed: ${res.status}`); + const raws = (await res.json()) as RawOrg[]; + return raws.map((r) => rawToActiveSpace(r, 'owner')); +} + +async function setActiveOnServer(organizationId: string): Promise { + const res = await fetch(__endpoints.setActive, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ organizationId }), + }); + if (!res.ok) throw new Error(`organization/set-active failed: ${res.status}`); +} + +/** + * Narrow a Better Auth raw organization into our typed ActiveSpace. Unknown + * metadata.type falls back to 'personal' because every Mana-created org + * carries a type — if one is missing it's legacy seed data from before the + * hooks landed, and 'personal' is the safest default. + */ +function rawToActiveSpace(raw: RawOrg, role: string): ActiveSpace { + const meta = (raw.metadata ?? {}) as { type?: unknown }; + const type: SpaceType = isSpaceType(meta.type) ? meta.type : 'personal'; + return { + id: raw.id, + slug: raw.slug ?? '', + name: raw.name, + type, + role, + }; +} diff --git a/apps/mana/apps/web/src/lib/data/scope/bootstrap.ts b/apps/mana/apps/web/src/lib/data/scope/bootstrap.ts new file mode 100644 index 000000000..0fb8de821 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/bootstrap.ts @@ -0,0 +1,67 @@ +/** + * Sentinel reconciliation — rewrites `_personal:` placeholders + * left by the Dexie v28 upgrade with the user's real personal-space id. + * + * Runs once per login after the active space has loaded. Idempotent — + * re-running after all sentinels are rewritten is a no-op. + * + * See docs/plans/spaces-foundation.md §5.3 ("Sentinel reconciliation"). + */ + +import { db } from '../database'; +import { SYNC_APP_MAP } from '../module-registry'; +import { getCurrentUserId } from '../current-user'; +import { getActiveSpace } from './active-space.svelte'; + +/** + * Rewrite every record whose spaceId is `_personal:` to the + * real personal-space id. Returns the total number of records rewritten. + * + * Safe to call before `loadActiveSpace()` has resolved — it'll no-op and + * return 0. The caller is expected to retry after the active space is + * ready (typically chained: `loadActiveSpace().then(reconcileSentinels)`). + */ +export async function reconcileSentinels(): Promise { + const userId = getCurrentUserId(); + if (!userId) return 0; + const space = getActiveSpace(); + if (!space || space.type !== 'personal') { + // Only the personal space owns sentinel records — other spaces were + // created after v28 shipped, so their records already have the + // correct spaceId from the Dexie creating-hook. + return 0; + } + + const sentinel = `_personal:${userId}`; + if (sentinel === space.id) return 0; // already reconciled or pathological + + const appTableNames = new Set(); + for (const tables of Object.values(SYNC_APP_MAP)) { + for (const t of tables) appTableNames.add(t); + } + + let rewritten = 0; + for (const name of appTableNames) { + // Use a transaction per table so one slow table doesn't block the rest + // if something aborts — partial reconciliation is recoverable on next + // boot because the filter is idempotent. + const table = db.table(name); + const count = await table + .filter((record: unknown) => { + const r = record as { spaceId?: unknown }; + return r.spaceId === sentinel; + }) + .modify({ spaceId: space.id }); + rewritten += count; + } + return rewritten; +} + +/** + * Sentinel value a record has before it's been reconciled, for the given + * user. Exposed for tests and for the creating hook that needs to match + * against it when reading back fresh writes. + */ +export function personalSpaceSentinel(userId: string): string { + return `_personal:${userId}`; +} diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts new file mode 100644 index 000000000..e0ff3594a --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -0,0 +1,33 @@ +/** + * Spaces scope layer — barrel. + * + * Module code should import everything it needs from here rather than + * reaching into the individual files, so future refactors of the + * internals stay invisible to consumers. + * + * See docs/plans/spaces-foundation.md for the full RFC. + */ + +export { + getActiveSpace, + getActiveSpaceId, + getActiveSpaceStatus, + getActiveSpaceError, + setActiveSpace, + loadActiveSpace, + type ActiveSpace, + type ActiveSpaceStatus, +} from './active-space.svelte'; + +export { reconcileSentinels, personalSpaceSentinel } from './bootstrap'; + +export { + scopedTable, + scopedForModule, + assertModuleAllowed, + getInScopeSpaceIds, + ScopeNotReadyError, + ModuleNotInSpaceError, +} from './scoped-db'; + +export { applyVisibility, isVisibleToCurrentUser, type Visibility } from './visibility'; diff --git a/apps/mana/apps/web/src/lib/data/scope/scope.test.ts b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts new file mode 100644 index 000000000..42a0a1c76 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests for the pure helpers in the scope layer. The Dexie- and + * fetch-backed functions (`loadActiveSpace`, `reconcileSentinels`, + * `scopedTable`) need integration harnesses and are covered separately. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { applyVisibility, isVisibleToCurrentUser } from './visibility'; +import { personalSpaceSentinel } from './bootstrap'; +import { assertModuleAllowed, ModuleNotInSpaceError, ScopeNotReadyError } from './scoped-db'; +import { setActiveSpace } from './active-space.svelte'; +import * as currentUser from '../current-user'; + +describe('personalSpaceSentinel', () => { + it('prefixes the user id with `_personal:`', () => { + expect(personalSpaceSentinel('u1')).toBe('_personal:u1'); + }); + + it('is consistent for the same user', () => { + expect(personalSpaceSentinel('u1')).toBe(personalSpaceSentinel('u1')); + }); +}); + +describe('visibility', () => { + beforeEach(() => { + vi.spyOn(currentUser, 'getCurrentUserId').mockReturnValue('me'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes space-visible records through unchanged', () => { + const records = [ + { id: 'a', visibility: 'space', authorId: 'me' }, + { id: 'b', visibility: 'space', authorId: 'someone-else' }, + ]; + expect(applyVisibility(records)).toEqual(records); + }); + + it('hides private records authored by someone else', () => { + const records = [ + { id: 'a', visibility: 'private', authorId: 'me' }, + { id: 'b', visibility: 'private', authorId: 'someone-else' }, + ]; + expect(applyVisibility(records)).toEqual([records[0]]); + }); + + it('treats missing visibility as space-visible (safe default)', () => { + const records = [{ id: 'a', authorId: 'me' }]; + expect(applyVisibility(records)).toEqual(records); + }); + + it('isVisibleToCurrentUser matches applyVisibility for single records', () => { + expect(isVisibleToCurrentUser({ visibility: 'space', authorId: 'x' })).toBe(true); + expect(isVisibleToCurrentUser({ visibility: 'private', authorId: 'me' })).toBe(true); + expect(isVisibleToCurrentUser({ visibility: 'private', authorId: 'x' })).toBe(false); + }); +}); + +describe('assertModuleAllowed', () => { + afterEach(() => { + setActiveSpace(null); + }); + + it('throws ScopeNotReady when no active space', () => { + setActiveSpace(null); + expect(() => assertModuleAllowed('todo')).toThrow(ScopeNotReadyError); + }); + + it('allows any module in a personal space', () => { + setActiveSpace({ id: 'x', slug: '@me', name: 'Me', type: 'personal', role: 'owner' }); + expect(() => assertModuleAllowed('todo')).not.toThrow(); + expect(() => assertModuleAllowed('mood')).not.toThrow(); + expect(() => assertModuleAllowed('club-finance')).not.toThrow(); + }); + + it('rejects personal-only modules in a brand space', () => { + setActiveSpace({ id: 'y', slug: '@e', name: 'E', type: 'brand', role: 'owner' }); + // mood is not in the brand allowlist + expect(() => assertModuleAllowed('mood')).toThrow(ModuleNotInSpaceError); + }); + + it('allows whitelisted modules in a brand space', () => { + setActiveSpace({ id: 'y', slug: '@e', name: 'E', type: 'brand', role: 'owner' }); + expect(() => assertModuleAllowed('social-relay')).not.toThrow(); + expect(() => assertModuleAllowed('mail')).not.toThrow(); + }); +}); 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 new file mode 100644 index 000000000..13e201b06 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -0,0 +1,106 @@ +/** + * Scoped Dexie — drop-in replacement for `db.` that auto-applies + * the active-space filter on every query and stamps the active-space id + * on every create. + * + * Module code moves from: + * `db.table('tasks').where({ isCompleted: 0 }).toArray()` + * to: + * `scoped.table('tasks').where({ isCompleted: 0 }).toArray()` + * + * Under the hood the wrapped table adds `.and(r => r.spaceId === activeId)` + * to every query. No schema changes — the spaceId index lands per-table + * in follow-ups once hot paths are identified. See + * docs/plans/spaces-foundation.md. + * + * Visibility ('private' records) is filtered in a layer above this (see + * visibility.ts). This module only enforces space boundaries. + */ + +import type { Collection, Table } from 'dexie'; +import { db } from '../database'; +import { getCurrentUserId } from '../current-user'; +import { getActiveSpaceId } from './active-space.svelte'; +import { personalSpaceSentinel } from './bootstrap'; +import { isModuleAllowedInSpace, type SpaceModuleId, type SpaceType } from '@mana/shared-types'; +import { getActiveSpace } from './active-space.svelte'; + +export class ScopeNotReadyError extends Error { + constructor() { + super('No active space — call loadActiveSpace() before querying scoped data.'); + this.name = 'ScopeNotReadyError'; + } +} + +export class ModuleNotInSpaceError extends Error { + constructor(moduleId: SpaceModuleId, type: SpaceType) { + super(`Module "${moduleId}" is not available in a ${type} space.`); + this.name = 'ModuleNotInSpaceError'; + } +} + +/** + * 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. + */ +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); + } + return ids; +} + +/** + * Return a Collection that applies the space filter — chainable with any + * further `.where()`, `.filter()`, `.toArray()`, `.modify()`. + * + * Use this when you need the whole table filtered; for an indexed where- + * clause, use `scoped.where(tableName, clause)` so the index is used + * first and the spaceId filter runs on the narrowed set. + */ +export function scopedTable(tableName: string): Collection { + const table = db.table(tableName) as Table; + const ids = getInScopeSpaceIds(); + const check = (record: unknown) => { + const r = record as { spaceId?: unknown }; + return typeof r.spaceId === 'string' && ids.includes(r.spaceId); + }; + return table.filter(check); +} + +/** + * Assert the given module is allowed in the active space type; throws + * ModuleNotInSpaceError if not. Use at the entry point of a module's + * query / store functions so bypassing the UI gate still hits this + * structural guard. + */ +export function assertModuleAllowed(moduleId: SpaceModuleId): void { + const space = getActiveSpace(); + if (!space) throw new ScopeNotReadyError(); + if (!isModuleAllowedInSpace(moduleId, space.type)) { + throw new ModuleNotInSpaceError(moduleId, space.type); + } +} + +/** + * Wrap a single-table operation in both scope and module checks. Returns + * the filtered Collection ready for further chaining. Preferred entry + * point for module queries: + * + * const tasks = await scopedForModule('todo', 'tasks').toArray(); + */ +export function scopedForModule( + moduleId: SpaceModuleId, + tableName: string +): Collection { + assertModuleAllowed(moduleId); + return scopedTable(tableName); +} diff --git a/apps/mana/apps/web/src/lib/data/scope/visibility.ts b/apps/mana/apps/web/src/lib/data/scope/visibility.ts new file mode 100644 index 000000000..737ba2067 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/visibility.ts @@ -0,0 +1,48 @@ +/** + * Visibility filtering for records inside a shared Space. + * + * Every record carries `visibility: 'space' | 'private'`. A `private` + * record is visible only to its `authorId`, even when other members of + * the same Space could technically read the row. This handles the + * "draft note in a shared family space" case — the author keeps it + * hidden until they flip it to 'space'. + * + * Sits in a layer above scoped-db: that module enforces space boundaries, + * this one enforces per-member visibility. + * + * See docs/plans/spaces-foundation.md §3.5. + */ + +import { getCurrentUserId } from '../current-user'; + +export type Visibility = 'space' | 'private'; + +/** + * Drop private records not authored by the current user from a list. + * Leaves 'space' records untouched. + * + * Runs client-side — the server will enforce the same rule once the + * 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[] { + const me = getCurrentUserId(); + return records.filter((r) => { + if (r.visibility !== 'private') return true; + return typeof r.authorId === 'string' && r.authorId === me; + }); +} + +/** + * Predicate for use inside Dexie `.filter()` chains so visibility can be + * pushed into the query instead of the hot path. Matches the same rule + * as `applyVisibility`. + */ +export function isVisibleToCurrentUser(record: unknown): boolean { + const r = record as { visibility?: unknown; authorId?: unknown }; + if (r.visibility !== 'private') return true; + const me = getCurrentUserId(); + return typeof r.authorId === 'string' && r.authorId === me; +}