mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
9d69e4419d
commit
166d6c6ffb
11 changed files with 683 additions and 331 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
15
services/mana-auth/src/spaces/index.ts
Normal file
15
services/mana-auth/src/spaces/index.ts
Normal 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';
|
||||
84
services/mana-auth/src/spaces/metadata.spec.ts
Normal file
84
services/mana-auth/src/spaces/metadata.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
services/mana-auth/src/spaces/metadata.ts
Normal file
68
services/mana-auth/src/spaces/metadata.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue