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)), ), );};
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)),
),
);
};