Northlight
Plugin

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 ?? url resolved against origin, or against http://<Host header> when origin is not set. Pass origin when the public origin differs from the Host the app sees.
  • Body. For a non-GET/HEAD request, req.rawBody (Nest with rawBody: true) is used when present; otherwise the stream is read. A content-length above bodyLimit (default 65536 bytes), or more bytes than that, answers 413 invalid_request before 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-cookie included.
  • Response. The body is streamed chunk by chunk, waiting for drain when the socket is full. When the client disconnects, the request's signal aborts and the response stream is cancelled.
  • Failure. A handler that throws answers 500 handler_failed in 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, --limit with no stated default, or next_cursor output alone is additive, and so is a declared default, 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 file output.

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.

On this page