Developer Docs

Platform Admin Provisioning

The Tower is Mallnline's internal platform administration dashboard. Access is controlled by a dedicated platform_admins table in the auth Postgres database โ€” completely decoupled from Organization membership. This means a Malet Owner who creates an Organization does not automatically become a platform admin.

IMPORTANT

Platform admin access is restricted to @mallnline.com email addresses. This is enforced at the API level.

Architecture

The Problem (Pre-Provisioning)

Previously, is_privileged_user() in the auth service queried org_members for OWNER or ADMIN roles. This meant:

  • Any Malet Owner who created an Organization automatically got Tower access
  • No way to distinguish between "Org Admin" and "Platform Admin"
  • No audit trail for who granted platform access

The Solution

A dedicated platform_admins table with:

  • Invite-chain provisioning โ€” all admins trace back to a seeded SUPER_ADMIN
  • 3-tier role system โ€” SUPER_ADMIN, ADMIN, VIEWER
  • Soft-delete revocation โ€” revoked_at timestamp for access removal
  • Domain restriction โ€” only @mallnline.com emails
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     invite     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     invite     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ SUPER_ADMIN โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚    ADMIN     โ”‚       โœ—        โ”‚   VIEWER    โ”‚
โ”‚  (seeded)   โ”‚               โ”‚ (can manage) โ”‚               โ”‚ (read-only) โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚                                                           โ–ฒ
       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                              invite

Only SUPER_ADMIN users can send invites. ADMIN and VIEWER roles cannot escalate.

Database Schema

`platform_admins` Table

Column Type Description
id UUID (PK) Auto-generated
user_id UUID (FK โ†’ users, UNIQUE) One admin record per user
role platform_admin_role enum SUPER_ADMIN, ADMIN, VIEWER
granted_by UUID (FK โ†’ users, nullable) NULL for seed records
granted_at TIMESTAMPTZ When access was granted
revoked_at TIMESTAMPTZ (nullable) NULL = active, set = revoked

`platform_admin_invites` Table

Column Type Description
id UUID (PK) Auto-generated
email VARCHAR(255) Must end with @mallnline.com
role platform_admin_role enum Role to grant on acceptance
token VARCHAR(128, UNIQUE) UUID + 64-char random token
invited_by UUID (FK โ†’ users) The SUPER_ADMIN who sent the invite
expires_at TIMESTAMPTZ 72 hours from creation
accepted_at TIMESTAMPTZ (nullable) NULL = pending, set = accepted
created_at TIMESTAMPTZ Auto-set

REST API

All endpoints are on the auth service and require authentication via session cookie.

`POST /admin/invite`

Create a platform admin invite. SUPER_ADMIN only.

// Request
{
  "email": "alice@mallnline.com",
  "role": "ADMIN"  // or "VIEWER", defaults to "ADMIN"
}

// Response (200)
{
  "message": "Invite created successfully",
  "email": "alice@mallnline.com",
  "role": "ADMIN",
  "token": "a1b2c3...96chars",
  "expires_at": "2026-04-22T18:00:00Z"
}

Error responses:

Status Condition
400 Email doesn't end with @mallnline.com
400 Role is not ADMIN or VIEWER
403 Caller is not SUPER_ADMIN
409 User is already a platform admin
409 Pending invite already exists for this email

`POST /admin/invite/accept`

Accept an invite. Any authenticated user (must match invite email).

// Request
{ "token": "a1b2c3...96chars" }

// Response (200)
{
  "message": "Welcome to The Tower",
  "role": "ADMIN"
}

Error responses:

Status Condition
403 Authenticated user's email doesn't match invite
404 Invalid token
409 Invite already accepted
410 Invite expired (72h TTL)

`GET /admin/admins`

List all active platform admins. Any platform admin can view.

// Response (200)
{
  "admins": [
    {
      "id": "uuid",
      "user_id": "uuid",
      "role": "SUPER_ADMIN",
      "granted_by": null,
      "granted_at": "2026-04-19T00:00:00Z",
      "email": "meekdenzo@gmail.com",
      "username": "meekdenzo",
      "display_name": null
    }
  ],
  "total": 2
}

`DELETE /admin/admins/:id`

Revoke a platform admin. SUPER_ADMIN only. Soft-deletes by setting revoked_at.

// Response (200)
{
  "message": "Platform admin access revoked",
  "revoked_user_id": "uuid"
}

Error responses:

Status Condition
400 Cannot revoke your own access
403 Caller is not SUPER_ADMIN
404 Admin record not found

Role Capabilities

Capability SUPER_ADMIN ADMIN VIEWER
View Tower dashboard โœ… โœ… โœ…
View analytics โœ… โœ… โœ…
View user listing โœ… โœ… โœ…
View platform admins โœ… โœ… โœ…
Send admin invites โœ… โŒ โŒ
Revoke admin access โœ… โŒ โŒ
Destructive actions โœ… โœ… โŒ

Session TTL Impact

Platform admins receive shorter session TTLs for security:

Session Type Platform Admin Regular User
Session token 15 minutes 60 minutes
Refresh token 7 days 30 days

Frontend Integration

Auth Store

The /me endpoint now returns platform_admin_role alongside is_privileged:

// $currentUser
{
  id: "uuid",
  email: "meekdenzo@gmail.com",
  is_privileged: true,
  platform_admin_role: "SUPER_ADMIN",  // new field
  // ...
}

Tower Routes

Route Description Access
/tower Main dashboard is_privileged
/tower โ†’ Team tab Admin management All platform admins (Invite/Revoke: SUPER_ADMIN only)
/tower/accept-invite?token=xxx Invite acceptance Authenticated + matching email

The Tower link in SideNav, UserMenu, and footer is conditionally rendered based on $currentUser?.is_privileged.

Seed Migration

The initial SUPER_ADMINs are seeded by email lookup (not hardcoded UUIDs), ensuring the migration survives database wipes:

INSERT INTO platform_admins (user_id, role)
SELECT id, 'SUPER_ADMIN'::platform_admin_role
FROM users
WHERE email IN ('meekdenzo@gmail.com', 'mallnline.dev@gmail.com')
ON CONFLICT (user_id) DO NOTHING;

Component State

The Team tab in +page.svelte uses the following Svelte 5 runes state:

State Variable Type Description
teamAdmins Array<{id, user_id, role, granted_by, granted_at, email, username, display_name}> Fetched admin list
teamLoading boolean Loading state for admin list
teamError string Error message from fetch
showInviteModal boolean Invite modal visibility
inviteEmail string Email input binding
inviteRole 'ADMIN' | 'VIEWER' Role selector binding
inviteSubmitting boolean Invite submission in progress
inviteMessage string Success toast text
inviteError string Invite error text
isSuperAdmin boolean (derived) user?.platform_admin_role === 'SUPER_ADMIN'

File Reference

File Purpose
src/routes/tower/+page.svelte Main Tower dashboard with Team tab (lines 835โ€“950)
src/routes/tower/+page.ts SSR disabled (ssr: false)
src/routes/tower/accept-invite/+page.svelte Invite acceptance page
src/routes/tower/accept-invite/+page.ts SSR disabled
src/stores/auth.ts User interface with platform_admin_role field
src/lib/services/auth.ts refreshAuth() used after invite acceptance
tests/platformAdmin.test.ts Unit tests (25 cases)
tests/tower-team.test.ts E2E tests (5 cases)