Compare commits
8 Commits
b78064caeb
...
1f6d5a4a9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
1f6d5a4a9f
|
|||
|
d8397dfb38
|
|||
|
73ef3df01f
|
|||
|
25c8bab741
|
|||
|
b48012c1f6
|
|||
|
bd4053f952
|
|||
|
ce5a27d014
|
|||
|
665092464a
|
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
138
apps/api-server/src/middlewares/auth.ts
Normal file
138
apps/api-server/src/middlewares/auth.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/api-server/src/middlewares/index.ts
Normal file
8
apps/api-server/src/middlewares/index.ts
Normal 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";
|
||||||
64
apps/api-server/src/middlewares/login-request.ts
Normal file
64
apps/api-server/src/middlewares/login-request.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
10
apps/api-server/src/middlewares/os.ts
Normal file
10
apps/api-server/src/middlewares/os.ts
Normal 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>();
|
||||||
19
apps/api-server/src/middlewares/superuser.ts
Normal file
19
apps/api-server/src/middlewares/superuser.ts
Normal 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,49 +3,49 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
export const adminAuthCompleteLogin =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.auth.completeLogin.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const email = input.email.toLowerCase();
|
||||||
const email = input.email.toLowerCase();
|
|
||||||
|
|
||||||
// First check if any login request exists for this email
|
// First check if any login request exists for this email
|
||||||
const anyRequest = await context.db
|
const anyRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.where("email", "=", email)
|
.where("email", "=", email)
|
||||||
.orderBy("created_at", "desc")
|
.orderBy("created_at", "desc")
|
||||||
.select(["id", "completed_at", "expires_at"])
|
.select(["id", "completed_at", "expires_at"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!anyRequest) {
|
if (!anyRequest) {
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
message: `No login request found for ${email}`,
|
message: `No login request found for ${email}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already completed
|
// Check if already completed
|
||||||
if (anyRequest.completed_at) {
|
if (anyRequest.completed_at) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Login request already completed",
|
message: "Login request already completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
// Check if expired
|
||||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Login request expired (15 min limit). Start a new login flow.",
|
"Login request expired (15 min limit). Start a new login flow.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete the login request
|
// Complete the login request
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", anyRequest.id)
|
.where("id", "=", anyRequest.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toSiteResponse } from "../helpers.js";
|
import { toSiteResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
export const adminOrgsListSites =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.listSites.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug } = input;
|
||||||
const { slug } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sites = await context.db
|
const sites = await context.db
|
||||||
.selectFrom("org_sites")
|
.selectFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return sites.map(toSiteResponse);
|
return sites.map(toSiteResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, domain } = input;
|
const { slug, domain } = input;
|
||||||
|
|
||||||
// Use transaction to prevent race condition on site creation
|
// Use transaction to prevent race condition on site creation
|
||||||
@@ -70,32 +68,33 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
export const adminOrgsRemoveSite =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.removeSite.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug, domain } = input;
|
||||||
const { slug, domain } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("org_sites")
|
.deleteFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("domain", "=", domain)
|
.where("domain", "=", domain)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
export const adminUsersConfirmEmail =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.users.confirmEmail.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const result = await context.db
|
||||||
const result = await context.db
|
.updateTable("users")
|
||||||
.updateTable("users")
|
.set({
|
||||||
.set({
|
email_verified_at: new Date(),
|
||||||
email_verified_at: new Date(),
|
updated_at: new Date(),
|
||||||
updated_at: new Date(),
|
})
|
||||||
})
|
.where("email", "=", input.email.toLowerCase())
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.executeTakeFirst();
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
7
apps/api-server/src/procedures/me/_base.ts
Normal file
7
apps/api-server/src/procedures/me/_base.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Base route for me procedures with auth middleware applied
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authedProcedure } from "../base.js";
|
||||||
|
|
||||||
|
export const meRoute = authedProcedure.me;
|
||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,40 +2,38 @@
|
|||||||
* Get current user auth status
|
* Get current user auth status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meAuthStatus = os.me.authStatus
|
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
},
|
},
|
||||||
auth: context.auth,
|
auth: context.auth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete account handler
|
* Delete account handler
|
||||||
@@ -14,39 +14,37 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||||
* - Clears session cookie
|
* - Clears session cookie
|
||||||
*/
|
*/
|
||||||
export const meDelete = os.me.delete
|
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||||
.use(authMiddleware)
|
const { password } = input;
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { password } = input;
|
|
||||||
|
|
||||||
// Fetch user with password hash
|
// Fetch user with password hash
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["password_hash"])
|
.select(["password_hash"])
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
// Verify password (required for account deletion)
|
// Verify password (required for account deletion)
|
||||||
if (!user.password_hash) {
|
if (!user.password_hash) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Cannot delete account without a password. Please set a password first.",
|
"Cannot delete account without a password. Please set a password first.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.password_hash);
|
const valid = await verifyPassword(password, user.password_hash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||||
await context.db
|
await context.db
|
||||||
.deleteFrom("users")
|
.deleteFrom("users")
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Clear session cookie
|
// Clear session cookie
|
||||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,37 +2,35 @@
|
|||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meGet = os.me.get
|
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,64 +3,61 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List pending invites for the current user
|
* List pending invites for the current user
|
||||||
* Only returns invites where the user's email matches and email is verified
|
* Only returns invites where the user's email matches and email is verified
|
||||||
*/
|
*/
|
||||||
export const listInvites = os.me.invites.list
|
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
// Only show invites if email is verified
|
||||||
.handler(async ({ context }) => {
|
if (!context.user.emailVerifiedAt) {
|
||||||
// Only show invites if email is verified
|
return [];
|
||||||
if (!context.user.emailVerifiedAt) {
|
}
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get non-expired invites matching user's email
|
// Get non-expired invites matching user's email
|
||||||
const invites = await context.db
|
const invites = await context.db
|
||||||
.selectFrom("org_invites")
|
.selectFrom("org_invites")
|
||||||
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
||||||
.innerJoin("users", "users.id", "org_invites.invited_by")
|
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||||
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
||||||
.where("org_invites.expires_at", ">", new Date())
|
.where("org_invites.expires_at", ">", new Date())
|
||||||
.select([
|
.select([
|
||||||
"org_invites.id",
|
"org_invites.id",
|
||||||
"org_invites.role",
|
"org_invites.role",
|
||||||
"org_invites.created_at",
|
"org_invites.created_at",
|
||||||
"org_invites.expires_at",
|
"org_invites.expires_at",
|
||||||
"orgs.id as org_id",
|
"orgs.id as org_id",
|
||||||
"orgs.slug as org_slug",
|
"orgs.slug as org_slug",
|
||||||
"orgs.display_name as org_display_name",
|
"orgs.display_name as org_display_name",
|
||||||
"orgs.logo_url as org_logo_url",
|
"orgs.logo_url as org_logo_url",
|
||||||
"users.display_name as inviter_name",
|
"users.display_name as inviter_name",
|
||||||
"users.email as inviter_email",
|
"users.email as inviter_email",
|
||||||
])
|
])
|
||||||
.orderBy("org_invites.created_at", "desc")
|
.orderBy("org_invites.created_at", "desc")
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return invites.map((i) => ({
|
return invites.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
org: {
|
org: {
|
||||||
id: i.org_id,
|
id: i.org_id,
|
||||||
slug: i.org_slug,
|
slug: i.org_slug,
|
||||||
displayName: i.org_display_name,
|
displayName: i.org_display_name,
|
||||||
logoUrl: i.org_logo_url,
|
logoUrl: i.org_logo_url,
|
||||||
},
|
},
|
||||||
role: i.role,
|
role: i.role,
|
||||||
invitedBy: i.inviter_name ?? i.inviter_email,
|
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||||
createdAt: i.created_at,
|
createdAt: i.created_at,
|
||||||
expiresAt: i.expires_at,
|
expiresAt: i.expires_at,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific invite by ID
|
* Get a specific invite by ID
|
||||||
* Only returns if the invite belongs to the current user's email
|
* Only returns if the invite belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const getInvite = os.me.invites.get
|
export const getInvite = meRoute.invites.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only show invite if email is verified
|
// Only show invite if email is verified
|
||||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
|||||||
createdAt: invite.created_at,
|
createdAt: invite.created_at,
|
||||||
expiresAt: invite.expires_at,
|
expiresAt: invite.expires_at,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invite by ID
|
* Accept an invite by ID
|
||||||
* Adds user to org and deletes the invite
|
* Adds user to org and deletes the invite
|
||||||
*/
|
*/
|
||||||
export const acceptInvite = os.me.invites.accept
|
export const acceptInvite = meRoute.invites.accept.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only allow accepting if email is verified
|
// Only allow accepting if email is verified
|
||||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decline an invite
|
* Decline an invite
|
||||||
* Deletes the invite if it belongs to the current user's email
|
* Deletes the invite if it belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const declineInvite = os.me.invites.decline
|
export const declineInvite = meRoute.invites.decline.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Delete the invite only if it matches user's email
|
// Delete the invite only if it matches user's email
|
||||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import {
|
import {
|
||||||
countOwners,
|
countOwners,
|
||||||
getMembership,
|
getMembership,
|
||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
* List all members of an org
|
* List all members of an org
|
||||||
* Any member can view the member list
|
* Any member can view the member list
|
||||||
*/
|
*/
|
||||||
export const membersList = os.orgs.members.list
|
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -48,65 +47,70 @@ export const membersList = os.orgs.members.list
|
|||||||
role: m.role,
|
role: m.role,
|
||||||
createdAt: m.created_at,
|
createdAt: m.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a member's role
|
* Update a member's role
|
||||||
* Only owners can change roles
|
* Only owners can change roles
|
||||||
* Uses transaction to prevent race condition when demoting owners
|
* Uses transaction to prevent race condition when demoting owners
|
||||||
*/
|
*/
|
||||||
export const membersUpdateRole = os.orgs.members.updateRole
|
export const membersUpdateRole =
|
||||||
.use(authMiddleware)
|
authedProcedure.orgs.members.updateRole.handler(
|
||||||
.handler(async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const { slug, userId, role: newRole } = input;
|
const { slug, userId, role: newRole } = input;
|
||||||
|
|
||||||
// Lookup org and verify ownership
|
// Lookup org and verify ownership
|
||||||
const org = await lookupOrgBySlug(context.db, slug);
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
const membership = await getMembership(
|
||||||
requireRole(membership, "owner");
|
context.db,
|
||||||
|
org.id,
|
||||||
|
context.user.id,
|
||||||
|
);
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
|
||||||
await context.db.transaction().execute(async (trx) => {
|
await context.db.transaction().execute(async (trx) => {
|
||||||
// Get the target member's current membership
|
// Get the target member's current membership
|
||||||
const targetMember = await trx
|
const targetMember = await trx
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select(["id", "role"])
|
.select(["id", "role"])
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!targetMember) {
|
if (!targetMember) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||||
}
|
|
||||||
|
|
||||||
// If demoting an owner, check if they're the last one
|
|
||||||
if (targetMember.role === "owner" && newRole !== "owner") {
|
|
||||||
const ownerCount = await countOwners(trx, org.id);
|
|
||||||
if (ownerCount === 1) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Cannot demote the only owner",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update the role
|
// If demoting an owner, check if they're the last one
|
||||||
await trx
|
if (targetMember.role === "owner" && newRole !== "owner") {
|
||||||
.updateTable("org_members")
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
.set({ role: newRole })
|
if (ownerCount === 1) {
|
||||||
.where("id", "=", targetMember.id)
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
.execute();
|
message: "Cannot demote the only owner",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
// Update the role
|
||||||
});
|
await trx
|
||||||
|
.updateTable("org_members")
|
||||||
|
.set({ role: newRole })
|
||||||
|
.where("id", "=", targetMember.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a member from an org
|
* Remove a member from an org
|
||||||
* Owners can remove anyone, admins can only remove members
|
* Owners can remove anyone, admins can only remove members
|
||||||
* Uses transaction to prevent race condition when removing owners
|
* Uses transaction to prevent race condition when removing owners
|
||||||
*/
|
*/
|
||||||
export const membersRemove = os.orgs.members.remove
|
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, userId } = input;
|
const { slug, userId } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user