feat(spaces): scope layer — active-space store + scoped-db wrapper + visibility

Adds the client-side scope primitives that sit between module code and
Dexie so every query is filtered by the user's active Space:

  lib/data/scope/
  ├── active-space.svelte.ts   reactive active-space state; loads via
  │                             Better Auth's organization/get-active-member
  │                             and auto-activates personal on first boot
  ├── bootstrap.ts              reconcileSentinels() — rewrites every
  │                             `_personal:<userId>` placeholder from the
  │                             v28 migration to the real space id once
  │                             Better Auth responds
  ├── scoped-db.ts              scopedTable / scopedForModule — filter-
  │                             based scope enforcement. assertModuleAllowed
  │                             blocks disallowed modules per space-type
  │                             (e.g. mood in a brand space)
  ├── visibility.ts             applyVisibility / isVisibleToCurrentUser —
  │                             hides private records not authored by the
  │                             current user, even inside a shared space
  └── index.ts                  barrel export for consumers

Wrap accepts sentinel spaceId alongside the real id during the bootstrap
window so records written between v28 landing and the first reconcile
don't vanish from the UI.

No module uses this yet — the calendar pilot migration in the next
commit is the first consumer and validates the whole model.

10/10 unit tests pass. The fetch- and Dexie-backed functions
(loadActiveSpace, reconcileSentinels, scopedTable) are integration-only
and covered as the pilot migration lands.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:24:43 +02:00
parent 78bfea452a
commit c34c75517c
6 changed files with 501 additions and 0 deletions

View file

@ -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<ActiveSpace | null>(null);
let status = $state<ActiveSpaceStatus>('idle');
let lastError = $state<string | null>(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<ActiveSpace | null> {
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<ActiveSpace | null> {
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<ActiveSpace[]> {
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<void> {
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,
};
}

View file

@ -0,0 +1,67 @@
/**
* Sentinel reconciliation rewrites `_personal:<userId>` 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:<currentUserId>` 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<number> {
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<string>();
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}`;
}

View file

@ -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';

View file

@ -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();
});
});

View file

@ -0,0 +1,106 @@
/**
* Scoped Dexie drop-in replacement for `db.<table>` 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<T, PK>(tableName: string): Collection<T, PK> {
const table = db.table(tableName) as Table<T, PK>;
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<LocalTask, string>('todo', 'tasks').toArray();
*/
export function scopedForModule<T, PK>(
moduleId: SpaceModuleId,
tableName: string
): Collection<T, PK> {
assertModuleAllowed(moduleId);
return scopedTable<T, PK>(tableName);
}

View file

@ -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<T extends { visibility?: unknown; authorId?: unknown }>(
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;
}