Compare commits

..

36 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
170 changed files with 4346 additions and 2162 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

@@ -4,5 +4,5 @@ severity: error
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading." 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." note: "Use Number() to convert the result if you need a number type."
rule: rule:
pattern: countAll<number>() pattern: $OBJ.countAll<number>()
fix: countAll() 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

@@ -1,5 +1,12 @@
# 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 ## Database Scripts
Use the wrapper scripts instead of running dbmate directly: Use the wrapper scripts instead of running dbmate directly:
@@ -22,13 +29,13 @@ This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs) - tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work) - Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
## macOS sed Syntax ## sed Syntax (GNU coreutils)
macOS uses BSD sed which differs from GNU sed: This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file` - In-place edit: `sed -i 's/old/new/g' file`
- GNU sed (Linux): `sed -i 's/old/new/g' file` - Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|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`
- 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 ## SvelteKit resolve() Usage

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,7 +113,10 @@ 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-dump` | Dump database schema (strips `\restrict` lines) |
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) | | `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |

View File

@@ -12,14 +12,14 @@
"test": "bun test src/ --no-parallel" "test": "bun test src/ --no-parallel"
}, },
"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",

View File

@@ -29,6 +29,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -36,6 +37,7 @@ import {
initTestDb, initTestDb,
TEST_RP, TEST_RP,
truncateAllTables, truncateAllTables,
uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { router } from "../../router.js"; import { router } from "../../router.js";
@@ -74,6 +76,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -84,7 +91,7 @@ async function createSession(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -115,9 +122,7 @@ async function createOrg(
logoUrl?: string; logoUrl?: string;
}, },
): Promise<{ id: number; slug: string }> { ): Promise<{ id: number; slug: string }> {
const slug = const slug = options?.slug ?? `org-${uniqueTestId()}`;
options?.slug ??
`org-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`;
const result = await db const result = await db
.insertInto("orgs") .insertInto("orgs")
@@ -183,7 +188,7 @@ async function createLoginRequest(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = `login-${String(Date.now())}${String(Math.random())}`; const token = `login-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
@@ -212,7 +217,7 @@ async function createOrgInvite(
email: string, email: string,
invitedBy: number, invitedBy: number,
): Promise<{ id: number }> { ): Promise<{ id: number }> {
const token = `invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const token = `invite-${uniqueTestId()}`;
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const result = await db const result = await db
@@ -461,7 +466,7 @@ describeE2E("admin", () => {
test("creates passwordless user", async () => { test("creates passwordless user", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -492,7 +497,7 @@ describeE2E("admin", () => {
test("creates user with name", async () => { test("creates user with name", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -519,7 +524,7 @@ describeE2E("admin", () => {
test("creates user and adds to organization as member", async () => { test("creates user and adds to organization as member", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -554,7 +559,7 @@ describeE2E("admin", () => {
test("creates user and adds to organization with custom role", async () => { test("creates user and adds to organization with custom role", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -588,7 +593,7 @@ describeE2E("admin", () => {
test("normalizes email to lowercase", async () => { test("normalizes email to lowercase", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -615,7 +620,7 @@ describeE2E("admin", () => {
test("throws CONFLICT for duplicate email", async () => { test("throws CONFLICT for duplicate email", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -637,7 +642,7 @@ describeE2E("admin", () => {
test("throws NOT_FOUND for non-existent org", async () => { test("throws NOT_FOUND for non-existent org", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1060,7 +1065,7 @@ describeE2E("admin", () => {
test("creates organization with owner", async () => { test("creates organization with owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1096,20 +1101,22 @@ describeE2E("admin", () => {
expect(org?.display_name).toBe("New Organization"); expect(org?.display_name).toBe("New Organization");
// Verify owner membership // Verify owner membership
if (org) {
const membership = await db const membership = await db
.selectFrom("org_members") .selectFrom("org_members")
.where("org_id", "=", org?.id) .where("org_id", "=", org.id)
.where("user_id", "=", owner.id) .where("user_id", "=", owner.id)
.selectAll() .selectAll()
.executeTakeFirst(); .executeTakeFirst();
expect(membership).toBeDefined(); expect(membership).toBeDefined();
expect(membership?.role).toBe("owner"); expect(membership?.role).toBe("owner");
}
}); });
test("normalizes owner email to lowercase", async () => { test("normalizes owner email to lowercase", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1135,7 +1142,7 @@ describeE2E("admin", () => {
test("throws NOT_FOUND for non-existent owner", async () => { test("throws NOT_FOUND for non-existent owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1160,7 +1167,7 @@ describeE2E("admin", () => {
test("throws CONFLICT for duplicate slug", async () => { test("throws CONFLICT for duplicate slug", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1284,7 +1291,7 @@ describeE2E("admin", () => {
await createOrg(db, { await createOrg(db, {
slug: "test-org", slug: "test-org",
displayName: "Old", displayName: "Old",
logoUrl: null, logoUrl: undefined,
}); });
const { token: sessionToken } = await createSession(db, admin.id); const { token: sessionToken } = await createSession(db, admin.id);
@@ -1379,7 +1386,7 @@ describeE2E("admin", () => {
test("deletes organization and related records", async () => { test("deletes organization and related records", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1444,7 +1451,7 @@ describeE2E("admin", () => {
test("throws NOT_FOUND for non-existent organization", async () => { test("throws NOT_FOUND for non-existent organization", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1564,7 +1571,7 @@ describeE2E("admin", () => {
test("adds site to organization", async () => { test("adds site to organization", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1596,7 +1603,7 @@ describeE2E("admin", () => {
test("throws NOT_FOUND for non-existent organization", async () => { test("throws NOT_FOUND for non-existent organization", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1617,7 +1624,7 @@ describeE2E("admin", () => {
test("throws CONFLICT for duplicate domain", async () => { test("throws CONFLICT for duplicate domain", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1640,7 +1647,7 @@ describeE2E("admin", () => {
test("throws CONFLICT for domain in another organization", async () => { test("throws CONFLICT for domain in another organization", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1793,7 +1800,7 @@ describeE2E("admin", () => {
// Verify login request was completed // Verify login request was completed
const request = await db const request = await db
.selectFrom("login_requests") .selectFrom("login_requests")
.where("id", "=", String(loginRequest.id)) .where("id", "=", loginRequest.id.toString())
.select(["completed_at"]) .select(["completed_at"])
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
@@ -1825,7 +1832,7 @@ describeE2E("admin", () => {
const request = await db const request = await db
.selectFrom("login_requests") .selectFrom("login_requests")
.where("id", "=", String(loginRequest.id)) .where("id", "=", loginRequest.id.toString())
.select(["completed_at"]) .select(["completed_at"])
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();

View File

@@ -41,12 +41,14 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
getSharedDb, getSharedDb,
initTestDb, initTestDb,
TEST_RP, TEST_RP,
uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
@@ -99,6 +101,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -146,7 +153,7 @@ async function createSession(
userId: number, userId: number,
options?: { deviceId?: bigint }, options?: { deviceId?: bigint },
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -154,7 +161,7 @@ async function createSession(
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
user_id: userId, user_id: userId,
device_id: options?.deviceId ? String(options.deviceId) : null, device_id: options?.deviceId ? options.deviceId.toString() : null,
token_hash: tokenHashValue, token_hash: tokenHashValue,
trusted_mode: false, trusted_mode: false,
expires_at: expiresAt, expires_at: expiresAt,
@@ -178,7 +185,7 @@ async function createLoginRequest(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ token: string; id: number }> { ): Promise<{ token: string; id: number }> {
const token = `login_test-${String(Date.now())}${String(Math.random())}`; const token = `login_test-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
@@ -228,7 +235,7 @@ async function createEmailVerification(
userId: number, userId: number,
options?: { expiresAt?: Date }, options?: { expiresAt?: Date },
): Promise<string> { ): Promise<string> {
const token = `verify-${String(Date.now())}${String(Math.random())}`; const token = `verify-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -252,7 +259,7 @@ async function createPasswordReset(
userId: number, userId: number,
options?: { expiresAt?: Date; usedAt?: Date | null }, options?: { expiresAt?: Date; usedAt?: Date | null },
): Promise<string> { ): Promise<string> {
const token = `reset-${String(Date.now())}${String(Math.random())}`; const token = `reset-${uniqueTestId()}`;
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
await db await db
@@ -457,7 +464,7 @@ describeE2E("auth", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
@@ -483,7 +490,7 @@ describeE2E("auth", () => {
await db await db
.updateTable("webauthn_challenges") .updateTable("webauthn_challenges")
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Step 4: Try to signup with expired challenge // Step 4: Try to signup with expired challenge
@@ -540,7 +547,7 @@ describeE2E("auth", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
@@ -1072,7 +1079,7 @@ describeE2E("auth", () => {
const loginRequest = await db const loginRequest = await db
.selectFrom("login_requests") .selectFrom("login_requests")
.selectAll() .selectAll()
.where("id", "=", String(loginRequestId)) .where("id", "=", loginRequestId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(loginRequest).toBeUndefined(); expect(loginRequest).toBeUndefined();
@@ -1152,7 +1159,7 @@ describeE2E("auth", () => {
}); });
// Create login request without device fingerprint // Create login request without device fingerprint
const token = `login_test-${String(Date.now())}`; const token = `login_test-${uniqueTestId()}`;
await db await db
.insertInto("login_requests") .insertInto("login_requests")
.values({ .values({
@@ -1644,7 +1651,7 @@ describeE2E("auth", () => {
const session = await db const session = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(session?.revoked_at).not.toBeNull(); expect(session?.revoked_at).not.toBeNull();
@@ -1981,7 +1988,7 @@ describeE2E("auth", () => {
// Clean up registration session // Clean up registration session
await db await db
.deleteFrom("sessions") .deleteFrom("sessions")
.where("id", "=", String(regSessionId)) .where("id", "=", regSessionId.toString())
.execute(); .execute();
// Step 1: Create login request // Step 1: Create login request

View File

@@ -23,12 +23,14 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
getSharedDb, getSharedDb,
initTestDb, initTestDb,
TEST_RP, TEST_RP,
uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { router } from "../../router.js"; import { router } from "../../router.js";
@@ -81,6 +83,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -92,7 +99,7 @@ async function createSession(
userId: number, userId: number,
options?: { ipAddress?: string; userAgent?: string }, options?: { ipAddress?: string; userAgent?: string },
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -125,9 +132,7 @@ async function createDevice(
userAgent?: string; userAgent?: string;
}, },
): Promise<{ fingerprint: string; deviceId: number }> { ): Promise<{ fingerprint: string; deviceId: number }> {
const fingerprint = const fingerprint = options?.fingerprint ?? `test-fp-${uniqueTestId()}`;
options?.fingerprint ??
`test-fp-${String(Date.now())}${String(Math.random())}`;
const result = await db const result = await db
.insertInto("user_devices") .insertInto("user_devices")
@@ -153,7 +158,7 @@ async function createApiToken(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<{ token: string; name: string }> { ): Promise<{ token: string; name: string }> {
const token = `test-api-token-${String(Date.now())}${String(Math.random())}`; const token = `test-api-token-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
@@ -224,7 +229,7 @@ describeE2E("me", () => {
const user = await createTestUser(db, { email: "expired@example.com" }); const user = await createTestUser(db, { email: "expired@example.com" });
// Create an expired session // Create an expired session
const token = `expired-session-${String(Date.now())}`; const token = `expired-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
await db await db
.insertInto("sessions") .insertInto("sessions")
@@ -249,7 +254,7 @@ describeE2E("me", () => {
const user = await createTestUser(db, { email: "revoked@example.com" }); const user = await createTestUser(db, { email: "revoked@example.com" });
// Create a revoked session // Create a revoked session
const token = `revoked-session-${String(Date.now())}`; const token = `revoked-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
await db await db
.insertInto("sessions") .insertInto("sessions")
@@ -925,7 +930,7 @@ describeE2E("me", () => {
country: "US", country: "US",
trusted_mode: true, trusted_mode: true,
}) })
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.execute(); .execute();
const context = createAPIContext(db, { sessionToken }); const context = createAPIContext(db, { sessionToken });
@@ -968,7 +973,7 @@ describeE2E("me", () => {
const session = await db const session = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(sessionId2)) .where("id", "=", sessionId2.toString())
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(session.revoked_at).not.toBeNull(); expect(session.revoked_at).not.toBeNull();
@@ -1021,7 +1026,7 @@ describeE2E("me", () => {
await db await db
.updateTable("sessions") .updateTable("sessions")
.set({ revoked_at: new Date() }) .set({ revoked_at: new Date() })
.where("id", "=", String(sessionId2)) .where("id", "=", sessionId2.toString())
.execute(); .execute();
const context = createAPIContext(db, { sessionToken: sessionToken1 }); const context = createAPIContext(db, { sessionToken: sessionToken1 });
@@ -1080,7 +1085,7 @@ describeE2E("me", () => {
const currentSession = await db const currentSession = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(id1)) .where("id", "=", id1.toString())
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(currentSession.revoked_at).toBeNull(); expect(currentSession.revoked_at).toBeNull();
@@ -1088,7 +1093,7 @@ describeE2E("me", () => {
const otherSessions = await db const otherSessions = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["id", "revoked_at"]) .select(["id", "revoked_at"])
.where("id", "in", [String(id2), String(id3)]) .where("id", "in", [id2.toString(), id3.toString()])
.execute(); .execute();
for (const session of otherSessions) { for (const session of otherSessions) {
@@ -1116,7 +1121,7 @@ describeE2E("me", () => {
const session = await db const session = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(session.revoked_at).toBeNull(); expect(session.revoked_at).toBeNull();
}); });
@@ -1147,7 +1152,7 @@ describeE2E("me", () => {
region: "NY", region: "NY",
country: "US", country: "US",
}) })
.where("id", "=", String(deviceId)) .where("id", "=", deviceId.toString())
.execute(); .execute();
const { token: sessionToken } = await createSession(db, user.id); const { token: sessionToken } = await createSession(db, user.id);
@@ -1256,7 +1261,7 @@ describeE2E("me", () => {
const device = await db const device = await db
.selectFrom("user_devices") .selectFrom("user_devices")
.select(["is_trusted", "name"]) .select(["is_trusted", "name"])
.where("id", "=", String(deviceId)) .where("id", "=", deviceId.toString())
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(device.is_trusted).toBe(true); expect(device.is_trusted).toBe(true);
@@ -1401,7 +1406,7 @@ describeE2E("me", () => {
const device = await db const device = await db
.selectFrom("user_devices") .selectFrom("user_devices")
.select(["is_trusted"]) .select(["is_trusted"])
.where("id", "=", String(deviceId)) .where("id", "=", deviceId.toString())
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(device.is_trusted).toBe(false); expect(device.is_trusted).toBe(false);
@@ -1501,7 +1506,7 @@ async function createTrustedSession(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -1568,7 +1573,7 @@ async function createOrgInvite(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ id: number }> { ): Promise<{ id: number }> {
const token = `invite-token-${String(Date.now())}-${Math.random().toString(36).slice(2)}`; const token = `invite-token-${uniqueTestId()}-${Math.random().toString(36).slice(2)}`;
const result = await db const result = await db
.insertInto("org_invites") .insertInto("org_invites")
.values({ .values({
@@ -1693,7 +1698,7 @@ describeE2E("me.apiTokens and me.invites", () => {
}); });
expect(tokens).toHaveLength(1); expect(tokens).toHaveLength(1);
expect(tokens[0].name).toBe("User1 Token"); expect(tokens[0]?.name).toBe("User1 Token");
}); });
}); });
}); });
@@ -1727,7 +1732,7 @@ describeE2E("me.apiTokens and me.invites", () => {
.execute(); .execute();
expect(tokens).toHaveLength(1); expect(tokens).toHaveLength(1);
expect(tokens[0].name).toBe("My New Token"); expect(tokens[0]?.name).toBe("My New Token");
}); });
}); });
@@ -1937,10 +1942,10 @@ describeE2E("me.apiTokens and me.invites", () => {
}); });
expect(invites).toHaveLength(1); expect(invites).toHaveLength(1);
expect(invites[0].org.slug).toBe("invite-org"); expect(invites[0]?.org.slug).toBe("invite-org");
expect(invites[0].org.displayName).toBe("Invite Org"); expect(invites[0]?.org.displayName).toBe("Invite Org");
expect(invites[0].role).toBe("admin"); expect(invites[0]?.role).toBe("admin");
expect(invites[0].invitedBy).toBe("Inviter Person"); expect(invites[0]?.invitedBy).toBe("Inviter Person");
}); });
}); });
@@ -2086,7 +2091,7 @@ describeE2E("me.apiTokens and me.invites", () => {
describe("me.invites.accept", () => { describe("me.invites.accept", () => {
test("accepts invite and adds user to org", async () => { test("accepts invite and adds user to org", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const inviter = await createTestUser(db, { const inviter = await createTestUser(db, {
email: `inviter-accept-${uniqueId}@example.com`, email: `inviter-accept-${uniqueId}@example.com`,
@@ -2188,7 +2193,7 @@ describeE2E("me.apiTokens and me.invites", () => {
test("returns error if already a member", async () => { test("returns error if already a member", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const inviter = await createTestUser(db, { const inviter = await createTestUser(db, {
email: `inviter-already-${uniqueId}@example.com`, email: `inviter-already-${uniqueId}@example.com`,

View File

@@ -14,12 +14,14 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
getSharedDb, getSharedDb,
initTestDb, initTestDb,
TEST_RP, TEST_RP,
uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { router } from "../../router.js"; import { router } from "../../router.js";
@@ -57,6 +59,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -68,7 +75,7 @@ async function createSession(
userId: number, userId: number,
options?: { trustedMode?: boolean }, options?: { trustedMode?: boolean },
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -166,9 +173,7 @@ async function createOrgInvite(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = const token = options?.token ?? `invite-${uniqueTestId()}`;
options?.token ??
`invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); options?.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
@@ -319,7 +324,7 @@ describeE2E("orgs", () => {
describe("orgs.create", () => { describe("orgs.create", () => {
test("creates org and makes user owner", async () => { test("creates org and makes user owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const user = await createTestUser(db, { const user = await createTestUser(db, {
email: `user-${uniqueId}@example.com`, email: `user-${uniqueId}@example.com`,
@@ -349,7 +354,7 @@ describeE2E("orgs", () => {
test("rejects duplicate slug", async () => { test("rejects duplicate slug", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const user = await createTestUser(db, { const user = await createTestUser(db, {
email: `user-${uniqueId}@example.com`, email: `user-${uniqueId}@example.com`,
@@ -532,7 +537,7 @@ describeE2E("orgs", () => {
describe("orgs.delete", () => { describe("orgs.delete", () => {
test("deletes org when user is owner", async () => { test("deletes org when user is owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const user = await createTestUser(db, { const user = await createTestUser(db, {
email: `user-${uniqueId}@example.com`, email: `user-${uniqueId}@example.com`,
@@ -581,7 +586,7 @@ describeE2E("orgs", () => {
describe("orgs.leave", () => { describe("orgs.leave", () => {
test("allows member to leave org", async () => { test("allows member to leave org", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -614,7 +619,7 @@ describeE2E("orgs", () => {
test("allows owner to leave when there are other owners", async () => { test("allows owner to leave when there are other owners", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner1 = await createTestUser(db, { const owner1 = await createTestUser(db, {
email: `owner1-${uniqueId}@example.com`, email: `owner1-${uniqueId}@example.com`,
@@ -647,7 +652,7 @@ describeE2E("orgs", () => {
test("prevents only owner from leaving", async () => { test("prevents only owner from leaving", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -770,7 +775,7 @@ describeE2E("orgs", () => {
describe("orgs.members.updateRole", () => { describe("orgs.members.updateRole", () => {
test("owner can promote member to admin", async () => { test("owner can promote member to admin", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -803,7 +808,7 @@ describeE2E("orgs", () => {
test("owner can promote member to owner", async () => { test("owner can promote member to owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -836,7 +841,7 @@ describeE2E("orgs", () => {
test("owner can demote owner to admin when multiple owners exist", async () => { test("owner can demote owner to admin when multiple owners exist", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner1 = await createTestUser(db, { const owner1 = await createTestUser(db, {
email: `owner1-${uniqueId}@example.com`, email: `owner1-${uniqueId}@example.com`,
@@ -869,7 +874,7 @@ describeE2E("orgs", () => {
test("prevents demoting the only owner", async () => { test("prevents demoting the only owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -916,7 +921,7 @@ describeE2E("orgs", () => {
test("rejects when target member not found", async () => { test("rejects when target member not found", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -942,7 +947,7 @@ describeE2E("orgs", () => {
describe("orgs.members.remove", () => { describe("orgs.members.remove", () => {
test("owner can remove member", async () => { test("owner can remove member", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -975,7 +980,7 @@ describeE2E("orgs", () => {
test("owner can remove admin", async () => { test("owner can remove admin", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1008,7 +1013,7 @@ describeE2E("orgs", () => {
test("owner can remove other owner when multiple owners exist", async () => { test("owner can remove other owner when multiple owners exist", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner1 = await createTestUser(db, { const owner1 = await createTestUser(db, {
email: `owner1-${uniqueId}@example.com`, email: `owner1-${uniqueId}@example.com`,
@@ -1041,7 +1046,7 @@ describeE2E("orgs", () => {
test("prevents removing the only owner", async () => { test("prevents removing the only owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1065,7 +1070,7 @@ describeE2E("orgs", () => {
test("admin can remove member", async () => { test("admin can remove member", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1102,7 +1107,7 @@ describeE2E("orgs", () => {
test("admin cannot remove owner", async () => { test("admin cannot remove owner", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1130,7 +1135,7 @@ describeE2E("orgs", () => {
test("admin cannot remove other admin", async () => { test("admin cannot remove other admin", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1162,7 +1167,7 @@ describeE2E("orgs", () => {
test("member cannot remove anyone", async () => { test("member cannot remove anyone", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1192,7 +1197,7 @@ describeE2E("orgs", () => {
test("rejects when target member not found", async () => { test("rejects when target member not found", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1297,7 +1302,7 @@ describeE2E("orgs", () => {
describe("orgs.invites.create", () => { describe("orgs.invites.create", () => {
test("admin can create member invite", async () => { test("admin can create member invite", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1331,7 +1336,7 @@ describeE2E("orgs", () => {
test("admin can create admin invite", async () => { test("admin can create admin invite", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const admin = await createTestUser(db, { const admin = await createTestUser(db, {
email: `admin-${uniqueId}@example.com`, email: `admin-${uniqueId}@example.com`,
@@ -1385,7 +1390,7 @@ describeE2E("orgs", () => {
test("owner can create owner invite", async () => { test("owner can create owner invite", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1571,7 +1576,7 @@ describeE2E("orgs", () => {
describe("orgs.invites.accept", () => { describe("orgs.invites.accept", () => {
test("accepts invite and adds user to org", async () => { test("accepts invite and adds user to org", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,
@@ -1669,9 +1674,7 @@ describeE2E("orgs", () => {
test("rejects when email doesn't match", async () => { test("rejects when email doesn't match", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const owner = await createTestUser(db, { email: "owner@example.com" }); const owner = await createTestUser(db, { email: "owner@example.com" });
const _invitee = await createTestUser(db, { await createTestUser(db, { email: "invitee@example.com" });
email: "invitee@example.com",
});
const wrongUser = await createTestUser(db, { const wrongUser = await createTestUser(db, {
email: "wrong@example.com", email: "wrong@example.com",
}); });
@@ -1701,7 +1704,7 @@ describeE2E("orgs", () => {
test("handles already a member gracefully", async () => { test("handles already a member gracefully", async () => {
const db = getSharedDb(); const db = getSharedDb();
const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; const uniqueId = uniqueTestId();
const owner = await createTestUser(db, { const owner = await createTestUser(db, {
email: `owner-${uniqueId}@example.com`, email: `owner-${uniqueId}@example.com`,

View File

@@ -12,6 +12,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -20,6 +21,7 @@ import {
initTestDb, initTestDb,
KNOWN_AAGUIDS, KNOWN_AAGUIDS,
TEST_RP, TEST_RP,
uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
@@ -50,6 +52,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -60,7 +67,7 @@ async function createSession(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<string> { ): Promise<string> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -87,7 +94,7 @@ async function createLoginRequest(
userId: number, userId: number,
email: string, email: string,
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = `test-login-${String(Date.now())}${String(Math.random())}`; const token = `test-login-${uniqueTestId()}`;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const result = await db const result = await db
@@ -132,6 +139,11 @@ function createLoginRequestContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -236,7 +248,7 @@ describeE2E("webauthn", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeDefined(); expect(challengeRow).toBeDefined();
@@ -382,7 +394,7 @@ describeE2E("webauthn", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
@@ -585,7 +597,7 @@ describeE2E("webauthn", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(authChallengeId)) .where("id", "=", authChallengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();

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,12 +3,11 @@
*/ */
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
@@ -48,4 +47,5 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
.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,13 +4,12 @@
*/ */
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
@@ -29,12 +28,11 @@ export const adminOrgsListSites = os.admin.orgs.listSites
.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,12 +68,12 @@ 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
@@ -98,4 +96,5 @@ export const adminOrgsRemoveSite = os.admin.orgs.removeSite
} }
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,12 +3,11 @@
*/ */
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({
@@ -23,4 +22,5 @@ export const adminUsersConfirmEmail = os.admin.users.confirmEmail
} }
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,19 +31,21 @@ 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
await withTransaction(context.db, async (trx) => {
// Delete any existing password reset tokens for this user (security measure)
await trx
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
await trx
.insertInto("password_resets") .insertInto("password_resets")
.values({ .values({
user_id: user.id, user_id: user.id,
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .execute();
});
// Send password reset email (stubbed) // Send password reset email
await sendPasswordResetEmail(user.email, token); 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,9 +90,13 @@ 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);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await withTransaction(
context.db,
async (trx) => {
// Upsert user device // Upsert user device
const deviceId = await upsertUserDevice( const deviceId = await upsertUserDevice(
context.db, trx,
userId, userId,
deviceFingerprint, deviceFingerprint,
geo, geo,
@@ -99,14 +104,10 @@ export const loginIfRequestIsCompleted =
); );
// Check if device is already trusted // Check if device is already trusted
const deviceTrusted = await isDeviceTrusted( const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
context.db,
userId,
deviceFingerprint,
);
// Create session with trusted mode = true (email-confirmed login) // Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId, deviceId,
trustedMode: true, trustedMode: true,
@@ -115,11 +116,15 @@ export const loginIfRequestIsCompleted =
}); });
// Delete the login request (it's been consumed) // Delete the login request (it's been consumed)
await context.db await trx
.deleteFrom("login_requests") .deleteFrom("login_requests")
.where("id", "=", loginRequest.id) .where("id", "=", loginRequest.id)
.execute(); .execute();
return { session: newSession, deviceTrusted: trusted };
},
);
// 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";
@@ -108,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();
@@ -134,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
@@ -149,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", {
@@ -159,7 +160,7 @@ export async function signupWithPasskey(
// Create user and passkey in a transaction (handle race condition if concurrent signup) // Create user and passkey in a transaction (handle race condition if concurrent signup)
try { try {
const result = await db.transaction().execute(async (trx) => { const result = await withTransaction(db, async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
.insertInto("users") .insertInto("users")
@@ -200,7 +201,7 @@ export async function signupWithPasskey(
// 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 };
@@ -269,8 +270,16 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
}); });
} }
// Generate verification token
const verificationToken = generateSecureBase58Token();
const verificationExpiresAt = generateExpiry(
TOKEN_DURATIONS.EMAIL_VERIFICATION,
);
// Create session and email verification in transaction
const session = await withTransaction(context.db, async (trx) => {
// Create session (7 days, trusted mode false initially, no device) // Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId: null, deviceId: null,
trustedMode: false, trustedMode: false,
@@ -278,6 +287,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
userAgent, 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
setCookie( setCookie(
context.resHeaders, context.resHeaders,
@@ -286,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")
.values({
user_id: userId,
token: verificationToken, token: verificationToken,
expires_at: expiresAt, expiryHours: 24,
}) });
.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,11 +2,9 @@
* 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)
.handler(async ({ context }) => {
const user = await context.db const user = await context.db
.selectFrom("users") .selectFrom("users")
.select([ .select([

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,9 +14,7 @@ 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)
.handler(async ({ input, context }) => {
const { password } = input; const { password } = input;
// Fetch user with password hash // Fetch user with password hash

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,11 +2,9 @@
* 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)
.handler(async ({ context }) => {
const user = await context.db const user = await context.db
.selectFrom("users") .selectFrom("users")
.select([ .select([

View File

@@ -3,15 +3,13 @@
*/ */
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)
.handler(async ({ context }) => {
// Only show invites if email is verified // Only show invites if email is verified
if (!context.user.emailVerifiedAt) { if (!context.user.emailVerifiedAt) {
return []; return [];
@@ -58,9 +56,8 @@ export const listInvites = os.me.invites.list
* 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 =====

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,21 +47,26 @@ 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(
context.db,
org.id,
context.user.id,
);
requireRole(membership, "owner"); requireRole(membership, "owner");
await context.db.transaction().execute(async (trx) => { await context.db.transaction().execute(async (trx) => {
@@ -97,16 +101,16 @@ export const membersUpdateRole = os.orgs.members.updateRole
}); });
return { success: true }; 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

@@ -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,23 +1,26 @@
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( throw new Error(
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`, `Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
); );
} }
return role as OrgRole;
}
interface CreateUserFlags { interface CreateUserFlags {
email: string; email: string;
name?: string; name?: string;
@@ -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

@@ -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

@@ -5,7 +5,7 @@ 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 {

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>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</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">% 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">
<div class="flex items-center gap-2"> <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="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
<span class="text-[13px] font-medium text-foreground">{row.name}</span> <span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
</div> </div>
</Table.Cell> {/snippet}
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell> </MetricsTable>
<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>

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 SourceRow extends MetricsRow {
name: string;
}
const tableData: SourceRow[] = [
{ {
id: 1, id: 1,
name: "Google AdX", name: "Google AdX",
@@ -43,51 +47,10 @@ const tableData = [
impPercent: 7.28, impPercent: 7.28,
}, },
]; ];
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="Source">
<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 SourceRow).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">Source</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

@@ -1,95 +0,0 @@
<script lang="ts">
import { X } from "@lucide/svelte";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
interface Props {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "destructive" | "default";
loading?: boolean;
onconfirm: () => void;
oncancel: () => void;
}
let {
open = $bindable(false),
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
loading = false,
onconfirm,
oncancel,
}: Props = $props();
function handleCancel() {
open = false;
oncancel();
}
function handleConfirm() {
onconfirm();
}
</script>
<DialogPrimitive.Root bind:open>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
"rounded-lg border bg-background p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"duration-200"
)}
>
<!-- Close button -->
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
onclick={handleCancel}
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
<!-- Header -->
<div class="space-y-2">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
{description}
</DialogPrimitive.Description>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end gap-3">
<Button variant="outline" onclick={handleCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onclick={handleConfirm}
disabled={loading}
>
{#if loading}
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{/if}
{confirmLabel}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

View File

@@ -1,3 +1,2 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as OrgAvatar } from "./org-avatar.svelte"; export { default as OrgAvatar } from "./org-avatar.svelte";
export { default as RoleBadge } from "./role-badge.svelte"; export { default as RoleBadge } from "./role-badge.svelte";

View File

@@ -28,7 +28,7 @@ let {
onConfirm, onConfirm,
}: Props = $props(); }: Props = $props();
async function handleConfirm() { async function handleConfirm(): Promise<void> {
await onConfirm(); await onConfirm();
} }
</script> </script>
@@ -54,8 +54,8 @@ async function handleConfirm() {
<LoadingButton <LoadingButton
variant="destructive" variant="destructive"
class="w-full" class="w-full"
loading={loading} {loading}
loadingText={loadingText} {loadingText}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}
@@ -77,7 +77,7 @@ async function handleConfirm() {
{cancelText} {cancelText}
</Button> </Button>
<LoadingButton <LoadingButton
loading={loading} {loading}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}

View File

@@ -0,0 +1 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";

View File

@@ -14,7 +14,6 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -25,6 +24,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";

View File

@@ -12,7 +12,6 @@ import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, 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 { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -23,6 +22,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -15,7 +15,6 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -26,6 +25,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -6,7 +6,6 @@ import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -14,6 +13,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card/index.js"; } from "$lib/components/ui/card/index.js";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Skeleton } from "$lib/components/ui/skeleton/index.js"; import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { import {
Table, Table,
@@ -238,8 +238,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant="destructive" variant="destructive"
confirmLabel="Delete" confirmText="Delete"
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

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