Merge branch 'wt2': Add typed context and middleware pattern
- Add procedures/base.ts with typed os implementer and middlewares - Refactor router to use authMiddleware and loginRequestMiddleware - Flatten passkey routes to me.listPasskeys/createPasskey/renamePasskey/deletePasskey - Stub admin procedures with NOT_IMPLEMENTED (to be reimplemented with superuser middleware) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,6 @@
|
|||||||
* First step in the login flow - validates email and returns available auth methods
|
* 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 {
|
import {
|
||||||
COOKIE_DURATIONS,
|
COOKIE_DURATIONS,
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
@@ -19,8 +16,7 @@ import {
|
|||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||||
import { isDeviceTrusted } from "../../utils/session.js";
|
import { isDeviceTrusted } from "../../utils/session.js";
|
||||||
|
import { os } from "../base.js";
|
||||||
const os = implement(contract);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create login request handler
|
* Create login request handler
|
||||||
@@ -33,7 +29,6 @@ const os = implement(contract);
|
|||||||
*/
|
*/
|
||||||
export const createLoginRequest = os.auth.createLoginRequest.handler(
|
export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { email: rawEmail } = input;
|
const { email: rawEmail } = input;
|
||||||
|
|
||||||
// Normalize email to lowercase
|
// Normalize email to lowercase
|
||||||
@@ -41,14 +36,14 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
|
|
||||||
// Read or generate device fingerprint
|
// Read or generate device fingerprint
|
||||||
let deviceFingerprint = getCookie(
|
let deviceFingerprint = getCookie(
|
||||||
ctx.reqHeaders,
|
context.reqHeaders,
|
||||||
COOKIE_NAMES.DEVICE_FINGERPRINT,
|
COOKIE_NAMES.DEVICE_FINGERPRINT,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!deviceFingerprint) {
|
if (!deviceFingerprint) {
|
||||||
deviceFingerprint = generateDeviceFingerprint();
|
deviceFingerprint = generateDeviceFingerprint();
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.resHeaders,
|
context.resHeaders,
|
||||||
COOKIE_NAMES.DEVICE_FINGERPRINT,
|
COOKIE_NAMES.DEVICE_FINGERPRINT,
|
||||||
deviceFingerprint,
|
deviceFingerprint,
|
||||||
COOKIE_OPTIONS.device,
|
COOKIE_OPTIONS.device,
|
||||||
@@ -56,7 +51,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Look up user by email
|
// Look up user by email
|
||||||
const user = await ctx.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["id", "password_hash"])
|
.select(["id", "password_hash"])
|
||||||
.where("email", "=", email)
|
.where("email", "=", email)
|
||||||
@@ -70,7 +65,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
|
|
||||||
// Set placeholder login request token cookie
|
// Set placeholder login request token cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.resHeaders,
|
context.resHeaders,
|
||||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||||
placeholderToken,
|
placeholderToken,
|
||||||
COOKIE_OPTIONS.loginRequest,
|
COOKIE_OPTIONS.loginRequest,
|
||||||
@@ -89,13 +84,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
|
|
||||||
// Check if device is trusted
|
// Check if device is trusted
|
||||||
const isTrustedDevice = await isDeviceTrusted(
|
const isTrustedDevice = await isDeviceTrusted(
|
||||||
ctx.db,
|
context.db,
|
||||||
userId,
|
userId,
|
||||||
deviceFingerprint,
|
deviceFingerprint,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if user has passkey
|
// Check if user has passkey
|
||||||
const passkey = await ctx.db
|
const passkey = await context.db
|
||||||
.selectFrom("passkeys")
|
.selectFrom("passkeys")
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
@@ -106,13 +101,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
const hasPassword = user.password_hash !== null;
|
const hasPassword = user.password_hash !== null;
|
||||||
|
|
||||||
// Get geo info and user agent
|
// Get geo info and user agent
|
||||||
const geo = getGeoInfo(ctx.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders);
|
||||||
const userAgent = getUserAgent(ctx.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Create login request
|
// Create login request
|
||||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
||||||
|
|
||||||
const loginRequest = await ctx.db
|
const loginRequest = await context.db
|
||||||
.insertInto("login_requests")
|
.insertInto("login_requests")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
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
|
// Set login request token cookie with the real login request ID
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.resHeaders,
|
context.resHeaders,
|
||||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||||
loginRequestId,
|
loginRequestId,
|
||||||
COOKIE_OPTIONS.loginRequest,
|
COOKIE_OPTIONS.loginRequest,
|
||||||
|
|||||||
@@ -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
|
* Forgot password handler
|
||||||
* Public procedure (no authentication required)
|
* Public procedure (no authentication required)
|
||||||
@@ -14,16 +5,21 @@ const os = implement(contract);
|
|||||||
* Anti-enumeration: Always returns success even if user doesn't exist
|
* Anti-enumeration: Always returns success even if user doesn't exist
|
||||||
* This prevents attackers from determining which emails are registered
|
* 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(
|
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Normalize email to lowercase
|
// Normalize email to lowercase
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
// Look up user by email
|
// Look up user by email
|
||||||
const user = await ctx.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["id", "email"])
|
.select(["id", "email"])
|
||||||
.where("email", "=", normalizedEmail)
|
.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 exists, create password reset token and send email
|
||||||
if (user) {
|
if (user) {
|
||||||
// Delete any existing password reset tokens for this user (security measure)
|
// Delete any existing password reset tokens for this user (security measure)
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("password_resets")
|
.deleteFrom("password_resets")
|
||||||
.where("user_id", "=", user.id)
|
.where("user_id", "=", user.id)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -43,7 +39,7 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
|||||||
// 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 ctx.db
|
await context.db
|
||||||
.insertInto("password_resets")
|
.insertInto("password_resets")
|
||||||
.values({
|
.values({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
|||||||
@@ -16,9 +16,6 @@
|
|||||||
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
* 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 {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
COOKIE_OPTIONS,
|
COOKIE_OPTIONS,
|
||||||
@@ -32,8 +29,7 @@ import {
|
|||||||
isDeviceTrusted,
|
isDeviceTrusted,
|
||||||
upsertUserDevice,
|
upsertUserDevice,
|
||||||
} from "../../utils/session.js";
|
} from "../../utils/session.js";
|
||||||
|
import { os } from "../base.js";
|
||||||
const os = implement(contract);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a string looks like a UUID (fake token)
|
* Check if a string looks like a UUID (fake token)
|
||||||
@@ -50,11 +46,9 @@ const isUUID = (str: string): boolean => {
|
|||||||
*/
|
*/
|
||||||
export const loginIfRequestIsCompleted =
|
export const loginIfRequestIsCompleted =
|
||||||
os.auth.loginIfRequestIsCompleted.handler(async ({ context }) => {
|
os.auth.loginIfRequestIsCompleted.handler(async ({ context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
|
|
||||||
// Read login request token from cookie
|
// Read login request token from cookie
|
||||||
const loginRequestToken = getCookie(
|
const loginRequestToken = getCookie(
|
||||||
ctx.reqHeaders,
|
context.reqHeaders,
|
||||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,7 +72,7 @@ export const loginIfRequestIsCompleted =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch login request from database
|
// Fetch login request from database
|
||||||
const loginRequest = await ctx.db
|
const loginRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.select([
|
.select([
|
||||||
"id",
|
"id",
|
||||||
@@ -115,12 +109,12 @@ export const loginIfRequestIsCompleted =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current request info
|
// Get current request info
|
||||||
const geo = getGeoInfo(ctx.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders);
|
||||||
const userAgent = getUserAgent(ctx.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Upsert user device
|
// Upsert user device
|
||||||
const deviceId = await upsertUserDevice(
|
const deviceId = await upsertUserDevice(
|
||||||
ctx.db,
|
context.db,
|
||||||
userId,
|
userId,
|
||||||
deviceFingerprint,
|
deviceFingerprint,
|
||||||
geo,
|
geo,
|
||||||
@@ -129,13 +123,13 @@ export const loginIfRequestIsCompleted =
|
|||||||
|
|
||||||
// Check if device is already trusted
|
// Check if device is already trusted
|
||||||
const deviceTrusted = await isDeviceTrusted(
|
const deviceTrusted = await isDeviceTrusted(
|
||||||
ctx.db,
|
context.db,
|
||||||
userId,
|
userId,
|
||||||
deviceFingerprint,
|
deviceFingerprint,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create session with trusted mode = true (email-confirmed login)
|
// Create session with trusted mode = true (email-confirmed login)
|
||||||
const session = await createSession(ctx.db, {
|
const session = await createSession(context.db, {
|
||||||
userId,
|
userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
trustedMode: true,
|
trustedMode: true,
|
||||||
@@ -144,21 +138,21 @@ export const loginIfRequestIsCompleted =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete the login request (it's been consumed)
|
// Delete the login request (it's been consumed)
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("login_requests")
|
.deleteFrom("login_requests")
|
||||||
.where("id", "=", String(loginRequestId))
|
.where("id", "=", String(loginRequestId))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.resHeaders,
|
context.resHeaders,
|
||||||
COOKIE_NAMES.SESSION_TOKEN,
|
COOKIE_NAMES.SESSION_TOKEN,
|
||||||
session.token,
|
session.token,
|
||||||
COOKIE_OPTIONS.session,
|
COOKIE_OPTIONS.session,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear login request cookie
|
// 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
|
// Determine redirect path based on device trust status
|
||||||
const redirectTo = deviceTrusted ? "/dashboard" : "/auth/trust-device";
|
const redirectTo = deviceTrusted ? "/dashboard" : "/auth/trust-device";
|
||||||
|
|||||||
@@ -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
|
* Confirm password login via email link token
|
||||||
* Public procedure - no authentication required
|
* 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
|
* This is called when user clicks the confirmation link in their email
|
||||||
* for untrusted device login attempts.
|
* for untrusted device login attempts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { os } from "../base.js";
|
||||||
|
|
||||||
export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(
|
export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { token } = input;
|
const { token } = input;
|
||||||
|
|
||||||
// Find the login request by token
|
// Find the login request by token
|
||||||
const loginRequest = await ctx.db
|
const loginRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.select(["id", "expires_at", "completed_at"])
|
.select(["id", "expires_at", "completed_at"])
|
||||||
.where("token", "=", token)
|
.where("token", "=", token)
|
||||||
@@ -48,7 +45,7 @@ export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark as completed
|
// Mark as completed
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", loginRequest.id)
|
.where("id", "=", loginRequest.id)
|
||||||
|
|||||||
@@ -3,16 +3,13 @@
|
|||||||
* Second step in the login flow - verifies password and completes/confirms login
|
* Second step in the login flow - verifies password and completes/confirms login
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { APIContext } from "../../context.js";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { implement, ORPCError } from "@orpc/server";
|
|
||||||
import { contract } from "@reviq/api-contract";
|
|
||||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||||
import { generateSecureToken } from "../../utils/crypto.js";
|
import { generateSecureToken } from "../../utils/crypto.js";
|
||||||
import { sendLoginConfirmationEmail } from "../../utils/email.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";
|
||||||
const os = implement(contract);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a string is a valid login request ID (numeric)
|
* 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(
|
export const loginPassword = os.auth.loginPassword.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { password } = input;
|
const { password } = input;
|
||||||
|
|
||||||
// Read login request token from cookie
|
// Read login request token from cookie
|
||||||
const loginRequestToken = getCookie(
|
const loginRequestToken = getCookie(
|
||||||
ctx.reqHeaders,
|
context.reqHeaders,
|
||||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,7 +61,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
const loginRequestId = loginRequestToken;
|
const loginRequestId = loginRequestToken;
|
||||||
|
|
||||||
// Fetch login request with user data in single query (optimized JOIN)
|
// Fetch login request with user data in single query (optimized JOIN)
|
||||||
const result = await ctx.db
|
const result = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||||
.select([
|
.select([
|
||||||
@@ -114,12 +110,12 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
// Password is valid - check if device is trusted
|
// Password is valid - check if device is trusted
|
||||||
// If no device fingerprint, treat as untrusted
|
// If no device fingerprint, treat as untrusted
|
||||||
const deviceTrusted = result.device_fingerprint
|
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;
|
: false;
|
||||||
|
|
||||||
if (deviceTrusted) {
|
if (deviceTrusted) {
|
||||||
// Device is trusted - complete login immediately
|
// Device is trusted - complete login immediately
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({
|
.set({
|
||||||
completed_at: new Date(),
|
completed_at: new Date(),
|
||||||
@@ -130,7 +126,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
// Device is untrusted - generate confirmation token and send email
|
// Device is untrusted - generate confirmation token and send email
|
||||||
const confirmationToken = generateSecureToken();
|
const confirmationToken = generateSecureToken();
|
||||||
|
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({
|
.set({
|
||||||
token: confirmationToken,
|
token: confirmationToken,
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
* Logout procedure - revokes the current session and clears the session cookie
|
* 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";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
const os = implement(contract);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout handler
|
* Logout handler
|
||||||
@@ -15,18 +11,16 @@ const os = implement(contract);
|
|||||||
* - 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.handler(
|
export const logout = os.auth.logout
|
||||||
async ({ context }: { context: unknown }) => {
|
.use(authMiddleware)
|
||||||
const ctx = context as AuthenticatedContext;
|
.handler(async ({ context }) => {
|
||||||
|
|
||||||
// Revoke the current session
|
// Revoke the current session
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
.set({ revoked_at: new Date() })
|
.set({ revoked_at: new Date() })
|
||||||
.where("id", "=", String(ctx.session.id))
|
.where("id", "=", String(context.session.id))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Clear the session cookie
|
// Clear the session cookie
|
||||||
deleteCookie(ctx.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|||||||
@@ -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
|
* Resend email verification to authenticated user
|
||||||
* Requires authentication
|
* Requires authentication
|
||||||
@@ -18,20 +9,25 @@ const os = implement(contract);
|
|||||||
* 4. Create new email_verifications record with 24 hour expiry
|
* 4. Create new email_verifications record with 24 hour expiry
|
||||||
* 5. Send verification email (stubbed)
|
* 5. Send verification email (stubbed)
|
||||||
*/
|
*/
|
||||||
export const resendVerificationEmail = os.auth.resendVerificationEmail.handler(
|
|
||||||
async ({ context }) => {
|
|
||||||
const ctx = context as AuthenticatedContext;
|
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
// Check if email is already verified
|
// Check if email is already verified
|
||||||
if (ctx.user.emailVerifiedAt !== null) {
|
if (context.user.emailVerifiedAt !== null) {
|
||||||
// Email already verified, return early
|
// Email already verified, return early
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete any existing verification tokens for this user
|
// Delete any existing verification tokens for this user
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("email_verifications")
|
.deleteFrom("email_verifications")
|
||||||
.where("user_id", "=", ctx.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Generate new secure token
|
// Generate new secure token
|
||||||
@@ -39,16 +35,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail.handler(
|
|||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||||
|
|
||||||
// Create new verification record
|
// Create new verification record
|
||||||
await ctx.db
|
await context.db
|
||||||
.insertInto("email_verifications")
|
.insertInto("email_verifications")
|
||||||
.values({
|
.values({
|
||||||
user_id: ctx.user.id,
|
user_id: context.user.id,
|
||||||
token,
|
token,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
// Send verification email (stubbed)
|
||||||
await sendVerificationEmail(ctx.user.email, token);
|
await sendVerificationEmail(context.user.email, token);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|||||||
@@ -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
|
* Reset password handler
|
||||||
* Public procedure (no authentication required)
|
* Public procedure (no authentication required)
|
||||||
@@ -12,13 +5,17 @@ const os = implement(contract);
|
|||||||
* Validates the reset token, checks password strength, updates password,
|
* Validates the reset token, checks password strength, updates password,
|
||||||
* marks token as used, and revokes all existing sessions
|
* 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(
|
export const resetPassword = os.auth.resetPassword.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { token, newPassword } = input;
|
const { token, newPassword } = input;
|
||||||
|
|
||||||
// Find the password reset token
|
// Find the password reset token
|
||||||
const passwordReset = await ctx.db
|
const passwordReset = await context.db
|
||||||
.selectFrom("password_resets")
|
.selectFrom("password_resets")
|
||||||
.select(["id", "user_id", "expires_at", "used_at"])
|
.select(["id", "user_id", "expires_at", "used_at"])
|
||||||
.where("token", "=", token)
|
.where("token", "=", token)
|
||||||
@@ -59,7 +56,7 @@ export const resetPassword = os.auth.resetPassword.handler(
|
|||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
// Update user's password
|
// Update user's password
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set({
|
.set({
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
@@ -69,7 +66,7 @@ export const resetPassword = os.auth.resetPassword.handler(
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Mark the reset token as used
|
// Mark the reset token as used
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("password_resets")
|
.updateTable("password_resets")
|
||||||
.set({
|
.set({
|
||||||
used_at: now,
|
used_at: now,
|
||||||
@@ -78,7 +75,7 @@ export const resetPassword = os.auth.resetPassword.handler(
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Revoke ALL sessions for this user (security measure)
|
// Revoke ALL sessions for this user (security measure)
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
.set({
|
.set({
|
||||||
revoked_at: now,
|
revoked_at: now,
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import type {
|
|||||||
} from "@simplewebauthn/types";
|
} from "@simplewebauthn/types";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import type { DB } from "@reviq/db-schema";
|
import type { DB } from "@reviq/db-schema";
|
||||||
import type { APIContext } from "../../context.js";
|
|
||||||
import type { RPInfo } from "../../utils/webauthn.js";
|
import type { RPInfo } from "../../utils/webauthn.js";
|
||||||
import { implement, ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { contract } from "@reviq/api-contract";
|
|
||||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||||
|
import { os } from "../base.js";
|
||||||
import {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
COOKIE_OPTIONS,
|
COOKIE_OPTIONS,
|
||||||
@@ -26,8 +25,6 @@ import { hashPassword, validatePassword } from "../../utils/password.js";
|
|||||||
import { createSession } from "../../utils/session.js";
|
import { createSession } from "../../utils/session.js";
|
||||||
import { getRPInfo, KNOWN_AAGUIDS } from "../../utils/webauthn.js";
|
import { getRPInfo, KNOWN_AAGUIDS } from "../../utils/webauthn.js";
|
||||||
|
|
||||||
const os = implement(contract);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create user with password authentication
|
* Create user with password authentication
|
||||||
* Validates password strength and creates user record
|
* Validates password strength and creates user record
|
||||||
@@ -208,14 +205,13 @@ export async function signupWithPasskey(
|
|||||||
* - Sends verification email (stubbed)
|
* - Sends verification email (stubbed)
|
||||||
*/
|
*/
|
||||||
export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { email: rawEmail, password, passkeyInfo } = input;
|
const { email: rawEmail, password, passkeyInfo } = input;
|
||||||
|
|
||||||
// Normalize email to lowercase
|
// Normalize email to lowercase
|
||||||
const email = rawEmail.toLowerCase();
|
const email = rawEmail.toLowerCase();
|
||||||
|
|
||||||
// Check if email already exists (anti-enumeration: return generic error)
|
// Check if email already exists (anti-enumeration: return generic error)
|
||||||
const existingUser = await ctx.db
|
const existingUser = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.where("email", "=", email)
|
.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
|
// Get geo info and user agent for session creation
|
||||||
const geo = getGeoInfo(ctx.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders);
|
||||||
const userAgent = getUserAgent(ctx.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
let userId: number;
|
let userId: number;
|
||||||
|
|
||||||
// Delegate to appropriate signup function
|
// Delegate to appropriate signup function
|
||||||
if (password) {
|
if (password) {
|
||||||
userId = await signupWithPassword(ctx.db, email, password);
|
userId = await signupWithPassword(context.db, email, password);
|
||||||
} else if (passkeyInfo) {
|
} else if (passkeyInfo) {
|
||||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName);
|
||||||
userId = await signupWithPasskey(ctx.db, email, passkeyInfo, rpInfo);
|
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||||
} else {
|
} else {
|
||||||
// Should never reach here due to schema validation
|
// Should never reach here due to schema validation
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
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)
|
// Create session (7 days, trusted mode false initially, no device)
|
||||||
const session = await createSession(ctx.db, {
|
const session = await createSession(context.db, {
|
||||||
userId,
|
userId,
|
||||||
deviceId: null,
|
deviceId: null,
|
||||||
trustedMode: false,
|
trustedMode: false,
|
||||||
@@ -255,7 +251,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.resHeaders,
|
context.resHeaders,
|
||||||
COOKIE_NAMES.SESSION_TOKEN,
|
COOKIE_NAMES.SESSION_TOKEN,
|
||||||
session.token,
|
session.token,
|
||||||
COOKIE_OPTIONS.session,
|
COOKIE_OPTIONS.session,
|
||||||
@@ -266,7 +262,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||||
|
|
||||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||||
await ctx.db
|
await context.db
|
||||||
.insertInto("email_verifications")
|
.insertInto("email_verifications")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
|||||||
@@ -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
|
* Verify user email with token from URL
|
||||||
* Public procedure - no authentication required
|
* Public procedure - no authentication required
|
||||||
@@ -14,13 +8,16 @@ const os = implement(contract);
|
|||||||
* 3. Update user's email_verified_at timestamp
|
* 3. Update user's email_verified_at timestamp
|
||||||
* 4. Delete the verification record
|
* 4. Delete the verification record
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { os } from "../base.js";
|
||||||
|
|
||||||
export const verifyEmail = os.auth.verifyEmail.handler(
|
export const verifyEmail = os.auth.verifyEmail.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { token } = input;
|
const { token } = input;
|
||||||
|
|
||||||
// Find the verification record
|
// Find the verification record
|
||||||
const verification = await ctx.db
|
const verification = await context.db
|
||||||
.selectFrom("email_verifications")
|
.selectFrom("email_verifications")
|
||||||
.select(["id", "user_id", "expires_at"])
|
.select(["id", "user_id", "expires_at"])
|
||||||
.where("token", "=", token)
|
.where("token", "=", token)
|
||||||
@@ -35,7 +32,7 @@ export const verifyEmail = os.auth.verifyEmail.handler(
|
|||||||
// Check if token is expired
|
// Check if token is expired
|
||||||
if (new Date() > verification.expires_at) {
|
if (new Date() > verification.expires_at) {
|
||||||
// Clean up expired token
|
// Clean up expired token
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("email_verifications")
|
.deleteFrom("email_verifications")
|
||||||
.where("id", "=", verification.id)
|
.where("id", "=", verification.id)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -46,14 +43,14 @@ export const verifyEmail = os.auth.verifyEmail.handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update user's email_verified_at
|
// Update user's email_verified_at
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set({ email_verified_at: new Date() })
|
.set({ email_verified_at: new Date() })
|
||||||
.where("id", "=", verification.user_id)
|
.where("id", "=", verification.user_id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Delete the verification record
|
// Delete the verification record
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("email_verifications")
|
.deleteFrom("email_verifications")
|
||||||
.where("id", "=", verification.id)
|
.where("id", "=", verification.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
215
apps/api-server/src/procedures/base.ts
Normal file
215
apps/api-server/src/procedures/base.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Base procedures with typed context for oRPC handlers
|
||||||
|
*
|
||||||
|
* Uses implement(contract).$context<T>() 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<APIContext>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import type {
|
import { ORPCError } from "@orpc/server";
|
||||||
APIContext,
|
|
||||||
AuthenticatedContext,
|
|
||||||
LoginRequestContext,
|
|
||||||
SuperuserContext,
|
|
||||||
} from "./context.js";
|
|
||||||
import { implement, ORPCError } from "@orpc/server";
|
|
||||||
import { contract } from "@reviq/api-contract";
|
|
||||||
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
||||||
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
||||||
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.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 { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js";
|
||||||
import { signup as signupHandler } from "./procedures/auth/signup.js";
|
import { signup as signupHandler } from "./procedures/auth/signup.js";
|
||||||
import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js";
|
import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js";
|
||||||
|
import { authMiddleware, loginRequestMiddleware, os } from "./procedures/base.js";
|
||||||
import {
|
import {
|
||||||
createAuthenticationOptions as createAuthOptions,
|
createAuthenticationOptions as createAuthOptions,
|
||||||
createRegistrationOptions as createRegOptions,
|
createRegistrationOptions as createRegOptions,
|
||||||
@@ -25,88 +19,60 @@ import {
|
|||||||
verifyRegistration as verifyReg,
|
verifyRegistration as verifyReg,
|
||||||
} from "./utils/webauthn.js";
|
} from "./utils/webauthn.js";
|
||||||
|
|
||||||
const os = implement(contract);
|
// Auth procedures (imported from procedure files)
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<SuperuserContext>;
|
|
||||||
if (!ctx.user?.isSuperuser) {
|
|
||||||
throw new Error("Unauthorized: Superuser access required");
|
|
||||||
}
|
|
||||||
return context as SuperuserContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auth procedures
|
|
||||||
const signup = signupHandler;
|
const signup = signupHandler;
|
||||||
|
|
||||||
const verifyEmail = verifyEmailHandler;
|
const verifyEmail = verifyEmailHandler;
|
||||||
|
|
||||||
const resendVerificationEmail = resendVerificationHandler;
|
const resendVerificationEmail = resendVerificationHandler;
|
||||||
|
|
||||||
const createLoginRequest = createLoginRequestHandler;
|
const createLoginRequest = createLoginRequestHandler;
|
||||||
|
|
||||||
const loginPassword = loginPasswordHandler;
|
const loginPassword = loginPasswordHandler;
|
||||||
|
|
||||||
const loginPasswordConfirm = loginPasswordConfirmHandler;
|
const loginPasswordConfirm = loginPasswordConfirmHandler;
|
||||||
|
|
||||||
const loginIfRequestIsCompleted = loginIfRequestIsCompletedHandler;
|
const loginIfRequestIsCompleted = loginIfRequestIsCompletedHandler;
|
||||||
|
|
||||||
const forgotPassword = forgotPasswordHandler;
|
const forgotPassword = forgotPasswordHandler;
|
||||||
|
|
||||||
const resetPassword = resetPasswordHandler;
|
const resetPassword = resetPasswordHandler;
|
||||||
|
|
||||||
const logout = logoutHandler;
|
const logout = logoutHandler;
|
||||||
|
|
||||||
// WebAuthn procedures
|
// WebAuthn procedures
|
||||||
const createRegistrationOptions =
|
const createRegistrationOptions =
|
||||||
os.auth.webauthn.createRegistrationOptions.handler(
|
os.auth.webauthn.createRegistrationOptions.handler(
|
||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const ctx = context as APIContext;
|
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// For signup flow, we don't have a user yet
|
// For signup flow, we don't have a user yet
|
||||||
// The user will be created when signup is called with the passkeyInfo
|
// 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;
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const verifyRegistration = os.auth.webauthn.verifyRegistration.handler(
|
const verifyRegistration = os.auth.webauthn.verifyRegistration
|
||||||
async ({ input, context }) => {
|
.use(authMiddleware)
|
||||||
const ctx = context as AuthenticatedContext;
|
.handler(async ({ input, context }) => {
|
||||||
const { challengeId, response } = input;
|
const { challengeId, response } = input;
|
||||||
|
|
||||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName);
|
||||||
await verifyReg(ctx.db, rpInfo, ctx.user.id, challengeId, response);
|
await verifyReg(context.db, rpInfo, context.user.id, challengeId, response);
|
||||||
|
});
|
||||||
|
|
||||||
return undefined;
|
const createAuthenticationOptions = os.auth.webauthn.createAuthenticationOptions
|
||||||
},
|
.use(loginRequestMiddleware)
|
||||||
);
|
.handler(async ({ context }) => {
|
||||||
|
const rpInfo = getRPInfo(context.origin, context.allowedOrigins, context.rpName);
|
||||||
const createAuthenticationOptions =
|
const result = await createAuthOptions(context.db, rpInfo, context.user.id);
|
||||||
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);
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler(
|
const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
||||||
async ({ input, context }) => {
|
.use(loginRequestMiddleware)
|
||||||
const ctx = context as LoginRequestContext;
|
.handler(async ({ input, context }) => {
|
||||||
const { challengeId, response } = input;
|
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(
|
const verified = await verifyAuth(
|
||||||
ctx.db,
|
context.db,
|
||||||
rpInfo,
|
rpInfo,
|
||||||
ctx.user.id,
|
context.user.id,
|
||||||
challengeId,
|
challengeId,
|
||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
@@ -116,36 +82,33 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler(
|
|||||||
message: "Authentication failed",
|
message: "Authentication failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Me procedures
|
// Me procedures
|
||||||
const meGet = os.me.get.handler(async () => {
|
const meGet = os.me.get.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupProfile = os.me.setupProfile.handler(async () => {
|
const setupProfile = os.me.setupProfile.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateProfile = os.me.updateProfile.handler(async () => {
|
const updateProfile = os.me.updateProfile.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const meDelete = os.me.delete.handler(async () => {
|
const meDelete = os.me.delete.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPassword = os.me.setPassword.handler(async () => {
|
const setPassword = os.me.setPassword.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const passkeysList = os.me.passkeys.list.handler(async ({ context }) => {
|
const listPasskeys = os.me.listPasskeys
|
||||||
const ctx = context as AuthenticatedContext;
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
const passkeys = await getUserPasskeys(ctx.db, ctx.user.id);
|
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||||
|
|
||||||
return passkeys.map((p) => ({
|
return passkeys.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -155,486 +118,217 @@ const passkeysList = os.me.passkeys.list.handler(async ({ context }) => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const passkeysRename = os.me.passkeys.rename.handler(
|
const createPasskey = os.me.createPasskey
|
||||||
async ({ input, context }) => {
|
.use(authMiddleware)
|
||||||
const ctx = context as AuthenticatedContext;
|
.handler(async ({ input, context }) => {
|
||||||
|
const { name: _name } = input;
|
||||||
|
|
||||||
|
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;
|
const { passkeyId, name } = input;
|
||||||
|
|
||||||
await ctx.db
|
await context.db
|
||||||
.updateTable("passkeys")
|
.updateTable("passkeys")
|
||||||
.set({ name })
|
.set({ name })
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", String(passkeyId))
|
||||||
.where("user_id", "=", ctx.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
return undefined;
|
const deletePasskey = os.me.deletePasskey
|
||||||
},
|
.use(authMiddleware)
|
||||||
);
|
.handler(async ({ input, context }) => {
|
||||||
|
|
||||||
const passkeysDelete = os.me.passkeys.delete.handler(
|
|
||||||
async ({ input, context }) => {
|
|
||||||
const ctx = context as AuthenticatedContext;
|
|
||||||
const { passkeyId } = input;
|
const { passkeyId } = input;
|
||||||
|
|
||||||
// Check if this is the last passkey and user has no password
|
// Check if this is the last passkey and user has no password
|
||||||
const user = await ctx.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["password_hash"])
|
.select(["password_hash"])
|
||||||
.where("id", "=", ctx.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
const passkeyCount = await ctx.db
|
const passkeyCount = await context.db
|
||||||
.selectFrom("passkeys")
|
.selectFrom("passkeys")
|
||||||
.select(ctx.db.fn.countAll().as("count"))
|
.select(context.db.fn.countAll().as("count"))
|
||||||
.where("user_id", "=", ctx.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
||||||
throw new Error(
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
"Cannot delete the last passkey when you have no password set",
|
message: "Cannot delete the last passkey when you have no password set",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await context.db
|
||||||
.deleteFrom("passkeys")
|
.deleteFrom("passkeys")
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", String(passkeyId))
|
||||||
.where("user_id", "=", ctx.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const listSessions = os.me.listSessions.handler(async () => {
|
|
||||||
throw new Error("Not implemented");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeSession = os.me.revokeSession.handler(async () => {
|
const listSessions = os.me.listSessions.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeAllSessions = os.me.revokeAllSessions.handler(async () => {
|
const revokeSession = os.me.revokeSession.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const getDeviceInfo = os.me.getDeviceInfo.handler(async () => {
|
const revokeAllSessions = os.me.revokeAllSessions.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const trustDevice = os.me.trustDevice.handler(async () => {
|
const getDeviceInfo = os.me.getDeviceInfo.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const listTrustedDevices = os.me.listTrustedDevices.handler(async () => {
|
const trustDevice = os.me.trustDevice.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const untrustDevice = os.me.untrustDevice.handler(async () => {
|
const listTrustedDevices = os.me.listTrustedDevices.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices.handler(
|
const untrustDevice = os.me.untrustDevice.use(authMiddleware).handler(async () => {
|
||||||
async () => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
throw new Error("Not implemented");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Orgs procedures
|
|
||||||
const orgsList = os.orgs.list.handler(async () => {
|
|
||||||
throw new Error("Not implemented");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgsCreate = os.orgs.create.handler(async () => {
|
const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
|
||||||
throw new Error("Not implemented");
|
.use(authMiddleware)
|
||||||
|
.handler(async () => {
|
||||||
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgsGet = os.orgs.get.handler(async () => {
|
// Orgs procedures (all require auth)
|
||||||
throw new Error("Not implemented");
|
const orgsList = os.orgs.list.use(authMiddleware).handler(async () => {
|
||||||
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgsUpdate = os.orgs.update.handler(async () => {
|
const orgsCreate = os.orgs.create.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgsDelete = os.orgs.delete.handler(async () => {
|
const orgsGet = os.orgs.get.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgsLeave = os.orgs.leave.handler(async () => {
|
const orgsUpdate = os.orgs.update.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgsDelete = os.orgs.delete.use(authMiddleware).handler(async () => {
|
||||||
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgsLeave = os.orgs.leave.use(authMiddleware).handler(async () => {
|
||||||
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orgs members procedures
|
// Orgs members procedures
|
||||||
const membersList = os.orgs.members.list.handler(async () => {
|
const membersList = os.orgs.members.list.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const membersUpdateRole = os.orgs.members.updateRole.handler(async () => {
|
const membersUpdateRole = os.orgs.members.updateRole.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const membersRemove = os.orgs.members.remove.handler(async () => {
|
const membersRemove = os.orgs.members.remove.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orgs invites procedures
|
// Orgs invites procedures
|
||||||
const invitesList = os.orgs.invites.list.handler(async () => {
|
const invitesList = os.orgs.invites.list.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitesCreate = os.orgs.invites.create.handler(async () => {
|
const invitesCreate = os.orgs.invites.create.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitesCancel = os.orgs.invites.cancel.handler(async () => {
|
const invitesCancel = os.orgs.invites.cancel.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitesAccept = os.orgs.invites.accept.handler(async () => {
|
const invitesAccept = os.orgs.invites.accept.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orgs sites procedures
|
// Orgs sites procedures
|
||||||
const sitesList = os.orgs.sites.list.handler(async () => {
|
const sitesList = os.orgs.sites.list.use(authMiddleware).handler(async () => {
|
||||||
throw new Error("Not implemented");
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin orgs procedures
|
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
|
||||||
const adminOrgsList = os.admin.orgs.list.handler(async ({ context }) => {
|
const adminOrgsList = os.admin.orgs.list.use(authMiddleware).handler(async () => {
|
||||||
const ctx = requireSuperuser(context);
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminOrgsGet = os.admin.orgs.get.handler(async ({ input, context }) => {
|
const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => {
|
||||||
const ctx = requireSuperuser(context);
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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 adminOrgsCreate = os.admin.orgs.create.handler(
|
const adminOrgsCreate = os.admin.orgs.create.use(authMiddleware).handler(async () => {
|
||||||
async ({ input, context }) => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
const ctx = requireSuperuser(context);
|
|
||||||
const { slug, displayName, ownerEmail } = input;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
if (!owner) {
|
|
||||||
const result = await trx
|
|
||||||
.insertInto("users")
|
|
||||||
.values({ email: ownerEmail.toLowerCase() })
|
|
||||||
.returning("id")
|
|
||||||
.executeTakeFirst();
|
|
||||||
owner = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!owner) {
|
|
||||||
throw new Error("Failed to create owner user");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create org
|
|
||||||
const org = await trx
|
|
||||||
.insertInto("orgs")
|
|
||||||
.values({ slug, display_name: displayName })
|
|
||||||
.returning(["id", "slug"])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
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.use(authMiddleware).handler(async () => {
|
||||||
},
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
);
|
});
|
||||||
|
|
||||||
const adminOrgsUpdate = os.admin.orgs.update.handler(
|
const adminOrgsDelete = os.admin.orgs.delete.use(authMiddleware).handler(async () => {
|
||||||
async ({ input, context }) => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
const ctx = requireSuperuser(context);
|
});
|
||||||
const { slug, displayName, logoUrl } = input;
|
|
||||||
|
|
||||||
const updates: Record<string, string | undefined> = {};
|
const adminOrgsListSites = os.admin.orgs.listSites.use(authMiddleware).handler(async () => {
|
||||||
if (displayName !== undefined) {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
updates.display_name = displayName;
|
});
|
||||||
}
|
|
||||||
if (logoUrl !== undefined) {
|
|
||||||
updates.logo_url = logoUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
const adminOrgsAddSite = os.admin.orgs.addSite.use(authMiddleware).handler(async () => {
|
||||||
await ctx.db
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
.updateTable("orgs")
|
});
|
||||||
.set(updates)
|
|
||||||
.where("slug", "=", slug)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
const adminOrgsRemoveSite = os.admin.orgs.removeSite.use(authMiddleware).handler(async () => {
|
||||||
},
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
);
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin users procedures
|
// Admin users procedures
|
||||||
const adminUsersList = os.admin.users.list.handler(async ({ context }) => {
|
const adminUsersList = os.admin.users.list.use(authMiddleware).handler(async () => {
|
||||||
const ctx = requireSuperuser(context);
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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 adminUsersGet = os.admin.users.get.handler(async ({ input, context }) => {
|
const adminUsersGet = os.admin.users.get.use(authMiddleware).handler(async () => {
|
||||||
const ctx = requireSuperuser(context);
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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 adminUsersCreate = os.admin.users.create.handler(
|
const adminUsersCreate = os.admin.users.create.use(authMiddleware).handler(async () => {
|
||||||
async ({ input, context }) => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
const ctx = requireSuperuser(context);
|
|
||||||
const { email, name, orgSlug, orgRole } = input;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
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.use(authMiddleware).handler(async () => {
|
||||||
},
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
);
|
});
|
||||||
|
|
||||||
const adminUsersUpdate = os.admin.users.update.handler(
|
const adminUsersConfirmEmail = os.admin.users.confirmEmail.use(authMiddleware).handler(async () => {
|
||||||
async ({ input, context }) => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin auth procedures
|
// Admin auth procedures
|
||||||
const adminAuthCompleteLogin = os.admin.auth.completeLogin.handler(
|
const adminAuthCompleteLogin = os.admin.auth.completeLogin.use(authMiddleware).handler(async () => {
|
||||||
async ({ input, context }) => {
|
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build the router
|
// Build the router
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
@@ -662,11 +356,10 @@ export const router = os.router({
|
|||||||
updateProfile,
|
updateProfile,
|
||||||
delete: meDelete,
|
delete: meDelete,
|
||||||
setPassword,
|
setPassword,
|
||||||
passkeys: {
|
listPasskeys,
|
||||||
list: passkeysList,
|
createPasskey,
|
||||||
rename: passkeysRename,
|
renamePasskey,
|
||||||
delete: passkeysDelete,
|
deletePasskey,
|
||||||
},
|
|
||||||
listSessions,
|
listSessions,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
revokeAllSessions,
|
revokeAllSessions,
|
||||||
|
|||||||
Reference in New Issue
Block a user