From 0c07a8235f73e73e9949fb67f2b8f1bf35fdb85b Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 08:12:54 -0500 Subject: [PATCH] feat(access): add multi-user auth and access APIs --- packages/shared/src/index.ts | 26 + packages/shared/src/types/access.ts | 75 +++ packages/shared/src/types/index.ts | 9 + packages/shared/src/types/project.ts | 1 + packages/shared/src/validators/access.ts | 61 ++ packages/shared/src/validators/index.ts | 12 + server/src/__tests__/auth-routes.test.ts | 147 +++++ .../src/__tests__/invite-create-route.test.ts | 2 +- server/src/__tests__/invite-expiry.test.ts | 4 +- .../__tests__/invite-summary-route.test.ts | 24 +- .../__tests__/issue-feedback-routes.test.ts | 1 - .../openclaw-invite-prompt-route.test.ts | 12 +- server/src/app.ts | 19 +- server/src/routes/access.ts | 576 ++++++++++++++++-- server/src/routes/auth.ts | 100 +++ 15 files changed, 1000 insertions(+), 69 deletions(-) create mode 100644 server/src/__tests__/auth-routes.test.ts create mode 100644 server/src/routes/auth.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165459..7936f9ab20c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -53,6 +53,9 @@ export { LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, + COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -125,6 +128,8 @@ export { type LiveEventType, type PrincipalType, type MembershipStatus, + type CompanyMembershipRole, + type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -289,11 +294,20 @@ export type { ActivityEvent, SidebarBadges, InboxDismissal, + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, CompanyPortabilityInclude, CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, @@ -525,12 +539,18 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, type CreateFinanceEvent, @@ -539,12 +559,18 @@ export { type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, companySkillTrustLevelSchema, diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index db6554eea2e..2cf72abc320 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -1,5 +1,7 @@ import type { AgentAdapterType, + CompanyStatus, + HumanCompanyMembershipRole, InstanceUserRole, InviteJoinType, InviteType, @@ -33,6 +35,30 @@ export interface PrincipalPermissionGrant { updatedAt: Date; } +export interface AccessUserProfile { + id: string; + email: string | null; + name: string | null; + image: string | null; +} + +export interface CompanyMemberRecord extends CompanyMembership { + principalType: "user"; + membershipRole: HumanCompanyMembershipRole | null; + user: AccessUserProfile | null; + grants: PrincipalPermissionGrant[]; +} + +export interface CompanyMembersResponse { + members: CompanyMemberRecord[]; + access: { + currentUserRole: HumanCompanyMembershipRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +} + export interface Invite { id: string; companyId: string | null; @@ -48,6 +74,17 @@ export interface Invite { updatedAt: Date; } +export type InviteState = "active" | "revoked" | "accepted" | "expired"; + +export interface CompanyInviteRecord extends Invite { + companyName: string | null; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + state: InviteState; + invitedByUser: AccessUserProfile | null; + relatedJoinRequestId: string | null; +} + export interface JoinRequest { id: string; inviteId: string; @@ -72,6 +109,26 @@ export interface JoinRequest { updatedAt: Date; } +export interface JoinRequestInviteSummary { + id: string; + inviteType: InviteType; + allowedJoinTypes: InviteJoinType; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; + acceptedAt: Date | null; + invitedByUser: AccessUserProfile | null; +} + +export interface JoinRequestRecord extends JoinRequest { + requesterUser: AccessUserProfile | null; + approvedByUser: AccessUserProfile | null; + rejectedByUser: AccessUserProfile | null; + invite: JoinRequestInviteSummary | null; +} + export interface InstanceUserRoleGrant { id: string; userId: string; @@ -79,3 +136,21 @@ export interface InstanceUserRoleGrant { createdAt: Date; updatedAt: Date; } + +export interface AdminUserDirectoryEntry extends AccessUserProfile { + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +} + +export interface UserCompanyAccessEntry extends CompanyMembership { + principalType: "user"; + companyName: string | null; + companyStatus: CompanyStatus | null; +} + +export interface UserCompanyAccessResponse { + user: (AccessUserProfile & { + isInstanceAdmin: boolean; + }) | null; + companyAccess: UserCompanyAccessEntry[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 888740d3d99..a78bde9c404 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -166,11 +166,20 @@ export type { ActivityEvent } from "./activity.js"; export type { SidebarBadges } from "./sidebar-badges.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; export type { + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, } from "./access.js"; export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 954a5bc6f9c..2aa50361557 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -4,6 +4,7 @@ import type { ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, } from "./workspace-runtime.js"; +import type { AgentEnvConfig } from "./secrets.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index c67584a5bf2..12eb0cf5504 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -50,6 +50,12 @@ export const listJoinRequestsQuerySchema = z.object({ export type ListJoinRequestsQuery = z.infer; +export const listCompanyInvitesQuerySchema = z.object({ + state: z.enum(["active", "revoked", "accepted", "expired"]).optional(), +}); + +export type ListCompanyInvitesQuery = z.infer; + export const claimJoinRequestApiKeySchema = z.object({ claimSecret: z.string().min(16).max(256), }); @@ -103,3 +109,58 @@ export const updateUserCompanyAccessSchema = z.object({ }); export type UpdateUserCompanyAccess = z.infer; + +export const searchAdminUsersQuerySchema = z.object({ + query: z.string().trim().max(120).optional().default(""), +}); + +export type SearchAdminUsersQuery = z.infer; + +const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/; + +function isValidProfileImage(value: string): boolean { + if (profileImageAssetPathPattern.test(value)) return true; + + try { + new URL(value); + return true; + } catch { + return false; + } +} + +const profileImageSchema = z + .string() + .trim() + .min(1) + .max(4000) + .refine(isValidProfileImage, { message: "Invalid profile image URL" }); + +export const currentUserProfileSchema = z.object({ + id: z.string().min(1), + email: z.string().email().nullable(), + name: z.string().min(1).max(120).nullable(), + image: profileImageSchema.nullable(), +}); + +export type CurrentUserProfile = z.infer; + +export const authSessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: currentUserProfileSchema, +}); + +export type AuthSession = z.infer; + +export const updateCurrentUserProfileSchema = z.object({ + name: z.string().trim().min(1).max(120), + image: z + .union([profileImageSchema, z.literal(""), z.null()]) + .optional() + .transform((value) => value === "" ? null : value), +}); + +export type UpdateCurrentUserProfile = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index aca19625694..896ebcea8d3 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -248,22 +248,34 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, } from "./access.js"; diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts new file mode 100644 index 00000000000..7ef45f149ef --- /dev/null +++ b/server/src/__tests__/auth-routes.test.ts @@ -0,0 +1,147 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createUpdateChain(row: unknown) { + return { + set(values: unknown) { + return { + where() { + return { + returning() { + return Promise.resolve([{ ...(row as Record), ...(values as Record) }]); + }, + }; + }, + }; + }, + }; +} + +function createDb(row: Record) { + return { + select: vi.fn(() => createSelectChain([row])), + update: vi.fn(() => createUpdateChain(row)), + } as any; +} + +async function createApp(actor: Express.Request["actor"], row: Record) { + const [{ authRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/auth.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api/auth", authRoutes(createDb(row))); + app.use(errorHandler); + return app; +} + +describe("auth routes", () => { + const baseUser = { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: "https://example.com/jane.png", + }; + + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the persisted user profile in the session payload", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + session: { + id: "paperclip:session:user-1", + userId: "user-1", + }, + user: baseUser, + }); + }); + + it("updates the signed-in profile", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator", image: "" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: null, + }); + }); + + it("accepts Paperclip asset paths for avatars", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "/api/assets/asset-1/content" }); + + expect(res.status).toBe(200); + expect(res.body.image).toBe("/api/assets/asset-1/content"); + }); + + it("rejects invalid avatar image references", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "not-a-url" }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts index 996b781ad95..2553a1421e4 100644 --- a/server/src/__tests__/invite-create-route.test.ts +++ b/server/src/__tests__/invite-create-route.test.ts @@ -34,7 +34,7 @@ function createDbStub() { allowedJoinTypes: "human", tokenHash: "hash", defaultsPayload: { humanRole: "viewer" }, - expiresAt: new Date("2027-03-07T00:10:00.000Z"), + expiresAt: new Date("2027-03-10T00:00:00.000Z"), invitedByUserId: null, revokedAt: null, acceptedAt: null, diff --git a/server/src/__tests__/invite-expiry.test.ts b/server/src/__tests__/invite-expiry.test.ts index c84a2a95572..66a7e7d2808 100644 --- a/server/src/__tests__/invite-expiry.test.ts +++ b/server/src/__tests__/invite-expiry.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { companyInviteExpiresAt } from "../routes/access.js"; describe("companyInviteExpiresAt", () => { - it("sets invite expiration to 10 minutes after invite creation time", () => { + it("sets invite expiration to 72 hours after invite creation time", () => { const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z"); const expiresAt = companyInviteExpiresAt(createdAtMs); - expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z"); + expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); }); }); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index c23bf5f63f9..3a750b88901 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -5,13 +5,17 @@ import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; function createSelectChain(rows: unknown[]) { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve(rows); + }, + }; return { from() { - return { - where() { - return Promise.resolve(rows); - }, - }; + return query; }, }; } @@ -48,7 +52,7 @@ function createApp(db: Record) { } describe("GET /invites/:token", () => { - it("returns companyName in the invite summary response", async () => { + it("returns company branding in the invite summary response", async () => { const invite = { id: "invite-1", companyId: "company-1", @@ -64,7 +68,11 @@ describe("GET /invites/:token", () => { updatedAt: new Date("2026-03-07T00:00:00.000Z"), }; const app = createApp( - createDbStub([invite], [{ name: "Acme Robotics" }]), + createDbStub([invite], [{ + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }]), ); const res = await request(app).get("/api/invites/pcp_invite_test"); @@ -72,6 +80,8 @@ describe("GET /invites/:token", () => { expect(res.status).toBe(200); expect(res.body.companyId).toBe("company-1"); expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.companyBrandColor).toBe("#114488"); + expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content"); expect(res.body.inviteType).toBe("company_join"); }); }); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 6a9578278d9..2374ec1e777 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -154,7 +154,6 @@ describe("issue feedback trace routes", () => { }); const res = await request(app).get("/api/feedback-traces/trace-1"); - expect(res.status).toBe(403); expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 0f288a843d4..15e3e73f27e 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -76,7 +76,8 @@ function createDbStub() { "feedbackDataSharingEnabled" in table; const select = vi.fn((selection?: unknown) => ({ from(table: unknown) { - return { + const query = { + leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockImplementation(() => { if (isInvitesTable(table)) { return Promise.resolve([createdInvite]); @@ -85,11 +86,16 @@ function createDbStub() { (selection && typeof selection === "object" && "name" in selection) || isCompaniesTable(table) ) { - return Promise.resolve([{ name: "Acme AI" }]); + return Promise.resolve([{ + name: "Acme AI", + brandColor: "#225577", + logoAssetId: "logo-1", + }]); } return Promise.resolve([]); }), }; + return query; }, })); return { @@ -208,6 +214,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(200); expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.companyBrandColor).toBe("#225577"); + expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content"); expect(res.body.inviteType).toBe("company_join"); expect(res.body.allowedJoinTypes).toBe("agent"); }); diff --git a/server/src/app.ts b/server/src/app.ts index dd89cd7f9ed..41521b4f6b4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -27,6 +27,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { llmRoutes } from "./routes/llms.js"; +import { authRoutes } from "./routes/auth.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; @@ -116,23 +117,7 @@ export async function createApp( resolveSession: opts.resolveSession, }), ); - app.get("/api/auth/get-session", (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - res.json({ - session: { - id: `paperclip:${req.actor.source}:${req.actor.userId}`, - userId: req.actor.userId, - }, - user: { - id: req.actor.userId, - email: null, - name: req.actor.source === "local_implicit" ? "Local Board" : null, - }, - }); - }); + app.use("/api/auth", authRoutes(db)); if (opts.betterAuthHandler) { app.all("/api/auth/{*authPath}", opts.betterAuthHandler); } diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 717b4a9db86..49cc413aadc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,14 +9,17 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, eq, isNull, desc } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, authUsers, companies, + companyLogos, + companyMemberships, invites, - joinRequests + joinRequests, + principalPermissionGrants, } from "@paperclipai/db"; import { acceptInviteSchema, @@ -24,8 +27,11 @@ import { claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, + searchAdminUsersQuerySchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS @@ -48,6 +54,11 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; import { assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, @@ -62,7 +73,7 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_"; const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const INVITE_TOKEN_SUFFIX_LENGTH = 8; const INVITE_TOKEN_MAX_RETRIES = 5; -const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000; +const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; function createInviteToken() { const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); @@ -858,8 +869,18 @@ function toInviteSummaryResponse( req: Request, token: string, invite: typeof invites.$inferSelect, - companyName: string | null = null + company: + | string + | { + name: string | null; + brandColor: string | null; + logoUrl: string | null; + } + | null = null ) { + const companyInfo = typeof company === "string" + ? { name: company, brandColor: null, logoUrl: null } + : company; const baseUrl = requestBaseUrl(req); const invitePath = `/invite/${token}`; const onboardingPath = `/api/invites/${token}/onboarding`; @@ -868,9 +889,12 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, - companyName, + companyName: companyInfo?.name ?? null, + companyLogoUrl: companyInfo?.logoUrl ?? null, + companyBrandColor: companyInfo?.brandColor ?? null, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), expiresAt: invite.expiresAt, invitePath, inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath, @@ -888,6 +912,261 @@ function toInviteSummaryResponse( }; } +async function loadUsersById(db: Db, userIds: string[]) { + if (userIds.length === 0) return new Map>(); + const rows = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)); + return new Map(rows.map((row) => [row.id, toUserProfile(row)])); +} + +async function loadCompanyAccessSummary( + req: Request, + access: ReturnType, + companyId: string, +) { + if (req.actor.type !== "board") { + return { + currentUserRole: null, + canManageMembers: false, + canInviteUsers: false, + canApproveJoinRequests: false, + }; + } + if (isLocalImplicit(req)) { + return { + currentUserRole: "owner" as const, + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: true, + }; + } + const userId = req.actor.userId ?? null; + const membership = + userId ? await access.getMembership(companyId, "user", userId) : null; + const [canManageMembers, canInviteUsers, canApproveJoinRequests] = + await Promise.all([ + access.canUser(companyId, userId, "users:manage_permissions"), + access.canUser(companyId, userId, "users:invite"), + access.canUser(companyId, userId, "joins:approve"), + ]); + + return { + currentUserRole: + membership?.status === "active" && membership.membershipRole + ? normalizeHumanRole(membership.membershipRole, "operator") + : null, + canManageMembers, + canInviteUsers, + canApproveJoinRequests, + }; +} + +async function loadCompanyMemberRecords(db: Db, companyId: string) { + const members = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const [userMap, grants] = await Promise.all([ + loadUsersById(db, userIds), + userIds.length > 0 + ? db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + inArray(principalPermissionGrants.principalId, userIds), + ), + ) + : Promise.resolve([]), + ]); + + const grantsByPrincipalId = new Map(); + for (const grant of grants) { + const existing = grantsByPrincipalId.get(grant.principalId) ?? []; + existing.push(grant); + grantsByPrincipalId.set(grant.principalId, existing); + } + + return members.map((member) => ({ + ...member, + principalType: "user" as const, + membershipRole: member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : null, + user: userMap.get(member.principalId) ?? null, + grants: grantsByPrincipalId.get(member.principalId) ?? [], + })); +} + +async function loadCompanyInviteRecords(db: Db, companyId: string) { + const rows = await db + .select() + .from(invites) + .where(eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)); + const userIds = [ + ...new Set( + rows + .map((invite) => invite.invitedByUserId) + .filter((value): value is string => Boolean(value)), + ), + ]; + const [userMap, joinRows, companyName] = await Promise.all([ + loadUsersById(db, userIds), + db + .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)), + db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((companyRows) => companyRows[0]?.name ?? null), + ]); + const joinRequestIdByInviteId = new Map( + joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]), + ); + + return rows.map((invite) => ({ + ...invite, + companyName, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + state: inviteState(invite), + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, + })); +} + +async function loadJoinRequestRecords(db: Db, companyId: string) { + const rows = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)) + .orderBy(desc(joinRequests.createdAt)); + const inviteIds = [...new Set(rows.map((row) => row.inviteId))]; + const inviteRows = inviteIds.length + ? await db + .select() + .from(invites) + .where(inArray(invites.id, inviteIds)) + : []; + const userIds = [ + ...new Set( + [ + ...rows.map((row) => row.requestingUserId), + ...rows.map((row) => row.approvedByUserId), + ...rows.map((row) => row.rejectedByUserId), + ...inviteRows.map((invite) => invite.invitedByUserId), + ].filter((value): value is string => Boolean(value)), + ), + ]; + const userMap = await loadUsersById(db, userIds); + const inviteMap = new Map(inviteRows.map((invite) => [invite.id, invite])); + + return rows.map((row) => { + const invite = inviteMap.get(row.inviteId) ?? null; + return { + ...toJoinRequestResponse(row), + requesterUser: row.requestingUserId + ? userMap.get(row.requestingUserId) ?? null + : null, + approvedByUser: row.approvedByUserId + ? userMap.get(row.approvedByUserId) ?? null + : null, + rejectedByUser: row.rejectedByUserId + ? userMap.get(row.rejectedByUserId) ?? null + : null, + invite: invite + ? { + id: invite.id, + inviteType: invite.inviteType, + allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + revokedAt: invite.revokedAt, + acceptedAt: invite.acceptedAt, + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + } + : null, + }; + }); +} + +async function loadUserCompanyAccessResponse( + db: Db, + access: ReturnType, + userId: string, +) { + const [memberships, user, isInstanceAdmin] = await Promise.all([ + access.listUserCompanyAccess(userId), + db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null), + access.isInstanceAdmin(userId), + ]); + const companyIds = [...new Set(memberships.map((membership) => membership.companyId))]; + const companyRows = companyIds.length + ? await db + .select({ + id: companies.id, + name: companies.name, + status: companies.status, + }) + .from(companies) + .where(inArray(companies.id, companyIds)) + : []; + const companyMap = new Map(companyRows.map((company) => [company.id, company])); + + return { + user: user + ? { + ...toUserProfile(user), + isInstanceAdmin, + } + : null, + companyAccess: memberships.map((membership) => { + const company = companyMap.get(membership.companyId) ?? null; + return { + ...membership, + principalType: "user" as const, + companyName: company?.name ?? null, + companyStatus: company?.status ?? null, + }; + }), + }; +} + function buildOnboardingDiscoveryDiagnostics(input: { apiBaseUrl: string; deploymentMode: DeploymentMode; @@ -1352,12 +1631,22 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null + agentMessage: string | null, + humanRole: "owner" | "admin" | "operator" | "viewer" | null = null, ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" ? { ...defaultsPayload } : {}; + if (humanRole) { + const existingHuman = + isPlainObject(merged.human) ? { ...(merged.human as Record) } : {}; + merged.human = { + ...existingHuman, + role: humanRole, + grants: grantsForHumanRole(humanRole), + }; + } if (agentMessage) { merged.agentMessage = agentMessage; } @@ -1377,10 +1666,44 @@ function inviteExpired(invite: typeof invites.$inferSelect) { return invite.expiresAt.getTime() <= Date.now(); } +function inviteState(invite: typeof invites.$inferSelect) { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (inviteExpired(invite)) return "expired" as const; + return "active" as const; +} + +function extractInviteHumanRole(invite: typeof invites.$inferSelect) { + if (invite.allowedJoinTypes === "agent") return null; + return resolveHumanInviteRole( + invite.defaultsPayload as Record | null | undefined, + ); +} + function isLocalImplicit(req: Request) { return req.actor.type === "board" && req.actor.source === "local_implicit"; } +function toUserProfile( + user: + | { + id: string; + email: string | null; + name: string | null; + image?: string | null; + } + | null + | undefined, +) { + if (!user) return null; + return { + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }; +} + async function resolveActorEmail(db: Db, req: Request): Promise { if (isLocalImplicit(req)) return "local@paperclip.local"; const userId = req.actor.userId; @@ -1851,6 +2174,7 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: "owner" | "admin" | "operator" | "viewer" | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1858,13 +2182,18 @@ export function accessRoutes( typeof input.agentMessage === "string" ? input.agentMessage.trim() || null : null; + const effectiveHumanRole = + input.allowedJoinTypes === "agent" + ? null + : input.humanRole ?? "operator"; const insertValues = { companyId: input.companyId, inviteType: "company_join" as const, allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage + normalizedAgentMessage, + effectiveHumanRole, ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1899,14 +2228,25 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } - async function getInviteCompanyName(companyId: string | null) { - if (!companyId) return null; + async function getInviteCompanyBranding(companyId: string | null) { + if (!companyId) { + return { name: null, brandColor: null, logoUrl: null }; + } const company = await db - .select({ name: companies.name }) + .select({ + name: companies.name, + brandColor: companies.brandColor, + logoAssetId: companyLogos.assetId, + }) .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); - return company?.name ?? null; + return { + name: company?.name ?? null, + brandColor: company?.brandColor ?? null, + logoUrl: company?.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, + }; } router.get("/skills/available", (_req, res) => { @@ -1947,6 +2287,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, + humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -1965,23 +2306,24 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + humanRole: extractInviteHumanRole(created), hasAgentMessage: Boolean(normalizedAgentMessage) } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, invitePath: inviteSummary.invitePath, inviteUrl: inviteSummary.inviteUrl, - companyName, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2000,6 +2342,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: "agent", + humanRole: null, defaultsPayload: null, agentMessage: req.body.agentMessage ?? null }); @@ -2022,19 +2365,19 @@ export function accessRoutes( } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, invitePath: inviteSummary.invitePath, inviteUrl: inviteSummary.inviteUrl, - companyName, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2059,8 +2402,8 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); - res.json(toInviteSummaryResponse(req, token, invite, companyName)); + const companyBranding = await getInviteCompanyBranding(invite.companyId); + res.json(toInviteSummaryResponse(req, token, invite, companyBranding)); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2075,10 +2418,10 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res.json(buildInviteOnboardingManifest(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name })); }); @@ -2094,13 +2437,13 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res .type("text/plain; charset=utf-8") .send( buildInviteOnboardingTextDocument(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name }) ); }); @@ -2512,14 +2855,14 @@ export function accessRoutes( const response = toJoinRequestResponse(created); if (claimSecret) { - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, { ...opts, - companyName + companyName: companyBranding.name } ); res.status(202).json({ @@ -2581,22 +2924,30 @@ export function accessRoutes( res.json(revoked); }); + router.get("/companies/:companyId/invites", async (req, res) => { + const companyId = req.params.companyId as string; + await assertCompanyPermission(req, companyId, "users:invite"); + const query = listCompanyInvitesQuerySchema.parse(req.query); + const invitesForCompany = await loadCompanyInviteRecords(db, companyId); + res.json( + invitesForCompany.filter((invite) => + query.state ? invite.state === query.state : true, + ), + ); + }); + router.get("/companies/:companyId/join-requests", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "joins:approve"); const query = listJoinRequestsQuerySchema.parse(req.query); - const all = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)) - .orderBy(desc(joinRequests.createdAt)); + const all = await loadJoinRequestRecords(db, companyId); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; if (query.requestType && row.requestType !== query.requestType) return false; return true; }); - res.json(filtered.map(toJoinRequestResponse)); + res.json(filtered); }); router.post( @@ -2631,11 +2982,14 @@ export function accessRoutes( if (existing.requestType === "human") { if (!existing.requestingUserId) throw conflict("Join request missing user identity"); + const membershipRole = resolveHumanInviteRole( + invite.defaultsPayload as Record | null, + ); await access.ensureMembership( companyId, "user", existing.requestingUserId, - "member", + membershipRole, "active" ); const grants = grantsFromDefaults( @@ -2877,10 +3231,80 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); - const members = await access.listMembers(companyId); - res.json(members); + const [members, currentAccess] = await Promise.all([ + loadCompanyMemberRecords(db, companyId), + loadCompanyAccessSummary(req, access, companyId), + ]); + res.json({ + members, + access: currentAccess, + }); }); + router.patch( + "/companies/:companyId/members/:memberId", + validate(updateCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + + const existing = await access.getMemberById(companyId, memberId); + if (!existing) throw notFound("Member not found"); + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const updated = await access.updateMember(companyId, memberId, req.body); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), @@ -2895,7 +3319,22 @@ export function accessRoutes( req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); - res.json(updated); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.permissions_updated", + entityType: "company_membership", + entityId: memberId, + details: { + grantCount: req.body.grants?.length ?? 0, + }, + }); + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); } ); @@ -2909,6 +3348,66 @@ export function accessRoutes( } ); + router.get("/admin/users", async (req, res) => { + await assertInstanceAdmin(req); + const query = searchAdminUsersQuerySchema.parse(req.query); + const needle = query.query.trim().toLowerCase(); + const users = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .orderBy(desc(authUsers.updatedAt)); + const filteredUsers = needle + ? users.filter((user) => + [user.name, user.email] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(needle)), + ) + : users; + const userIds = filteredUsers.slice(0, 50).map((user) => user.id); + const memberships = userIds.length + ? await db + .select({ + principalId: companyMemberships.principalId, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.principalId, userIds), + ), + ) + : []; + const membershipCountByUserId = new Map(); + for (const membership of memberships) { + membershipCountByUserId.set( + membership.principalId, + (membershipCountByUserId.get(membership.principalId) ?? 0) + 1, + ); + } + const adminIds = new Set( + await Promise.all( + userIds.map(async (userId) => + (await access.isInstanceAdmin(userId)) ? userId : null, + ), + ).then((values) => values.filter((value): value is string => Boolean(value))), + ); + + res.json( + filteredUsers.slice(0, 50).map((user) => ({ + ...toUserProfile(user), + isInstanceAdmin: adminIds.has(user.id), + activeCompanyMembershipCount: + membershipCountByUserId.get(user.id) ?? 0, + })), + ); + }); + router.post( "/admin/users/:userId/demote-instance-admin", async (req, res) => { @@ -2923,8 +3422,7 @@ export function accessRoutes( router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.listUserCompanyAccess(userId); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); }); router.put( @@ -2933,11 +3431,11 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess( + await access.setUserCompanyAccess( userId, req.body.companyIds ?? [] ); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); } ); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 00000000000..e7b0ad4c3bd --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { authUsers } from "@paperclipai/db"; +import { + authSessionSchema, + currentUserProfileSchema, + updateCurrentUserProfileSchema, +} from "@paperclipai/shared"; +import { unauthorized } from "../errors.js"; +import { validate } from "../middleware/validate.js"; + +async function loadCurrentUserProfile(db: Db, userId: string) { + const user = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null); + + if (!user) { + throw unauthorized("Signed-in user not found"); + } + + return currentUserProfileSchema.parse({ + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }); +} + +export function authRoutes(db: Db) { + const router = Router(); + + router.get("/get-session", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const user = await loadCurrentUserProfile(db, req.actor.userId); + res.json(authSessionSchema.parse({ + session: { + id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`, + userId: req.actor.userId, + }, + user, + })); + }); + + router.get("/profile", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + res.json(await loadCurrentUserProfile(db, req.actor.userId)); + }); + + router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const patch = updateCurrentUserProfileSchema.parse(req.body); + const now = new Date(); + + const updated = await db + .update(authUsers) + .set({ + name: patch.name, + image: patch.image ?? null, + updatedAt: now, + }) + .where(eq(authUsers.id, req.actor.userId)) + .returning({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .then((rows) => rows[0] ?? null); + + if (!updated) { + throw unauthorized("Signed-in user not found"); + } + + res.json(currentUserProfileSchema.parse({ + id: updated.id, + email: updated.email ?? null, + name: updated.name ?? null, + image: updated.image ?? null, + })); + }); + + return router; +}