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:
Forgotten
2026-02-23 14:40:32 -06:00
parent 60d6122271
commit e1f2be7ecf
24 changed files with 1530 additions and 49 deletions

View File

@@ -2,7 +2,18 @@ import { readConfigFile } from "./config-file.js";
import { existsSync } from "node:fs";
import { config as loadDotenv } from "dotenv";
import { resolvePaperclipEnvPath } from "./paths.js";
import { SECRET_PROVIDERS, STORAGE_PROVIDERS, type SecretProvider, type StorageProvider } from "@paperclip/shared";
import {
AUTH_BASE_URL_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
type SecretProvider,
type StorageProvider,
} from "@paperclip/shared";
import {
resolveDefaultEmbeddedPostgresDir,
resolveDefaultSecretsKeyFilePath,
@@ -18,7 +29,12 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
type DatabaseMode = "embedded-postgres" | "postgres";
export interface Config {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
host: string;
port: number;
authBaseUrlMode: AuthBaseUrlMode;
authPublicBaseUrl: string | undefined;
databaseMode: DatabaseMode;
databaseUrl: string | undefined;
embeddedPostgresDataDir: string;
@@ -84,8 +100,45 @@ export function loadConfig(): Config {
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
: (fileStorage?.s3?.forcePathStyle ?? false);
const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE;
const deploymentModeFromEnv =
deploymentModeFromEnvRaw && DEPLOYMENT_MODES.includes(deploymentModeFromEnvRaw as DeploymentMode)
? (deploymentModeFromEnvRaw as DeploymentMode)
: null;
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
const deploymentExposureFromEnv =
deploymentExposureFromEnvRaw &&
DEPLOYMENT_EXPOSURES.includes(deploymentExposureFromEnvRaw as DeploymentExposure)
? (deploymentExposureFromEnvRaw as DeploymentExposure)
: null;
const deploymentExposure: DeploymentExposure =
deploymentMode === "local_trusted"
? "private"
: (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private");
const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE;
const authBaseUrlModeFromEnv =
authBaseUrlModeFromEnvRaw &&
AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
: null;
const authPublicBaseUrlRaw =
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
fileConfig?.auth?.publicBaseUrl;
const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined;
const authBaseUrlMode: AuthBaseUrlMode =
authBaseUrlModeFromEnv ??
fileConfig?.auth?.baseUrlMode ??
(authPublicBaseUrl ? "explicit" : "auto");
return {
deploymentMode,
deploymentExposure,
host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1",
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
authBaseUrlMode,
authPublicBaseUrl,
databaseMode: fileDatabaseMode,
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
embeddedPostgresDataDir: resolveHomeAwarePath(