feat(spaces): move access tier from user to space

Migration from user-level tier to Space-level tier, following the
Spaces foundation plan. User-visible effect: the tier that gates
module access now belongs to the active Space, not the user account.
Personal Spaces inherit the user's old tier on signup so nothing
downgrades.

shared-types:
- New SpaceTier type ('guest' | 'public' | 'beta' | 'alpha' | 'founder').
- New spaceTierMeets(actual, required) helper.
- SpaceMetadata gains an optional `tier` field.

mana-auth:
- createPersonalSpaceFor reads user.accessTier and stamps it into the
  personal Space's metadata.tier. A founder-tier user setting up their
  first Space keeps founder access in that Space.
- databaseHooks.user.create.after now forwards accessTier into the
  personal-space creator.

apps/web (scope layer):
- ActiveSpace gains a required `tier: SpaceTier`; rawToActiveSpace
  reads it from organization.metadata, defaulting to 'public' if
  missing or invalid.
- New getEffectiveTier(userFallback) helper resolves the tier to use
  for gating: prefers the active Space's tier, falls back to the
  caller-supplied user tier during the boot window.

apps/web ((app) layout):
- `effectiveTier` $derived replaces every authStore.user?.tier reference
  in the layout's access-gating logic (appItems, routeBlocked,
  routeTierLabels). AuthGate deeper in the UI keeps using user.tier as
  its own fallback — the tier move is additive, not destructive.

What this does NOT do yet:
- The user.accessTier column still exists and is still the initial
  source for personal-space tier. Removing it is a later cleanup once
  every code path reads through the Space primitive.
- No admin API for setting tier on a Space (PUT /api/v1/admin/spaces/
  :id/tier). Follow-up when admin tooling needs it — today admins still
  set user.accessTier, which flows to the personal space on next
  signup.

Resolves the MANA_APPS-tier-patch workaround memory: future sessions
can adjust tier per Space instead of per User.

0 errors across 7151 files. 10/10 scope tests pass.

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 20:10:06 +02:00
parent 88e3adb9d3
commit 79a6da3e2e
8 changed files with 117 additions and 15 deletions

View file

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

View file

@ -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<void> {
* 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,
};
}

View file

@ -15,6 +15,7 @@ export {
getActiveSpaceError,
setActiveSpace,
loadActiveSpace,
getEffectiveTier,
type ActiveSpace,
type ActiveSpaceStatus,
} from './active-space.svelte';

View file

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

View file

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