Compare commits

...

76 Commits

Author SHA1 Message Date
igm
8da4379583 fix readme
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 18:29:02 +08:00
igm
1f6d5a4a9f linting
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 18:07:31 +08:00
igm
d8397dfb38 Simplify middleware and remove unused code
- Remove unused orgMemberMiddleware (org procedures use helper functions)
- Remove orgMemberProcedure from base.ts
- Simplify superuserMiddleware using inline concat syntax
- Import OrgInfo/OrgMembership from context.ts instead of redefining

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:06:25 +08:00
igm
73ef3df01f Add pre-configured procedures and use them throughout codebase
- Add authedProcedure, superuserProcedure, loginRequestProcedure,
  orgMemberProcedure in base.ts
- Create procedures/me/_base.ts with meRoute = authedProcedure.me
- Update all me procedures to use meRoute.X.handler()
- Update auth/logout and auth/resend-verification to use authedProcedure
- Update all admin procedures to use superuserProcedure
- Update all orgs procedures to use authedProcedure

This reduces boilerplate and makes middleware usage consistent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:57:15 +08:00
igm
25c8bab741 Add orgMemberMiddleware for org-scoped procedures
- Add OrgInfo, OrgMembership, OrgMemberContext types to context.ts
- Create org-member.ts middleware that:
  - Chains with authMiddleware
  - Takes input with org slug
  - Looks up org and verifies membership
  - Adds org and membership info to context
- Export from middlewares/index.ts and procedures/base.ts

Also simplify superuserMiddleware to use authMiddleware.concat()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:49:03 +08:00
igm
b48012c1f6 Move middlewares to dedicated folder with one per file
- Create src/middlewares/ folder with separate files:
  - os.ts: base implementer
  - auth.ts: authentication middleware
  - login-request.ts: login request middleware
  - superuser.ts: chains authMiddleware then checks superuser
- Update base.ts to re-export from middlewares
- Update admin procedures to use merged superuserMiddleware
  (no longer need to chain authMiddleware.use(superuserMiddleware))

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:46:14 +08:00
igm
bd4053f952 Remove unused auth middleware and utils
- Delete src/middleware/auth.ts (createAuthMiddleware, createSuperuserMiddleware)
- Delete src/utils/auth.ts (authenticateRequest)

These files were never imported or used anywhere in the codebase.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:39:07 +08:00
igm
ce5a27d014 Remove redundant null/undefined tests from createDb
TypeScript already enforces the string type at compile time,
so runtime tests for invalid type inputs are unnecessary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:34:25 +08:00
igm
665092464a Fix all linter errors
- Remove unused biome suppression comment in completions.ts
- Remove unnecessary if condition in execute-bootstrap.test.ts
- Add eslint-disable comments for any type assertions in client.test.ts
- Add eslint-disable comments for expect().rejects patterns
- Fix template literal number expression with toString()
- Fix error handling in test-db.ts to avoid object stringify

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:30:00 +08:00
igm
b78064caeb Merge branch 'email-cleanup'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 17:23:04 +08:00
igm
c60041a1bb Replace dbmate with direct schema.sql execution in tests
Some checks failed
CI / ci (push) Has been cancelled
Instead of running dbmate migrations, tests now directly execute the
db/schema.sql file on the test database. This is faster and removes
the dbmate dependency from tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:12:10 +08:00
igm
40d743c8c2 Apply linter formatting fixes to emails package
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:12:06 +08:00
igm
e43c006bb1 Fix merge conflicts and add withTransaction helper
- Add withTransaction helper that gracefully handles nested transactions
  (reuses existing transaction in tests, starts new one otherwise)
- Update auth procedures to use withTransaction instead of direct .transaction()
- Add email config to all e2e test contexts (required by merged code)
- Remove duplicate verification token code from signup procedure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:07:14 +08:00
igm
8e65c2e698 Merge branch 'transactions-in-procedure' 2026-01-12 15:53:41 +08:00
igm
b085a315be Add transactions to auth procedures and extract DB models
- Wrap multiple DB operations in transactions for atomicity:
  - login-if-completed: device upsert + session + login_request deletion
  - forgot-password: delete old tokens + insert new token
  - signup: session + email_verification creation

- Extract reusable DB model operations to packages/db/src/models/:
  - sessions.ts: insertSession()
  - user-devices.ts: upsertUserDevice(), isDeviceTrusted()

- Update session.ts to use new model functions from @reviq/db
- Fix type narrowing in admin.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:52:05 +08:00
igm
1ed41e5c4c Merge branch 'db-coverage' 2026-01-12 15:51:48 +08:00
igm
84644c8bfb Merge branch 'email-cleanup' 2026-01-12 15:51:38 +08:00
igm
5ecf12a1a1 Consolidate duplicate components and create reusable MetricsTable
- Merge two ConfirmDialog components into single shared ui/confirm-dialog
  with consistent API across account and org pages
- Create MetricsTable component to reduce duplication across dashboard
  table components (ad-unit, country, domain, source tables)
- Reduces code duplication by ~200 lines
- Consistent styling and behavior across all confirmation dialogs
- Single source of truth for metrics table structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:29 +08:00
igm
c2b815dd6a Extract emails into separate package with clean interface
- Create packages/emails/ with EmailClient interface abstraction
- Wrap Postmark ServerClient in adapter for clean typing
- Add createLoggingEmailClient for dev mode (logs to console)
- Split email templates into individual files with full test coverage
- Update api-server to use new package via context injection
- Remove EMAIL_DEV_MODE - now uses POSTMARK_API_KEY presence
- Delete apps/api-server/src/utils/email.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:12 +08:00
igm
67930d90d5 Simplify apps/cli/ code
- config.ts: Convert arrow functions to function declarations
- api-client.ts: Extract duplicated RPCLink logic into buildClient helper
- format-error.ts: Add centralized ORPCError handling
- complete-login.ts: Remove redundant error handling (now in formatError)
- status.ts: Simplify formatRelativeTime, improve whitespace
- create.ts: Rename validRoles to VALID_ROLES, add as const, early return
- completions.ts: Derive Shell type from SUPPORTED_SHELLS array

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:42:39 +08:00
igm
58ffa68f4c Add tests for @reviq/db package
- token.test.ts: Unit tests for generateToken, parseToken, hashToken
- client.test.ts: Tests for createDb validation and e2e connectivity
- execute-bootstrap.test.ts: Comprehensive e2e tests for bootstrap
  operation including overwrite mode and related record cleanup

Coverage: client.ts 100%, token.ts 100%, execute-bootstrap.ts 98.69%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:35:02 +08:00
igm
5a2e0297e5 Merge branch 'more-spec'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 15:23:25 +08:00
igm
c9de0b1ac5 Add sideEffects: false to all library packages
Enables tree-shaking for bundlers by marking all library packages
as side-effect-free.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:22:24 +08:00
igm
0f50291490 Add @types/bun to api-contract for test file compilation
The test file imports from bun:test which requires bun types. Added
@types/bun dependency and configured tsconfig to include bun types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:20:21 +08:00
igm
9c6694cad4 Auto-skip e2e tests when TEST_DATABASE_URL is not configured
Some checks failed
CI / ci (push) Has been cancelled
Previously, e2e tests would fail with a confusing URL parse error when
TEST_DATABASE_URL was not set. Now SKIP_DB_TESTS automatically becomes
true when the database URL is missing, gracefully skipping these tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:15:15 +08:00
igm
f9f1dc7403 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 15:08:49 +08:00
igm
b27a977809 Document test scripts in README and CLAUDE.md
- Add test:unit, test:cov, test:unit:cov to README scripts table
- Add Running Tests section to CLAUDE.md recommending test:cov

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:08:35 +08:00
igm
7edc4ba8a9 gnu sed 2026-01-12 15:08:17 +08:00
igm
16f827e8f0 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add test utilities and ast-grep rules for code quality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:05:07 +08:00
igm
947c73dbdc Remove unnecessary exclude from tsconfig files
TypeScript excludes node_modules by default, and dist is handled
by outDir or include patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:55 +08:00
igm
2baf10b0cd Replace String() calls with .toString()/.toLocaleString() per ast-grep rule
- Add formatError() helper in CLI to safely handle unknown error types
- Add uniqueTestId() helper for generating unique test identifiers
- Replace String(id) with id.toString() for database ID conversions
- Replace String(n) with n.toLocaleString() for user-facing number formatting
- Fix TypeScript errors in test files (undefined checks, unused variables)
- Update lint commands to include ast-grep scanning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:46 +08:00
igm
8b081d5ba8 Remove unnecessary testing/README.md
Sub-packages test-helpers and virtual-authenticator have their own READMEs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:57:28 +08:00
igm
01f1e1c9e3 Add READMEs for remaining packages
- db-schema: Database schema types from kysely-codegen
- db: Database client and helper functions
- testing: Overview of testing packages
- test-helpers: Database testing utilities
- virtual-authenticator: WebAuthn virtual authenticator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:59:34 +08:00
igm
26d10d452f Rename @reviq/utils to @reviq/server-utils and add package READMEs
- Rename packages/utils/ to packages/server-utils/
- Update all imports and package.json references
- Add READMEs for frontend-utils, server-utils, and common packages
- Update main README with new package structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:57:28 +08:00
igm
8b63eb3538 Add ast-grep rule to prevent String() function usage
Prefer .toString() or .toLocaleString() over String() for
more predictable behavior and consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:48:13 +08:00
igm
587e151fbd Fix ast-grep tests and add no-countall-number test
- Update zod-namespace-import snapshot (semicolon fix)
- Add test cases for no-countall-number rule
- Update rule pattern to match method calls on objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:45:14 +08:00
igm
94b6de5970 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add @reviq/test-helpers package with e2e tests for admin, auth, orgs, and webauthn.
Move test utilities to shared package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:43:28 +08:00
igm
6fa4da1abb Fix lint errors and add ast-grep rule for countAll
- Fix template literal expressions: wrap Date.now() in String()
- Add missing afterAll import in admin.test.ts
- Fix countOwners to use countAll() without misleading <number> type
- Add ast-grep rule to prevent countAll<number>() usage
- Fix formatting issues from merge conflict resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:40:06 +08:00
igm
92f7e1df09 Merge origin/master and migrate tests to describeE2E
- Resolve merge conflicts in auth.test.ts, me.test.ts, db/schema.sql
- Merge new loginRequestMiddleware tests into auth.test.ts describeE2E wrapper
- Merge new authMiddleware tests into me.test.ts describeE2E wrapper
- Add me.apiTokens and me.invites tests in separate describeE2E block
- Migrate admin.test.ts to use describeE2E and @reviq/test-helpers
- Migrate orgs.test.ts to use describeE2E and @reviq/test-helpers

All e2e tests now properly use the describeE2E helper which enables
SKIP_DB_TESTS environment variable support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:19:29 +08:00
igm
b2fba6e150 Add test infrastructure with coverage and DB test skipping
- Create @reviq/test-helpers package with shared test utilities
- Add describeE2E helper that auto-prefixes test names with [e2e]
- Support SKIP_DB_TESTS=1 to skip database-dependent tests
- Add unix socket support for TEST_DATABASE_URL
- Add root commands: test:unit, test:all, test:cov, test:unit:cov
- Configure bunfig.toml to exclude dist/ from coverage reports
- Clean up tsconfig.json files to remove redundant settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:03:41 +08:00
igm
ebc85af62c Add comprehensive e2e tests for API procedures with 100% coverage
- Add admin.test.ts: Tests for superuser operations (users, orgs, sites)
- Add orgs.test.ts: Tests for org management, members, invites, sites
- Expand me.test.ts: Add API tokens, invites, authMiddleware error paths
- Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix

Bug fixes:
- Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number
- Fix signup race condition by handling unique constraint violations gracefully

All 283 tests pass with 100% function coverage on procedures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:53:19 +08:00
igm
6b8dd27898 Merge branch 'schema-sql-fix' 2026-01-12 12:41:15 +08:00
igm
61fdd3329f Add OrgAvatar component and frontend-utils package
Some checks failed
CI / ci (push) Has been cancelled
- Create @reviq/frontend-utils package for frontend-specific utilities
- Add OrgAvatar component with size variants (xs, sm, md, lg, xl)
- Display org initials with deterministic colors when no logo available
- Add getOrgInitials and getOrgColor utility functions
- Update org-switcher and all org display pages to use OrgAvatar
- Add noNonNullAssertion lint rule as error in biome.jsonc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:34:23 +08:00
igm
848d9e9af1 Add db-dump and db-migrate scripts to strip \restrict lines
PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump
output (CVE-2025-8714 security fix), causing schema.sql to appear
changed on every dump even when the schema hasn't changed.

These wrapper scripts run dbmate and strip the \restrict lines from
the output to keep schema.sql stable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:33:20 +08:00
igm
44a480179b Merge remote-tracking branch 'origin/master'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-11 14:19:58 +08:00
igm
628b01f4d8 Add type-safe navigation helpers and public pages
- Create gotoLogin() helper for login redirects with search params
- Add /terms and /privacy public routes with Tailwind typography
- Update auth-guard to allow unauthenticated access to public pages
- Fix resolve() usage across navigation components using as const pattern
- Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve
- Document SvelteKit resolve() patterns in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:19:33 +08:00
igm
8939deefbe Merge pull request 'Update db and db-schema packages to export from dist/' (#1) from fix-exports into master
Reviewed-on: https://git.rev.iq/igm/publisher-dashboard/pulls/1
2026-01-11 05:19:11 +00:00
igm
4d9fbdeed5 Add tea 0.10.1 nix derivation and Gitea PR skill
- Pin tea CLI to 0.10.1 to avoid TTY bug in 0.11.x
- Add .claude/skills/gitea for PR creation workflow
- Document tea CLI usage in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:59:47 +08:00
igm
76a5e40900 Merge branch 'gitea-action' 2026-01-11 12:34:17 +08:00
igm
b1d07626f3 Add packages/common for shared utilities
Create new @reviq/common package with environment-agnostic utilities:
- Date formatting: formatDate, formatDateTime, formatLongDate,
  formatRelativeDate, formatRelativeTime
- User utilities: getUserInitials, formatRole

Consolidate date formatting from publisher-dashboard into shared package.
All utilities include comprehensive test coverage with bun:test.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:34:10 +08:00
igm
99539bbdcb Update Bun version to 1.3.5
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:27:52 +08:00
igm
9a119da96e Update db and db-schema packages to export from dist/
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:22:04 +08:00
igm
7358129802 Fix TypeScript and linting errors across publisher-dashboard
- Add type assertions for dynamic route paths in goto() and resolve()
- Add missing key attributes to {#each} blocks
- Wrap navigation hrefs with resolve() for SvelteKit compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:10:27 +08:00
igm
eedd664db8 Add Gitea Action CI workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:22:51 +08:00
igm
a02e1f0862 Merge branch 'tea-cli' 2026-01-10 19:47:11 +08:00
igm
2fb42c0fa5 add gitea cli 2026-01-10 19:47:06 +08:00
igm
3d42324750 Merge branch 'svelte-lint'
# Conflicts:
#	apps/publisher-dashboard/src/lib/components/account/account-nav.svelte
2026-01-10 19:42:12 +08:00
igm
ac4b8dc99a Add eslint-plugin-svelte and fix all Svelte linting errors
- Configure eslint-plugin-svelte with TypeScript parser support
- Add keys to all {#each} blocks for proper reactivity
- Wrap navigation paths with resolve() from $app/paths
- Remove unnecessary children snippets and useless mustaches
- Add @typescript-eslint/parser and svelte-eslint-parser dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:34:25 +08:00
igm
cf71cb63d7 Update account settings layout to match org settings
Add left nav with descriptions on desktop and horizontal tabs on mobile,
consistent with the organization settings layout pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:31:16 +08:00
igm
730021a5ea Merge branch 'parallelize-tests-better' 2026-01-10 19:17:50 +08:00
igm
c698a85cc1 update readme 2026-01-10 19:17:48 +08:00
igm
462799ca3d Apply linting fixes and update schema
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:17:39 +08:00
igm
dcb48a5d5e Migrate e2e tests to transaction-based isolation
Replace table truncation with transaction rollback for test isolation.
Each test now runs in a transaction that auto-rolls back, improving
test performance and isolation. Tests that call procedures with internal
transactions use getSharedDb() directly with appropriate comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:16:47 +08:00
igm
8f3a1f2962 Merge origin/master into reviq-auth-login-command
Resolved conflicts:
- apps/api-server/src/router.ts: Use meRoutes from master
- packages/api-contract/src/contract.ts: Keep master's nested sessions/devices/invites structure, add apiTokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:03:37 +08:00
igm
a7d6beaf5a Add API token management for CLI authentication
- Add reviq auth login --token <token> command for CLI authentication
- Create /account/api-tokens page for token management (superuser only)
- Add me.apiTokens endpoints (list, create, delete)
- Require superuser status and trusted session for token creation
- Show API Tokens nav link only for superusers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:58:27 +08:00
igm
48ffba6c5f Apply linting fixes to layout components
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:49:49 +08:00
igm
1b46fc0ecc delete unsued 2026-01-10 18:27:39 +08:00
igm
587d17c39c Update README with comprehensive project documentation
- Add tech stack overview (frontend, backend, shared packages)
- Document project structure with directory tree
- Expand setup instructions with manual development option
- Add scripts reference table
- Document features (auth, organizations, dashboard)
- Add frontend routes overview
- Document API structure and namespaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:25:22 +08:00
RevIQ
cca901a9b9 Merge branch 'whats-left' 2026-01-10 18:10:40 +08:00
RevIQ
42badf3c52 Add DBIP city database and improve geo test coverage
- Add dbip-city-lite package to devenv for GeoIP testing
- Set GEOIP_DATABASE_PATH env var to point to the MMDB database
- Add tests for initGeoReader double-init and error handling
- Add real database tests for IP lookups (US, AU, DE, GB)
- Make real database tests conditional with describe.skipIf
- Improve test coverage from ~97% to 98.82%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:10:30 +08:00
RevIQ
bd53a60497 Merge branch 'more-testing-stuff' 2026-01-10 18:10:03 +08:00
RevIQ
d486e2444e Add org settings layout with responsive nav and member management
- Create SettingsLayout component with left sidebar nav (desktop) and
  horizontal scroll nav (mobile)
- Add settings gear icon to sidebar (Lucide icon, only in org context)
- Fix home icon highlighting to only match exact org home path
- Create /settings/members route with full member management
- Create /settings/sites placeholder route
- Update general settings to use new SettingsLayout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:09:17 +08:00
RevIQ
319edf70db Fix IP address not being set on sessions from localhost
The extractClientIP() function only checked proxy headers (X-Forwarded-For,
CF-Connecting-IP, etc.) which don't exist when running locally without a proxy.

Changes:
- Add clientIP field to APIContext
- Use Bun's server.requestIP() to get client IP from direct socket connection
- Update getGeoInfo() to accept fallback IP parameter
- Pass context.clientIP to getGeoInfo() in auth procedures

Now sessions will have IP address set even for local development (::1 or 127.0.0.1).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:08:21 +08:00
RevIQ
74b26818ca Add comprehensive e2e tests for all auth procedures
Tests cover all login scenarios from docs/initial-app.md:
- Signup with password and passkey
- Password login with trusted device (immediate completion)
- Password login with untrusted device (email confirmation)
- Full passkey authentication flow
- User with no auth methods (stays pending)
- Non-existent email (anti-enumeration with fake token)
- Email verification and resend flows
- Password reset with session revocation
- Logout

All auth procedures now have 100% function coverage.
127 tests passing across 3 e2e test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:55:39 +08:00
RevIQ
b93f5e0b69 lint 2026-01-10 17:52:00 +08:00
RevIQ
fb68f341dd Reorganize layouts with dedicated admin sidebar (dark theme)
- Create admin layout with dark sidebar (zinc-900 background, light text)
- Move dashboard components to layout/dashboard/ subfolder
- Move admin components to layout/admin/ subfolder
- Admin sidebar has: Dashboard, Organizations, Users nav items
- Admin header shows "Admin" badge and "Exit Admin" link
- Update all route imports to use new barrel exports
- Add macOS sed syntax reference to CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:45:17 +08:00
265 changed files with 17339 additions and 4891 deletions

View File

@@ -0,0 +1,16 @@
id: no-countall-number
snapshots:
countAll<number>():
fixed: countAll()
labels:
- source: countAll<number>()
style: primary
start: 0
end: 18
eb.fn.countAll<number>().as("count"):
fixed: eb.fn.countAll().as("count")
labels:
- source: eb.fn.countAll<number>()
style: primary
start: 0
end: 24

View File

@@ -0,0 +1,20 @@
id: no-string-function
snapshots:
String(123):
labels:
- source: String(123)
style: primary
start: 0
end: 11
String(Date.now()):
labels:
- source: String(Date.now())
style: primary
start: 0
end: 18
String(value):
labels:
- source: String(value)
style: primary
start: 0
end: 13

View File

@@ -3,7 +3,7 @@ snapshots:
? |
import { z } from "zod";
: fixed: |
import * as z from "zod"
import * as z from "zod";
labels:
- source: import { z } from "zod";
style: primary
@@ -12,7 +12,7 @@ snapshots:
? |
import { z, ZodError } from "zod";
: fixed: |
import * as z from "zod"
import * as z from "zod";
labels:
- source: import { z, ZodError } from "zod";
style: primary

View File

@@ -0,0 +1,9 @@
id: no-countall-number
valid:
# Plain countAll() is fine
- eb.fn.countAll().as("count")
# Other type arguments are fine
- eb.fn.countAll<string>().as("count")
invalid:
# countAll<number>() should be flagged
- eb.fn.countAll<number>().as("count")

View File

@@ -0,0 +1,13 @@
id: no-string-function
valid:
# toString() is fine
- value.toString()
- (123).toString()
- date.toLocaleString()
# Other functions named String are fine
- myString(value)
invalid:
# String() function should be flagged
- String(value)
- String(123)
- String(Date.now())

View File

@@ -0,0 +1,8 @@
id: no-countall-number
language: typescript
severity: error
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
note: "Use Number() to convert the result if you need a number type."
rule:
pattern: $OBJ.countAll<number>()
fix: $OBJ.countAll()

View File

@@ -0,0 +1,7 @@
id: no-string-function
language: typescript
severity: error
message: "Don't use String() - use .toString() or .toLocaleString() instead."
note: "String() can have unexpected behavior. Use .toString() for general conversion or .toLocaleString() for locale-aware formatting."
rule:
pattern: String($VAL)

View File

@@ -0,0 +1,77 @@
---
name: gitea
description: Create pull requests on Gitea using the tea CLI. Use when the user asks to "create a PR", "open a pull request", "make a PR", "submit PR", or any variation involving pull requests for this repository.
---
# Gitea Pull Requests
This project uses Gitea (git.rev.iq) for hosting and the `tea` CLI for creating pull requests.
## Prerequisites
- The `tea` CLI is installed via devenv (pinned to 0.10.1 to avoid TTY bugs in 0.11.x)
- Login is configured via `~/.config/tea/config.yml`
## Creating a Pull Request
When asked to create a PR, follow these steps:
### 1. Check current state
```bash
git status
git log --oneline -5
git diff master...HEAD --stat
```
### 2. Ensure changes are committed and pushed
If there are uncommitted changes, commit them first. Then push:
```bash
git push -u origin <branch-name>
```
### 3. Create the PR using tea
```bash
tea pr create \
-r igm/publisher-dashboard \
--title "PR title here" \
--description "## Summary
- Change 1
- Change 2
🤖 Generated with [Claude Code](https://claude.ai/code)" \
--head <source-branch> \
--base master
```
**Important flags:**
- `-r igm/publisher-dashboard` - Always specify the repo explicitly (required due to SSH remote detection issues)
- `--head` - The source branch (your feature branch)
- `--base` - The target branch (usually `master`)
### 4. Return the PR URL
The command outputs the PR URL. Always share this with the user.
## Example Output
```
# #1 Update packages to export from dist/ (open)
@igm created 2024-01-11 **master** <- **fix-exports**
--------
• No Conflicts
• Maintainers are allowed to edit
https://git.rev.iq/igm/publisher-dashboard/pulls/1
```
## Troubleshooting
- If tea fails with TTY errors, ensure you're using tea 0.10.1 (configured in `nix/tea.nix`)
- The repo flag `-r igm/publisher-dashboard` is required because the SSH remote isn't auto-detected

34
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.5"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- name: Test
run: bun run test

View File

@@ -1,8 +1,85 @@
# Claude Code Notes
## Running Tests
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
- `bun run test:cov` - Run all tests with coverage (preferred)
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
## Database Scripts
Use the wrapper scripts instead of running dbmate directly:
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
- `./scripts/db-migrate` - Run migrations and dump clean schema
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
## Development Server
Before starting the dev server, check if it's already running:
- Use `lsof -i :6827` or check for existing background tasks
- The dev server runs on port 6827 (may fall back to 6828 if port is in use)
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
## Pull Requests
This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- Use the `/gitea` skill when creating PRs
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
## sed Syntax (GNU coreutils)
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
- In-place edit: `sed -i 's/old/new/g' file`
- Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
- For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
- Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
## SvelteKit resolve() Usage
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
### Static routes - use resolve() directly
```svelte
href={resolve("/auth/login")}
href={resolve("/dashboard")}
```
### Dynamic routes - use two-argument form
```svelte
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
```
### Login redirects - use gotoLogin helper
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
```typescript
import { gotoLogin } from "$lib/utils/navigation";
gotoLogin(page.url.pathname);
```
This helper uses resolve() internally and handles the query string correctly.
### Navigation arrays - use `as const` with route patterns
For type-safe navigation arrays, define routes as literal strings with `as const`:
```typescript
const navItems = [
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
] as const;
```
Then use resolve with params:
```svelte
{#each navItems as item (item.route)}
<a href={resolve(item.route, { slug })}>
{/each}
```
### Runtime strings - skip resolve, use eslint-disable
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
```typescript
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(redirectUrl);
```

152
README.md
View File

@@ -1,9 +1,61 @@
# Reviq Publisher Dashboard
A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server.
## Tech Stack
### Frontend (`apps/publisher-dashboard`)
- **SvelteKit** with Svelte 5 (runes)
- **Tailwind CSS v4** for styling
- **TanStack Query** for data fetching
- **bits-ui** for accessible UI primitives
- **Lucide** for icons
- **WebAuthn/Passkeys** for passwordless authentication
### Backend (`apps/api-server`)
- **Bun** runtime
- **oRPC** for type-safe API (contract-first)
- **Kysely** for type-safe SQL queries
- **PostgreSQL** database
- **Postmark** for transactional emails
### CLI (`apps/cli`)
- **Stricli** for command parsing
- API token-based authentication
- User, organization, and site management commands
### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/common` - Shared utilities for frontend and backend
- `@reviq/db` - Database client and queries
- `@reviq/db-schema` - Database schema and codegen
- `@reviq/frontend-utils` - Frontend-specific utilities
- `@reviq/server-utils` - Server/CLI utilities
## Project Structure
```
publisher-dashboard/
├── apps/
│ ├── api-server/ # Backend API server
│ ├── cli/ # Command-line interface
│ └── publisher-dashboard/ # SvelteKit frontend
├── packages/
│ ├── api-contract/ # Shared oRPC contract
│ ├── common/ # Shared utilities (frontend + backend)
│ ├── db/ # Database client
│ ├── db-schema/ # DB schema & codegen
│ ├── frontend-utils/ # Frontend utilities
│ ├── server-utils/ # Server/CLI utilities
│ └── testing/ # Test utilities
└── db/ # Database migrations
```
## Setup
### Prerequisites
- [Bun](https://bun.sh/) v1.3.5+
- [devenv](https://devenv.sh/) for development environment management
### Environment Variables
@@ -29,9 +81,109 @@ devenv up
This starts:
- PostgreSQL database
- Publisher dashboard dev server (port 6827)
- API server
- Package build watcher
The database is automatically initialized with:
- Database: `reviq-dashboard`
- User: `reviq`
- Password: `reviq`
### Manual Development
If not using devenv, start services individually:
```bash
# Install dependencies
bun install
# Build packages first
bun run build:packages
# Start dev server
bun run dev
```
## Scripts
| Script | Description |
|--------|-------------|
| `bun run dev` | Start all dev servers |
| `bun run build` | Build all packages and apps |
| `bun run typecheck` | Run TypeScript type checking |
| `bun run lint` | Run Biome and ESLint |
| `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run all tests (requires database) |
| `bun run test:unit` | Run unit tests only (no database required) |
| `bun run test:cov` | Run all tests with coverage report |
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
| `bun run db:codegen` | Generate database types |
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
## CLI
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
Quick start:
```bash
# Build the CLI
bun run --cwd apps/cli build
# Login with an API token
./apps/cli/dist/reviq auth login --token <your-token>
# Check status
./apps/cli/dist/reviq auth status
```
## Features
### Authentication
- Passwordless login with passkeys (WebAuthn)
- Email verification
- Session management with device tracking
### Organizations
- Create and manage organizations
- Member management with roles (owner, admin, member)
- Invite members via email
- Organization settings
### Dashboard
- Organization switcher
- Performance metrics
- Reports (coming soon)
- Site management (coming soon)
## Architecture
### Frontend Routes
```
/ # Landing page
/login # Login page
/dashboard # Organization list
/dashboard/[slug] # Organization home
/dashboard/[slug]/performance # Performance metrics
/dashboard/[slug]/reports # Reports (placeholder)
/dashboard/[slug]/settings # Organization settings
├── /members # Member management
└── /sites # Sites (placeholder)
/account # User account settings
├── /security # Security settings
└── /sessions # Active sessions
/admin # Admin panel
```
### API Structure
The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`.
Key API namespaces:
- `auth` - Authentication (passkeys, sessions)
- `me` - Current user profile
- `orgs` - Organization management
- `orgs.members` - Member management
- `orgs.invites` - Invitation management

View File

@@ -9,19 +9,17 @@
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
"test": "bun test src/ --no-parallel"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*",
"@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0",
@@ -34,12 +32,11 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"typescript": "catalog:"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ export const getAllowedOrigins = (): string[] => {
// Default to localhost origins for development
return [
`http://localhost:${String(DEFAULT_PORT)}`,
`http://localhost:${DEFAULT_PORT.toString()}`,
"http://localhost:6827",
"http://localhost:6828",
];
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
/** Base URL for generating email links */
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
/** Dev mode: log emails instead of sending (default: true) */
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
/** Postmark API key (optional - uses logging client if not set) */
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
// ===== Token Expiration Times =====

View File

@@ -3,8 +3,18 @@
*/
import type { Database } from "@reviq/db-schema";
import type { EmailClient } from "@reviq/emails";
import type { Kysely } from "kysely";
/**
* Email configuration for the API
*/
export interface EmailConfig {
client: EmailClient;
fromAddress: string;
baseUrl: string;
}
/**
* Base API context available to all handlers
*/
@@ -21,6 +31,10 @@ export interface APIContext {
reqHeaders: Headers;
/** Response headers (for setting cookies) */
resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */
clientIP?: string | null;
/** Email client and configuration */
email: EmailConfig;
}
/**
@@ -101,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
/** User with superuser privileges */
user: SessionUser & { isSuperuser: true };
}
/**
* Organization info in context
*/
export interface OrgInfo {
id: number;
slug: string;
displayName: string;
logoUrl: string | null;
createdAt: Date;
}
/**
* User's membership in an org
*/
export interface OrgMembership {
id: number;
role: "owner" | "admin" | "member";
createdAt: Date;
}
/**
* Org member context for org-scoped procedures
* Requires user to be a member of the org
*/
export interface OrgMemberContext extends AuthenticatedContext {
/** The organization */
org: OrgInfo;
/** User's membership in the org */
membership: OrgMembership;
}

View File

@@ -2,11 +2,15 @@ import type { APIContext } from "./context.js";
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
import { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db";
import { createLoggingEmailClient, createPostmarkClient } from "@reviq/emails";
import pino from "pino";
import {
BASE_URL,
DEFAULT_PORT,
DEFAULT_RP_NAME,
EMAIL_FROM,
getAllowedOrigins,
POSTMARK_API_KEY,
} from "./constants.js";
import { router } from "./router.js";
@@ -24,6 +28,16 @@ if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required");
}
const db = createDb(databaseUrl);
// Create email client - use Postmark if API key is set, otherwise log to console
const emailClient = POSTMARK_API_KEY
? createPostmarkClient(POSTMARK_API_KEY)
: createLoggingEmailClient();
if (!POSTMARK_API_KEY) {
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
}
const handler = new RPCHandler(router, {
plugins: [
new LoggingHandlerPlugin({
@@ -39,17 +53,21 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
Bun.serve({
port,
async fetch(request) {
async fetch(request, server) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/v1/rpc")) {
// Build context for the request
const origin =
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
// Create response headers for setting cookies
const resHeaders = new Headers();
// Get client IP from Bun's server (fallback for when no proxy headers)
const socketInfo = server.requestIP(request);
const clientIP = socketInfo?.address ?? null;
const context: APIContext = {
db,
origin,
@@ -57,6 +75,12 @@ Bun.serve({
rpName,
reqHeaders: request.headers,
resHeaders,
clientIP,
email: {
client: emailClient,
fromAddress: EMAIL_FROM,
baseUrl: BASE_URL,
},
};
const { response } = await handler.handle(request, {

View File

@@ -1,181 +0,0 @@
/**
* Authentication middleware for oRPC server
*
* Handles authentication via:
* - Session cookie (rev.session_token) - for browser clients
* - API key header (x-api-key) - for CLI and programmatic access
*/
import type {
APIContext,
AuthenticatedContext,
AuthInfo,
Session,
SessionUser,
} from "../context.js";
import { ORPCError } from "@orpc/server";
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
import { hashToken } from "../utils/crypto.js";
/**
* Create the auth middleware function
* This returns a middleware handler that can be used with oRPC procedures
*/
export const createAuthMiddleware = () => {
return async ({
context,
next,
}: {
context: APIContext;
next: (opts: {
context: Omit<AuthenticatedContext, keyof APIContext>;
}) => Promise<unknown>;
}) => {
const { db, reqHeaders } = context;
// Try session cookie first
let tokenHash: string | undefined;
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
if (sessionToken) {
tokenHash = await hashToken(sessionToken);
}
// Fall back to API key header (for CLI)
const apiKey = reqHeaders.get("x-api-key");
if (!tokenHash && apiKey) {
tokenHash = await hashToken(apiKey);
}
if (!tokenHash) {
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
}
// Look up session (check not expired and not revoked)
const session = await db
.selectFrom("sessions")
.where("token_hash", "=", tokenHash)
.where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.selectAll()
.executeTakeFirst();
// Fall back to API token if no session found
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",
});
}
// Update last_used_at for API tokens
if (apiToken) {
await db
.updateTable("api_tokens")
.set({ last_used_at: new Date() })
.where("id", "=", apiToken.id)
.execute();
}
// Fetch user details
const user = await db
.selectFrom("users")
.where("id", "=", userId)
.select([
"id",
"email",
"display_name",
"email_verified_at",
"is_superuser",
])
.executeTakeFirst();
if (!user) {
throw new ORPCError("UNAUTHORIZED", {
message: "User not found",
});
}
const sessionUser: SessionUser = {
id: user.id,
email: user.email,
displayName: user.display_name,
emailVerifiedAt: user.email_verified_at,
isSuperuser: user.is_superuser,
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {
user: sessionUser,
session: sessionInfo,
auth: authInfo,
},
});
};
};
/**
* Middleware to require superuser access
*/
export const createSuperuserMiddleware = () => {
return async ({
context,
next,
}: {
context: AuthenticatedContext;
next: () => Promise<unknown>;
}) => {
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Superuser access required",
});
}
return next();
};
};

View File

@@ -0,0 +1,138 @@
/**
* Auth middleware - validates session/API token and adds user to context
*/
import type { AuthInfo, Session, SessionUser } from "../context.js";
import { ORPCError } from "@orpc/server";
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
import { hashToken } from "../utils/crypto.js";
import { os } from "./os.js";
export const authMiddleware = os.middleware(async ({ context, next }) => {
const { db, reqHeaders } = context;
// Try session cookie first
let tokenHash: string | undefined;
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
if (sessionToken) {
tokenHash = await hashToken(sessionToken);
}
// Fall back to API key header (for CLI)
const apiKey = reqHeaders.get("x-api-key");
if (!tokenHash && apiKey) {
tokenHash = await hashToken(apiKey);
}
if (!tokenHash) {
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
}
// Look up session (check not expired and not revoked)
const session = await db
.selectFrom("sessions")
.where("token_hash", "=", tokenHash)
.where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.selectAll()
.executeTakeFirst();
// Fall back to API token if no session found
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",
});
}
// Update last_used_at for API tokens
if (apiToken) {
await db
.updateTable("api_tokens")
.set({ last_used_at: new Date() })
.where("id", "=", apiToken.id)
.execute();
}
// Fetch user details
const user = await db
.selectFrom("users")
.where("id", "=", userId)
.select([
"id",
"email",
"display_name",
"email_verified_at",
"is_superuser",
])
.executeTakeFirst();
if (!user) {
throw new ORPCError("UNAUTHORIZED", {
message: "User not found",
});
}
const sessionUser: SessionUser = {
id: user.id,
email: user.email,
displayName: user.display_name,
emailVerifiedAt: user.email_verified_at,
isSuperuser: user.is_superuser,
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {
user: sessionUser,
session: sessionInfo,
auth: authInfo,
},
});
});

View File

@@ -0,0 +1,8 @@
/**
* Middleware exports
*/
export { authMiddleware } from "./auth.js";
export { loginRequestMiddleware } from "./login-request.js";
export { os } from "./os.js";
export { superuserMiddleware } from "./superuser.js";

View File

@@ -0,0 +1,64 @@
/**
* Login request middleware - validates login request token from cookie
*/
import type { SessionUser } from "../context.js";
import { ORPCError } from "@orpc/server";
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
import { os } from "./os.js";
export const loginRequestMiddleware = os.middleware(
async ({ context, next }) => {
const { db, reqHeaders } = context;
// Read login request token from cookie
const loginRequestToken = getCookie(
reqHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
if (!loginRequestToken) {
throw new ORPCError("BAD_REQUEST", {
message: "No login request found",
});
}
// Fetch login request with user data by token
const result = await db
.selectFrom("login_requests")
.innerJoin("users", "users.id", "login_requests.user_id")
.select([
"login_requests.id",
"login_requests.user_id",
"login_requests.expires_at",
"users.email",
"users.display_name",
"users.email_verified_at",
"users.is_superuser",
])
.where("login_requests.token", "=", loginRequestToken)
.where("login_requests.expires_at", ">", new Date())
.executeTakeFirst();
if (!result) {
throw new ORPCError("BAD_REQUEST", {
message: "Login request expired or not found",
});
}
const sessionUser: SessionUser = {
id: result.user_id,
email: result.email,
displayName: result.display_name,
emailVerifiedAt: result.email_verified_at,
isSuperuser: result.is_superuser,
};
return next({
context: {
loginRequestId: Number(result.id),
user: sessionUser,
},
});
},
);

View File

@@ -0,0 +1,10 @@
/**
* Base implementer with typed APIContext
* All procedures and middlewares should derive from this
*/
import type { APIContext } from "../context.js";
import { implement } from "@orpc/server";
import { contract } from "@reviq/api-contract";
export const os = implement(contract).$context<APIContext>();

View File

@@ -0,0 +1,19 @@
/**
* Superuser middleware - authenticates and requires superuser access
*
* This middleware chains authMiddleware first, then checks for superuser.
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware } from "./auth.js";
export const superuserMiddleware = authMiddleware.concat(
async ({ context, next }) => {
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Superuser access required",
});
}
return next();
},
);

View File

@@ -3,12 +3,11 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminAuthCompleteLogin =
superuserProcedure.admin.auth.completeLogin.handler(
async ({ input, context }) => {
const email = input.email.toLowerCase();
// First check if any login request exists for this email
@@ -48,4 +47,5 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
.execute();
return { success: true };
});
},
);

View File

@@ -3,12 +3,10 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminOrgsCreate = os.admin.orgs.create
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
async ({ input, context }) => {
const { slug, displayName, ownerEmail } = input;
// Find owner user by email (outside transaction - read-only)
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
});
return { slug };
});
},
);

View File

@@ -3,12 +3,10 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminOrgsDelete = os.admin.orgs.delete
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
async ({ input, context }) => {
const { slug } = input;
// Delete org and related records in transaction
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
});
return { success: true };
});
},
);

View File

@@ -3,13 +3,11 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
import { toOrgResponse } from "../helpers.js";
export const adminOrgsGet = os.admin.orgs.get
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
async ({ input, context }) => {
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", input.slug)
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
return toOrgResponse(org);
});
},
);

View File

@@ -2,13 +2,12 @@
* admin.orgs.list - List all organizations
*/
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
import { toOrgResponse } from "../helpers.js";
export const adminOrgsList = os.admin.orgs.list
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ context }) => {
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
async ({ context }) => {
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
return orgs.map(toOrgResponse);
});
},
);

View File

@@ -4,13 +4,12 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
import { toSiteResponse } from "../helpers.js";
export const adminOrgsListSites = os.admin.orgs.listSites
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsListSites =
superuserProcedure.admin.orgs.listSites.handler(
async ({ input, context }) => {
const { slug } = input;
const org = await context.db
@@ -29,12 +28,11 @@ export const adminOrgsListSites = os.admin.orgs.listSites
.execute();
return sites.map(toSiteResponse);
});
},
);
export const adminOrgsAddSite = os.admin.orgs.addSite
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
async ({ input, context }) => {
const { slug, domain } = input;
// Use transaction to prevent race condition on site creation
@@ -70,12 +68,12 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
});
return { success: true };
});
},
);
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsRemoveSite =
superuserProcedure.admin.orgs.removeSite.handler(
async ({ input, context }) => {
const { slug, domain } = input;
const org = await context.db
@@ -98,4 +96,5 @@ export const adminOrgsRemoveSite = os.admin.orgs.removeSite
}
return { success: true };
});
},
);

View File

@@ -3,12 +3,10 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminOrgsUpdate = os.admin.orgs.update
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
async ({ input, context }) => {
const { slug, displayName, logoUrl } = input;
// Check if there are actual updates to make
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
}
return { success: true };
});
},
);

View File

@@ -3,12 +3,11 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminUsersConfirmEmail =
superuserProcedure.admin.users.confirmEmail.handler(
async ({ input, context }) => {
const result = await context.db
.updateTable("users")
.set({
@@ -23,4 +22,5 @@ export const adminUsersConfirmEmail = os.admin.users.confirmEmail
}
return { success: true };
});
},
);

View File

@@ -3,12 +3,10 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminUsersCreate = os.admin.users.create
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
async ({ input, context }) => {
const { email, name, orgSlug, orgRole } = input;
const normalizedEmail = email.toLowerCase();
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
});
return { success: true };
});
},
);

View File

@@ -3,13 +3,11 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
import { toUserResponse } from "../helpers.js";
export const adminUsersGet = os.admin.users.get
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
async ({ input, context }) => {
const user = await context.db
.selectFrom("users")
.where("email", "=", input.email.toLowerCase())
@@ -19,4 +17,5 @@ export const adminUsersGet = os.admin.users.get
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
return toUserResponse(user);
});
},
);

View File

@@ -2,13 +2,12 @@
* admin.users.list - List all users
*/
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
import { toUserResponse } from "../helpers.js";
export const adminUsersList = os.admin.users.list
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ context }) => {
export const adminUsersList = superuserProcedure.admin.users.list.handler(
async ({ context }) => {
const users = await context.db.selectFrom("users").selectAll().execute();
return users.map(toUserResponse);
});
},
);

View File

@@ -3,12 +3,10 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { superuserProcedure } from "../../base.js";
export const adminUsersUpdate = os.admin.users.update
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
async ({ input, context }) => {
const { email, isSuperuser } = input;
const normalizedEmail = email.toLowerCase();
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
}
return { success: true };
});
},
);

View File

@@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
const hasPassword = user.password_hash !== null;
// Get geo info and user agent
const geo = getGeoInfo(context.reqHeaders);
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders);
// Create login request with secure token

View File

@@ -6,12 +6,13 @@
* This prevents attackers from determining which emails are registered
*/
import { withTransaction } from "@reviq/db";
import { sendPasswordResetEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendPasswordResetEmail } from "../../utils/email.js";
import { os } from "../base.js";
export const forgotPassword = os.auth.forgotPassword.handler(
@@ -30,19 +31,21 @@ export const forgotPassword = os.auth.forgotPassword.handler(
// If user exists, create password reset token and send email
if (user) {
// Delete any existing password reset tokens for this user (security measure)
await context.db
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
// Generate secure base58 token
const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
await context.db
// Delete old tokens and insert new one in transaction
await withTransaction(context.db, async (trx) => {
// Delete any existing password reset tokens for this user (security measure)
await trx
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
await trx
.insertInto("password_resets")
.values({
user_id: user.id,
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt,
})
.execute();
});
// Send password reset email (stubbed)
await sendPasswordResetEmail(user.email, token);
// Send password reset email
await sendPasswordResetEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: user.email,
token,
expiryHours: 1,
});
}
// Always return success (anti-enumeration)

View File

@@ -16,6 +16,7 @@
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
*/
import { withTransaction } from "@reviq/db";
import {
COOKIE_NAMES,
COOKIE_OPTIONS,
@@ -86,12 +87,16 @@ export const loginIfRequestIsCompleted =
}
// Get current request info
const geo = getGeoInfo(context.reqHeaders);
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await withTransaction(
context.db,
async (trx) => {
// Upsert user device
const deviceId = await upsertUserDevice(
context.db,
trx,
userId,
deviceFingerprint,
geo,
@@ -99,14 +104,10 @@ export const loginIfRequestIsCompleted =
);
// Check if device is already trusted
const deviceTrusted = await isDeviceTrusted(
context.db,
userId,
deviceFingerprint,
);
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
// Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, {
const newSession = await createSession(trx, {
userId,
deviceId,
trustedMode: true,
@@ -115,11 +116,15 @@ export const loginIfRequestIsCompleted =
});
// Delete the login request (it's been consumed)
await context.db
await trx
.deleteFrom("login_requests")
.where("id", "=", loginRequest.id)
.execute();
return { session: newSession, deviceTrusted: trusted };
},
);
// Set session cookie
setCookie(
context.resHeaders,

View File

@@ -4,8 +4,8 @@
*/
import { ORPCError } from "@orpc/server";
import { sendLoginConfirmationEmail } from "@reviq/emails";
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
import { sendLoginConfirmationEmail } from "../../utils/email.js";
import { verifyPassword } from "../../utils/password.js";
import { isDeviceTrusted } from "../../utils/session.js";
import { os } from "../base.js";
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
} else {
// Device is untrusted - send confirmation email with existing token
// The same base58 token is used for both cookie lookup and email confirmation
await sendLoginConfirmationEmail(result.email, result.token);
await sendLoginConfirmationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: result.email,
token: result.token,
expiryMinutes: 15,
});
}
return { success: true };

View File

@@ -3,7 +3,7 @@
*/
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
/**
* Logout handler
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
* - Revokes the current session by setting revoked_at to now()
* - Clears the session cookie from the response
*/
export const logout = os.auth.logout
.use(authMiddleware)
.handler(async ({ context }) => {
export const logout = authedProcedure.auth.logout.handler(
async ({ context }) => {
// Revoke the current session
await context.db
.updateTable("sessions")
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
return { success: true };
});
},
);

View File

@@ -10,17 +10,16 @@
* 5. Send verification email (stubbed)
*/
import { sendVerificationEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
export const resendVerificationEmail = os.auth.resendVerificationEmail
.use(authMiddleware)
.handler(async ({ context }) => {
export const resendVerificationEmail =
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
// Check if email is already verified
if (context.user.emailVerifiedAt !== null) {
// Email already verified, return early
@@ -47,8 +46,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
})
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(context.user.email, token);
// Send verification email
await sendVerificationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: context.user.email,
token,
expiryHours: 24,
});
return { success: true };
});

View File

@@ -10,6 +10,8 @@ import type {
import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server";
import { withTransaction } from "@reviq/db";
import { sendVerificationEmail } from "@reviq/emails";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import {
COOKIE_NAMES,
@@ -21,7 +23,6 @@ import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { hashPassword, validatePassword } from "../../utils/password.js";
import { createSession } from "../../utils/session.js";
@@ -52,7 +53,8 @@ export async function signupWithPassword(
// Hash password
const passwordHash = await hashPassword(password);
// Create user
// Create user (handle race condition if concurrent signup with same email)
try {
const user = await db
.insertInto("users")
.values({
@@ -63,6 +65,16 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow();
return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
}
/**
@@ -97,7 +109,7 @@ export async function signupWithPasskey(
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("options")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.where("created_at", ">", fifteenMinutesAgo)
.executeTakeFirst();
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
// Delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
// Log error for debugging but don't expose to client
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
// Delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
throw new ORPCError("BAD_REQUEST", {
@@ -146,8 +158,9 @@ export async function signupWithPasskey(
});
}
// Create user and passkey in a transaction
const result = await db.transaction().execute(async (trx) => {
// Create user and passkey in a transaction (handle race condition if concurrent signup)
try {
const result = await withTransaction(db, async (trx) => {
// Create user
const user = await trx
.insertInto("users")
@@ -188,13 +201,23 @@ export async function signupWithPasskey(
// Delete the challenge
await trx
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
return { userId: newUserId };
});
return result.userId;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
}
/**
@@ -225,7 +248,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
}
// Get geo info and user agent for session creation
const geo = getGeoInfo(context.reqHeaders);
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders);
let userId: number;
@@ -241,14 +264,22 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
);
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else {
// Should never reach here due to schema validation
// Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required",
});
}
// Generate verification token
const verificationToken = generateSecureBase58Token();
const verificationExpiresAt = generateExpiry(
TOKEN_DURATIONS.EMAIL_VERIFICATION,
);
// Create session and email verification in transaction
const session = await withTransaction(context.db, async (trx) => {
// Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, {
const newSession = await createSession(trx, {
userId,
deviceId: null,
trustedMode: false,
@@ -256,6 +287,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
userAgent,
});
// Store verification token (store raw token, not hash - it's already high-entropy)
await trx
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: verificationExpiresAt,
})
.execute();
return newSession;
});
// Set session cookie
setCookie(
context.resHeaders,
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
COOKIE_OPTIONS.session,
);
// Generate verification token
const verificationToken = generateSecureBase58Token();
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
// Store verification token (store raw token, not hash - it's already high-entropy)
await context.db
.insertInto("email_verifications")
.values({
user_id: userId,
// Send verification email
await sendVerificationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email,
token: verificationToken,
expires_at: expiresAt,
})
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken);
expiryHours: 24,
});
return { success: true };
});

View File

@@ -8,227 +8,22 @@
import type {
APIContext,
AuthenticatedContext,
AuthInfo,
LoginRequestContext,
Session,
SessionUser,
} from "../context.js";
import { implement, ORPCError } from "@orpc/server";
import { contract } from "@reviq/api-contract";
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
import { hashToken } from "../utils/crypto.js";
import {
authMiddleware,
loginRequestMiddleware,
os,
superuserMiddleware,
} from "../middlewares/index.js";
/**
* Base implementer with typed APIContext
* All procedures should be derived from this
*/
export const os = implement(contract).$context<APIContext>();
// Re-export middlewares and os
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
/**
* Auth middleware - validates session/API token and adds user to context
* Use with os.use(authMiddleware) to create authenticated procedures
*/
export const authMiddleware = os.middleware(async ({ context, next }) => {
const { db, reqHeaders } = context;
// Try session cookie first
let tokenHash: string | undefined;
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
if (sessionToken) {
tokenHash = await hashToken(sessionToken);
}
// Fall back to API key header (for CLI)
const apiKey = reqHeaders.get("x-api-key");
if (!tokenHash && apiKey) {
tokenHash = await hashToken(apiKey);
}
if (!tokenHash) {
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
}
// Look up session (check not expired and not revoked)
const session = await db
.selectFrom("sessions")
.where("token_hash", "=", tokenHash)
.where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.selectAll()
.executeTakeFirst();
// Fall back to API token if no session found
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",
});
}
// Update last_used_at for API tokens
if (apiToken) {
await db
.updateTable("api_tokens")
.set({ last_used_at: new Date() })
.where("id", "=", apiToken.id)
.execute();
}
// Fetch user details
const user = await db
.selectFrom("users")
.where("id", "=", userId)
.select([
"id",
"email",
"display_name",
"email_verified_at",
"is_superuser",
])
.executeTakeFirst();
if (!user) {
throw new ORPCError("UNAUTHORIZED", {
message: "User not found",
});
}
const sessionUser: SessionUser = {
id: user.id,
email: user.email,
displayName: user.display_name,
emailVerifiedAt: user.email_verified_at,
isSuperuser: user.is_superuser,
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {
user: sessionUser,
session: sessionInfo,
auth: authInfo,
},
});
});
/**
* Login request middleware - validates login request token from cookie
*/
export const loginRequestMiddleware = os.middleware(
async ({ context, next }) => {
const { db, reqHeaders } = context;
// Read login request token from cookie
const loginRequestToken = getCookie(
reqHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
if (!loginRequestToken) {
throw new ORPCError("BAD_REQUEST", {
message: "No login request found",
});
}
// Fetch login request with user data by token
const result = await db
.selectFrom("login_requests")
.innerJoin("users", "users.id", "login_requests.user_id")
.select([
"login_requests.id",
"login_requests.user_id",
"login_requests.expires_at",
"users.email",
"users.display_name",
"users.email_verified_at",
"users.is_superuser",
])
.where("login_requests.token", "=", loginRequestToken)
.where("login_requests.expires_at", ">", new Date())
.executeTakeFirst();
if (!result) {
throw new ORPCError("BAD_REQUEST", {
message: "Login request expired or not found",
});
}
const sessionUser: SessionUser = {
id: result.user_id,
email: result.email,
displayName: result.display_name,
emailVerifiedAt: result.email_verified_at,
isSuperuser: result.is_superuser,
};
return next({
context: {
loginRequestId: Number(result.id),
user: sessionUser,
},
});
},
);
/**
* Superuser middleware - requires admin access (must be used after authMiddleware)
*/
export const superuserMiddleware = os.middleware(async ({ context, next }) => {
// This middleware should be used after authMiddleware
const ctx = context as AuthenticatedContext;
if (!ctx.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Superuser access required",
});
}
return next();
});
// Pre-configured procedures with middleware applied
export const authedProcedure = os.use(authMiddleware);
export const superuserProcedure = os.use(superuserMiddleware);
export const loginRequestProcedure = os.use(loginRequestMiddleware);
// Type exports for use in procedure files
export type { APIContext, AuthenticatedContext, LoginRequestContext };

View File

@@ -0,0 +1,7 @@
/**
* Base route for me procedures with auth middleware applied
*/
import { authedProcedure } from "../base.js";
export const meRoute = authedProcedure.me;

View File

@@ -2,6 +2,7 @@
* Me routes - consolidated exports for os.router()
*/
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js";
import {
@@ -54,4 +55,9 @@ export const meRoutes = {
untrust: untrustDevice,
revokeAll: revokeAllTrustedDevices,
},
apiTokens: {
list: listApiTokens,
create: createApiToken,
delete: deleteApiToken,
},
};

View File

@@ -0,0 +1,109 @@
/**
* API token management procedures
* Allows users to create and manage API tokens for CLI/programmatic access
*/
import { ORPCError } from "@orpc/server";
import {
generateSecureBase58Token,
hashToken,
TOKEN_PREFIX,
} from "../../utils/crypto.js";
import { meRoute } from "./_base.js";
/** Token expiration: 365 days */
const TOKEN_EXPIRATION_DAYS = 365;
/**
* List all API tokens for the current user
* Returns token metadata (not the actual token values)
*/
export const listApiTokens = meRoute.apiTokens.list.handler(
async ({ context }) => {
const tokens = await context.db
.selectFrom("api_tokens")
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
.where("user_id", "=", context.user.id)
.orderBy("created_at", "desc")
.execute();
return tokens.map((token) => ({
id: Number(token.id),
name: token.name,
lastUsedAt: token.last_used_at?.toISOString() ?? null,
createdAt: token.created_at.toISOString(),
expiresAt: token.expires_at.toISOString(),
}));
},
);
/**
* Create a new API token
* Requires superuser status and trusted session
*/
export const createApiToken = meRoute.apiTokens.create.handler(
async ({ input, context }) => {
// Require superuser status
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Only superusers can create API tokens.",
});
}
// Require trusted session for creating API tokens
if (!context.session.trustedMode) {
throw new ORPCError("FORBIDDEN", {
message:
"Creating API tokens requires a trusted session. Please re-authenticate.",
});
}
const { name } = input;
// Generate a new API token
const token = generateSecureBase58Token(TOKEN_PREFIX);
const tokenHash = await hashToken(token);
// Calculate expiration
const expiresAt = new Date(
Date.now() + TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000,
);
// Insert into api_tokens table
await context.db
.insertInto("api_tokens")
.values({
user_id: context.user.id,
token_hash: tokenHash,
name,
expires_at: expiresAt,
})
.execute();
return {
token,
expiresAt: expiresAt.toISOString(),
};
},
);
/**
* Delete an API token
*/
export const deleteApiToken = meRoute.apiTokens.delete.handler(
async ({ input, context }) => {
const result = await context.db
.deleteFrom("api_tokens")
.where("id", "=", input.tokenId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
if (result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", {
message: "API token not found",
});
}
return { success: true };
},
);

View File

@@ -2,11 +2,9 @@
* Get current user auth status
*/
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
export const meAuthStatus = os.me.authStatus
.use(authMiddleware)
.handler(async ({ context }) => {
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
@@ -38,4 +36,4 @@ export const meAuthStatus = os.me.authStatus
},
auth: context.auth,
};
});
});

View File

@@ -5,7 +5,7 @@
import { ORPCError } from "@orpc/server";
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
import { verifyPassword } from "../../utils/password.js";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* Delete account handler
@@ -14,9 +14,7 @@ import { authMiddleware, os } from "../base.js";
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
* - Clears session cookie
*/
export const meDelete = os.me.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
const { password } = input;
// Fetch user with password hash
@@ -49,4 +47,4 @@ export const meDelete = os.me.delete
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
return { success: true };
});
});

View File

@@ -3,7 +3,7 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
/**
@@ -13,9 +13,8 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
* @throws BAD_REQUEST if no device fingerprint found
* @throws NOT_FOUND if device doesn't exist
*/
export const getDeviceInfo = os.me.devices.getInfo
.use(authMiddleware)
.handler(async ({ context }) => {
export const getDeviceInfo = meRoute.devices.getInfo.handler(
async ({ context }) => {
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
const device = await context.db
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
lastUsedAt: device.last_used_at,
isTrusted: device.is_trusted,
};
});
},
);
/**
* Trust device handler
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
* @throws BAD_REQUEST if no device fingerprint found
* @throws NOT_FOUND if device doesn't exist
*/
export const trustDevice = os.me.devices.trust
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const trustDevice = meRoute.devices.trust.handler(
async ({ input, context }) => {
const { name } = input;
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
}
return { success: true };
});
},
);
/**
* List trusted devices handler
* - Requires authentication
* - Returns all trusted devices for the current user
*/
export const listTrustedDevices = os.me.devices.listTrusted
.use(authMiddleware)
.handler(async ({ context }) => {
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
async ({ context }) => {
const devices = await context.db
.selectFrom("user_devices")
.selectAll()
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
lastUsedAt: d.last_used_at,
isTrusted: d.is_trusted,
}));
});
},
);
/**
* Untrust device handler
@@ -102,13 +102,12 @@ export const listTrustedDevices = os.me.devices.listTrusted
* - Marks device as untrusted by ID
* @throws NOT_FOUND if device doesn't exist
*/
export const untrustDevice = os.me.devices.untrust
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const untrustDevice = meRoute.devices.untrust.handler(
async ({ input, context }) => {
const result = await context.db
.updateTable("user_devices")
.set({ is_trusted: false })
.where("id", "=", String(input.deviceId))
.where("id", "=", input.deviceId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
}
return { success: true };
});
},
);
/**
* Revoke all trusted devices handler
* - Requires authentication
* - Marks all devices as untrusted
*/
export const revokeAllTrustedDevices = os.me.devices.revokeAll
.use(authMiddleware)
.handler(async ({ context }) => {
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
async ({ context }) => {
await context.db
.updateTable("user_devices")
.set({ is_trusted: false })
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
.execute();
return { success: true };
});
},
);

View File

@@ -2,11 +2,9 @@
* Get current user profile
*/
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
export const meGet = os.me.get
.use(authMiddleware)
.handler(async ({ context }) => {
export const meGet = meRoute.get.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
@@ -35,4 +33,4 @@ export const meGet = os.me.get
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
};
});
});

View File

@@ -3,15 +3,13 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* List pending invites for the current user
* Only returns invites where the user's email matches and email is verified
*/
export const listInvites = os.me.invites.list
.use(authMiddleware)
.handler(async ({ context }) => {
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
// Only show invites if email is verified
if (!context.user.emailVerifiedAt) {
return [];
@@ -52,15 +50,14 @@ export const listInvites = os.me.invites.list
createdAt: i.created_at,
expiresAt: i.expires_at,
}));
});
});
/**
* Get a specific invite by ID
* Only returns if the invite belongs to the current user's email
*/
export const getInvite = os.me.invites.get
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const getInvite = meRoute.invites.get.handler(
async ({ input, context }) => {
const { inviteId } = input;
// Only show invite if email is verified
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
createdAt: invite.created_at,
expiresAt: invite.expires_at,
};
});
},
);
/**
* Accept an invite by ID
* Adds user to org and deletes the invite
*/
export const acceptInvite = os.me.invites.accept
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const acceptInvite = meRoute.invites.accept.handler(
async ({ input, context }) => {
const { inviteId } = input;
// Only allow accepting if email is verified
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
}
return { success: true };
});
},
);
/**
* Decline an invite
* Deletes the invite if it belongs to the current user's email
*/
export const declineInvite = os.me.invites.decline
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const declineInvite = meRoute.invites.decline.handler(
async ({ input, context }) => {
const { inviteId } = input;
// Delete the invite only if it matches user's email
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
}
return { success: true };
});
},
);

View File

@@ -4,16 +4,15 @@
import { ORPCError } from "@orpc/server";
import { getUserPasskeys } from "../../utils/webauthn.js";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* List passkeys handler
* - Requires authentication
* - Returns all passkeys for the current user
*/
export const listPasskeys = os.me.passkeys.list
.use(authMiddleware)
.handler(async ({ context }) => {
export const listPasskeys = meRoute.passkeys.list.handler(
async ({ context }) => {
const passkeys = await getUserPasskeys(context.db, context.user.id);
return passkeys.map((p) => ({
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
createdAt: p.createdAt,
lastUsedAt: p.lastUsedAt,
}));
});
},
);
/**
* Rename passkey handler
@@ -30,15 +30,14 @@ export const listPasskeys = os.me.passkeys.list
* - Updates passkey name
* @throws NOT_FOUND if passkey doesn't exist
*/
export const renamePasskey = os.me.passkeys.rename
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const renamePasskey = meRoute.passkeys.rename.handler(
async ({ input, context }) => {
const { passkeyId, name } = input;
const result = await context.db
.updateTable("passkeys")
.set({ name })
.where("id", "=", String(passkeyId))
.where("id", "=", passkeyId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
}
return { success: true };
});
},
);
/**
* Delete passkey handler
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
* @throws NOT_FOUND if passkey doesn't exist
* @throws BAD_REQUEST if trying to delete last passkey without password
*/
export const deletePasskey = os.me.passkeys.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const deletePasskey = meRoute.passkeys.delete.handler(
async ({ input, context }) => {
const { passkeyId } = input;
// Use transaction to prevent race condition when checking last passkey
@@ -86,7 +85,7 @@ export const deletePasskey = os.me.passkeys.delete
const result = await trx
.deleteFrom("passkeys")
.where("id", "=", String(passkeyId))
.where("id", "=", passkeyId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
});
return { success: true };
});
},
);

View File

@@ -3,7 +3,7 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* List sessions handler
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
* - Returns all sessions for the current user
* - Includes isCurrent flag to identify active session
*/
export const listSessions = os.me.sessions.list
.use(authMiddleware)
.handler(async ({ context }) => {
export const listSessions = meRoute.sessions.list.handler(
async ({ context }) => {
const sessions = await context.db
.selectFrom("sessions")
.selectAll()
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
isCurrent: s.id === context.session.id,
revokedAt: s.revoked_at,
}));
});
},
);
/**
* Revoke session handler
@@ -42,13 +42,12 @@ export const listSessions = os.me.sessions.list
* @throws NOT_FOUND if session doesn't exist
* @throws BAD_REQUEST if trying to revoke current session
*/
export const revokeSession = os.me.sessions.revoke
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const revokeSession = meRoute.sessions.revoke.handler(
async ({ input, context }) => {
const { sessionId } = input;
// Prevent revoking current session (use logout instead)
if (String(sessionId) === context.session.id) {
if (sessionId.toString() === context.session.id) {
throw new ORPCError("BAD_REQUEST", {
message: "Cannot revoke current session. Use logout instead.",
});
@@ -57,7 +56,7 @@ export const revokeSession = os.me.sessions.revoke
const result = await context.db
.updateTable("sessions")
.set({ revoked_at: new Date() })
.where("id", "=", String(sessionId))
.where("id", "=", sessionId.toString())
.where("user_id", "=", context.user.id)
.where("revoked_at", "is", null)
.executeTakeFirst();
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
}
return { success: true };
});
},
);
/**
* Revoke all sessions handler
* - Requires authentication
* - Revokes all sessions except current
*/
export const revokeAllSessions = os.me.sessions.revokeAll
.use(authMiddleware)
.handler(async ({ context }) => {
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
async ({ context }) => {
// Revoke all sessions except current
await context.db
.updateTable("sessions")
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
.execute();
return { success: true };
});
},
);

View File

@@ -8,7 +8,7 @@ import {
validatePassword,
verifyPassword,
} from "../../utils/password.js";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* Set password handler
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
* - If user has existing password, currentPassword is required
* - Validates new password strength using zxcvbn
*/
export const setPassword = os.me.setPassword
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const setPassword = meRoute.setPassword.handler(
async ({ input, context }) => {
const { currentPassword, newPassword } = input;
// Fetch current password hash
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
.execute();
return { success: true };
});
},
);

View File

@@ -2,11 +2,10 @@
* Setup user profile (initial setup after signup)
*/
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
export const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const setupProfile = meRoute.setupProfile.handler(
async ({ input, context }) => {
const { displayName, fullName, phoneNumber } = input;
await context.db
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
.execute();
return { success: true };
});
},
);

View File

@@ -3,7 +3,7 @@
*/
import type { ProfileUpdate } from "./helpers.js";
import { authMiddleware, os } from "../base.js";
import { meRoute } from "./_base.js";
/**
* Update profile handler
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
* - Automatically sets updated_at timestamp
*/
export const updateProfile = os.me.updateProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const updateProfile = meRoute.updateProfile.handler(
async ({ input, context }) => {
const updates: Partial<ProfileUpdate> = {};
if (input.displayName !== undefined) {
updates.display_name = input.displayName;
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
}
return { success: true };
});
},
);

View File

@@ -3,15 +3,14 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
import { getMembership, lookupOrgBySlug } from "./helpers.js";
/**
* List all orgs the current user is a member of
*/
export const orgsList = os.orgs.list
.use(authMiddleware)
.handler(async ({ context }) => {
export const orgsList = authedProcedure.orgs.list.handler(
async ({ context }) => {
const orgs = await context.db
.selectFrom("org_members")
.innerJoin("orgs", "orgs.id", "org_members.org_id")
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
logoUrl: o.logo_url,
createdAt: o.created_at,
}));
});
},
);
/**
* Create a new org
* The creating user becomes the owner
*/
export const orgsCreate = os.orgs.create
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const orgsCreate = authedProcedure.orgs.create.handler(
async ({ input, context }) => {
const { slug, displayName } = input;
try {
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
}
throw error;
}
});
},
);
/**
* Get a single org by slug
* Requires membership
*/
export const orgsGet = os.orgs.get
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const orgsGet = authedProcedure.orgs.get.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify membership
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
logoUrl: org.logoUrl,
createdAt: org.createdAt,
};
});
},
);

View File

@@ -5,25 +5,11 @@
import type { DB, OrgRole } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { OrgInfo, OrgMembership } from "../../context.js";
import { ORPCError } from "@orpc/server";
// ===== Types =====
/** Org info returned from lookup */
export interface OrgInfo {
id: number;
slug: string;
displayName: string;
logoUrl: string | null;
createdAt: Date;
}
/** User's membership in an org */
export interface OrgMembership {
id: number;
role: OrgRole;
createdAt: Date;
}
// Re-export types for convenience
export type { OrgInfo, OrgMembership };
// ===== Role Hierarchy =====
@@ -115,10 +101,11 @@ export async function countOwners(
): Promise<number> {
const result = await db
.selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count"))
.select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId)
.where("role", "=", "owner")
.executeTakeFirstOrThrow();
return result.count;
// PostgreSQL COUNT returns bigint (string), convert to number
return Number(result.count);
}

View File

@@ -3,22 +3,21 @@
*/
import { ORPCError } from "@orpc/server";
import { sendOrgInviteEmail } from "@reviq/emails";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
/**
* List pending invites for an org
* Requires admin or owner role
*/
export const invitesList = os.orgs.invites.list
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const invitesList = authedProcedure.orgs.invites.list.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify admin+ role
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
createdAt: i.created_at,
expiresAt: i.expires_at,
}));
});
},
);
/**
* Create an invite for a new member
* Requires admin or owner role
* Only owners can invite new owners (privilege escalation prevention)
*/
export const invitesCreate = os.orgs.invites.create
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
async ({ input, context }) => {
const { slug, email: rawEmail, role } = input;
const email = rawEmail.toLowerCase();
@@ -122,18 +121,28 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation email
const inviterName = context.user.displayName ?? context.user.email;
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
await sendOrgInviteEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email,
token,
orgName: org.displayName,
inviterName,
role,
expiryDays: ORG_INVITE_EXPIRY_DAYS,
});
return { success: true };
});
},
);
/**
* Cancel a pending invite
* Requires admin or owner role
*/
export const invitesCancel = os.orgs.invites.cancel
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
async ({ input, context }) => {
const { slug, inviteId } = input;
// Lookup org and verify admin+ role
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
}
return { success: true };
});
},
);
/**
* Accept an invitation
* Token-based lookup, requires auth but no org membership
* Handles race condition if user is already a member
*/
export const invitesAccept = os.orgs.invites.accept
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
async ({ input, context }) => {
const { token } = input;
// Find the invite by token (must not be expired)
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
}
return { success: true };
});
},
);

View File

@@ -3,7 +3,7 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
import {
countOwners,
getMembership,
@@ -15,9 +15,8 @@ import {
* Update org details
* Requires admin or owner role
*/
export const orgsUpdate = os.orgs.update
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const orgsUpdate = authedProcedure.orgs.update.handler(
async ({ input, context }) => {
const { slug, displayName, logoUrl } = input;
// Lookup org and verify membership with admin+ role
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
.execute();
return { success: true };
});
},
);
/**
* Delete an org
* Requires owner role
* FK CASCADE handles deleting members, invites, and sites
*/
export const orgsDelete = os.orgs.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const orgsDelete = authedProcedure.orgs.delete.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify ownership
@@ -61,16 +60,16 @@ export const orgsDelete = os.orgs.delete
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
return { success: true };
});
},
);
/**
* Leave an org
* Cannot leave if you're the only owner
* Uses transaction to prevent race condition where multiple owners leave simultaneously
*/
export const orgsLeave = os.orgs.leave
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const orgsLeave = authedProcedure.orgs.leave.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and get membership
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
});
return { success: true };
});
},
);

View File

@@ -3,7 +3,7 @@
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
import {
countOwners,
getMembership,
@@ -15,9 +15,8 @@ import {
* List all members of an org
* Any member can view the member list
*/
export const membersList = os.orgs.members.list
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const membersList = authedProcedure.orgs.members.list.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify membership
@@ -48,21 +47,26 @@ export const membersList = os.orgs.members.list
role: m.role,
createdAt: m.created_at,
}));
});
},
);
/**
* Update a member's role
* Only owners can change roles
* Uses transaction to prevent race condition when demoting owners
*/
export const membersUpdateRole = os.orgs.members.updateRole
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const membersUpdateRole =
authedProcedure.orgs.members.updateRole.handler(
async ({ input, context }) => {
const { slug, userId, role: newRole } = input;
// Lookup org and verify ownership
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
const membership = await getMembership(
context.db,
org.id,
context.user.id,
);
requireRole(membership, "owner");
await context.db.transaction().execute(async (trx) => {
@@ -97,16 +101,16 @@ export const membersUpdateRole = os.orgs.members.updateRole
});
return { success: true };
});
},
);
/**
* Remove a member from an org
* Owners can remove anyone, admins can only remove members
* Uses transaction to prevent race condition when removing owners
*/
export const membersRemove = os.orgs.members.remove
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const membersRemove = authedProcedure.orgs.members.remove.handler(
async ({ input, context }) => {
const { slug, userId } = input;
// Lookup org and verify membership
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
});
return { success: true };
});
},
);

View File

@@ -2,16 +2,15 @@
* Org sites procedures - list
*/
import { authMiddleware, os } from "../base.js";
import { authedProcedure } from "../base.js";
import { getMembership, lookupOrgBySlug } from "./helpers.js";
/**
* List all sites for an org
* Any member can view the site list
*/
export const sitesList = os.orgs.sites.list
.use(authMiddleware)
.handler(async ({ input, context }) => {
export const sitesList = authedProcedure.orgs.sites.list.handler(
async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify membership
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
domain: s.domain,
createdAt: s.created_at,
}));
});
},
);

View File

@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
await context.db
.updateTable("login_requests")
.set({ completed_at: new Date() })
.where("id", "=", String(context.loginRequestId))
.where("id", "=", context.loginRequestId.toString())
.execute();
return { success: true };

View File

@@ -1,75 +0,0 @@
/**
* Authentication utilities for token handling
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { hashToken } from "./crypto.js";
export interface AuthenticatedUser {
id: number;
email: string;
isSuperuser: boolean;
}
/**
* Authenticate a request using session token or API key
* Returns the authenticated user or null if not authenticated
*/
export const authenticateRequest = async (
db: Kysely<Database>,
sessionToken?: string,
apiKey?: string,
): Promise<AuthenticatedUser | null> => {
// Try session cookie first, then API key
const token = sessionToken ?? apiKey;
if (!token) {
return null;
}
const tokenHash = await hashToken(token);
// Check sessions table
const session = await db
.selectFrom("sessions")
.innerJoin("users", "users.id", "sessions.user_id")
.where("sessions.token_hash", "=", tokenHash)
.where("sessions.expires_at", ">", new Date())
.where("sessions.revoked_at", "is", null)
.select(["users.id", "users.email", "users.is_superuser"])
.executeTakeFirst();
if (session) {
return {
id: session.id,
email: session.email,
isSuperuser: session.is_superuser,
};
}
// Check API tokens table
const apiToken = await db
.selectFrom("api_tokens")
.innerJoin("users", "users.id", "api_tokens.user_id")
.where("api_tokens.token_hash", "=", tokenHash)
.where("api_tokens.expires_at", ">", new Date())
.select(["users.id", "users.email", "users.is_superuser"])
.executeTakeFirst();
if (apiToken) {
// Update last_used_at
await db
.updateTable("api_tokens")
.set({ last_used_at: new Date() })
.where("token_hash", "=", tokenHash)
.execute();
return {
id: apiToken.id,
email: apiToken.email,
isSuperuser: apiToken.is_superuser,
};
}
return null;
};

View File

@@ -1,4 +1,4 @@
import { generateSecureBase58Token } from "@reviq/utils";
import { generateSecureBase58Token } from "@reviq/server-utils";
import { base58 } from "@scure/base";
// Re-export for convenience

View File

@@ -1,419 +0,0 @@
/**
* Email sending utilities using Postmark
* Implements Workstream G: Email Service (Backend)
*/
import type { OrgRole } from "@reviq/db-schema";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { ServerClient } from "postmark";
import {
BASE_URL,
EMAIL_DEV_MODE,
EMAIL_FROM,
EMAIL_VERIFICATION_EXPIRY_HOURS,
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
ORG_INVITE_EXPIRY_DAYS,
PASSWORD_RESET_EXPIRY_HOURS,
POSTMARK_API_KEY,
} from "../constants.js";
// ===== Types =====
/**
* Email send result
*/
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
// ===== Postmark Client =====
let postmarkClient: ServerClient | null = null;
const getPostmarkClient = (): ServerClient => {
if (!postmarkClient) {
if (!POSTMARK_API_KEY) {
throw new Error(
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
);
}
postmarkClient = new ServerClient(POSTMARK_API_KEY);
}
return postmarkClient;
};
// ===== URL Helpers =====
/**
* Build a URL with query parameters using the URL constructor
*/
const buildUrl = (path: string, params: Record<string, string>): string => {
const url = new URL(path, BASE_URL);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
};
// ===== HTML Escaping =====
/**
* Escape HTML special characters to prevent XSS
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// ===== Core Email Function =====
interface SendEmailParams {
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
/**
* Send an email via Postmark (or log in dev mode)
*/
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
const { to, subject, htmlBody, textBody } = params;
// Dev mode: log instead of sending
if (EMAIL_DEV_MODE) {
console.log("=== DEV MODE EMAIL ===");
console.log(`To: ${to}`);
console.log(`Subject: ${subject}`);
console.log(`Body:\n${textBody}`);
console.log("======================");
return { success: true, messageId: "dev-mode" };
}
try {
const client = getPostmarkClient();
const result = await client.sendEmail({
From: EMAIL_FROM,
To: to,
Subject: subject,
HtmlBody: htmlBody,
TextBody: textBody,
});
return { success: true, messageId: result.MessageID };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to send email to ${to}:`, message);
return { success: false, error: message };
}
};
// ===== Template Helpers =====
const durationFormatter = new DurationFormat("en", { style: "long" });
const formatExpiryHours = (hours: number): string =>
durationFormatter.format({ hours });
const formatExpiryMinutes = (minutes: number): string =>
durationFormatter.format({ minutes });
const formatExpiryDays = (days: number): string =>
durationFormatter.format({ days });
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
/**
* Get the correct article (a/an) for a role
*/
const getArticleForRole = (role: OrgRole): string => {
return role === "owner" || role === "admin" ? "an" : "a";
};
// ===== Email Templates =====
// Common styles
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
const containerStyles =
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
const paragraphStyles =
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
const buttonStyles =
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
// Verification Email
const buildVerificationEmailHtml = (
verifyUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Verify your email</h1>
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
const buildVerificationEmailText = (
verifyUrl: string,
expiresIn: string,
): string =>
`Verify your email
Please verify your email address by clicking the link below:
${verifyUrl}
This link expires in ${expiresIn}.
If you didn't create an account, you can safely ignore this email.
`;
// Password Reset Email
const buildPasswordResetEmailHtml = (
resetUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Reset your password</h1>
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
</div>
</body>
</html>
`;
const buildPasswordResetEmailText = (
resetUrl: string,
expiresIn: string,
): string =>
`Reset your password
We received a request to reset your password. Click the link below to choose a new password:
${resetUrl}
This link expires in ${expiresIn}.
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
`;
// Login Confirmation Email
const buildLoginConfirmationEmailHtml = (
confirmUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Confirm your login</h1>
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
</div>
</body>
</html>
`;
const buildLoginConfirmationEmailText = (
confirmUrl: string,
expiresIn: string,
): string =>
`Confirm your login
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
${confirmUrl}
This link expires in ${expiresIn}.
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
`;
// Org Invite Email
const buildOrgInviteEmailHtml = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const safeOrgName = escapeHtml(orgName);
const safeInviterName = escapeHtml(inviterName);
const safeEmail = escapeHtml(email);
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
};
const buildOrgInviteEmailText = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `You've been invited to join ${orgName}
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
Click the link below to accept the invitation:
${inviteUrl}
This invitation expires in ${expiresIn}.
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
`;
};
// ===== Email Helpers =====
/**
* Send verification email to user
*/
export async function sendVerificationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/verify", { token });
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Verify your email address",
htmlBody: buildVerificationEmailHtml(url, expiresIn),
textBody: buildVerificationEmailText(url, expiresIn),
});
}
/**
* Send login confirmation email (for untrusted device flow)
*/
export async function sendLoginConfirmationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/confirm", { token });
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
return sendEmail({
to: email,
subject: "Confirm your login",
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
textBody: buildLoginConfirmationEmailText(url, expiresIn),
});
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/reset-password", { token });
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Reset your password",
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
textBody: buildPasswordResetEmailText(url, expiresIn),
});
}
/**
* Send org invite email
*/
export async function sendOrgInviteEmail(
email: string,
token: string,
orgName: string,
inviterName: string,
role: OrgRole,
): Promise<EmailResult> {
const url = buildUrl("/invite/accept", { token });
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
return sendEmail({
to: email,
subject: `You've been invited to join ${orgName}`,
htmlBody: buildOrgInviteEmailHtml(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
textBody: buildOrgInviteEmailText(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
});
}

View File

@@ -1,10 +1,18 @@
import { beforeEach, describe, expect, test } from "bun:test";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
test,
} from "bun:test";
import {
_resetForTesting,
_setReaderForTesting,
extractClientIP,
getGeoInfo,
getUserAgent,
initGeoReader,
lookupGeoFromIP,
} from "./geo.js";
@@ -220,3 +228,110 @@ describe("getUserAgent", () => {
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
});
});
describe("initGeoReader", () => {
beforeEach(() => {
_resetForTesting();
});
test("calling initGeoReader twice does not reinitialize", async () => {
// First call initializes
await initGeoReader();
// Second call should return early (covers the early return branch)
await initGeoReader();
// If we get here without error, the early return worked
expect(true).toBe(true);
});
test("handles missing database file gracefully", async () => {
// Save original env
const originalPath = Bun.env.GEOIP_DATABASE_PATH;
// Point to non-existent file
Bun.env.GEOIP_DATABASE_PATH = "/nonexistent/path/to/db.mmdb";
// Should not throw, just log a warning
await initGeoReader();
// Lookups should return nulls since reader failed to initialize
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
city: null,
region: null,
country: null,
});
// Restore original env
if (originalPath) {
Bun.env.GEOIP_DATABASE_PATH = originalPath;
} else {
delete Bun.env.GEOIP_DATABASE_PATH;
}
});
});
// Only run real database tests if GEOIP_DATABASE_PATH is set
const hasGeoDatabase = !!Bun.env.GEOIP_DATABASE_PATH;
describe.skipIf(!hasGeoDatabase)("real GeoIP database", () => {
beforeAll(async () => {
_resetForTesting();
await initGeoReader();
});
afterAll(() => {
_resetForTesting();
});
test("looks up Google DNS (8.8.8.8) - US", () => {
const result = lookupGeoFromIP("8.8.8.8");
expect(result.country).toBe("US");
});
test("looks up Cloudflare DNS (1.1.1.1) - AU", () => {
const result = lookupGeoFromIP("1.1.1.1");
// Cloudflare's 1.1.1.1 is geolocated to Sydney, Australia
expect(result.country).toBe("AU");
});
test("looks up known German IP", () => {
// Deutsche Telekom IP range
const result = lookupGeoFromIP("80.150.6.143");
expect(result.country).toBe("DE");
});
test("looks up known UK IP", () => {
// BBC IP range
const result = lookupGeoFromIP("212.58.244.71");
expect(result.country).toBe("GB");
});
test("returns city data for major IPs", () => {
const result = lookupGeoFromIP("8.8.8.8");
// DBIP returns "Mountain View" for Google DNS
expect(result.city).toBe("Mountain View");
expect(result.region).toBe("California");
});
test("getGeoInfo uses real database when no CF headers", () => {
const headers = createHeaders({ "X-Real-IP": "8.8.8.8" });
const result = getGeoInfo(headers);
expect(result.ip).toBe("8.8.8.8");
expect(result.country).toBe("US");
expect(result.city).toBe("Mountain View");
});
test("returns nulls for private/reserved IPs", () => {
const result = lookupGeoFromIP("192.168.1.1");
expect(result.city).toBeNull();
expect(result.country).toBeNull();
});
test("returns nulls for localhost", () => {
const result = lookupGeoFromIP("127.0.0.1");
expect(result.city).toBeNull();
expect(result.country).toBeNull();
});
});

View File

@@ -126,9 +126,16 @@ export const lookupGeoFromIP = (
/**
* Extract geolocation info from request headers.
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
*
* @param headers - Request headers to extract proxy IP headers from
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
*/
export const getGeoInfo = (headers: Headers): GeoInfo => {
const ip = extractClientIP(headers);
export const getGeoInfo = (
headers: Headers,
fallbackIP?: string | null,
): GeoInfo => {
// Try proxy headers first, then fall back to direct connection IP
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
// Try Cloudflare geo headers first
const cfCountry = headers.get("CF-IPCountry");

View File

@@ -1,7 +1,7 @@
import {
hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil,
} from "@reviq/utils";
} from "@reviq/server-utils";
import zxcvbn from "zxcvbn";
export interface PasswordValidationResult {

View File

@@ -1,6 +1,11 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { Kysely, Transaction } from "kysely";
import type { GeoInfo } from "./geo.js";
import {
isDeviceTrusted as dbIsDeviceTrusted,
upsertUserDevice as dbUpsertUserDevice,
insertSession,
} from "@reviq/db";
import { COOKIE_DURATIONS } from "./cookies.js";
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
@@ -23,33 +28,26 @@ export interface SessionResult {
* Returns the raw token (to be sent in cookie) and session details
*/
export async function createSession(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
options: CreateSessionOptions,
): Promise<SessionResult> {
const token = generateSessionToken();
const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const result = await insertSession(db, {
userId: options.userId,
deviceId: options.deviceId,
tokenHash,
trustedMode: options.trustedMode,
geo: options.geo,
userAgent: options.userAgent,
expiresAt,
});
return {
token,
sessionId: Number(result.id),
sessionId: result.sessionId,
expiresAt,
};
}
@@ -60,53 +58,22 @@ export async function createSession(
* Returns the device ID
*/
export async function upsertUserDevice(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: GeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.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(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
}

View File

@@ -162,7 +162,7 @@ export const verifyRegistration = async (
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("options")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.executeTakeFirst();
if (!challengeRow) {
@@ -189,7 +189,7 @@ export const verifyRegistration = async (
// Always delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
}
@@ -278,7 +278,7 @@ export const verifyAuthentication = async (
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("options")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.executeTakeFirst();
if (!challengeRow) {
@@ -321,7 +321,7 @@ export const verifyAuthentication = async (
counter: verification.authenticationInfo.newCounter.toString(),
last_used_at: new Date(),
})
.where("id", "=", String(passkey.id))
.where("id", "=", passkey.id.toString())
.execute();
return true;
@@ -329,7 +329,7 @@ export const verifyAuthentication = async (
// Always delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
}
};

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false,
"composite": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"]
}

86
apps/cli/README.md Normal file
View File

@@ -0,0 +1,86 @@
# RevIQ CLI
Command-line interface for RevIQ database and user management.
## Installation
```bash
# Build the CLI
bun run build
# The compiled binary will be at dist/reviq
```
## Usage
```bash
# Run directly with bun
bun run cli <command>
# Or use the compiled binary
./dist/reviq <command>
```
## Commands
### Authentication
```bash
# Login with an API token
reviq auth login --token <your-token>
# Check authentication status
reviq auth status
# Logout
reviq auth logout
```
To get an API token:
1. Log in to the web dashboard
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
### User Management
```bash
# Create a new user
reviq user create --email <email>
# Confirm email
reviq user confirm-email --code <code>
```
### Organization Management
```bash
# List organizations
reviq org list
# Create an organization
reviq org create --name <name> --slug <slug>
# Add a site to an organization
reviq org add-site --org <slug> --domain <domain>
```
### Admin Commands
```bash
# Complete login (admin)
reviq admin complete-login
```
### Other Commands
```bash
# Bootstrap the database
reviq bootstrap
# Generate shell completions
reviq completions
```
## Configuration
Credentials are stored at `~/.config/reviq/credentials.json`.

View File

@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test": "bun test"
"test": "bun test src/"
},
"dependencies": {
"@noble/hashes": "^2.0.1",

View File

@@ -1,7 +1,7 @@
import type { LocalContext } from "../../context.js";
import { ORPCError } from "@orpc/client";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface CompleteLoginFlags {
email: string;
@@ -20,14 +20,7 @@ async function completeLogin(
console.log(`Completed login request for: ${flags.email}`);
} catch (error) {
if (error instanceof ORPCError) {
console.error(`Error [${String(error.code)}]:`, error.message);
} else {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
}
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -1,17 +1,23 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js";
import { generateToken, hashToken } from "../../utils/token.js";
import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
interface LoginFlags {
email: string;
token: string;
"api-url"?: string;
}
interface LoginStatusOutput {
status: "pending" | "completed" | "expired";
}
/**
* Login to RevIQ with an API token
*
* To get an API token:
* 1. Log in to the web dashboard
* 2. Go to Account Settings > API Tokens
* 3. Create a new token and copy it
* 4. Run: reviq auth login --token <your-token>
*/
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +29,28 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return;
}
console.log("Starting login flow...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
console.log("Validating API token...\n");
try {
// Create login request
const createResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
// Create a temporary API client with the provided token
const api = createApiClient(apiUrl, flags.token);
if (!createResponse.ok) {
const text = await createResponse.text();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Validate the token by fetching the user's auth status
const authStatus = await api.me.authStatus();
// Construct the login URL
const loginUrl = new URL(`${apiUrl}/login`);
loginUrl.searchParams.set("email", flags.email);
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
console.log("Opening browser for authentication...");
console.log(`\nIf the browser doesn't open, visit:`);
console.log(` ${loginUrl.toString()}\n`);
// Try to open the browser
const openCommand =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
try {
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
stdout: "ignore",
stderr: "ignore",
// Save credentials
await writeConfig({
apiUrl,
token: flags.token,
email: authStatus.user.email,
});
await proc.exited;
} catch {
// Ignore errors opening browser - user can use the URL
}
console.log("Waiting for login to complete...");
console.log("(Press Ctrl+C to cancel)\n");
// Poll for completion
const maxAttempts = 120; // 2 minutes at 1 second intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
try {
const statusResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CLI-Callback-Token": callbackToken,
},
},
);
if (statusResponse.ok) {
const status = (await statusResponse.json()) as LoginStatusOutput;
if (status.status === "completed") {
// Login completed - we should have received a token
// For now, we'll need the API to return the token
console.log("Login completed successfully!");
// TODO: The API needs to return the session token when login completes
// For now, this is a placeholder
console.log(
"\nNote: Browser-based login flow requires API integration.",
);
console.log("Use 'reviq bootstrap' to create initial credentials.");
return;
}
if (status.status === "expired") {
console.error("Login request expired. Please try again.");
this.process.exit(1);
}
}
} catch {
// Ignore polling errors and continue
}
// Show progress indicator
process.stdout.write(".");
}
console.log("\n\nLogin timed out. Please try again.");
this.process.exit(1);
console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Login failed:", formatError(error));
console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1);
}
}
@@ -142,10 +59,10 @@ export const loginCommand = buildCommand({
func: login,
parameters: {
flags: {
email: {
token: {
kind: "parsed",
parse: String,
brief: "Email address to login with",
brief: "API token from the web dashboard",
},
"api-url": {
kind: "parsed",
@@ -156,8 +73,13 @@ export const loginCommand = buildCommand({
},
},
docs: {
brief: "Login to RevIQ",
fullDescription:
"Opens a browser to complete authentication and stores the credentials locally.",
brief: "Login to RevIQ with an API token",
fullDescription: `Authenticates with RevIQ using an API token.
To get an API token:
1. Log in to the web dashboard at http://localhost:9861
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
4. Run: reviq auth login --token <your-token>`,
},
});

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { getConfigPath, readConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
import { TOKEN_PREFIX } from "../../utils/token.js";
function formatDate(date: Date): string {
@@ -14,19 +15,21 @@ function formatRelativeTime(date: Date): string {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `${String(Math.abs(diffDays))} days ago`;
return `${Math.abs(diffDays).toLocaleString()} days ago`;
}
if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours <= 0) {
return "expired";
}
return `in ${String(diffHours)} hours`;
return diffHours <= 0
? "expired"
: `in ${diffHours.toLocaleString()} hours`;
}
if (diffDays === 1) {
return "tomorrow";
}
return `in ${String(diffDays)} days`;
return `in ${diffDays.toLocaleString()} days`;
}
async function status(this: LocalContext): Promise<void> {
@@ -96,9 +99,7 @@ async function status(this: LocalContext): Promise<void> {
);
}
} catch (error) {
console.log(
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
console.log(` Error: ${formatError(error)}`);
console.log(
"\n Unable to connect to API. Local credentials may be invalid.",
);

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../context.js";
import { createDb, executeBootstrap } from "@reviq/db";
import { buildCommand } from "@stricli/core";
import { writeConfig } from "../utils/config.js";
import { formatError } from "../utils/format-error.js";
interface BootstrapFlags {
email: string;
@@ -47,10 +48,7 @@ async function bootstrap(
await db.destroy();
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
await db.destroy();
this.process.exit(1);
}

View File

@@ -1,9 +1,8 @@
import type { LocalContext } from "../context.js";
import { buildCommand } from "@stricli/core";
type Shell = "bash" | "zsh" | "fish";
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
type Shell = (typeof SUPPORTED_SHELLS)[number];
function parseShell(value: string): Shell {
const shell = value.toLowerCase();
@@ -45,7 +44,6 @@ function completions(
_flags: Record<string, never>,
shell: Shell,
): void {
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
switch (shell) {
case "bash":
console.log("To enable bash completions for reviq, run:\n");

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface AddSiteFlags {
org: string;
@@ -18,10 +19,7 @@ async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
console.log(`Added site ${flags.domain} to org ${flags.org}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface CreateOrgFlags {
slug: string;
@@ -24,10 +25,7 @@ async function create(
console.log(`Created org: ${result.slug}`);
console.log(`Owner: ${flags.owner}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
async function list(this: LocalContext): Promise<void> {
try {
@@ -23,12 +24,9 @@ async function list(this: LocalContext): Promise<void> {
console.log();
}
console.log(`Total: ${String(orgs.length)} organization(s)`);
console.log(`Total: ${orgs.length.toLocaleString()} organization(s)`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface ConfirmEmailFlags {
email: string;
@@ -19,10 +20,7 @@ async function confirmEmail(
console.log(`Confirmed email for: ${flags.email}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -1,21 +1,24 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
type OrgRole = "owner" | "admin" | "member";
const validRoles: OrgRole[] = ["owner", "admin", "member"];
const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
function parseRole(role: string | undefined): OrgRole | undefined {
if (!role) {
return undefined;
}
if (validRoles.includes(role as OrgRole)) {
return role as OrgRole;
}
if (!VALID_ROLES.includes(role as OrgRole)) {
throw new Error(
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
);
}
return role as OrgRole;
}
interface CreateUserFlags {
@@ -45,10 +48,7 @@ async function create(
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
}
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

@@ -10,25 +10,40 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>;
function buildClient(apiUrl: string, token: string): ApiClient {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: { "X-API-Key": token },
});
return createORPCClient(link) as unknown as ApiClient;
}
/**
* Create an oRPC API client with provided credentials
*/
export function createApiClient(apiUrl: string, token: string): ApiClient;
/**
* Create an oRPC API client with the stored credentials
* Throws an error if not logged in
*/
export const createApiClient = async (): Promise<ApiClient> => {
export function createApiClient(): Promise<ApiClient>;
export function createApiClient(
apiUrl?: string,
token?: string,
): ApiClient | Promise<ApiClient> {
if (apiUrl !== undefined && token !== undefined) {
return buildClient(apiUrl, token);
}
return (async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
// Cast to ApiClient for type-safe API calls
return createORPCClient(link) as unknown as ApiClient;
};
return buildClient(config.apiUrl, config.token);
})();
}

View File

@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
/**
* Get the path to the config file
*/
export const getConfigPath = (): string => CONFIG_FILE;
export function getConfigPath(): string {
return CONFIG_FILE;
}
/**
* Read the config file
* Returns null if the file doesn't exist or is invalid
*/
export const readConfig = async (): Promise<Config | null> => {
export async function readConfig(): Promise<Config | null> {
try {
const data = await readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data) as Config;
} catch {
return null;
}
};
}
/**
* Write the config file
* Creates the config directory if it doesn't exist
*/
export const writeConfig = async (config: Config): Promise<void> => {
export async function writeConfig(config: Config): Promise<void> {
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
mode: 0o600,
});
};
}
/**
* Delete the config file
* Ignores errors if the file doesn't exist
*/
export const deleteConfig = async (): Promise<void> => {
export async function deleteConfig(): Promise<void> {
try {
await unlink(CONFIG_FILE);
} catch {
// Ignore if doesn't exist
}
};
}

View File

@@ -0,0 +1,20 @@
import { ORPCError } from "@orpc/client";
/**
* Format an unknown error value into a string message.
* Handles ORPCError, Error instances, strings, and other types safely.
*/
export function formatError(error: unknown): string {
if (error instanceof ORPCError) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
return `[${error.code}] ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- intentional unknown coercion
return `${error}`;
}

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false,
"composite": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"]
}

View File

@@ -1,10 +1,24 @@
import { configs } from "@macalinao/eslint-config";
import tsParser from "@typescript-eslint/parser";
import svelte from "eslint-plugin-svelte";
import svelteParser from "svelte-eslint-parser";
export default [
{
ignores: [".svelte-kit/**", "build/**"],
},
...configs.fast,
...svelte.configs["flat/recommended"],
{
files: ["**/*.svelte", "**/*.svelte.ts"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
languageOptions: {
parserOptions: {

View File

@@ -15,6 +15,8 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
@@ -36,12 +38,16 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "catalog:",
"eslint-plugin-svelte": "^3.14.0",
"svelte": "^5.28.2",
"svelte-check": "^4.2.1",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
/* Geist Sans - Modern, clean typeface */
@font-face {

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import ClockIcon from "@lucide/svelte/icons/clock";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js";
interface Props {
@@ -12,13 +15,33 @@ interface Props {
let { class: className }: Props = $props();
const navItems = [
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const baseNavItems = [
{ href: "/account", label: "Profile", icon: UserIcon },
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
];
// Add API Tokens link for superusers only
const navItems = $derived(
userQuery.data?.isSuperuser
? [
...baseNavItems,
{
href: "/account/api-tokens",
label: "API Tokens",
icon: KeyRoundIcon,
},
]
: baseNavItems,
);
function isActive(href: string, pathname: string): boolean {
if (href === "/account") {
return pathname === "/account";
@@ -33,10 +56,10 @@ function isActive(href: string, pathname: string): boolean {
className
)}
>
{#each navItems as item}
{#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active

View File

@@ -3,6 +3,7 @@ import { AlertTriangle } from "@lucide/svelte";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
@@ -51,7 +52,7 @@ async function handleDelete(e: Event) {
open = false;
// Redirect to login
goto("/auth/login");
goto(resolve("/auth/login"));
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete account";
isDeleting = false;

View File

@@ -1,7 +1,6 @@
export { default as AccountNav } from "./account-nav.svelte";
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
export { default as PasskeyList } from "./passkey-list.svelte";
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import ConfirmDialog from "./confirm-dialog.svelte";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
interface Passkey {
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) {
selectedPasskey = passkey;
renameDialogOpen = true;

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { gotoLogin } from "$lib/utils/navigation";
interface Props {
children: Snippet;
@@ -11,25 +11,29 @@ interface Props {
let { children }: Props = $props();
// Check if current path is an auth page (doesn't require login)
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
// Check if current path is a public page (doesn't require login)
const isPublicPage = $derived(
page.url.pathname.startsWith("/auth") ||
page.url.pathname === "/terms" ||
page.url.pathname === "/privacy",
);
// Fetch user to check if logged in (only for non-auth pages)
// Fetch user to check if logged in (only for protected pages)
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
enabled: !isAuthPage,
enabled: !isPublicPage,
retry: false,
}));
// Redirect to login if not authenticated on non-auth pages
// Redirect to login if not authenticated on protected pages
$effect(() => {
if (!isAuthPage && userQuery.error) {
goto(`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}`);
if (!isPublicPage && userQuery.error) {
gotoLogin(page.url.pathname);
}
});
</script>
{#if isAuthPage || userQuery.data || userQuery.isPending}
{#if isPublicPage || userQuery.data || userQuery.isPending}
{@render children()}
{/if}

View File

@@ -32,7 +32,7 @@ const config = $derived(strengthConfig[score]);
<div class="space-y-2">
<!-- Strength bars -->
<div class="flex gap-1">
{#each Array(4) as _, i}
{#each Array(4) as _, i (i)}
<div
class="h-1 flex-1 rounded-full transition-colors {i < score
? config.color
@@ -52,7 +52,7 @@ const config = $derived(strengthConfig[score]);
{#if result.feedback.warning}
<p class="text-destructive">{result.feedback.warning}</p>
{/if}
{#each result.feedback.suggestions as suggestion}
{#each result.feedback.suggestions as suggestion, i (i)}
<p>{suggestion}</p>
{/each}
</div>

Some files were not shown because too many files have changed in this diff Show More