@fabrika/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // @fabrika/app — the first-party server framework for Fabrika applications:
2
+ // Fetch-based HTTP routing, typed RPC, middleware, and object-level authorization.
3
+
4
+ // ─── RPC ─────────────────────────────────────────────────────────────
5
+ export { initRpc } from './rpc/builder.js'
6
+ export type { InferRpcClient, InferRpcContractClient, RpcProcedureContract, RpcRouterFor } from './rpc/contract.js'
7
+ export type { InferRouter, InferRouterClient } from './rpc/types.js'
8
+
9
+ // ─── Schema contract (validator-agnostic) ────────────────────────────
10
+ export type { StandardSchemaV1 } from './standard-schema.js'
11
+
12
+ // ─── Routing ─────────────────────────────────────────────────────────
13
+ export { matchRoutes, route } from './router.js'
14
+ export type { HttpMethod, HttpRoute, Route, RouteOptions, RouteParams, RpcRoute } from './router.js'
15
+
16
+ // ─── Application ─────────────────────────────────────────────────────
17
+ export { defineApp } from './app.js'
18
+ export type { AppConfig, AssetsLike, FabrikaApp, RequestExecutionContext } from './app.js'
19
+
20
+ // ─── Errors ──────────────────────────────────────────────────────────
21
+ export {
22
+ BadRequestError,
23
+ ConflictError,
24
+ defaultOnError,
25
+ ForbiddenError,
26
+ HttpError,
27
+ NotFoundError,
28
+ toErrorResponse,
29
+ UnauthorizedError,
30
+ } from './errors.js'
31
+
32
+ // ─── Middleware + auth contract ──────────────────────────────────────
33
+ export type { AuthContext, Scope } from '@fabrika/auth'
34
+ export type { Middleware } from './middleware.js'
35
+
36
+ // ─── Client ──────────────────────────────────────────────────────────
37
+ export { createRpcClient, RpcError, type RpcFetch } from './client.js'
38
+ export type { RpcClientOptions } from './client.js'
@@ -0,0 +1,38 @@
1
+ // ─── Middleware ──────────────────────────────────────────────────────
2
+ //
3
+ // @fabrika/auth owns the request-time contracts. The app framework consumes and
4
+ // re-exports them instead of maintaining a structurally duplicated copy.
5
+
6
+ import type { Middleware } from '@fabrika/auth'
7
+
8
+ export type { Middleware } from '@fabrika/auth'
9
+
10
+ /**
11
+ * A middleware runs in declared order around the inner dispatch. It may mutate
12
+ * `ctx` (e.g. set `ctx.auth`), short-circuit by returning a `Response` without
13
+ * calling `next()`, or wrap `next()` (await it, then mutate the `Response`, e.g.
14
+ * append a `Set-Cookie`).
15
+ */
16
+ /**
17
+ * Fold `middleware` (in declared order) around an innermost `dispatch`. Each
18
+ * middleware receives a `next` that advances to the following one; the last
19
+ * `next` calls `dispatch`.
20
+ */
21
+ export function runChain<Ctx>(
22
+ request: Request,
23
+ ctx: Ctx,
24
+ middleware: ReadonlyArray<Middleware<Ctx>>,
25
+ dispatch: () => Promise<Response>,
26
+ ): Promise<Response> {
27
+ let lastIndex = -1
28
+ const run = (index: number): Promise<Response> => {
29
+ if (index <= lastIndex) {
30
+ return Promise.reject(new Error('next() called multiple times in a single middleware'))
31
+ }
32
+ lastIndex = index
33
+ const current = middleware[index]
34
+ if (!current) return dispatch()
35
+ return current(request, ctx, () => run(index + 1))
36
+ }
37
+ return run(0)
38
+ }
package/src/router.ts ADDED
@@ -0,0 +1,149 @@
1
+ // ─── HTTP routing ────────────────────────────────────────────────────
2
+ //
3
+ // A tiny method-aware path router. HTTP routes carry a `(ctx, params) => Response`
4
+ // handler; RPC routes carry a `Router`. Params are inferred from the pattern
5
+ // string literal: `/api/:project/envelope` → `{ project: string }`.
6
+
7
+ import type { Middleware } from './middleware.js'
8
+ import type { AnyRouter } from './rpc/types.js'
9
+
10
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
11
+
12
+ /** Per-route options. `use` middleware run AFTER the global chain, scoped to this route only. */
13
+ export interface RouteOptions<Ctx> {
14
+ readonly use?: ReadonlyArray<Middleware<Ctx>>
15
+ }
16
+
17
+ /** Extract `:param` and terminal `*rest` names from a pattern literal. */
18
+ export type RouteParams<Pattern extends string> = Pattern extends `${infer _Head}:${infer Param}/${infer Rest}`
19
+ ? Record<Param, string> & RouteParams<Rest>
20
+ : Pattern extends `${infer _Head}:${infer Param}` ? Record<Param, string>
21
+ : Pattern extends `${infer _Head}*${infer Rest}` ? Rest extends '' ? {} : Record<Rest, string>
22
+ : {}
23
+
24
+ export type HttpHandler<Ctx, Pattern extends string> = (ctx: Ctx, params: RouteParams<Pattern>) => Response | Promise<Response>
25
+
26
+ export interface HttpRoute<Ctx, Pattern extends string = string> {
27
+ readonly kind: 'http'
28
+ /** `null` matches every method and is intended for mounting an existing Fetch handler. */
29
+ readonly method: HttpMethod | null
30
+ readonly pattern: Pattern
31
+ readonly segments: ReadonlyArray<string>
32
+ readonly use: ReadonlyArray<Middleware<Ctx>>
33
+ handler(ctx: Ctx, params: RouteParams<Pattern>): Response | Promise<Response>
34
+ }
35
+
36
+ export interface RpcRoute<Ctx> {
37
+ readonly kind: 'rpc'
38
+ readonly pattern: string
39
+ readonly segments: ReadonlyArray<string>
40
+ readonly use: ReadonlyArray<Middleware<Ctx>>
41
+ readonly router: AnyRouter
42
+ }
43
+
44
+ export type Route<Ctx> = HttpRoute<Ctx, string> | RpcRoute<Ctx>
45
+
46
+ function splitPath(path: string): string[] {
47
+ return path.split('/').filter((segment) => segment.length > 0)
48
+ }
49
+
50
+ function decodePathSegment(segment: string): string | null {
51
+ try {
52
+ return decodeURIComponent(segment)
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ function makeHttpRoute<Ctx, Pattern extends string>(
59
+ method: HttpMethod | null,
60
+ pattern: Pattern,
61
+ handler: HttpHandler<Ctx, Pattern>,
62
+ opts: RouteOptions<Ctx> | undefined,
63
+ ): HttpRoute<Ctx, Pattern> {
64
+ const segments = splitPath(pattern)
65
+ const wildcard = segments.findIndex((segment) => segment.startsWith('*'))
66
+ if (wildcard !== -1 && wildcard !== segments.length - 1) {
67
+ throw new Error('a wildcard route segment must be terminal')
68
+ }
69
+ return { kind: 'http', method, pattern, segments, use: opts?.use ?? [], handler }
70
+ }
71
+
72
+ /**
73
+ * Route builders. HTTP verbs take `(pattern, handler, opts?)`; `rpc` mounts a
74
+ * `Router` at a path. Annotate the handler's `ctx` (or place the route in a
75
+ * `Route<Ctx>[]`) to type the context. `opts.use` adds route-scoped middleware
76
+ * (run after the global chain) — e.g. a capability/api-key gate on one path group.
77
+ */
78
+ export const route = {
79
+ all<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
80
+ return makeHttpRoute(null, pattern, handler, opts)
81
+ },
82
+ get<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
83
+ return makeHttpRoute('GET', pattern, handler, opts)
84
+ },
85
+ post<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
86
+ return makeHttpRoute('POST', pattern, handler, opts)
87
+ },
88
+ put<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
89
+ return makeHttpRoute('PUT', pattern, handler, opts)
90
+ },
91
+ patch<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
92
+ return makeHttpRoute('PATCH', pattern, handler, opts)
93
+ },
94
+ delete<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
95
+ return makeHttpRoute('DELETE', pattern, handler, opts)
96
+ },
97
+ rpc<Ctx>(path: string, router: AnyRouter, opts?: RouteOptions<Ctx>): RpcRoute<Ctx> {
98
+ return { kind: 'rpc', pattern: path, segments: splitPath(path), use: opts?.use ?? [], router }
99
+ },
100
+ }
101
+
102
+ function matchSegments(routeSegments: ReadonlyArray<string>, pathSegments: ReadonlyArray<string>): Record<string, string> | null {
103
+ const params: Record<string, string> = {}
104
+ for (let i = 0; i < routeSegments.length; i++) {
105
+ const routeSegment = routeSegments[i]
106
+ if (routeSegment === undefined) return null
107
+ if (routeSegment.startsWith('*')) {
108
+ const name = routeSegment.slice(1)
109
+ if (name !== '') {
110
+ const decoded: string[] = []
111
+ for (const segment of pathSegments.slice(i)) {
112
+ const value = decodePathSegment(segment)
113
+ if (value === null) return null
114
+ decoded.push(value)
115
+ }
116
+ params[name] = decoded.join('/')
117
+ }
118
+ return params
119
+ }
120
+ const pathSegment = pathSegments[i]
121
+ if (pathSegment === undefined) return null
122
+ if (routeSegment.startsWith(':')) {
123
+ const value = decodePathSegment(pathSegment)
124
+ if (value === null) return null
125
+ params[routeSegment.slice(1)] = value
126
+ } else if (routeSegment !== pathSegment) {
127
+ return null
128
+ }
129
+ }
130
+ return routeSegments.length === pathSegments.length ? params : null
131
+ }
132
+
133
+ /** Find the first route matching the request's method + path. RPC routes match POST. */
134
+ export function matchRoutes<Ctx>(
135
+ routes: ReadonlyArray<Route<Ctx>>,
136
+ request: Request,
137
+ ): { route: Route<Ctx>; params: Record<string, string> } | null {
138
+ const pathSegments = splitPath(new URL(request.url).pathname)
139
+ for (const candidate of routes) {
140
+ if (candidate.kind === 'rpc') {
141
+ if (request.method !== 'POST') continue
142
+ } else if (candidate.method !== null && request.method !== candidate.method) {
143
+ continue
144
+ }
145
+ const params = matchSegments(candidate.segments, pathSegments)
146
+ if (params) return { route: candidate, params }
147
+ }
148
+ return null
149
+ }
@@ -0,0 +1,128 @@
1
+ // ─── RPC builder ─────────────────────────────────────────────────────
2
+ //
3
+ // `initRpc<Ctx>()` returns a `procedure` builder bound to the context type plus a
4
+ // `router` factory. Builders are immutable — each step returns a fresh builder.
5
+ //
6
+ // Two builder shapes keep the output type honest WITHOUT any cast:
7
+ // - ProcedureBuilder (no output schema): the terminal infers the output type
8
+ // from the handler's return — `t.procedure.input(X).query(() => ({ a: 1 }))`
9
+ // gives the client `{ a: number }`, tRPC-style, no `.output()` needed.
10
+ // - OutputProcedureBuilder (after `.output(schema)`): the terminal FIXES the
11
+ // output to the schema type and validates the handler result at runtime.
12
+
13
+ import type { AuthContext, Scope } from '@fabrika/auth'
14
+ import type { StandardSchemaV1 } from '../standard-schema.js'
15
+ import type { AuthRequirement, Procedure, ProcedureKind, Router, RouterDef } from './types.js'
16
+
17
+ // The implicit input schema for a procedure with no `.input()` — accepts only "no input".
18
+ const voidSchema: StandardSchemaV1<unknown, void> = {
19
+ '~standard': {
20
+ version: 1,
21
+ vendor: 'fabrika',
22
+ validate: (input) => (input === undefined || input === null ? { value: undefined } : { issues: [{ message: 'Expected no input' }] }),
23
+ },
24
+ }
25
+
26
+ type Handler<TContext, TInput, TOutput> = (args: { ctx: TContext; input: TInput }) => TOutput | Promise<TOutput>
27
+
28
+ function makeProcedure<TContext, TInput, TOutput>(
29
+ kind: ProcedureKind,
30
+ input: StandardSchemaV1<unknown, TInput>,
31
+ output: StandardSchemaV1<unknown, TOutput> | null,
32
+ requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
33
+ handler: Handler<TContext, TInput, TOutput>,
34
+ ): Procedure<TContext, TInput, TOutput> {
35
+ return { _tag: 'procedure', kind, input, output, requirements, handler }
36
+ }
37
+
38
+ /** Builder before an output schema is set — the terminal infers the output type from the handler. */
39
+ export class ProcedureBuilder<TContext, TInput> {
40
+ constructor(
41
+ private readonly _input: StandardSchemaV1<unknown, TInput>,
42
+ private readonly _requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
43
+ ) {}
44
+
45
+ /** Set the input schema — ANY Standard Schema validator (zod, valibot, arktype, …). Resets requirements. */
46
+ input<TNewInput>(schema: StandardSchemaV1<unknown, TNewInput>): ProcedureBuilder<TContext, TNewInput> {
47
+ return new ProcedureBuilder<TContext, TNewInput>(schema, [])
48
+ }
49
+
50
+ /** Set an output schema → switch to the output builder (handler result validated against it). */
51
+ output<TOutput>(schema: StandardSchemaV1<unknown, TOutput>): OutputProcedureBuilder<TContext, TInput, TOutput> {
52
+ return new OutputProcedureBuilder<TContext, TInput, TOutput>(this._input, schema, this._requirements)
53
+ }
54
+
55
+ /**
56
+ * Record an authz requirement, checked BEFORE the handler runs (all must pass).
57
+ * Only available when the context carries `auth: AuthContext`.
58
+ */
59
+ require<TThis extends { auth: AuthContext }>(
60
+ this: ProcedureBuilder<TThis, TInput>,
61
+ action: string,
62
+ scopeResolver?: (input: TInput, ctx: TThis) => Scope | undefined,
63
+ ): ProcedureBuilder<TThis, TInput> {
64
+ const requirement: AuthRequirement<TThis, TInput> = scopeResolver ? { action, scopeResolver } : { action }
65
+ return new ProcedureBuilder<TThis, TInput>(this._input, [...this._requirements, requirement])
66
+ }
67
+
68
+ /** Terminal: a neutral handler (tagged `query`). Output type inferred from the return. */
69
+ handler<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
70
+ return makeProcedure('query', this._input, null, this._requirements, fn)
71
+ }
72
+
73
+ /** Terminal: a `query`-tagged handler. Output type inferred from the return. */
74
+ query<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
75
+ return makeProcedure('query', this._input, null, this._requirements, fn)
76
+ }
77
+
78
+ /** Terminal: a `mutation`-tagged handler. Output type inferred from the return. */
79
+ mutation<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
80
+ return makeProcedure('mutation', this._input, null, this._requirements, fn)
81
+ }
82
+ }
83
+
84
+ /** Builder after `.output(schema)` — the terminal fixes the output to the schema type + validates it. */
85
+ export class OutputProcedureBuilder<TContext, TInput, TOutput> {
86
+ constructor(
87
+ private readonly _input: StandardSchemaV1<unknown, TInput>,
88
+ private readonly _output: StandardSchemaV1<unknown, TOutput>,
89
+ private readonly _requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
90
+ ) {}
91
+
92
+ /** Record an authz requirement, checked BEFORE the handler runs (all must pass). */
93
+ require<TThis extends { auth: AuthContext }>(
94
+ this: OutputProcedureBuilder<TThis, TInput, TOutput>,
95
+ action: string,
96
+ scopeResolver?: (input: TInput, ctx: TThis) => Scope | undefined,
97
+ ): OutputProcedureBuilder<TThis, TInput, TOutput> {
98
+ const requirement: AuthRequirement<TThis, TInput> = scopeResolver ? { action, scopeResolver } : { action }
99
+ return new OutputProcedureBuilder<TThis, TInput, TOutput>(this._input, this._output, [...this._requirements, requirement])
100
+ }
101
+
102
+ /** Terminal: a neutral handler (tagged `query`); result validated against the output schema. */
103
+ handler(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
104
+ return makeProcedure('query', this._input, this._output, this._requirements, fn)
105
+ }
106
+
107
+ /** Terminal: a `query`-tagged handler; result validated against the output schema. */
108
+ query(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
109
+ return makeProcedure('query', this._input, this._output, this._requirements, fn)
110
+ }
111
+
112
+ /** Terminal: a `mutation`-tagged handler; result validated against the output schema. */
113
+ mutation(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
114
+ return makeProcedure('mutation', this._input, this._output, this._requirements, fn)
115
+ }
116
+ }
117
+
118
+ export function initRpc<TContext>(): {
119
+ procedure: ProcedureBuilder<TContext, void>
120
+ router<TDef extends RouterDef>(def: TDef): Router<TDef>
121
+ } {
122
+ return {
123
+ procedure: new ProcedureBuilder<TContext, void>(voidSchema, []),
124
+ router<TDef extends RouterDef>(def: TDef): Router<TDef> {
125
+ return { _tag: 'router', _def: def }
126
+ },
127
+ }
128
+ }
@@ -0,0 +1,34 @@
1
+ // ─── Portable RPC contract ───────────────────────────────────────────
2
+ //
3
+ // Contracts carry only procedure input and output types. A browser-safe contract
4
+ // package can therefore describe an API without importing its server router,
5
+ // validators, handler context, or runtime dependencies.
6
+
7
+ import type { InferRouterClient, Procedure, Router, RouterDef } from './types.js'
8
+
9
+ /** A type-only RPC procedure declaration for a portable API contract. */
10
+ export interface RpcProcedureContract<TInput, TOutput> {
11
+ readonly '~rpc': {
12
+ readonly input: TInput
13
+ readonly output: TOutput
14
+ }
15
+ }
16
+
17
+ /** Client-facing view of a portable contract. */
18
+ export type InferRpcContractClient<TContract> = TContract extends RpcProcedureContract<infer TInput, infer TOutput>
19
+ ? (input: TInput) => Promise<TOutput>
20
+ : TContract extends object ? { [K in keyof TContract]: InferRpcContractClient<TContract[K]> }
21
+ : never
22
+
23
+ /** Client-facing view of either a server router or a portable contract. */
24
+ export type InferRpcClient<TApi> = TApi extends { readonly _tag: 'router'; readonly _def: RouterDef } ? InferRouterClient<TApi>
25
+ : InferRpcContractClient<TApi>
26
+
27
+ type RpcRouterDefinition<TContext, TContract> = {
28
+ readonly [K in keyof TContract]: TContract[K] extends RpcProcedureContract<infer TInput, infer TOutput> ? Procedure<TContext, TInput, TOutput>
29
+ : TContract[K] extends object ? RpcRouterFor<TContext, TContract[K]>
30
+ : never
31
+ }
32
+
33
+ /** Server router shape required to implement a portable contract. */
34
+ export type RpcRouterFor<TContext, TContract> = Router<RpcRouterDefinition<TContext, TContract>>
@@ -0,0 +1,150 @@
1
+ // ─── RPC dispatcher ──────────────────────────────────────────────────
2
+ //
3
+ // Resolves a dotted method path against a router, validates input, enforces
4
+ // authz requirements, runs the handler, and (optionally) validates output. All
5
+ // error mapping goes through the structural contract in `errors.ts`.
6
+
7
+ import type { AuthContext } from '@fabrika/auth'
8
+ import { ForbiddenError, HttpError, jsonResponse, readStructuralError } from '../errors.js'
9
+ import type { StandardSchemaV1 } from '../standard-schema.js'
10
+ import type { AnyProcedure, AnyRouter, RouterDef } from './types.js'
11
+
12
+ interface SingleCall {
13
+ method: string
14
+ input: unknown
15
+ }
16
+
17
+ interface SingleResult {
18
+ result?: unknown
19
+ error?: { type: string; message: string; issues?: unknown; loginUrl?: string }
20
+ }
21
+
22
+ interface DispatchOutcome {
23
+ readonly body: SingleResult
24
+ readonly status: number
25
+ }
26
+
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return value !== null && typeof value === 'object'
29
+ }
30
+
31
+ function hasAuth(ctx: unknown): ctx is { auth: AuthContext } {
32
+ if (!isRecord(ctx)) return false
33
+ const auth = ctx['auth']
34
+ return isRecord(auth) && typeof auth['can'] === 'function' && typeof auth['scopedTo'] === 'function' && typeof auth['audit'] === 'function'
35
+ }
36
+
37
+ /**
38
+ * Run a Standard Schema validator and ALWAYS return a Result — a validator that throws (rather than
39
+ * returning issues, as the spec intends) is folded into a single issue so the dispatcher never leaks
40
+ * a raw schema error.
41
+ */
42
+ async function safeValidate<T>(schema: StandardSchemaV1<unknown, T>, value: unknown): Promise<StandardSchemaV1.Result<T>> {
43
+ try {
44
+ return await schema['~standard'].validate(value)
45
+ } catch (err) {
46
+ return { issues: [{ message: err instanceof Error ? err.message : 'Validation failed' }] }
47
+ }
48
+ }
49
+
50
+ /** A short human message from the first issue (path-prefixed); the full list rides in the envelope. */
51
+ function formatIssues(issues: ReadonlyArray<StandardSchemaV1.Issue>): string {
52
+ const first = issues[0]
53
+ if (!first) return 'Validation failed'
54
+ const path = first.path?.map((seg) => (typeof seg === 'object' ? String(seg.key) : String(seg))).join('.')
55
+ return path ? `${path}: ${first.message}` : first.message
56
+ }
57
+
58
+ function resolve(router: AnyRouter, methodPath: string): AnyProcedure | null {
59
+ const parts = methodPath.split('.')
60
+ let node: AnyProcedure | AnyRouter = router
61
+ for (const part of parts) {
62
+ if (node._tag !== 'router') return null
63
+ const def: RouterDef = node._def
64
+ const next = def[part]
65
+ if (!next) return null
66
+ node = next
67
+ }
68
+ return node._tag === 'procedure' ? node : null
69
+ }
70
+
71
+ async function invokeProcedure(procedure: AnyProcedure, ctx: unknown, rawInput: unknown): Promise<unknown> {
72
+ // Normalize JSON's `null` to `undefined` for no-input procedures.
73
+ const normalized = rawInput === null ? undefined : rawInput
74
+ const inResult = await safeValidate(procedure.input, normalized)
75
+ if (inResult.issues) {
76
+ throw new HttpError(400, 'validation', formatIssues(inResult.issues), { issues: inResult.issues })
77
+ }
78
+ const input = inResult.value
79
+
80
+ for (const requirement of procedure.requirements) {
81
+ const scope = requirement.scopeResolver?.(input, ctx)
82
+ if (!hasAuth(ctx) || !ctx.auth.can(requirement.action, scope)) {
83
+ throw new ForbiddenError(`Forbidden: ${requirement.action}`)
84
+ }
85
+ }
86
+
87
+ const output = await procedure.handler({ ctx, input })
88
+ if (!procedure.output) return output
89
+ const outResult = await safeValidate(procedure.output, output)
90
+ if (outResult.issues) {
91
+ throw new HttpError(500, 'internal', `Output validation failed: ${formatIssues(outResult.issues)}`)
92
+ }
93
+ return outResult.value
94
+ }
95
+
96
+ async function dispatchOne(router: AnyRouter, ctx: unknown, call: SingleCall): Promise<DispatchOutcome> {
97
+ const procedure = resolve(router, call.method)
98
+ if (!procedure) {
99
+ return { body: { error: { type: 'method_not_found', message: `Unknown method: ${call.method}` } }, status: 404 }
100
+ }
101
+ try {
102
+ const result = await invokeProcedure(procedure, ctx, call.input)
103
+ return { body: { result }, status: 200 }
104
+ } catch (err) {
105
+ // HTTP status comes from the error's OWN structural httpStatus (ForbiddenError → 403,
106
+ // BadRequestError → 400, a custom `{ httpStatus: 503 }` → 503, a plain Error → 500) — not a
107
+ // hardcoded type table — so every error maps correctly without the dispatcher knowing its type.
108
+ const { httpStatus, type, message, issues, loginUrl } = readStructuralError(err)
109
+ return {
110
+ body: { error: { type, message, ...(issues !== undefined ? { issues } : {}), ...(loginUrl !== undefined ? { loginUrl } : {}) } },
111
+ status: httpStatus,
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Dispatch an RPC POST. The `ctx` is already built (and decorated by middleware,
118
+ * e.g. `ctx.auth`) by the server before this is called. Handles a single call
119
+ * `{ method, input }` or a `{ batch: [...] }`.
120
+ */
121
+ export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown; request: Request }): Promise<Response> {
122
+ let rawBody: unknown
123
+ try {
124
+ rawBody = await args.request.json()
125
+ } catch {
126
+ return jsonResponse({ error: { type: 'validation', message: 'Invalid JSON body' } }, 400)
127
+ }
128
+
129
+ if (isRecord(rawBody) && Array.isArray(rawBody['batch'])) {
130
+ const results: SingleResult[] = []
131
+ for (const call of rawBody['batch']) {
132
+ results.push((await dispatchOne(args.router, args.ctx, normalizeCall(call))).body)
133
+ }
134
+ return jsonResponse({ batch: results }, 200)
135
+ }
136
+
137
+ if (!isRecord(rawBody) || typeof rawBody['method'] !== 'string') {
138
+ return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input } or { batch: [...] }' } }, 400)
139
+ }
140
+
141
+ const single = await dispatchOne(args.router, args.ctx, { method: rawBody['method'], input: rawBody['input'] })
142
+ return jsonResponse(single.body, single.status)
143
+ }
144
+
145
+ function normalizeCall(call: unknown): SingleCall {
146
+ if (isRecord(call) && typeof call['method'] === 'string') {
147
+ return { method: call['method'], input: call['input'] }
148
+ }
149
+ return { method: '', input: undefined }
150
+ }
@@ -0,0 +1,77 @@
1
+ // ─── RPC core types ──────────────────────────────────────────────────
2
+ //
3
+ // A minimal tRPC-like engine. A router is a (possibly nested) map of procedures;
4
+ // a procedure carries an input schema, an OPTIONAL output schema, an authz
5
+ // requirement list, and a handler. The server-facing type is `InferRouter`; the
6
+ // client call types are `InferRouterClient`.
7
+
8
+ import type { AuthContext, Scope } from '@fabrika/auth'
9
+ import type { StandardSchemaV1 } from '../standard-schema.js'
10
+
11
+ /** `query` and `mutation` are metadata today — the dispatcher treats them identically. */
12
+ export type ProcedureKind = 'query' | 'mutation'
13
+
14
+ /**
15
+ * A recorded authorization requirement. `scopeResolver` is declared as a METHOD
16
+ * (bivariant params) on purpose, so a concrete `Procedure<C, I, O>` stays
17
+ * assignable to `AnyProcedure`.
18
+ */
19
+ export interface AuthRequirement<TContext, TInput> {
20
+ readonly action: string
21
+ scopeResolver?(input: TInput, ctx: TContext): Scope | undefined
22
+ }
23
+
24
+ // `TInput` is the schema's PARSED (output) type — what the handler receives and what the client is
25
+ // typed to send. Modeled as `StandardSchemaV1<unknown, TInput>` (output param = TInput); for plain
26
+ // schemas input === output, so the single param is faithful.
27
+ export interface Procedure<TContext, TInput, TOutput> {
28
+ readonly _tag: 'procedure'
29
+ readonly kind: ProcedureKind
30
+ readonly input: StandardSchemaV1<unknown, TInput>
31
+ readonly output: StandardSchemaV1<unknown, TOutput> | null
32
+ readonly requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>
33
+ handler(args: { ctx: TContext; input: TInput }): Promise<TOutput> | TOutput
34
+ }
35
+
36
+ export interface AnyProcedure {
37
+ readonly _tag: 'procedure'
38
+ readonly kind: ProcedureKind
39
+ readonly input: StandardSchemaV1
40
+ readonly output: StandardSchemaV1 | null
41
+ readonly requirements: ReadonlyArray<AuthRequirement<unknown, unknown>>
42
+ handler(args: { ctx: unknown; input: unknown }): Promise<unknown> | unknown
43
+ }
44
+
45
+ export type RouterDef = {
46
+ readonly [key: string]: AnyProcedure | AnyRouter
47
+ }
48
+
49
+ export interface Router<TDef extends RouterDef> {
50
+ readonly _tag: 'router'
51
+ readonly _def: TDef
52
+ }
53
+
54
+ export type AnyRouter = Router<RouterDef>
55
+
56
+ /** Re-export the auth contract used by procedure requirements. */
57
+ export type { AuthContext, Scope }
58
+
59
+ // ─── Inference ───────────────────────────────────────────────────────
60
+
61
+ /** Server-facing view of a router: each procedure as its handler signature. */
62
+ export type InferRouter<TRouter> = TRouter extends { _tag: 'router'; _def: infer TDef } ? TDef extends RouterDef ? {
63
+ [K in keyof TDef]: TDef[K] extends Procedure<infer C, infer I, infer O> ? (args: { ctx: C; input: I }) => Promise<O> | O
64
+ : TDef[K] extends Router<RouterDef> ? InferRouter<TDef[K]>
65
+ : never
66
+ }
67
+ : never
68
+ : never
69
+
70
+ /** Client-facing view of a router: each procedure as `(input) => Promise<output>`. */
71
+ export type InferRouterClient<TRouter> = TRouter extends { _tag: 'router'; _def: infer TDef } ? TDef extends RouterDef ? {
72
+ [K in keyof TDef]: TDef[K] extends Procedure<unknown, infer I, infer O> ? (input: I) => Promise<O>
73
+ : TDef[K] extends Router<RouterDef> ? InferRouterClient<TDef[K]>
74
+ : never
75
+ }
76
+ : never
77
+ : never