diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index c71659b..ee79c03 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -3,9 +3,6 @@ * First step in the login flow - validates email and returns available auth methods */ -import type { APIContext } from "../../context.js"; -import { implement } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; import { COOKIE_DURATIONS, COOKIE_NAMES, @@ -19,8 +16,7 @@ import { } from "../../utils/crypto.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { isDeviceTrusted } from "../../utils/session.js"; - -const os = implement(contract); +import { os } from "../base.js"; /** * Create login request handler @@ -33,7 +29,6 @@ const os = implement(contract); */ export const createLoginRequest = os.auth.createLoginRequest.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { email: rawEmail } = input; // Normalize email to lowercase @@ -41,14 +36,14 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Read or generate device fingerprint let deviceFingerprint = getCookie( - ctx.reqHeaders, + context.reqHeaders, COOKIE_NAMES.DEVICE_FINGERPRINT, ); if (!deviceFingerprint) { deviceFingerprint = generateDeviceFingerprint(); setCookie( - ctx.resHeaders, + context.resHeaders, COOKIE_NAMES.DEVICE_FINGERPRINT, deviceFingerprint, COOKIE_OPTIONS.device, @@ -56,7 +51,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( } // Look up user by email - const user = await ctx.db + const user = await context.db .selectFrom("users") .select(["id", "password_hash"]) .where("email", "=", email) @@ -70,7 +65,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Set placeholder login request token cookie setCookie( - ctx.resHeaders, + context.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN, placeholderToken, COOKIE_OPTIONS.loginRequest, @@ -89,13 +84,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Check if device is trusted const isTrustedDevice = await isDeviceTrusted( - ctx.db, + context.db, userId, deviceFingerprint, ); // Check if user has passkey - const passkey = await ctx.db + const passkey = await context.db .selectFrom("passkeys") .select(["id"]) .where("user_id", "=", userId) @@ -106,13 +101,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( const hasPassword = user.password_hash !== null; // Get geo info and user agent - const geo = getGeoInfo(ctx.reqHeaders); - const userAgent = getUserAgent(ctx.reqHeaders); + const geo = getGeoInfo(context.reqHeaders); + const userAgent = getUserAgent(context.reqHeaders); // Create login request const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST); - const loginRequest = await ctx.db + const loginRequest = await context.db .insertInto("login_requests") .values({ user_id: userId, @@ -132,7 +127,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Set login request token cookie with the real login request ID setCookie( - ctx.resHeaders, + context.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN, loginRequestId, COOKIE_OPTIONS.loginRequest, diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts index a5c87b8..9f71fc8 100644 --- a/apps/api-server/src/procedures/auth/forgot-password.ts +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -1,12 +1,3 @@ -import type { APIContext } from "../../context.js"; -import { implement } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; -import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; -import { sendPasswordResetEmail } from "../../utils/email.js"; - -const os = implement(contract); - /** * Forgot password handler * Public procedure (no authentication required) @@ -14,16 +5,21 @@ const os = implement(contract); * Anti-enumeration: Always returns success even if user doesn't exist * This prevents attackers from determining which emails are registered */ + +import { TOKEN_DURATIONS } from "../../utils/cookies.js"; +import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { sendPasswordResetEmail } from "../../utils/email.js"; +import { os } from "../base.js"; + export const forgotPassword = os.auth.forgotPassword.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { email } = input; // Normalize email to lowercase const normalizedEmail = email.toLowerCase(); // Look up user by email - const user = await ctx.db + const user = await context.db .selectFrom("users") .select(["id", "email"]) .where("email", "=", normalizedEmail) @@ -32,7 +28,7 @@ export const forgotPassword = os.auth.forgotPassword.handler( // If user exists, create password reset token and send email if (user) { // Delete any existing password reset tokens for this user (security measure) - await ctx.db + await context.db .deleteFrom("password_resets") .where("user_id", "=", user.id) .execute(); @@ -43,7 +39,7 @@ export const forgotPassword = os.auth.forgotPassword.handler( // Create password reset record with 1 hour expiry const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); - await ctx.db + await context.db .insertInto("password_resets") .values({ user_id: user.id, diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts index 0d1ee4d..499c83d 100644 --- a/apps/api-server/src/procedures/auth/login-if-completed.ts +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -16,9 +16,6 @@ * e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' } */ -import type { APIContext } from "../../context.js"; -import { implement } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; import { COOKIE_NAMES, COOKIE_OPTIONS, @@ -32,8 +29,7 @@ import { isDeviceTrusted, upsertUserDevice, } from "../../utils/session.js"; - -const os = implement(contract); +import { os } from "../base.js"; /** * Check if a string looks like a UUID (fake token) @@ -50,11 +46,9 @@ const isUUID = (str: string): boolean => { */ export const loginIfRequestIsCompleted = os.auth.loginIfRequestIsCompleted.handler(async ({ context }) => { - const ctx = context as APIContext; - // Read login request token from cookie const loginRequestToken = getCookie( - ctx.reqHeaders, + context.reqHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN, ); @@ -78,7 +72,7 @@ export const loginIfRequestIsCompleted = } // Fetch login request from database - const loginRequest = await ctx.db + const loginRequest = await context.db .selectFrom("login_requests") .select([ "id", @@ -115,12 +109,12 @@ export const loginIfRequestIsCompleted = } // Get current request info - const geo = getGeoInfo(ctx.reqHeaders); - const userAgent = getUserAgent(ctx.reqHeaders); + const geo = getGeoInfo(context.reqHeaders); + const userAgent = getUserAgent(context.reqHeaders); // Upsert user device const deviceId = await upsertUserDevice( - ctx.db, + context.db, userId, deviceFingerprint, geo, @@ -129,13 +123,13 @@ export const loginIfRequestIsCompleted = // Check if device is already trusted const deviceTrusted = await isDeviceTrusted( - ctx.db, + context.db, userId, deviceFingerprint, ); // Create session with trusted mode = true (email-confirmed login) - const session = await createSession(ctx.db, { + const session = await createSession(context.db, { userId, deviceId, trustedMode: true, @@ -144,21 +138,21 @@ export const loginIfRequestIsCompleted = }); // Delete the login request (it's been consumed) - await ctx.db + await context.db .deleteFrom("login_requests") .where("id", "=", String(loginRequestId)) .execute(); // Set session cookie setCookie( - ctx.resHeaders, + context.resHeaders, COOKIE_NAMES.SESSION_TOKEN, session.token, COOKIE_OPTIONS.session, ); // Clear login request cookie - deleteCookie(ctx.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN); + deleteCookie(context.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN); // Determine redirect path based on device trust status const redirectTo = deviceTrusted ? "/dashboard" : "/auth/trust-device"; diff --git a/apps/api-server/src/procedures/auth/login-password-confirm.ts b/apps/api-server/src/procedures/auth/login-password-confirm.ts index c0c2740..64f6262 100644 --- a/apps/api-server/src/procedures/auth/login-password-confirm.ts +++ b/apps/api-server/src/procedures/auth/login-password-confirm.ts @@ -1,9 +1,3 @@ -import type { APIContext } from "../../context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; - -const os = implement(contract); - /** * Confirm password login via email link token * Public procedure - no authentication required @@ -17,13 +11,16 @@ const os = implement(contract); * This is called when user clicks the confirmation link in their email * for untrusted device login attempts. */ + +import { ORPCError } from "@orpc/server"; +import { os } from "../base.js"; + export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { token } = input; // Find the login request by token - const loginRequest = await ctx.db + const loginRequest = await context.db .selectFrom("login_requests") .select(["id", "expires_at", "completed_at"]) .where("token", "=", token) @@ -48,7 +45,7 @@ export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler( } // Mark as completed - await ctx.db + await context.db .updateTable("login_requests") .set({ completed_at: new Date() }) .where("id", "=", loginRequest.id) diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts index f8a5081..a59991b 100644 --- a/apps/api-server/src/procedures/auth/login-password.ts +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -3,16 +3,13 @@ * Second step in the login flow - verifies password and completes/confirms login */ -import type { APIContext } from "../../context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; +import { ORPCError } from "@orpc/server"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; import { generateSecureToken } from "../../utils/crypto.js"; import { sendLoginConfirmationEmail } from "../../utils/email.js"; import { verifyPassword } from "../../utils/password.js"; import { isDeviceTrusted } from "../../utils/session.js"; - -const os = implement(contract); +import { os } from "../base.js"; /** * Check if a string is a valid login request ID (numeric) @@ -34,12 +31,11 @@ const isValidLoginRequestId = (value: string): boolean => { */ export const loginPassword = os.auth.loginPassword.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { password } = input; // Read login request token from cookie const loginRequestToken = getCookie( - ctx.reqHeaders, + context.reqHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN, ); @@ -65,7 +61,7 @@ export const loginPassword = os.auth.loginPassword.handler( const loginRequestId = loginRequestToken; // Fetch login request with user data in single query (optimized JOIN) - const result = await ctx.db + const result = await context.db .selectFrom("login_requests") .innerJoin("users", "users.id", "login_requests.user_id") .select([ @@ -114,12 +110,12 @@ export const loginPassword = os.auth.loginPassword.handler( // Password is valid - check if device is trusted // If no device fingerprint, treat as untrusted const deviceTrusted = result.device_fingerprint - ? await isDeviceTrusted(ctx.db, result.user_id, result.device_fingerprint) + ? await isDeviceTrusted(context.db, result.user_id, result.device_fingerprint) : false; if (deviceTrusted) { // Device is trusted - complete login immediately - await ctx.db + await context.db .updateTable("login_requests") .set({ completed_at: new Date(), @@ -130,7 +126,7 @@ export const loginPassword = os.auth.loginPassword.handler( // Device is untrusted - generate confirmation token and send email const confirmationToken = generateSecureToken(); - await ctx.db + await context.db .updateTable("login_requests") .set({ token: confirmationToken, diff --git a/apps/api-server/src/procedures/auth/logout.ts b/apps/api-server/src/procedures/auth/logout.ts index 4461a01..fd37140 100644 --- a/apps/api-server/src/procedures/auth/logout.ts +++ b/apps/api-server/src/procedures/auth/logout.ts @@ -2,12 +2,8 @@ * Logout procedure - revokes the current session and clears the session cookie */ -import type { AuthenticatedContext } from "../../context.js"; -import { implement } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js"; - -const os = implement(contract); +import { authMiddleware, os } from "../base.js"; /** * Logout handler @@ -15,18 +11,16 @@ const os = implement(contract); * - Revokes the current session by setting revoked_at to now() * - Clears the session cookie from the response */ -export const logout = os.auth.logout.handler( - async ({ context }: { context: unknown }) => { - const ctx = context as AuthenticatedContext; +export const logout = os.auth.logout + .use(authMiddleware) + .handler(async ({ context }) => { + // Revoke the current session + await context.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(context.session.id)) + .execute(); - // Revoke the current session - await ctx.db - .updateTable("sessions") - .set({ revoked_at: new Date() }) - .where("id", "=", String(ctx.session.id)) - .execute(); - - // Clear the session cookie - deleteCookie(ctx.resHeaders, COOKIE_NAMES.SESSION_TOKEN); - }, -); + // Clear the session cookie + deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); +}); diff --git a/apps/api-server/src/procedures/auth/resend-verification.ts b/apps/api-server/src/procedures/auth/resend-verification.ts index c937e65..28ffe75 100644 --- a/apps/api-server/src/procedures/auth/resend-verification.ts +++ b/apps/api-server/src/procedures/auth/resend-verification.ts @@ -1,12 +1,3 @@ -import type { AuthenticatedContext } from "../../context.js"; -import { implement } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; -import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; -import { sendVerificationEmail } from "../../utils/email.js"; - -const os = implement(contract); - /** * Resend email verification to authenticated user * Requires authentication @@ -18,37 +9,41 @@ const os = implement(contract); * 4. Create new email_verifications record with 24 hour expiry * 5. Send verification email (stubbed) */ -export const resendVerificationEmail = os.auth.resendVerificationEmail.handler( - async ({ context }) => { - const ctx = context as AuthenticatedContext; - // Check if email is already verified - if (ctx.user.emailVerifiedAt !== null) { - // Email already verified, return early - return; - } +import { TOKEN_DURATIONS } from "../../utils/cookies.js"; +import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { sendVerificationEmail } from "../../utils/email.js"; +import { authMiddleware, os } from "../base.js"; - // Delete any existing verification tokens for this user - await ctx.db - .deleteFrom("email_verifications") - .where("user_id", "=", ctx.user.id) - .execute(); +export const resendVerificationEmail = os.auth.resendVerificationEmail + .use(authMiddleware) + .handler(async ({ context }) => { + // Check if email is already verified + if (context.user.emailVerifiedAt !== null) { + // Email already verified, return early + return; + } - // Generate new secure token - const token = generateSecureToken(); - const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); + // Delete any existing verification tokens for this user + await context.db + .deleteFrom("email_verifications") + .where("user_id", "=", context.user.id) + .execute(); - // Create new verification record - await ctx.db - .insertInto("email_verifications") - .values({ - user_id: ctx.user.id, - token, - expires_at: expiresAt, - }) - .execute(); + // Generate new secure token + const token = generateSecureToken(); + const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); - // Send verification email (stubbed) - await sendVerificationEmail(ctx.user.email, token); - }, -); + // Create new verification record + await context.db + .insertInto("email_verifications") + .values({ + user_id: context.user.id, + token, + expires_at: expiresAt, + }) + .execute(); + + // Send verification email (stubbed) + await sendVerificationEmail(context.user.email, token); +}); diff --git a/apps/api-server/src/procedures/auth/reset-password.ts b/apps/api-server/src/procedures/auth/reset-password.ts index f4d23eb..af51079 100644 --- a/apps/api-server/src/procedures/auth/reset-password.ts +++ b/apps/api-server/src/procedures/auth/reset-password.ts @@ -1,10 +1,3 @@ -import type { APIContext } from "../../context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; -import { hashPassword, validatePassword } from "../../utils/password.js"; - -const os = implement(contract); - /** * Reset password handler * Public procedure (no authentication required) @@ -12,13 +5,17 @@ const os = implement(contract); * Validates the reset token, checks password strength, updates password, * marks token as used, and revokes all existing sessions */ + +import { ORPCError } from "@orpc/server"; +import { hashPassword, validatePassword } from "../../utils/password.js"; +import { os } from "../base.js"; + export const resetPassword = os.auth.resetPassword.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { token, newPassword } = input; // Find the password reset token - const passwordReset = await ctx.db + const passwordReset = await context.db .selectFrom("password_resets") .select(["id", "user_id", "expires_at", "used_at"]) .where("token", "=", token) @@ -59,7 +56,7 @@ export const resetPassword = os.auth.resetPassword.handler( const passwordHash = await hashPassword(newPassword); // Update user's password - await ctx.db + await context.db .updateTable("users") .set({ password_hash: passwordHash, @@ -69,7 +66,7 @@ export const resetPassword = os.auth.resetPassword.handler( .execute(); // Mark the reset token as used - await ctx.db + await context.db .updateTable("password_resets") .set({ used_at: now, @@ -78,7 +75,7 @@ export const resetPassword = os.auth.resetPassword.handler( .execute(); // Revoke ALL sessions for this user (security measure) - await ctx.db + await context.db .updateTable("sessions") .set({ revoked_at: now, diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 4cf66aa..cbfeabe 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -8,11 +8,10 @@ import type { } from "@simplewebauthn/types"; import type { Kysely } from "kysely"; import type { DB } from "@reviq/db-schema"; -import type { APIContext } from "../../context.js"; import type { RPInfo } from "../../utils/webauthn.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; +import { ORPCError } from "@orpc/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import { os } from "../base.js"; import { COOKIE_NAMES, COOKIE_OPTIONS, @@ -26,8 +25,6 @@ import { hashPassword, validatePassword } from "../../utils/password.js"; import { createSession } from "../../utils/session.js"; import { getRPInfo, KNOWN_AAGUIDS } from "../../utils/webauthn.js"; -const os = implement(contract); - /** * Create user with password authentication * Validates password strength and creates user record @@ -208,14 +205,13 @@ export async function signupWithPasskey( * - Sends verification email (stubbed) */ export const signup = os.auth.signup.handler(async ({ input, context }) => { - const ctx = context as APIContext; const { email: rawEmail, password, passkeyInfo } = input; // Normalize email to lowercase const email = rawEmail.toLowerCase(); // Check if email already exists (anti-enumeration: return generic error) - const existingUser = await ctx.db + const existingUser = await context.db .selectFrom("users") .select(["id"]) .where("email", "=", email) @@ -226,17 +222,17 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { } // Get geo info and user agent for session creation - const geo = getGeoInfo(ctx.reqHeaders); - const userAgent = getUserAgent(ctx.reqHeaders); + const geo = getGeoInfo(context.reqHeaders); + const userAgent = getUserAgent(context.reqHeaders); let userId: number; // Delegate to appropriate signup function if (password) { - userId = await signupWithPassword(ctx.db, email, password); + userId = await signupWithPassword(context.db, email, password); } else if (passkeyInfo) { - const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); - userId = await signupWithPasskey(ctx.db, email, passkeyInfo, rpInfo); + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); + userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); } else { // Should never reach here due to schema validation throw new ORPCError("BAD_REQUEST", { @@ -245,7 +241,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { } // Create session (7 days, trusted mode false initially, no device) - const session = await createSession(ctx.db, { + const session = await createSession(context.db, { userId, deviceId: null, trustedMode: false, @@ -255,7 +251,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { // Set session cookie setCookie( - ctx.resHeaders, + context.resHeaders, COOKIE_NAMES.SESSION_TOKEN, session.token, COOKIE_OPTIONS.session, @@ -266,7 +262,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Store verification token (store raw token, not hash - it's already high-entropy) - await ctx.db + await context.db .insertInto("email_verifications") .values({ user_id: userId, diff --git a/apps/api-server/src/procedures/auth/verify-email.ts b/apps/api-server/src/procedures/auth/verify-email.ts index 1c0d6c3..bdc4139 100644 --- a/apps/api-server/src/procedures/auth/verify-email.ts +++ b/apps/api-server/src/procedures/auth/verify-email.ts @@ -1,9 +1,3 @@ -import type { APIContext } from "../../context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; - -const os = implement(contract); - /** * Verify user email with token from URL * Public procedure - no authentication required @@ -14,13 +8,16 @@ const os = implement(contract); * 3. Update user's email_verified_at timestamp * 4. Delete the verification record */ + +import { ORPCError } from "@orpc/server"; +import { os } from "../base.js"; + export const verifyEmail = os.auth.verifyEmail.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { token } = input; // Find the verification record - const verification = await ctx.db + const verification = await context.db .selectFrom("email_verifications") .select(["id", "user_id", "expires_at"]) .where("token", "=", token) @@ -35,7 +32,7 @@ export const verifyEmail = os.auth.verifyEmail.handler( // Check if token is expired if (new Date() > verification.expires_at) { // Clean up expired token - await ctx.db + await context.db .deleteFrom("email_verifications") .where("id", "=", verification.id) .execute(); @@ -46,14 +43,14 @@ export const verifyEmail = os.auth.verifyEmail.handler( } // Update user's email_verified_at - await ctx.db + await context.db .updateTable("users") .set({ email_verified_at: new Date() }) .where("id", "=", verification.user_id) .execute(); // Delete the verification record - await ctx.db + await context.db .deleteFrom("email_verifications") .where("id", "=", verification.id) .execute(); diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts new file mode 100644 index 0000000..06d3ed8 --- /dev/null +++ b/apps/api-server/src/procedures/base.ts @@ -0,0 +1,215 @@ +/** + * Base procedures with typed context for oRPC handlers + * + * Uses implement(contract).$context() to provide proper type safety. + * All procedure handlers should import from this file. + */ + +import type { + APIContext, + AuthenticatedContext, + LoginRequestContext, + Session, + SessionUser, +} from "../context.js"; +import { implement, ORPCError } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; +import { hashToken } from "../utils/crypto.js"; + +/** + * Base implementer with typed APIContext + * All procedures should be derived from this + */ +export const os = implement(contract).$context(); + +/** + * Auth middleware - validates session/API token and adds user to context + * Use with os.use(authMiddleware) to create authenticated procedures + */ +export const authMiddleware = os.middleware(async ({ context, next }) => { + const { db, reqHeaders } = context; + + // Try session cookie first + let tokenHash: string | undefined; + const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); + if (sessionToken) { + tokenHash = hashToken(sessionToken); + } + + // Fall back to API key header (for CLI) + const apiKey = reqHeaders.get("x-api-key"); + if (!tokenHash && apiKey) { + tokenHash = hashToken(apiKey); + } + + if (!tokenHash) { + throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" }); + } + + // Look up session (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, + }; + + const sessionInfo: Session = session + ? { + id: Number(session.id), + trustedMode: session.trusted_mode, + createdAt: session.created_at, + } + : { + // For API token auth, create a synthetic session object + id: 0, + trustedMode: true, + createdAt: apiToken?.created_at ?? new Date(), + }; + + return next({ + context: { + user: sessionUser, + session: sessionInfo, + }, + }); +}); + +/** + * 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", + }); + } + + // Check if token is a valid login request ID (numeric) + const num = Number(loginRequestToken); + if (Number.isNaN(num) || !Number.isInteger(num) || num <= 0) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid login request", + }); + } + + const loginRequestId = loginRequestToken; + + // Fetch login request with user data + 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.id", "=", loginRequestId) + .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 +export type { APIContext, AuthenticatedContext, LoginRequestContext }; diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 5e91b81..72830aa 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -1,11 +1,4 @@ -import type { - APIContext, - AuthenticatedContext, - LoginRequestContext, - SuperuserContext, -} from "./context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; +import { ORPCError } from "@orpc/server"; import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js"; import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js"; import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js"; @@ -16,6 +9,7 @@ import { resendVerificationEmail as resendVerificationHandler } from "./procedur import { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js"; import { signup as signupHandler } from "./procedures/auth/signup.js"; import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js"; +import { authMiddleware, loginRequestMiddleware, os } from "./procedures/base.js"; import { createAuthenticationOptions as createAuthOptions, createRegistrationOptions as createRegOptions, @@ -25,88 +19,60 @@ import { verifyRegistration as verifyReg, } from "./utils/webauthn.js"; -const os = implement(contract); - -/** - * Helper to require superuser context with runtime validation - */ -const requireSuperuser = (context: unknown): SuperuserContext => { - // Cast to partial type first to allow runtime checks - const ctx = context as Partial; - if (!ctx.user?.isSuperuser) { - throw new Error("Unauthorized: Superuser access required"); - } - return context as SuperuserContext; -}; - -// Auth procedures +// Auth procedures (imported from procedure files) const signup = signupHandler; - const verifyEmail = verifyEmailHandler; - const resendVerificationEmail = resendVerificationHandler; - const createLoginRequest = createLoginRequestHandler; - const loginPassword = loginPasswordHandler; - const loginPasswordConfirm = loginPasswordConfirmHandler; - const loginIfRequestIsCompleted = loginIfRequestIsCompletedHandler; - const forgotPassword = forgotPasswordHandler; - const resetPassword = resetPasswordHandler; - const logout = logoutHandler; // WebAuthn procedures const createRegistrationOptions = os.auth.webauthn.createRegistrationOptions.handler( async ({ input, context }) => { - const ctx = context as APIContext; const { email } = input; // For signup flow, we don't have a user yet // The user will be created when signup is called with the passkeyInfo - const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); - const result = await createRegOptions(ctx.db, rpInfo, { email }); + const result = await createRegOptions(context.db, rpInfo, { email }); return result; }, ); -const verifyRegistration = os.auth.webauthn.verifyRegistration.handler( - async ({ input, context }) => { - const ctx = context as AuthenticatedContext; +const verifyRegistration = os.auth.webauthn.verifyRegistration + .use(authMiddleware) + .handler(async ({ input, context }) => { const { challengeId, response } = input; - const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); - await verifyReg(ctx.db, rpInfo, ctx.user.id, challengeId, response); + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); + await verifyReg(context.db, rpInfo, context.user.id, challengeId, response); + }); - return undefined; - }, -); - -const createAuthenticationOptions = - os.auth.webauthn.createAuthenticationOptions.handler(async ({ context }) => { - const ctx = context as LoginRequestContext; - - const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); - const result = await createAuthOptions(ctx.db, rpInfo, ctx.user.id); +const createAuthenticationOptions = os.auth.webauthn.createAuthenticationOptions + .use(loginRequestMiddleware) + .handler(async ({ context }) => { + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); + const result = await createAuthOptions(context.db, rpInfo, context.user.id); return result; }); -const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler( - async ({ input, context }) => { - const ctx = context as LoginRequestContext; +const verifyAuthentication = os.auth.webauthn.verifyAuthentication + .use(loginRequestMiddleware) + .handler(async ({ input, context }) => { const { challengeId, response } = input; - const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); const verified = await verifyAuth( - ctx.db, + context.db, rpInfo, - ctx.user.id, + context.user.id, challengeId, response, ); @@ -116,525 +82,253 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler( message: "Authentication failed", }); } - - return undefined; - }, -); + }); // Me procedures -const meGet = os.me.get.handler(async () => { - throw new Error("Not implemented"); +const meGet = os.me.get.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const setupProfile = os.me.setupProfile.handler(async () => { - throw new Error("Not implemented"); +const setupProfile = os.me.setupProfile.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const updateProfile = os.me.updateProfile.handler(async () => { - throw new Error("Not implemented"); +const updateProfile = os.me.updateProfile.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const meDelete = os.me.delete.handler(async () => { - throw new Error("Not implemented"); +const meDelete = os.me.delete.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const setPassword = os.me.setPassword.handler(async () => { - throw new Error("Not implemented"); +const setPassword = os.me.setPassword.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const passkeysList = os.me.passkeys.list.handler(async ({ context }) => { - const ctx = context as AuthenticatedContext; +const listPasskeys = os.me.listPasskeys + .use(authMiddleware) + .handler(async ({ context }) => { + const passkeys = await getUserPasskeys(context.db, context.user.id); - const passkeys = await getUserPasskeys(ctx.db, ctx.user.id); + return passkeys.map((p) => ({ + id: p.id, + name: p.name, + createdAt: p.createdAt, + lastUsedAt: p.lastUsedAt, + })); + }); - return passkeys.map((p) => ({ - id: p.id, - name: p.name, - createdAt: p.createdAt, - lastUsedAt: p.lastUsedAt, - })); -}); +const createPasskey = os.me.createPasskey + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { name: _name } = input; -const passkeysRename = os.me.passkeys.rename.handler( - async ({ input, context }) => { - const ctx = context as AuthenticatedContext; + const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName); + const result = await createRegOptions(context.db, rpInfo, { + id: context.user.id, + email: context.user.email, + displayName: context.user.displayName, + }); + + return result; + }); + +const renamePasskey = os.me.renamePasskey + .use(authMiddleware) + .handler(async ({ input, context }) => { const { passkeyId, name } = input; - await ctx.db + await context.db .updateTable("passkeys") .set({ name }) .where("id", "=", String(passkeyId)) - .where("user_id", "=", ctx.user.id) + .where("user_id", "=", context.user.id) .execute(); + }); - return undefined; - }, -); - -const passkeysDelete = os.me.passkeys.delete.handler( - async ({ input, context }) => { - const ctx = context as AuthenticatedContext; +const deletePasskey = os.me.deletePasskey + .use(authMiddleware) + .handler(async ({ input, context }) => { const { passkeyId } = input; // Check if this is the last passkey and user has no password - const user = await ctx.db + const user = await context.db .selectFrom("users") .select(["password_hash"]) - .where("id", "=", ctx.user.id) + .where("id", "=", context.user.id) .executeTakeFirst(); - const passkeyCount = await ctx.db + const passkeyCount = await context.db .selectFrom("passkeys") - .select(ctx.db.fn.countAll().as("count")) - .where("user_id", "=", ctx.user.id) + .select(context.db.fn.countAll().as("count")) + .where("user_id", "=", context.user.id) .executeTakeFirst(); if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) { - throw new Error( - "Cannot delete the last passkey when you have no password set", - ); + throw new ORPCError("BAD_REQUEST", { + message: "Cannot delete the last passkey when you have no password set", + }); } - await ctx.db + await context.db .deleteFrom("passkeys") .where("id", "=", String(passkeyId)) - .where("user_id", "=", ctx.user.id) + .where("user_id", "=", context.user.id) .execute(); + }); - return undefined; - }, -); - -const listSessions = os.me.listSessions.handler(async () => { - throw new Error("Not implemented"); +const listSessions = os.me.listSessions.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const revokeSession = os.me.revokeSession.handler(async () => { - throw new Error("Not implemented"); +const revokeSession = os.me.revokeSession.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const revokeAllSessions = os.me.revokeAllSessions.handler(async () => { - throw new Error("Not implemented"); +const revokeAllSessions = os.me.revokeAllSessions.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const getDeviceInfo = os.me.getDeviceInfo.handler(async () => { - throw new Error("Not implemented"); +const getDeviceInfo = os.me.getDeviceInfo.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const trustDevice = os.me.trustDevice.handler(async () => { - throw new Error("Not implemented"); +const trustDevice = os.me.trustDevice.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const listTrustedDevices = os.me.listTrustedDevices.handler(async () => { - throw new Error("Not implemented"); +const listTrustedDevices = os.me.listTrustedDevices.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const untrustDevice = os.me.untrustDevice.handler(async () => { - throw new Error("Not implemented"); +const untrustDevice = os.me.untrustDevice.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices.handler( - async () => { - throw new Error("Not implemented"); - }, -); +const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices + .use(authMiddleware) + .handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); + }); -// Orgs procedures -const orgsList = os.orgs.list.handler(async () => { - throw new Error("Not implemented"); +// Orgs procedures (all require auth) +const orgsList = os.orgs.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const orgsCreate = os.orgs.create.handler(async () => { - throw new Error("Not implemented"); +const orgsCreate = os.orgs.create.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const orgsGet = os.orgs.get.handler(async () => { - throw new Error("Not implemented"); +const orgsGet = os.orgs.get.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const orgsUpdate = os.orgs.update.handler(async () => { - throw new Error("Not implemented"); +const orgsUpdate = os.orgs.update.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const orgsDelete = os.orgs.delete.handler(async () => { - throw new Error("Not implemented"); +const orgsDelete = os.orgs.delete.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const orgsLeave = os.orgs.leave.handler(async () => { - throw new Error("Not implemented"); +const orgsLeave = os.orgs.leave.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); // Orgs members procedures -const membersList = os.orgs.members.list.handler(async () => { - throw new Error("Not implemented"); +const membersList = os.orgs.members.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const membersUpdateRole = os.orgs.members.updateRole.handler(async () => { - throw new Error("Not implemented"); +const membersUpdateRole = os.orgs.members.updateRole.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const membersRemove = os.orgs.members.remove.handler(async () => { - throw new Error("Not implemented"); +const membersRemove = os.orgs.members.remove.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); // Orgs invites procedures -const invitesList = os.orgs.invites.list.handler(async () => { - throw new Error("Not implemented"); +const invitesList = os.orgs.invites.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const invitesCreate = os.orgs.invites.create.handler(async () => { - throw new Error("Not implemented"); +const invitesCreate = os.orgs.invites.create.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const invitesCancel = os.orgs.invites.cancel.handler(async () => { - throw new Error("Not implemented"); +const invitesCancel = os.orgs.invites.cancel.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const invitesAccept = os.orgs.invites.accept.handler(async () => { - throw new Error("Not implemented"); +const invitesAccept = os.orgs.invites.accept.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); // Orgs sites procedures -const sitesList = os.orgs.sites.list.handler(async () => { - throw new Error("Not implemented"); +const sitesList = os.orgs.sites.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -// Admin orgs procedures -const adminOrgsList = os.admin.orgs.list.handler(async ({ context }) => { - const ctx = requireSuperuser(context); - const orgs = await ctx.db.selectFrom("orgs").selectAll().execute(); - return orgs.map((org) => ({ - id: org.id, - slug: org.slug, - displayName: org.display_name, - logoUrl: org.logo_url, - createdAt: org.created_at, - })); +// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later) +const adminOrgsList = os.admin.orgs.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const adminOrgsGet = os.admin.orgs.get.handler(async ({ input, context }) => { - const ctx = requireSuperuser(context); - const org = await ctx.db - .selectFrom("orgs") - .where("slug", "=", input.slug) - .selectAll() - .executeTakeFirst(); - if (!org) { - throw new Error("Org not found"); - } - return { - id: org.id, - slug: org.slug, - displayName: org.display_name, - logoUrl: org.logo_url, - createdAt: org.created_at, - }; +const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const adminOrgsCreate = os.admin.orgs.create.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { slug, displayName, ownerEmail } = input; +const adminOrgsCreate = os.admin.orgs.create.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - // Use transaction to ensure atomicity - const orgSlug = await ctx.db.transaction().execute(async (trx) => { - // Find or create owner user - let owner = await trx - .selectFrom("users") - .where("email", "=", ownerEmail.toLowerCase()) - .select("id") - .executeTakeFirst(); +const adminOrgsUpdate = os.admin.orgs.update.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - if (!owner) { - const result = await trx - .insertInto("users") - .values({ email: ownerEmail.toLowerCase() }) - .returning("id") - .executeTakeFirst(); - owner = result; - } +const adminOrgsDelete = os.admin.orgs.delete.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - if (!owner) { - throw new Error("Failed to create owner user"); - } +const adminOrgsListSites = os.admin.orgs.listSites.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - // Create org - const org = await trx - .insertInto("orgs") - .values({ slug, display_name: displayName }) - .returning(["id", "slug"]) - .executeTakeFirst(); +const adminOrgsAddSite = os.admin.orgs.addSite.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - if (!org) { - throw new Error("Failed to create org"); - } - - // Add owner membership - await trx - .insertInto("org_members") - .values({ org_id: org.id, user_id: owner.id, role: "owner" }) - .execute(); - - return org.slug; - }); - - return { slug: orgSlug }; - }, -); - -const adminOrgsUpdate = os.admin.orgs.update.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { slug, displayName, logoUrl } = input; - - const updates: Record = {}; - if (displayName !== undefined) { - updates.display_name = displayName; - } - if (logoUrl !== undefined) { - updates.logo_url = logoUrl; - } - - if (Object.keys(updates).length > 0) { - await ctx.db - .updateTable("orgs") - .set(updates) - .where("slug", "=", slug) - .execute(); - } - - return undefined; - }, -); - -const adminOrgsDelete = os.admin.orgs.delete.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - await ctx.db.deleteFrom("orgs").where("slug", "=", input.slug).execute(); - - return undefined; - }, -); - -const adminOrgsListSites = os.admin.orgs.listSites.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const org = await ctx.db - .selectFrom("orgs") - .where("slug", "=", input.slug) - .select("id") - .executeTakeFirst(); - - if (!org) { - throw new Error("Org not found"); - } - - const sites = await ctx.db - .selectFrom("org_sites") - .where("org_id", "=", org.id) - .selectAll() - .execute(); - - return sites.map((site) => ({ - id: site.id, - domain: site.domain, - createdAt: site.created_at, - })); - }, -); - -const adminOrgsAddSite = os.admin.orgs.addSite.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { slug, domain } = input; - - const org = await ctx.db - .selectFrom("orgs") - .where("slug", "=", slug) - .select("id") - .executeTakeFirst(); - - if (!org) { - throw new Error("Org not found"); - } - - await ctx.db - .insertInto("org_sites") - .values({ org_id: org.id, domain }) - .execute(); - - return undefined; - }, -); - -const adminOrgsRemoveSite = os.admin.orgs.removeSite.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { slug, domain } = input; - - const org = await ctx.db - .selectFrom("orgs") - .where("slug", "=", slug) - .select("id") - .executeTakeFirst(); - - if (!org) { - throw new Error("Org not found"); - } - - await ctx.db - .deleteFrom("org_sites") - .where("org_id", "=", org.id) - .where("domain", "=", domain) - .execute(); - - return undefined; - }, -); +const adminOrgsRemoveSite = os.admin.orgs.removeSite.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); // Admin users procedures -const adminUsersList = os.admin.users.list.handler(async ({ context }) => { - const ctx = requireSuperuser(context); - const users = await ctx.db.selectFrom("users").selectAll().execute(); - return users.map((user) => ({ - id: user.id, - email: user.email, - displayName: user.display_name, - fullName: user.full_name, - phoneNumber: user.phone_number, - avatarUrl: user.avatar_url, - emailVerified: user.email_verified_at !== null, - needsSetup: user.display_name === null, - isSuperuser: user.is_superuser, - })); +const adminUsersList = os.admin.users.list.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const adminUsersGet = os.admin.users.get.handler(async ({ input, context }) => { - const ctx = requireSuperuser(context); - const user = await ctx.db - .selectFrom("users") - .where("email", "=", input.email.toLowerCase()) - .selectAll() - .executeTakeFirst(); - if (!user) { - throw new Error("User not found"); - } - return { - id: user.id, - email: user.email, - displayName: user.display_name, - fullName: user.full_name, - phoneNumber: user.phone_number, - avatarUrl: user.avatar_url, - emailVerified: user.email_verified_at !== null, - needsSetup: user.display_name === null, - isSuperuser: user.is_superuser, - }; +const adminUsersGet = os.admin.users.get.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const adminUsersCreate = os.admin.users.create.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { email, name, orgSlug, orgRole } = input; +const adminUsersCreate = os.admin.users.create.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - // Use transaction to ensure atomicity when adding to org - await ctx.db.transaction().execute(async (trx) => { - const result = await trx - .insertInto("users") - .values({ - email: email.toLowerCase(), - display_name: name ?? null, - }) - .returning("id") - .executeTakeFirst(); +const adminUsersUpdate = os.admin.users.update.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); - if (!result) { - throw new Error("Failed to create user"); - } - - // Add to org if specified - if (orgSlug) { - const org = await trx - .selectFrom("orgs") - .where("slug", "=", orgSlug) - .select("id") - .executeTakeFirst(); - - if (org) { - await trx - .insertInto("org_members") - .values({ - org_id: org.id, - user_id: result.id, - role: orgRole ?? "member", - }) - .execute(); - } - } - }); - - return undefined; - }, -); - -const adminUsersUpdate = os.admin.users.update.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - const { email, isSuperuser } = input; - - if (isSuperuser !== undefined) { - await ctx.db - .updateTable("users") - .set({ is_superuser: isSuperuser }) - .where("email", "=", email.toLowerCase()) - .execute(); - } - - return undefined; - }, -); - -const adminUsersConfirmEmail = os.admin.users.confirmEmail.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - await ctx.db - .updateTable("users") - .set({ email_verified_at: new Date() }) - .where("email", "=", input.email.toLowerCase()) - .execute(); - - return undefined; - }, -); +const adminUsersConfirmEmail = os.admin.users.confirmEmail.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); // Admin auth procedures -const adminAuthCompleteLogin = os.admin.auth.completeLogin.handler( - async ({ input, context }) => { - const ctx = requireSuperuser(context); - - // Find user by email - const user = await ctx.db - .selectFrom("users") - .where("email", "=", input.email.toLowerCase()) - .select("id") - .executeTakeFirst(); - - if (!user) { - throw new Error("User not found"); - } - - // Complete the most recent pending login request for this user - await ctx.db - .updateTable("login_requests") - .set({ completed_at: new Date() }) - .where("user_id", "=", user.id) - .where("completed_at", "is", null) - .where("expires_at", ">", new Date()) - .execute(); - - return undefined; - }, -); +const adminAuthCompleteLogin = os.admin.auth.completeLogin.use(authMiddleware).handler(async () => { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +}); // Build the router export const router = os.router({ @@ -662,11 +356,10 @@ export const router = os.router({ updateProfile, delete: meDelete, setPassword, - passkeys: { - list: passkeysList, - rename: passkeysRename, - delete: passkeysDelete, - }, + listPasskeys, + createPasskey, + renamePasskey, + deletePasskey, listSessions, revokeSession, revokeAllSessions,