diff --git a/apps/api-server/package.json b/apps/api-server/package.json index efffc4d..652f4f0 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -1,11 +1,11 @@ { - "name": "api-server", + "name": "@reviq/api-server", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "bun run --hot src/index.ts", - "build": "bun build src/index.ts --outdir dist", + "build": "bun build src/index.ts --compile --outfile dist/api-server", "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", diff --git a/apps/api-server/src/middleware/auth.ts b/apps/api-server/src/middleware/auth.ts index 04c9209..72c01d6 100644 --- a/apps/api-server/src/middleware/auth.ts +++ b/apps/api-server/src/middleware/auth.ts @@ -112,36 +112,43 @@ export const createAuthMiddleware = () => { isSuperuser: user.is_superuser, }; - const sessionInfo: Session = session - ? { - id: session.id, - trustedMode: session.trusted_mode, - createdAt: session.created_at, - } - : { - // For API token auth, create a synthetic session object - // We know apiToken exists because userId came from it - id: "0", - trustedMode: true, - createdAt: apiToken?.created_at ?? new Date(), - }; + // Build session and auth info based on authentication method + let sessionInfo: Session; + let authInfo: AuthInfo; - // Build auth info based on authentication method - const authInfo: AuthInfo = session - ? { - method: "session", - sessionId: session.id, - expiresAt: session.expires_at, - createdAt: session.created_at, - } - : { - method: "api_token", - tokenId: apiToken?.id, - tokenName: apiToken?.name, - expiresAt: apiToken?.expires_at, - lastUsedAt: apiToken?.last_used_at, - createdAt: apiToken?.created_at, - }; + 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: { diff --git a/apps/api-server/src/procedures/admin/_routes.ts b/apps/api-server/src/procedures/admin/_routes.ts new file mode 100644 index 0000000..89988c4 --- /dev/null +++ b/apps/api-server/src/procedures/admin/_routes.ts @@ -0,0 +1,43 @@ +/** + * Admin routes - consolidated exports for os.router() + */ + +import { adminAuthCompleteLogin } from "./auth/complete-login.js"; +import { adminOrgsCreate } from "./orgs/create.js"; +import { adminOrgsDelete } from "./orgs/delete.js"; +import { adminOrgsGet } from "./orgs/get.js"; +import { adminOrgsList } from "./orgs/list.js"; +import { + adminOrgsAddSite, + adminOrgsListSites, + adminOrgsRemoveSite, +} from "./orgs/sites.js"; +import { adminOrgsUpdate } from "./orgs/update.js"; +import { adminUsersConfirmEmail } from "./users/confirm-email.js"; +import { adminUsersCreate } from "./users/create.js"; +import { adminUsersGet } from "./users/get.js"; +import { adminUsersList } from "./users/list.js"; +import { adminUsersUpdate } from "./users/update.js"; + +export const adminRoutes = { + orgs: { + list: adminOrgsList, + get: adminOrgsGet, + create: adminOrgsCreate, + update: adminOrgsUpdate, + delete: adminOrgsDelete, + listSites: adminOrgsListSites, + addSite: adminOrgsAddSite, + removeSite: adminOrgsRemoveSite, + }, + users: { + list: adminUsersList, + get: adminUsersGet, + create: adminUsersCreate, + update: adminUsersUpdate, + confirmEmail: adminUsersConfirmEmail, + }, + auth: { + completeLogin: adminAuthCompleteLogin, + }, +}; diff --git a/apps/api-server/src/procedures/admin/auth/complete-login.ts b/apps/api-server/src/procedures/admin/auth/complete-login.ts new file mode 100644 index 0000000..d49a1bd --- /dev/null +++ b/apps/api-server/src/procedures/admin/auth/complete-login.ts @@ -0,0 +1,32 @@ +/** + * admin.auth.completeLogin - Complete pending login request (dev helper) + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminAuthCompleteLogin = os.admin.auth.completeLogin + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const loginRequest = await context.db + .selectFrom("login_requests") + .where("email", "=", input.email.toLowerCase()) + .where("completed_at", "is", null) + .where("expires_at", ">", new Date()) + .orderBy("created_at", "desc") + .select(["id"]) + .executeTakeFirst(); + + if (!loginRequest) { + throw new ORPCError("NOT_FOUND", { + message: "No pending login request found", + }); + } + + await context.db + .updateTable("login_requests") + .set({ completed_at: new Date() }) + .where("id", "=", loginRequest.id) + .execute(); + }); diff --git a/apps/api-server/src/procedures/admin/helpers.ts b/apps/api-server/src/procedures/admin/helpers.ts new file mode 100644 index 0000000..d4614b8 --- /dev/null +++ b/apps/api-server/src/procedures/admin/helpers.ts @@ -0,0 +1,35 @@ +/** + * Admin procedure helpers - shared transformation functions + */ + +import type { OrgSites, Orgs, Users } from "@reviq/db-schema"; +import type { Selectable } from "kysely"; + +/** Transform org record to API response format */ +export const toOrgResponse = (org: Selectable) => ({ + id: org.id, + slug: org.slug, + displayName: org.display_name, + logoUrl: org.logo_url, + createdAt: org.created_at, +}); + +/** Transform user record to API response format */ +export const toUserResponse = (user: Selectable) => ({ + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: user.email_verified_at !== null, + needsSetup: user.display_name === null, + isSuperuser: user.is_superuser, +}); + +/** Transform site record to API response format */ +export const toSiteResponse = (site: Selectable) => ({ + id: site.id, + domain: site.domain, + createdAt: site.created_at, +}); diff --git a/apps/api-server/src/procedures/admin/orgs/create.ts b/apps/api-server/src/procedures/admin/orgs/create.ts new file mode 100644 index 0000000..3c0307b --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/create.ts @@ -0,0 +1,58 @@ +/** + * admin.orgs.create - Create organization with owner + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminOrgsCreate = os.admin.orgs.create + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug, displayName, ownerEmail } = input; + + // Find owner user by email (outside transaction - read-only) + const owner = await context.db + .selectFrom("users") + .where("email", "=", ownerEmail.toLowerCase()) + .select(["id"]) + .executeTakeFirst(); + if (!owner) { + throw new ORPCError("NOT_FOUND", { message: "User not found" }); + } + + // Create org and owner membership in transaction (with race condition protection) + await context.db.transaction().execute(async (trx) => { + // Check for existing org inside transaction to prevent race condition + const existingOrg = await trx + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (existingOrg) { + throw new ORPCError("CONFLICT", { + message: "Organization with this slug already exists", + }); + } + + const newOrg = await trx + .insertInto("orgs") + .values({ + slug, + display_name: displayName, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + await trx + .insertInto("org_members") + .values({ + org_id: newOrg.id, + user_id: owner.id, + role: "owner", + }) + .execute(); + }); + + return { slug }; + }); diff --git a/apps/api-server/src/procedures/admin/orgs/delete.ts b/apps/api-server/src/procedures/admin/orgs/delete.ts new file mode 100644 index 0000000..8b1e609 --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/delete.ts @@ -0,0 +1,36 @@ +/** + * admin.orgs.delete - Delete organization and all related records + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminOrgsDelete = os.admin.orgs.delete + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Delete org and related records in transaction + await context.db.transaction().execute(async (trx) => { + const org = await trx + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + + await trx + .deleteFrom("org_invites") + .where("org_id", "=", org.id) + .execute(); + await trx.deleteFrom("org_sites").where("org_id", "=", org.id).execute(); + await trx + .deleteFrom("org_members") + .where("org_id", "=", org.id) + .execute(); + await trx.deleteFrom("orgs").where("id", "=", org.id).execute(); + }); + }); diff --git a/apps/api-server/src/procedures/admin/orgs/get.ts b/apps/api-server/src/procedures/admin/orgs/get.ts new file mode 100644 index 0000000..13117a6 --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/get.ts @@ -0,0 +1,22 @@ +/** + * admin.orgs.get - Get organization by slug + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { toOrgResponse } from "../helpers.js"; + +export const adminOrgsGet = os.admin.orgs.get + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const org = await context.db + .selectFrom("orgs") + .where("slug", "=", input.slug) + .selectAll() + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + return toOrgResponse(org); + }); diff --git a/apps/api-server/src/procedures/admin/orgs/list.ts b/apps/api-server/src/procedures/admin/orgs/list.ts new file mode 100644 index 0000000..13f1ad5 --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/list.ts @@ -0,0 +1,14 @@ +/** + * admin.orgs.list - List all organizations + */ + +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { toOrgResponse } from "../helpers.js"; + +export const adminOrgsList = os.admin.orgs.list + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ context }) => { + const orgs = await context.db.selectFrom("orgs").selectAll().execute(); + return orgs.map(toOrgResponse); + }); diff --git a/apps/api-server/src/procedures/admin/orgs/sites.ts b/apps/api-server/src/procedures/admin/orgs/sites.ts new file mode 100644 index 0000000..250ebe6 --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/sites.ts @@ -0,0 +1,97 @@ +/** + * admin.orgs.listSites, admin.orgs.addSite, admin.orgs.removeSite + * Site management for organizations + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { toSiteResponse } from "../helpers.js"; + +export const adminOrgsListSites = os.admin.orgs.listSites + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + const org = await context.db + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + + const sites = await context.db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + + return sites.map(toSiteResponse); + }); + +export const adminOrgsAddSite = os.admin.orgs.addSite + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug, domain } = input; + + // Use transaction to prevent race condition on site creation + await context.db.transaction().execute(async (trx) => { + const org = await trx + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + + // Check if site already exists (inside transaction) + const existingSite = await trx + .selectFrom("org_sites") + .where("domain", "=", domain) + .select(["id"]) + .executeTakeFirst(); + if (existingSite) { + throw new ORPCError("CONFLICT", { + message: "Site with this domain already exists", + }); + } + + await trx + .insertInto("org_sites") + .values({ + org_id: org.id, + domain, + }) + .execute(); + }); + }); + +export const adminOrgsRemoveSite = os.admin.orgs.removeSite + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug, domain } = input; + + const org = await context.db + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + + const result = await context.db + .deleteFrom("org_sites") + .where("org_id", "=", org.id) + .where("domain", "=", domain) + .executeTakeFirst(); + + if (!result.numDeletedRows || result.numDeletedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Site not found" }); + } + }); diff --git a/apps/api-server/src/procedures/admin/orgs/update.ts b/apps/api-server/src/procedures/admin/orgs/update.ts new file mode 100644 index 0000000..d2b2bb1 --- /dev/null +++ b/apps/api-server/src/procedures/admin/orgs/update.ts @@ -0,0 +1,50 @@ +/** + * admin.orgs.update - Update organization + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminOrgsUpdate = os.admin.orgs.update + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { slug, displayName, logoUrl } = input; + + // Check if there are actual updates to make + if (displayName === undefined && logoUrl === undefined) { + // Verify org exists even for no-op + const org = await context.db + .selectFrom("orgs") + .where("slug", "=", slug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + return; + } + + const updates: Partial<{ + display_name: string; + logo_url: string | null; + updated_at: Date; + }> = { updated_at: new Date() }; + + if (displayName !== undefined) { + updates.display_name = displayName; + } + if (logoUrl !== undefined) { + updates.logo_url = logoUrl || null; + } + + const result = await context.db + .updateTable("orgs") + .set(updates) + .where("slug", "=", slug) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + }); diff --git a/apps/api-server/src/procedures/admin/users/confirm-email.ts b/apps/api-server/src/procedures/admin/users/confirm-email.ts new file mode 100644 index 0000000..a2fa6f1 --- /dev/null +++ b/apps/api-server/src/procedures/admin/users/confirm-email.ts @@ -0,0 +1,24 @@ +/** + * admin.users.confirmEmail - Confirm a user's email (used by CLI) + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminUsersConfirmEmail = os.admin.users.confirmEmail + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const result = await context.db + .updateTable("users") + .set({ + email_verified_at: new Date(), + updated_at: new Date(), + }) + .where("email", "=", input.email.toLowerCase()) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "User not found" }); + } + }); diff --git a/apps/api-server/src/procedures/admin/users/create.ts b/apps/api-server/src/procedures/admin/users/create.ts new file mode 100644 index 0000000..1e431a4 --- /dev/null +++ b/apps/api-server/src/procedures/admin/users/create.ts @@ -0,0 +1,63 @@ +/** + * admin.users.create - Create passwordless user, optionally add to org + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminUsersCreate = os.admin.users.create + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { email, name, orgSlug, orgRole } = input; + const normalizedEmail = email.toLowerCase(); + + // If orgSlug provided, verify org exists (outside transaction - read-only) + let orgId: number | undefined; + if (orgSlug) { + const org = await context.db + .selectFrom("orgs") + .where("slug", "=", orgSlug) + .select(["id"]) + .executeTakeFirst(); + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + orgId = org.id; + } + + // Create user in transaction (with race condition protection) + await context.db.transaction().execute(async (trx) => { + // Check for existing user inside transaction to prevent race condition + const existingUser = await trx + .selectFrom("users") + .where("email", "=", normalizedEmail) + .select(["id"]) + .executeTakeFirst(); + if (existingUser) { + throw new ORPCError("CONFLICT", { + message: "User with this email already exists", + }); + } + + const newUser = await trx + .insertInto("users") + .values({ + email: normalizedEmail, + display_name: name ?? null, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + if (orgId !== undefined) { + await trx + .insertInto("org_members") + .values({ + org_id: orgId, + user_id: newUser.id, + role: orgRole ?? "member", + }) + .execute(); + } + }); + }); diff --git a/apps/api-server/src/procedures/admin/users/get.ts b/apps/api-server/src/procedures/admin/users/get.ts new file mode 100644 index 0000000..b914e30 --- /dev/null +++ b/apps/api-server/src/procedures/admin/users/get.ts @@ -0,0 +1,22 @@ +/** + * admin.users.get - Get user by email + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { toUserResponse } from "../helpers.js"; + +export const adminUsersGet = os.admin.users.get + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const user = await context.db + .selectFrom("users") + .where("email", "=", input.email.toLowerCase()) + .selectAll() + .executeTakeFirst(); + if (!user) { + throw new ORPCError("NOT_FOUND", { message: "User not found" }); + } + return toUserResponse(user); + }); diff --git a/apps/api-server/src/procedures/admin/users/list.ts b/apps/api-server/src/procedures/admin/users/list.ts new file mode 100644 index 0000000..e1c6d27 --- /dev/null +++ b/apps/api-server/src/procedures/admin/users/list.ts @@ -0,0 +1,14 @@ +/** + * admin.users.list - List all users + */ + +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { toUserResponse } from "../helpers.js"; + +export const adminUsersList = os.admin.users.list + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ context }) => { + const users = await context.db.selectFrom("users").selectAll().execute(); + return users.map(toUserResponse); + }); diff --git a/apps/api-server/src/procedures/admin/users/update.ts b/apps/api-server/src/procedures/admin/users/update.ts new file mode 100644 index 0000000..ddfc353 --- /dev/null +++ b/apps/api-server/src/procedures/admin/users/update.ts @@ -0,0 +1,48 @@ +/** + * admin.users.update - Update user properties (e.g., isSuperuser) + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os, superuserMiddleware } from "../../base.js"; + +export const adminUsersUpdate = os.admin.users.update + .use(authMiddleware) + .use(superuserMiddleware) + .handler(async ({ input, context }) => { + const { email, isSuperuser } = input; + const normalizedEmail = email.toLowerCase(); + + // Check if there are actual updates to make + if (isSuperuser === undefined) { + // Verify user exists even for no-op + const user = await context.db + .selectFrom("users") + .where("email", "=", normalizedEmail) + .select(["id"]) + .executeTakeFirst(); + if (!user) { + throw new ORPCError("NOT_FOUND", { message: "User not found" }); + } + return; + } + + // Prevent superuser from demoting themselves + if (!isSuperuser && normalizedEmail === context.user.email.toLowerCase()) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot remove your own superuser status", + }); + } + + const result = await context.db + .updateTable("users") + .set({ + is_superuser: isSuperuser, + updated_at: new Date(), + }) + .where("email", "=", normalizedEmail) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "User not found" }); + } + }); diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index 122bb9d..23f5858 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -110,35 +110,43 @@ export const authMiddleware = os.middleware(async ({ context, next }) => { isSuperuser: user.is_superuser, }; - const sessionInfo: Session = session - ? { - id: session.id, - trustedMode: session.trusted_mode, - createdAt: session.created_at, - } - : { - // For API token auth, create a synthetic session object - id: "0", - trustedMode: true, - createdAt: apiToken?.created_at ?? new Date(), - }; + // Build session and auth info based on authentication method + let sessionInfo: Session; + let authInfo: AuthInfo; - // Build auth info based on authentication method - const authInfo: AuthInfo = session - ? { - method: "session", - sessionId: session.id, - expiresAt: session.expires_at, - createdAt: session.created_at, - } - : { - method: "api_token", - tokenId: apiToken?.id, - tokenName: apiToken?.name, - expiresAt: apiToken?.expires_at, - lastUsedAt: apiToken?.last_used_at, - createdAt: apiToken?.created_at, - }; + 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: { diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 428178c..26c12f8 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -1,4 +1,5 @@ import { ORPCError } from "@orpc/server"; +import { adminRoutes } from "./procedures/admin/_routes.js"; import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js"; import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js"; import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js"; @@ -241,91 +242,6 @@ const setupProfile = os.me.setupProfile // - invitesList, invitesCreate, invitesCancel, invitesAccept // - sitesList -// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later) -const adminOrgsList = os.admin.orgs.list - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const adminOrgsCreate = os.admin.orgs.create - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsUpdate = os.admin.orgs.update - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsDelete = os.admin.orgs.delete - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsListSites = os.admin.orgs.listSites - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsAddSite = os.admin.orgs.addSite - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminOrgsRemoveSite = os.admin.orgs.removeSite - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -// Admin users procedures -const adminUsersList = os.admin.users.list - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminUsersGet = os.admin.users.get - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminUsersCreate = os.admin.users.create - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminUsersUpdate = os.admin.users.update - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const adminUsersConfirmEmail = os.admin.users.confirmEmail - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -// Admin auth procedures -const adminAuthCompleteLogin = os.admin.auth.completeLogin - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - // Build the router export const router = os.router({ auth: { @@ -389,26 +305,5 @@ export const router = os.router({ list: sitesList, }, }, - admin: { - orgs: { - list: adminOrgsList, - get: adminOrgsGet, - create: adminOrgsCreate, - update: adminOrgsUpdate, - delete: adminOrgsDelete, - listSites: adminOrgsListSites, - addSite: adminOrgsAddSite, - removeSite: adminOrgsRemoveSite, - }, - users: { - list: adminUsersList, - get: adminUsersGet, - create: adminUsersCreate, - update: adminUsersUpdate, - confirmEmail: adminUsersConfirmEmail, - }, - auth: { - completeLogin: adminAuthCompleteLogin, - }, - }, + admin: adminRoutes, }); diff --git a/apps/cli/package.json b/apps/cli/package.json index d8e1cce..aeed952 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "bin": { - "reviq": "./dist/index.js", - "__reviq_bash_complete": "./dist/bash-complete.js" + "reviq": "./dist/reviq", + "__reviq_bash_complete": "./dist/bash-complete" }, "scripts": { - "build": "bun build src/bin/reviq.ts --outfile dist/index.js --target bun && bun build src/bin/bash-complete.ts --outfile dist/bash-complete.js --target bun", + "build": "bun build src/bin/reviq.ts --compile --outfile dist/reviq && bun build src/bin/bash-complete.ts --compile --outfile dist/bash-complete", "cli": "bun run src/bin/reviq.ts", "typecheck": "tsc --noEmit", "lint": "eslint . --cache", diff --git a/bun.lock b/bun.lock index bfd2fd8..be77f40 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, }, "apps/api-server": { - "name": "api-server", + "name": "@reviq/api-server", "version": "0.0.0", "dependencies": { "@formatjs/intl-durationformat": "^0.9.2", @@ -372,6 +372,8 @@ "@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"], + "@reviq/api-server": ["@reviq/api-server@workspace:apps/api-server"], + "@reviq/cli": ["@reviq/cli@workspace:apps/cli"], "@reviq/db": ["@reviq/db@workspace:packages/db"], @@ -538,8 +540,6 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "api-server": ["api-server@workspace:apps/api-server"], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], diff --git a/docs/initial-app.md b/docs/initial-app.md index 83d2dca..8f1a728 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -76,6 +76,15 @@ publisher-dashboard/ │ │ ├── index.ts # Server entry point (Bun.serve) │ │ ├── router.ts │ │ ├── procedures/ +│ │ │ ├── base.ts # Middleware (auth, superuser, loginRequest) +│ │ │ ├── auth/ # Auth procedures +│ │ │ ├── me/ # User self-management procedures +│ │ │ └── admin/ # Superuser-only procedures +│ │ │ ├── _routes.ts # Consolidated admin route exports +│ │ │ ├── helpers.ts # Shared transform functions +│ │ │ ├── auth/ +│ │ │ ├── orgs/ +│ │ │ └── users/ │ │ └── middleware/ │ ├── publisher-dashboard/ # SvelteKit frontend │ │ ├── package.json @@ -2306,12 +2315,18 @@ _Can run parallel to H after F1 is done_ _Depends on: D1 (auth middleware)_ -- [ ] **J1**: Implement org middleware (slug lookup, membership check) -- [ ] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get` -- [ ] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave` -- [ ] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove` -- [ ] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept` -- [ ] **J6**: Implement `orgs.sites.list` +- [x] **J1**: Implement org middleware (slug lookup, membership check) +- [x] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get` +- [x] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave` +- [x] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove` +- [x] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept` +- [x] **J6**: Implement `orgs.sites.list` + +_Implementation notes:_ +- Files in `procedures/orgs/` with `index.ts` for consolidated exports +- Helper functions in `helpers.ts`: `lookupOrgBySlug`, `getMembership`, `requireRole`, `countOwners` +- Race conditions prevented via Kysely transactions for owner count checks +- Privilege escalation prevented: only owners can invite new owners #### Workstream K: Admin Procedures (Backend) @@ -2324,6 +2339,12 @@ _Can run parallel to J2-J6_ - [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite` - [x] **K5**: Implement `admin.auth.completeLogin` (dev helper) +_Implementation notes:_ +- Files in `procedures/admin/` with `_routes.ts` for consolidated exports +- Helper functions in `helpers.ts`: `toOrgResponse`, `toUserResponse`, `toSiteResponse` +- Race conditions prevented via transaction-scoped existence checks +- Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status + #### Workstream L: Org Pages (Frontend) _Depends on: J1-J6, C3_