Files
publisher-dashboard/docs/initial-app.md
RevIQ c8152ce86e Implement Workstream M: Admin Pages (Frontend)
Add superuser admin interface for managing organizations and users:
- Admin layout with access control (redirects non-superusers)
- Admin dashboard with org/user counts and quick actions
- Org management: list, create, view/edit details, manage sites
- User management: list, view details, toggle superuser, confirm email
- SuperuserBadge component for consistent superuser indication
- Sidebar shows admin link (shield icon) for superusers only
- Centralized date formatting utility at $lib/utils/format-date.ts
- Test plan documentation at docs/test-plans/admin.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:18:36 +08:00

98 KiB

Initial App: Auth & Organization Management

Goal

Set up basic authentication and user/organization management to prepare for the rest of the dashboard. This includes:

  • User signup/login via passkeys and passwords
  • Email verification flow
  • Organizations with members and role-based permissions
  • Superadmin functionality for managing orgs and sites
  • Deployment

Tech Stack

Layer Technology Rationale
Frontend SvelteKit (static) Already using in other RevIQ projects
State Management TanStack Svelte Query Server state caching, auto-refresh, loading states
Backend oRPC End-to-end type-safe RPC, OpenAPI generation
Database Postgres Reliable, good tooling, sufficient for our scale
Query Builder Kysely Type-safe SQL, lightweight, close to raw SQL
Migrations dbmate Simple raw SQL migrations
Module System NodeNext Use .js extensions on all local imports

oRPC Documentation


Monorepo Structure

Use a monorepo with contract-first oRPC. The contract and database schema are defined in shared packages.

publisher-dashboard/
├── packages/
│   ├── api-contract/              # Shared oRPC contract
│   │   ├── package.json           # @publisher-dashboard/api-contract
│   │   └── src/
│   │       ├── index.ts
│   │       ├── contract.ts
│   │       └── schemas/
│   │           ├── auth.ts
│   │           ├── user.ts
│   │           ├── org.ts
│   │           └── admin.ts
│   ├── db-schema/                 # Generated Kysely types only
│   │   ├── package.json           # @publisher-dashboard/db-schema
│   │   └── src/
│   │       ├── index.ts           # Re-export types
│   │       └── types.ts           # Generated by kysely-codegen
│   └── db/                        # Database client + queries
│       ├── package.json           # @publisher-dashboard/db
│       └── src/
│           ├── index.ts           # Export db instance
│           ├── client.ts          # Kysely client setup
│           └── queries/           # Reusable query functions
│               ├── users.ts
│               ├── sessions.ts
│               └── orgs.ts
├── apps/
│   ├── api-server/                # oRPC server implementation
│   │   ├── package.json
│   │   └── src/
│   │       ├── index.ts           # Server entry point (Bun.serve)
│   │       ├── router.ts
│   │       ├── procedures/
│   │       │   ├── base.ts        # Middleware (auth, superuser, loginRequest)
│   │       │   ├── auth/          # Auth procedures
│   │       │   ├── me/            # User self-management procedures
│   │       │   └── admin/         # Superuser-only procedures
│   │       │       ├── _routes.ts # Consolidated admin route exports
│   │       │       ├── helpers.ts # Shared transform functions
│   │       │       ├── auth/
│   │       │       ├── orgs/
│   │       │       └── users/
│   │       └── middleware/
│   ├── publisher-dashboard/       # SvelteKit frontend
│   │   ├── package.json
│   │   └── src/
│   │       ├── lib/
│   │       │   ├── api.ts
│   │       │   └── queries.ts
│   │       └── routes/
│   └── cli/                       # Admin CLI (stricli)
│       ├── package.json           # bin: { "reviq": "./dist/index.js" }
│       └── src/
│           ├── index.ts           # CLI entry point
│           └── commands/
│               ├── auth.ts        # reviq auth login/logout/status
│               ├── bootstrap.ts   # reviq bootstrap (superuser + org, direct DB)
│               ├── user.ts        # reviq user create/confirm-email
│               └── org.ts         # reviq org create/list/add-site
├── db/
│   └── migrations/                # dbmate migrations
├── package.json                   # Workspace root (workspaces: ["packages/*", "apps/*"])
├── dbmate.yml
└── bun.lockb

Package dependencies:

  • db depends on db-schema
  • api-server depends on api-contract, db
  • publisher-dashboard depends on api-contract
  • cli depends on api-contract, db (uses API for most commands, direct DB only for bootstrap)

Contract Definition

// packages/api-contract/src/contract.ts
import { oc } from "@orpc/contract";
import { authContract } from "./schemas/auth.js";
import { userContract } from "./schemas/user.js";
import { orgContract } from "./schemas/org.js";
import { adminContract } from "./schemas/admin.js";

export const contract = {
  auth: authContract,
  me: userContract,
  orgs: orgContract,
  admin: adminContract,
};

Contract Implementation

// apps/api-server/src/router.ts
import { implement } from "@orpc/server";
import { contract } from "@dashboard/api-contract";

const os = implement(contract);

export const router = os.router({
  auth: authRouter,
  me: userRouter,
  orgs: orgRouter,
  admin: adminRouter,
});

Database Schema

-- db/migrations/001_initial_schema.sql

-- Enums
CREATE TYPE org_role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE passkey_device_type AS ENUM ('singleDevice', 'multiDevice');

-- Users (authentication)
CREATE TABLE users (
    id              SERIAL PRIMARY KEY,
    email           TEXT UNIQUE NOT NULL,
    email_verified_at TIMESTAMPTZ,       -- NULL until verified
    full_name       TEXT,                -- Legal/full name (set in profile setup)
    display_name    TEXT,                -- Preferred display name (set in profile setup)
    phone_number    TEXT,                -- E.164 format, validated with libphonenumber-js
    avatar_url      TEXT,
    password_hash   TEXT,                -- NULL if using passkey only
    require_passkey BOOLEAN NOT NULL DEFAULT false,  -- If true, must use passkey to login
    is_superuser   BOOLEAN NOT NULL DEFAULT false,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Email verification tokens
CREATE TABLE email_verifications (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token           TEXT UNIQUE NOT NULL,
    expires_at      TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Password reset tokens
CREATE TABLE password_resets (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token           TEXT UNIQUE NOT NULL,
    expires_at      TIMESTAMPTZ NOT NULL,
    used_at         TIMESTAMPTZ,             -- Set when token is used
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- WebAuthn challenges (short-lived, for passkey registration/authentication)
CREATE TABLE webauthn_challenges (
    id              BIGSERIAL PRIMARY KEY,
    options         JSONB NOT NULL,      -- Full options object from simplewebauthn
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Passkeys (WebAuthn credentials)
CREATE TABLE passkeys (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    credential_id   BYTEA UNIQUE NOT NULL,
    public_key      BYTEA NOT NULL,
    webauthn_user_id TEXT UNIQUE NOT NULL,
    counter         BIGINT NOT NULL DEFAULT 0,
    device_type     passkey_device_type NOT NULL,
    backup_eligible BOOLEAN NOT NULL,
    backup_status   BOOLEAN NOT NULL,
    transports      JSONB,               -- ['usb', 'ble', 'nfc', 'internal']
    rpid            TEXT NOT NULL,
    name            TEXT NOT NULL,       -- e.g. "MacBook Touch ID"
    last_used_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- User devices (tracks all devices a user has logged in from)
-- Note: device_fingerprint is NOT unique - multiple users can share the same
-- browser/device (e.g., shared family computer, work terminal)
CREATE TABLE user_devices (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_fingerprint TEXT NOT NULL,        -- Random ID stored in cookie
    name            TEXT,                    -- e.g. "Chrome on MacOS"
    is_trusted      BOOLEAN NOT NULL DEFAULT false,
    user_agent      TEXT NOT NULL,
    ip_address      TEXT,
    city            TEXT,
    region          TEXT,
    country         TEXT,
    last_used_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(user_id, device_fingerprint)      -- One device record per user+fingerprint
);

-- Sessions (token_hash stores SHA-256 hash of token, not raw token)
-- Sessions are kept for audit history, not deleted by cron
CREATE TABLE sessions (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_id       BIGINT REFERENCES user_devices(id) ON DELETE SET NULL,
    token_hash      TEXT UNIQUE NOT NULL,    -- SHA-256 hash of session token
    trusted_mode    BOOLEAN NOT NULL,        -- True if passkey or email-confirmed login
    ip_address      TEXT,
    city            TEXT,
    region          TEXT,
    country         TEXT,
    user_agent      TEXT,
    expires_at      TIMESTAMPTZ NOT NULL,
    revoked_at      TIMESTAMPTZ,             -- NULL if active, set when logged out
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- API tokens (for CLI and programmatic access)
CREATE TABLE api_tokens (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,           -- e.g. "CLI token", "CI/CD"
    token_hash      TEXT UNIQUE NOT NULL,    -- SHA-256 hash of token
    last_used_at    TIMESTAMPTZ,
    expires_at      TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Login requests (tracks login attempts for polling and email confirmation)
-- Deleted after successful login via loginIfRequestIsCompleted()
-- Note: Only created for existing users. Non-existent users get a fake token (anti-enumeration).
CREATE TABLE login_requests (
    id              BIGSERIAL PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    email           TEXT NOT NULL,           -- Email entered by user (normalized lowercase)
    token           TEXT UNIQUE,             -- Token sent in email link (NULL until email sent)
    device_fingerprint TEXT,                 -- From cookie at login request time
    ip_address      TEXT,
    city            TEXT,
    region          TEXT,
    country         TEXT,
    user_agent      TEXT,
    completed_at    TIMESTAMPTZ,             -- NULL until login completed
    expires_at      TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Orgs (companies/accounts)
CREATE TABLE orgs (
    id              SERIAL PRIMARY KEY,
    slug            TEXT UNIQUE NOT NULL,
    display_name    TEXT NOT NULL,
    logo_url        TEXT,                -- Cloudflare R2 URL
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Org members (users belonging to orgs)
CREATE TABLE org_members (
    id              SERIAL PRIMARY KEY,
    org_id          INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role            org_role NOT NULL DEFAULT 'member',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(org_id, user_id)
);

-- Org sites (maps orgs to domains for report access)
CREATE TABLE org_sites (
    id              SERIAL PRIMARY KEY,
    org_id          INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
    domain          TEXT NOT NULL,       -- key used to filter reports
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(domain)
);

-- Org invites (pending invitations)
CREATE TABLE org_invites (
    id              SERIAL PRIMARY KEY,
    org_id          INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
    email           TEXT NOT NULL,
    role            org_role NOT NULL DEFAULT 'member',
    invited_by      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token           TEXT UNIQUE NOT NULL,
    expires_at      TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(org_id, email)
);

-- Indexes (UNIQUE constraints already create indexes, so we skip those)
-- Note: orgs.slug has an index via UNIQUE constraint (used for all org API lookups)
-- Lookup indexes
CREATE INDEX idx_passkeys_user ON passkeys(user_id);
CREATE INDEX idx_user_devices_user ON user_devices(user_id);
CREATE INDEX idx_user_devices_fingerprint ON user_devices(device_fingerprint);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_device ON sessions(device_id);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_login_requests_user ON login_requests(user_id);
CREATE INDEX idx_password_resets_user ON password_resets(user_id);
CREATE INDEX idx_org_members_user ON org_members(user_id);
CREATE INDEX idx_org_members_org ON org_members(org_id);
CREATE INDEX idx_org_sites_org ON org_sites(org_id);
CREATE INDEX idx_org_invites_email ON org_invites(email);

-- Active session lookup (excludes revoked sessions)
CREATE INDEX idx_sessions_active ON sessions(token_hash) WHERE revoked_at IS NULL;

-- Cleanup job indexes (for deleting expired rows)
-- Note: sessions are NOT deleted, kept for audit history (use revoked_at instead)
CREATE INDEX idx_api_tokens_expires ON api_tokens(expires_at);
CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at);
CREATE INDEX idx_password_resets_expires ON password_resets(expires_at);
CREATE INDEX idx_webauthn_challenges_created ON webauthn_challenges(created_at);
CREATE INDEX idx_org_invites_expires ON org_invites(expires_at);
CREATE INDEX idx_login_requests_expires ON login_requests(expires_at);

API Procedures

oRPC procedures served at /api/v1/rpc. Uses Zod for input validation. All emails are normalized to lowercase on insert.

Token Expiration Durations

Token Type Duration Notes
Session (rev.session_token) 7 days Sliding window - refreshed on activity
Login request 15 minutes Deleted after successful login
Email verification 24 hours Can resend if expired
Password reset 1 hour Single use, marked used_at when consumed
WebAuthn challenge 5 minutes Cleanup job deletes old challenges
Org invite 7 days Can be resent
API token Configurable User sets expiry, max 1 year

API Token Format

API tokens use base58 encoding with a reviq_ prefix for easy identification:

import bs58 from "bs58";
import { randomBytes } from "crypto";

function generateApiToken(): string {
  const bytes = randomBytes(32); // 256 bits of entropy
  return `reviq_${bs58.encode(bytes)}`;
}
// e.g., "reviq_3yZe7d2kJ8qNfGhBvCxMpLsRtWuAeYi4nKoP5mXvQwE"

Why base58?

  • URL-safe (no +, /, = like base64)
  • No ambiguous characters (no 0O1lI)
  • Easy to copy/paste
  • reviq_ prefix makes tokens grep-able in logs

Storage: Hash with SHA-256 before storing in api_tokens.token_hash. Never store raw tokens.

Password Strength

Use zxcvbn for password strength validation:

import zxcvbn from "zxcvbn";

function validatePassword(
  password: string,
  userInputs: string[] = []
): { valid: boolean; feedback: string[] } {
  const result = zxcvbn(password, userInputs); // userInputs = [email, displayName] to penalize
  if (result.score < 3) {
    return {
      valid: false,
      feedback:
        result.feedback.suggestions.length > 0
          ? result.feedback.suggestions
          : [
              "Password is too weak. Try a longer phrase or add numbers/symbols.",
            ],
    };
  }
  return { valid: true, feedback: [] };
}

Requirements:

  • Minimum score of 3 (out of 4) from zxcvbn
  • Pass user's email and display name as userInputs to penalize passwords containing them

Auth Procedures (auth.*)

In development, use reviq user confirm-email --email <email> to bypass email verification.

Trusted Devices

Users can register devices as "trusted" to skip email confirmation on future password logins:

  • A device_fingerprint cookie (random UUID, HttpOnly, 1 year expiry) identifies the device
  • When logging in from a trusted device, password login completes immediately
  • When logging in from a new device, email confirmation is required for password login
  • Passkey login always works regardless of device trust status
  • Device stores: user agent, IP, geolocation (city/region/country)
  • Users can view and revoke trusted devices in /account/auth

Session Metadata

All sessions store:

  • trusted_mode: true if logged in via passkey, false if password
  • ip_address, city, region, country: Geolocation at login time
  • user_agent: Browser/device info
  • device_id: Link to user device record (if any)
  • Sessions are never deleted - kept for audit history, marked with revoked_at when logged out
  • Sessions expire after 7 days (sliding window)

Signup Flow

Signup creates an account with email + passkey or email + password. Profile info (name, phone) is collected separately after signup.

  1. Create account (/auth/signup):

    • Email (required)
    • Check browserSupportsWebAuthn() from @simplewebauthn/browser:
      • If supported → show "Create account with passkey" (primary) + "Use password instead" link
      • If not supported → show password + confirm password fields
    • Password must pass zxcvbn validation (score ≥ 3)
  2. Account creation (auth.signup):

    • Normalizes email to lowercase
    • Creates user with email_verified_at = NULL, display_name = NULL
    • If passkey: verifies registration response, stores passkey with name "Default"
    • If password: validates strength with zxcvbn, stores password hash
    • Creates session immediately → sets rev.session_token cookie (7 day expiry)
    • Sends verification email in background
  3. Profile setup (/auth/setup/user):

    • Shown after signup (and on any page load if display_name is NULL)
    • Collects: display name (required), full name (optional), phone number (optional)
    • Calls me.setupProfile({ displayName, fullName?, phoneNumber? })
  4. Email verification (optional, can happen later):

    • User clicks link → auth.verifyEmail({ token }) → sets email_verified_at
    • Some features may require verified email

Anti-enumeration: If email already exists, show generic "Check your email" message instead of revealing the account exists.

Login Flow

The login flow is split into two steps, with all authentication methods completing via loginIfRequestIsCompleted():

Step 1: Email Entry (/auth/login)

  • User enters email address
  • Calls auth.createLoginRequest({ email }):
    • Normalizes email to lowercase
    • Reads/sets device_fingerprint cookie
    • If user exists: Creates row in login_requests with email, device fingerprint, IP, geo, user agent
    • If user doesn't exist: Generates a fake token (no DB row created)
    • Sets login_request_token cookie (15 min expiry) - real or fake token
    • Returns { hasPasskey, hasPassword, isTrustedDevice, email }
  • Anti-enumeration: Always succeeds. Non-existent users get a fake token.

Step 2: Authentication (depends on account state)

  • Has passkey/auth/login/passkey - use passkey (recommended)
  • Has password + trusted device/auth/login/password - enter password
  • Has password + new device/auth/login/password - enter password, then email confirmation
  • No password AND no passkey/auth/confirm - shows "Check your email" (no email sent, polling will expire)

All login methods complete via loginIfRequestIsCompleted():

  • Passkey: verifyAuthentication() marks complete → poll creates session
  • Password (trusted): loginPassword() marks complete → poll creates session
  • Password (untrusted): loginPassword() sends email → user clicks link → poll creates session

Error messages: All login failures return "Invalid email or password" to prevent enumeration.

Login Request Flow

All login attempts use the login_requests table (for existing users) or fake tokens (for non-existent users), tracked via login_request_token cookie:

  1. Create login request: auth.createLoginRequest({ email })

    • Normalizes email to lowercase
    • Reads/sets device_fingerprint cookie
    • If user exists: Creates row in login_requests with device_fingerprint, ip_address, city, region, country, user_agent
    • If user doesn't exist: Generates fake token (random UUID), no DB row
    • Sets login_request_token cookie (real ID or fake token)
    • Returns { hasPasskey, hasPassword, isTrustedDevice, email } (all false for non-existent users)
  2. Authentication (marks completed_at when successful):

    • Passkey: createAuthenticationOptions()verifyAuthentication() → marks complete
    • Password (trusted): loginPassword() → marks complete immediately
    • Password (untrusted): loginPassword() → sends email → loginPasswordConfirm({ token }) marks complete
  3. Complete login: Client polls auth.loginIfRequestIsCompleted():

    • Reads login_request_token cookie
    • If fake token (no DB row): Returns { status: 'pending' } until expired, then { status: 'expired' }
    • If real token: Returns { status: 'pending' | 'completed' | 'expired' }
    • When completed:
      • Creates session (7 day expiry)
      • Creates/updates user_devices record with latest geo info
      • Deletes the login_request row
      • Clears login_request_token cookie
      • Returns { status: 'completed', redirectTo: '/auth/trust-device' | '/dashboard' }
  4. Complete via CLI (dev only): reviq admin complete-login --email user@example.com

    • Finds most recent login request for user
    • Sets completed_at = now()

Password Reset Flow

  1. Request reset (/auth/forgot-password):

    • User enters email
    • auth.forgotPassword({ email }):
      • Normalizes email to lowercase
      • If user exists: creates password_resets row, sends email
      • Always returns success (anti-enumeration)
    • Show "Check your email" message
  2. Reset password (/auth/reset-password?token=xxx):

    • Page loads with token from URL
    • User enters new password + confirm
    • auth.resetPassword({ token, newPassword }):
      • Validates token not expired and not used
      • Validates password strength with zxcvbn
      • Updates users.password_hash
      • Sets password_resets.used_at = now()
      • Revokes all existing sessions for security
    • Redirect to /auth/login with success message

Procedures

  • auth.signup({ email, password?, passkeyInfo?: { challengeId, response: RegistrationResponseJSON } }) - Creates user, sets session cookie, sends verification email. Must provide either password or passkeyInfo. Password validated with zxcvbn. Passkey stored with name "Default".
  • auth.verifyEmail({ token }) - Verify email token, sets email_verified_at
  • auth.resendVerificationEmail() - Resend verification email to current user (rate-limited)
  • auth.createLoginRequest({ email }) - Reads/sets device_fingerprint cookie. If user exists: creates login request with device/geo info. If user doesn't exist: generates fake token. Sets login_request_token cookie, returns { hasPasskey, hasPassword, isTrustedDevice, email }.
  • auth.loginPassword({ password }) - Reads login_request_token cookie, validates password. If trusted device: marks complete. If untrusted: sends confirmation email. Returns "Invalid email or password" on failure. Returns error for fake tokens.
  • auth.loginPasswordConfirm({ token }) - Called when user clicks password confirmation link, marks login request complete
  • auth.loginIfRequestIsCompleted() - Reads login_request_token cookie, returns { status, redirectTo? }. For fake tokens: returns pending until expired. When completed: creates session, updates device, deletes login request, clears cookie.
  • auth.forgotPassword({ email }) - Sends password reset email if user exists. Always returns success (anti-enumeration).
  • auth.resetPassword({ token, newPassword }) - Validates token, validates password with zxcvbn, updates password, revokes all sessions.
  • auth.logout - Revoke current session (sets revoked_at)

WebAuthn Procedures (auth.webauthn.*)

These procedures wrap the WebAuthn registration and authentication flows. Both createRegistrationOptions and createAuthenticationOptions store the challenge in webauthn_challenges table and return a challengeId.

  • auth.webauthn.createRegistrationOptions({ email }) - Creates challenge in DB, returns { challengeId, options: PublicKeyCredentialCreationOptionsJSON }
  • auth.webauthn.verifyRegistration({ challengeId, response: RegistrationResponseJSON }) - Verify registration response against stored challenge, store passkey in DB
  • auth.webauthn.createAuthenticationOptions() - Reads login_request_token cookie, creates challenge in DB, returns { challengeId, options: PublicKeyCredentialRequestOptionsJSON }
  • auth.webauthn.verifyAuthentication({ challengeId, response: AuthenticationResponseJSON }) - Reads login_request_token cookie, verifies authentication response against stored challenge, marks login request complete

Usage:

  • Signup with passkey: createRegistrationOptions → user creates passkey → signup with passkeyInfo
  • Login: createLoginRequestcreateAuthenticationOptions → user authenticates → verifyAuthentication → trust device screen
  • Add passkey (logged in): createRegistrationOptions → user creates passkey → verifyRegistration

User Procedures (me.*)

Profile

  • me.get - Get current user profile (includes needsSetup: boolean if display_name is NULL)
  • me.setupProfile({ displayName, fullName?, phoneNumber? }) - Initial profile setup (required before using app)
  • me.updateProfile({ displayName?, fullName?, phoneNumber?, avatarUrl? }) - Update profile fields
  • me.delete - Delete account (GDPR) - requires password confirmation

Authentication Settings

  • me.setPassword({ currentPassword?, newPassword }) - Set or update password. currentPassword required if user already has a password. Validates with zxcvbn.
  • me.listPasskeys - List user's passkeys with name, created_at, last_used_at
  • me.createPasskey({ name }) - Register a new passkey. Name is required (e.g., "MacBook Pro Touch ID").
  • me.renamePasskey({ passkeyId, name }) - Rename a passkey
  • me.deletePasskey({ passkeyId }) - Remove a passkey. Cannot delete last passkey if user has no password.

Sessions & Devices

  • me.listSessions - List sessions (includes revoked, for audit history) with IP, location, device info
  • me.revokeSession({ sessionId }) - Revoke a specific session
  • me.revokeAllSessions - Revoke all sessions except current
  • me.getDeviceInfo() - Get current device info for trust device screen: { ip, location, browser, os, suggestedName }
  • me.trustDevice({ name }) - Trust the current device (sets is_trusted = true in user_devices)
  • me.listTrustedDevices - List trusted devices with name, last IP/location, last_used_at
  • me.untrustDevice({ deviceId }) - Remove trust from a device (sets is_trusted = false)
  • me.revokeAllTrustedDevices - Remove trust from all devices

Org Procedures (orgs.*)

All org procedures take slug (not id) as the org identifier. The orgs.slug column has a unique index for fast lookups.

  • orgs.list - List orgs the current user is a member of
  • orgs.create - Create a new org (user becomes owner), returns slug
  • orgs.get({ slug }) - Get org details (members only)
  • orgs.update({ slug, ... }) - Update org (display_name, logo_url) - admin/owner only
  • orgs.delete({ slug }) - Delete org - owner only
  • orgs.leave({ slug }) - Leave an org (cannot leave if you're the only owner)

Org Member Procedures (orgs.members.*)

  • orgs.members.list({ slug }) - List org members - members can view
  • orgs.members.updateRole({ slug, userId, role }) - Change member role - admin/owner only
  • orgs.members.remove({ slug, userId }) - Remove member - admin/owner only

Org Invite Procedures (orgs.invites.*)

  • orgs.invites.list({ slug }) - List pending invites - admin/owner only
  • orgs.invites.create({ slug, email, role }) - Create invite - admin/owner only
  • orgs.invites.cancel({ slug, inviteId }) - Cancel invite - admin/owner only
  • orgs.invites.accept({ token }) - Accept invite by token (creates membership)

Org Site Procedures (orgs.sites.*)

  • orgs.sites.list({ slug }) - List sites for org - members can view
  • (Sites are managed by superusers only - see Admin Procedures)

Admin Procedures (admin.*) - Superuser only

  • admin.orgs.list - List all orgs
  • admin.orgs.get({ slug }) - Get org details
  • admin.orgs.create({ slug, displayName, ownerEmail }) - Create org
  • admin.orgs.update({ slug, ... }) - Update org
  • admin.orgs.delete({ slug }) - Delete org
  • admin.orgs.listSites({ slug }) - List sites for org
  • admin.orgs.addSite({ slug, domain }) - Add site to org
  • admin.orgs.removeSite({ slug, domain }) - Remove site from org
  • admin.users.list - List all users
  • admin.users.get({ email }) - Get user details
  • admin.users.create({ email, name?, orgSlug?, orgRole? }) - Create passwordless user
  • admin.users.update({ email, ... }) - Update user (set is_superuser, etc.)
  • admin.users.confirmEmail({ email }) - Confirm a user's email (used by CLI)
  • admin.auth.completeLogin({ email }) - Complete most recent login request for user (used by CLI)

Authentication & Context

oRPC uses middleware to handle authentication and inject user data into the context. This section defines how cookies and API keys are read, and how different procedure types enforce access control.

See: oRPC Middleware docs, oRPC Context docs

Plugins Setup

Both RequestHeadersPlugin and ResponseHeadersPlugin are required to read cookies/headers and set response cookies:

import {
  RequestHeadersPlugin,
  ResponseHeadersPlugin,
} from "@orpc/server/plugins";

const handler = new RPCHandler(router, {
  plugins: [new RequestHeadersPlugin(), new ResponseHeadersPlugin()],
});

Context Types

import type {
  RequestHeadersPluginContext,
  ResponseHeadersPluginContext,
} from "@orpc/server/plugins";

// Base context provided by plugins
interface BaseContext
  extends RequestHeadersPluginContext, ResponseHeadersPluginContext {}

// Context after auth middleware runs
interface AuthContext extends BaseContext {
  user: { id: number; email: string; isSuperuser: boolean };
}

// Context for org-scoped procedures
interface OrgContext extends AuthContext {
  org: { id: number; slug: string };
  membership: { role: "owner" | "admin" | "member" };
}

Auth Middleware

Reads session cookie or API key header to authenticate the user:

import { getCookie } from "@orpc/server/helpers";
import { ORPCError } from "@orpc/server";

const authMiddleware = base.middleware(async ({ context, next }) => {
  // Try session cookie first
  let tokenHash: string | undefined;
  const sessionToken = getCookie(context.reqHeaders, "session");
  if (sessionToken) {
    tokenHash = hashToken(sessionToken);
  }

  // Fall back to API key header (for CLI)
  const apiKey = context.reqHeaders?.get("x-api-key");
  if (!tokenHash && apiKey) {
    tokenHash = hashToken(apiKey);
  }

  if (!tokenHash) {
    throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
  }

  // Look up session or API token
  const session = await db
    .selectFrom("sessions")
    .where("token_hash", "=", tokenHash)
    .where("expires_at", ">", new Date())
    .selectAll()
    .executeTakeFirst();

  const apiToken = !session
    ? await db
        .selectFrom("api_tokens")
        .where("token_hash", "=", tokenHash)
        .where("expires_at", ">", new Date())
        .selectAll()
        .executeTakeFirst()
    : undefined;

  const userId = session?.user_id ?? apiToken?.user_id;
  if (!userId) {
    throw new ORPCError("UNAUTHORIZED", {
      message: "Invalid or expired token",
    });
  }

  const user = await db
    .selectFrom("users")
    .where("id", "=", userId)
    .select(["id", "email", "is_superuser"])
    .executeTakeFirstOrThrow();

  return next({
    context: {
      user: { id: user.id, email: user.email, isSuperuser: user.is_superuser },
    },
  });
});

Org Middleware

Validates org slug and user membership:

const orgMiddleware = authMiddleware.middleware(
  async ({ context, input, next }) => {
    const { slug } = input as { slug: string };

    const org = await db
      .selectFrom("orgs")
      .where("slug", "=", slug)
      .select(["id", "slug"])
      .executeTakeFirst();

    if (!org) {
      throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
    }

    const membership = await db
      .selectFrom("org_members")
      .where("org_id", "=", org.id)
      .where("user_id", "=", context.user.id)
      .select(["role"])
      .executeTakeFirst();

    if (!membership && !context.user.isSuperuser) {
      throw new ORPCError("FORBIDDEN", { message: "Not a member of this org" });
    }

    return next({
      context: {
        org,
        membership: membership ?? { role: "owner" as const }, // Superusers act as owners
      },
    });
  }
);

Procedure Types

// Public - no authentication required
const publicProcedure = base;

// Authenticated - requires valid session or API key
const authedProcedure = base.use(authMiddleware);

// Org member - requires auth + org membership
const orgProcedure = base.use(orgMiddleware);

// Org admin - requires auth + org admin/owner role
const orgAdminProcedure = orgProcedure.use(async ({ context, next }) => {
  if (context.membership.role === "member") {
    throw new ORPCError("FORBIDDEN", {
      message: "Admin or owner role required",
    });
  }
  return next();
});

// Org owner - requires auth + org owner role
const orgOwnerProcedure = orgProcedure.use(async ({ context, next }) => {
  if (context.membership.role !== "owner") {
    throw new ORPCError("FORBIDDEN", { message: "Owner role required" });
  }
  return next();
});

// Superuser - requires auth + is_superuser flag
const superuserProcedure = authedProcedure.use(async ({ context, next }) => {
  if (!context.user.isSuperuser) {
    throw new ORPCError("FORBIDDEN", { message: "Superuser access required" });
  }
  return next();
});

Procedure Type Usage

Procedure Type Used For
publicProcedure auth.signup, auth.login*, auth.verifyEmail
authedProcedure auth.logout, me.*
orgProcedure orgs.get, orgs.members.list, orgs.sites.list
orgAdminProcedure orgs.update, orgs.members.*, orgs.invites.*
orgOwnerProcedure orgs.delete
superuserProcedure admin.*

Input Validation

Slug Validation

Org slugs must be alphanumeric with hyphens allowed (but not at start or end), similar to domain name rules:

import { z } from "zod";

const slugSchema = z
  .string()
  .min(2)
  .max(63)
  .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
    message:
      "Slug must be lowercase alphanumeric with hyphens (not at start/end)",
  });

// Usage in procedures
const getOrg = orgProcedure
  .input(z.object({ slug: slugSchema }))
  .handler(async ({ context }) => {
    return context.org;
  });

Email Validation

const emailSchema = z.string().email().toLowerCase();

Phone Number Validation

Phone numbers are stored in E.164 format (e.g., +14155551234). Use libphonenumber-js for validation:

import {
  parsePhoneNumberWithError,
  isValidPhoneNumber,
} from "libphonenumber-js";

const phoneSchema = z
  .string()
  .optional()
  .transform((val) => {
    if (!val) return undefined;
    const phone = parsePhoneNumberWithError(val);
    return phone.format("E.164"); // +14155551234
  })
  .refine((val) => !val || isValidPhoneNumber(val), {
    message: "Invalid phone number",
  });

// Signup input
const passkeyInfoSchema = z.object({
  challengeId: z.number(),
  response: z.any(), // RegistrationResponseJSON from @simplewebauthn/browser
});

const signupInput = z
  .object({
    email: emailSchema,
    password: z.string().min(8).optional(),
    passkeyInfo: passkeyInfoSchema.optional(),
  })
  .refine((data) => data.password || data.passkeyInfo, {
    message: "Either password or passkeyInfo is required",
  });

// Profile setup input
const setupProfileInput = z.object({
  displayName: z.string().min(1).max(100),
  fullName: z.string().max(200).optional(),
  phoneNumber: phoneSchema,
});

Cookies

All cookies use the rev. prefix to namespace them. All cookies are HttpOnly, Secure, and SameSite=Lax.

Cookie Purpose Lifetime
rev.session_token Session token (hashed in DB) 7 days (sliding)
rev.device_fingerprint Identifies devices across users 1 year
rev.login_request_token Tracks current login attempt 15 minutes

rev.session_token - Authentication session

  • Set after successful login (any method)
  • Contains random token, SHA-256 hash stored in sessions.token_hash
  • 7-day sliding window (refreshed on each request)
  • Cleared on logout

rev.device_fingerprint - Device identification

  • Set on first createLoginRequest() call if not present
  • Random UUID identifying this browser/device
  • Multiple users can share the same fingerprint (shared computer)
  • Used to determine trusted device status per user
  • Persists across sessions

rev.login_request_token - Login attempt tracking

  • Set by createLoginRequest(), contains login request ID
  • Read by loginPassword(), createAuthenticationOptions(), verifyAuthentication(), loginIfRequestIsCompleted()
  • Cleared by loginIfRequestIsCompleted() after successful login

Implementation

Use oRPC's cookie helpers:

import { setCookie, deleteCookie, getCookie } from "@orpc/server/helpers";

// Set session cookie (7 day sliding window)
setCookie(context.resHeaders, "rev.session_token", session.token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 7, // 7 days
});

// Set device fingerprint cookie
setCookie(context.resHeaders, "rev.device_fingerprint", fingerprint, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 365, // 1 year
});

// Set login request cookie
setCookie(
  context.resHeaders,
  "rev.login_request_token",
  String(loginRequest.id),
  {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15, // 15 minutes
  }
);

// Read cookies
const sessionToken = getCookie(context.reqHeaders, "rev.session_token");
const deviceFingerprint = getCookie(
  context.reqHeaders,
  "rev.device_fingerprint"
);
const loginRequestId = getCookie(context.reqHeaders, "rev.login_request_token");

// Clear on logout
deleteCookie(context.resHeaders, "rev.session_token", { path: "/" });

// Clear login request after successful login
deleteCookie(context.resHeaders, "rev.login_request_token", { path: "/" });

See: oRPC Cookie Helper docs


Geolocation

Used to capture IP address and location for sessions, devices, and login requests.

Strategy

  1. Cloudflare headers (preferred) - Free with any Cloudflare plan
  2. geoip-lite (fallback) - Bundled MaxMind GeoLite2 database, no API calls

Cloudflare Headers

When behind Cloudflare, these headers are automatically added:

Header Description Example
CF-Connecting-IP Client IP address 203.0.113.42
CF-IPCountry ISO 3166-1 alpha-2 country code US
CF-Region Region/state name California
CF-Region-Code Region code CA
CF-City City name San Francisco
CF-Latitude Latitude 37.7749
CF-Longitude Longitude -122.4194

Note: CF-IPCountry is available on all plans. City/region headers require enabling "IP Geolocation" in Cloudflare dashboard (free).

Fallback: geoip-lite

If Cloudflare headers aren't present (local dev, non-Cloudflare deployment):

pnpm add geoip-lite
pnpm add -D @types/geoip-lite
import geoip from "geoip-lite";

// Returns null if IP not found
const geo = geoip.lookup("203.0.113.42");
// {
//   country: "US",
//   region: "CA",
//   city: "San Francisco",
//   ll: [37.7749, -122.4194],
//   ...
// }

Implementation

interface GeoInfo {
  ip: string;
  city: string | null;
  region: string | null;
  country: string | null;
}

function getGeoInfo(headers: Headers): GeoInfo {
  // Get IP: Cloudflare header or X-Forwarded-For fallback
  const ip =
    headers.get("CF-Connecting-IP") ??
    headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ??
    "unknown";

  // Try Cloudflare headers first
  const cfCity = headers.get("CF-City");
  if (cfCity) {
    return {
      ip,
      city: cfCity,
      region: headers.get("CF-Region"),
      country: headers.get("CF-IPCountry"),
    };
  }

  // Fallback to geoip-lite
  if (ip !== "unknown") {
    const geo = geoip.lookup(ip);
    if (geo) {
      return {
        ip,
        city: geo.city || null,
        region: geo.region || null,
        country: geo.country || null,
      };
    }
  }

  return { ip, city: null, region: null, country: null };
}

// Format for display
function formatLocation(geo: GeoInfo): string {
  const parts = [geo.city, geo.region, geo.country].filter(Boolean);
  return parts.join(", ") || "Unknown location";
}
// e.g., "San Francisco, CA, US" or "London, England, GB"

Usage

Called when creating sessions and updating devices (in loginIfRequestIsCompleted):

const geo = getGeoInfo(context.reqHeaders);
const userAgent = context.reqHeaders.get("User-Agent") ?? "Unknown";
const deviceFingerprint = getCookie(
  context.reqHeaders,
  "rev.device_fingerprint"
);

// Upsert device record with latest geo/user agent info
// (user's location and browser can change over time)
if (deviceFingerprint) {
  await db
    .insertInto("user_devices")
    .values({
      user_id: user.id,
      device_fingerprint: deviceFingerprint,
      ip_address: geo.ip,
      city: geo.city,
      region: geo.region,
      country: geo.country,
      user_agent: userAgent,
    })
    .onConflict((oc) =>
      oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
        ip_address: geo.ip,
        city: geo.city,
        region: geo.region,
        country: geo.country,
        user_agent: userAgent,
        last_used_at: new Date(),
      })
    )
    .execute();
}

// Create session with current geo info
await db.insertInto("sessions").values({
  user_id: user.id,
  token_hash: hashToken(token),
  ip_address: geo.ip,
  city: geo.city,
  region: geo.region,
  country: geo.country,
  user_agent: userAgent,
  // ...
});

Permissions

Org Roles

  • member - Can view org data and list of sites
  • admin - Can view org data and invite/remove members
  • owner - Can view org data, invite/remove members, and manage org settings (rename, delete)

Superuser

Superusers (users.is_superuser = true) can access all /admin procedures. This includes viewing any org (even without membership), creating/deleting orgs, adding/removing sites from any org, and granting/revoking superuser status to other users.


Environment Variables

Backend (api/.env)

DATABASE_URL=postgres://user:pass@host:5432/dashboard?sslmode=require
AUTH_ORIGIN=https://dashboard.rev.iq
POSTMARK_API_KEY=xxx
FROM_EMAIL=noreply@rev.iq

Frontend

No environment variables needed - API is served on the same domain at /api/v1/.


Frontend Pages

apps/publisher-dashboard/src/routes/
├── +page.svelte              # Landing, redirects to login or dashboard
├── +layout.svelte            # QueryClientProvider, auth context
├── auth/
│   ├── login/
│   │   ├── +page.svelte      # Step 1: Email entry
│   │   ├── passkey/+page.svelte  # Passkey authentication
│   │   └── password/+page.svelte # Password authentication
│   ├── confirm/+page.svelte  # Poll for email confirmation
│   ├── trust-device/+page.svelte # Trust this device prompt
│   ├── signup/+page.svelte   # Signup form (email + passkey/password)
│   ├── setup/
│   │   └── user/+page.svelte # Profile setup (display name, full name, phone)
│   ├── verify/+page.svelte   # Email verification callback
│   ├── forgot-password/+page.svelte  # Request password reset
│   └── reset-password/+page.svelte   # Set new password (with token)
├── dashboard/
│   ├── +page.svelte          # List of user's orgs
│   └── [org]/
│       ├── +page.svelte      # Org overview
│       ├── members/+page.svelte   # Manage members
│       └── settings/+page.svelte  # Org settings
├── account/
│   ├── +page.svelte          # Profile settings (display name, full name, phone, avatar)
│   ├── auth/+page.svelte     # Authentication settings (email, password, passkeys)
│   ├── devices/+page.svelte  # Trusted devices (view, revoke trust)
│   └── sessions/+page.svelte # Session history (active + past, revoke)
└── admin/
    ├── +page.svelte          # Admin dashboard
    ├── orgs/
    │   ├── +page.svelte      # List all orgs
    │   ├── new/+page.svelte  # Create new org
    │   └── [id]/+page.svelte # Org details, manage sites
    └── users/
        ├── +page.svelte      # List all users
        └── [id]/+page.svelte # User details, toggle superuser

Auth Pages Specification

All authentication pages live under /auth/. Each page is fully specified below.


/auth/signup - Create Account

Purpose: Create a new account with email + passkey or password.

Layout:

┌─────────────────────────────────────┐
│           Create account            │
├─────────────────────────────────────┤
│  Email                              │
│  ┌─────────────────────────────┐    │
│  │ you@example.com             │    │
│  └─────────────────────────────┘    │
│                                     │
│  [If passkey supported:]            │
│  ┌─────────────────────────────┐    │
│  │  Create account with passkey │   │
│  └─────────────────────────────┘    │
│         Use password instead        │
│                                     │
│  [If passkey NOT supported:]        │
│  Password                           │
│  ┌─────────────────────────────┐    │
│  │ ••••••••                    │    │
│  └─────────────────────────────┘    │
│  Confirm password                   │
│  ┌─────────────────────────────┐    │
│  │ ••••••••                    │    │
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │       Create account        │    │
│  └─────────────────────────────┘    │
│                                     │
│  Already have an account? Log in    │
└─────────────────────────────────────┘

On page load:

  • Check browserSupportsWebAuthn() from @simplewebauthn/browser
  • Show passkey flow if supported, password fields otherwise

Passkey flow:

  1. User enters email, clicks "Create account with passkey"
  2. createRegistrationOptions({ email }){ challengeId, options }
  3. startRegistration(options) → WebAuthn prompt
  4. signup({ email, passkeyInfo: { challengeId, response } })
  5. Server creates user + passkey + session → sets rev.session_token cookie
  6. Redirect to /auth/setup/user

Password flow:

  1. User enters email + password + confirm, clicks "Create account"
  2. Client validates passwords match
  3. signup({ email, password })
  4. Server creates user + session → sets rev.session_token cookie
  5. Redirect to /auth/setup/user

Anti-enumeration: If email exists, show "Check your email for next steps" (don't reveal account exists).

Verification email: Sent in background after account creation.


/auth/setup/user - Profile Setup

Purpose: Collect display name and optional profile info after signup.

Guard: Redirect here from any authenticated page if me.get() returns needsSetup: true.

Layout:

┌─────────────────────────────────────┐
│         Complete your profile       │
├─────────────────────────────────────┤
│  What should we call you? *         │
│  ┌─────────────────────────────┐    │
│  │ Display name                │    │
│  └─────────────────────────────┘    │
│                                     │
│  Full name (for invoices)           │
│  ┌─────────────────────────────┐    │
│  │ Legal name                  │    │
│  └─────────────────────────────┘    │
│                                     │
│  Phone number                       │
│  ┌────┐ ┌──────────────────────┐    │
│  │ +1 │ │ (555) 123-4567       │    │
│  └────┘ └──────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │         Continue            │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

On submit:

  1. Validate display name (required, 1-100 chars)
  2. Validate phone with libphonenumber-js (if provided)
  3. me.setupProfile({ displayName, fullName?, phoneNumber? })
  4. Redirect to /dashboard

/auth/login - Login Email Entry

Purpose: Enter email to start login flow.

Layout:

┌─────────────────────────────────────┐
│              Log in                 │
├─────────────────────────────────────┤
│  Email                              │
│  ┌─────────────────────────────┐    │
│  │ you@example.com             │    │
│  └─────────────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │          Continue           │    │
│  └─────────────────────────────┘    │
│                                     │
│  Don't have an account? Sign up     │
└─────────────────────────────────────┘

On submit:

  1. createLoginRequest({ email })
  2. Sets rev.device_fingerprint cookie (if not present)
  3. Sets rev.login_request_token cookie (real ID or fake token)
  4. Response: { hasPasskey, hasPassword, isTrustedDevice, email }
  5. Redirect based on response:
    • hasPasskey/auth/login/passkey
    • hasPassword/auth/login/password
    • Neither → /auth/confirm (shows "Check your email")

Anti-enumeration: Always succeeds. Non-existent users get a fake token and are sent to /auth/confirm where polling will eventually expire (no email is sent).


/auth/login/passkey - Passkey Authentication

Purpose: Authenticate with passkey (WebAuthn).

Guard: Requires rev.login_request_token cookie. Redirect to /auth/login if missing.

Layout:

┌─────────────────────────────────────┐
│        Sign in with passkey         │
├─────────────────────────────────────┤
│                                     │
│       [Passkey icon/animation]      │
│                                     │
│    Use your fingerprint, face,      │
│    or security key to sign in       │
│                                     │
│  ┌─────────────────────────────┐    │
│  │    Try again                │    │  [shown on error]
│  └─────────────────────────────┘    │
│                                     │
│       Get an email instead          │
│  [If hasPassword:]                  │
│       Log in with password          │
└─────────────────────────────────────┘

On page load:

  1. Auto-trigger: createAuthenticationOptions(){ challengeId, options }
  2. startAuthentication(options) → WebAuthn prompt
  3. On success: verifyAuthentication({ challengeId, response })
  4. Server marks login request complete, creates session
  5. Redirect to /auth/trust-device (or dashboard if already trusted)

On error: Show "Try again" button, keep fallback links visible.


/auth/login/password - Password Authentication

Purpose: Authenticate with password.

Guard: Requires rev.login_request_token cookie. Redirect to /auth/login if missing.

Layout:

┌─────────────────────────────────────┐
│              Log in                 │
├─────────────────────────────────────┤
│  Logging in as user@example.com     │
│                                     │
│  Password                           │
│  ┌─────────────────────────────┐    │
│  │ ••••••••                    │    │
│  └─────────────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │          Sign in            │    │
│  └─────────────────────────────┘    │
│                                     │
│       Get an email instead          │
│       Forgot password?              │
└─────────────────────────────────────┘

On submit:

  1. loginPassword({ password })
  2. If trusted device: login complete → redirect to dashboard
  3. If untrusted device: sends confirmation email → redirect to /auth/confirm

/auth/confirm - Email Confirmation Polling

Purpose: Wait for user to click password confirmation email link (sent when logging in from untrusted device).

Guard: Requires rev.login_request_token cookie. Redirect to /auth/login if missing.

Layout:

┌─────────────────────────────────────┐
│          Check your email           │
├─────────────────────────────────────┤
│                                     │
│       [Email icon/animation]        │
│                                     │
│  We sent a login link to:           │
│  u***@example.com                   │
│                                     │
│  Click the link in the email        │
│  to continue signing in.            │
│                                     │
│       Resend email (47s)            │  [countdown, then enabled]
│                                     │
│  [Dev mode banner:]                 │
│  ┌─────────────────────────────┐    │
│  │ Dev: reviq admin complete-  │    │
│  │ login --email your@email    │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

Polling:

  1. Poll loginIfRequestIsCompleted() every 3 seconds
  2. Response: { status: 'pending' | 'completed' | 'expired' }
  3. On completed: creates session → redirect to /auth/trust-device (or dashboard)
  4. On expired: show error, link to start over

Resend: Rate-limited to once per 60 seconds, show countdown.


/auth/trust-device - Trust Device Prompt

Purpose: Ask user if they want to trust this device for future logins.

Guard: Requires active session. Only shown after login on untrusted device.

Data: Call me.getDeviceInfo() on load to get:

  • ip - Client IP address
  • location - Formatted location string (e.g., "San Francisco, CA, US")
  • browser - Browser name (e.g., "Chrome")
  • os - Operating system (e.g., "Mac OS X")
  • suggestedName - Auto-generated device name (e.g., "Chrome on Mac OS X")

Layout:

┌─────────────────────────────────────┐
│        Trust this device?           │
├─────────────────────────────────────┤
│                                     │
│  You're signing in from a new       │
│  device. Would you like to trust    │
│  it for future logins?              │
│                                     │
│  ┌─────────────────────────────┐    │
│  │ Device info                 │    │
│  │ IP: 203.0.113.42            │    │
│  │ Location: San Francisco, CA │    │
│  │ Browser: Chrome on Mac OS X │    │
│  └─────────────────────────────┘    │
│                                     │
│  Device name                        │
│  ┌─────────────────────────────┐    │
│  │ Chrome on Mac OS X          │    │  [editable, pre-filled]
│  └─────────────────────────────┘    │
│                                     │
│  Trusted devices can sign in with   │
│  just a password (no email needed). │
│                                     │
│  ┌─────────────────────────────┐    │
│  │   Yes, trust this device    │    │
│  └─────────────────────────────┘    │
│                                     │
│       No thanks, just this once     │
└─────────────────────────────────────┘

Device name generation: Use ua-parser-js to parse User-Agent:

import UAParser from "ua-parser-js";

function generateDeviceName(userAgent: string): string {
  const parser = new UAParser(userAgent);
  const browser = parser.getBrowser().name ?? "Unknown browser";
  const os = parser.getOS().name ?? "Unknown OS";
  return `${browser} on ${os}`;
}
// e.g., "Chrome on Mac OS X", "Safari on iOS", "Firefox on Windows"

On "Yes":

  1. Call me.trustDevice({ name }) - sets is_trusted = true in user_devices with user-provided name
  2. Redirect to /dashboard (or /auth/setup/user if needsSetup)

On "No": Redirect to /dashboard (or /auth/setup/user if needsSetup).


/auth/verify - Email Verification Callback

Purpose: Handle email verification link clicks.

URL: /auth/verify?token=<token>

On load:

  1. Extract token from query params
  2. verifyEmail({ token })
  3. On success: redirect to /dashboard with success toast "Email verified!"
  4. On error (invalid/expired): show error with link to resend

/auth/forgot-password - Request Password Reset

Purpose: Request a password reset email.

Layout:

┌─────────────────────────────────────┐
│        Forgot your password?        │
├─────────────────────────────────────┤
│                                     │
│  Enter your email and we'll send    │
│  you a link to reset your password. │
│                                     │
│  Email                              │
│  ┌─────────────────────────────┐    │
│  │ you@example.com             │    │
│  └─────────────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │     Send reset link         │    │
│  └─────────────────────────────┘    │
│                                     │
│       Back to login                 │
└─────────────────────────────────────┘

On submit:

  1. forgotPassword({ email })
  2. Always show success (anti-enumeration): "Check your email for a reset link"
  3. Email sent if user exists (expires in 1 hour)

/auth/reset-password - Set New Password

Purpose: Set a new password using reset token.

URL: /auth/reset-password?token=<token>

Guard: Redirect to /auth/forgot-password if token missing or invalid.

Layout:

┌─────────────────────────────────────┐
│        Reset your password          │
├─────────────────────────────────────┤
│                                     │
│  New password                       │
│  ┌─────────────────────────────┐    │
│  │ ••••••••                    │    │
│  └─────────────────────────────┘    │
│  [Password strength indicator]      │
│                                     │
│  Confirm password                   │
│  ┌─────────────────────────────┐    │
│  │ ••••••••                    │    │
│  └─────────────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │     Reset password          │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

On page load:

  • Validate token is present in URL
  • Optionally pre-validate token with server (show error if expired)

On submit:

  1. Validate passwords match
  2. Validate password strength with zxcvbn (score ≥ 3)
  3. resetPassword({ token, newPassword })
  4. On success: redirect to /auth/login with success toast "Password reset! Please log in."
  5. On error (invalid/expired token): show error with link to request new reset

Account Pages Specification

Account settings pages live under /account/. Requires authentication.


/account - Profile Settings

Purpose: Edit profile information.

Layout:

┌─────────────────────────────────────┐
│           Profile Settings          │
├─────────────────────────────────────┤
│                                     │
│  [Avatar upload]                    │
│                                     │
│  Display name *                     │
│  ┌─────────────────────────────┐    │
│  │ Ian                         │    │
│  └─────────────────────────────┘    │
│                                     │
│  Full name (for invoices)           │
│  ┌─────────────────────────────┐    │
│  │ Ian Macalinao               │    │
│  └─────────────────────────────┘    │
│                                     │
│  Phone number                       │
│  ┌────┐ ┌──────────────────────┐    │
│  │ +1 │ │ (555) 123-4567       │    │
│  └────┘ └──────────────────────┘    │
│                                     │
│  ┌─────────────────────────────┐    │
│  │        Save changes         │    │
│  └─────────────────────────────┘    │
│                                     │
│  ─────────────────────────────────  │
│                                     │
│  [Delete account] (danger zone)     │
└─────────────────────────────────────┘

On save: me.updateProfile({ displayName, fullName?, phoneNumber?, avatarUrl? })


/account/auth - Authentication Settings

Purpose: Manage password and passkeys.

Layout:

┌─────────────────────────────────────┐
│      Authentication Settings        │
├─────────────────────────────────────┤
│                                     │
│  EMAIL                              │
│  ─────────────────────────────────  │
│  user@example.com ✓                 │
│                                     │
│  PASSWORD                           │
│  ─────────────────────────────────  │
│  [If has password:]                 │
│  Password set ✓                     │
│  [Change password]                  │
│                                     │
│  [If no password:]                  │
│  No password set                    │
│  [Set password]                     │
│                                     │
│  PASSKEYS                           │
│  ─────────────────────────────────  │
│  ┌─────────────────────────────┐    │
│  │ Default                     │    │
│  │ Created Jan 5, 2025         │    │
│  │ Last used: Today            │    │
│  │     [Rename] [Delete]       │    │
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │ MacBook Pro Touch ID        │    │
│  │ Created Dec 20, 2024        │    │
│  │ Last used: Yesterday        │    │
│  │     [Rename] [Delete]       │    │
│  └─────────────────────────────┘    │
│                                     │
│  [+ Add passkey]                    │
└─────────────────────────────────────┘

Change password flow:

  1. Click "Change password" → modal with current password, new password, confirm
  2. Validate new password with zxcvbn
  3. me.setPassword({ currentPassword, newPassword })

Set password flow (for passkey-only users):

  1. Click "Set password" → modal with new password, confirm
  2. Validate password with zxcvbn
  3. me.setPassword({ newPassword })

Add passkey flow:

  1. Click "+ Add passkey" → prompt for name (e.g., "Work Laptop")
  2. createRegistrationOptions → WebAuthn prompt
  3. me.createPasskey({ name }) → passkey saved

Delete passkey: Cannot delete last passkey if user has no password.


/account/devices - Trusted Devices

Purpose: View and manage trusted devices.

Layout:

┌─────────────────────────────────────┐
│          Trusted Devices            │
├─────────────────────────────────────┤
│                                     │
│  Trusted devices can sign in with   │
│  just a password (no email needed). │
│                                     │
│  ┌─────────────────────────────┐    │
│  │ Chrome on Mac OS X          │    │
│  │ San Francisco, CA, US       │    │
│  │ Last used: Today            │    │
│  │           [Remove trust]    │    │
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │ Safari on iPhone       ★    │    │  ★ = current device
│  │ San Francisco, CA, US       │    │
│  │ Last used: Today            │    │
│  │           [Remove trust]    │    │
│  └─────────────────────────────┘    │
│                                     │
│  [Remove all trusted devices]       │
└─────────────────────────────────────┘

Remove trust: me.untrustDevice({ deviceId }) - sets is_trusted = false


/account/sessions - Session History

Purpose: View active and past sessions.

Layout:

┌─────────────────────────────────────┐
│           Session History           │
├─────────────────────────────────────┤
│                                     │
│  ACTIVE SESSIONS                    │
│  ┌─────────────────────────────┐    │
│  │ Chrome on Mac OS X     ★    │    │  ★ = current session
│  │ San Francisco, CA, US       │    │
│  │ Started: Jan 5, 2025        │    │
│  │ [via passkey]               │    │
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │ Safari on iPhone            │    │
│  │ Los Angeles, CA, US         │    │
│  │ Started: Jan 3, 2025        │    │
│  │ [via password]  [Revoke]    │    │
│  └─────────────────────────────┘    │
│                                     │
│  [Revoke all other sessions]        │
│                                     │
│  PAST SESSIONS                      │
│  ┌─────────────────────────────┐    │
│  │ Firefox on Windows          │    │
│  │ New York, NY, US            │    │
│  │ Dec 20 - Dec 25, 2024       │    │
│  │ [Logged out]                │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

Revoke session: me.revokeSession({ sessionId })


Auth Flow Diagrams

Signup Flow

┌─────────────────┐
│  /auth/signup   │ Enter email
└────────┬────────┘
         │ signup()
         ▼
┌─────────────────────┐
│ /auth/setup/user    │ Complete profile
└──────────┬──────────┘
           │ setupProfile()
           ▼
┌─────────────────────┐
│     /dashboard      │
└─────────────────────┘

Login Flow

┌─────────────────┐
│  /auth/login    │ Enter email
└────────┬────────┘
         │ createLoginRequest()
         ▼
    ┌────┴──────────────┐ (based on recommendedMethod)
    ▼                   ▼
┌────────┐        ┌────────────────┐
│passkey │        │   password     │
└───┬────┘        └───────┬────────┘
    │                     │
    │    ┌────────────────┴────────────────┐
    │    ▼                                 ▼
    │  (trusted device)           (untrusted device)
    │    │                                 │
    │    │                     ┌───────────┘
    │    │                     ▼
    │    │              ┌─────────────────────┐
    │    │              │   /auth/confirm     │ Poll for email click
    │    │              └──────────┬──────────┘
    │    │                         │ completed
    └────┼─────────────────────────┘
         ▼
    ┌─────────────────────┐
    │ /auth/trust-device  │ (if untrusted device)
    └──────────┬──────────┘
               ▼
    ┌─────────────────────┐
    │     /dashboard      │
    └─────────────────────┘

Libraries

Backend

bun add @orpc/contract @orpc/server @orpc/client zod kysely pg @simplewebauthn/server @noble/hashes postmark libphonenumber-js zxcvbn bs58 ua-parser-js geoip-lite
bun add -D kysely-codegen @types/pg @types/zxcvbn @types/ua-parser-js @types/geoip-lite
Package Purpose
@orpc/contract oRPC contract - define API contract separately
@orpc/server oRPC server - implement contract with handlers
@orpc/client oRPC client - type-safe RPC calls
zod Schema validation for procedure inputs
kysely Type-safe SQL query builder
pg PostgreSQL client for Node.js
kysely-codegen Generate TypeScript types from database schema
@simplewebauthn/server WebAuthn/passkey server-side verification
@noble/hashes Password hashing via scrypt (Workers-compatible)
postmark Transactional email API (verification, invites)
libphonenumber-js Phone number parsing/validation (E.164 format)
zxcvbn Password strength estimation
bs58 Base58 encoding for API tokens
ua-parser-js User-Agent parsing for device names
geoip-lite IP geolocation fallback (bundled MaxMind DB)

Frontend

bun add @orpc/client @orpc/tanstack-query @simplewebauthn/browser @tanstack/svelte-query libphonenumber-js zxcvbn ua-parser-js svelte-sonner
bun add -D @types/zxcvbn @types/ua-parser-js
Package Purpose
@orpc/client oRPC client - type-safe RPC calls
@orpc/tanstack-query oRPC + TanStack Query integration
@simplewebauthn/browser WebAuthn/passkey browser API wrapper
@tanstack/svelte-query Server state management, caching, refetching
libphonenumber-js Phone number parsing/validation/formatting
zxcvbn Client-side password strength estimation
ua-parser-js User-Agent parsing for device name display
svelte-sonner Toast notifications for success/error states

CLI

bun add @orpc/client stricli kysely postgres
Package Purpose
@orpc/client oRPC client for API calls
stricli Type-safe CLI framework with shell completions
kysely Direct DB access (for bootstrap only)
postgres PostgreSQL client (postgres.js, lighter than pg)

Use Bun native utilities wherever possible:

  • Bun.file() / Bun.write() for file I/O
  • Bun.spawn() for subprocesses (e.g., opening browser)
  • import.meta.env for environment variables (not process.env)

Build script (apps/cli/package.json):

{
  "scripts": {
    "build": "bun build ./src/index.ts --compile --outfile=dist/reviq"
  }
}

This produces a standalone reviq binary with no runtime dependencies.


CLI Reference

The CLI (reviq) is used for administrative tasks. Most commands call the API using a stored token; only bootstrap connects directly to the database.

Command Summary

Command Description
reviq bootstrap Initial setup: create superuser, org, and API token
reviq auth login Login via browser and store API token
reviq auth logout Revoke token and remove local credentials
reviq auth status Show current auth state
reviq user create Create a passwordless user
reviq user confirm-email Confirm a user's email (dev helper)
reviq admin complete-login Complete a login request (dev helper)
reviq org create Create an organization
reviq org list List all organizations
reviq org add-site Add a site/domain to an org
reviq org remove-site Remove a site/domain from an org
reviq completions Generate shell completions

Bootstrap

reviq bootstrap --user ian@rev.iq --password "secretpassword"

Performs initial system setup (direct DB access):

  1. Creates/updates user with password
  2. Confirms email and grants superuser
  3. Creates "reviq" org with user as owner
  4. Creates API token and saves to ~/.config/reviq/credentials.json

Authentication

reviq auth login      # Opens browser, stores token locally
reviq auth logout     # Revokes token on server and locally
reviq auth status     # Shows: user, API URL, config path

User Management

reviq user create --email ian@rev.iq --name "Ian MacAlinao"
reviq user create --email ian@rev.iq --org reviq --role member

reviq user confirm-email --email ian@rev.iq
  • create: Creates passwordless user (verified, must use email link or passkey)
  • confirm-email: Confirms email for login (useful in dev to skip email)

Admin Commands

reviq admin complete-login --email ian@rev.iq
  • complete-login: Completes the most recent login request for a user (magic link or password confirmation). The user's polling client will receive the session on next poll. Useful in dev to bypass email.

Organization Management

reviq org create --owner ian@rev.iq --name "My Org"
reviq org list
reviq org add-site --org my-org --domain example.com
reviq org remove-site --org my-org --domain example.com

Shell Completions

reviq completions bash >> ~/.bashrc
reviq completions zsh >> ~/.zshrc
reviq completions fish > ~/.config/fish/completions/reviq.fish

Environment Variables

API_BASE_URL=http://localhost:6827/api/v1   # API endpoint
DATABASE_URL=postgres://...                  # For bootstrap only

Dev Setup

Add to devenv.nix to run CLI without building:

scripts.reviq.exec = ''
  bun run --cwd "$DEVENV_ROOT/apps/cli" src/index.ts "$@"
'';

Session Token Hashing

Session and API tokens are hashed before storage to prevent mass session hijacking if the database leaks.

Flow

  1. Create session: Generate random token, return to client, store SHA-256 hash in DB
  2. Validate session: Hash incoming token, lookup by hash
import { createHash } from "crypto";

function hashToken(token: string): string {
  return createHash("sha256").update(token).digest("hex");
}

// Creating a session
const token = crypto.randomUUID(); // returned to client
const tokenHash = hashToken(token); // stored in DB

// Validating a session
const tokenHash = hashToken(incomingToken);
const session = await db
  .selectFrom("sessions")
  .where("token_hash", "=", tokenHash)
  .where("expires_at", ">", new Date())
  .executeTakeFirst();

Cleanup Jobs

Cron jobs to delete expired rows. Without these, tables grow forever.

Note: Sessions are NOT deleted - they're kept for audit history. Instead, expired/logged-out sessions have revoked_at set.

-- Run hourly
DELETE FROM api_tokens WHERE expires_at < now();
DELETE FROM login_requests WHERE expires_at < now();

-- Run daily
DELETE FROM email_verifications WHERE expires_at < now();
DELETE FROM webauthn_challenges WHERE created_at < now() - interval '15 minutes';
DELETE FROM org_invites WHERE expires_at < now();

-- Auto-revoke expired sessions (don't delete, just mark revoked)
UPDATE sessions SET revoked_at = now()
WHERE expires_at < now() AND revoked_at IS NULL;

Open Questions

  1. Deployment - Self-host with Bun, or deploy to edge runtime (Cloudflare Workers)?
  2. Rate limiting - Add rate limiting on auth endpoints to prevent brute force attacks

Implementation Tasks

Tasks are organized into parallel workstreams. Within each workstream, tasks should be completed in order. Workstreams can run concurrently.

Phase 1: Foundation (Must complete before Phase 2)

All Phase 1 tasks can run in parallel.

Workstream A: Database & Schema

  • A1: Create dbmate migration 001_initial_schema.sql with all tables, enums, indexes
  • A2: Set up @publisher-dashboard/db-schema package with kysely-codegen
  • A3: Set up @publisher-dashboard/db package with Kysely client

Workstream B: API Contract

  • B1: Create @publisher-dashboard/api-contract package structure
  • B2: Define Zod schemas for all input/output types (auth, user, org, admin)
  • B3: Define oRPC contract with all procedure signatures

Workstream C: Project Infrastructure

  • C1: Initialize monorepo with workspace config (package.json, bun.lockb)
  • C2: Set up apps/api-server with Bun.serve entry point
  • C3: Set up apps/publisher-dashboard SvelteKit project with TanStack Query
  • C4: Set up apps/cli with stricli framework
  • C5: Create devenv.nix with scripts and environment variables

Phase 2: Core Auth (Depends on Phase 1)

Workstream D: Auth Procedures (Backend)

Depends on: A3, B3, C2

  • D1: Implement auth middleware (session cookie + API key header)
  • D2: Implement auth.signup (email + password or passkey)
  • D3: Implement auth.createLoginRequest with device/geo capture
  • D4: Implement auth.loginPassword (trusted vs untrusted device flow)
  • D5: Implement auth.loginPasswordConfirm (email link handler)
  • D6: Implement auth.loginIfRequestIsCompleted (polling + session creation)
  • D7: Implement auth.logout
  • D8: Implement auth.verifyEmail and auth.resendVerificationEmail
  • D9: Implement auth.forgotPassword and auth.resetPassword

Workstream E: WebAuthn Procedures (Backend)

Depends on: A3, B3, C2 Can run parallel to D

  • E1: Implement auth.webauthn.createRegistrationOptions
  • E2: Implement auth.webauthn.verifyRegistration
  • E3: Implement auth.webauthn.createAuthenticationOptions
  • E4: Implement auth.webauthn.verifyAuthentication

Workstream F: User Procedures (Backend)

Depends on: D1 (auth middleware)

  • F1: Implement me.get and me.setupProfile
  • F2: Implement me.updateProfile
  • F3: Implement me.setPassword
  • F4: Implement me.listPasskeys, me.createPasskey, me.renamePasskey, me.deletePasskey
  • F5: Implement me.listSessions, me.revokeSession, me.revokeAllSessions
  • F6: Implement me.getDeviceInfo, me.trustDevice, me.listTrustedDevices, me.untrustDevice, me.revokeAllTrustedDevices
  • F7: Implement me.delete (account deletion)

Workstream G: Email Service (Backend)

Depends on: C2 Can run parallel to D, E, F

  • G1: Set up Postmark client with env config
  • G2: Create email templates (verification, password reset, login confirmation, org invite)
  • G3: Implement sendVerificationEmail() helper
  • G4: Implement sendPasswordResetEmail() helper
  • G5: Implement sendLoginConfirmationEmail() helper
  • G6: Implement sendOrgInviteEmail() helper

Phase 3: Auth Frontend (Depends on Phase 2 D, E)

Workstream H: Auth Pages (Frontend)

Depends on: D1-D9, E1-E4, C3

  • H1: Create /auth/signup page (passkey detection, password fallback)
  • H2: Create /auth/setup/user page (profile setup)
  • H3: Create /auth/login page (email entry, createLoginRequest)
  • H4: Create /auth/login/passkey page (WebAuthn flow)
  • H5: Create /auth/login/password page
  • H6: Create /auth/confirm page (polling for email confirmation)
  • H7: Create /auth/trust-device page
  • H8: Create /auth/verify page (email verification callback)
  • H9: Create /auth/forgot-password page
  • H10: Create /auth/reset-password page

Workstream I: Account Pages (Frontend)

Depends on: F1-F7, C3 Can run parallel to H after F1 is done

  • I1: Create /account page (profile settings, avatar upload)
  • I2: Create /account/auth page (password, passkeys management)
  • I3: Create /account/devices page (trusted devices)
  • I4: Create /account/sessions page (session history)

Phase 4: Organizations (Can start after Phase 2 D1)

Workstream J: Org Procedures (Backend)

Depends on: D1 (auth middleware)

  • J1: Implement org middleware (slug lookup, membership check)
  • J2: Implement orgs.list, orgs.create, orgs.get
  • J3: Implement orgs.update, orgs.delete, orgs.leave
  • J4: Implement orgs.members.list, orgs.members.updateRole, orgs.members.remove
  • J5: Implement orgs.invites.list, orgs.invites.create, orgs.invites.cancel, orgs.invites.accept
  • J6: Implement orgs.sites.list

Implementation notes:

  • Files in procedures/orgs/ with index.ts for consolidated exports
  • Helper functions in helpers.ts: lookupOrgBySlug, getMembership, requireRole, countOwners
  • Race conditions prevented via Kysely transactions for owner count checks
  • Privilege escalation prevented: only owners can invite new owners

Workstream K: Admin Procedures (Backend)

Depends on: D1 (auth middleware), J1 Can run parallel to J2-J6

  • K1: Implement superuser middleware
  • K2: Implement admin.orgs.* procedures
  • K3: Implement admin.users.* procedures
  • K4: Implement admin.orgs.addSite, admin.orgs.removeSite
  • K5: Implement admin.auth.completeLogin (dev helper)

Implementation notes:

  • Files in procedures/admin/ with _routes.ts for consolidated exports
  • Helper functions in helpers.ts: toOrgResponse, toUserResponse, toSiteResponse
  • Race conditions prevented via transaction-scoped existence checks
  • Self-demotion guard in adminUsersUpdate prevents superusers from removing their own status

Workstream L: Org Pages (Frontend)

Depends on: J1-J6, C3

  • L1: Create /dashboard page (org list)
  • L2: Create /dashboard/[slug] page (org overview)
  • L3: Create /dashboard/[slug]/members page
  • L4: Create /dashboard/[slug]/settings page
  • L5: Create /invite/accept page (org invite accept flow)

Implementation notes:

  • Route param uses [slug] to match API contract
  • Shared org context via +layout.svelte provides role detection (owner/admin/member)
  • Role-based UI: owners can manage roles, admins can invite/remove, members view-only
  • Confirmation dialogs for destructive actions (remove member, cancel invite, leave/delete org)
  • Reusable components: $lib/components/org/role-badge.svelte, confirm-dialog.svelte
  • Sidebar updated with "Organizations" nav item

Workstream M: Admin Pages (Frontend)

Depends on: K1-K5, C3 Can run parallel to L

  • M1: Create /admin dashboard page
  • M2: Create /admin/orgs pages (list, new, details)
  • M3: Create /admin/users pages (list, details)

Implementation notes:

  • Admin layout at /routes/admin/+layout.svelte provides superuser access control
  • Redirects non-superusers to /dashboard with toast error
  • Admin dashboard shows org/user counts with quick action links
  • Org management: list all orgs, create new with owner email, view/edit details, manage sites
  • User management: list all users, view details, toggle superuser status, confirm email
  • Sidebar shows admin link (shield icon) only for superusers
  • Reusable component: $lib/components/admin/superuser-badge.svelte
  • All destructive actions use ConfirmDialog

Phase 5: CLI (Can start after Phase 1)

Workstream N: CLI Infrastructure

Depends on: C4

  • N1: Set up stricli CLI structure with command routing
  • N2: Implement config file handling (~/.config/reviq/credentials.json)
  • N3: Implement API client wrapper for CLI (reads token from config)

Workstream N-Bootstrap: CLI Bootstrap (Direct DB)

Depends on: A3, N1, N2

  • N4: Implement reviq bootstrap - create superuser with password
  • N5: Implement reviq bootstrap - create "reviq" org with user as owner
  • N6: Implement reviq bootstrap - generate API token and save to config

Workstream N-Auth: CLI Auth Commands

Depends on: N1, N2, N3, D1-D9

  • N7: Implement reviq auth login (open browser, poll for token, save to config)
  • N8: Implement reviq auth logout (revoke token, delete from config)
  • N9: Implement reviq auth status (show current user, API URL, config path)

Workstream N-User: CLI User Commands

Depends on: N3, K3

  • N10: Implement reviq user create --email --name [--org --role]
  • N11: Implement reviq user confirm-email --email (dev helper)

Workstream N-Admin: CLI Admin Commands

Depends on: N3, K5

  • N12: Implement reviq admin complete-login --email (dev helper)

Workstream N-Org: CLI Org Commands

Depends on: N3, K2, K4

  • N13: Implement reviq org create --owner --name
  • N14: Implement reviq org list
  • N15: Implement reviq org add-site --org --domain
  • N16: Implement reviq org remove-site --org --domain

Workstream N-Completions: CLI Shell Completions

Depends on: N1

  • N17: Implement reviq completions bash/zsh/fish

Phase 6: Polish & Operations (After core features)

Workstream O: Cleanup & Maintenance

Depends on: All backend procedures

  • O1: Implement cleanup job for expired tokens (cron or pg_cron)
  • O2: Add rate limiting to auth endpoints
  • O3: Add pagination to list endpoints
  • O4: Add request logging middleware

Workstream P: Testing

Can run parallel across all phases

  • P1: Set up test infrastructure (vitest or bun:test)
  • P2: Write unit tests for password hashing, token generation
  • P3: Write integration tests for auth flows
  • P4: Write integration tests for org flows
  • P5: Write E2E tests for critical user journeys

Task Dependency Graph

Phase 1 (Parallel):
  A1 → A2 → A3
  B1 → B2 → B3
  C1 → C2, C3, C4, C5

Phase 2 (After Phase 1):
  D1 → D2-D9 (sequential)
  E1-E4 (parallel to D)
  F1-F7 (after D1)
  G1-G6 (parallel to D, E, F)

Phase 3 (After Phase 2):
  H1-H10 (sequential, after D+E)
  I1-I4 (parallel to H, after F)

Phase 4 (After D1):
  J1 → J2-J6
  K1 → K2-K5 (parallel to J)
  L1-L5 (after J)
  M1-M3 (after K, parallel to L)

Phase 5 (After Phase 1, some tasks need Phase 2/4):
  N1-N3 (CLI infra, after C4)
  N4-N6 (bootstrap, after A3 + N1-N2)
  N7-N9 (auth commands, after D1-D9 + N1-N3)
  N10-N11 (user commands, after K3 + N3)
  N12 (admin commands, after K5 + N3)
  N13-N16 (org commands, after K2 + K4 + N3)
  N17 (completions, after N1)

Phase 6 (After core):
  O1-O4 (parallel)
  P1-P5 (can run throughout)