REST routes

defineRestRoute — public raw-HTTP endpoints (URL + JSON) for third parties that don't speak the rpc WebSocket. Schema-typed input/output, guards, deprecation/sunset, mounted on the same listener.

The five primitives above ride one WebSocket — great for your own UI, wrong for a third party calling your API with a plain URL + JSON body. defineRestRoute (from @voltro/protocol/rest) is the public raw-HTTP surface: a schema-typed request/response endpoint that desugars to exactly one HTTP route on the same listener the rpc server + plugin routes use. It is NOT a separate runtime.

// routes/v1/customers.list.route.tsx
import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
import { Schema } from 'effect'

export default defineRestRoute({
  method: 'GET',
  path:   '/v1/customers',
  // Input shape is `{ query?, params?, body? }`. The desugar parses the
  // query string, path params, and JSON body into this, then decodes it.
  input:  Schema.Struct({
    query: Schema.Struct({
      limit:  Schema.optional(Schema.Number.pipe(Schema.between(1, 100))),
      cursor: Schema.optional(Schema.String),
    }),
  }),
  output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
  summary: 'List customers',                  // OpenAPI metadata — inert until a generator consumes it
  guards:  [requireScope('customers:read')],  // → 403 when the subject lacks the scope
  handler: async ({ query }, ctx) => {
    const limit = query.limit ?? 20
    // ctx = { subject, headers, store }. The auth resolver populates
    // `subject`; `store` is the framework DataStore (cast at the call site).
    return { data, nextCursor }
  },
})

Registration

REST routes are NOT auto-discovered by file suffix — register them explicitly via restRoutes in app.config.ts (the *.route.tsx filename is convention, not magic):

import listCustomers from './routes/v1/customers.list.route'

export default {
  type: 'api' as const,
  name: 'publicApi',
  restRoutes: [listCustomers],
}

The module's default export is the descriptor — one descriptor per file, the same one-procedure-per-file discipline as *.query.ts / *.mutation.ts.

The request lifecycle (the desugar)

Each descriptor becomes one HTTP route. Per request, in order:

Step Outcome on failure
1. Method gate 405 Method Not Allowed (Allow: header)
2. Sunset gate (past sunset date) 410 Gone + replacement pointer
3. Input decode ({ query, params, body } → schema) 400 Bad Request
4. Guards (after decode + ctx resolution) the guard's { status, message } (e.g. 403)
5. await handler(input, ctx) a thrown { status, message } → that status; anything else → 500
6. Output encode → JSON 200 OK

deprecated sets a Deprecation: true response header; sunset (an ISO date) sets a Sunset: header and, once past, flips the route to 410. Both are pure metadata otherwise — the route keeps working until the sunset date.

ctx — what the handler gets

interface RestRouteContext {
  readonly subject: Subject                              // resolved by the auth resolver
  readonly headers: Readonly<Record<string, string>>     // request headers
  readonly store: unknown                                // framework DataStore — cast at the call site
}

The same AuthMiddleware/ConnectionInfoMiddleware that gate the rpc surface run here too, so a forwarded session cookie resolves the same Subject + tenant as the WebSocket path.

Guards

A RestGuard runs after input decode, before the handler. Return undefined to proceed, or { status, message } to short-circuit:

import { requireScope, type RestGuard } from '@voltro/protocol/rest'

// Built-in: rejects 403 unless the subject carries the scope (admin '*' bypasses).
guards: [requireScope('customers:read')]

// Custom guard:
const requireApiKey: RestGuard = (ctx) =>
  ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }

Idempotency (Idempotency-Key)

Set idempotency: true in app.config.ts and every mutating REST request (POST/PUT/PATCH/DELETE) that carries an Idempotency-Key header is deduplicated:

// app.config.ts
export default { type: 'api' as const, name: 'api', restRoutes: [chargeRoute], idempotency: true }
// or: idempotency: { header: 'Idempotency-Key', ttlMs: 86_400_000 }
# First call runs the handler + caches the response.
curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
#   → { "chargeId": "uuid-1", … }

# A retry with the SAME key replays the cached response — the handler does NOT run again.
curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
#   → { "chargeId": "uuid-1", … }   (+ response header `Idempotency-Replayed: true`)
  • Replay within the window returns the first response verbatim, with Idempotency-Replayed: true.
  • In-flight duplicate (the first call hasn't finished) gets 409.
  • Keys are scoped per (tenant, method, path), persisted in _voltro_idempotency, expiring after ttlMs (default 24h).

This is the Stripe-style contract — the client opts in by sending the header. It's the standard for external API clients. Scope + guarantee:

  • REST/HTTP only. It rides the Idempotency-Key HTTP header. The WS rpc transport (useMutation from your own frontend) has no per-call header — guard double-submit there with optimistic UI + a disabled button, not server idempotency.
  • Inbound webhooks already dedup via @voltro/plugin-webhooks (provider key + _voltro_webhook_*) — don't double-cover them.
  • Atomic claim, non-atomic completion. Two concurrent same-key requests resolve to exactly one execution (the UNIQUE(scope,key) insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to completed leaves the key in-flight (a retry 409s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.

Projecting an existing procedure — publicApi

You often want to offer an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with publicApi and the framework mounts ONE HTTP route that runs the same handler, under the same guards:

// queries/absenceRequests.list.query.ts
export default defineQuery({
  name:   'absenceRequests.list',
  input:  Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
  output: Schema.Array(AbsenceRequest),
  guards: [requireScope('absences:read')],
  publicApi: {},   // → GET /v1/absenceRequests/list?status=open&limit=20
})
  • Method derives from the kind: query → GET, mutation / action → POST (override with method).
  • Path derives from the tag: /<version>/<tag-as-path> (override with path; set version).
  • Input binding follows the method: for GET the descriptor's input schema is bound to the query string, otherwise to the JSON body. So filter and pagination fields are plain URL params — no separate input shape.
  • Relations need nothing extra: eager loading is resolved server-side by the executor, so include works identically over HTTP.
  • Authorization is the same code as the WebSocket path — the declarative guards:, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
  • Also available per endpoint: scopes (extra API-key scopes), rateLimit, idempotent.

This pairs with crud.list: filter / paginate / sort / include on the executor plus publicApi: {} on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.

Use publicApi when the procedure already exists and the derived URL is fine; use defineRestRoute when you need a hand-shaped URL, path params, or a response that isn't the procedure's output.

Live updates over HTTP — stream: 'sse'

A third party that can't open your WebSocket can still follow changes: stream: 'sse' on a QUERY projects it as Server-Sent Events — the initial snapshot, then a delta per change, until the client disconnects.

export default defineQuery({
  name:   'orders.live',
  input:  Schema.Struct({ status: Schema.optional(Schema.String) }),
  output: Schema.Array(Order),
  guards: [requireScope('orders:read')],
  publicApi: { stream: 'sse' },   // → GET /v1/orders/live?status=open  (text/event-stream)
})
// any EventSource client — no Voltro SDK needed
const es = new EventSource('/v1/orders/live?status=open')
es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
es.addEventListener('delta',    (e) => applyDelta(JSON.parse(e.data)))

Each event's _tag becomes the SSE event: name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across data: lines (a raw \n would truncate the event), a retry: hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.

Same guarantees as the WebSocket path, because it is the same code: the declarative guards:, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect during setup). A guard denial arrives as one error event rather than an HTTP status — by then the response headers are already sent.

stream: 'sse' on a mutation or action is ignored: there is nothing to subscribe to.

For a hand-written defineRestRoute, the same machinery is available directly — return sse((emit) => unsubscribe) from the handler and frame events with sseFrame(event, data) (both from @voltro/protocol/rest).

REST route vs Action

Both are unary request/response. Pick by transport + audience:

Action REST route
Transport rpc over WebSocket (+ POST /rpc) public raw HTTP at a URL you choose
Caller your own UI via useAction third parties with fetch / curl / SDKs
Wire shape the rpc JSON envelope plain JSON body + status codes
Typed errors Schema.TaggedError on the rpc channel HTTP status codes (throw { status, message })

Use an action when your own client calls it; use a REST route when an external system needs a stable, documented URL. For signed inbound webhooks (Stripe, GitHub, …) use @voltro/plugin-webhooks instead — it adds signature verification + idempotency on top.