Skip to content
Pilotoo
v0.1

Authenticating users

The kit never logs anyone in. Your app already knows who its users are, so the gateway asks an IdentityResolver who is calling, once per socket connection and once per request. Tenant isolation, roles and ownership of conversations and attachments all follow from the answer.

type Identity = { subject: string; tenant: string; roles: readonly string[] };

Without any configuration, every caller is { subject: 'local', tenant: 'local', roles: ['owner'] }. That is fine for local development and not for anything shared.

If your app has an endpoint that answers “who am I?” for a bearer token, set one variable and write no code:

.env
AUTH_IDENTITY_URL=https://api.example.com/me
AUTH_IDENTITY_TIMEOUT_MS=3000

The server forwards the caller’s Authorization header to that URL with a GET. The JSON body must already be an identity (subject, tenant, roles). 401 and 403 mean the caller is not identified. The timeout defaults to 5000 ms.

Most endpoints do not return that exact shape. Create the resolver in code and add map:

src/identity.ts
import { createHttpIdentityResolver } from '@pilotoo/agent-runtime';
export const identityResolver = createHttpIdentityResolver({
url: `${process.env.API_URL}/auth/me`,
timeoutMs: 3000,
map: (body) => {
const me = body as { user?: { id: string; permissions: string[] }; tenantId?: string };
if (!me.user || !me.tenantId) return undefined;
return {
subject: me.user.id,
tenant: me.tenantId,
roles: me.user.permissions.map((code) => `permission:${code}`),
};
},
});
src/app.module.ts
PilotooModule.forRoot({ env: process.env, identityResolver });

The mapped value is validated either way. A body that does not map to a valid identity makes the lookup fail, so the caller is reported as unavailable rather than unidentified.

The resolver calls your endpoint on every request and socket connection. If that is too much, wrap it. This cache keeps a successful answer for 60 seconds:

src/identity.ts
import { createHash } from 'node:crypto';
import type { Credentials, Identity, IdentityResolver } from '@pilotoo/agent-runtime';
export function cached(inner: IdentityResolver, ttlMs = 60_000): IdentityResolver {
const entries = new Map<string, { identity: Identity; expires: number }>();
return {
async resolve(credentials: Credentials) {
const key = createHash('sha256')
.update(JSON.stringify([credentials.authorization, credentials.headers]))
.digest('hex');
const hit = entries.get(key);
if (hit && hit.expires > Date.now()) return hit.identity;
const identity = await inner.resolve(credentials);
if (identity) entries.set(key, { identity, expires: Date.now() + ttlMs });
return identity;
},
};
}

Pass cached(identityResolver) as identityResolver.

roles are your own vocabulary. Settings that gate a feature take comma-separated role names, and unset means nobody:

Setting Gates
AGENT_MANAGER_ROLES Creating and editing agents
USAGE_ADMIN_ROLES Reading everyone’s usage
SCHEDULE_ROLES, SCHEDULE_ADMIN_ROLES Using and administering schedules
MODEL_GATEWAY_ROLES Serving the model gateway

The kit matches role names, nothing more. If your app has permissions rather than roles, map each one to a role name in map, as permission:<code> above, and list those names in the settings:

.env
AGENT_MANAGER_ROLES=permission:agents.manage
USAGE_ADMIN_ROLES=permission:usage.read

Some identity services pick the tenant from a header, for example one token valid for several tenants. List such headers:

PilotooModule.forRoot({ identityHeaders: ['x-tenant-id'] });

The REST guard and the socket handshake then copy exactly those headers into Credentials.headers (names lower-case), and the HTTP resolver sends them to the identity URL beside Authorization. None longer than 256 characters, none sent twice. With no list, nothing is copied.

Implement resolve(credentials) for any other scheme:

import type { IdentityResolver } from '@pilotoo/agent-runtime';
const resolver: IdentityResolver = {
async resolve({ authorization }) {
const session = await sessions.find(authorization);
return session ? { subject: session.userId, tenant: session.orgId, roles: session.roles } : undefined;
},
};

Answering undefined means unidentified. A thrown error means the answer is unavailable. createFixedIdentityResolver(identity) serves a host without authentication.

IdentityGuard is app-wide, so your own controllers are guarded too. Mark a public route with @Anonymous() and read the caller with @CallerIdentity(); both come from @pilotoo/server.

Resolver Use it for
createHttpIdentityResolver({ url, fetch?, map?, timeoutMs? }) Asking your own endpoint.
createFixedIdentityResolver(identity) A host without authentication.
Your own IdentityResolver Anything else.

The HTTP resolver forwards the caller’s Authorization header with a GET, reads 401 and 403 as unidentified, and never puts the token in an error message. The same resolver serves REST requests and the websocket handshake. A scheduled run has no request, so the host supplies the credentials it runs with.