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

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