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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ Applied idempotently, inside one advisory-locked transaction so concurrent
hub replicas cannot race the same DDL:

```ts
import { applyAgentTokenMigrations } from "@corbits/agent-token/migrations";
import { runMigrations } from "@intx/db";
import { runAgentTokenMigrations } from "@corbits/agent-token/migrations";

await applyAgentTokenMigrations(databaseUrl);
await runMigrations(dbConfig, { schema: "public" });
await runAgentTokenMigrations(dbConfig, { schema: "public" });
```

## License
Expand Down
13 changes: 13 additions & 0 deletions migrations/0001_agent_token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE SCHEMA IF NOT EXISTS "agent_token";
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "agent_token"."token" (
"id" text PRIMARY KEY,
"tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE,
"definition_id" text NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL UNIQUE,
"created_at" timestamptz NOT NULL DEFAULT now(),
"revoked_at" timestamptz
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_token_token_tenant_id_idx" ON "agent_token"."token" ("tenant_id");
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"files": [
"dist",
"migrations",
"README.md",
"LICENSE"
],
Expand Down
6 changes: 3 additions & 3 deletions src/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// DB-gated: skipped when no DATABASE_URL is reachable. Migrations run into
// a scratch schema so this test never touches a real tenant table;
// `applyAgentTokenMigrations` is told that scratch schema so its
// `runAgentTokenMigrations` is told that scratch schema so its
// `tenant_id` FK targets it.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
Expand All @@ -9,7 +9,7 @@ import type { TenantEnv } from "@intx/hub-api";
import { eq } from "drizzle-orm";
import { Hono, type MiddlewareHandler } from "hono";

import { applyAgentTokenMigrations } from "./schema";
import { runAgentTokenMigrations } from "./migrations";
import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "./tokens";
import { mountAgentTokens } from "./mount";
import { createAgentTokenVerifier, requireAgentToken } from "./middleware";
Expand Down Expand Up @@ -79,7 +79,7 @@ describeIfDb("agent tokens", () => {

beforeAll(async () => {
await runMigrations(target, { schema: SCHEMA });
await applyAgentTokenMigrations(databaseUrl ?? "", { tenantSchema: SCHEMA });
await runAgentTokenMigrations(target, { schema: SCHEMA });
});

afterAll(async () => {
Expand Down
73 changes: 72 additions & 1 deletion src/migrations.ts
Original file line number Diff line number Diff line change
@@ -1 +1,72 @@
export { applyAgentTokenMigrations } from "./schema.js";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import type { DBConfig } from "@intx/db";
import postgres from "postgres";

// The packed layout keeps <pkg>/migrations next to <pkg>/dist and <pkg>/src.
const MIGRATIONS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"migrations",
);

function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}

/**
* Applies the shipped SQL idempotently, inside one advisory-locked
* transaction so concurrent hub replicas cannot race the same DDL. `schema`
* is where the host's Interchange tables live, as passed to Interchange's
* `runMigrations`; the `tenant_id` FK is pointed at its `tenant` table.
* Every file re-runs on every boot, with no record of what already ran, so
* each migration must be idempotent.
*/
export async function runAgentTokenMigrations(
config: DBConfig,
options: { schema: string },
): Promise<void> {
if (options.schema.length === 0) {
throw new Error("runAgentTokenMigrations: schema name must not be empty");
}
const schemaIdent = quoteIdentifier(options.schema);
const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
if (files.length === 0) {
throw new Error(`runAgentTokenMigrations: no .sql files found in ${MIGRATIONS_DIR}`);
}
const statements: string[] = [];
for (const file of files) {
const raw = await readFile(path.join(MIGRATIONS_DIR, file), "utf8");
for (const stmt of raw
.replace(/"public"\.(?=")/g, `${schemaIdent}.`)
.split("--> statement-breakpoint")) {
if (stmt.trim().length > 0) statements.push(stmt);
}
}

const client = postgres({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
ssl: config.ssl,
max: 1,
onnotice: () => undefined,
});
try {
await client.begin(async (tx) => {
await tx.unsafe(`SELECT pg_advisory_xact_lock(hashtext('corbits_agent_token'))`);
for (const stmt of statements) await tx.unsafe(stmt);
});
} catch (error) {
throw new Error(
`@corbits/agent-token migration failed: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
} finally {
await client.end({ timeout: 5 });
}
}
46 changes: 0 additions & 46 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// its agent definitions. Kept on its own Postgres schema, with a real FK
// back to Interchange's `tenant` table.
import { pgTable, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
import postgres from "postgres";

const hostTenant = pgTable("tenant", { id: text("id").primaryKey() });

Expand All @@ -20,48 +19,3 @@ export const agentTokenTable = agentTokenSchema.table("token", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
});

function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}

/** Builds the FK against `tenantSchema.tenant` — `"public"` in every real
* deployment, a scratch schema in tests. */
function agentTokenMigrationSql(tenantSchema: string): string {
const tenantTable = `${quoteIdentifier(tenantSchema)}."tenant"`;
return `
CREATE SCHEMA IF NOT EXISTS "agent_token";
CREATE TABLE IF NOT EXISTS "agent_token"."token" (
"id" text PRIMARY KEY,
"tenant_id" text NOT NULL REFERENCES ${tenantTable}("id") ON DELETE CASCADE,
"definition_id" text NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL UNIQUE,
"created_at" timestamptz NOT NULL DEFAULT now(),
"revoked_at" timestamptz
);
CREATE INDEX IF NOT EXISTS "agent_token_token_tenant_id_idx" ON "agent_token"."token" ("tenant_id");
`;
}

/** Applies the migration idempotently, inside one advisory-locked
* transaction so concurrent hub replicas cannot race the same DDL. */
export async function applyAgentTokenMigrations(
databaseUrl: string,
options?: { tenantSchema?: string },
): Promise<void> {
const client = postgres(databaseUrl, { max: 1, onnotice: () => undefined });
try {
await client.begin(async (tx) => {
await tx.unsafe(`SELECT pg_advisory_xact_lock(hashtext('corbits_agent_token'))`);
await tx.unsafe(agentTokenMigrationSql(options?.tenantSchema ?? "public"));
});
} catch (error) {
throw new Error(
`@corbits/agent-token migration failed: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
} finally {
await client.end({ timeout: 5 });
}
}