feat(access): add multi-user auth and access APIs

This commit is contained in:
Dotta committed 2026-04-11 08:12:54 -05:00
1 parent 682ffe6795
commit 0c07a8235f
15 files changed
+1000 -69

No files matched your search

+26
View File
@@ -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,
+75
View File
@@ -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[];
}
+9
View File
@@ -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 {
+1
View File
@@ -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";
+61
View File
@@ -50,6 +50,12 @@ export const listJoinRequestsQuerySchema = z.object({
export type ListJoinRequestsQuery = z.infer<typeof listJoinRequestsQuerySchema>;
export const listCompanyInvitesQuerySchema = z.object({
state: z.enum(["active", "revoked", "accepted", "expired"]).optional(),
});
export type ListCompanyInvitesQuery = z.infer<typeof listCompanyInvitesQuerySchema>;
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<typeof updateUserCompanyAccessSchema>;
export const searchAdminUsersQuerySchema = z.object({
query: z.string().trim().max(120).optional().default(""),
});
export type SearchAdminUsersQuery = z.infer<typeof searchAdminUsersQuerySchema>;
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<typeof currentUserProfileSchema>;
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<typeof authSessionSchema>;
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<typeof updateCurrentUserProfileSchema>;
+12
View File
@@ -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";
+147
View File
@@ -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<string, unknown>), ...(values as Record<string, unknown>) }]);
},
};
},
};
},
};
}
function createDb(row: Record<string, unknown>) {
return {
select: vi.fn(() => createSelectChain([row])),
update: vi.fn(() => createUpdateChain(row)),
} as any;
}
async function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
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);
});
});
@@ -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,
+2 -2
View File
@@ -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");
});
});
@@ -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<string, unknown>) {
}
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");
});
});
@@ -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();
});
@@ -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");
});
+2 -17
View File
@@ -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);
}
+537 -39
View File
@@ -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<string, ReturnType<typeof toUserProfile>>();
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<typeof accessService>,
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<string, typeof grants>();
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<typeof accessService>,
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<string, unknown> | null | undefined,
agentMessage: string | null
agentMessage: string | null,
humanRole: "owner" | "admin" | "operator" | "viewer" | null = null,
): Record<string, unknown> | null {
const merged =
defaultsPayload && typeof defaultsPayload === "object"
? { ...defaultsPayload }
: {};
if (humanRole) {
const existingHuman =
isPlainObject(merged.human) ? { ...(merged.human as Record<string, unknown>) } : {};
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<string, unknown> | 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<string | null> {
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<string, unknown> | 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<string, unknown> | 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<string, number>();
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));
}
);
+100
View File
@@ -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;
}