From e1f2be7ecff711f2f5db9a0f64dbd3133df4410e Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 14:40:32 -0600 Subject: [PATCH] feat(server): integrate Better Auth, access control, and deployment mode startup Wire up Better Auth for session-based authentication. Add actor middleware that resolves local_trusted mode to an implicit board actor and authenticated mode to Better Auth sessions. Add access service with membership, permission, invite, and join-request management. Register access routes for member/invite/ join-request CRUD. Update health endpoint to report deployment mode and bootstrap status. Enforce tasks:assign and agents:create permissions in issue and agent routes. Add deployment mode validation at startup with guardrails (loopback-only for local_trusted, auth config required for authenticated). Co-Authored-By: Claude Opus 4.6 --- .../__tests__/board-mutation-guard.test.ts | 12 +- server/src/app.ts | 38 +- server/src/auth/better-auth.ts | 105 ++++ server/src/config.ts | 55 +- server/src/index.ts | 143 ++++- server/src/middleware/auth.ts | 53 +- server/src/middleware/board-mutation-guard.ts | 8 + server/src/realtime/live-events-ws.ts | 15 +- server/src/routes/access.ts | 554 ++++++++++++++++++ server/src/routes/agents.ts | 63 +- server/src/routes/authz.ts | 14 +- server/src/routes/companies.ts | 38 +- server/src/routes/health.ts | 42 +- server/src/routes/index.ts | 1 + server/src/routes/issues.ts | 67 ++- server/src/routes/sidebar-badges.ts | 24 +- server/src/services/access.ts | 268 +++++++++ server/src/services/companies.ts | 8 + server/src/services/heartbeat.ts | 2 +- server/src/services/index.ts | 1 + server/src/services/issues.ts | 47 +- server/src/services/sidebar-badges.ts | 6 +- server/src/startup-banner.ts | 10 +- server/src/types/express.d.ts | 5 +- 24 files changed, 1530 insertions(+), 49 deletions(-) create mode 100644 server/src/auth/better-auth.ts create mode 100644 server/src/routes/access.ts create mode 100644 server/src/services/access.ts diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index ae28e7d2..1ef1d495 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -3,11 +3,13 @@ import express from "express"; import request from "supertest"; import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; -function createApp(actorType: "board" | "agent") { +function createApp(actorType: "board" | "agent", boardSource: "session" | "local_implicit" = "session") { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - req.actor = actorType === "board" ? { type: "board", userId: "board" } : { type: "agent", agentId: "agent-1" }; + req.actor = actorType === "board" + ? { type: "board", userId: "board", source: boardSource } + : { type: "agent", agentId: "agent-1" }; next(); }); app.use(boardMutationGuard()); @@ -34,6 +36,12 @@ describe("boardMutationGuard", () => { expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" }); }); + it("allows local implicit board mutations without origin", async () => { + const app = createApp("board", "local_implicit"); + const res = await request(app).post("/mutate").send({ ok: true }); + expect(res.status).toBe(204); + }); + it("allows board mutations from trusted origin", async () => { const app = createApp("board"); const res = await request(app) diff --git a/server/src/app.ts b/server/src/app.ts index 8a136bc5..a85fbaa2 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,8 +1,9 @@ -import express, { Router } from "express"; +import express, { Router, type Request as ExpressRequest } from "express"; import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclip/db"; +import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared"; import type { StorageService } from "./storage/types.js"; import { httpLogger, errorHandler } from "./middleware/index.js"; import { actorMiddleware } from "./middleware/auth.js"; @@ -21,21 +22,49 @@ import { dashboardRoutes } from "./routes/dashboard.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; +import { accessRoutes } from "./routes/access.js"; +import type { BetterAuthSessionResult } from "./auth/better-auth.js"; type UiMode = "none" | "static" | "vite-dev"; -export async function createApp(db: Db, opts: { uiMode: UiMode; storageService: StorageService }) { +export async function createApp( + db: Db, + opts: { + uiMode: UiMode; + storageService: StorageService; + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + authReady: boolean; + betterAuthHandler?: express.RequestHandler; + resolveSession?: (req: ExpressRequest) => Promise; + }, +) { const app = express(); app.use(express.json()); app.use(httpLogger); - app.use(actorMiddleware(db)); + app.use( + actorMiddleware(db, { + deploymentMode: opts.deploymentMode, + resolveSession: opts.resolveSession, + }), + ); + if (opts.betterAuthHandler) { + app.all("/api/auth/*authPath", opts.betterAuthHandler); + } app.use(llmRoutes(db)); // Mount API routes const api = Router(); api.use(boardMutationGuard()); - api.use("/health", healthRoutes()); + api.use( + "/health", + healthRoutes(db, { + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + authReady: opts.authReady, + }), + ); api.use("/companies", companyRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); @@ -48,6 +77,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode; storageService: api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); + api.use(accessRoutes(db)); app.use("/api", api); const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts new file mode 100644 index 00000000..6d8f1b0f --- /dev/null +++ b/server/src/auth/better-auth.ts @@ -0,0 +1,105 @@ +import type { Request, RequestHandler } from "express"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { toNodeHandler } from "better-auth/node"; +import type { Db } from "@paperclip/db"; +import { + authAccounts, + authSessions, + authUsers, + authVerifications, +} from "@paperclip/db"; +import type { Config } from "../config.js"; + +export type BetterAuthSessionUser = { + id: string; + email?: string | null; + name?: string | null; +}; + +export type BetterAuthSessionResult = { + session: { id: string; userId: string } | null; + user: BetterAuthSessionUser | null; +}; + +type BetterAuthInstance = ReturnType; + +function headersFromExpressRequest(req: Request): Headers { + const headers = new Headers(); + for (const [key, raw] of Object.entries(req.headers)) { + if (!raw) continue; + if (Array.isArray(raw)) { + for (const value of raw) headers.append(key, value); + continue; + } + headers.set(key, raw); + } + return headers; +} + +export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance { + const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; + + const authConfig = { + baseURL: baseUrl, + secret, + database: drizzleAdapter(db, { + provider: "pg", + schema: { + user: authUsers, + session: authSessions, + account: authAccounts, + verification: authVerifications, + }, + }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + }, + }; + + if (!baseUrl) { + delete (authConfig as { baseURL?: string }).baseURL; + } + + return betterAuth(authConfig); +} + +export function createBetterAuthHandler(auth: BetterAuthInstance): RequestHandler { + const handler = toNodeHandler(auth); + return (req, res, next) => { + void Promise.resolve(handler(req, res)).catch(next); + }; +} + +export async function resolveBetterAuthSession( + auth: BetterAuthInstance, + req: Request, +): Promise { + const api = (auth as unknown as { api?: { getSession?: (input: unknown) => Promise } }).api; + if (!api?.getSession) return null; + + const sessionValue = await api.getSession({ + headers: headersFromExpressRequest(req), + }); + if (!sessionValue || typeof sessionValue !== "object") return null; + + const value = sessionValue as { + session?: { id?: string; userId?: string } | null; + user?: { id?: string; email?: string | null; name?: string | null } | null; + }; + const session = value.session?.id && value.session.userId + ? { id: value.session.id, userId: value.session.userId } + : null; + const user = value.user?.id + ? { + id: value.user.id, + email: value.user.email ?? null, + name: value.user.name ?? null, + } + : null; + + if (!session || !user) return null; + return { session, user }; +} diff --git a/server/src/config.ts b/server/src/config.ts index 41d1a0fb..e9f0a0eb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,7 +2,18 @@ import { readConfigFile } from "./config-file.js"; import { existsSync } from "node:fs"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; -import { SECRET_PROVIDERS, STORAGE_PROVIDERS, type SecretProvider, type StorageProvider } from "@paperclip/shared"; +import { + AUTH_BASE_URL_MODES, + DEPLOYMENT_EXPOSURES, + DEPLOYMENT_MODES, + SECRET_PROVIDERS, + STORAGE_PROVIDERS, + type AuthBaseUrlMode, + type DeploymentExposure, + type DeploymentMode, + type SecretProvider, + type StorageProvider, +} from "@paperclip/shared"; import { resolveDefaultEmbeddedPostgresDir, resolveDefaultSecretsKeyFilePath, @@ -18,7 +29,12 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) { type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + host: string; port: number; + authBaseUrlMode: AuthBaseUrlMode; + authPublicBaseUrl: string | undefined; databaseMode: DatabaseMode; databaseUrl: string | undefined; embeddedPostgresDataDir: string; @@ -84,8 +100,45 @@ export function loadConfig(): Config { ? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true" : (fileStorage?.s3?.forcePathStyle ?? false); + const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE; + const deploymentModeFromEnv = + deploymentModeFromEnvRaw && DEPLOYMENT_MODES.includes(deploymentModeFromEnvRaw as DeploymentMode) + ? (deploymentModeFromEnvRaw as DeploymentMode) + : null; + const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted"; + const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE; + const deploymentExposureFromEnv = + deploymentExposureFromEnvRaw && + DEPLOYMENT_EXPOSURES.includes(deploymentExposureFromEnvRaw as DeploymentExposure) + ? (deploymentExposureFromEnvRaw as DeploymentExposure) + : null; + const deploymentExposure: DeploymentExposure = + deploymentMode === "local_trusted" + ? "private" + : (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private"); + const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE; + const authBaseUrlModeFromEnv = + authBaseUrlModeFromEnvRaw && + AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) + ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) + : null; + const authPublicBaseUrlRaw = + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + fileConfig?.auth?.publicBaseUrl; + const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; + const authBaseUrlMode: AuthBaseUrlMode = + authBaseUrlModeFromEnv ?? + fileConfig?.auth?.baseUrlMode ?? + (authPublicBaseUrl ? "explicit" : "auto"); + return { + deploymentMode, + deploymentExposure, + host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1", port: Number(process.env.PORT) || fileConfig?.server.port || 3100, + authBaseUrlMode, + authPublicBaseUrl, databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, embeddedPostgresDataDir: resolveHomeAwarePath( diff --git a/server/src/index.ts b/server/src/index.ts index b00c1801..4a3d8bbd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,12 +3,18 @@ import { createServer } from "node:http"; import { resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import type { Request as ExpressRequest } from "express"; +import { and, eq } from "drizzle-orm"; import { createDb, ensurePostgresDatabase, inspectMigrations, applyPendingMigrations, reconcilePendingMigrationHistory, + authUsers, + companies, + companyMemberships, + instanceUserRoles, } from "@paperclip/db"; import detectPort from "detect-port"; import { createApp } from "./app.js"; @@ -18,6 +24,11 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { heartbeatService } from "./services/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; +import { + createBetterAuthHandler, + createBetterAuthInstance, + resolveBetterAuthSession, +} from "./auth/better-auth.js"; type EmbeddedPostgresInstance = { initialise(): Promise; @@ -121,6 +132,71 @@ async function ensureMigrations(connectionString: string, label: string): Promis return "applied (pending migrations)"; } +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; +} + +const LOCAL_BOARD_USER_ID = "local-board"; +const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; +const LOCAL_BOARD_USER_NAME = "Board"; + +async function ensureLocalTrustedBoardPrincipal(db: any): Promise { + const now = new Date(); + const existingUser = await db + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + + if (!existingUser) { + await db.insert(authUsers).values({ + id: LOCAL_BOARD_USER_ID, + name: LOCAL_BOARD_USER_NAME, + email: LOCAL_BOARD_USER_EMAIL, + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); + } + + const role = await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (!role) { + await db.insert(instanceUserRoles).values({ + userId: LOCAL_BOARD_USER_ID, + role: "instance_admin", + }); + } + + const companyRows = await db.select({ id: companies.id }).from(companies); + for (const company of companyRows) { + const membership = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID), + ), + ) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (membership) continue; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: LOCAL_BOARD_USER_ID, + status: "active", + membershipRole: "owner", + }); + } +} + let db; let embeddedPostgres: EmbeddedPostgresInstance | null = null; let embeddedPostgresStartedByThisProcess = false; @@ -217,9 +293,64 @@ if (config.databaseUrl) { startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } +if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { + throw new Error( + `local_trusted mode requires loopback host binding (received: ${config.host}). ` + + "Use authenticated mode for non-loopback deployments.", + ); +} + +if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { + throw new Error("local_trusted mode only supports private exposure"); +} + +if (config.deploymentMode === "authenticated") { + if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { + throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); + } + if (config.deploymentExposure === "public") { + if (config.authBaseUrlMode !== "explicit") { + throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit"); + } + if (!config.authPublicBaseUrl) { + throw new Error("authenticated public exposure requires auth.publicBaseUrl"); + } + } +} + +let authReady = config.deploymentMode === "local_trusted"; +let betterAuthHandler: ReturnType | undefined; +let resolveSession: + | ((req: ExpressRequest) => Promise>>) + | undefined; +if (config.deploymentMode === "local_trusted") { + await ensureLocalTrustedBoardPrincipal(db as any); +} +if (config.deploymentMode === "authenticated") { + const betterAuthSecret = + process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (!betterAuthSecret) { + throw new Error( + "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", + ); + } + const auth = createBetterAuthInstance(db as any, config); + betterAuthHandler = createBetterAuthHandler(auth); + resolveSession = (req) => resolveBetterAuthSession(auth, req); + authReady = true; +} + const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); -const app = await createApp(db as any, { uiMode, storageService }); +const app = await createApp(db as any, { + uiMode, + storageService, + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authReady, + betterAuthHandler, + resolveSession, +}); const server = createServer(app); const listenPort = await detectPort(config.port); @@ -227,7 +358,7 @@ if (listenPort !== config.port) { logger.warn({ requestedPort: config.port, selectedPort: listenPort }, "Requested port is busy; using next free port"); } -setupLiveEventsWebSocketServer(server, db as any); +setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode }); if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any); @@ -258,9 +389,13 @@ if (config.heartbeatSchedulerEnabled) { }, config.heartbeatSchedulerIntervalMs); } -server.listen(listenPort, () => { - logger.info(`Server listening on :${listenPort}`); +server.listen(listenPort, config.host, () => { + logger.info(`Server listening on ${config.host}:${listenPort}`); printStartupBanner({ + host: config.host, + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authReady, requestedPort: config.port, listenPort, uiMode, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index fbf4be06..977d5a5b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,22 +1,65 @@ import { createHash } from "node:crypto"; -import type { RequestHandler } from "express"; +import type { Request, RequestHandler } from "express"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agentApiKeys, agents } from "@paperclip/db"; +import { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclip/db"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; +import type { DeploymentMode } from "@paperclip/shared"; +import type { BetterAuthSessionResult } from "../auth/better-auth.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } -export function actorMiddleware(db: Db): RequestHandler { +interface ActorMiddlewareOptions { + deploymentMode: DeploymentMode; + resolveSession?: (req: Request) => Promise; +} + +export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler { return async (req, _res, next) => { - req.actor = { type: "board", userId: "board" }; + req.actor = + opts.deploymentMode === "local_trusted" + ? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" } + : { type: "none", source: "none" }; const runIdHeader = req.header("x-paperclip-run-id"); const authHeader = req.header("authorization"); if (!authHeader?.toLowerCase().startsWith("bearer ")) { + if (opts.deploymentMode === "authenticated" && opts.resolveSession) { + const session = await opts.resolveSession(req); + if (session?.user?.id) { + const userId = session.user.id; + const [roleRow, memberships] = await Promise.all([ + db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null), + db + .select({ companyId: companyMemberships.companyId }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + eq(companyMemberships.status, "active"), + ), + ), + ]); + req.actor = { + type: "board", + userId, + companyIds: memberships.map((row) => row.companyId), + isInstanceAdmin: Boolean(roleRow), + runId: runIdHeader ?? undefined, + source: "session", + }; + next(); + return; + } + } if (runIdHeader) req.actor.runId = runIdHeader; next(); return; @@ -64,6 +107,7 @@ export function actorMiddleware(db: Db): RequestHandler { companyId: claims.company_id, keyId: undefined, runId: runIdHeader || claims.run_id || undefined, + source: "agent_jwt", }; next(); return; @@ -91,6 +135,7 @@ export function actorMiddleware(db: Db): RequestHandler { companyId: key.companyId, keyId: key.id, runId: runIdHeader || undefined, + source: "agent_key", }; next(); diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index 5520fc86..e77cb8d4 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -51,6 +51,14 @@ export function boardMutationGuard(): RequestHandler { return; } + // Local-trusted mode uses an implicit board actor for localhost-only development. + // In this mode, origin/referer headers can be omitted by some clients for multipart + // uploads; do not block those mutations. + if (req.actor.source === "local_implicit") { + next(); + return; + } + if (!isTrustedBoardMutationRequest(req)) { res.status(403).json({ error: "Board mutation requires trusted browser origin" }); return; diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index cdcbf8a2..dcc1d46e 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -4,6 +4,7 @@ import type { Duplex } from "node:stream"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { agentApiKeys } from "@paperclip/db"; +import type { DeploymentMode } from "@paperclip/shared"; import { WebSocket, WebSocketServer } from "ws"; import { logger } from "../middleware/logger.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; @@ -52,13 +53,17 @@ async function authorizeUpgrade( req: IncomingMessage, companyId: string, url: URL, + deploymentMode: DeploymentMode, ): Promise { const queryToken = url.searchParams.get("token")?.trim() ?? ""; const authToken = parseBearerToken(req.headers.authorization); const token = authToken ?? (queryToken.length > 0 ? queryToken : null); - // Browser board context has no bearer token in V1. + // Local trusted browser board context has no bearer token in V1. if (!token) { + if (deploymentMode !== "local_trusted") { + return null; + } return { companyId, actorType: "board", @@ -89,7 +94,11 @@ async function authorizeUpgrade( }; } -export function setupLiveEventsWebSocketServer(server: HttpServer, db: Db) { +export function setupLiveEventsWebSocketServer( + server: HttpServer, + db: Db, + opts: { deploymentMode: DeploymentMode }, +) { const wss = new WebSocketServer({ noServer: true }); const cleanupByClient = new Map void>(); const aliveByClient = new Map(); @@ -153,7 +162,7 @@ export function setupLiveEventsWebSocketServer(server: HttpServer, db: Db) { return; } - void authorizeUpgrade(db, req, companyId, url) + void authorizeUpgrade(db, req, companyId, url, opts.deploymentMode) .then((context) => { if (!context) { rejectUpgrade(socket, "403 Forbidden", "forbidden"); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts new file mode 100644 index 00000000..75581e40 --- /dev/null +++ b/server/src/routes/access.ts @@ -0,0 +1,554 @@ +import { createHash, randomBytes } from "node:crypto"; +import { Router } from "express"; +import type { Request } from "express"; +import { and, eq, isNull, desc } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { + agentApiKeys, + authUsers, + invites, + joinRequests, +} from "@paperclip/db"; +import { + acceptInviteSchema, + createCompanyInviteSchema, + listJoinRequestsQuerySchema, + updateMemberPermissionsSchema, + updateUserCompanyAccessSchema, + PERMISSION_KEYS, +} from "@paperclip/shared"; +import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService, agentService, logActivity } from "../services/index.js"; +import { assertCompanyAccess } from "./authz.js"; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createInviteToken() { + return `pcp_invite_${randomBytes(24).toString("hex")}`; +} + +function requestIp(req: Request) { + const forwarded = req.header("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + return req.ip || "unknown"; +} + +function inviteExpired(invite: typeof invites.$inferSelect) { + return invite.expiresAt.getTime() <= Date.now(); +} + +function isLocalImplicit(req: Request) { + return req.actor.type === "board" && req.actor.source === "local_implicit"; +} + +async function resolveActorEmail(db: Db, req: Request): Promise { + if (isLocalImplicit(req)) return "local@paperclip.local"; + const userId = req.actor.userId; + if (!userId) return null; + const user = await db + .select({ email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null); + return user?.email ?? null; +} + +function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent", +): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record | null }> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && typeof record.scope === "object" && !Array.isArray(record.scope) + ? (record.scope as Record) + : null, + }); + } + return result; +} + +export function accessRoutes(db: Db) { + const router = Router(); + const access = accessService(db); + const agents = agentService(db); + + async function assertInstanceAdmin(req: Request) { + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.isInstanceAdmin(req.actor.userId); + if (!allowed) throw forbidden("Instance admin required"); + } + + async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden(); + const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey); + if (!allowed) throw forbidden("Permission denied"); + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, permissionKey); + if (!allowed) throw forbidden("Permission denied"); + } + + router.post( + "/companies/:companyId/invites", + validate(createCompanyInviteSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCompanyPermission(req, companyId, "users:invite"); + + const token = createInviteToken(); + const created = await db + .insert(invites) + .values({ + companyId, + inviteType: "company_join", + tokenHash: hashToken(token), + allowedJoinTypes: req.body.allowedJoinTypes, + defaultsPayload: req.body.defaultsPayload ?? null, + expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), + invitedByUserId: req.actor.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + action: "invite.created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + }, + }); + + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + }); + }, + ); + + router.get("/invites/:token", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + res.json({ + id: invite.id, + companyId: invite.companyId, + inviteType: invite.inviteType, + allowedJoinTypes: invite.allowedJoinTypes, + expiresAt: invite.expiresAt, + }); + }); + + router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + if (invite.inviteType === "bootstrap_ceo") { + if (req.body.requestType !== "human") { + throw badRequest("Bootstrap invite requires human request type"); + } + if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) { + throw unauthorized("Authenticated user required for bootstrap acceptance"); + } + const userId = req.actor.userId ?? "local-board"; + const existingAdmin = await access.isInstanceAdmin(userId); + if (!existingAdmin) { + await access.promoteInstanceAdmin(userId); + } + const updatedInvite = await db + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where(eq(invites.id, invite.id)) + .returning() + .then((rows) => rows[0] ?? invite); + res.status(202).json({ + inviteId: updatedInvite.id, + inviteType: updatedInvite.inviteType, + bootstrapAccepted: true, + userId, + }); + return; + } + + const requestType = req.body.requestType as "human" | "agent"; + const companyId = invite.companyId; + if (!companyId) throw conflict("Invite is missing company scope"); + if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) { + throw badRequest(`Invite does not allow ${requestType} joins`); + } + + if (requestType === "human" && req.actor.type !== "board") { + throw unauthorized("Human invite acceptance requires authenticated user"); + } + if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) { + throw unauthorized("Authenticated user is required"); + } + if (requestType === "agent" && !req.body.agentName) { + throw badRequest("agentName is required for agent join requests"); + } + + const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; + const created = await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt))); + + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null, + requestEmailSnapshot: requestType === "human" ? actorEmail : null, + agentName: requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? req.body.adapterType ?? null : null, + capabilities: requestType === "agent" ? req.body.capabilities ?? null : null, + agentDefaultsPayload: requestType === "agent" ? req.body.agentDefaultsPayload ?? null : null, + }) + .returning() + .then((rows) => rows[0]); + return row; + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "invite-agent" + : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), + action: "join.requested", + entityType: "join_request", + entityId: created.id, + details: { requestType, requestIp: created.requestIp }, + }); + + res.status(202).json(created); + }); + + router.post("/invites/:inviteId/revoke", async (req, res) => { + const id = req.params.inviteId as string; + const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null); + if (!invite) throw notFound("Invite not found"); + if (invite.inviteType === "bootstrap_ceo") { + await assertInstanceAdmin(req); + } else { + if (!invite.companyId) throw conflict("Invite is missing company scope"); + await assertCompanyPermission(req, invite.companyId, "users:invite"); + } + if (invite.acceptedAt) throw conflict("Invite already consumed"); + if (invite.revokedAt) return res.json(invite); + + const revoked = await db + .update(invites) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where(eq(invites.id, id)) + .returning() + .then((rows) => rows[0]); + + if (invite.companyId) { + await logActivity(db, { + companyId: invite.companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + action: "invite.revoked", + entityType: "invite", + entityId: id, + }); + } + + res.json(revoked); + }); + + 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 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); + }); + + router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); + + const existing = await db + .select() + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); + + const invite = await db + .select() + .from(invites) + .where(eq(invites.id, existing.inviteId)) + .then((rows) => rows[0] ?? null); + if (!invite) throw notFound("Invite not found"); + + let createdAgentId: string | null = existing.createdAgentId ?? null; + if (existing.requestType === "human") { + if (!existing.requestingUserId) throw conflict("Join request missing user identity"); + await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active"); + const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "human"); + await access.setPrincipalGrants( + companyId, + "user", + existing.requestingUserId, + grants, + req.actor.userId ?? null, + ); + } else { + const created = await agents.create(companyId, { + name: existing.agentName ?? "New Agent", + role: "general", + title: null, + status: "idle", + reportsTo: null, + capabilities: existing.capabilities ?? null, + adapterType: existing.adapterType ?? "process", + adapterConfig: + existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object" + ? (existing.agentDefaultsPayload as Record) + : {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + }); + createdAgentId = created.id; + await access.ensureMembership(companyId, "agent", created.id, "member", "active"); + const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "agent"); + await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null); + } + + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + approvedAt: new Date(), + createdAgentId, + updatedAt: new Date(), + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType, createdAgentId }, + }); + + res.json(approved); + }); + + router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); + + const existing = await db + .select() + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); + + const rejected = await db + .update(joinRequests) + .set({ + status: "rejected", + rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + rejectedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.rejected", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType }, + }); + + res.json(rejected); + }); + + router.post("/join-requests/:requestId/claim-api-key", async (req, res) => { + const requestId = req.params.requestId as string; + const joinRequest = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.id, requestId)) + .then((rows) => rows[0] ?? null); + if (!joinRequest) throw notFound("Join request not found"); + if (joinRequest.requestType !== "agent") throw badRequest("Only agent join requests can claim API keys"); + if (joinRequest.status !== "approved") throw conflict("Join request must be approved before key claim"); + if (!joinRequest.createdAgentId) throw conflict("Join request has no created agent"); + + const existingKey = await db + .select({ id: agentApiKeys.id }) + .from(agentApiKeys) + .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId)) + .then((rows) => rows[0] ?? null); + if (existingKey) throw conflict("API key already claimed"); + + const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key"); + + await logActivity(db, { + companyId: joinRequest.companyId, + actorType: "system", + actorId: "join-claim", + action: "agent_api_key.claimed", + entityType: "agent_api_key", + entityId: created.id, + details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId }, + }); + + res.status(201).json({ + keyId: created.id, + token: created.token, + agentId: joinRequest.createdAgentId, + createdAt: created.createdAt, + }); + }); + + 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); + }); + + router.patch( + "/companies/:companyId/members/:memberId/permissions", + validate(updateMemberPermissionsSchema), + 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 updated = await access.setMemberPermissions( + companyId, + memberId, + req.body.grants ?? [], + req.actor.userId ?? null, + ); + if (!updated) throw notFound("Member not found"); + res.json(updated); + }, + ); + + router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const result = await access.promoteInstanceAdmin(userId); + res.status(201).json(result); + }); + + router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const removed = await access.demoteInstanceAdmin(userId); + if (!removed) throw notFound("Instance admin role not found"); + res.json(removed); + }); + + 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); + }); + + router.put( + "/admin/users/:userId/company-access", + validate(updateUserCompanyAccessSchema), + async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []); + res.json(memberships); + }, + ); + + return router; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 21aef860..a610c7ca 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,5 @@ import { Router, type Request } from "express"; +import { randomUUID } from "node:crypto"; import type { Db } from "@paperclip/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db"; import { and, desc, eq, inArray, sql } from "drizzle-orm"; @@ -15,6 +16,7 @@ import { import { validate } from "../middleware/validate.js"; import { agentService, + accessService, approvalService, heartbeatService, issueApprovalService, @@ -26,10 +28,12 @@ import { forbidden } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; +import { runClaudeLogin } from "@paperclip/adapter-claude-local/server"; export function agentRoutes(db: Db) { const router = Router(); const svc = agentService(db); + const access = accessService(db); const approvalsSvc = approvalService(db); const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); @@ -43,13 +47,21 @@ export function agentRoutes(db: Db) { async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") return null; + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + return null; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - if (!canCreateAgents(actorAgent)) { + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); + if (!allowedByGrant && !canCreateAgents(actorAgent)) { throw forbidden("Missing permission: can create agents"); } return actorAgent; @@ -61,11 +73,15 @@ export function agentRoutes(db: Db) { async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") return true; + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; + return access.canUser(companyId, req.actor.userId, "agents:create"); + } if (!req.actor.agentId) return false; const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) return false; - return canCreateAgents(actorAgent); + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); + return allowedByGrant || canCreateAgents(actorAgent); } async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { @@ -80,7 +96,13 @@ export function agentRoutes(db: Db) { if (actorAgent.id === targetAgent.id) return; if (actorAgent.role === "ceo") return; - if (canCreateAgents(actorAgent)) return; + const allowedByGrant = await access.hasPermission( + targetAgent.companyId, + "agent", + actorAgent.id, + "agents:create", + ); + if (allowedByGrant || canCreateAgents(actorAgent)) return; throw forbidden("Only CEO or agent creators can modify other agents"); } @@ -919,6 +941,37 @@ export function agentRoutes(db: Db) { res.status(202).json(run); }); + router.post("/agents/:id/claude-login", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + assertCompanyAccess(req, agent.companyId); + if (agent.adapterType !== "claude_local") { + res.status(400).json({ error: "Login is only supported for claude_local agents" }); + return; + } + + const config = asRecord(agent.adapterConfig) ?? {}; + const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); + const result = await runClaudeLogin({ + runId: `claude-login-${randomUUID()}`, + agent: { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + adapterType: agent.adapterType, + adapterConfig: agent.adapterConfig, + }, + config: runtimeConfig, + }); + + res.json(result); + }); + router.get("/companies/:companyId/heartbeat-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 048ded48..4782489b 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,5 +1,5 @@ import type { Request } from "express"; -import { forbidden } from "../errors.js"; +import { forbidden, unauthorized } from "../errors.js"; export function assertBoard(req: Request) { if (req.actor.type !== "board") { @@ -8,12 +8,24 @@ export function assertBoard(req: Request) { } export function assertCompanyAccess(req: Request, companyId: string) { + if (req.actor.type === "none") { + throw unauthorized(); + } if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } + if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + const allowedCompanies = req.actor.companyIds ?? []; + if (!allowedCompanies.includes(companyId)) { + throw forbidden("User does not have access to this company"); + } + } } export function getActorInfo(req: Request) { + if (req.actor.type === "none") { + throw unauthorized(); + } if (req.actor.type === "agent") { return { actorType: "agent" as const, diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d5af7e20..3bb43336 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,26 +1,45 @@ import { Router } from "express"; import type { Db } from "@paperclip/db"; import { createCompanySchema, updateCompanySchema } from "@paperclip/shared"; +import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; -import { companyService, logActivity } from "../services/index.js"; -import { assertBoard } from "./authz.js"; +import { accessService, companyService, logActivity } from "../services/index.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; export function companyRoutes(db: Db) { const router = Router(); const svc = companyService(db); + const access = accessService(db); - router.get("/", async (_req, res) => { + router.get("/", async (req, res) => { + assertBoard(req); const result = await svc.list(); - res.json(result); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + res.json(result); + return; + } + const allowed = new Set(req.actor.companyIds ?? []); + res.json(result.filter((company) => allowed.has(company.id))); }); - router.get("/stats", async (_req, res) => { + router.get("/stats", async (req, res) => { + assertBoard(req); + const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin + ? null + : new Set(req.actor.companyIds ?? []); const stats = await svc.stats(); - res.json(stats); + if (!allowed) { + res.json(stats); + return; + } + const filtered = Object.fromEntries(Object.entries(stats).filter(([companyId]) => allowed.has(companyId))); + res.json(filtered); }); router.get("/:companyId", async (req, res) => { + assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const company = await svc.getById(companyId); if (!company) { res.status(404).json({ error: "Company not found" }); @@ -31,7 +50,11 @@ export function companyRoutes(db: Db) { router.post("/", validate(createCompanySchema), async (req, res) => { assertBoard(req); + if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) { + throw forbidden("Instance admin required"); + } const company = await svc.create(req.body); + await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active"); await logActivity(db, { companyId: company.id, actorType: "user", @@ -47,6 +70,7 @@ export function companyRoutes(db: Db) { router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const company = await svc.update(companyId, req.body); if (!company) { res.status(404).json({ error: "Company not found" }); @@ -67,6 +91,7 @@ export function companyRoutes(db: Db) { router.post("/:companyId/archive", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const company = await svc.archive(companyId); if (!company) { res.status(404).json({ error: "Company not found" }); @@ -86,6 +111,7 @@ export function companyRoutes(db: Db) { router.delete("/:companyId", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const company = await svc.remove(companyId); if (!company) { res.status(404).json({ error: "Company not found" }); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 6a70ae2b..ca0dfb30 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,10 +1,46 @@ import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { count, sql } from "drizzle-orm"; +import { instanceUserRoles } from "@paperclip/db"; +import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared"; -export function healthRoutes() { +export function healthRoutes( + db?: Db, + opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + authReady: boolean; + } = { + deploymentMode: "local_trusted", + deploymentExposure: "private", + authReady: true, + }, +) { const router = Router(); - router.get("/", (_req, res) => { - res.json({ status: "ok" }); + router.get("/", async (_req, res) => { + if (!db) { + res.json({ status: "ok" }); + return; + } + + let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; + if (opts.deploymentMode === "authenticated") { + const roleCount = await db + .select({ count: count() }) + .from(instanceUserRoles) + .where(sql`${instanceUserRoles.role} = 'instance_admin'`) + .then((rows) => Number(rows[0]?.count ?? 0)); + bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending"; + } + + res.json({ + status: "ok", + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + authReady: opts.authReady, + bootstrapStatus, + }); }); return router; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index fbd5a515..c509d544 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -11,3 +11,4 @@ export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { llmRoutes } from "./llms.js"; +export { accessRoutes } from "./access.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5d0b9fd4..80548c79 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -12,6 +12,7 @@ import { import type { StorageService } from "../storage/types.js"; import { validate } from "../middleware/validate.js"; import { + accessService, agentService, goalService, heartbeatService, @@ -21,6 +22,7 @@ import { projectService, } from "../services/index.js"; import { logger } from "../middleware/logger.js"; +import { forbidden, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; @@ -35,6 +37,7 @@ const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([ export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = issueService(db); + const access = accessService(db); const heartbeat = heartbeatService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); @@ -78,6 +81,31 @@ export function issueRoutes(db: Db, storage: StorageService) { return false; } + function canCreateAgentsLegacy(agent: { permissions: Record | null | undefined; role: string }) { + if (agent.role === "ceo") return true; + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); + } + + async function assertCanAssignTasks(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); + if (!allowed) throw forbidden("Missing permission: tasks:assign"); + return; + } + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign"); + if (allowedByGrant) return; + const actorAgent = await agentsSvc.getById(req.actor.agentId); + if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return; + throw forbidden("Missing permission: tasks:assign"); + } + throw unauthorized(); + } + function requireAgentRunId(req: Request, res: Response) { if (req.actor.type !== "agent") return null; const runId = req.actor.runId?.trim(); @@ -124,15 +152,30 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + async function normalizeIssueIdentifier(rawId: string): Promise { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + const issue = await svc.getByIdentifier(rawId); + if (issue) { + return issue.id; + } + } + return rawId; + } + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes router.param("id", async (req, res, next, rawId) => { try { - if (/^[A-Z]+-\d+$/i.test(rawId)) { - const issue = await svc.getByIdentifier(rawId); - if (issue) { - req.params.id = issue.id; - } - } + req.params.id = await normalizeIssueIdentifier(rawId); + next(); + } catch (err) { + next(err); + } + }); + + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes. + router.param("issueId", async (req, res, next, rawId) => { + try { + req.params.issueId = await normalizeIssueIdentifier(rawId); next(); } catch (err) { next(err); @@ -240,6 +283,9 @@ export function issueRoutes(db: Db, storage: StorageService) { router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (req.body.assigneeAgentId || req.body.assigneeUserId) { + await assertCanAssignTasks(req, companyId); + } const actor = getActorInfo(req); const issue = await svc.create(companyId, { @@ -285,6 +331,12 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } assertCompanyAccess(req, existing.companyId); + const assigneeWillChange = + (req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) || + (req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId); + if (assigneeWillChange) { + await assertCanAssignTasks(req, existing.companyId); + } if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; @@ -344,8 +396,7 @@ export function issueRoutes(db: Db, storage: StorageService) { } - const assigneeChanged = - req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId; + const assigneeChanged = assigneeWillChange; // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs. void (async () => { diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 9eeb782e..b5b245c0 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -1,16 +1,38 @@ import { Router } from "express"; import type { Db } from "@paperclip/db"; +import { and, eq, sql } from "drizzle-orm"; +import { joinRequests } from "@paperclip/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; +import { accessService } from "../services/access.js"; import { assertCompanyAccess } from "./authz.js"; export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); + const access = accessService(db); router.get("/companies/:companyId/sidebar-badges", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const badges = await svc.get(companyId); + let canApproveJoins = false; + if (req.actor.type === "board") { + canApproveJoins = + req.actor.source === "local_implicit" || + Boolean(req.actor.isInstanceAdmin) || + (await access.canUser(companyId, req.actor.userId, "joins:approve")); + } else if (req.actor.type === "agent" && req.actor.agentId) { + canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve"); + } + + const joinRequestCount = canApproveJoins + ? await db + .select({ count: sql`count(*)` }) + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + .then((rows) => Number(rows[0]?.count ?? 0)) + : 0; + + const badges = await svc.get(companyId, { joinRequests: joinRequestCount }); res.json(badges); }); diff --git a/server/src/services/access.ts b/server/src/services/access.ts new file mode 100644 index 00000000..59bb1804 --- /dev/null +++ b/server/src/services/access.ts @@ -0,0 +1,268 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { + companyMemberships, + instanceUserRoles, + principalPermissionGrants, +} from "@paperclip/db"; +import type { PermissionKey, PrincipalType } from "@paperclip/shared"; + +type MembershipRow = typeof companyMemberships.$inferSelect; +type GrantInput = { + permissionKey: PermissionKey; + scope?: Record | null; +}; + +export function accessService(db: Db) { + async function isInstanceAdmin(userId: string | null | undefined): Promise { + if (!userId) return false; + const row = await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null); + return Boolean(row); + } + + async function getMembership( + companyId: string, + principalType: PrincipalType, + principalId: string, + ): Promise { + return db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, principalType), + eq(companyMemberships.principalId, principalId), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function hasPermission( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + ): Promise { + const membership = await getMembership(companyId, principalType, principalId); + if (!membership || membership.status !== "active") return false; + const grant = await db + .select({ id: principalPermissionGrants.id }) + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(grant); + } + + async function canUser( + companyId: string, + userId: string | null | undefined, + permissionKey: PermissionKey, + ): Promise { + if (!userId) return false; + if (await isInstanceAdmin(userId)) return true; + return hasPermission(companyId, "user", userId, permissionKey); + } + + async function listMembers(companyId: string) { + return db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.companyId, companyId)) + .orderBy(sql`${companyMemberships.createdAt} desc`); + } + + async function setMemberPermissions( + companyId: string, + memberId: string, + grants: GrantInput[], + grantedByUserId: string | null, + ) { + const member = await db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!member) return null; + + await db.transaction(async (tx) => { + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, member.principalType), + eq(principalPermissionGrants.principalId, member.principalId), + ), + ); + if (grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + grants.map((grant) => ({ + companyId, + principalType: member.principalType, + principalId: member.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId, + createdAt: new Date(), + updatedAt: new Date(), + })), + ); + } + }); + + return member; + } + + async function promoteInstanceAdmin(userId: string) { + const existing = await db + .select() + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null); + if (existing) return existing; + return db + .insert(instanceUserRoles) + .values({ + userId, + role: "instance_admin", + }) + .returning() + .then((rows) => rows[0]); + } + + async function demoteInstanceAdmin(userId: string) { + return db + .delete(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .returning() + .then((rows) => rows[0] ?? null); + } + + async function listUserCompanyAccess(userId: string) { + return db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))) + .orderBy(sql`${companyMemberships.createdAt} desc`); + } + + async function setUserCompanyAccess(userId: string, companyIds: string[]) { + const existing = await listUserCompanyAccess(userId); + const existingByCompany = new Map(existing.map((row) => [row.companyId, row])); + const target = new Set(companyIds); + + await db.transaction(async (tx) => { + const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id); + if (toDelete.length > 0) { + await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete)); + } + + for (const companyId of target) { + if (existingByCompany.has(companyId)) continue; + await tx.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "member", + }); + } + }); + + return listUserCompanyAccess(userId); + } + + async function ensureMembership( + companyId: string, + principalType: PrincipalType, + principalId: string, + membershipRole: string | null = "member", + status: "pending" | "active" | "suspended" = "active", + ) { + const existing = await getMembership(companyId, principalType, principalId); + if (existing) { + if (existing.status !== status || existing.membershipRole !== membershipRole) { + const updated = await db + .update(companyMemberships) + .set({ status, membershipRole, updatedAt: new Date() }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + return updated ?? existing; + } + return existing; + } + + return db + .insert(companyMemberships) + .values({ + companyId, + principalType, + principalId, + status, + membershipRole, + }) + .returning() + .then((rows) => rows[0]); + } + + async function setPrincipalGrants( + companyId: string, + principalType: PrincipalType, + principalId: string, + grants: GrantInput[], + grantedByUserId: string | null, + ) { + await db.transaction(async (tx) => { + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + ), + ); + if (grants.length === 0) return; + await tx.insert(principalPermissionGrants).values( + grants.map((grant) => ({ + companyId, + principalType, + principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId, + createdAt: new Date(), + updatedAt: new Date(), + })), + ); + }); + } + + return { + isInstanceAdmin, + canUser, + hasPermission, + getMembership, + ensureMembership, + listMembers, + setMemberPermissions, + promoteInstanceAdmin, + demoteInstanceAdmin, + listUserCompanyAccess, + setUserCompanyAccess, + setPrincipalGrants, + }; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index c6137be8..a41c5643 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -18,6 +18,10 @@ import { approvals, activityLog, companySecrets, + joinRequests, + invites, + principalPermissionGrants, + companyMemberships, } from "@paperclip/db"; export function companyService(db: Db) { @@ -68,6 +72,10 @@ export function companyService(db: Db) { await tx.delete(approvalComments).where(eq(approvalComments.companyId, id)); await tx.delete(approvals).where(eq(approvals.companyId, id)); await tx.delete(companySecrets).where(eq(companySecrets.companyId, id)); + await tx.delete(joinRequests).where(eq(joinRequests.companyId, id)); + await tx.delete(invites).where(eq(invites.companyId, id)); + await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id)); + await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id)); await tx.delete(issues).where(eq(issues.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id)); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b9192b56..4629f49f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -985,7 +985,7 @@ export function heartbeatService(db: Db) { : outcome === "cancelled" ? "cancelled" : outcome === "failed" - ? "adapter_failed" + ? (adapterResult.errorCode ?? "adapter_failed") : null, exitCode: adapterResult.exitCode, signal: adapterResult.signal, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 87d16a40..2c55878b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -12,6 +12,7 @@ export { costService } from "./costs.js"; export { heartbeatService } from "./heartbeat.js"; export { dashboardService } from "./dashboard.js"; export { sidebarBadgeService } from "./sidebar-badges.js"; +export { accessService } from "./access.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index be7a0f5e..6578d875 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4,6 +4,7 @@ import { agents, assets, companies, + companyMemberships, goals, heartbeatRuns, issueAttachments, @@ -77,6 +78,24 @@ export function issueService(db: Db) { } } + async function assertAssignableUser(companyId: string, userId: string) { + const membership = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!membership) { + throw notFound("Assignee user not found"); + } + } + async function isTerminalOrMissingHeartbeatRun(runId: string) { const run = await db .select({ status: heartbeatRuns.status }) @@ -157,9 +176,18 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null), create: async (companyId: string, data: Omit) => { + if (data.assigneeAgentId && data.assigneeUserId) { + throw unprocessable("Issue can only have one assignee"); + } if (data.assigneeAgentId) { await assertAssignableAgent(companyId, data.assigneeAgentId); } + if (data.assigneeUserId) { + await assertAssignableUser(companyId, data.assigneeUserId); + } + if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { + throw unprocessable("in_progress issues require an assignee"); + } return db.transaction(async (tx) => { const [company] = await tx .update(companies) @@ -203,12 +231,23 @@ export function issueService(db: Db) { updatedAt: new Date(), }; - if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) { + const nextAssigneeAgentId = + data.assigneeAgentId !== undefined ? data.assigneeAgentId : existing.assigneeAgentId; + const nextAssigneeUserId = + data.assigneeUserId !== undefined ? data.assigneeUserId : existing.assigneeUserId; + + if (nextAssigneeAgentId && nextAssigneeUserId) { + throw unprocessable("Issue can only have one assignee"); + } + if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } if (data.assigneeAgentId) { await assertAssignableAgent(existing.companyId, data.assigneeAgentId); } + if (data.assigneeUserId) { + await assertAssignableUser(existing.companyId, data.assigneeUserId); + } applyStatusSideEffects(data.status, patch); if (data.status && data.status !== "done") { @@ -220,7 +259,10 @@ export function issueService(db: Db) { if (data.status && data.status !== "in_progress") { patch.checkoutRunId = null; } - if (data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.assigneeAgentId) { + if ( + (data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.assigneeAgentId) || + (data.assigneeUserId !== undefined && data.assigneeUserId !== existing.assigneeUserId) + ) { patch.checkoutRunId = null; } @@ -277,6 +319,7 @@ export function issueService(db: Db) { .update(issues) .set({ assigneeAgentId: agentId, + assigneeUserId: null, checkoutRunId, executionRunId: checkoutRunId, status: "in_progress", diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index ce55764c..5ed1fb8a 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -8,7 +8,7 @@ const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"]; export function sidebarBadgeService(db: Db) { return { - get: async (companyId: string): Promise => { + get: async (companyId: string, extra?: { joinRequests?: number }): Promise => { const actionableApprovals = await db .select({ count: sql`count(*)` }) .from(approvals) @@ -39,10 +39,12 @@ export function sidebarBadgeService(db: Db) { FAILED_HEARTBEAT_STATUSES.includes(row.runStatus), ).length; + const joinRequests = extra?.joinRequests ?? 0; return { - inbox: actionableApprovals + failedRuns, + inbox: actionableApprovals + failedRuns + joinRequests, approvals: actionableApprovals, failedRuns, + joinRequests, }; }, }; diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts index 89a069c0..a260b976 100644 --- a/server/src/startup-banner.ts +++ b/server/src/startup-banner.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; +import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared"; import { parse as parseEnvFileContents } from "dotenv"; @@ -17,6 +18,10 @@ type EmbeddedPostgresInfo = { }; type StartupBannerOptions = { + host: string; + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + authReady: boolean; requestedPort: number; listenPort: number; uiMode: UiMode; @@ -88,7 +93,8 @@ function resolveAgentJwtSecretStatus( } export function printStartupBanner(opts: StartupBannerOptions): void { - const baseUrl = `http://localhost:${opts.listenPort}`; + const baseHost = opts.host === "0.0.0.0" ? "localhost" : opts.host; + const baseUrl = `http://${baseHost}:${opts.listenPort}`; const apiUrl = `${baseUrl}/api`; const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl; const configPath = resolvePaperclipConfigPath(); @@ -134,6 +140,8 @@ export function printStartupBanner(opts: StartupBannerOptions): void { ...art, color(" ───────────────────────────────────────────────────────", "blue"), row("Mode", `${dbMode} | ${uiMode}`), + row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`), + row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")), row("Server", portValue), row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`), row("UI", uiUrl), diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 7401a0c1..d4840575 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -4,12 +4,15 @@ declare global { namespace Express { interface Request { actor: { - type: "board" | "agent"; + type: "board" | "agent" | "none"; userId?: string; agentId?: string; companyId?: string; + companyIds?: string[]; + isInstanceAdmin?: boolean; keyId?: string; runId?: string; + source?: "local_implicit" | "session" | "agent_key" | "agent_jwt" | "none"; }; } }