Deployment
Serve plugin Fetch handlers with authentication, mount paths, and archive compression.
createFetchHandler(plugin, options) returns (request: Request) => Promise<Response>. Use it directly in Cloudflare Workers, Bun, Deno, and any framework route that accepts Fetch Request and Response objects. For Node's http, Express and Nest, mount it with createNodeHandler (see Node, Express and Nest); the package itself has no framework dependency.
The handler serves the archive for every GET request and runs a command for a matching POST request. It routes a command from the longest matching suffix of the request path, so the same handler works at the origin root, under /crtr, and behind a proxy that preserves that path.
Mount paths
Install the public URL where the handler is mounted. For https://acme.example.com/crtr, run crtr pkg plugin install --endpoint https://acme.example.com/crtr --name acme. The archive generated for that request declares /crtr/acme/... as every rest.path.
This prefix is required because crtr stores only https://acme.example.com as the command transport endpoint after installation. An archive declaring /acme/... for a handler mounted at /crtr sends commands to the origin root. Do not use baseUrl as a repair; absolute paths replace a base URL path.
Set baseUrl only when request.url is not the public URL, such as a proxy that rewrites the path or terminates TLS on a different host. Pass the complete public URL, including its mount path, to createFetchHandler. The generated rest.path must be the public path an agent could request directly.
Authentication
Pass token to require Authorization: Bearer <token> on archive and command requests. --auth-env NAME tells crtr which environment variable of the calling process holds that value. It does not configure the server. For a command an agent runs, the calling process is its broker, which reads the profile env store (crtr profile env set <profile> --name NAME) rather than your shell's exports. Omitting token serves without authentication and logs a warning when the handler is created.
A capability provider registered in the directory passes auth: {issuer, audience, providerId} instead of token; passing both throws. providerId is the provider's directory principal id — for a third-party app, its OAuth client id, bare or as app:<id>; 1–64 characters of A-Z a-z 0-9 _ -, validated when the handler is created. issuer is the directory's issuer URL and audience is the provider's endpoint URL exactly as registered. Every command request's bearer must be an outbound token the directory minted. The handler verifies it with @north-light/identity: it fetches the JWKS from <issuer>/.well-known/jwks.json, caches it, and refetches once when a token names an unknown kid. It checks the signature, iss, aud (exact match, so a trailing slash differs) and exp. A failed check answers 401 unauthorized. Next the called leaf's <providerId>:<group> must be in the token's scope (<plugin name>:<group> when providerId is not set, which is right only for a first-party provider whose name is its id; the directory always mints <provider id>:<group>, so a third-party app that omits providerId gets 403 on every call). If the scope is missing, the handler answers 403 scope_missing with that string in both field and received; when providerId is unset and the token carries the leaf's group under another id, next reads Set auth.providerId to your app's client id (<that id>). A command request under auth must also carry Crtr-Request-Id. Every leaf then gets ctx.caller: {sub, grantee, clientId, scope, initiatingApp, act, jti, requestId, runId?}. act and initiatingApp are for your audit; the kit authorizes nothing on them. The archive and GET /versions stay public under auth. For local development, point issuer at a test issuer that serves /.well-known/jwks.json.
Node, Express and Nest
createNodeHandler(handler, { bodyLimit?, origin? }) adapts the Fetch handler to (req, res) => Promise<void> for Node's http, Express and Nest. It imports nothing from node:, and its request and response types are structural, so it needs none of those frameworks' types.
import { createFetchHandler, createNodeHandler } from '@north-light/plugin';
const handler = createFetchHandler(plugin, {
auth: { issuer: ISSUER, audience: `${APP_ORIGIN}/plugin`, providerId: CLIENT_ID },
baseUrl: `${APP_ORIGIN}/plugin`,
requests,
});
const node = createNodeHandler(handler, { bodyLimit: 65536 });
// Express / Nest controller method:
// @All(['', '*path']) serve(@Req() req, @Res() res) { return node(req, res); }
// node:http:
// createServer((req, res) => void node(req, res));- URL.
originalUrl ?? urlresolved againstorigin, or againsthttp://<Host header>whenoriginis not set. Passoriginwhen the public origin differs from the Host the app sees. - Body. For a non-
GET/HEADrequest,req.rawBody(Nest withrawBody: true) is used when present; otherwise the stream is read. Acontent-lengthabovebodyLimit(default 65536 bytes), or more bytes than that, answers 413invalid_requestbefore the handler runs, so before the token is checked. - Headers. Every request header is passed on; a header Node delivers as an array is appended value by value. The response keeps repeated headers,
set-cookieincluded. - Response. The body is streamed chunk by chunk, waiting for
drainwhen the socket is full. When the client disconnects, the request'ssignalaborts and the response stream is cancelled. - Failure. A handler that throws answers 500
handler_failedin the kit's error shape; the adapter never rejects.
Major versions
A definition with version: N, or a list of definitions with one per supported major, is served per major. Each major N is mounted under <mount>/v<N>/, and its archive at GET <mount>/v<N>/ declares /v<N> routes. GET <mount>/versions answers {latest, supported}. An unversioned single definition keeps the unversioned paths above. With baseUrl, pass the endpoint without any /v<N> segment.
What needs a new major
compareManifests(previous, next) returns every change to a leaf that can break a caller written against the previous archive; the directory runs it against each major's stored archive. A leaf is breaking when it:
- is removed or renamed, or changes its tool group;
- removes a parameter, changes its type, makes it required, or adds a required one; removes an enum choice from a flag; or changes the environment variable it defaults from; or moves an existing positional;
- gains paging where it returned everything: a limit flag whose constraint states a default page size ("default N"), or a cursor input together with a
next_cursor-style output. The answer keeps its shape, so callers that do not page silently get a partial list. A changed stated page size is breaking too. A new--cursor,--limitwith no stated default, ornext_cursoroutput alone is additive, and so is a declareddefault, which crtr applies while parsing and does not send; - changes how the request is built: method, route (apart from the
/v<N>segment), body root, constant body fields, a parameter's placement or wire name, or streaming; - removes an output field, changes its type, makes it optional, drops an enum value, or adds a
fileoutput.
A new optional flag, a new leaf, a new optional output field and changed prose are additive.
Archive response
The install response is an uncompressed tar with Content-Type: application/x-tar. Do not configure a proxy, CDN, or framework middleware to gzip or otherwise compress it. Crtr rejects compressed archive bytes. The handler sends an ETag and supports conditional If-None-Match requests automatically.
A non-streaming successful command response is a bare JSON result object. Error responses are JSON error envelopes on non-2xx statuses. See Errors and streaming for the exact error shape.