From 252a0a982be0746ea2094acfd1416f26125de43c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:25:13 -0700 Subject: [PATCH 1/2] docs(readme): make snippets compile and say what the package is for The opener names the use case, the grant sentence reads grammatically in the README and the mount's doc comment, and the column table that restated a private table definition is gone. --- README.md | 85 ++++++++++++++++++++++++++++------------------------ src/mount.ts | 4 +-- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index fa755c0..141d208 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # @corbits/agent-token -Bearer tokens that let a deployed agent definition call back into the -Interchange hub that deployed it. A tenant mints a token for one of its -definitions; the plaintext is returned once and only its sha256 digest is -stored. A mount that wants to accept an agent as a caller runs the -middleware and reads the tenant and definition straight off the verified +Bearer tokens that let agents authenticate inbound calls to your +Interchange hub. Use it when a deployed agent calls back into the hub that +deployed it: a tenant mints a token for one of its agent definitions, the +plaintext is returned once and only its sha256 digest is stored, and a +guarded route reads the tenant and definition straight off the verified token. ## Install @@ -21,19 +21,30 @@ The host supplies the Interchange stack as peers: `@intx/db`, Routes are relative and go under the host's own tenant prefix, so the acting tenant comes from the host's authenticated context and never from a path parameter. The host supplies its own grant middleware: minting a token is -minting a credential, so it is gated the way the host gates credential -creation — once, its way. +minting a credential, so the host gates it exactly as it gates credential +creation. ```ts -const tokenApp = new Hono(); -mountAgentTokens(tokenApp, { - db, - requireGrant: requireGrant("credential:*", "create"), - // A token is scoped to a definition, so the host confirms this tenant - // owns it; an unknown definition answers 404 with no detail. - resolveDefinition: (tenantId, definitionId) => hub.tenantOwnsDefinition(tenantId, definitionId), -}); -app.route("/api/tenants/:tenantId", tokenApp); +import { mountAgentTokens, type AgentTokenDb } from "@corbits/agent-token"; +import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; + +export function mountTokens( + app: Hono, + db: AgentTokenDb, + requireGrant: RequireGrant, + tenantOwnsDefinition: (tenantId: string, definitionId: string) => Promise, +) { + const tokenApp = new Hono(); + mountAgentTokens(tokenApp, { + db, + requireGrant: requireGrant("credential:*", "create"), + // A token is scoped to a definition, so the host confirms this tenant + // owns it; an unknown definition answers 404 with no detail. + resolveDefinition: tenantOwnsDefinition, + }); + app.route("/api/tenants/:tenantId", tokenApp); +} ``` | Route | | @@ -53,10 +64,16 @@ It must run behind the hub's tenant middleware, which sets `tenant`; mounted without it, a valid token fails the request with a 500 rather than passing. ```ts -app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => { - const { definitionId } = c.get("agentToken"); - ... -}); +import { requireAgentToken, type AgentTokenDb } from "@corbits/agent-token"; +import type { TenantEnv } from "@intx/hub-api"; +import type { Hono } from "hono"; + +export function mountArtifacts(app: Hono, db: AgentTokenDb) { + app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => { + const { definitionId } = c.get("agentToken"); + return c.json({ id: c.req.param("id"), definitionId }); + }); +} ``` `createAgentTokenVerifier` is the bearer lookup as a plain function, for a @@ -87,30 +104,20 @@ the mount refuses a token whose `tenantId` is not the resolved run's. - Revoke sets `revoked_at`; a revoked row never verifies again and cannot be un-revoked. -## Schema / migrations +## Migrations -One table, on its own Postgres schema (`agent_token.token`), FK'd back to -Interchange's `tenant` table: - -| Column | | -|---|---| -| `id` | primary key | -| `tenant_id` | FK → `tenant.id`, cascades on delete | -| `definition_id` | the agent definition this token acts as | -| `name` | what the minting caller called it | -| `token_hash` | sha256 of the plaintext, unique | -| `created_at` | mint time | -| `revoked_at` | set once, on revoke | - -Applied idempotently, inside one advisory-locked transaction so concurrent -hub replicas cannot race the same DDL: +The package ships its SQL. Run it after Interchange's own migrations, with +the same config and schema; it is idempotent and advisory-locked, so +concurrent hub replicas cannot race it: ```ts -import { runMigrations } from "@intx/db"; import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; +import { runMigrations, type DBConfig } from "@intx/db"; -await runMigrations(dbConfig, { schema: "public" }); -await runAgentTokenMigrations(dbConfig, { schema: "public" }); +export async function migrate(dbConfig: DBConfig) { + await runMigrations(dbConfig, { schema: "public" }); + await runAgentTokenMigrations(dbConfig, { schema: "public" }); +} ``` ## License diff --git a/src/mount.ts b/src/mount.ts index 3c243f9..c4b7ce8 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -21,8 +21,8 @@ export type MountAgentTokensOpts; /** * The host's own authority check, run as middleware on every route. Minting - * a token is minting a credential, so it is gated the way the host gates - * credential creation — once, its way, not by a boolean this package + * a token is minting a credential, so the host gates it exactly as it gates + * credential creation, rather than through a boolean this package * interprets. */ requireGrant: MiddlewareHandler; From 9c3378c182a04bc536f840a864fbf31f923748e7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 11:19:19 -0700 Subject: [PATCH 2/2] docs(readme): polish to the Corbits README standard Opens with what the package is and where it plugs into Interchange, adds a Quickstart, reference tables and upgrade notes for 0.1, and moves development and migration rules to CONTRIBUTING.md. --- CONTRIBUTING.md | 24 ++++++ README.md | 207 ++++++++++++++++++++++++++---------------------- 2 files changed, 137 insertions(+), 94 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac22897 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +## Development + +```sh +git clone https://github.com/corbitsdev/corbits-agent-token.git +cd corbits-agent-token +bun install +createdb agent_token_dev +export DATABASE_URL=postgres://localhost:5432/agent_token_dev + +bun run typecheck +bun run test +bun run build +``` + +The suites in `e2e/` run against a real Postgres and skip when `DATABASE_URL` is unset. CI sets it. + +- `e2e/agent-token.drizzle.test.ts` runs Interchange's migrations into a scratch schema and exercises the routes, the middleware and the verifier. +- `e2e/upgrade-from-0.1.0.test.ts` creates its own database, builds it with the published 0.1.0 (the `agent-token-0.1.0` dev dependency), then upgrades it with this build. + +## Migrations + +`migrations/*.sql` ship in the package. `runAgentTokenMigrations` replays every file on every boot, in one transaction under an advisory lock, with no record of what already ran. Every statement must be idempotent (`IF NOT EXISTS`, guarded `ALTER`s, re-runnable backfills). `"public".` in a file is rewritten to the host schema passed as `schema`. diff --git a/README.md b/README.md index 141d208..aa3f4cd 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,144 @@ # @corbits/agent-token -Bearer tokens that let agents authenticate inbound calls to your -Interchange hub. Use it when a deployed agent calls back into the hub that -deployed it: a tenant mints a token for one of its agent definitions, the -plaintext is returned once and only its sha256 digest is stored, and a -guarded route reads the tenant and definition straight off the verified -token. +[![npm](https://img.shields.io/npm/v/@corbits/agent-token.svg)](https://www.npmjs.com/package/@corbits/agent-token) [![License: LGPL-2.1](https://img.shields.io/badge/license-LGPL--2.1-green.svg)](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE) + +Bearer tokens that a deployed agent presents when it calls back into its Interchange hub (the multi-tenant control plane): mint, list and revoke routes, SHA-256 digests in Postgres, and a Hono middleware that verifies the bearer against the route's tenant. A Corbits hub module that mounts on `@intx/hub-api` and `@intx/db`. + +## Why @corbits/agent-token? + +1. **Scoped to a tenant and an agent definition.** A token carries the tenant that minted it and the agent definition (the hub's record of a deployable agent) it was minted for. The middleware refuses a token on any other tenant's routes. +2. **The plaintext is shown once.** Only `sha256(token)` is stored. The mint response is the only place the plaintext appears. +3. **The hub stays in charge.** Every route runs the host's `requireGrant` (the hub's permission check), and the tenant comes from the hub's context, never from the request. + +It authenticates agents calling the hub. It does not hold outbound credentials an agent uses to call other services. ## Install +```bash +bun add @corbits/agent-token @intx/authz @intx/db @intx/hub-api drizzle-orm hono postgres ``` -bun add @corbits/agent-token + +## Quickstart + +With `DATABASE_URL` pointing at a hub database that has run `runAgentTokenMigrations` (see [Using with Interchange](#using-with-interchange)): + +```ts +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { mintAgentToken, verifyAgentToken } from "@corbits/agent-token"; + +const client = postgres(process.env.DATABASE_URL!); +const db = drizzle(client); + +const minted = await mintAgentToken(db, { + tenantId: "acme", + definitionId: "support-agent", + name: "demo", +}); +console.log(await verifyAgentToken(db, minted.token)); +await client.end(); ``` -The host supplies the Interchange stack as peers: `@intx/db`, -`@intx/hub-api`, `drizzle-orm`, `hono` and `postgres`. +`acme` must be a tenant in the hub. It prints `{ id, tenantId: "acme", definitionId: "support-agent" }`. -## Mount (`mountAgentTokens`) +## Where it fits -Routes are relative and go under the host's own tenant prefix, so the acting -tenant comes from the host's authenticated context and never from a path -parameter. The host supplies its own grant middleware: minting a token is -minting a credential, so the host gates it exactly as it gates credential -creation. +[Interchange](https://github.com/faremeter/interchange) runs AI agents as principals: accounts with their own identity, permissions and credentials. Its hub is the multi-tenant control plane that holds tenants, principals and grants (permissions a principal holds on a resource); its sidecar is the agent runtime. -```ts -import { mountAgentTokens, type AgentTokenDb } from "@corbits/agent-token"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { Hono } from "hono"; +- **Runs in:** the hub, as routes and middleware on its Hono app and one table in its Postgres (`agent_token` schema). +- **Plugs into:** [`@intx/hub-api`](https://github.com/faremeter/interchange/tree/main/packages/hub-api) (`TenantEnv`, `requireGrant`) and [`@intx/db`](https://github.com/faremeter/interchange/tree/main/packages/db) (its `DBConfig`, and its `tenant` table as the FK target). +- **Pairs with:** the run-scoped routes of [`@corbits/memory`](https://github.com/corbitsdev/corbits-memory) and [`@corbits/artifacts`](https://github.com/corbitsdev/corbits-artifacts), which accept these tokens from agents. -export function mountTokens( - app: Hono, - db: AgentTokenDb, - requireGrant: RequireGrant, - tenantOwnsDefinition: (tenantId: string, definitionId: string) => Promise, -) { - const tokenApp = new Hono(); - mountAgentTokens(tokenApp, { - db, - requireGrant: requireGrant("credential:*", "create"), - // A token is scoped to a definition, so the host confirms this tenant - // owns it; an unknown definition answers 404 with no detail. - resolveDefinition: tenantOwnsDefinition, - }); - app.route("/api/tenants/:tenantId", tokenApp); -} -``` +## Reference -| Route | | -|---|---| -| `GET /agent-tokens` | List the tenant's tokens (never the plaintext or its digest) | -| `POST /agent-tokens` | Mint a token (`definitionId`, `name`); the plaintext is in the 201 response and nowhere else | -| `DELETE /agent-tokens/:id` | Revoke a token; revocation is final | +### `mountAgentTokens(app, opts)` -## Middleware (`requireAgentToken`) +Mounts relative routes on a `Hono`. Put it under the hub's tenant prefix. -Reads `Authorization: Bearer`, hashes the presented value, and looks up an -unrevoked row minted by the route's own tenant (`c.get("tenant")`). On -success it sets `agentToken` — `{ id, tenantId, definitionId }` — on the -context; missing, malformed, unknown, revoked and other-tenant tokens all -get the same bare 401, with no detail that would tell a caller which it was. -It must run behind the hub's tenant middleware, which sets `tenant`; mounted -without it, a valid token fails the request with a 500 rather than passing. +| `opts` | Type | What the host provides | +| ------------------- | ------------------- | -------------------------------------------------------------------------------------- | +| `db` | `AgentTokenDb` | The hub's drizzle handle, for example from `createDB`. | +| `requireGrant` | `MiddlewareHandler` | The grant check for minting a credential, run on every route. | +| `resolveDefinition` | `ResolveDefinition` | `(tenantId, definitionId) => boolean \| Promise`: whether the tenant owns it. | -```ts -import { requireAgentToken, type AgentTokenDb } from "@corbits/agent-token"; -import type { TenantEnv } from "@intx/hub-api"; -import type { Hono } from "hono"; - -export function mountArtifacts(app: Hono, db: AgentTokenDb) { - app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => { - const { definitionId } = c.get("agentToken"); - return c.json({ id: c.req.param("id"), definitionId }); - }); -} -``` +| Route | Result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /agent-tokens` | `200 { tokens }`, without plaintext or digest. | +| `POST /agent-tokens` | Mints for `{ definitionId, name }`: `201 { token }` with the plaintext. `400 invalid_body` on a bad body, `404 not_found` for a definition the tenant does not own. | +| `DELETE /agent-tokens/:id` | Revokes: `200 { ok: true }`, or `404 not_found`. Revocation is final. | + +### `requireAgentToken({ db })` + +Middleware that reads `Authorization: Bearer ` and sets `agentToken` (`{ id, tenantId, definitionId }`) on the context. Missing, unknown, revoked and other-tenant tokens all get the same `401 { "error": "unauthorized" }`. It must run behind the hub's tenant middleware; without `tenant` on the context every request fails with a 500. + +### `createAgentTokenVerifier({ db })` + +The same check as a function, `(c) => Promise`, for a route that falls back to its own authentication when no agent token is presented. It also refuses other-tenant tokens. + +### Run-scoped types -`createAgentTokenVerifier` is the bearer lookup as a plain function, for a -hub mount that wants to fall back to its own authentication when no bearer -is presented. Like the middleware, it refuses a token minted by another -tenant. +For a route an agent calls on behalf of one workflow run (a single execution of a deployed agent): `ResolvedWorkflowRunScope` (`{ tenantId, principalId, runId }`), `WorkflowRunScopeEnv` (`TenantEnv` plus `workflowRunScope`), and `AgentTokenAuth`: `{ verify, resolveRun }`, where `verify` is a `createAgentTokenVerifier` and `resolveRun(runAddress)` is the host's lookup from the run address in the request to a `ResolvedWorkflowRunScope`, or `null`. `AgentTokenVariables` types `c.get("agentToken")` on a host's own Env. -## Run-scoped mounts +### Functions -A mount that serves a deployed agent's run, rather than a browser session, -imports its types from here: `ResolvedWorkflowRunScope` (`{ tenantId, -principalId, runId }`), `WorkflowRunScopeEnv` (the hub's `TenantEnv` plus a -`workflowRunScope` variable) and `AgentTokenAuth`, the host's `verify` and -`resolveRun` pair. `createAgentTokenVerifier({ db })` is a ready `verify`; -the mount refuses a token whose `tenantId` is not the resolved run's. +`mintAgentToken(db, { tenantId, definitionId, name })` returns a `MintedAgentToken`, `verifyAgentToken(db, token)` an `AgentTokenIdentity` or `undefined`, and `revokeAgentToken(db, { tenantId, id })` whether a live token was revoked. -## Security +### `runAgentTokenMigrations(dbConfig, { schema })` -- The token is 32 random bytes from `node:crypto`'s `randomBytes`, - base64url-encoded. -- Only `sha256(token)` is stored; lookups are by digest, and the digest - comparison is constant-time. -- Scope — the tenant and the definition — travels with the row, not with - the caller's claim. The tenant is read off the host's context and the - definition is confirmed to belong to it before a token is minted. -- Every route runs the host's grant middleware, so authority is checked - once and by the host. -- Revoke sets `revoked_at`; a revoked row never verifies again and cannot - be un-revoked. +From `@corbits/agent-token/migrations`. Run it after Interchange's `runMigrations`, with the same config and the same `schema`: the host schema that holds the `tenant` table. The `token` table always lives in the `agent_token` schema. It is idempotent and takes an advisory lock, so several replicas can start at once. -## Migrations +## Using with Interchange -The package ships its SQL. Run it after Interchange's own migrations, with -the same config and schema; it is idempotent and advisory-locked, so -concurrent hub replicas cannot race it: +1. Run `runAgentTokenMigrations` at hub start, after `runMigrations`. +2. Mount `mountAgentTokens` under the tenant prefix, gated with `requireGrant("credential:*", "create")`, since minting a token creates a credential. +3. Give the minted token to the deployed agent, for example as a secret in its environment. +4. Put `requireAgentToken` (or `createAgentTokenVerifier`) on each hub route the agent calls. ```ts +import { timeWindowEvaluator } from "@intx/authz"; +import { createDB, createGrantStore, runMigrations } from "@intx/db"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; +import { requireAgentToken } from "@corbits/agent-token"; import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; -import { runMigrations, type DBConfig } from "@intx/db"; -export async function migrate(dbConfig: DBConfig) { - await runMigrations(dbConfig, { schema: "public" }); - await runAgentTokenMigrations(dbConfig, { schema: "public" }); -} +const dbConfig = { + host: "localhost", + port: 5432, + user: "postgres", + password: "postgres", + database: "interchange", +}; + +await runMigrations(dbConfig, { schema: "public" }); +await runAgentTokenMigrations(dbConfig, { schema: "public" }); + +const { db } = createDB(dbConfig); +export const requireGrant = createRequireGrant({ + grantStore: createGrantStore(db), + conditionRegistry: { time_window: timeWindowEvaluator }, +}); + +export const whoami = new Hono().get( + "/whoami", + requireAgentToken({ db }), + (c) => c.json(c.get("agentToken")), +); ``` +Mount the token routes with `mountAgentTokens(tokens, { db, requireGrant: requireGrant("credential:*", "create"), resolveDefinition })` on a `Hono`. `resolveDefinition` is the host's check that the tenant owns the agent definition, answered from its own definition records. Mount `tokens` and `whoami` on the hub app under `/api/tenants/:tenantId`, behind the hub's auth and tenant middleware. + +`POST /api/tenants//agent-tokens` with `{ "definitionId": "...", "name": "..." }` returns `{ "token": { "id", "token", ... } }`. Calling `GET /api/tenants//whoami` with `Authorization: Bearer ` returns `{ "id", "tenantId", "definitionId" }`. + +## Upgrading from 0.1 + +- Existing databases and tokens keep working: the migration is unchanged and re-runs cleanly, and tokens minted by 0.1.0 still verify. +- `applyAgentTokenMigrations(databaseUrl, { tenantSchema })` is replaced by `runAgentTokenMigrations(dbConfig, { schema })` from `@corbits/agent-token/migrations`. A 0.1.0 table keeps its FK to the schema it was first created against. +- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `postgres` are now peer dependencies. +- The root no longer exports `agentTokenSchema`, `agentTokenTable`, `generateAgentToken`, `hashAgentToken`, `agentTokenHashEquals` or `bearerFromAuthorization`. +- `mountAgentTokens` drops `resolveTenantId` and reads the tenant from `TenantEnv`. +- `requireAgentToken` and `createAgentTokenVerifier` now refuse tokens minted by another tenant, and need the hub's tenant middleware in front of them. + ## License -LGPL-2.1 +[LGPL-2.1](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE)