diff --git a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte index 28821a0cb..3e775f277 100644 --- a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte @@ -12,7 +12,7 @@ import { getActiveSpace, loadActiveSpace, type ActiveSpace } from '$lib/data/scope'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; - import { isSpaceType } from '@mana/shared-types'; + import { isSpaceType, isSpaceTier } from '@mana/shared-types'; import SpaceCreateDialog from './SpaceCreateDialog.svelte'; interface Props { @@ -49,12 +49,14 @@ metadata?: unknown; }>; spaces = raw.map((o) => { - const meta = (o.metadata ?? {}) as { type?: unknown }; + const meta = (o.metadata ?? {}) as { type?: unknown; tier?: unknown }; const type = isSpaceType(meta.type) ? meta.type : 'personal'; + const tier = isSpaceTier(meta.tier) ? meta.tier : 'public'; return { id: o.id, slug: o.slug ?? '', name: o.name, + tier, type, role: 'member', // real role comes via /get-active-member; not needed for display }; diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index 0e09a2f17..23f78527c 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -9,14 +9,15 @@ * See docs/plans/spaces-foundation.md §5. */ -import type { SpaceType } from '@mana/shared-types'; -import { isSpaceType } from '@mana/shared-types'; +import type { SpaceType, SpaceTier } from '@mana/shared-types'; +import { isSpaceType, isSpaceTier } from '@mana/shared-types'; export interface ActiveSpace { id: string; slug: string; name: string; type: SpaceType; + tier: SpaceTier; role: string; } @@ -48,6 +49,22 @@ export function setActiveSpace(space: ActiveSpace | null): void { lastError = null; } +/** + * The tier to use for app-access gating right now. Prefers the active + * Space's tier; falls back to the caller-supplied user tier for the + * bootstrap window where the active space isn't loaded yet. + * + * Callers pass their own user-tier fallback (usually `authStore.user?.tier`) + * rather than having this module reach into auth — keeps the scope + * layer free of UI-auth dependencies. + */ +export function getEffectiveTier(userFallback: SpaceTier | string | undefined): SpaceTier { + const space = active; + if (space?.tier && isSpaceTier(space.tier)) return space.tier; + if (typeof userFallback === 'string' && isSpaceTier(userFallback)) return userFallback; + return 'guest'; +} + /** * Resolve the user's active space from Better Auth. Idempotent — safe to * call multiple times; successive calls short-circuit when `status === 'ready'`. @@ -147,13 +164,15 @@ async function setActiveOnServer(organizationId: string): Promise { * hooks landed, and 'personal' is the safest default. */ function rawToActiveSpace(raw: RawOrg, role: string): ActiveSpace { - const meta = (raw.metadata ?? {}) as { type?: unknown }; + const meta = (raw.metadata ?? {}) as { type?: unknown; tier?: unknown }; const type: SpaceType = isSpaceType(meta.type) ? meta.type : 'personal'; + const tier: SpaceTier = isSpaceTier(meta.tier) ? meta.tier : 'public'; return { id: raw.id, slug: raw.slug ?? '', name: raw.name, type, + tier, role, }; } diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts index bbaaca81e..c85fdb066 100644 --- a/apps/mana/apps/web/src/lib/data/scope/index.ts +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -15,6 +15,7 @@ export { getActiveSpaceError, setActiveSpace, loadActiveSpace, + getEffectiveTier, type ActiveSpace, type ActiveSpaceStatus, } from './active-space.svelte'; diff --git a/apps/mana/apps/web/src/lib/data/scope/scope.test.ts b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts index 42a0a1c76..7e5d594ba 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scope.test.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts @@ -68,20 +68,41 @@ describe('assertModuleAllowed', () => { }); it('allows any module in a personal space', () => { - setActiveSpace({ id: 'x', slug: '@me', name: 'Me', type: 'personal', role: 'owner' }); + setActiveSpace({ + id: 'x', + slug: '@me', + name: 'Me', + type: 'personal', + tier: 'founder', + 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' }); + setActiveSpace({ + id: 'y', + slug: '@e', + name: 'E', + type: 'brand', + tier: 'public', + 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' }); + setActiveSpace({ + id: 'y', + slug: '@e', + name: 'E', + type: 'brand', + tier: 'public', + role: 'owner', + }); expect(() => assertModuleAllowed('social-relay')).not.toThrow(); expect(() => assertModuleAllowed('mail')).not.toThrow(); }); diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 6a89ee5b3..c3069f3f5 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -54,6 +54,7 @@ import { useSyncStatusItems } from '$lib/components/layout/use-sync-status-items.svelte'; import RouteTierGate from '$lib/components/layout/RouteTierGate.svelte'; import SpaceSwitcher from '$lib/components/layout/SpaceSwitcher.svelte'; + import { getEffectiveTier } from '$lib/data/scope'; import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte'; import { Microphone, Stop } from '@mana/shared-icons'; import { @@ -108,7 +109,10 @@ } // ── App switcher ──────────────────────────────────────── - let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier)); + // Prefer the active Space's tier for gating — falls back to the user + // tier only during the bootstrap window where no space has loaded. + let effectiveTier = $derived(getEffectiveTier(authStore.user?.tier)); + let appItems = $derived(getPillAppItems('mana', undefined, undefined, effectiveTier)); // ── Per-route tier gate ───────────────────────────────── // AuthGate (the wrapping component) only checks tiers onMount and only @@ -130,15 +134,14 @@ }); let routeBlocked = $derived.by(() => { if (!routeAppId) return false; - const tier = authStore.user?.tier ?? 'guest'; - return !hasAppAccess(tier, routeAppId.requiredTier); + return !hasAppAccess(effectiveTier, routeAppId.requiredTier); }); let routeTierLabels = $derived.by(() => { const labels = ACCESS_TIER_LABELS[($locale || 'de') === 'de' ? 'de' : 'en']; - const userTier = (authStore.user?.tier ?? 'guest') as AccessTier; + const tier = effectiveTier as AccessTier; const required = routeAppId?.requiredTier ?? ('public' as AccessTier); return { - user: labels[userTier] ?? userTier, + user: labels[tier] ?? tier, required: labels[required] ?? required, }; }); diff --git a/packages/shared-types/src/spaces.ts b/packages/shared-types/src/spaces.ts index 9402c764f..e8b2af831 100644 --- a/packages/shared-types/src/spaces.ts +++ b/packages/shared-types/src/spaces.ts @@ -203,8 +203,55 @@ export function isModuleAllowedInSpace(moduleId: SpaceModuleId, spaceType: Space * for our Space extension. `type` is required; other fields accumulate as * features land (voiceDoc, legalEntity, uid, aiPersonaId, …). */ +/** + * The access tiers a Space can have. Gates module access via + * `requiredTier` on each ManaApp. + * + * Ordered from least to most access. A higher tier implies access to + * everything a lower tier can reach. + */ +export type SpaceTier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder'; + +export const SPACE_TIERS: readonly SpaceTier[] = [ + 'guest', + 'public', + 'beta', + 'alpha', + 'founder', +] as const; + +const TIER_LEVEL: Record = { + guest: 0, + public: 1, + beta: 2, + alpha: 3, + founder: 4, +}; + +export function isSpaceTier(value: unknown): value is SpaceTier { + return typeof value === 'string' && (SPACE_TIERS as readonly string[]).includes(value); +} + +/** + * Check whether a Space's tier is high enough to meet a required tier. + * Both undefined/invalid tiers are treated as 'guest' (least access). + */ +export function spaceTierMeets(actual: SpaceTier | undefined, required: SpaceTier): boolean { + const a = actual && isSpaceTier(actual) ? TIER_LEVEL[actual] : 0; + const r = TIER_LEVEL[required]; + return a >= r; +} + export interface SpaceMetadata { type: SpaceType; + /** + * Access tier for this Space. Gates which modules / features the + * Space can use via ManaApp.requiredTier. Defaults to 'public'. + * The signup hook stamps the user's prior user-level tier onto the + * personal Space so no one loses access during the user→space tier + * migration. + */ + tier?: SpaceTier; voiceDoc?: string; legalEntity?: string; uid?: string; diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index 010e6e3b3..81f54a43d 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -226,6 +226,7 @@ export function createBetterAuth(databaseUrl: string) { id: user.id, email: user.email, name: user.name, + accessTier: (user as { accessTier?: string | null }).accessTier, }); }, }, diff --git a/services/mana-auth/src/spaces/personal-space.ts b/services/mana-auth/src/spaces/personal-space.ts index d8d3a7d01..08f23b618 100644 --- a/services/mana-auth/src/spaces/personal-space.ts +++ b/services/mana-auth/src/spaces/personal-space.ts @@ -13,6 +13,7 @@ import { and, eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; +import { isSpaceTier, type SpaceTier } from '@mana/shared-types'; import { organizations, members } from '../db/schema/organizations'; import type { Database } from '../db/connection'; import { buildSpaceMetadata } from './metadata'; @@ -111,7 +112,7 @@ export function dbSlugTaken(db: Database): SlugTakenLookup { */ export async function createPersonalSpaceFor( db: Database, - user: { id: string; email: string; name?: string | null } + user: { id: string; email: string; name?: string | null; accessTier?: string | null } ): Promise<{ organizationId: string; slug: string; created: boolean }> { // Idempotency guard — check for existing personal space via member join. const existing = await db @@ -133,12 +134,19 @@ export async function createPersonalSpaceFor( const memberId = nanoid(); const displayName = user.name?.trim() || user.email.split('@', 1)[0] || 'Personal'; + // Carry the user's existing access tier onto the personal Space so + // the user→space tier migration doesn't downgrade anyone. A founder + // account setting up their first space stays at founder in that + // space. Invalid or missing values default to 'public' — matches the + // Better Auth user.accessTier default. + const inheritedTier: SpaceTier = isSpaceTier(user.accessTier) ? user.accessTier : 'public'; + await db.transaction(async (tx) => { await tx.insert(organizations).values({ id: orgId, name: displayName, slug, - metadata: buildSpaceMetadata('personal'), + metadata: buildSpaceMetadata('personal', { tier: inheritedTier }), logo: null, }); await tx.insert(members).values({