Compare commits

..

9 Commits

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

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

This reduces boilerplate and makes middleware usage consistent.

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

Also simplify superuserMiddleware to use authMiddleware.concat()

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:30:00 +08:00
63 changed files with 811 additions and 1023 deletions

View File

@@ -55,7 +55,7 @@ publisher-dashboard/
### Prerequisites ### Prerequisites
- [Bun](https://bun.sh/) v1.1.42+ - [Bun](https://bun.sh/) v1.3.5+
- [devenv](https://devenv.sh/) for development environment management - [devenv](https://devenv.sh/) for development environment management
### Environment Variables ### Environment Variables

View File

@@ -29,6 +29,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -39,7 +40,6 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";

View File

@@ -41,6 +41,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -50,7 +51,6 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";

View File

@@ -23,6 +23,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -32,7 +33,6 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";

View File

@@ -14,6 +14,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -23,7 +24,6 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";

View File

@@ -12,6 +12,7 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import { import {
createTestUser, createTestUser,
describeE2E, describeE2E,
@@ -23,7 +24,6 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";

View File

@@ -115,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
/** User with superuser privileges */ /** User with superuser privileges */
user: SessionUser & { isSuperuser: true }; user: SessionUser & { isSuperuser: true };
} }
/**
* Organization info in context
*/
export interface OrgInfo {
id: number;
slug: string;
displayName: string;
logoUrl: string | null;
createdAt: Date;
}
/**
* User's membership in an org
*/
export interface OrgMembership {
id: number;
role: "owner" | "admin" | "member";
createdAt: Date;
}
/**
* Org member context for org-scoped procedures
* Requires user to be a member of the org
*/
export interface OrgMemberContext extends AuthenticatedContext {
/** The organization */
org: OrgInfo;
/** User's membership in the org */
membership: OrgMembership;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,7 +122,8 @@ export const loginIfRequestIsCompleted =
.execute(); .execute();
return { session: newSession, deviceTrusted: trusted }; return { session: newSession, deviceTrusted: trusted };
}); },
);
// Set session cookie // Set session cookie
setCookie( setCookie(

View File

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

View File

@@ -16,11 +16,10 @@ import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { authMiddleware, os } from "../base.js"; import { authedProcedure } from "../base.js";
export const resendVerificationEmail = os.auth.resendVerificationEmail export const resendVerificationEmail =
.use(authMiddleware) authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
.handler(async ({ context }) => {
// Check if email is already verified // Check if email is already verified
if (context.user.emailVerifiedAt !== null) { if (context.user.emailVerifiedAt !== null) {
// Email already verified, return early // Email already verified, return early

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,25 +5,11 @@
import type { DB, OrgRole } from "@reviq/db-schema"; import type { DB, OrgRole } from "@reviq/db-schema";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { OrgInfo, OrgMembership } from "../../context.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
// ===== Types ===== // Re-export types for convenience
export type { OrgInfo, OrgMembership };
/** Org info returned from lookup */
export interface OrgInfo {
id: number;
slug: string;
displayName: string;
logoUrl: string | null;
createdAt: Date;
}
/** User's membership in an org */
export interface OrgMembership {
id: number;
role: OrgRole;
createdAt: Date;
}
// ===== Role Hierarchy ===== // ===== Role Hierarchy =====

View File

@@ -9,16 +9,15 @@ import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { authMiddleware, os } from "../base.js"; import { authedProcedure } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
/** /**
* List pending invites for an org * List pending invites for an org
* Requires admin or owner role * Requires admin or owner role
*/ */
export const invitesList = os.orgs.invites.list export const invitesList = authedProcedure.orgs.invites.list.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug } = input; const { slug } = input;
// Lookup org and verify admin+ role // Lookup org and verify admin+ role
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
createdAt: i.created_at, createdAt: i.created_at,
expiresAt: i.expires_at, expiresAt: i.expires_at,
})); }));
}); },
);
/** /**
* Create an invite for a new member * Create an invite for a new member
* Requires admin or owner role * Requires admin or owner role
* Only owners can invite new owners (privilege escalation prevention) * Only owners can invite new owners (privilege escalation prevention)
*/ */
export const invitesCreate = os.orgs.invites.create export const invitesCreate = authedProcedure.orgs.invites.create.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug, email: rawEmail, role } = input; const { slug, email: rawEmail, role } = input;
const email = rawEmail.toLowerCase(); const email = rawEmail.toLowerCase();
@@ -135,15 +134,15 @@ export const invitesCreate = os.orgs.invites.create
}); });
return { success: true }; return { success: true };
}); },
);
/** /**
* Cancel a pending invite * Cancel a pending invite
* Requires admin or owner role * Requires admin or owner role
*/ */
export const invitesCancel = os.orgs.invites.cancel export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { slug, inviteId } = input; const { slug, inviteId } = input;
// Lookup org and verify admin+ role // Lookup org and verify admin+ role
@@ -163,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
} }
return { success: true }; return { success: true };
}); },
);
/** /**
* Accept an invitation * Accept an invitation
* Token-based lookup, requires auth but no org membership * Token-based lookup, requires auth but no org membership
* Handles race condition if user is already a member * Handles race condition if user is already a member
*/ */
export const invitesAccept = os.orgs.invites.accept export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
.use(authMiddleware) async ({ input, context }) => {
.handler(async ({ input, context }) => {
const { token } = input; const { token } = input;
// Find the invite by token (must not be expired) // Find the invite by token (must not be expired)
@@ -235,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
} }
return { success: true }; return { success: true };
}); },
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,9 @@ function formatRelativeTime(date: Date): string {
if (diffDays === 0) { if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
return diffHours <= 0 ? "expired" : `in ${diffHours.toLocaleString()} hours`; return diffHours <= 0
? "expired"
: `in ${diffHours.toLocaleString()} hours`;
} }
if (diffDays === 1) { if (diffDays === 1) {

View File

@@ -44,7 +44,6 @@ function completions(
_flags: Record<string, never>, _flags: Record<string, never>,
shell: Shell, shell: Shell,
): void { ): void {
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
switch (shell) { switch (shell) {
case "bash": case "bash":
console.log("To enable bash completions for reviq, run:\n"); console.log("To enable bash completions for reviq, run:\n");

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -14,6 +13,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card/index.js"; } from "$lib/components/ui/card/index.js";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Skeleton } from "$lib/components/ui/skeleton/index.js"; import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { import {
Table, Table,

View File

@@ -17,7 +17,6 @@ import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org"; import { OrgAvatar } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -28,6 +27,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { import {

View File

@@ -13,7 +13,6 @@ import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -21,6 +20,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { import {

View File

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

View File

@@ -13,7 +13,6 @@ import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -21,6 +20,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { import {

View File

@@ -15,16 +15,7 @@ const describeE2E = describe.skipIf(SKIP_DB_TESTS);
describe("createDb", () => { describe("createDb", () => {
test("throws error for empty connection string", () => { test("throws error for empty connection string", () => {
expect(() => createDb("")).toThrow("Database connection string is required"); expect(() => createDb("")).toThrow(
});
test("throws error for null-ish connection string", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(null as any)).toThrow(
"Database connection string is required",
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(undefined as any)).toThrow(
"Database connection string is required", "Database connection string is required",
); );
}); });

View File

@@ -42,7 +42,7 @@ let testCounter = 0;
const uniqueTestId = (): string => { const uniqueTestId = (): string => {
const timestamp = Date.now(); const timestamp = Date.now();
testCounter++; testCounter++;
return `${timestamp}-${testCounter.toString()}`; return `${timestamp.toString()}-${testCounter.toString()}`;
}; };
/** Truncate all tables */ /** Truncate all tables */
@@ -95,10 +95,8 @@ describeE2E("[e2e] executeBootstrap", () => {
}); });
afterAll(async () => { afterAll(async () => {
if (db) {
await truncateAllTables(db); await truncateAllTables(db);
await db.destroy(); await db.destroy();
}
}); });
test("creates superuser with correct email and password", async () => { test("creates superuser with correct email and password", async () => {
@@ -263,6 +261,7 @@ describeE2E("[e2e] executeBootstrap", () => {
test("throws error for password less than 8 characters", async () => { test("throws error for password less than 8 characters", async () => {
await withTestTransaction(db, async (trx) => { await withTestTransaction(db, async (trx) => {
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
executeBootstrap(trx, { executeBootstrap(trx, {
email: "admin@example.com", email: "admin@example.com",
@@ -274,6 +273,7 @@ describeE2E("[e2e] executeBootstrap", () => {
test("throws error for password exactly 7 characters", async () => { test("throws error for password exactly 7 characters", async () => {
await withTestTransaction(db, async (trx) => { await withTestTransaction(db, async (trx) => {
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
executeBootstrap(trx, { executeBootstrap(trx, {
email: "admin@example.com", email: "admin@example.com",
@@ -296,6 +296,7 @@ describeE2E("[e2e] executeBootstrap", () => {
test("throws error for invalid email without @", async () => { test("throws error for invalid email without @", async () => {
await withTestTransaction(db, async (trx) => { await withTestTransaction(db, async (trx) => {
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
executeBootstrap(trx, { executeBootstrap(trx, {
email: "invalidemail", email: "invalidemail",
@@ -326,6 +327,7 @@ describeE2E("[e2e] executeBootstrap", () => {
}); });
// Attempt to create the same user again // Attempt to create the same user again
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
executeBootstrap(trx, { executeBootstrap(trx, {
email: "admin@example.com", email: "admin@example.com",
@@ -552,19 +554,19 @@ describeE2E("[e2e] executeBootstrap", () => {
}); });
// Create another org and invite // Create another org and invite
const [otherOrg] = await trx const otherOrg = await trx
.insertInto("orgs") .insertInto("orgs")
.values({ .values({
slug: `other-org-${uniqueId}`, slug: `other-org-${uniqueId}`,
display_name: "Other Org", display_name: "Other Org",
}) })
.returning(["id"]) .returning(["id"])
.execute(); .executeTakeFirstOrThrow();
await trx await trx
.insertInto("org_invites") .insertInto("org_invites")
.values({ .values({
org_id: otherOrg!.id, org_id: otherOrg.id,
email: "invitee@example.com", email: "invitee@example.com",
role: "member", role: "member",
invited_by: result1.user.id, invited_by: result1.user.id,
@@ -611,14 +613,14 @@ describeE2E("[e2e] executeBootstrap", () => {
.execute(); .execute();
// Add org invites (to the org, not by the user) // Add org invites (to the org, not by the user)
const [anotherUser] = await trx const anotherUser = await trx
.insertInto("users") .insertInto("users")
.values({ .values({
email: `other-${uniqueId}@example.com`, email: `other-${uniqueId}@example.com`,
display_name: "Other User", display_name: "Other User",
}) })
.returning(["id"]) .returning(["id"])
.execute(); .executeTakeFirstOrThrow();
await trx await trx
.insertInto("org_invites") .insertInto("org_invites")
@@ -626,7 +628,7 @@ describeE2E("[e2e] executeBootstrap", () => {
org_id: result1.org.id, org_id: result1.org.id,
email: "invitee@example.com", email: "invitee@example.com",
role: "member", role: "member",
invited_by: anotherUser!.id, invited_by: anotherUser.id,
token: "invite-token-2", token: "invite-token-2",
expires_at: new Date(Date.now() + 86400000), expires_at: new Date(Date.now() + 86400000),
}) })

View File

@@ -30,5 +30,5 @@ export async function withTransaction<T>(
} }
// Not in a transaction, start one // Not in a transaction, start one
return (db as Kysely<Database>).transaction().execute(callback); return db.transaction().execute(callback);
} }

View File

@@ -202,9 +202,11 @@ export async function runMigrations(): Promise<void> {
await client.query(schemaSql); await client.query(schemaSql);
} catch (error) { } catch (error) {
// Ignore "already exists" errors - schema may have already been applied // Ignore "already exists" errors - schema may have already been applied
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : "Unknown error";
if (!message.includes("already exists")) { if (!message.includes("already exists")) {
throw new Error(`Schema application failed: ${message}`); throw error instanceof Error
? error
: new Error(`Schema application failed: ${message}`);
} }
} finally { } finally {
await client.end(); await client.end();