diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5e8cf25 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +## Purpose + +`@corbits/agent-token` authenticates deployed agents calling back into their +Interchange hub. It owns the `agent_token` Postgres schema, the mint, list and +revoke routes, and the middleware that verifies a bearer. It does not own +grants, tenants or outbound credentials an agent uses to call other services. + +## Layout + +- `src/tokens.ts` — `mintAgentToken`, `revokeAgentToken`, `verifyAgentToken` and the SHA-256 digest. +- `src/mount.ts` — `mountAgentTokens`, the tenant-scoped mint, list and revoke routes. +- `src/middleware.ts` — `requireAgentToken` and `createAgentTokenVerifier`. +- `src/workflow-run-scope.ts` — the run-scope types shared by run-scoped mounts. +- `src/schema.ts` — the `agent_token` schema's one table. +- `src/migrations.ts` — `runAgentTokenMigrations`, applies `migrations/*.sql`. +- `src/index.ts` — the only module consumers import from (plus `./migrations`). +- `e2e/` — real-Postgres suites, including the 0.1.0 upgrade test. + +## Rules + +- Store only `sha256(token)`; the plaintext appears once, in the mint response. +- A token is valid only on routes of the tenant that minted it; anything else is a 401. +- The acting tenant comes from the host context, never from a path parameter. +- Every route runs the host's `requireGrant`. +- Every migration statement is idempotent; there is no ledger. + +## Local development + +```sh +bun install && bun run check +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b968d64..80d08de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,21 +3,15 @@ ## 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 check # oxlint, oxfmt --check, typecheck, tests (what CI runs) -bun run format # oxfmt -bun run build +bun run check ``` -The suites in `e2e/` run against a real Postgres and skip when `DATABASE_URL` is unset. CI sets it. +`bun run check` runs typecheck, lint, format check and unit tests. `bun run format` rewrites the tree. + +Contributors sign the [CLA](CLA.md) on their first PR; the CLA bot explains how. -- `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. +`bun run test:e2e` needs Postgres in `DATABASE_URL`, e.g. `DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres bun run test:e2e`; the suites skip when it is unset. `e2e/agent-token.drizzle.test.ts` exercises the routes, middleware and verifier on a scratch schema. `e2e/upgrade-from-0.1.0.test.ts` builds its own database with the published 0.1.0 (the `agent-token-0.1.0` dev dependency), then upgrades it with this build. ## Migrations @@ -29,3 +23,16 @@ Commit subjects and PR titles follow [Conventional Commits](https://www.conventi Add `!` only for public API breaks: removed or renamed exports, changed signatures, newly required params. Peer and dependency range changes are `build(deps):` with no `!`. Keep subjects imperative, lowercase after the colon, 72 characters or less, and free of ticket IDs. Every PR links its issue with a `Closes ` line in the PR body. + +## Releasing + +Releases are manual. On a clean, up-to-date `main`: + +```sh +npm version -m "chore(release): %s" +git push --follow-tags +gh release create "v$(node -p 'require("./package.json").version')" --generate-notes +npm publish +``` + +Bump minor only for breaking API changes; everything else is a patch. `prepack` builds `dist/` from the tagged commit. diff --git a/README.md b/README.md index aa3f4cd..6ec21a7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # @corbits/agent-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? @@ -61,11 +59,13 @@ Mounts relative routes on a `Hono`. Put it under the hub's tenant pre | `requireGrant` | `MiddlewareHandler` | The grant check for minting a credential, run on every route. | | `resolveDefinition` | `ResolveDefinition` | `(tenantId, definitionId) => boolean \| Promise`: whether the tenant owns it. | -| 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. | +| 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 or a field over 256 characters, `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. | + +Tokens do not expire: each stays valid until it is revoked. ### `requireAgentToken({ db })` diff --git a/e2e/agent-token.drizzle.test.ts b/e2e/agent-token.drizzle.test.ts index b0475c4..07f16f1 100644 --- a/e2e/agent-token.drizzle.test.ts +++ b/e2e/agent-token.drizzle.test.ts @@ -205,6 +205,36 @@ describeIfDb("agent tokens", () => { } }); + test("the mount refuses a name or definitionId over 256 characters", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_len_${randomUUID().slice(0, 8)}`; + await seedTenant(db, tenantId); + const app = mountedApp(db, tenantId, { owns: ["x".repeat(257)] }); + const mint = (body: unknown) => + app.request("/agent-tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + expect( + (await mint({ definitionId: "def_artifacts", name: "n".repeat(257) })) + .status, + ).toBe(400); + expect( + (await mint({ definitionId: "x".repeat(257), name: "artifacts" })) + .status, + ).toBe(400); + expect( + (await mint({ definitionId: "def_artifacts", name: "n".repeat(256) })) + .status, + ).toBe(404); + } finally { + await close(); + } + }); + test("the middleware admits a live bearer on its own tenant and 401s everything else", async () => { const { db, close } = createDB({ ...target, schema: SCHEMA }); try { diff --git a/src/mount.ts b/src/mount.ts index c8ae415..d2feb35 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -37,8 +37,8 @@ export type MountAgentTokensOpts< }; const MintBody = type({ - definitionId: "string > 0", - name: "string > 0", + definitionId: "0 < string <= 256", + name: "0 < string <= 256", }); /** Mount `/agent-tokens` onto the host's app, under its tenant prefix. */