@package-system

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.

PatternMatchesResult
items/api/packages/my-pkg/itemsreq.path rest is []
items/:id/api/packages/my-pkg/items/123req.query.id === '123'
items/*/api/packages/my-pkg/items/a/breq.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:

ConditionResponse
No pattern matched404 with a safe error message
Method not exported (and no default handler)405 with an Allow header
Trust-tier rate limit exceeded429 (trusted: 300/min, untrusted: 30/min; first-party unlimited)
Handler threw an object with a status field, status < 500That status, with the error message
Handler threw anything else, or a status >= 500500, 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.

On this page