Skip to content

@shutter-network/concorde/gateway

A Gateway is the whole of a deployment as one object: a record of parts under keys of the Operator's own, and a part itself, so two calls start and stop everything. Each entry is a Component, which is a start and a stop and nothing more.

createGateway is where a deployment starts. It builds the four parts every deployment has, a Db, the Agent server, the Public server and the Signal Worker, hands them to the extend callback on GatewayOptions, and answers with a Gateway holding those four beside whatever extend returned. InfraComponents names them and is what extend reads its arguments from. createBareGateway takes a finished record instead, for a deployment whose infrastructure has a shape of its own, and serverComponent turns a server the Operator built into a Component for such a record.

Every component this package ships is constructed by hand inside extend, one create* each, and only the ones a deployment wants. extend runs first and handlers reads its result, so a Signal Handler closes over a component of your own and never the reverse.

A server is also where authentication is composed. Each scheme a deployment accepts is an Auth, a Component with one more member that registers itself with the Public server at construction, and AuthOutcome is what one answers about a request. ServerComponent holds the registered Auths and composes them into the one requireUser every protected route takes, so a route reading request.concordeUser does not care which scheme named the User.

Two of the four are documented on subpaths of their own: @shutter-network/concorde/db holds the Db, and @shutter-network/concorde/signals holds the Signal Worker and the whole Signal Handler vocabulary. The other two are plain Fastify instances, each reached on .fastify. This subpath owns no tables and exports no schema, so every table a deployment needs comes from a component it constructed in extend.

Example

The smallest Gateway that runs: one Signal Handler, and nothing else of the Operator's own.

ts
import { readFileSync } from "node:fs";
import { createGateway } from "@shutter-network/concorde/gateway";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { templateHandler } from "@shutter-network/concorde/signals";

const gateway = createGateway({
  databaseUrl: process.env.DATABASE_URL ?? "",
  runtime: createPiRuntime({ image: "my-agent:1" }),
  // Not loopback: the agent reaches this server from a container of its own.
  agentListen: { host: "0.0.0.0", port: 8081 },
  publicListen: { host: "0.0.0.0", port: 8080 },
  handlers: () => ({
    "note.written": templateHandler({
      template: readFileSync(new URL("./prompts/note-written.hbs", import.meta.url), "utf8"),
      session: () => "notes",
      data: (signal) => signal.payload,
    }),
  }),
});

await gateway.start();
process.once("SIGTERM", () => void gateway.stop());

Classes

NoAuthRegisteredError

A protected route was reached on a server that no Auth had registered with.

Thrown rather than refused, so the request is a 500 an Operator finds in their log. The alternative is a 401, which is the answer every User would read as their own credential having failed, for a mistake none of them made.

Extends

  • Error

Constructors

Constructor
ts
new (method: string, url: string): NoAuthRegisteredError;
Parameters
method

string

url

string

Returns

NoAuthRegisteredError

Overrides
ts
Error.constructor

Type Aliases

Auth

ts
type Auth = Component & {
  readonly scheme: string;
  authenticate: (request: FastifyRequest) => Promise<AuthOutcome>;
};

One authentication scheme: what owns that scheme's secret and turns a request carrying it into a User.

An Auth registers itself with a server at the end of its own constructor, and the server asks every registered Auth in turn on every protected route. Which schemes a deployment accepts is therefore which Auths it constructs, and in which order.

It is an ordinary Component with one more member, so it is keyed in the Gateway's record beside everything else and is switched off by not constructing it.

Type Declaration

scheme
ts
readonly scheme: string;

The HTTP authentication scheme this answers for, such as Bearer.

One token, with no space and no parameters in it: the server writes the challenge around it. Every 401 the server composes names this scheme, whether or not this Auth was the one that refused the request.

authenticate()
ts
authenticate(request: FastifyRequest): Promise<AuthOutcome>;

Reads the request, and answers that it carries nothing of this scheme, or that it carries one that failed, or that it names this User.

The whole request is given, so a credential in a header, in a body field or anywhere else is expressible. It is read and not written: assigning to it decides nothing, because the server assigns request.concordeUser itself from the User this answers with.

A thrown error keeps its ordinary meaning. It is not a refusal, and the request is a 500.

Parameters
request

FastifyRequest

Returns

Promise<AuthOutcome>


AuthOutcome

ts
type AuthOutcome =
  | {
      readonly kind: "absent";
    }
  | {
      readonly code: "invalid_request" | "invalid_token";
      readonly detail?: string;
      readonly kind: "refused";
    }
  | {
      readonly kind: "authenticated";
      readonly user: UserRecord;
    };

What an Auth answers about one request.

absent and refused are separate so that a request carrying nothing of this scheme falls through to the next Auth without this one inventing a failure. The server stops at the first refused, so an Auth that answers refused where it means absent shuts every scheme behind it out of the request.

Union Members

Type Literal
ts
{
  kind: "absent";
}
kind
ts
readonly kind: "absent";

This request carries no credential of this scheme.


Type Literal
ts
{
  code: "invalid_request" | "invalid_token";
  detail?: string;
  kind: "refused";
}
code
ts
readonly code: "invalid_request" | "invalid_token";

Why, in the two words RFC 6750 defines and in no others: invalid_request for a credential that arrived malformed, and invalid_token for one that was well formed and did not verify.

It reaches the client, in this scheme's challenge in the WWW-Authenticate header of the 401. It is closed because a word an Auth invented would be a word the framework cannot promise says nothing about who exists in this deployment.

detail?
ts
readonly detail?: string;

One sentence about the mechanics, for whoever runs the deployment.

It never reaches the wire. It goes to the Logger the server was built with and nowhere else, so a URL that a proxy rewrote is something an Operator can diagnose and a client learns nothing from. Write the mechanical fact here, never the identity: which check failed, and not whether the User exists.

kind
ts
readonly kind: "refused";

This request carries a credential of this scheme, and it did not work.


Type Literal
ts
{
  kind: "authenticated";
  user: UserRecord;
}
kind
ts
readonly kind: "authenticated";

This request carries a credential of this scheme, and it names this User.

user
ts
readonly user: UserRecord;

Component

ts
type Component = {
  start: () => Promise<void>;
  stop: () => Promise<void>;
};

One part of a Gateway: it starts, and it stops.

A part with nothing to start and nothing to release supplies two methods that do nothing, which is ordinary rather than an apology: the record is the Gateway's directory of its own parts, and a part that holds no resource still belongs in it.

A Component has no name of its own. Its key in the Gateway's record is its name, and that key is what a failed start is reported under.

Methods

start()
ts
start(): Promise<void>;
Returns

Promise<void>

stop()
ts
stop(): Promise<void>;
Returns

Promise<void>


Gateway

ts
type Gateway<C> = Component & {
  readonly components: C;
};

Every Component a deployment runs, under the Operator's own keys.

It has a Component's shape and therefore is one, so start and stop on the whole deployment are the same two calls as on any part of it.

Type Declaration

components
ts
readonly components: C;

The record as it was given, so a part is reached by the key you wrote it under.

Type Parameters

C

C extends Record<string, Component>


GatewayExtension

ts
type GatewayExtension = Record<string, Component> & { [K in keyof InfraComponents]?: never };

What extend may return: Components under keys of your own, and none of the four infrastructure keys.

Those four are a type error rather than a substitution, because a spread would overwrite one in silence. Call createBareGateway to run a Db, a server or a Signal Worker of your own.


GatewayOptions

ts
type GatewayOptions<E> = {
  readonly agentListen: FastifyListenOptions;
  readonly databaseUrl: string;
  readonly extend?: (components: InfraComponents) => E;
  readonly handlers: (components: InfraComponents & E) => SignalHandlers;
  readonly logger?: Logger;
  readonly publicListen: FastifyListenOptions;
  readonly runtime: Runtime;
  readonly sweepIntervalMs?: number;
};

Type Parameters

E

E extends GatewayExtension

Properties

agentListen
ts
readonly agentListen: FastifyListenOptions;

Where the Agent server binds. Use loopback.

Nothing on this server authenticates anything, so reaching the port is read and write access to every route on it. Where the agent's own container reaches this process is a second value and is not derived from this one: state it in the instructions you mount into the Workspace.

databaseUrl
ts
readonly databaseUrl: string;

Where the Db connects. Nothing is on the wire until start, so a URL that answers nowhere fails there and not here.

No environment is read for it. Construction throws and names this option when it is absent, which is the one refusal a JavaScript caller can reach.

extend?
ts
readonly extend?: (components: InfraComponents) => E;

Builds Components of your own out of the four this call constructed, and returns them under keys of your own.

Every component this call does not build is constructed here, one create* each: Users, Signatures, Decisions, the Messenger with the single Channel that reaches people, and the Scheduler. A deployment that wants none of them omits this callback.

Parameters
components

InfraComponents

Returns

E

handlers
ts
readonly handlers: (components: InfraComponents & E) => SignalHandlers;

Builds the kind-to-Handler map out of the four infrastructure Components and whatever extend returned.

A callback, because a Signal Handler almost always closes over a Component. It runs after extend and cannot be seen by it, so a Handler reaches a component of your own and never the reverse.

Parameters
components

InfraComponents & E

Returns

SignalHandlers

logger?
ts
readonly logger?: Logger;

Where the Signal Worker logs, and where a refused request's detail is written. Defaults to a pino instance on stdout.

It reaches the Worker and both servers, which are the parts this call builds. A component built in extend takes its own.

publicListen
ts
readonly publicListen: FastifyListenOptions;

Where the Public server binds. This is the surface meant to be exposed, so loopback inside a container reaches nobody.

runtime
ts
readonly runtime: Runtime;

What a Prompt is handed to, and what an outcome comes back from.

createPiRuntime on @shutter-network/concorde/pi returns one for pi, and createAgentContainerRuntime on @shutter-network/concorde/agent-container builds one for any other agent program.

sweepIntervalMs?
ts
readonly sweepIntervalMs?: number;

How often the Signal Worker looks for Signals left pending, in milliseconds, in place of the Worker's own interval.

It is the backstop and not the normal path, an emitted Signal waking the Worker as it is written, so this is how long a Signal can wait when a wake-up went missing.


InfraComponents

ts
type InfraComponents = {
  agentServer: ServerComponent<FastifyInstance>;
  db: Db;
  publicServer: ServerComponent<FastifyInstance>;
  worker: SignalWorker;
};

The four parts every deployment has, under the keys they are filed under.

This is what extend is handed, and the four keys handlers is handed beside whatever extend returned. The same four keys are on gateway.components afterwards.

Properties

agentServer
ts
agentServer: ServerComponent<FastifyInstance>;

Where the agent's routes go, and the server nothing authenticates on.

It carries registerAuth and requireUser like the Public one, and nobody should use either. Every caller here is trusted, so no Auth is meant to register, and a route on this server that takes requireUser throws on every request instead of serving one.

db
ts
db: Db;
publicServer
ts
publicServer: ServerComponent<FastifyInstance>;

The exposed server, and where every Auth registers itself.

It is built before extend runs and holds no component, so an Auth registers with it from its own constructor and the order they are asked in is the order they were constructed in.

worker
ts
worker: SignalWorker;

ListeningServer

ts
type ListeningServer = {
  close: () => Promise<unknown>;
  listen: (options: FastifyListenOptions) => Promise<unknown>;
};

Methods

close()
ts
close(): Promise<unknown>;
Returns

Promise<unknown>

listen()
ts
listen(options: FastifyListenOptions): Promise<unknown>;
Parameters
options

FastifyListenOptions

Returns

Promise<unknown>


ServerComponent

ts
type ServerComponent<S> = Component & {
  readonly fastify: S;
  readonly requireUser: preHandlerAsyncHookHandler;
  registerAuth: (auth: Auth) => void;
};

A server in a Gateway's record: the instance, its place in the start order, and the authentication every protected route on it goes through.

Type Declaration

fastify
ts
readonly fastify: S;

The instance that was passed in, unwrapped, so routes of your own go on the same server.

requireUser
ts
readonly requireUser: preHandlerAsyncHookHandler;

The preHandler a protected route on this server takes, as one route option.

publicServer.requireUser on a route asks every registered Auth in turn. The first that authenticates the request has its User assigned to request.concordeUser; the first that refuses one ends it there, and so does a request no Auth recognised. Every refusal is the same 401 with the same body, and carries a WWW-Authenticate header naming every scheme this server accepts.

It reads the registered Auths per request, so a route registered before the Auth that authenticates it works. A hook and not a plugin, so it goes on a route of your own at any depth and under any prefix. Nothing is protected by default, and a route that omits it reads request.concordeUser as undefined despite the type.

Throws

NoAuthRegisteredError if no Auth has registered with this server. A wiring mistake is a 500 rather than a 401 that every User would read as their own credential failing.

registerAuth()
ts
registerAuth(auth: Auth): void;

Adds an Auth to the schemes this server accepts, at the end of that Auth's own constructor.

Registration order is the order ServerComponent.requireUser asks them in, and it is the order the schemes are named in a 401. Nothing is refused: a second Auth of one scheme is two challenges in the header and two chances to authenticate a request.

Parameters
auth

Auth

Returns

void

Type Parameters

S

S extends ListeningServer = FastifyInstance


ServerComponentOptions

ts
type ServerComponentOptions = {
  readonly logger?: Logger;
};

Properties

logger?
ts
readonly logger?: Logger;

Where a refused request's detail is written. Defaults to a pino instance on stdout.

One warn line per refusal that carried one, naming the scheme and the code beside it. Nothing else is written here, so a server no Auth registered with logs nothing through it.

Functions

createBareGateway()

ts
function createBareGateway<C>(components: C): Gateway<C>;

Assembles a Gateway from a record of Components. Start order is key order, and stop order is the reverse of it.

A Component counts as started only once its own start resolves. If one throws, everything already started is stopped and the error is rethrown, so a failed boot leaves nothing running. stop stops every Component even when one of them throws, gathers the failures into an AggregateError, and finds nothing left to do on a second call.

Two properties of a JavaScript record are not guarded against. An integer-like key such as "2" sorts ahead of every word, so a Component under one starts first. A symbol key is never started at all.

Type Parameters

C

C extends Record<string, Component>

Parameters

components

C

Returns

Gateway<C>


createGateway()

ts
function createGateway<E>(options: GatewayOptions<E>): Gateway<never>;

Builds the Db, both self-describing servers and the Signal Worker, runs extend and then handlers, and answers with a Gateway holding those four under db, agentServer, publicServer and worker, beside whatever extend returned.

Nothing connects, listens or applies DDL. Construction registers routes and returns, so the database has to be carrying your own tables by the time you call gateway.start().

Register routes of your own with fastify.register rather than writing them onto the instance. A route written straight onto it is served, and absent from the OpenAPI document.

Type Parameters

E

E extends GatewayExtension = Record<string, never>

Parameters

options

GatewayOptions<E>

Returns

Gateway<never>

Throws

If databaseUrl is absent.


serverComponent()

ts
function serverComponent<S>(
  server: S,
  listen: FastifyListenOptions,
  options?: ServerComponentOptions
): ServerComponent<S>;

Wraps a server as a Component: start binds it, and stop closes it.

It constructs nothing, so call Fastify() with whatever options you want and state where the instance binds. There is no default address. What comes back carries that instance on .fastify with its own type parameters intact, withTypeProvider and http2 included, so routes of your own go on the same server the framework's components registered theirs on.

It also carries the authentication every protected route on that server goes through. No scheme is accepted until an Auth registers itself here.

Type Parameters

S

S extends ListeningServer

Parameters

server

S

listen

FastifyListenOptions

options?

ServerComponentOptions = {}

Returns

ServerComponent<S>