/** * T12 · frp server-plugin HTTP shim (v0.8 MVP stepping-stone). A THIN adapter implementing frp's * `Login` / `NewProxy` / `NewUserConn` server-plugin hooks by DELEGATING the decision to P3's * control-plane authz endpoint. NO tenancy logic lives here — it forwards and enforces the * deny-by-default answer. Malformed hook bodies are rejected at the Zod boundary. * * Retirement (v0.9): frp login plugin → T8 upgrade gate; frp subdomain vhost → T7 router; * frp yamux → the native §4.1 mux. This shim is deleted when the native mux lands. */ import { z } from 'zod' export type FrpOp = 'Login' | 'NewProxy' | 'NewUserConn' /** frp server-plugin request envelope: `{ version, op, content }` (content shape varies by op). */ const FrpHookRequestSchema = z .object({ version: z.string().min(1), op: z.enum(['Login', 'NewProxy', 'NewUserConn']), content: z.record(z.unknown()), }) .strict() export type FrpHookRequest = z.infer /** frp plugin response: reject (deny) OR pass-through unchanged. */ export type FrpHookResponse = | { readonly reject: true; readonly reject_reason: string } | { readonly reject: false; readonly unchange: true } /** The control-plane (P3) decision the shim forwards to — it owns ALL tenancy logic. */ export interface ControlPlaneAuthz { authorize(op: FrpOp, content: Readonly>): Promise<{ allow: boolean }> } const REJECT_MALFORMED: FrpHookResponse = { reject: true, reject_reason: 'malformed hook request' } const REJECT_DENIED: FrpHookResponse = { reject: true, reject_reason: 'denied by control plane' } const ALLOW: FrpHookResponse = { reject: false, unchange: true } /** * Handle one frp server-plugin hook call. Validates the body (deny-by-default on malformed), * forwards the decision to P3, and maps allow→pass-through / deny→reject. Never decides tenancy. */ export async function handleFrpHook(raw: unknown, authz: ControlPlaneAuthz): Promise { const parsed = FrpHookRequestSchema.safeParse(raw) if (!parsed.success) return REJECT_MALFORMED const { op, content } = parsed.data const decision = await authz.authorize(op, content) return decision.allow ? ALLOW : REJECT_DENIED }