Compare commits

...

54 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
224 changed files with 14496 additions and 6334 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"; import { z } from "zod";
: fixed: | : fixed: |
import * as z from "zod" import * as z from "zod";
labels: labels:
- source: import { z } from "zod"; - source: import { z } from "zod";
style: primary style: primary
@@ -12,7 +12,7 @@ snapshots:
? | ? |
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
: fixed: | : fixed: |
import * as z from "zod" import * as z from "zod";
labels: labels:
- source: import { z, ZodError } from "zod"; - source: import { z, ZodError } from "zod";
style: primary 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,5 +1,20 @@
# Claude Code Notes # 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 ## Development Server
Before starting the dev server, check if it's already running: Before starting the dev server, check if it's already running:
@@ -7,10 +22,64 @@ Before starting the dev server, check if it's already running:
- The dev server runs on port 6827 (may fall back to 6828 if port is in use) - 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` - Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
## macOS sed Syntax ## Pull Requests
macOS uses BSD sed which differs from GNU sed: This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file` - Use the `/gitea` skill when creating PRs
- GNU sed (Linux): `sed -i 's/old/new/g' file` - tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file` - Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
## 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);
```

View File

@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
### Shared Packages ### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC) - `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/common` - Shared utilities for frontend and backend
- `@reviq/db` - Database client and queries - `@reviq/db` - Database client and queries
- `@reviq/db-schema` - Database schema and codegen - `@reviq/db-schema` - Database schema and codegen
- `@reviq/utils` - Shared utilities - `@reviq/frontend-utils` - Frontend-specific utilities
- `@reviq/server-utils` - Server/CLI utilities
## Project Structure ## Project Structure
@@ -40,10 +42,12 @@ publisher-dashboard/
│ └── publisher-dashboard/ # SvelteKit frontend │ └── publisher-dashboard/ # SvelteKit frontend
├── packages/ ├── packages/
│ ├── api-contract/ # Shared oRPC contract │ ├── api-contract/ # Shared oRPC contract
│ ├── common/ # Shared utilities (frontend + backend)
│ ├── db/ # Database client │ ├── db/ # Database client
│ ├── db-schema/ # DB schema & codegen │ ├── db-schema/ # DB schema & codegen
│ ├── testing/ # Test utilities │ ├── frontend-utils/ # Frontend utilities
── utils/ # Shared utilities ── server-utils/ # Server/CLI utilities
│ └── testing/ # Test utilities
└── db/ # Database migrations └── db/ # Database migrations
``` ```
@@ -51,7 +55,7 @@ publisher-dashboard/
### Prerequisites ### Prerequisites
- [Bun](https://bun.sh/) v1.1.42+ - [Bun](https://bun.sh/) v1.3.5+
- [devenv](https://devenv.sh/) for development environment management - [devenv](https://devenv.sh/) for development environment management
### Environment Variables ### Environment Variables
@@ -109,8 +113,13 @@ bun run dev
| `bun run typecheck` | Run TypeScript type checking | | `bun run typecheck` | Run TypeScript type checking |
| `bun run lint` | Run Biome and ESLint | | `bun run lint` | Run Biome and ESLint |
| `bun run lint:fix` | Fix linting issues | | `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run tests | | `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 | | `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 ## CLI

View File

@@ -9,19 +9,17 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test": "bun test src/ --no-parallel"
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2", "@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2", "@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*", "@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*", "@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0", "@simplewebauthn/types": "^12.0.0",
@@ -34,12 +32,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:" "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 // Default to localhost origins for development
return [ return [
`http://localhost:${String(DEFAULT_PORT)}`, `http://localhost:${DEFAULT_PORT.toString()}`,
"http://localhost:6827", "http://localhost:6827",
"http://localhost:6828", "http://localhost:6828",
]; ];
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
/** Base URL for generating email links */ /** Base URL for generating email links */
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827"; export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
/** Dev mode: log emails instead of sending (default: true) */ /** Postmark API key (optional - uses logging client if not set) */
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY; export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
// ===== Token Expiration Times ===== // ===== Token Expiration Times =====

View File

@@ -3,8 +3,18 @@
*/ */
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { EmailClient } from "@reviq/emails";
import type { Kysely } from "kysely"; 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 * Base API context available to all handlers
*/ */
@@ -23,6 +33,8 @@ export interface APIContext {
resHeaders: Headers; resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */ /** Client IP address from direct connection (fallback when no proxy headers) */
clientIP?: string | null; clientIP?: string | null;
/** Email client and configuration */
email: EmailConfig;
} }
/** /**
@@ -103,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
/** User with superuser privileges */ /** User with superuser privileges */
user: SessionUser & { isSuperuser: true }; 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 { LoggingHandlerPlugin } from "@orpc/experimental-pino";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db"; import { createDb } from "@reviq/db";
import { createLoggingEmailClient, createPostmarkClient } from "@reviq/emails";
import pino from "pino"; import pino from "pino";
import { import {
BASE_URL,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_RP_NAME, DEFAULT_RP_NAME,
EMAIL_FROM,
getAllowedOrigins, getAllowedOrigins,
POSTMARK_API_KEY,
} from "./constants.js"; } from "./constants.js";
import { router } from "./router.js"; import { router } from "./router.js";
@@ -24,6 +28,16 @@ if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required"); throw new Error("DATABASE_URL environment variable is required");
} }
const db = createDb(databaseUrl); 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, { const handler = new RPCHandler(router, {
plugins: [ plugins: [
new LoggingHandlerPlugin({ new LoggingHandlerPlugin({
@@ -45,7 +59,7 @@ Bun.serve({
if (url.pathname.startsWith("/api/v1/rpc")) { if (url.pathname.startsWith("/api/v1/rpc")) {
// Build context for the request // Build context for the request
const origin = const origin =
request.headers.get("origin") ?? `http://localhost:${String(port)}`; request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
// Create response headers for setting cookies // Create response headers for setting cookies
const resHeaders = new Headers(); const resHeaders = new Headers();
@@ -62,6 +76,11 @@ Bun.serve({
reqHeaders: request.headers, reqHeaders: request.headers,
resHeaders, resHeaders,
clientIP, clientIP,
email: {
client: emailClient,
fromAddress: EMAIL_FROM,
baseUrl: BASE_URL,
},
}; };
const { response } = await handler.handle(request, { 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,49 +3,49 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js"; import { superuserProcedure } from "../../base.js";
export const adminAuthCompleteLogin = os.admin.auth.completeLogin export const adminAuthCompleteLogin =
.use(authMiddleware) superuserProcedure.admin.auth.completeLogin.handler(
.use(superuserMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => { const email = input.email.toLowerCase();
const email = input.email.toLowerCase();
// First check if any login request exists for this email // First check if any login request exists for this email
const anyRequest = await context.db const anyRequest = await context.db
.selectFrom("login_requests") .selectFrom("login_requests")
.where("email", "=", email) .where("email", "=", email)
.orderBy("created_at", "desc") .orderBy("created_at", "desc")
.select(["id", "completed_at", "expires_at"]) .select(["id", "completed_at", "expires_at"])
.executeTakeFirst(); .executeTakeFirst();
if (!anyRequest) { if (!anyRequest) {
throw new ORPCError("NOT_FOUND", { throw new ORPCError("NOT_FOUND", {
message: `No login request found for ${email}`, message: `No login request found for ${email}`,
}); });
} }
// Check if already completed // Check if already completed
if (anyRequest.completed_at) { if (anyRequest.completed_at) {
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Login request already completed", message: "Login request already completed",
}); });
} }
// Check if expired // Check if expired
if (new Date(anyRequest.expires_at) < new Date()) { if (new Date(anyRequest.expires_at) < new Date()) {
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: message:
"Login request expired (15 min limit). Start a new login flow.", "Login request expired (15 min limit). Start a new login flow.",
}); });
} }
// Complete the login request // Complete the login request
await context.db await context.db
.updateTable("login_requests") .updateTable("login_requests")
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", anyRequest.id) .where("id", "=", anyRequest.id)
.execute(); .execute();
return { success: true }; return { success: true };
}); },
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,37 +4,35 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js"; import { superuserProcedure } from "../../base.js";
import { toSiteResponse } from "../helpers.js"; import { toSiteResponse } from "../helpers.js";
export const adminOrgsListSites = os.admin.orgs.listSites export const adminOrgsListSites =
.use(authMiddleware) superuserProcedure.admin.orgs.listSites.handler(
.use(superuserMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => { const { slug } = input;
const { slug } = input;
const org = await context.db const org = await context.db
.selectFrom("orgs") .selectFrom("orgs")
.where("slug", "=", slug) .where("slug", "=", slug)
.select(["id"]) .select(["id"])
.executeTakeFirst(); .executeTakeFirst();
if (!org) { if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
} }
const sites = await context.db const sites = await context.db
.selectFrom("org_sites") .selectFrom("org_sites")
.where("org_id", "=", org.id) .where("org_id", "=", org.id)
.selectAll() .selectAll()
.execute(); .execute();
return sites.map(toSiteResponse); return sites.map(toSiteResponse);
}); },
);
export const adminOrgsAddSite = os.admin.orgs.addSite export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
.use(authMiddleware) async ({ input, context }) => {
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug, domain } = input; const { slug, domain } = input;
// Use transaction to prevent race condition on site creation // Use transaction to prevent race condition on site creation
@@ -70,32 +68,33 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
}); });
return { success: true }; return { success: true };
}); },
);
export const adminOrgsRemoveSite = os.admin.orgs.removeSite export const adminOrgsRemoveSite =
.use(authMiddleware) superuserProcedure.admin.orgs.removeSite.handler(
.use(superuserMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => { const { slug, domain } = input;
const { slug, domain } = input;
const org = await context.db const org = await context.db
.selectFrom("orgs") .selectFrom("orgs")
.where("slug", "=", slug) .where("slug", "=", slug)
.select(["id"]) .select(["id"])
.executeTakeFirst(); .executeTakeFirst();
if (!org) { if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
} }
const result = await context.db const result = await context.db
.deleteFrom("org_sites") .deleteFrom("org_sites")
.where("org_id", "=", org.id) .where("org_id", "=", org.id)
.where("domain", "=", domain) .where("domain", "=", domain)
.executeTakeFirst(); .executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) { if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Site not found" }); throw new ORPCError("NOT_FOUND", { message: "Site not found" });
} }
return { success: true }; return { success: true };
}); },
);

View File

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

View File

@@ -3,24 +3,24 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js"; import { superuserProcedure } from "../../base.js";
export const adminUsersConfirmEmail = os.admin.users.confirmEmail export const adminUsersConfirmEmail =
.use(authMiddleware) superuserProcedure.admin.users.confirmEmail.handler(
.use(superuserMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => { const result = await context.db
const result = await context.db .updateTable("users")
.updateTable("users") .set({
.set({ email_verified_at: new Date(),
email_verified_at: new Date(), updated_at: new Date(),
updated_at: new Date(), })
}) .where("email", "=", input.email.toLowerCase())
.where("email", "=", input.email.toLowerCase()) .executeTakeFirst();
.executeTakeFirst();
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "User not found" }); throw new ORPCError("NOT_FOUND", { message: "User not found" });
} }
return { success: true }; return { success: true };
}); },
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import type {
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js"; import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { withTransaction } from "@reviq/db";
import { sendVerificationEmail } from "@reviq/emails";
import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
@@ -21,7 +23,6 @@ import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { hashPassword, validatePassword } from "../../utils/password.js"; import { hashPassword, validatePassword } from "../../utils/password.js";
import { createSession } from "../../utils/session.js"; import { createSession } from "../../utils/session.js";
@@ -52,17 +53,28 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
const user = await db try {
.insertInto("users") const user = await db
.values({ .insertInto("users")
email, .values({
password_hash: passwordHash, email,
}) password_hash: passwordHash,
.returning(["id"]) })
.executeTakeFirstOrThrow(); .returning(["id"])
.executeTakeFirstOrThrow();
return user.id; 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 const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("options") .select("options")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.where("created_at", ">", fifteenMinutesAgo) .where("created_at", ">", fifteenMinutesAgo)
.executeTakeFirst(); .executeTakeFirst();
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Log error for debugging but don't expose to client // Log error for debugging but don't expose to client
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
@@ -146,55 +158,66 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
const result = await db.transaction().execute(async (trx) => { try {
// Create user const result = await withTransaction(db, async (trx) => {
const user = await trx // Create user
.insertInto("users") const user = await trx
.values({ .insertInto("users")
email, .values({
password_hash: null, email,
}) password_hash: null,
.returning(["id"]) })
.executeTakeFirstOrThrow(); .returning(["id"])
.executeTakeFirstOrThrow();
const newUserId = user.id; const newUserId = user.id;
// Get friendly name from AAGUID // Get friendly name from AAGUID
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const passkeyName = guidName ?? "Default"; const passkeyName = guidName ?? "Default";
// Store the passkey // Store the passkey
const { credential, credentialDeviceType, credentialBackedUp } = const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo; registrationInfo;
await trx await trx
.insertInto("passkeys") .insertInto("passkeys")
.values({ .values({
user_id: newUserId, user_id: newUserId,
credential_id: Buffer.from(credential.id, "base64url"), credential_id: Buffer.from(credential.id, "base64url"),
public_key: Buffer.from(credential.publicKey), public_key: Buffer.from(credential.publicKey),
webauthn_user_id: options.user.id, webauthn_user_id: options.user.id,
counter: BigInt(credential.counter), counter: BigInt(credential.counter),
device_type: credentialDeviceType as "singleDevice" | "multiDevice", device_type: credentialDeviceType as "singleDevice" | "multiDevice",
backup_eligible: registrationInfo.credentialBackedUp, backup_eligible: registrationInfo.credentialBackedUp,
backup_status: credentialBackedUp, backup_status: credentialBackedUp,
transports: JSON.stringify(response.response.transports ?? []), transports: JSON.stringify(response.response.transports ?? []),
rpid: rpInfo.rpID, rpid: rpInfo.rpID,
name: passkeyName, name: passkeyName,
}) })
.execute(); .execute();
// Delete the challenge // Delete the challenge
await trx await trx
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
return { userId: newUserId }; return { userId: newUserId };
}); });
return result.userId; 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;
}
} }
/** /**
@@ -241,19 +264,40 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });
} }
// Create session (7 days, trusted mode false initially, no device) // Generate verification token
const session = await createSession(context.db, { const verificationToken = generateSecureBase58Token();
userId, const verificationExpiresAt = generateExpiry(
deviceId: null, TOKEN_DURATIONS.EMAIL_VERIFICATION,
trustedMode: false, );
geo,
userAgent, // 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 newSession = await createSession(trx, {
userId,
deviceId: null,
trustedMode: false,
geo,
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 // Set session cookie
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
COOKIE_OPTIONS.session, COOKIE_OPTIONS.session,
); );
// Generate verification token // Send verification email
const verificationToken = generateSecureBase58Token(); await sendVerificationEmail({
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); client: context.email.client,
fromAddress: context.email.fromAddress,
// Store verification token (store raw token, not hash - it's already high-entropy) baseUrl: context.email.baseUrl,
await context.db email,
.insertInto("email_verifications") token: verificationToken,
.values({ expiryHours: 24,
user_id: userId, });
token: verificationToken,
expires_at: expiresAt,
})
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken);
return { success: true }; return { success: true };
}); });

View File

@@ -8,227 +8,22 @@
import type { import type {
APIContext, APIContext,
AuthenticatedContext, AuthenticatedContext,
AuthInfo,
LoginRequestContext, LoginRequestContext,
Session,
SessionUser,
} from "../context.js"; } from "../context.js";
import { implement, ORPCError } from "@orpc/server"; import {
import { contract } from "@reviq/api-contract"; authMiddleware,
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; loginRequestMiddleware,
import { hashToken } from "../utils/crypto.js"; os,
superuserMiddleware,
} from "../middlewares/index.js";
/** // Re-export middlewares and os
* Base implementer with typed APIContext export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
* All procedures should be derived from this
*/
export const os = implement(contract).$context<APIContext>();
/** // Pre-configured procedures with middleware applied
* Auth middleware - validates session/API token and adds user to context export const authedProcedure = os.use(authMiddleware);
* Use with os.use(authMiddleware) to create authenticated procedures export const superuserProcedure = os.use(superuserMiddleware);
*/ export const loginRequestProcedure = os.use(loginRequestMiddleware);
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();
});
// Type exports for use in procedure files // Type exports for use in procedure files
export type { APIContext, AuthenticatedContext, LoginRequestContext }; 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

@@ -9,7 +9,7 @@ import {
hashToken, hashToken,
TOKEN_PREFIX, TOKEN_PREFIX,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { authMiddleware, os } from "../base.js"; import { meRoute } from "./_base.js";
/** Token expiration: 365 days */ /** Token expiration: 365 days */
const TOKEN_EXPIRATION_DAYS = 365; const TOKEN_EXPIRATION_DAYS = 365;
@@ -18,9 +18,8 @@ const TOKEN_EXPIRATION_DAYS = 365;
* List all API tokens for the current user * List all API tokens for the current user
* Returns token metadata (not the actual token values) * Returns token metadata (not the actual token values)
*/ */
export const listApiTokens = os.me.apiTokens.list export const listApiTokens = meRoute.apiTokens.list.handler(
.use(authMiddleware) async ({ context }) => {
.handler(async ({ context }) => {
const tokens = await context.db const tokens = await context.db
.selectFrom("api_tokens") .selectFrom("api_tokens")
.select(["id", "name", "last_used_at", "created_at", "expires_at"]) .select(["id", "name", "last_used_at", "created_at", "expires_at"])
@@ -35,15 +34,15 @@ export const listApiTokens = os.me.apiTokens.list
createdAt: token.created_at.toISOString(), createdAt: token.created_at.toISOString(),
expiresAt: token.expires_at.toISOString(), expiresAt: token.expires_at.toISOString(),
})); }));
}); },
);
/** /**
* Create a new API token * Create a new API token
* Requires superuser status and trusted session * Requires superuser status and trusted session
*/ */
export const createApiToken = os.me.apiTokens.create export const createApiToken = meRoute.apiTokens.create.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
// Require superuser status // Require superuser status
if (!context.user.isSuperuser) { if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", { throw new ORPCError("FORBIDDEN", {
@@ -85,17 +84,17 @@ export const createApiToken = os.me.apiTokens.create
token, token,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
}; };
}); },
);
/** /**
* Delete an API token * Delete an API token
*/ */
export const deleteApiToken = os.me.apiTokens.delete export const deleteApiToken = meRoute.apiTokens.delete.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const result = await context.db const result = await context.db
.deleteFrom("api_tokens") .deleteFrom("api_tokens")
.where("id", "=", String(input.tokenId)) .where("id", "=", input.tokenId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.executeTakeFirst(); .executeTakeFirst();
@@ -106,4 +105,5 @@ export const deleteApiToken = os.me.apiTokens.delete
} }
return { success: true }; return { success: true };
}); },
);

View File

@@ -2,40 +2,38 @@
* Get current user auth status * Get current user auth status
*/ */
import { authMiddleware, os } from "../base.js"; import { meRoute } from "./_base.js";
export const meAuthStatus = os.me.authStatus export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
.use(authMiddleware) const user = await context.db
.handler(async ({ context }) => { .selectFrom("users")
const user = await context.db .select([
.selectFrom("users") "id",
.select([ "email",
"id", "display_name",
"email", "full_name",
"display_name", "phone_number",
"full_name", "avatar_url",
"phone_number", "email_verified_at",
"avatar_url", "is_superuser",
"email_verified_at", "password_hash",
"is_superuser", ])
"password_hash", .where("id", "=", context.user.id)
]) .executeTakeFirstOrThrow();
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return { return {
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.display_name, displayName: user.display_name,
fullName: user.full_name, fullName: user.full_name,
phoneNumber: user.phone_number, phoneNumber: user.phone_number,
avatarUrl: user.avatar_url, avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null, emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null, needsSetup: user.display_name === null,
isSuperuser: user.is_superuser, isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null, hasPassword: user.password_hash !== null,
}, },
auth: context.auth, auth: context.auth,
}; };
}); });

View File

@@ -5,7 +5,7 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js"; import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
import { verifyPassword } from "../../utils/password.js"; import { verifyPassword } from "../../utils/password.js";
import { authMiddleware, os } from "../base.js"; import { meRoute } from "./_base.js";
/** /**
* Delete account handler * Delete account handler
@@ -14,39 +14,37 @@ import { authMiddleware, os } from "../base.js";
* - Deletes user record (cascades to sessions, devices, passkeys, etc.) * - Deletes user record (cascades to sessions, devices, passkeys, etc.)
* - Clears session cookie * - Clears session cookie
*/ */
export const meDelete = os.me.delete export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
.use(authMiddleware) const { password } = input;
.handler(async ({ input, context }) => {
const { password } = input;
// Fetch user with password hash // Fetch user with password hash
const user = await context.db const user = await context.db
.selectFrom("users") .selectFrom("users")
.select(["password_hash"]) .select(["password_hash"])
.where("id", "=", context.user.id) .where("id", "=", context.user.id)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
// Verify password (required for account deletion) // Verify password (required for account deletion)
if (!user.password_hash) { if (!user.password_hash) {
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: message:
"Cannot delete account without a password. Please set a password first.", "Cannot delete account without a password. Please set a password first.",
}); });
} }
const valid = await verifyPassword(password, user.password_hash); const valid = await verifyPassword(password, user.password_hash);
if (!valid) { if (!valid) {
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" }); throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
} }
// Delete user (cascades to sessions, devices, passkeys, etc.) // Delete user (cascades to sessions, devices, passkeys, etc.)
await context.db await context.db
.deleteFrom("users") .deleteFrom("users")
.where("id", "=", context.user.id) .where("id", "=", context.user.id)
.execute(); .execute();
// Clear session cookie // Clear session cookie
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
return { success: true }; return { success: true };
}); });

View File

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

View File

@@ -2,37 +2,35 @@
* Get current user profile * Get current user profile
*/ */
import { authMiddleware, os } from "../base.js"; import { meRoute } from "./_base.js";
export const meGet = os.me.get export const meGet = meRoute.get.handler(async ({ context }) => {
.use(authMiddleware) const user = await context.db
.handler(async ({ context }) => { .selectFrom("users")
const user = await context.db .select([
.selectFrom("users") "id",
.select([ "email",
"id", "display_name",
"email", "full_name",
"display_name", "phone_number",
"full_name", "avatar_url",
"phone_number", "email_verified_at",
"avatar_url", "is_superuser",
"email_verified_at", "password_hash",
"is_superuser", ])
"password_hash", .where("id", "=", context.user.id)
]) .executeTakeFirstOrThrow();
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.display_name, displayName: user.display_name,
fullName: user.full_name, fullName: user.full_name,
phoneNumber: user.phone_number, phoneNumber: user.phone_number,
avatarUrl: user.avatar_url, avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null, emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null, needsSetup: user.display_name === null,
isSuperuser: user.is_superuser, isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null, hasPassword: user.password_hash !== null,
}; };
}); });

View File

@@ -3,64 +3,61 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js"; import { meRoute } from "./_base.js";
/** /**
* List pending invites for the current user * List pending invites for the current user
* Only returns invites where the user's email matches and email is verified * Only returns invites where the user's email matches and email is verified
*/ */
export const listInvites = os.me.invites.list export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
.use(authMiddleware) // Only show invites if email is verified
.handler(async ({ context }) => { if (!context.user.emailVerifiedAt) {
// Only show invites if email is verified return [];
if (!context.user.emailVerifiedAt) { }
return [];
}
// Get non-expired invites matching user's email // Get non-expired invites matching user's email
const invites = await context.db const invites = await context.db
.selectFrom("org_invites") .selectFrom("org_invites")
.innerJoin("orgs", "orgs.id", "org_invites.org_id") .innerJoin("orgs", "orgs.id", "org_invites.org_id")
.innerJoin("users", "users.id", "org_invites.invited_by") .innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.email", "=", context.user.email.toLowerCase()) .where("org_invites.email", "=", context.user.email.toLowerCase())
.where("org_invites.expires_at", ">", new Date()) .where("org_invites.expires_at", ">", new Date())
.select([ .select([
"org_invites.id", "org_invites.id",
"org_invites.role", "org_invites.role",
"org_invites.created_at", "org_invites.created_at",
"org_invites.expires_at", "org_invites.expires_at",
"orgs.id as org_id", "orgs.id as org_id",
"orgs.slug as org_slug", "orgs.slug as org_slug",
"orgs.display_name as org_display_name", "orgs.display_name as org_display_name",
"orgs.logo_url as org_logo_url", "orgs.logo_url as org_logo_url",
"users.display_name as inviter_name", "users.display_name as inviter_name",
"users.email as inviter_email", "users.email as inviter_email",
]) ])
.orderBy("org_invites.created_at", "desc") .orderBy("org_invites.created_at", "desc")
.execute(); .execute();
return invites.map((i) => ({ return invites.map((i) => ({
id: i.id, id: i.id,
org: { org: {
id: i.org_id, id: i.org_id,
slug: i.org_slug, slug: i.org_slug,
displayName: i.org_display_name, displayName: i.org_display_name,
logoUrl: i.org_logo_url, logoUrl: i.org_logo_url,
}, },
role: i.role, role: i.role,
invitedBy: i.inviter_name ?? i.inviter_email, invitedBy: i.inviter_name ?? i.inviter_email,
createdAt: i.created_at, createdAt: i.created_at,
expiresAt: i.expires_at, expiresAt: i.expires_at,
})); }));
}); });
/** /**
* Get a specific invite by ID * Get a specific invite by ID
* Only returns if the invite belongs to the current user's email * Only returns if the invite belongs to the current user's email
*/ */
export const getInvite = os.me.invites.get export const getInvite = meRoute.invites.get.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { inviteId } = input; const { inviteId } = input;
// Only show invite if email is verified // Only show invite if email is verified
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
createdAt: invite.created_at, createdAt: invite.created_at,
expiresAt: invite.expires_at, expiresAt: invite.expires_at,
}; };
}); },
);
/** /**
* Accept an invite by ID * Accept an invite by ID
* Adds user to org and deletes the invite * Adds user to org and deletes the invite
*/ */
export const acceptInvite = os.me.invites.accept export const acceptInvite = meRoute.invites.accept.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { inviteId } = input; const { inviteId } = input;
// Only allow accepting if email is verified // Only allow accepting if email is verified
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
} }
return { success: true }; return { success: true };
}); },
);
/** /**
* Decline an invite * Decline an invite
* Deletes the invite if it belongs to the current user's email * Deletes the invite if it belongs to the current user's email
*/ */
export const declineInvite = os.me.invites.decline export const declineInvite = meRoute.invites.decline.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { inviteId } = input; const { inviteId } = input;
// Delete the invite only if it matches user's email // Delete the invite only if it matches user's email
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
} }
return { success: true }; return { success: true };
}); },
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,25 +5,11 @@
import type { DB, OrgRole } from "@reviq/db-schema"; import type { DB, OrgRole } from "@reviq/db-schema";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { OrgInfo, OrgMembership } from "../../context.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
// ===== Types ===== // Re-export types for convenience
export type { OrgInfo, OrgMembership };
/** 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;
}
// ===== Role Hierarchy ===== // ===== Role Hierarchy =====
@@ -115,10 +101,11 @@ export async function countOwners(
): Promise<number> { ): Promise<number> {
const result = await db const result = await db
.selectFrom("org_members") .selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count")) .select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId) .where("org_id", "=", orgId)
.where("role", "=", "owner") .where("role", "=", "owner")
.executeTakeFirstOrThrow(); .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 { ORPCError } from "@orpc/server";
import { sendOrgInviteEmail } from "@reviq/emails";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js"; import { authedProcedure } from "../base.js";
import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
/** /**
* List pending invites for an org * List pending invites for an org
* Requires admin or owner role * Requires admin or owner role
*/ */
export const invitesList = os.orgs.invites.list export const invitesList = authedProcedure.orgs.invites.list.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug } = input; const { slug } = input;
// Lookup org and verify admin+ role // Lookup org and verify admin+ role
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
createdAt: i.created_at, createdAt: i.created_at,
expiresAt: i.expires_at, expiresAt: i.expires_at,
})); }));
}); },
);
/** /**
* Create an invite for a new member * Create an invite for a new member
* Requires admin or owner role * Requires admin or owner role
* Only owners can invite new owners (privilege escalation prevention) * Only owners can invite new owners (privilege escalation prevention)
*/ */
export const invitesCreate = os.orgs.invites.create export const invitesCreate = authedProcedure.orgs.invites.create.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug, email: rawEmail, role } = input; const { slug, email: rawEmail, role } = input;
const email = rawEmail.toLowerCase(); const email = rawEmail.toLowerCase();
@@ -122,18 +121,28 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation email // Send invitation email
const inviterName = context.user.displayName ?? context.user.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 }; return { success: true };
}); },
);
/** /**
* Cancel a pending invite * Cancel a pending invite
* Requires admin or owner role * Requires admin or owner role
*/ */
export const invitesCancel = os.orgs.invites.cancel export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug, inviteId } = input; const { slug, inviteId } = input;
// Lookup org and verify admin+ role // Lookup org and verify admin+ role
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
} }
return { success: true }; return { success: true };
}); },
);
/** /**
* Accept an invitation * Accept an invitation
* Token-based lookup, requires auth but no org membership * Token-based lookup, requires auth but no org membership
* Handles race condition if user is already a member * Handles race condition if user is already a member
*/ */
export const invitesAccept = os.orgs.invites.accept export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { token } = input; const { token } = input;
// Find the invite by token (must not be expired) // Find the invite by token (must not be expired)
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
} }
return { success: true }; return { success: true };
}); },
);

View File

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

View File

@@ -3,7 +3,7 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js"; import { authedProcedure } from "../base.js";
import { import {
countOwners, countOwners,
getMembership, getMembership,
@@ -15,9 +15,8 @@ import {
* List all members of an org * List all members of an org
* Any member can view the member list * Any member can view the member list
*/ */
export const membersList = os.orgs.members.list export const membersList = authedProcedure.orgs.members.list.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug } = input; const { slug } = input;
// Lookup org and verify membership // Lookup org and verify membership
@@ -48,65 +47,70 @@ export const membersList = os.orgs.members.list
role: m.role, role: m.role,
createdAt: m.created_at, createdAt: m.created_at,
})); }));
}); },
);
/** /**
* Update a member's role * Update a member's role
* Only owners can change roles * Only owners can change roles
* Uses transaction to prevent race condition when demoting owners * Uses transaction to prevent race condition when demoting owners
*/ */
export const membersUpdateRole = os.orgs.members.updateRole export const membersUpdateRole =
.use(authMiddleware) authedProcedure.orgs.members.updateRole.handler(
.handler(async ({ input, context }) => { async ({ input, context }) => {
const { slug, userId, role: newRole } = input; const { slug, userId, role: newRole } = input;
// Lookup org and verify ownership // Lookup org and verify ownership
const org = await lookupOrgBySlug(context.db, slug); const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id); const membership = await getMembership(
requireRole(membership, "owner"); context.db,
org.id,
context.user.id,
);
requireRole(membership, "owner");
await context.db.transaction().execute(async (trx) => { await context.db.transaction().execute(async (trx) => {
// Get the target member's current membership // Get the target member's current membership
const targetMember = await trx const targetMember = await trx
.selectFrom("org_members") .selectFrom("org_members")
.select(["id", "role"]) .select(["id", "role"])
.where("org_id", "=", org.id) .where("org_id", "=", org.id)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.executeTakeFirst(); .executeTakeFirst();
if (!targetMember) { if (!targetMember) {
throw new ORPCError("NOT_FOUND", { message: "Member not found" }); throw new ORPCError("NOT_FOUND", { message: "Member not found" });
}
// If demoting an owner, check if they're the last one
if (targetMember.role === "owner" && newRole !== "owner") {
const ownerCount = await countOwners(trx, org.id);
if (ownerCount === 1) {
throw new ORPCError("BAD_REQUEST", {
message: "Cannot demote the only owner",
});
} }
}
// Update the role // If demoting an owner, check if they're the last one
await trx if (targetMember.role === "owner" && newRole !== "owner") {
.updateTable("org_members") const ownerCount = await countOwners(trx, org.id);
.set({ role: newRole }) if (ownerCount === 1) {
.where("id", "=", targetMember.id) throw new ORPCError("BAD_REQUEST", {
.execute(); message: "Cannot demote the only owner",
}); });
}
}
return { success: true }; // Update the role
}); await trx
.updateTable("org_members")
.set({ role: newRole })
.where("id", "=", targetMember.id)
.execute();
});
return { success: true };
},
);
/** /**
* Remove a member from an org * Remove a member from an org
* Owners can remove anyone, admins can only remove members * Owners can remove anyone, admins can only remove members
* Uses transaction to prevent race condition when removing owners * Uses transaction to prevent race condition when removing owners
*/ */
export const membersRemove = os.orgs.members.remove export const membersRemove = authedProcedure.orgs.members.remove.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug, userId } = input; const { slug, userId } = input;
// Lookup org and verify membership // Lookup org and verify membership
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
}); });
return { success: true }; return { success: true };
}); },
);

View File

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

View File

@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
await context.db await context.db
.updateTable("login_requests") .updateTable("login_requests")
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", String(context.loginRequestId)) .where("id", "=", context.loginRequestId.toString())
.execute(); .execute();
return { success: true }; 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"; import { base58 } from "@scure/base";
// Re-export for convenience // 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,7 +1,7 @@
import { import {
hashPassword as hashPasswordUtil, hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil, verifyPassword as verifyPasswordUtil,
} from "@reviq/utils"; } from "@reviq/server-utils";
import zxcvbn from "zxcvbn"; import zxcvbn from "zxcvbn";
export interface PasswordValidationResult { export interface PasswordValidationResult {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js"; import { readConfig, writeConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
interface LoginFlags { interface LoginFlags {
token: string; token: string;
@@ -47,10 +48,7 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
console.log(`Logged in as ${authStatus.user.email}`); console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json"); console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) { } catch (error) {
console.error( console.error("Login failed:", formatError(error));
"Login failed:",
error instanceof Error ? error.message : String(error),
);
console.log("\nMake sure your API token is valid."); console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens"); console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1); this.process.exit(1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>; 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 * Create an oRPC API client with provided credentials
*/ */
@@ -25,18 +33,10 @@ export function createApiClient(
apiUrl?: string, apiUrl?: string,
token?: string, token?: string,
): ApiClient | Promise<ApiClient> { ): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) { if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({ return buildClient(apiUrl, token);
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
} }
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => { return (async (): Promise<ApiClient> => {
const config = await readConfig(); const config = await readConfig();
if (!config) { if (!config) {
@@ -44,14 +44,6 @@ export function createApiClient(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
); );
} }
return buildClient(config.apiUrl, config.token);
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})(); })();
} }

View File

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

View File

@@ -15,6 +15,8 @@
"@orpc/client": "^1.13.2", "@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",
@@ -36,6 +38,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4", "@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",

View File

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

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
> >
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)} {@const active = isActive(item.href, $page.url.pathname)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href)} <a href={item.href}
class={cn( 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]", "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 active

View File

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

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte"; import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query"; import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; 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"; import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
interface Passkey { interface Passkey {
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null); let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false); 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) { function openRename(passkey: Passkey) {
selectedPasskey = passkey; selectedPasskey = passkey;
renameDialogOpen = true; renameDialogOpen = true;

View File

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

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50"> <div class="divide-y divide-border/50">
{#each filters as filter (filter.label)} {#each filters as filter (filter.label)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(filter.href)} <a href={filter.href}
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30" class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
> >
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground"> <div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab, ($page.url.searchParams.get("tab") as TabId) || defaultTab,
); );
function handleTabChange(tabId: string) { function handleTabChange(tabId: string): void {
const url = new URL($page.url); const url = new URL($page.url);
if (tabId === defaultTab) { if (tabId === defaultTab) {
url.searchParams.delete("tab"); url.searchParams.delete("tab");
} else { } else {
url.searchParams.set("tab", tabId); url.searchParams.set("tab", tabId);
} }
goto(resolve(url.toString()), { replaceState: true, noScroll: true }); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString(), { replaceState: true, noScroll: true });
} }
</script> </script>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface AdUnitRow extends MetricsRow {
name: string;
}
const tableData: AdUnitRow[] = [
{ {
id: 1, id: 1,
name: "/header/leaderboard-728x90", name: "/header/leaderboard-728x90",
@@ -51,58 +55,10 @@ const tableData = [
impPercent: 9.16, impPercent: 9.16,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface CountryRow extends MetricsRow {
name: string;
code: string;
}
const tableData: CountryRow[] = [
{ {
id: 1, id: 1,
name: "United States", name: "United States",
@@ -57,54 +62,14 @@ const tableData = [
impPercent: 4.68, impPercent: 4.68,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Country">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> {@const countryRow = row as CountryRow}
<Table.Head class="h-10 w-10 pl-5"></Table.Head> <div class="flex items-center gap-2">
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head> <span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head> <span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head> </div>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head> {/snippet}
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head> </MetricsTable>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<div class="flex items-center gap-2">
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.code}</span>
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface DomainRow extends MetricsRow {
name: string;
}
const tableData: DomainRow[] = [
{ {
id: 1, id: 1,
name: "example.com", name: "example.com",
@@ -27,51 +31,10 @@ const tableData = [
impPercent: 18.45, impPercent: 18.45,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Domain">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -2,4 +2,8 @@ export { default as AdUnitTable } from "./ad-unit-table.svelte";
export { default as CountryTable } from "./country-table.svelte"; export { default as CountryTable } from "./country-table.svelte";
export { default as DomainTable } from "./domain-table.svelte"; export { default as DomainTable } from "./domain-table.svelte";
export { default as KeyValueTable } from "./key-value-table.svelte"; export { default as KeyValueTable } from "./key-value-table.svelte";
export {
default as MetricsTable,
type MetricsRow,
} from "./metrics-table.svelte";
export { default as SourceTable } from "./source-table.svelte"; export { default as SourceTable } from "./source-table.svelte";

View File

@@ -1,7 +1,17 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import * as Table from "$lib/components/ui/table";
const tableData = [ interface KeyValueRow {
id: number;
key: string;
value: string;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
const tableData: KeyValueRow[] = [
{ {
id: 1, id: 1,
key: "device", key: "device",

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from "svelte";
import * as Table from "$lib/components/ui/table";
export interface MetricsRow {
id: number;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
interface Props {
data: MetricsRow[];
labelHeader: string;
labelCell: Snippet<[{ row: MetricsRow; index: number }]>;
showSortIcon?: boolean;
}
let { data, labelHeader, labelCell, showSortIcon = false }: Props = $props();
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = $derived(Math.max(...data.map((d) => d.revPercent)));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">{labelHeader}</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
{#if showSortIcon}
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
{@render labelCell({ row, index: i })}
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

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