Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: agent_token
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/agent_token
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- run: bun install --frozen-lockfile
- run: bun run check
- run: bun run build
- run: npm pack --dry-run
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bun.lock
dist
7 changes: 7 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "all",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false
}
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ bun install
createdb agent_token_dev
export DATABASE_URL=postgres://localhost:5432/agent_token_dev

bun run typecheck
bun run test
bun run check # eslint, prettier --check, typecheck, tests (what CI runs)
bun run format # prettier --write
bun run build
```

Expand All @@ -22,3 +22,10 @@ The suites in `e2e/` run against a real Postgres and skip when `DATABASE_URL` is
## 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`.

## Commit messages

Commit subjects and PR titles follow [Conventional Commits](https://www.conventionalcommits.org): `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, `perf`, and `chore(release): x.y.z` for releases.
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 <issue id>` line in the PR body.
243 changes: 240 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

84 changes: 65 additions & 19 deletions e2e/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ import { randomUUID } from "node:crypto";
import { createDB, dropSchema, runMigrations, schema } from "@intx/db";
import type { TenantEnv } from "@intx/hub-api";
import { eq } from "drizzle-orm";
import { type } from "arktype";
import { Hono, type MiddlewareHandler } from "hono";

import { runAgentTokenMigrations } from "../src/migrations";
import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "../src/tokens";
import {
hashAgentToken,
mintAgentToken,
revokeAgentToken,
verifyAgentToken,
} from "../src/tokens";
import { mountAgentTokens } from "../src/mount";
import { createAgentTokenVerifier, requireAgentToken } from "../src/middleware";
import type { WorkflowRunScopeEnv } from "../src/workflow-run-scope";
Expand All @@ -20,6 +26,10 @@ const describeIfDb = databaseUrl === undefined ? describe.skip : describe;

const SCHEMA = "agent_token_test";

const MintResponse = type({ token: { id: "string", token: "string" } });
const ListResponse = type({ tokens: "unknown[]" });
const VerifyResponse = type({ identity: "unknown" });

function dbTargetFromUrl(url: string) {
const parsed = new URL(url);
return {
Expand All @@ -37,7 +47,10 @@ function tenantFrom<E extends TenantEnv>(
tenantId: string,
): MiddlewareHandler<E> {
return async (c, next) => {
const [tenant] = await db.select().from(schema.tenant).where(eq(schema.tenant.id, tenantId));
const [tenant] = await db
.select()
.from(schema.tenant)
.where(eq(schema.tenant.id, tenantId));
if (tenant === undefined) return c.json({ error: "not_found" }, 404);
c.set("tenant", tenant);
await next();
Expand Down Expand Up @@ -75,7 +88,9 @@ function mintRequest(definitionId: string): RequestInit {
}

describeIfDb("agent tokens", () => {
const target = dbTargetFromUrl(databaseUrl ?? "postgres://localhost:5432/unused");
const target = dbTargetFromUrl(
databaseUrl ?? "postgres://localhost:5432/unused",
);

beforeAll(async () => {
await runMigrations(target, { schema: SCHEMA });
Expand Down Expand Up @@ -117,9 +132,13 @@ describeIfDb("agent tokens", () => {

expect(await verifyAgentToken(db, "not-a-token")).toBeUndefined();

expect(await revokeAgentToken(db, { tenantId, id: minted.id })).toBe(true);
expect(await revokeAgentToken(db, { tenantId, id: minted.id })).toBe(
true,
);
expect(await verifyAgentToken(db, minted.token)).toBeUndefined();
expect(await revokeAgentToken(db, { tenantId, id: minted.id })).toBe(false);
expect(await revokeAgentToken(db, { tenantId, id: minted.id })).toBe(
false,
);
} finally {
await close();
}
Expand All @@ -133,18 +152,25 @@ describeIfDb("agent tokens", () => {

const app = mountedApp(db, tenantId);

const created = await app.request("/agent-tokens", mintRequest("def_artifacts"));
const created = await app.request(
"/agent-tokens",
mintRequest("def_artifacts"),
);
expect(created.status).toBe(201);
const body = (await created.json()) as { token: { id: string; token: string } };
const body = MintResponse.assert(await created.json());
const plaintext = body.token.token;

const listed = await app.request("/agent-tokens");
const listedBody = (await listed.json()) as { tokens: Array<Record<string, unknown>> };
const listedBody = ListResponse.assert(await listed.json());
expect(listedBody.tokens).toHaveLength(1);
expect(JSON.stringify(listedBody)).not.toContain(plaintext);
expect(JSON.stringify(listedBody)).not.toContain(hashAgentToken(plaintext));
expect(JSON.stringify(listedBody)).not.toContain(
hashAgentToken(plaintext),
);

const revoked = await app.request(`/agent-tokens/${body.token.id}`, { method: "DELETE" });
const revoked = await app.request(`/agent-tokens/${body.token.id}`, {
method: "DELETE",
});
expect(revoked.status).toBe(200);
expect(await verifyAgentToken(db, plaintext)).toBeUndefined();
} finally {
Expand All @@ -159,11 +185,17 @@ describeIfDb("agent tokens", () => {
await seedTenant(db, tenantId);

const gated = mountedApp(db, tenantId, { gate: "deny" });
expect((await gated.request("/agent-tokens", mintRequest("def_artifacts"))).status).toBe(403);
expect(
(await gated.request("/agent-tokens", mintRequest("def_artifacts")))
.status,
).toBe(403);
expect((await gated.request("/agent-tokens")).status).toBe(403);

const app = mountedApp(db, tenantId);
const foreign = await app.request("/agent-tokens", mintRequest("def_somebody_else"));
const foreign = await app.request(
"/agent-tokens",
mintRequest("def_somebody_else"),
);
expect(foreign.status).toBe(404);
// No detail: a definition another tenant owns reads exactly like one
// that never existed.
Expand Down Expand Up @@ -200,20 +232,27 @@ describeIfDb("agent tokens", () => {
app.get("/tenants/:tenantId/guarded", requireAgentToken({ db }), (c) =>
c.json({ tenantId: c.get("agentToken").tenantId }),
);
app.get("/untenanted", requireAgentToken({ db }), (c) => c.json({ ok: true }));
app.get("/untenanted", requireAgentToken({ db }), (c) =>
c.json({ ok: true }),
);
const bearer = { headers: { authorization: `Bearer ${minted.token}` } };

const ok = await app.request(`/tenants/${tenantId}/guarded`, bearer);
expect(ok.status).toBe(200);
expect(await ok.json()).toEqual({ tenantId });

const crossTenant = await app.request(`/tenants/${otherTenantId}/guarded`, bearer);
const crossTenant = await app.request(
`/tenants/${otherTenantId}/guarded`,
bearer,
);
expect(crossTenant.status).toBe(401);

// Mounted without the tenant middleware, a valid token never passes.
expect((await app.request("/untenanted", bearer)).status).toBe(500);

expect((await app.request(`/tenants/${tenantId}/guarded`)).status).toBe(401);
expect((await app.request(`/tenants/${tenantId}/guarded`)).status).toBe(
401,
);
expect(
(
await app.request(`/tenants/${tenantId}/guarded`, {
Expand Down Expand Up @@ -244,20 +283,27 @@ describeIfDb("agent tokens", () => {
app.use("/:tenantId/*", async (c, next) =>
tenantFrom<WorkflowRunScopeEnv>(db, c.req.param("tenantId"))(c, next),
);
app.get("/:tenantId/verify", async (c) => c.json({ identity: (await verify(c)) ?? null }));
const identityFor = async (authorization?: string, onTenant = tenantId) => {
app.get("/:tenantId/verify", async (c) =>
c.json({ identity: (await verify(c)) ?? null }),
);
const identityFor = async (
authorization?: string,
onTenant = tenantId,
) => {
const res = await app.request(`/${onTenant}/verify`, {
headers: authorization === undefined ? {} : { authorization },
});
return ((await res.json()) as { identity: unknown }).identity;
return VerifyResponse.assert(await res.json()).identity;
};

expect(await identityFor(`Bearer ${minted.token}`)).toEqual({
id: minted.id,
tenantId,
definitionId: "def_artifacts",
});
expect(await identityFor(`Bearer ${minted.token}`, otherTenantId)).toBeNull();
expect(
await identityFor(`Bearer ${minted.token}`, otherTenantId),
).toBeNull();
expect(await identityFor()).toBeNull();
expect(await identityFor("Bearer nope")).toBeNull();
expect(await identityFor(` bearer ${minted.token} `)).toEqual({
Expand Down
46 changes: 46 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// @ts-check

import * as eslint from "@eslint/js";
import * as tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";

export default defineConfig(
eslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
globalIgnores(["**/dist/**", "tmp/**"]),
{
linterOptions: {
reportUnusedDisableDirectives: "warn",
},
rules: {
"no-console": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-unused-expressions": [
"error",
{ allowTaggedTemplates: true },
],
"@typescript-eslint/consistent-type-definitions": 0,
"@typescript-eslint/no-unsafe-type-assertion": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
);
17 changes: 13 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@corbits/agent-token",
"version": "0.1.0",
"description": "Bearer tokens that let a deployed agent definition call back into its Interchange hub: a mint/revoke mount plus a verifying middleware",
"version": "0.2.0",
"description": "Bearer tokens that a deployed agent presents when it calls back into its Interchange hub: mint/revoke routes and a verifying Hono middleware",
"license": "LGPL-2.1-only",
"author": "Sawyer Cutler <sawyer@dirtroad.dev>",
"homepage": "https://github.com/corbitsdev/corbits-agent-token#readme",
Expand Down Expand Up @@ -31,14 +31,18 @@
"README.md",
"LICENSE"
],
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prepack": "bun run build",
"typecheck": "tsc --noEmit",
"test:e2e": "bun test ./e2e"
"test:e2e": "bun test ./e2e",
"lint": "eslint .",
"format": "prettier --write .",
"check": "bun run lint && prettier --check . && bun run typecheck && bun run test:e2e"
},
"dependencies": {
"arktype": "^2.2.0"
Expand All @@ -51,13 +55,18 @@
"postgres": "^3.4.8"
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@intx/db": "0.4.0",
"@intx/hub-api": "0.4.0",
"@types/bun": "1.3.9",
"agent-token-0.1.0": "npm:@corbits/agent-token@0.1.0",
"drizzle-orm": "0.45.2",
"eslint": "9.34.0",
"hono": "4.13.8",
"jiti": "2.5.1",
"postgres": "3.4.9",
"typescript": "5.9.3"
"prettier": "3.6.2",
"typescript": "5.9.3",
"typescript-eslint": "8.42.0"
}
}
Loading
Loading