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;
}

386
pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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",

View file

@ -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: {

View file

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

View file

@ -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<string, unknown>);
expect(meta.type).toBe('brand');
});
});

View file

@ -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, 'type'> = {}
): SpaceMetadata {
if (!isSpaceType(type)) {
throw new Error(`Invalid SpaceType: ${String(type)}`);
}
return { ...extra, type };
}