Files
publisher-dashboard/docs/initial-app.md
2026-01-09 18:18:07 +08:00

2493 lines
98 KiB
Markdown

# 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
- [Getting Started](https://orpc.dev/docs/getting-started)
- [Define Contract](https://orpc.dev/docs/contract-first/define-contract) - Contract-first API definition
- [Implement Contract](https://orpc.dev/docs/contract-first/implement-contract) - Implement the contract
- [Procedures](https://orpc.dev/docs/procedure) - Define RPC endpoints with input validation
- [Routers](https://orpc.dev/docs/router) - Organize procedures hierarchically
- [Middleware & Context](https://orpc.dev/docs/middleware) - Auth, logging, etc.
- [Error Handling](https://orpc.dev/docs/error-handling)
- [Cookie Helper](https://orpc.dev/docs/helpers/cookie) - Set cookies for sessions
- [TanStack Query Integration](https://orpc.dev/docs/integrations/tanstack-query)
---
## 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
```typescript
// 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
```typescript
// 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
```sql
-- 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:
```typescript
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:
```typescript
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**: `createLoginRequest``createAuthenticationOptions` → 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](https://orpc.dev/docs/middleware), [oRPC Context docs](https://orpc.dev/docs/context)
### Plugins Setup
Both `RequestHeadersPlugin` and `ResponseHeadersPlugin` are required to read cookies/headers and set response cookies:
```typescript
import {
RequestHeadersPlugin,
ResponseHeadersPlugin,
} from "@orpc/server/plugins";
const handler = new RPCHandler(router, {
plugins: [new RequestHeadersPlugin(), new ResponseHeadersPlugin()],
});
```
### Context Types
```typescript
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:
```typescript
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:
```typescript
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
```typescript
// 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:
```typescript
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
```typescript
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:
```typescript
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 |
### Cookie Details
**`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:
```typescript
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](https://orpc.dev/docs/helpers/cookie)
---
## 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):
```bash
pnpm add geoip-lite
pnpm add -D @types/geoip-lite
```
```typescript
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
```typescript
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`):
```typescript
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)**
```bash
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:
```typescript
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
```bash
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
```bash
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
```bash
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`):
```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
```bash
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
```bash
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
```bash
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
```bash
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
```bash
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
```bash
reviq completions bash >> ~/.bashrc
reviq completions zsh >> ~/.zshrc
reviq completions fish > ~/.config/fish/completions/reviq.fish
```
### Environment Variables
```bash
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:
```nix
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
```typescript
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.
```sql
-- 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
- [x] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes
- [x] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen
- [x] **A3**: Set up `@publisher-dashboard/db` package with Kysely client
#### Workstream B: API Contract
- [x] **B1**: Create `@publisher-dashboard/api-contract` package structure
- [x] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin)
- [x] **B3**: Define oRPC contract with all procedure signatures
#### Workstream C: Project Infrastructure
- [x] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`)
- [x] **C2**: Set up `apps/api-server` with Bun.serve entry point
- [x] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query
- [x] **C4**: Set up `apps/cli` with stricli framework
- [x] **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_
- [x] **D1**: Implement auth middleware (session cookie + API key header)
- [x] **D2**: Implement `auth.signup` (email + password or passkey)
- [x] **D3**: Implement `auth.createLoginRequest` with device/geo capture
- [x] **D4**: Implement `auth.loginPassword` (trusted vs untrusted device flow)
- [x] **D5**: Implement `auth.loginPasswordConfirm` (email link handler)
- [x] **D6**: Implement `auth.loginIfRequestIsCompleted` (polling + session creation)
- [x] **D7**: Implement `auth.logout`
- [x] **D8**: Implement `auth.verifyEmail` and `auth.resendVerificationEmail`
- [x] **D9**: Implement `auth.forgotPassword` and `auth.resetPassword`
#### Workstream E: WebAuthn Procedures (Backend)
_Depends on: A3, B3, C2_
_Can run parallel to D_
- [x] **E1**: Implement `auth.webauthn.createRegistrationOptions`
- [x] **E2**: Implement `auth.webauthn.verifyRegistration`
- [x] **E3**: Implement `auth.webauthn.createAuthenticationOptions`
- [x] **E4**: Implement `auth.webauthn.verifyAuthentication`
#### Workstream F: User Procedures (Backend)
_Depends on: D1 (auth middleware)_
- [x] **F1**: Implement `me.get` and `me.setupProfile`
- [x] **F2**: Implement `me.updateProfile`
- [x] **F3**: Implement `me.setPassword`
- [x] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey`
- [x] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions`
- [x] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices`
- [x] **F7**: Implement `me.delete` (account deletion)
#### Workstream G: Email Service (Backend)
_Depends on: C2_
_Can run parallel to D, E, F_
- [x] **G1**: Set up Postmark client with env config
- [x] **G2**: Create email templates (verification, password reset, login confirmation, org invite)
- [x] **G3**: Implement `sendVerificationEmail()` helper
- [x] **G4**: Implement `sendPasswordResetEmail()` helper
- [x] **G5**: Implement `sendLoginConfirmationEmail()` helper
- [x] **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_
- [x] **H1**: Create `/auth/signup` page (passkey detection, password fallback)
- [x] **H2**: Create `/auth/setup/user` page (profile setup)
- [x] **H3**: Create `/auth/login` page (email entry, createLoginRequest)
- [x] **H4**: Create `/auth/login/passkey` page (WebAuthn flow)
- [x] **H5**: Create `/auth/login/password` page
- [x] **H6**: Create `/auth/confirm` page (polling for email confirmation)
- [x] **H7**: Create `/auth/trust-device` page
- [x] **H8**: Create `/auth/verify` page (email verification callback)
- [x] **H9**: Create `/auth/forgot-password` page
- [x] **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_
- [x] **I1**: Create `/account` page (profile settings, avatar upload)
- [x] **I2**: Create `/account/auth` page (password, passkeys management)
- [x] **I3**: Create `/account/devices` page (trusted devices)
- [x] **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)_
- [x] **J1**: Implement org middleware (slug lookup, membership check)
- [x] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
- [x] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
- [x] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
- [x] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
- [x] **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_
- [x] **K1**: Implement superuser middleware
- [x] **K2**: Implement `admin.orgs.*` procedures
- [x] **K3**: Implement `admin.users.*` procedures
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
- [x] **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_
- [x] **L1**: Create `/dashboard` page (org list)
- [x] **L2**: Create `/dashboard/[slug]` page (org overview)
- [x] **L3**: Create `/dashboard/[slug]/members` page
- [x] **L4**: Create `/dashboard/[slug]/settings` page
- [x] **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)
---
### Phase 5: CLI (Can start after Phase 1)
#### Workstream N: CLI Infrastructure
_Depends on: C4_
- [x] **N1**: Set up stricli CLI structure with command routing
- [x] **N2**: Implement config file handling (`~/.config/reviq/credentials.json`)
- [x] **N3**: Implement API client wrapper for CLI (reads token from config)
#### Workstream N-Bootstrap: CLI Bootstrap (Direct DB)
_Depends on: A3, N1, N2_
- [x] **N4**: Implement `reviq bootstrap` - create superuser with password
- [x] **N5**: Implement `reviq bootstrap` - create "reviq" org with user as owner
- [x] **N6**: Implement `reviq bootstrap` - generate API token and save to config
#### Workstream N-Auth: CLI Auth Commands
_Depends on: N1, N2, N3, D1-D9_
- [x] **N7**: Implement `reviq auth login` (open browser, poll for token, save to config)
- [x] **N8**: Implement `reviq auth logout` (revoke token, delete from config)
- [x] **N9**: Implement `reviq auth status` (show current user, API URL, config path)
#### Workstream N-User: CLI User Commands
_Depends on: N3, K3_
- [x] **N10**: Implement `reviq user create --email --name [--org --role]`
- [x] **N11**: Implement `reviq user confirm-email --email` (dev helper)
#### Workstream N-Admin: CLI Admin Commands
_Depends on: N3, K5_
- [x] **N12**: Implement `reviq admin complete-login --email` (dev helper)
#### Workstream N-Org: CLI Org Commands
_Depends on: N3, K2, K4_
- [x] **N13**: Implement `reviq org create --owner --name`
- [x] **N14**: Implement `reviq org list`
- [x] **N15**: Implement `reviq org add-site --org --domain`
- [x] **N16**: Implement `reviq org remove-site --org --domain`
#### Workstream N-Completions: CLI Shell Completions
_Depends on: N1_
- [x] **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)
```