feat(spaces): validate space metadata on Better Auth organization hooks

Moves the canonical SpaceType + SPACE_MODULE_ALLOWLIST to @mana/shared-types
(framework-free) so the Bun services can consume them without pulling in
Svelte. shared-branding keeps only the UI-facing labels and descriptions
and re-exports the canonical types for frontend convenience.

Wires two Better Auth organization hooks in mana-auth:
- beforeCreateOrganization asserts metadata.type is a valid SpaceType,
  rejecting the create with a BAD_REQUEST otherwise.
- beforeDeleteOrganization rejects deletion of the personal space.

Covered by bun tests (11 assertions) for the helper module.

No migration and no schema change — type lives in the existing
organization.metadata jsonb column.

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:05:38 +02:00
parent 9d69e4419d
commit 166d6c6ffb
11 changed files with 683 additions and 331 deletions

View file

@ -21,6 +21,9 @@
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint ."
},
"dependencies": {
"@mana/shared-types": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",

View file

@ -71,12 +71,16 @@ export {
export type { AppId, AppBranding, LogoProps, AppLogoWithNameProps } from './types';
// Spaces (multi-tenancy primitive — see docs/plans/spaces-foundation.md)
// Canonical types live in @mana/shared-types; branding adds only UI strings.
export {
SPACE_TYPES,
SPACE_TYPE_LABELS,
SPACE_TYPE_DESCRIPTIONS,
SPACE_MODULE_ALLOWLIST,
isModuleAllowedInSpace,
isSpaceType,
parseSpaceMetadata,
type SpaceType,
type SpaceModuleId,
type SpaceMetadata,
} from './spaces';

View file

@ -1,40 +1,14 @@
/**
* Space Types & Module Allowlist
* Space UI-facing labels and descriptions
*
* A "Space" is the unit of data ownership in Mana. Every record belongs to
* exactly one Space. Users join Spaces via Better Auth's `member` relation.
*
* Space = Better Auth Organization with a typed `metadata.type` field. The
* type drives which modules are available inside the space (see
* `SPACE_MODULE_ALLOWLIST` below).
* The canonical type/allowlist definitions live in `@mana/shared-types`
* (framework-free so Bun services can import them). This file adds only
* the i18n strings that belong to the UI branding layer.
*
* See docs/plans/spaces-foundation.md for the full RFC.
*/
/**
* The six canonical Space types. Every Better Auth organization must have
* exactly one of these as `metadata.type`.
*
* - `personal` single-member, auto-created on signup. Holds private data
* like mood, sleep, dreams that don't belong in a shared context.
* - `brand` external communication identity (e.g. Edisconet, a creator
* persona). Hosts social-relay, mail, landing, public content.
* - `club` association/Verein. Member management, dues, events,
* governance. Target for the ClubDesk-replacement roadmap.
* - `family` household/family/WG. Shared calendar, shopping, recipes.
* - `team` work team / project. Tasks, chat, docs.
* - `practice` freelancer/solo-business. Invoicing, clients, time tracking.
*/
export type SpaceType = 'personal' | 'brand' | 'club' | 'family' | 'team' | 'practice';
export const SPACE_TYPES: readonly SpaceType[] = [
'personal',
'brand',
'club',
'family',
'team',
'practice',
] as const;
import type { SpaceType } from '@mana/shared-types';
export const SPACE_TYPE_LABELS = {
de: {
@ -53,7 +27,7 @@ export const SPACE_TYPE_LABELS = {
team: 'Team',
practice: 'Practice',
},
} as const;
} as const satisfies Record<'de' | 'en', Record<SpaceType, string>>;
export const SPACE_TYPE_DESCRIPTIONS = {
de: {
@ -72,161 +46,18 @@ export const SPACE_TYPE_DESCRIPTIONS = {
team: 'Work team or project with multiple collaborators.',
practice: 'Freelancer or solo business with clients and invoices.',
},
} as const;
} as const satisfies Record<'de' | 'en', Record<SpaceType, string>>;
/**
* Module IDs referenced by the allowlist. Strings (not a strict enum) because
* the allowlist intentionally includes modules that don't exist yet e.g.
* `club-finance`, `social-relay` so features can be gated before the code
* lands.
*/
export type SpaceModuleId = string;
/**
* Which modules are available inside each Space type.
*
* The personal space gets everything (sentinel `'*'`). Other types get a
* curated subset modules dealing with intimate personal data (mood,
* dreams, period, body measurements, ) are intentionally excluded from
* shared spaces.
*
* Rule of thumb: if a module's data would feel wrong shared with co-workers
* or club members, keep it out.
*/
export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[] | '*'> = {
personal: '*',
brand: [
'mana',
'social-relay', // future — not yet built
'mail',
'contacts',
'calendar',
'storage',
'uload',
'landing', // future
'presi',
'cards',
'picture',
'quotes',
'news',
'news-research',
'research-lab',
'ai-agents',
'companion',
'times',
'notes',
'photos',
'invoices',
'activity',
'goals',
],
club: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'news',
'research-lab',
'club-members', // future — ClubDesk Paket A
'club-finance', // future — ClubDesk Paket B
'invoices',
'finance',
'landing', // future — Paket C (Vereinswebsite)
'presi',
'cards',
'quotes',
'companion',
'times',
'notes',
'photos',
'activity',
'goals',
],
family: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'recipes',
'food',
'places',
'presi',
'cards',
'photos',
'notes',
'companion',
'goals',
'activity',
'wetter',
'wisekeep',
'firsts',
],
team: [
'mana',
'contacts',
'calendar',
'events',
'storage',
'mail',
'uload',
'news',
'news-research',
'research-lab',
'presi',
'cards',
'picture',
'notes',
'quotes',
'invoices',
'companion',
'ai-agents',
'times',
'activity',
'goals',
],
practice: [
'mana',
'contacts',
'calendar',
'storage',
'mail',
'uload',
'invoices',
'finance',
'times',
'notes',
'presi',
'cards',
'quotes',
'companion',
'research-lab',
'activity',
'goals',
],
} as const;
/**
* Check whether a module is available inside a given Space type.
*
* Used by:
* - Scope wrapper (apps/mana/.../data/scope/scoped-db.ts) to block queries
* against disallowed modules structural guard against UI bypass.
* - UI module launcher to hide disabled modules in the active space.
* - Route guards that check before mounting a module page.
*/
export function isModuleAllowedInSpace(moduleId: SpaceModuleId, spaceType: SpaceType): boolean {
const allow = SPACE_MODULE_ALLOWLIST[spaceType];
if (allow === '*') return true;
return allow.includes(moduleId);
}
// Re-export canonical types from shared-types so frontend consumers can
// import everything space-related from `@mana/shared-branding` for
// convenience.
export {
SPACE_TYPES,
SPACE_MODULE_ALLOWLIST,
isModuleAllowedInSpace,
isSpaceType,
parseSpaceMetadata,
type SpaceType,
type SpaceModuleId,
type SpaceMetadata,
} from '@mana/shared-types';

View file

@ -29,6 +29,9 @@ export * from './landing-config';
// AI structured-output Zod schemas (shared between mana-api + web frontend)
export * from './ai-schemas';
// Space types (multi-tenancy primitive — see docs/plans/spaces-foundation.md)
export * from './spaces';
// API types
export interface User {
id: string;

View file

@ -0,0 +1,225 @@
/**
* Space Types & Module Allowlist
*
* Framework-free definition of the Space primitive the unit of data
* ownership in Mana. Consumed by both the SvelteKit frontend and the
* Bun/Hono services (mana-auth, mana-api, mana-sync), which is why this
* lives in shared-types instead of shared-branding (the latter carries
* Svelte components and is too heavy for a server).
*
* UI-facing labels/descriptions for these types live in shared-branding.
*
* See docs/plans/spaces-foundation.md for the full RFC.
*/
/**
* The six canonical Space types. Every Better Auth organization must have
* exactly one of these as `metadata.type`.
*
* - `personal` single-member, auto-created on signup. Holds private data
* like mood, sleep, dreams that don't belong in a shared context.
* - `brand` external communication identity (e.g. Edisconet, a creator
* persona). Hosts social-relay, mail, landing, public content.
* - `club` association/Verein. Member management, dues, events,
* governance. Target for the ClubDesk-replacement roadmap.
* - `family` household/family/WG. Shared calendar, shopping, recipes.
* - `team` work team / project. Tasks, chat, docs.
* - `practice` freelancer/solo-business. Invoicing, clients, time tracking.
*/
export type SpaceType = 'personal' | 'brand' | 'club' | 'family' | 'team' | 'practice';
export const SPACE_TYPES: readonly SpaceType[] = [
'personal',
'brand',
'club',
'family',
'team',
'practice',
] as const;
export function isSpaceType(value: unknown): value is SpaceType {
return typeof value === 'string' && (SPACE_TYPES as readonly string[]).includes(value);
}
/**
* Module IDs referenced by the allowlist. Strings (not a strict enum) because
* the allowlist intentionally includes modules that don't exist yet e.g.
* `club-finance`, `social-relay` so features can be gated before the code
* lands.
*/
export type SpaceModuleId = string;
/**
* Which modules are available inside each Space type.
*
* The personal space gets everything (sentinel `'*'`). Other types get a
* curated subset modules dealing with intimate personal data (mood,
* dreams, period, body measurements, ) are intentionally excluded from
* shared spaces.
*
* Rule of thumb: if a module's data would feel wrong shared with co-workers
* or club members, keep it out.
*/
export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[] | '*'> = {
personal: '*',
brand: [
'mana',
'social-relay', // future — not yet built
'mail',
'contacts',
'calendar',
'storage',
'uload',
'landing', // future
'presi',
'cards',
'picture',
'quotes',
'news',
'news-research',
'research-lab',
'ai-agents',
'companion',
'times',
'notes',
'photos',
'invoices',
'activity',
'goals',
],
club: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'news',
'research-lab',
'club-members', // future — ClubDesk Paket A
'club-finance', // future — ClubDesk Paket B
'invoices',
'finance',
'landing', // future — Paket C (Vereinswebsite)
'presi',
'cards',
'quotes',
'companion',
'times',
'notes',
'photos',
'activity',
'goals',
],
family: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'recipes',
'food',
'places',
'presi',
'cards',
'photos',
'notes',
'companion',
'goals',
'activity',
'wetter',
'wisekeep',
'firsts',
],
team: [
'mana',
'contacts',
'calendar',
'events',
'storage',
'mail',
'uload',
'news',
'news-research',
'research-lab',
'presi',
'cards',
'picture',
'notes',
'quotes',
'invoices',
'companion',
'ai-agents',
'times',
'activity',
'goals',
],
practice: [
'mana',
'contacts',
'calendar',
'storage',
'mail',
'uload',
'invoices',
'finance',
'times',
'notes',
'presi',
'cards',
'quotes',
'companion',
'research-lab',
'activity',
'goals',
],
} as const;
/**
* Check whether a module is available inside a given Space type.
*
* Used by:
* - Scope wrapper (apps/mana/.../data/scope/scoped-db.ts) to block queries
* against disallowed modules structural guard against UI bypass.
* - UI module launcher to hide disabled modules in the active space.
* - Route guards that check before mounting a module page.
*/
export function isModuleAllowedInSpace(moduleId: SpaceModuleId, spaceType: SpaceType): boolean {
const allow = SPACE_MODULE_ALLOWLIST[spaceType];
if (allow === '*') return true;
return allow.includes(moduleId);
}
/**
* Shape of the `metadata` JSONB column on Better Auth's `organization` table
* for our Space extension. `type` is required; other fields accumulate as
* features land (voiceDoc, legalEntity, uid, aiPersonaId, ).
*/
export interface SpaceMetadata {
type: SpaceType;
voiceDoc?: string;
legalEntity?: string;
uid?: string;
aiPersonaId?: string;
[key: string]: unknown;
}
/**
* Narrow a raw metadata blob (from Better Auth / DB) to a validated
* SpaceMetadata. Returns null if no valid type field is present callers
* decide whether to reject or default.
*/
export function parseSpaceMetadata(raw: unknown): SpaceMetadata | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
if (!isSpaceType(obj.type)) return null;
return obj as SpaceMetadata;
}