Add account management UI with profile settings, authentication options, device/passkey management, and session management pages. Key changes: - Add account pages: profile, auth, devices, sessions - Add dialog components: confirm, add-passkey, change-password, rename-passkey - Return passkeyId from verifyRegistration to fix race condition - Add hasPassword field to user schema - Add aria-label to dialog close button for accessibility - Add avatar URL validation and fix phone input styling - Add comprehensive test plan documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
96 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
- Getting Started
- Define Contract - Contract-first API definition
- Implement Contract - Implement the contract
- Procedures - Define RPC endpoints with input validation
- Routers - Organize procedures hierarchically
- Middleware & Context - Auth, logging, etc.
- Error Handling
- Cookie Helper - Set cookies for sessions
- TanStack Query Integration
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/
│ │ └── 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:
dbdepends ondb-schemaapi-serverdepends onapi-contract,dbpublisher-dashboarddepends onapi-contractclidepends onapi-contract,db(uses API for most commands, direct DB only forbootstrap)
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
userInputsto 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_fingerprintcookie (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:trueif logged in via passkey,falseif passwordip_address,city,region,country: Geolocation at login timeuser_agent: Browser/device infodevice_id: Link to user device record (if any)- Sessions are never deleted - kept for audit history, marked with
revoked_atwhen 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.
-
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)
-
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_tokencookie (7 day expiry) - Sends verification email in background
-
Profile setup (
/auth/setup/user):- Shown after signup (and on any page load if
display_nameis NULL) - Collects: display name (required), full name (optional), phone number (optional)
- Calls
me.setupProfile({ displayName, fullName?, phoneNumber? })
- Shown after signup (and on any page load if
-
Email verification (optional, can happen later):
- User clicks link →
auth.verifyEmail({ token })→ setsemail_verified_at - Some features may require verified email
- User clicks link →
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_fingerprintcookie - If user exists: Creates row in
login_requestswith email, device fingerprint, IP, geo, user agent - If user doesn't exist: Generates a fake token (no DB row created)
- Sets
login_request_tokencookie (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:
-
Create login request:
auth.createLoginRequest({ email })- Normalizes email to lowercase
- Reads/sets
device_fingerprintcookie - If user exists: Creates row in
login_requestswith 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_tokencookie (real ID or fake token) - Returns
{ hasPasskey, hasPassword, isTrustedDevice, email }(all false for non-existent users)
-
Authentication (marks
completed_atwhen successful):- Passkey:
createAuthenticationOptions()→verifyAuthentication()→ marks complete - Password (trusted):
loginPassword()→ marks complete immediately - Password (untrusted):
loginPassword()→ sends email →loginPasswordConfirm({ token })marks complete
- Passkey:
-
Complete login: Client polls
auth.loginIfRequestIsCompleted():- Reads
login_request_tokencookie - 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_tokencookie - Returns
{ status: 'completed', redirectTo: '/auth/trust-device' | '/dashboard' }
- Reads
-
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
-
Request reset (
/auth/forgot-password):- User enters email
auth.forgotPassword({ email }):- Normalizes email to lowercase
- If user exists: creates
password_resetsrow, sends email - Always returns success (anti-enumeration)
- Show "Check your email" message
-
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/loginwith success message
Procedures
auth.signup({ email, password?, passkeyInfo?: { challengeId, response: RegistrationResponseJSON } })- Creates user, sets session cookie, sends verification email. Must provide eitherpasswordorpasskeyInfo. Password validated with zxcvbn. Passkey stored with name "Default".auth.verifyEmail({ token })- Verify email token, setsemail_verified_atauth.resendVerificationEmail()- Resend verification email to current user (rate-limited)auth.createLoginRequest({ email })- Reads/setsdevice_fingerprintcookie. If user exists: creates login request with device/geo info. If user doesn't exist: generates fake token. Setslogin_request_tokencookie, returns{ hasPasskey, hasPassword, isTrustedDevice, email }.auth.loginPassword({ password })- Readslogin_request_tokencookie, 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 completeauth.loginIfRequestIsCompleted()- Readslogin_request_tokencookie, returns{ status, redirectTo? }. For fake tokens: returnspendinguntil 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 (setsrevoked_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 DBauth.webauthn.createAuthenticationOptions()- Readslogin_request_tokencookie, creates challenge in DB, returns{ challengeId, options: PublicKeyCredentialRequestOptionsJSON }auth.webauthn.verifyAuthentication({ challengeId, response: AuthenticationResponseJSON })- Readslogin_request_tokencookie, verifies authentication response against stored challenge, marks login request complete
Usage:
- Signup with passkey:
createRegistrationOptions→ user creates passkey →signupwithpasskeyInfo - 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 (includesneedsSetup: booleanifdisplay_nameis NULL)me.setupProfile({ displayName, fullName?, phoneNumber? })- Initial profile setup (required before using app)me.updateProfile({ displayName?, fullName?, phoneNumber?, avatarUrl? })- Update profile fieldsme.delete- Delete account (GDPR) - requires password confirmation
Authentication Settings
me.setPassword({ currentPassword?, newPassword })- Set or update password.currentPasswordrequired if user already has a password. Validates with zxcvbn.me.listPasskeys- List user's passkeys with name, created_at, last_used_atme.createPasskey({ name })- Register a new passkey. Name is required (e.g., "MacBook Pro Touch ID").me.renamePasskey({ passkeyId, name })- Rename a passkeyme.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 infome.revokeSession({ sessionId })- Revoke a specific sessionme.revokeAllSessions- Revoke all sessions except currentme.getDeviceInfo()- Get current device info for trust device screen:{ ip, location, browser, os, suggestedName }me.trustDevice({ name })- Trust the current device (setsis_trusted = trueinuser_devices)me.listTrustedDevices- List trusted devices with name, last IP/location, last_used_atme.untrustDevice({ deviceId })- Remove trust from a device (setsis_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 oforgs.create- Create a new org (user becomes owner), returns slugorgs.get({ slug })- Get org details (members only)orgs.update({ slug, ... })- Update org (display_name, logo_url) - admin/owner onlyorgs.delete({ slug })- Delete org - owner onlyorgs.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 vieworgs.members.updateRole({ slug, userId, role })- Change member role - admin/owner onlyorgs.members.remove({ slug, userId })- Remove member - admin/owner only
Org Invite Procedures (orgs.invites.*)
orgs.invites.list({ slug })- List pending invites - admin/owner onlyorgs.invites.create({ slug, email, role })- Create invite - admin/owner onlyorgs.invites.cancel({ slug, inviteId })- Cancel invite - admin/owner onlyorgs.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 orgsadmin.orgs.get({ slug })- Get org detailsadmin.orgs.create({ slug, displayName, ownerEmail })- Create orgadmin.orgs.update({ slug, ... })- Update orgadmin.orgs.delete({ slug })- Delete orgadmin.orgs.listSites({ slug })- List sites for orgadmin.orgs.addSite({ slug, domain })- Add site to orgadmin.orgs.removeSite({ slug, domain })- Remove site from orgadmin.users.list- List all usersadmin.users.get({ email })- Get user detailsadmin.users.create({ email, name?, orgSlug?, orgRole? })- Create passwordless useradmin.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 |
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:
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: "/" });
Geolocation
Used to capture IP address and location for sessions, devices, and login requests.
Strategy
- Cloudflare headers (preferred) - Free with any Cloudflare plan
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:
- User enters email, clicks "Create account with passkey"
createRegistrationOptions({ email })→{ challengeId, options }startRegistration(options)→ WebAuthn promptsignup({ email, passkeyInfo: { challengeId, response } })- Server creates user + passkey + session → sets
rev.session_tokencookie - Redirect to
/auth/setup/user
Password flow:
- User enters email + password + confirm, clicks "Create account"
- Client validates passwords match
signup({ email, password })- Server creates user + session → sets
rev.session_tokencookie - 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:
- Validate display name (required, 1-100 chars)
- Validate phone with libphonenumber-js (if provided)
me.setupProfile({ displayName, fullName?, phoneNumber? })- 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:
createLoginRequest({ email })- Sets
rev.device_fingerprintcookie (if not present) - Sets
rev.login_request_tokencookie (real ID or fake token) - Response:
{ hasPasskey, hasPassword, isTrustedDevice, email } - Redirect based on response:
hasPasskey→/auth/login/passkeyhasPassword→/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:
- Auto-trigger:
createAuthenticationOptions()→{ challengeId, options } startAuthentication(options)→ WebAuthn prompt- On success:
verifyAuthentication({ challengeId, response }) - Server marks login request complete, creates session
- 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:
loginPassword({ password })- If trusted device: login complete → redirect to dashboard
- 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:
- Poll
loginIfRequestIsCompleted()every 3 seconds - Response:
{ status: 'pending' | 'completed' | 'expired' } - On
completed: creates session → redirect to/auth/trust-device(or dashboard) - 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 addresslocation- 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":
- Call
me.trustDevice({ name })- setsis_trusted = trueinuser_deviceswith user-provided name - Redirect to
/dashboard(or/auth/setup/userifneedsSetup)
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:
- Extract
tokenfrom query params verifyEmail({ token })- On success: redirect to
/dashboardwith success toast "Email verified!" - 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:
forgotPassword({ email })- Always show success (anti-enumeration): "Check your email for a reset link"
- 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:
- Validate passwords match
- Validate password strength with zxcvbn (score ≥ 3)
resetPassword({ token, newPassword })- On success: redirect to
/auth/loginwith success toast "Password reset! Please log in." - 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:
- Click "Change password" → modal with current password, new password, confirm
- Validate new password with zxcvbn
me.setPassword({ currentPassword, newPassword })
Set password flow (for passkey-only users):
- Click "Set password" → modal with new password, confirm
- Validate password with zxcvbn
me.setPassword({ newPassword })
Add passkey flow:
- Click "+ Add passkey" → prompt for name (e.g., "Work Laptop")
createRegistrationOptions→ WebAuthn promptme.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/OBun.spawn()for subprocesses (e.g., opening browser)import.meta.envfor environment variables (notprocess.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):
- Creates/updates user with password
- Confirms email and grants superuser
- Creates "reviq" org with user as owner
- 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
- Create session: Generate random token, return to client, store SHA-256 hash in DB
- 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
- Deployment - Self-host with Bun, or deploy to edge runtime (Cloudflare Workers)?
- 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.sqlwith all tables, enums, indexes - A2: Set up
@publisher-dashboard/db-schemapackage with kysely-codegen - A3: Set up
@publisher-dashboard/dbpackage with Kysely client
Workstream B: API Contract
- B1: Create
@publisher-dashboard/api-contractpackage 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-serverwith Bun.serve entry point - C3: Set up
apps/publisher-dashboardSvelteKit project with TanStack Query - C4: Set up
apps/cliwith stricli framework - C5: Create
devenv.nixwith 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.createLoginRequestwith 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.verifyEmailandauth.resendVerificationEmail - D9: Implement
auth.forgotPasswordandauth.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.getandme.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/signuppage (passkey detection, password fallback) - H2: Create
/auth/setup/userpage (profile setup) - H3: Create
/auth/loginpage (email entry, createLoginRequest) - H4: Create
/auth/login/passkeypage (WebAuthn flow) - H5: Create
/auth/login/passwordpage - H6: Create
/auth/confirmpage (polling for email confirmation) - H7: Create
/auth/trust-devicepage - H8: Create
/auth/verifypage (email verification callback) - H9: Create
/auth/forgot-passwordpage - H10: Create
/auth/reset-passwordpage
Workstream I: Account Pages (Frontend)
Depends on: F1-F7, C3 Can run parallel to H after F1 is done
- I1: Create
/accountpage (profile settings, avatar upload) - I2: Create
/account/authpage (password, passkeys management) - I3: Create
/account/devicespage (trusted devices) - I4: Create
/account/sessionspage (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
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)
Workstream L: Org Pages (Frontend)
Depends on: J1-J6, C3
- L1: Create
/dashboardpage (org list) - L2: Create
/dashboard/[org]page (org overview) - L3: Create
/dashboard/[org]/memberspage - L4: Create
/dashboard/[org]/settingspage - L5: Create org invite accept flow
Workstream M: Admin Pages (Frontend)
Depends on: K1-K5, C3 Can run parallel to L
- M1: Create
/admindashboard page - M2: Create
/admin/orgspages (list, new, details) - M3: Create
/admin/userspages (list, details)
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)