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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user