import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { AdminError, AdminForbidden, AdminHttpApi, AdminUnauthorized, type InviteCode as InviteCodeSchema, } from "./api"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import { requireInstanceAdmin } from "./require-admin"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { createInviteCode, listInviteCodes, revokeInviteCode, type InviteCodeRow, type InviteRole, } from "../auth/invites"; // --------------------------------------------------------------------------- // Handlers for the self-host admin (invite-code) API. Every Promise-returning // boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with // a typed failure — no raw try/catch, no Promise.catch. Each route is gated by // the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member // of THIS INSTANCE'S organization, named explicitly rather than taken from the // caller's session (see require-admin.ts for the escalation that rule refuses — // this plane mints invite codes, so a bypass here is durable admin). // --------------------------------------------------------------------------- const requestHeaders = Effect.map( HttpServerRequest.HttpServerRequest.asEffect(), (request): Headers => new Headers({ ...request.headers }), ); // Resolve + authorize the caller, rendering the shared gate's denial in THIS // plane's error vocabulary (the HttpApi group declares these, not the users // plane's). const requireAdmin = (headers: Headers) => requireInstanceAdmin(headers).pipe( Effect.mapError((denial) => denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(), ), ); const narrowRole = (role: string | undefined): InviteRole => role === "admin" ? "admin" : "member"; // Drop the internal audit columns (createdBy/usedBy) for the wire shape. const toWire = (row: InviteCodeRow): typeof InviteCodeSchema.Type => ({ id: row.id, code: row.code, role: row.role, label: row.label, createdAt: row.createdAt, expiresAt: row.expiresAt, usedByEmail: row.usedByEmail, usedAt: row.usedAt, }); export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handlers) => handlers .handle("listInvites", () => Effect.gen(function* () { yield* requireAdmin(yield* requestHeaders); const { client } = yield* SelfHostDb; const rows = yield* Effect.tryPromise({ try: () => listInviteCodes(client), catch: () => new AdminError({ message: "Failed to list invites" }), }); return { invites: rows.map(toWire) }; }), ) .handle("createInvite", ({ payload }) => Effect.gen(function* () { const member = yield* requireAdmin(yield* requestHeaders); const { client } = yield* SelfHostDb; const days = payload.expiresInDays ?? null; const expiresAt = days && days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : null; const row = yield* Effect.tryPromise({ try: () => createInviteCode(client, { createdBy: member.userId, role: narrowRole(payload.role), label: payload.label?.trim() ? payload.label.trim() : null, expiresAt, }), catch: () => new AdminError({ message: "Failed to create invite" }), }); return toWire(row); }), ) .handle("revokeInvite", ({ params }) => Effect.gen(function* () { yield* requireAdmin(yield* requestHeaders); const { client } = yield* SelfHostDb; yield* Effect.tryPromise({ try: () => revokeInviteCode(client, params.inviteId), catch: () => new AdminError({ message: "Failed to revoke invite" }), }); return { success: true }; }), ), ); export interface SelfHostAdminApiDeps { readonly betterAuth: BetterAuthHandle; readonly db: SelfHostDbHandle; readonly mountPrefix: `/${string}`; } /** * The mountable extension route layer: registers the admin routes on the * `mountPrefix`-prefixed view of the ambient router (so `/admin/*` is served at * `/api/admin/*`). Better Auth + the DB handle are app singletons, provided via * `provideRequest` so the handlers' per-request requirement markers are cleared * (a plain `Layer.provide` leaves them on the layer's requirement channel). The * residual platform/router requirements are cleared by the serve binding — the * loose `RouteExtension` channel the app's `extensions.routes` accepts. */ export const makeSelfHostAdminApiLayer = ({ betterAuth, db, mountPrefix, }: SelfHostAdminApiDeps) => { const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), ); return HttpApiBuilder.layer(AdminHttpApi).pipe( Layer.provide(AdminHandlers), Layer.provide(prefixedRouter), HttpRouter.provideRequest( Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), ), ); };