mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
88e3adb9d3
commit
79a6da3e2e
8 changed files with 117 additions and 15 deletions
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
getActiveSpaceError,
|
||||
setActiveSpace,
|
||||
loadActiveSpace,
|
||||
getEffectiveTier,
|
||||
type ActiveSpace,
|
||||
type ActiveSpaceStatus,
|
||||
} from './active-space.svelte';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SpaceTier, number> = {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue