feat(spaces): add spaces pg schema — credentials + module_permissions

Groundwork for server-side Space extensions that must NOT live in Dexie:
  - spaces.credentials         — per-space OAuth tokens, API keys, SMTP
                                 configs. Access tokens are stored
                                 encrypted at rest with the service KEK.
  - spaces.module_permissions  — role × module read/write/admin overrides
                                 on top of the SPACE_MODULE_ALLOWLIST
                                 defaults.

Both tables FK to auth.organizations with ON DELETE CASCADE so deleting
a space drops its credentials and permission overrides automatically.

RLS is intentionally deferred — enabling it now would lock out services
that don't yet pass space context. A follow-up migration turns it on
after mana-api speaks the Spaces protocol end-to-end.

To apply locally: bun run db:push in services/mana-auth, or psql -f
sql/004_spaces.sql against the mana_platform DB.

No runtime code reads these tables yet — they're the scaffolding that
Task-8 (mana-sync) and the eventual social-relay/clubs modules will
consume.

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:13:33 +02:00
parent da1bb2d6e9
commit 698ffe797c
3 changed files with 167 additions and 0 deletions

View file

@ -0,0 +1,70 @@
-- Migration 004: Spaces schema
--
-- Adds the `spaces` schema with two server-side tables that extend Better
-- Auth organizations for our multi-tenancy model. See
-- docs/plans/spaces-foundation.md for the full RFC, and the Drizzle
-- definitions at src/db/schema/spaces.ts.
--
-- Why a separate schema:
-- - Keeps auth tables focused on identity, not domain extensions
-- - Lets us grant narrower RLS policies per schema
-- - Mirrors the pgSchema-per-concern pattern used across mana_platform
--
-- Idempotent: re-running on a partially-migrated DB is safe.
-- ─── Schema ──────────────────────────────────────────────────────
CREATE SCHEMA IF NOT EXISTS spaces;
-- ─── credentials ────────────────────────────────────────────────
-- Per-space external credentials: OAuth tokens, API keys, SMTP configs.
-- NEVER stored client-side — these are server-held secrets, wrapped with
-- the service-wide KEK (same mechanism as auth.encryption_vaults).
CREATE TABLE IF NOT EXISTS spaces.credentials (
space_id TEXT NOT NULL,
provider TEXT NOT NULL,
access_token_encrypted TEXT NOT NULL,
refresh_token_encrypted TEXT,
expires_at TIMESTAMPTZ,
scopes TEXT[],
provider_account_id TEXT,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (space_id, provider),
CONSTRAINT space_credentials_space_fk
FOREIGN KEY (space_id) REFERENCES auth.organizations (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS space_credentials_space_idx
ON spaces.credentials (space_id);
-- ─── module_permissions ─────────────────────────────────────────
-- Role × module permission matrix. If no row exists for a given
-- (space, role, module) tuple, the default is derived from SPACE_MODULE_ALLOWLIST
-- plus role-tier fallback (owner > admin > member). Rows here are
-- explicit overrides — typically written when a space owner customises
-- the default permissions for a custom role.
CREATE TABLE IF NOT EXISTS spaces.module_permissions (
space_id TEXT NOT NULL,
role TEXT NOT NULL,
module_id TEXT NOT NULL,
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_write BOOLEAN NOT NULL DEFAULT FALSE,
can_admin BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (space_id, role, module_id),
CONSTRAINT space_module_permissions_space_fk
FOREIGN KEY (space_id) REFERENCES auth.organizations (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS space_module_permissions_space_module_idx
ON spaces.module_permissions (space_id, module_id);
-- ─── RLS ─────────────────────────────────────────────────────────
-- Defer enabling RLS until the rest of the app is scope-aware. Turning
-- it on now would lock out services that don't yet pass the space
-- context. Re-enable in a follow-up migration once mana-api speaks the
-- Spaces protocol end-to-end.
--
-- ALTER TABLE spaces.credentials ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE spaces.module_permissions ENABLE ROW LEVEL SECURITY;

View file

@ -3,3 +3,4 @@ export * from './organizations';
export * from './api-keys';
export * from './login-attempts';
export * from './encryption-vaults';
export * from './spaces';

View file

@ -0,0 +1,96 @@
/**
* Spaces Postgres schema extensions for Better Auth organizations.
*
* The canonical SpaceType + SpaceMetadata contract lives in
* `@mana/shared-types`; the organization itself lives in the `auth` schema
* (see organizations.ts). This file adds the *server-side* extensions that
* don't belong in the client-synced world:
*
* - `spaces.credentials` per-space OAuth tokens + API keys
* (LinkedIn, Stripe, Twilio, SMTP, ).
* Must live server-side because we never
* want them in IndexedDB / Dexie.
* - `spaces.module_permissions` role × module × action matrix.
* Lets a club's trainer read `calendar`
* but not `club-finance`, for example.
*
* See docs/plans/spaces-foundation.md.
*/
import { pgSchema, text, timestamp, boolean, index, primaryKey } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
export const spacesSchema = pgSchema('spaces');
/**
* Per-space external credentials.
*
* Tokens are encrypted at rest with the service-wide KEK (same mechanism
* as auth.encryption_vaults). The `(space_id, provider)` composite key
* means one token per provider per space a second LinkedIn OAuth flow
* for the same Edisconet space overwrites the first.
*
* No FK on `provider` it's a free-form string (`linkedin`, `stripe`,
* `twilio_sms`, `twitter`, ) so we can add integrations without schema
* migrations. Service code handles the provider-specific payload.
*/
export const spaceCredentials = spacesSchema.table(
'credentials',
{
spaceId: text('space_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
provider: text('provider').notNull(),
accessTokenEncrypted: text('access_token_encrypted').notNull(),
refreshTokenEncrypted: text('refresh_token_encrypted'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
scopes: text('scopes').array(),
providerAccountId: text('provider_account_id'),
// Free-form per-provider metadata (org name, page id, webhook secret).
// Kept as text JSON to avoid pulling the jsonb type in — callers
// parse/serialize explicitly.
metadataJson: text('metadata_json'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.spaceId, table.provider] }),
spaceIdx: index('space_credentials_space_idx').on(table.spaceId),
})
);
/**
* Role × module permission matrix for a space.
*
* Example rows for a club:
* (org_123, 'owner', 'club-finance', true, true, true)
* (org_123, 'admin', 'club-finance', true, true, false)
* (org_123, 'trainer', 'club-finance', false, false, false) -- explicit deny
* (org_123, 'trainer', 'calendar', true, true, false)
*
* If no row exists for `(space, role, module)`, the fallback is the
* default derived from the space type (see SPACE_MODULE_ALLOWLIST in
* shared-types) + role tier (owner > admin > member). These rows only
* exist when the space owner has customized the default.
*/
export const spaceModulePermissions = spacesSchema.table(
'module_permissions',
{
spaceId: text('space_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
role: text('role').notNull(),
moduleId: text('module_id').notNull(),
canRead: boolean('can_read').notNull().default(true),
canWrite: boolean('can_write').notNull().default(false),
canAdmin: boolean('can_admin').notNull().default(false),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.spaceId, table.role, table.moduleId] }),
spaceModuleIdx: index('space_module_permissions_space_module_idx').on(
table.spaceId,
table.moduleId
),
})
);