Skip to content

Commit d2d71b5

Browse files
committed
Polish the README 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.
1 parent a4f9eff commit d2d71b5

2 files changed

Lines changed: 115 additions & 97 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Contributing
2+
3+
## Development
4+
5+
```sh
6+
git clone https://github.com/corbitsdev/corbits-agent-token.git
7+
cd corbits-agent-token
8+
bun install
9+
createdb agent_token_dev
10+
export DATABASE_URL=postgres://localhost:5432/agent_token_dev
11+
12+
bun run typecheck
13+
bun run test
14+
bun run build
15+
```
16+
17+
The suites in `e2e/` run against a real Postgres and skip when `DATABASE_URL` is unset. CI sets it.
18+
19+
- `e2e/agent-token.drizzle.test.ts` runs Interchange's migrations into a scratch schema and exercises the routes, the middleware and the verifier.
20+
- `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.
21+
22+
## Migrations
23+
24+
`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`.

‎README.md‎

Lines changed: 91 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,120 @@
11
# @corbits/agent-token
22

3-
Bearer tokens that let agents authenticate inbound calls to your
4-
Interchange hub. Use it when a deployed agent calls back into the hub that
5-
deployed it: a tenant mints a token for one of its agent definitions, the
6-
plaintext is returned once and only its sha256 digest is stored, and a
7-
guarded route reads the tenant and definition straight off the verified
8-
token.
3+
[![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)
94

10-
## Install
5+
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`.
116

12-
```
13-
bun add @corbits/agent-token
14-
```
7+
## Why @corbits/agent-token?
158

16-
The host supplies the Interchange stack as peers: `@intx/db`,
17-
`@intx/hub-api`, `drizzle-orm`, `hono` and `postgres`.
9+
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.
10+
2. **The plaintext is shown once.** Only `sha256(token)` is stored. The mint response is the only place the plaintext appears.
11+
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.
12+
13+
It authenticates agents calling the hub. It does not hold outbound credentials an agent uses to call other services.
14+
15+
## Install
1816

19-
## Mount (`mountAgentTokens`)
17+
```bash
18+
npm add @corbits/agent-token @intx/db @intx/hub-api drizzle-orm hono postgres
19+
```
2020

21-
Routes are relative and go under the host's own tenant prefix, so the acting
22-
tenant comes from the host's authenticated context and never from a path
23-
parameter. The host supplies its own grant middleware: minting a token is
24-
minting a credential, so the host gates it exactly as it gates credential
25-
creation.
21+
## Quickstart
2622

2723
```ts
28-
import { mountAgentTokens, type AgentTokenDb } from "@corbits/agent-token";
24+
import { createDB, type DBConfig } from "@intx/db";
2925
import type { RequireGrant, TenantEnv } from "@intx/hub-api";
3026
import { Hono } from "hono";
27+
import { mountAgentTokens, requireAgentToken } from "@corbits/agent-token";
28+
import { runAgentTokenMigrations } from "@corbits/agent-token/migrations";
3129

32-
export function mountTokens(
33-
app: Hono<TenantEnv>,
34-
db: AgentTokenDb,
35-
requireGrant: RequireGrant,
36-
tenantOwnsDefinition: (tenantId: string, definitionId: string) => Promise<boolean>,
37-
) {
38-
const tokenApp = new Hono<TenantEnv>();
39-
mountAgentTokens(tokenApp, {
40-
db,
41-
requireGrant: requireGrant("credential:*", "create"),
42-
resolveTenantId: (c) => c.get("tenant").id,
43-
// A token is scoped to a definition, so the host confirms this tenant
44-
// owns it; an unknown definition answers 404 with no detail.
45-
resolveDefinition: tenantOwnsDefinition,
46-
});
47-
app.route("/api/tenants/:tenantId", tokenApp);
48-
}
30+
declare const app: Hono<TenantEnv>;
31+
declare const dbConfig: DBConfig;
32+
declare const requireGrant: RequireGrant;
33+
declare const tenantOwnsDefinition: (
34+
tenantId: string,
35+
id: string,
36+
) => Promise<boolean>;
37+
38+
await runAgentTokenMigrations(dbConfig, { schema: "public" });
39+
const { db } = createDB(dbConfig);
40+
41+
const tokens = new Hono<TenantEnv>();
42+
mountAgentTokens(tokens, {
43+
db,
44+
requireGrant: requireGrant("credential:*", "create"),
45+
resolveDefinition: tenantOwnsDefinition,
46+
});
47+
app.route("/api/tenants/:tenantId", tokens);
48+
49+
app.get("/api/tenants/:tenantId/whoami", requireAgentToken({ db }), (c) =>
50+
c.json(c.get("agentToken")),
51+
);
4952
```
5053

51-
| Route | |
52-
|---|---|
53-
| `GET /agent-tokens` | List the tenant's tokens (never the plaintext or its digest) |
54-
| `POST /agent-tokens` | Mint a token (`definitionId`, `name`); the plaintext is in the 201 response and nowhere else |
55-
| `DELETE /agent-tokens/:id` | Revoke a token; revocation is final |
54+
`POST /api/tenants/<id>/agent-tokens` with `{ "definitionId": "...", "name": "..." }` returns `{ "token": { "id", "token", ... } }`. Calling `GET /api/tenants/<id>/whoami` with `Authorization: Bearer <token>` returns `{ "id", "tenantId", "definitionId" }`.
5655

57-
## Middleware (`requireAgentToken`)
56+
## Where it fits
5857

59-
Reads `Authorization: Bearer`, hashes the presented value, and looks up an
60-
unrevoked row minted by the route's own tenant (`c.get("tenant")`). On
61-
success it sets `agentToken` — `{ id, tenantId, definitionId }` — on the
62-
context; missing, malformed, unknown, revoked and other-tenant tokens all
63-
get the same bare 401, with no detail that would tell a caller which it was.
64-
It must run behind the hub's tenant middleware, which sets `tenant`; mounted
65-
without it, a valid token fails the request with a 500 rather than passing.
58+
[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.
6659

67-
```ts
68-
import { requireAgentToken, type AgentTokenDb } from "@corbits/agent-token";
69-
import type { TenantEnv } from "@intx/hub-api";
70-
import type { Hono } from "hono";
71-
72-
export function mountArtifacts(app: Hono<TenantEnv>, db: AgentTokenDb) {
73-
app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => {
74-
const { definitionId } = c.get("agentToken");
75-
return c.json({ id: c.req.param("id"), definitionId });
76-
});
77-
}
78-
```
60+
- **Runs in:** the hub, as routes and middleware on its Hono app and one table in its Postgres (`agent_token` schema).
61+
- **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).
62+
- **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.
7963

80-
`createAgentTokenVerifier` is the bearer lookup as a plain function, for a
81-
hub mount that wants to fall back to its own authentication when no bearer
82-
is presented. It does not compare tenants: the caller checks the returned
83-
`tenantId` against whatever it scopes the request to.
64+
## Reference
8465

85-
## Run-scoped mounts
66+
### `mountAgentTokens(app, opts)`
8667

87-
A mount that serves a deployed agent's run, rather than a browser session,
88-
imports its types from here: `ResolvedWorkflowRunScope` (`{ tenantId,
89-
principalId, runId }`), `WorkflowRunScopeEnv` (the hub's `TenantEnv` plus a
90-
`workflowRunScope` variable) and `AgentTokenAuth`, the host's `verify` and
91-
`resolveRun` pair. `createAgentTokenVerifier({ db })` is a ready `verify`;
92-
the mount refuses a token whose `tenantId` is not the resolved run's.
68+
Mounts relative routes on a `Hono<TenantEnv>`. Put it under the hub's tenant prefix.
9369

94-
## Security
70+
| `opts` | Type | What the host provides |
71+
| ------------------- | ------------------- | -------------------------------------------------------------------------------------- |
72+
| `db` | `AgentTokenDb` | The hub's drizzle handle, for example from `createDB`. |
73+
| `requireGrant` | `MiddlewareHandler` | The grant check for minting a credential, run on every route. |
74+
| `resolveDefinition` | `ResolveDefinition` | `(tenantId, definitionId) => boolean \| Promise<boolean>`: whether the tenant owns it. |
9575

96-
- The token is 32 random bytes from `node:crypto`'s `randomBytes`,
97-
base64url-encoded.
98-
- Only `sha256(token)` is stored; lookups are by digest, and the digest
99-
comparison is constant-time.
100-
- Scope — the tenant and the definition — travels with the row, not with
101-
the caller's claim. The tenant is read off the host's context and the
102-
definition is confirmed to belong to it before a token is minted.
103-
- Every route runs the host's grant middleware, so authority is checked
104-
once and by the host.
105-
- Revoke sets `revoked_at`; a revoked row never verifies again and cannot
106-
be un-revoked.
76+
| Route | Result |
77+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78+
| `GET /agent-tokens` | `200 { tokens }`, without plaintext or digest. |
79+
| `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. |
80+
| `DELETE /agent-tokens/:id` | Revokes: `200 { ok: true }`, or `404 not_found`. Revocation is final. |
10781

108-
## Migrations
82+
### `requireAgentToken({ db })`
10983

110-
The package ships its SQL. Run it after Interchange's own migrations, with
111-
the same config and schema; it is idempotent and advisory-locked, so
112-
concurrent hub replicas cannot race it:
84+
Middleware that reads `Authorization: Bearer <token>` 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.
11385

114-
```ts
115-
import { runAgentTokenMigrations } from "@corbits/agent-token/migrations";
116-
import { runMigrations, type DBConfig } from "@intx/db";
86+
### `createAgentTokenVerifier({ db })`
11787

118-
export async function migrate(dbConfig: DBConfig) {
119-
await runMigrations(dbConfig, { schema: "public" });
120-
await runAgentTokenMigrations(dbConfig, { schema: "public" });
121-
}
122-
```
88+
The same check as a function, `(c) => Promise<AgentTokenContext | undefined>`, for a route that falls back to its own authentication when no agent token is presented. It also refuses other-tenant tokens.
89+
90+
### Run-scoped types
91+
92+
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.
93+
94+
### Functions
95+
96+
`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.
97+
98+
### `runAgentTokenMigrations(dbConfig, { schema })`
99+
100+
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.
101+
102+
## Using with Interchange
103+
104+
1. Run `runAgentTokenMigrations` at hub start, after `runMigrations`.
105+
2. Mount `mountAgentTokens` under the tenant prefix, gated with `requireGrant("credential:*", "create")`, since minting a token creates a credential.
106+
3. Give the minted token to the deployed agent, for example as a secret in its environment.
107+
4. Put `requireAgentToken` (or `createAgentTokenVerifier`) on each hub route the agent calls.
108+
109+
## Upgrading from 0.1
110+
111+
- Existing databases and tokens keep working: the migration is unchanged and re-runs cleanly, and tokens minted by 0.1.0 still verify.
112+
- `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.
113+
- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `postgres` are now peer dependencies.
114+
- The root no longer exports `agentTokenSchema`, `agentTokenTable`, `generateAgentToken`, `hashAgentToken`, `agentTokenHashEquals` or `bearerFromAuthorization`.
115+
- `mountAgentTokens` drops `resolveTenantId` and reads the tenant from `TenantEnv`.
116+
- `requireAgentToken` and `createAgentTokenVerifier` now refuse tokens minted by another tenant, and need the hub's tenant middleware in front of them.
123117

124118
## License
125119

126-
LGPL-2.1
120+
[LGPL-2.1](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE)

0 commit comments

Comments
 (0)