Routes
Declaring HTTP route handlers with patterns and feature gates.
A package serves its own API under /api/packages/<package-id>/.... Route
handlers are file-based: each file in src/routes/ (compiled to
dist/src/routes/*.js for first-party node packages) exports a URL pattern, an
optional feature gate, and named HTTP method handlers. The host's route
dispatcher does the matching, gating, rate limiting, and error wrapping — the
handler only contains domain logic.
import type { RouteHandler } from '@neuralis/package-system';
export const pattern = 'items';
export const feature = 'my-pkg.read';
export const GET: RouteHandler = async (req, state) => {
const items = await state.store.list(req.session.projectId);
return { status: 200, body: { items } };
};
export const POST: RouteHandler = async (req, state) => {
const item = await state.store.create(req.body, req.session);
return { status: 201, body: item };
};A route file may export GET, POST, PUT, PATCH, DELETE, and an
optional default export as a catch-all handler. state is the object the
package's lifecycle init returned (see
Lifecycle). WASM project packages author the
same per-file route files; the build records each route's pattern and
feature in dist/routes.json and the host runs the SAME feature gate before
entering the sandbox — so a WASM route's feature is enforced exactly like a
node route's. See WASM build.
Routes are the platform's common calling surface, and every caller class
passes the same gate stack: the browser UI and widgets (HTTP with the cookie
session), skill scripts (session-ticket curl), external MCP clients, and —
in-process — a trusted WASM package's ctx.callRoute, which dispatches with
the current caller's real session.
req.interactive is a host-derived transport marker, not identity: true
only when the request arrived on an interactive human channel (a browser
cookie session), absent for ticketed and in-process callers — it cannot be
forged from a request body. A route may grant an interactive human an
immediate commit while the identical agent call stays governed
(pending_review); consumers must read it as strict === true. The WASM
dispatch path never sets it, so sandboxed callers land on the governed branch
by construction.
Pattern matching
Patterns are segment-based, support :param placeholders, and may end in a
* wildcard. The dispatcher tries longer patterns first, so a specific route
always beats a wildcard.
| Pattern | Matches | Result |
|---|---|---|
items | /api/packages/my-pkg/items | req.path rest is [] |
items/:id | /api/packages/my-pkg/items/123 | req.query.id === '123' |
items/* | /api/packages/my-pkg/items/a/b | req.path rest is ['a', 'b'] |
Matched :param captures are merged into req.query; the remaining segments
after the pattern arrive as req.path.
The feature gate runs before the handler
The feature export is enforced by the dispatcher before the handler is
invoked: a caller whose granted features do not include the declared feature
(or the '*' wildcard) gets a 403 with
Forbidden: missing feature '<id>', and the handler never runs. There is no
manual check to forget. Feature ids come from package manifests'
requires.providesFeatures; grants resolve from the caller's role — see
Features and access.
The export is optional, and that is the one thing to be deliberate about: a
route module that declares no feature is dispatched ungated — the
dispatcher calls the gate only when the export is present, so the caller's
authenticated identity and project membership are all that stand in front of
the handler. Declare a feature on every route that reads or writes anything,
and treat an omitted one as a decision rather than a default.
Identity is session-derived, never caller-supplied
Every request carries req.session — the canonical session context (userId,
projectId, optional agentId, role, grantedFeatures, and related scope
fields) built by the host from the caller's verified authentication: the
logged-in web session, a verified per-stream skill ticket, or an authenticated
MCP identity. Handlers must scope every read and write to it.
Route input never restates identity. A body or query field naming a userId
or projectId is not trusted and should not exist — the same rule that bans
session-context keys from tool schemas. See
Sessions for the session shapes.
Responses and errors
A handler returns { status, body?, headers? }. The dispatcher never lets an
exception escape:
| Condition | Response |
|---|---|
| No pattern matched | 404 with a safe error message |
| Method not exported (and no default handler) | 405 with an Allow header |
| Trust-tier rate limit exceeded | 429 (trusted: 300/min, untrusted: 30/min; first-party unlimited) |
Handler threw an object with a status field, status < 500 | That status, with the error message |
Handler threw anything else, or a status >= 500 | 500, body { error: 'Internal server error' } — the real message goes to the server log. A host-run package's response also carries an errorId to quote; a WASM-sandboxed one does not, because a guest has no operator-readable log to correlate against |
Throwing Object.assign(new Error('Not found'), { status: 404 }) from inside
a handler is the idiomatic way to short-circuit with a clean client error.
Redaction is keyed on the resulting status, not on how the error was
thrown. A 4xx message is intentional client-facing text and reaches the
caller unchanged; a 5xx message is internal detail and does not. Attaching
status: 500 to an error does not opt it out — the client still gets the fixed
string. On a host-run package that body also carries an errorId to quote when
reporting the fault.
Every dispatch also writes ONE structured line to the host's route log
(app/logs/routes.jsonl): matched pattern, method, status, duration, the
verified caller, and — on a refusal — which gate refused. It never contains a
request path; a no-match line carries a segment count instead, because route
paths routinely address file names and URIs. The level follows the status
(<400 info, 4xx warn, 5xx error) and defaults to warn, so a stock
deployment records refusals, faults and slow requests: a dispatch that took at
least the host's slow threshold is written at warn with slow: true,
whatever its status. Non-first-party route access is
additionally audit-logged with the package, trust tier, path, method, and
caller scope.
Testing without HTTP
Routes are plain functions over plain data, so they test without a server: construct a dispatcher from the route modules and dispatch a mock request.
import { RouteDispatcher } from '@neuralis/package-system';
import { createMockRouteRequest } from '@neuralis/package-system/testing';
import * as items from '../src/routes/items';
const dispatcher = new RouteDispatcher([
{ filename: 'items', pattern: items.pattern, feature: items.feature,
handlers: { GET: items.GET, POST: items.POST } },
]);
const res = await dispatcher.dispatch(
createMockRouteRequest({ method: 'GET', path: ['items'] }),
state,
);Cover the failure paths, not just the happy path: a caller without the feature
gets 403, a non-member sees no cross-project data, and an unknown path stays
404. The shared helpers are described in
Testing.