diff --git a/packages/shared-branding/package.json b/packages/shared-branding/package.json index ccf40ef12..a4c65c889 100644 --- a/packages/shared-branding/package.json +++ b/packages/shared-branding/package.json @@ -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", diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 1d6a8e5ec..c5a70f917 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -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'; diff --git a/packages/shared-branding/src/spaces.ts b/packages/shared-branding/src/spaces.ts index bd3dacc5a..d94a57268 100644 --- a/packages/shared-branding/src/spaces.ts +++ b/packages/shared-branding/src/spaces.ts @@ -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>; 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>; -/** - * 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 = { - 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'; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 7baa62e2d..304a10523 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -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; diff --git a/packages/shared-types/src/spaces.ts b/packages/shared-types/src/spaces.ts new file mode 100644 index 000000000..9402c764f --- /dev/null +++ b/packages/shared-types/src/spaces.ts @@ -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 = { + 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; + if (!isSpaceType(obj.type)) return null; + return obj as SpaceMetadata; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e28d6cd16..0a2f12852 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,14 +138,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) @@ -154,13 +154,13 @@ importers: version: 20.19.39 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) + version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.8.1 @@ -253,10 +253,10 @@ importers: version: 3.7.2 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) astro: specifier: ^5.16.11 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -612,6 +612,9 @@ importers: svelte-sonner: specifier: ^1.0.5 version: 1.1.0(svelte@5.55.1) + swissqrbill: + specifier: ^4.3.0 + version: 4.3.0(typescript@5.9.3) zod: specifier: ^3.25.76 version: 3.25.76 @@ -2018,6 +2021,10 @@ importers: version: 5.9.3 packages/shared-branding: + dependencies: + '@mana/shared-types': + specifier: workspace:* + version: link:../shared-types devDependencies: svelte: specifier: ^5.0.0 @@ -2586,6 +2593,9 @@ importers: '@mana/shared-hono': specifier: workspace:* version: link:../../packages/shared-hono + '@mana/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types bcryptjs: specifier: ^3.0.2 version: 3.0.3 @@ -15061,6 +15071,9 @@ packages: resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==} engines: {node: '>=18'} + svg-engine@0.3.0: + resolution: {integrity: sha512-s172jAcwfoCcvM/6DwNBvmWN3brztHGFENCR+RU3CBJKeBxPrRlTltVxX1Je5hst782QgP8PM6U37vUR/RhPng==} + svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} @@ -15078,6 +15091,18 @@ packages: engines: {node: '>=16'} hasBin: true + swissqrbill@4.3.0: + resolution: {integrity: sha512-FzSPEVWVQ3R6B0vghqi7VJCLp54AMYxCSiJGr2QzXo8OSU8JBv7XJcF67BAVAriGkuaZqVfhxuD6yRFT6WAXEA==} + engines: {node: '>=18.0.0'} + peerDependencies: + pdfkit: '>=0.13.0' + typescript: '>=4.7.0' + peerDependenciesMeta: + pdfkit: + optional: true + typescript: + optional: true + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -16686,6 +16711,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -16706,16 +16741,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - autoprefixer: 10.4.27(postcss@8.5.8) - postcss: 8.5.8 - postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -18875,6 +18900,11 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -23156,7 +23186,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.3': dependencies: @@ -23218,7 +23248,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@4.1.3': dependencies: @@ -23649,6 +23679,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.7.0 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) + vfile: 6.0.3 + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -23853,108 +23985,6 @@ snapshots: - uploadthing - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.7.0 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) - vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -25758,6 +25788,11 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -25767,6 +25802,10 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -25811,6 +25850,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.0 + astro-eslint-parser: 1.4.0 + eslint: 9.39.4(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -25984,6 +26037,47 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -32247,6 +32341,8 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 + svg-engine@0.3.0: {} + svg-parser@2.0.4: {} svg-pathdata@6.0.3: @@ -32272,6 +32368,12 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + swissqrbill@4.3.0(typescript@5.9.3): + dependencies: + svg-engine: 0.3.0 + optionalDependencies: + typescript: 5.9.3 + symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} @@ -32954,6 +33056,23 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 + vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -32988,23 +33107,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.32.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -33022,6 +33124,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -33030,10 +33136,6 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) diff --git a/services/mana-auth/package.json b/services/mana-auth/package.json index cc56a94db..94e326295 100644 --- a/services/mana-auth/package.json +++ b/services/mana-auth/package.json @@ -14,6 +14,7 @@ "dependencies": { "@mana/shared-ai": "workspace:*", "@mana/shared-hono": "workspace:*", + "@mana/shared-types": "workspace:*", "hono": "^4.7.0", "better-auth": "^1.4.3", "drizzle-orm": "^0.38.3", diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index a93709408..d84fc6dec 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -38,6 +38,7 @@ import { } from '../email/send'; import { sourceAppStore, passwordResetRedirectStore } from './stores'; import { TRUSTED_ORIGINS } from './sso-origins'; +import { assertValidSpaceMetadataForCreate, assertSpaceIsDeletable } from '../spaces'; // Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`) // keep working. New code should import from './sso-origins' directly. @@ -281,6 +282,21 @@ export function createBetterAuth(databaseUrl: string) { ); }, + /** + * Spaces — enforce that every organization carries a valid + * `metadata.type` (the Space type), and block deletion of the + * user's personal space. See docs/plans/spaces-foundation.md + * and ../spaces/metadata.ts. + */ + organizationHooks: { + beforeCreateOrganization: async ({ organization }) => { + assertValidSpaceMetadataForCreate(organization.metadata); + }, + beforeDeleteOrganization: async ({ organization }) => { + assertSpaceIsDeletable(organization.metadata); + }, + }, + // Custom roles and permissions organizationRole: { owner: { diff --git a/services/mana-auth/src/spaces/index.ts b/services/mana-auth/src/spaces/index.ts new file mode 100644 index 000000000..0de87628d --- /dev/null +++ b/services/mana-auth/src/spaces/index.ts @@ -0,0 +1,15 @@ +/** + * Spaces — multi-tenancy helpers for mana-auth. + * + * The canonical SpaceType + allowlist lives in @mana/shared-types. This + * barrel adds auth-side concerns: Better Auth hook helpers for validating + * organization metadata, and (future) slug generation for personal spaces. + * + * See docs/plans/spaces-foundation.md. + */ + +export { + assertValidSpaceMetadataForCreate, + assertSpaceIsDeletable, + buildSpaceMetadata, +} from './metadata'; diff --git a/services/mana-auth/src/spaces/metadata.spec.ts b/services/mana-auth/src/spaces/metadata.spec.ts new file mode 100644 index 000000000..afb5b72f0 --- /dev/null +++ b/services/mana-auth/src/spaces/metadata.spec.ts @@ -0,0 +1,84 @@ +/** + * Tests for Space-metadata validation used by Better Auth organization hooks. + */ + +import { describe, it, expect } from 'bun:test'; +import { + assertValidSpaceMetadataForCreate, + assertSpaceIsDeletable, + buildSpaceMetadata, +} from './metadata'; + +describe('assertValidSpaceMetadataForCreate', () => { + it('accepts metadata with every valid SpaceType', () => { + for (const type of ['personal', 'brand', 'club', 'family', 'team', 'practice'] as const) { + const parsed = assertValidSpaceMetadataForCreate({ type }); + expect(parsed.type).toBe(type); + } + }); + + it('preserves extra metadata fields', () => { + const parsed = assertValidSpaceMetadataForCreate({ + type: 'brand', + voiceDoc: 'hello', + uid: 'CH-123', + }); + expect(parsed.voiceDoc).toBe('hello'); + expect(parsed.uid).toBe('CH-123'); + }); + + it('rejects missing metadata', () => { + expect(() => assertValidSpaceMetadataForCreate(null)).toThrow(/type/i); + expect(() => assertValidSpaceMetadataForCreate(undefined)).toThrow(/type/i); + }); + + it('rejects missing type field', () => { + expect(() => assertValidSpaceMetadataForCreate({})).toThrow(/type/i); + expect(() => assertValidSpaceMetadataForCreate({ name: 'Edisconet' })).toThrow(/type/i); + }); + + it('rejects unknown SpaceType values', () => { + expect(() => assertValidSpaceMetadataForCreate({ type: 'corporate' })).toThrow(/type/i); + expect(() => assertValidSpaceMetadataForCreate({ type: 'PERSONAL' })).toThrow(/type/i); + }); +}); + +describe('assertSpaceIsDeletable', () => { + it('blocks deletion of personal spaces', () => { + expect(() => assertSpaceIsDeletable({ type: 'personal' })).toThrow( + /personal space cannot be deleted/i + ); + }); + + it('allows deletion of other space types', () => { + for (const type of ['brand', 'club', 'family', 'team', 'practice'] as const) { + expect(() => assertSpaceIsDeletable({ type })).not.toThrow(); + } + }); + + it('allows deletion when metadata is malformed (fail-open by design)', () => { + // If metadata is missing or invalid, we don't block — the delete endpoint + // enforces other permission checks (owner role, etc.) and we only want to + // guard the personal-space special case. + expect(() => assertSpaceIsDeletable(null)).not.toThrow(); + expect(() => assertSpaceIsDeletable({})).not.toThrow(); + expect(() => assertSpaceIsDeletable({ type: 'unknown' })).not.toThrow(); + }); +}); + +describe('buildSpaceMetadata', () => { + it('returns a metadata blob with the given type', () => { + expect(buildSpaceMetadata('club').type).toBe('club'); + }); + + it('merges extra fields after the type', () => { + const meta = buildSpaceMetadata('brand', { voiceDoc: 'X', uid: 'Y' }); + expect(meta).toEqual({ type: 'brand', voiceDoc: 'X', uid: 'Y' }); + }); + + it('lets explicit type win even if extras try to override', () => { + // Extra is typed to exclude `type`, but at runtime someone could try. + const meta = buildSpaceMetadata('brand', { voiceDoc: 'X' } as Record); + expect(meta.type).toBe('brand'); + }); +}); diff --git a/services/mana-auth/src/spaces/metadata.ts b/services/mana-auth/src/spaces/metadata.ts new file mode 100644 index 000000000..47a9a92b9 --- /dev/null +++ b/services/mana-auth/src/spaces/metadata.ts @@ -0,0 +1,68 @@ +/** + * Space metadata validation for Better Auth organization hooks. + * + * Every Better Auth organization in Mana must carry a `metadata.type` field + * that identifies the Space type (personal/brand/club/family/team/practice). + * This module enforces that contract at the plugin-hook layer. + * + * See docs/plans/spaces-foundation.md. + */ + +import { APIError } from 'better-auth/api'; +import { + SPACE_TYPES, + isSpaceType, + parseSpaceMetadata, + type SpaceMetadata, + type SpaceType, +} from '@mana/shared-types'; + +/** + * Validate the metadata blob that will be persisted for a new organization. + * Throws a Better Auth `APIError` (BAD_REQUEST) if the shape is invalid. + * + * Intended for `organizationHooks.beforeCreateOrganization`. + */ +export function assertValidSpaceMetadataForCreate(raw: unknown): SpaceMetadata { + const parsed = parseSpaceMetadata(raw); + if (!parsed) { + throw new APIError('BAD_REQUEST', { + message: `Organization metadata must include a valid "type" field. Expected one of: ${SPACE_TYPES.join(', ')}.`, + code: 'SPACE_METADATA_INVALID', + }); + } + return parsed; +} + +/** + * Guard a delete call against removing the user's personal space. + * Better Auth will still allow admins/owners to delete other spaces — we only + * protect the auto-created personal one, because losing it would orphan all + * the user's private data. + * + * Intended for `organizationHooks.beforeDeleteOrganization`. + */ +export function assertSpaceIsDeletable(metadata: unknown): void { + const parsed = parseSpaceMetadata(metadata); + if (parsed?.type === 'personal') { + throw new APIError('FORBIDDEN', { + message: 'The personal space cannot be deleted. Delete the user account instead.', + code: 'SPACE_PERSONAL_UNDELETABLE', + }); + } +} + +/** + * Build a metadata blob for a freshly-created space of a given type. Used by + * the signup-time personal-space auto-creator and by any future UI that + * creates spaces of other types. + */ +export function buildSpaceMetadata( + type: SpaceType, + extra: Omit = {} +): SpaceMetadata { + if (!isSpaceType(type)) { + throw new Error(`Invalid SpaceType: ${String(type)}`); + } + return { ...extra, type }; +}