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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,13 @@ import express from "express";
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
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();
|
next();
|
||||||
});
|
});
|
||||||
app.use(boardMutationGuard());
|
app.use(boardMutationGuard());
|
||||||
@@ -34,6 +36,12 @@ describe("boardMutationGuard", () => {
|
|||||||
expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" });
|
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 () => {
|
it("allows board mutations from trusted origin", async () => {
|
||||||
const app = createApp("board");
|
const app = createApp("board");
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import express, { Router } from "express";
|
import express, { Router, type Request as ExpressRequest } from "express";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
|
import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared";
|
||||||
import type { StorageService } from "./storage/types.js";
|
import type { StorageService } from "./storage/types.js";
|
||||||
import { httpLogger, errorHandler } from "./middleware/index.js";
|
import { httpLogger, errorHandler } from "./middleware/index.js";
|
||||||
import { actorMiddleware } from "./middleware/auth.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 { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.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";
|
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<BetterAuthSessionResult | null>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(httpLogger);
|
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));
|
app.use(llmRoutes(db));
|
||||||
|
|
||||||
// Mount API routes
|
// Mount API routes
|
||||||
const api = Router();
|
const api = Router();
|
||||||
api.use(boardMutationGuard());
|
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("/companies", companyRoutes(db));
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
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(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
|
api.use(accessRoutes(db));
|
||||||
app.use("/api", api);
|
app.use("/api", api);
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|||||||
105
server/src/auth/better-auth.ts
Normal file
105
server/src/auth/better-auth.ts
Normal file
@@ -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<typeof betterAuth>;
|
||||||
|
|
||||||
|
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<BetterAuthSessionResult | null> {
|
||||||
|
const api = (auth as unknown as { api?: { getSession?: (input: unknown) => Promise<unknown> } }).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 };
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@ import { readConfigFile } from "./config-file.js";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
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 {
|
import {
|
||||||
resolveDefaultEmbeddedPostgresDir,
|
resolveDefaultEmbeddedPostgresDir,
|
||||||
resolveDefaultSecretsKeyFilePath,
|
resolveDefaultSecretsKeyFilePath,
|
||||||
@@ -18,7 +29,12 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
|||||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
authBaseUrlMode: AuthBaseUrlMode;
|
||||||
|
authPublicBaseUrl: string | undefined;
|
||||||
databaseMode: DatabaseMode;
|
databaseMode: DatabaseMode;
|
||||||
databaseUrl: string | undefined;
|
databaseUrl: string | undefined;
|
||||||
embeddedPostgresDataDir: string;
|
embeddedPostgresDataDir: string;
|
||||||
@@ -84,8 +100,45 @@ export function loadConfig(): Config {
|
|||||||
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
|
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
|
||||||
: (fileStorage?.s3?.forcePathStyle ?? false);
|
: (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 {
|
return {
|
||||||
|
deploymentMode,
|
||||||
|
deploymentExposure,
|
||||||
|
host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1",
|
||||||
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
||||||
|
authBaseUrlMode,
|
||||||
|
authPublicBaseUrl,
|
||||||
databaseMode: fileDatabaseMode,
|
databaseMode: fileDatabaseMode,
|
||||||
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
||||||
embeddedPostgresDataDir: resolveHomeAwarePath(
|
embeddedPostgresDataDir: resolveHomeAwarePath(
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { createServer } from "node:http";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
|
import type { Request as ExpressRequest } from "express";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createDb,
|
createDb,
|
||||||
ensurePostgresDatabase,
|
ensurePostgresDatabase,
|
||||||
inspectMigrations,
|
inspectMigrations,
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
reconcilePendingMigrationHistory,
|
reconcilePendingMigrationHistory,
|
||||||
|
authUsers,
|
||||||
|
companies,
|
||||||
|
companyMemberships,
|
||||||
|
instanceUserRoles,
|
||||||
} from "@paperclip/db";
|
} from "@paperclip/db";
|
||||||
import detectPort from "detect-port";
|
import detectPort from "detect-port";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
@@ -18,6 +24,11 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
|||||||
import { heartbeatService } from "./services/index.js";
|
import { heartbeatService } from "./services/index.js";
|
||||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||||
import { printStartupBanner } from "./startup-banner.js";
|
import { printStartupBanner } from "./startup-banner.js";
|
||||||
|
import {
|
||||||
|
createBetterAuthHandler,
|
||||||
|
createBetterAuthInstance,
|
||||||
|
resolveBetterAuthSession,
|
||||||
|
} from "./auth/better-auth.js";
|
||||||
|
|
||||||
type EmbeddedPostgresInstance = {
|
type EmbeddedPostgresInstance = {
|
||||||
initialise(): Promise<void>;
|
initialise(): Promise<void>;
|
||||||
@@ -121,6 +132,71 @@ async function ensureMigrations(connectionString: string, label: string): Promis
|
|||||||
return "applied (pending migrations)";
|
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<void> {
|
||||||
|
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 db;
|
||||||
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
||||||
let embeddedPostgresStartedByThisProcess = false;
|
let embeddedPostgresStartedByThisProcess = false;
|
||||||
@@ -217,9 +293,64 @@ if (config.databaseUrl) {
|
|||||||
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
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<typeof createBetterAuthHandler> | undefined;
|
||||||
|
let resolveSession:
|
||||||
|
| ((req: ExpressRequest) => Promise<Awaited<ReturnType<typeof resolveBetterAuthSession>>>)
|
||||||
|
| 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 uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||||
const storageService = createStorageServiceFromConfig(config);
|
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 server = createServer(app);
|
||||||
const listenPort = await detectPort(config.port);
|
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");
|
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) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
@@ -258,9 +389,13 @@ if (config.heartbeatSchedulerEnabled) {
|
|||||||
}, config.heartbeatSchedulerIntervalMs);
|
}, config.heartbeatSchedulerIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(listenPort, () => {
|
server.listen(listenPort, config.host, () => {
|
||||||
logger.info(`Server listening on :${listenPort}`);
|
logger.info(`Server listening on ${config.host}:${listenPort}`);
|
||||||
printStartupBanner({
|
printStartupBanner({
|
||||||
|
host: config.host,
|
||||||
|
deploymentMode: config.deploymentMode,
|
||||||
|
deploymentExposure: config.deploymentExposure,
|
||||||
|
authReady,
|
||||||
requestedPort: config.port,
|
requestedPort: config.port,
|
||||||
listenPort,
|
listenPort,
|
||||||
uiMode,
|
uiMode,
|
||||||
|
|||||||
@@ -1,22 +1,65 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { RequestHandler } from "express";
|
import type { Request, RequestHandler } from "express";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
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 { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||||
|
import type { DeploymentMode } from "@paperclip/shared";
|
||||||
|
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||||
|
|
||||||
function hashToken(token: string) {
|
function hashToken(token: string) {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function actorMiddleware(db: Db): RequestHandler {
|
interface ActorMiddlewareOptions {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
||||||
return async (req, _res, next) => {
|
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 runIdHeader = req.header("x-paperclip-run-id");
|
||||||
|
|
||||||
const authHeader = req.header("authorization");
|
const authHeader = req.header("authorization");
|
||||||
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
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;
|
if (runIdHeader) req.actor.runId = runIdHeader;
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
@@ -64,6 +107,7 @@ export function actorMiddleware(db: Db): RequestHandler {
|
|||||||
companyId: claims.company_id,
|
companyId: claims.company_id,
|
||||||
keyId: undefined,
|
keyId: undefined,
|
||||||
runId: runIdHeader || claims.run_id || undefined,
|
runId: runIdHeader || claims.run_id || undefined,
|
||||||
|
source: "agent_jwt",
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
@@ -91,6 +135,7 @@ export function actorMiddleware(db: Db): RequestHandler {
|
|||||||
companyId: key.companyId,
|
companyId: key.companyId,
|
||||||
keyId: key.id,
|
keyId: key.id,
|
||||||
runId: runIdHeader || undefined,
|
runId: runIdHeader || undefined,
|
||||||
|
source: "agent_key",
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export function boardMutationGuard(): RequestHandler {
|
|||||||
return;
|
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)) {
|
if (!isTrustedBoardMutationRequest(req)) {
|
||||||
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Duplex } from "node:stream";
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { agentApiKeys } from "@paperclip/db";
|
import { agentApiKeys } from "@paperclip/db";
|
||||||
|
import type { DeploymentMode } from "@paperclip/shared";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||||
@@ -52,13 +53,17 @@ async function authorizeUpgrade(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
url: URL,
|
url: URL,
|
||||||
|
deploymentMode: DeploymentMode,
|
||||||
): Promise<UpgradeContext | null> {
|
): Promise<UpgradeContext | null> {
|
||||||
const queryToken = url.searchParams.get("token")?.trim() ?? "";
|
const queryToken = url.searchParams.get("token")?.trim() ?? "";
|
||||||
const authToken = parseBearerToken(req.headers.authorization);
|
const authToken = parseBearerToken(req.headers.authorization);
|
||||||
const token = authToken ?? (queryToken.length > 0 ? queryToken : null);
|
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 (!token) {
|
||||||
|
if (deploymentMode !== "local_trusted") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
companyId,
|
companyId,
|
||||||
actorType: "board",
|
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 wss = new WebSocketServer({ noServer: true });
|
||||||
const cleanupByClient = new Map<WebSocket, () => void>();
|
const cleanupByClient = new Map<WebSocket, () => void>();
|
||||||
const aliveByClient = new Map<WebSocket, boolean>();
|
const aliveByClient = new Map<WebSocket, boolean>();
|
||||||
@@ -153,7 +162,7 @@ export function setupLiveEventsWebSocketServer(server: HttpServer, db: Db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void authorizeUpgrade(db, req, companyId, url)
|
void authorizeUpgrade(db, req, companyId, url, opts.deploymentMode)
|
||||||
.then((context) => {
|
.then((context) => {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
rejectUpgrade(socket, "403 Forbidden", "forbidden");
|
rejectUpgrade(socket, "403 Forbidden", "forbidden");
|
||||||
|
|||||||
554
server/src/routes/access.ts
Normal file
554
server/src/routes/access.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<string, unknown> | null | undefined,
|
||||||
|
key: "human" | "agent",
|
||||||
|
): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record<string, unknown> | null }> {
|
||||||
|
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
|
||||||
|
const scoped = defaultsPayload[key];
|
||||||
|
if (!scoped || typeof scoped !== "object") return [];
|
||||||
|
const grants = (scoped as Record<string, unknown>).grants;
|
||||||
|
if (!Array.isArray(grants)) return [];
|
||||||
|
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
|
||||||
|
const result: Array<{
|
||||||
|
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||||
|
scope: Record<string, unknown> | null;
|
||||||
|
}> = [];
|
||||||
|
for (const item of grants) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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<string, unknown> | 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<string, unknown>)
|
||||||
|
: {},
|
||||||
|
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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router, type Request } from "express";
|
import { Router, type Request } from "express";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
|
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import {
|
import {
|
||||||
agentService,
|
agentService,
|
||||||
|
accessService,
|
||||||
approvalService,
|
approvalService,
|
||||||
heartbeatService,
|
heartbeatService,
|
||||||
issueApprovalService,
|
issueApprovalService,
|
||||||
@@ -26,10 +28,12 @@ import { forbidden } from "../errors.js";
|
|||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||||
import { redactEventPayload } from "../redaction.js";
|
import { redactEventPayload } from "../redaction.js";
|
||||||
|
import { runClaudeLogin } from "@paperclip/adapter-claude-local/server";
|
||||||
|
|
||||||
export function agentRoutes(db: Db) {
|
export function agentRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = agentService(db);
|
const svc = agentService(db);
|
||||||
|
const access = accessService(db);
|
||||||
const approvalsSvc = approvalService(db);
|
const approvalsSvc = approvalService(db);
|
||||||
const heartbeat = heartbeatService(db);
|
const heartbeat = heartbeatService(db);
|
||||||
const issueApprovalsSvc = issueApprovalService(db);
|
const issueApprovalsSvc = issueApprovalService(db);
|
||||||
@@ -43,13 +47,21 @@ export function agentRoutes(db: Db) {
|
|||||||
|
|
||||||
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
|
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
|
||||||
assertCompanyAccess(req, companyId);
|
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");
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||||
const actorAgent = await svc.getById(req.actor.agentId);
|
const actorAgent = await svc.getById(req.actor.agentId);
|
||||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||||
throw forbidden("Agent key cannot access another company");
|
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");
|
throw forbidden("Missing permission: can create agents");
|
||||||
}
|
}
|
||||||
return actorAgent;
|
return actorAgent;
|
||||||
@@ -61,11 +73,15 @@ export function agentRoutes(db: Db) {
|
|||||||
|
|
||||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||||
assertCompanyAccess(req, companyId);
|
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;
|
if (!req.actor.agentId) return false;
|
||||||
const actorAgent = await svc.getById(req.actor.agentId);
|
const actorAgent = await svc.getById(req.actor.agentId);
|
||||||
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
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 }) {
|
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.id === targetAgent.id) return;
|
||||||
if (actorAgent.role === "ceo") 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");
|
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);
|
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) => {
|
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { forbidden } from "../errors.js";
|
import { forbidden, unauthorized } from "../errors.js";
|
||||||
|
|
||||||
export function assertBoard(req: Request) {
|
export function assertBoard(req: Request) {
|
||||||
if (req.actor.type !== "board") {
|
if (req.actor.type !== "board") {
|
||||||
@@ -8,12 +8,24 @@ export function assertBoard(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assertCompanyAccess(req: Request, companyId: string) {
|
export function assertCompanyAccess(req: Request, companyId: string) {
|
||||||
|
if (req.actor.type === "none") {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
||||||
throw forbidden("Agent key cannot access another company");
|
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) {
|
export function getActorInfo(req: Request) {
|
||||||
|
if (req.actor.type === "none") {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
if (req.actor.type === "agent") {
|
if (req.actor.type === "agent") {
|
||||||
return {
|
return {
|
||||||
actorType: "agent" as const,
|
actorType: "agent" as const,
|
||||||
|
|||||||
@@ -1,26 +1,45 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
|
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
|
||||||
|
import { forbidden } from "../errors.js";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { companyService, logActivity } from "../services/index.js";
|
import { accessService, companyService, logActivity } from "../services/index.js";
|
||||||
import { assertBoard } from "./authz.js";
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
|
|
||||||
export function companyRoutes(db: Db) {
|
export function companyRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = companyService(db);
|
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();
|
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();
|
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) => {
|
router.get("/:companyId", async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
const company = await svc.getById(companyId);
|
const company = await svc.getById(companyId);
|
||||||
if (!company) {
|
if (!company) {
|
||||||
res.status(404).json({ error: "Company not found" });
|
res.status(404).json({ error: "Company not found" });
|
||||||
@@ -31,7 +50,11 @@ export function companyRoutes(db: Db) {
|
|||||||
|
|
||||||
router.post("/", validate(createCompanySchema), async (req, res) => {
|
router.post("/", validate(createCompanySchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
|
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
||||||
|
throw forbidden("Instance admin required");
|
||||||
|
}
|
||||||
const company = await svc.create(req.body);
|
const company = await svc.create(req.body);
|
||||||
|
await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active");
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -47,6 +70,7 @@ export function companyRoutes(db: Db) {
|
|||||||
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
|
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
const company = await svc.update(companyId, req.body);
|
const company = await svc.update(companyId, req.body);
|
||||||
if (!company) {
|
if (!company) {
|
||||||
res.status(404).json({ error: "Company not found" });
|
res.status(404).json({ error: "Company not found" });
|
||||||
@@ -67,6 +91,7 @@ export function companyRoutes(db: Db) {
|
|||||||
router.post("/:companyId/archive", async (req, res) => {
|
router.post("/:companyId/archive", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
const company = await svc.archive(companyId);
|
const company = await svc.archive(companyId);
|
||||||
if (!company) {
|
if (!company) {
|
||||||
res.status(404).json({ error: "Company not found" });
|
res.status(404).json({ error: "Company not found" });
|
||||||
@@ -86,6 +111,7 @@ export function companyRoutes(db: Db) {
|
|||||||
router.delete("/:companyId", async (req, res) => {
|
router.delete("/:companyId", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
const company = await svc.remove(companyId);
|
const company = await svc.remove(companyId);
|
||||||
if (!company) {
|
if (!company) {
|
||||||
res.status(404).json({ error: "Company not found" });
|
res.status(404).json({ error: "Company not found" });
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
import { Router } from "express";
|
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();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", (_req, res) => {
|
router.get("/", async (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
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;
|
return router;
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { activityRoutes } from "./activity.js";
|
|||||||
export { dashboardRoutes } from "./dashboard.js";
|
export { dashboardRoutes } from "./dashboard.js";
|
||||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||||
export { llmRoutes } from "./llms.js";
|
export { llmRoutes } from "./llms.js";
|
||||||
|
export { accessRoutes } from "./access.js";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { StorageService } from "../storage/types.js";
|
import type { StorageService } from "../storage/types.js";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import {
|
import {
|
||||||
|
accessService,
|
||||||
agentService,
|
agentService,
|
||||||
goalService,
|
goalService,
|
||||||
heartbeatService,
|
heartbeatService,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
projectService,
|
projectService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
|
import { forbidden, unauthorized } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
|
||||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
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) {
|
export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = issueService(db);
|
const svc = issueService(db);
|
||||||
|
const access = accessService(db);
|
||||||
const heartbeat = heartbeatService(db);
|
const heartbeat = heartbeatService(db);
|
||||||
const agentsSvc = agentService(db);
|
const agentsSvc = agentService(db);
|
||||||
const projectsSvc = projectService(db);
|
const projectsSvc = projectService(db);
|
||||||
@@ -78,6 +81,31 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | 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<string, unknown>).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) {
|
function requireAgentRunId(req: Request, res: Response) {
|
||||||
if (req.actor.type !== "agent") return null;
|
if (req.actor.type !== "agent") return null;
|
||||||
const runId = req.actor.runId?.trim();
|
const runId = req.actor.runId?.trim();
|
||||||
@@ -124,15 +152,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||||
|
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
|
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
||||||
router.param("id", async (req, res, next, rawId) => {
|
router.param("id", async (req, res, next, rawId) => {
|
||||||
try {
|
try {
|
||||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
req.params.id = await normalizeIssueIdentifier(rawId);
|
||||||
const issue = await svc.getByIdentifier(rawId);
|
next();
|
||||||
if (issue) {
|
} catch (err) {
|
||||||
req.params.id = issue.id;
|
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();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -240,6 +283,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
|
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||||
|
await assertCanAssignTasks(req, companyId);
|
||||||
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const issue = await svc.create(companyId, {
|
const issue = await svc.create(companyId, {
|
||||||
@@ -285,6 +331,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
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;
|
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||||
|
|
||||||
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||||
@@ -344,8 +396,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const assigneeChanged =
|
const assigneeChanged = assigneeWillChange;
|
||||||
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
|
|
||||||
|
|
||||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclip/db";
|
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 { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
|
import { accessService } from "../services/access.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
|
|
||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
|
const access = accessService(db);
|
||||||
|
|
||||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
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<number>`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);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
268
server/src/services/access.ts
Normal file
268
server/src/services/access.ts
Normal file
@@ -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<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function accessService(db: Db) {
|
||||||
|
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||||
|
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<MembershipRow | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
approvals,
|
approvals,
|
||||||
activityLog,
|
activityLog,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
|
joinRequests,
|
||||||
|
invites,
|
||||||
|
principalPermissionGrants,
|
||||||
|
companyMemberships,
|
||||||
} from "@paperclip/db";
|
} from "@paperclip/db";
|
||||||
|
|
||||||
export function companyService(db: 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(approvalComments).where(eq(approvalComments.companyId, id));
|
||||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||||
await tx.delete(companySecrets).where(eq(companySecrets.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(issues).where(eq(issues.companyId, id));
|
||||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||||
|
|||||||
@@ -985,7 +985,7 @@ export function heartbeatService(db: Db) {
|
|||||||
: outcome === "cancelled"
|
: outcome === "cancelled"
|
||||||
? "cancelled"
|
? "cancelled"
|
||||||
: outcome === "failed"
|
: outcome === "failed"
|
||||||
? "adapter_failed"
|
? (adapterResult.errorCode ?? "adapter_failed")
|
||||||
: null,
|
: null,
|
||||||
exitCode: adapterResult.exitCode,
|
exitCode: adapterResult.exitCode,
|
||||||
signal: adapterResult.signal,
|
signal: adapterResult.signal,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export { costService } from "./costs.js";
|
|||||||
export { heartbeatService } from "./heartbeat.js";
|
export { heartbeatService } from "./heartbeat.js";
|
||||||
export { dashboardService } from "./dashboard.js";
|
export { dashboardService } from "./dashboard.js";
|
||||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||||
|
export { accessService } from "./access.js";
|
||||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
agents,
|
agents,
|
||||||
assets,
|
assets,
|
||||||
companies,
|
companies,
|
||||||
|
companyMemberships,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueAttachments,
|
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) {
|
async function isTerminalOrMissingHeartbeatRun(runId: string) {
|
||||||
const run = await db
|
const run = await db
|
||||||
.select({ status: heartbeatRuns.status })
|
.select({ status: heartbeatRuns.status })
|
||||||
@@ -157,9 +176,18 @@ export function issueService(db: Db) {
|
|||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null),
|
||||||
|
|
||||||
create: async (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
|
create: async (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
|
||||||
|
if (data.assigneeAgentId && data.assigneeUserId) {
|
||||||
|
throw unprocessable("Issue can only have one assignee");
|
||||||
|
}
|
||||||
if (data.assigneeAgentId) {
|
if (data.assigneeAgentId) {
|
||||||
await assertAssignableAgent(companyId, 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) => {
|
return db.transaction(async (tx) => {
|
||||||
const [company] = await tx
|
const [company] = await tx
|
||||||
.update(companies)
|
.update(companies)
|
||||||
@@ -203,12 +231,23 @@ export function issueService(db: Db) {
|
|||||||
updatedAt: new Date(),
|
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");
|
throw unprocessable("in_progress issues require an assignee");
|
||||||
}
|
}
|
||||||
if (data.assigneeAgentId) {
|
if (data.assigneeAgentId) {
|
||||||
await assertAssignableAgent(existing.companyId, data.assigneeAgentId);
|
await assertAssignableAgent(existing.companyId, data.assigneeAgentId);
|
||||||
}
|
}
|
||||||
|
if (data.assigneeUserId) {
|
||||||
|
await assertAssignableUser(existing.companyId, data.assigneeUserId);
|
||||||
|
}
|
||||||
|
|
||||||
applyStatusSideEffects(data.status, patch);
|
applyStatusSideEffects(data.status, patch);
|
||||||
if (data.status && data.status !== "done") {
|
if (data.status && data.status !== "done") {
|
||||||
@@ -220,7 +259,10 @@ export function issueService(db: Db) {
|
|||||||
if (data.status && data.status !== "in_progress") {
|
if (data.status && data.status !== "in_progress") {
|
||||||
patch.checkoutRunId = null;
|
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;
|
patch.checkoutRunId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +319,7 @@ export function issueService(db: Db) {
|
|||||||
.update(issues)
|
.update(issues)
|
||||||
.set({
|
.set({
|
||||||
assigneeAgentId: agentId,
|
assigneeAgentId: agentId,
|
||||||
|
assigneeUserId: null,
|
||||||
checkoutRunId,
|
checkoutRunId,
|
||||||
executionRunId: checkoutRunId,
|
executionRunId: checkoutRunId,
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
|||||||
|
|
||||||
export function sidebarBadgeService(db: Db) {
|
export function sidebarBadgeService(db: Db) {
|
||||||
return {
|
return {
|
||||||
get: async (companyId: string): Promise<SidebarBadges> => {
|
get: async (companyId: string, extra?: { joinRequests?: number }): Promise<SidebarBadges> => {
|
||||||
const actionableApprovals = await db
|
const actionableApprovals = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
@@ -39,10 +39,12 @@ export function sidebarBadgeService(db: Db) {
|
|||||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const joinRequests = extra?.joinRequests ?? 0;
|
||||||
return {
|
return {
|
||||||
inbox: actionableApprovals + failedRuns,
|
inbox: actionableApprovals + failedRuns + joinRequests,
|
||||||
approvals: actionableApprovals,
|
approvals: actionableApprovals,
|
||||||
failedRuns,
|
failedRuns,
|
||||||
|
joinRequests,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
||||||
|
import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared";
|
||||||
|
|
||||||
import { parse as parseEnvFileContents } from "dotenv";
|
import { parse as parseEnvFileContents } from "dotenv";
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ type EmbeddedPostgresInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type StartupBannerOptions = {
|
type StartupBannerOptions = {
|
||||||
|
host: string;
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
authReady: boolean;
|
||||||
requestedPort: number;
|
requestedPort: number;
|
||||||
listenPort: number;
|
listenPort: number;
|
||||||
uiMode: UiMode;
|
uiMode: UiMode;
|
||||||
@@ -88,7 +93,8 @@ function resolveAgentJwtSecretStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function printStartupBanner(opts: StartupBannerOptions): void {
|
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 apiUrl = `${baseUrl}/api`;
|
||||||
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
|
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
|
||||||
const configPath = resolvePaperclipConfigPath();
|
const configPath = resolvePaperclipConfigPath();
|
||||||
@@ -134,6 +140,8 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
|
|||||||
...art,
|
...art,
|
||||||
color(" ───────────────────────────────────────────────────────", "blue"),
|
color(" ───────────────────────────────────────────────────────", "blue"),
|
||||||
row("Mode", `${dbMode} | ${uiMode}`),
|
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("Server", portValue),
|
||||||
row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`),
|
row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`),
|
||||||
row("UI", uiUrl),
|
row("UI", uiUrl),
|
||||||
|
|||||||
5
server/src/types/express.d.ts
vendored
5
server/src/types/express.d.ts
vendored
@@ -4,12 +4,15 @@ declare global {
|
|||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
actor: {
|
actor: {
|
||||||
type: "board" | "agent";
|
type: "board" | "agent" | "none";
|
||||||
userId?: string;
|
userId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
companyIds?: string[];
|
||||||
|
isInstanceAdmin?: boolean;
|
||||||
keyId?: string;
|
keyId?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
source?: "local_implicit" | "session" | "agent_key" | "agent_jwt" | "none";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user