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

@@ -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 };
}