/*
RFC-1187: durable agent registration store, backed by the same Upstash Redis
EU REST endpoint as the RFC-1112 receipt store. Persists registration records
(agentId → registration JSON), single-use registration challenges (GETDEL
consumption), and a SET index for operator listing. Stores credential secret
HASHES only — never plaintext secrets, never lead payload fields.
- Do not log or embed secrets — the caller injects RestRedisConfig.
- Do not implement the port contract here — the shape satisfies
AgentRegistrationRegistry from agent/registration.ts structurally.
- Do not cache — every call is a REST roundtrip; the gate's per-request
volume is small enough for v1.
- RFC-1187: initial agent registration store + Upstash Redis REST adapter.
- RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline
*/
import type { AgentRegistration, AgentRegistrationRegistry } from "../agent/registration.ts";
import type { RestRedisConfig } from "./qstash.ts";
const REG_KEY_PREFIX = "reg";
const CHALLENGE_KEY_PREFIX = "challenge";
const INDEX_KEY = "reg-index";
/**
* Adapt an Upstash Redis REST endpoint to the AgentRegistrationRegistry
* (RFC-1187). Registration records are plain GET/SET JSON; challenges are
* `SET … EX` + `GETDEL` (atomic single-use); the index is a SMEMBERS set
* maintained alongside `put`. Throws on transport errors so the caller can
* decide fail-open semantics.
*/
export function restRedisAgentRegistry(
config: RestRedisConfig,
fetchImpl: typeof fetch = fetch,
): AgentRegistrationRegistry {
const prefix = config.prefix ?? "gogol-agent-auth";
const headers = {
authorization: `Bearer ${config.token}`,
"content-type": "application/json",
};
async function run(commands: string[][]): Promise {
// Upstash REST accepts a pipeline array — one roundtrip for related ops.
const response = await fetchImpl(`${config.url}/pipeline`, {
method: "POST",
headers,
body: JSON.stringify(commands),
});
if (!response.ok) {
throw new Error(`redis agent-registry pipeline failed: ${response.status}`);
}
const data = (await response.json()) as Array<{ result?: unknown; error?: string }>;
for (const entry of data) {
if (entry.error) throw new Error(`redis agent-registry command failed: ${entry.error}`);
}
return data.map((entry) => entry.result ?? null);
}
return {
async get(agentId: string): Promise {
const [result] = await run([["GET", `${prefix}:${REG_KEY_PREFIX}:${agentId}`]]);
return typeof result === "string" ? (JSON.parse(result) as AgentRegistration) : null;
},
async put(registration: AgentRegistration): Promise {
await run([
[
"SET",
`${prefix}:${REG_KEY_PREFIX}:${registration.agentId}`,
JSON.stringify(registration),
],
["SADD", `${prefix}:${INDEX_KEY}`, registration.agentId],
]);
},
async putChallenge(nonce: string, ttlSeconds: number): Promise {
await run([
["SET", `${prefix}:${CHALLENGE_KEY_PREFIX}:${nonce}`, "1", "EX", String(ttlSeconds)],
]);
},
async consumeChallenge(nonce: string): Promise {
// GETDEL — atomic single-use read; a replay finds nothing.
const [result] = await run([["GETDEL", `${prefix}:${CHALLENGE_KEY_PREFIX}:${nonce}`]]);
return typeof result === "string";
},
async list(): Promise {
const [members] = await run([["SMEMBERS", `${prefix}:${INDEX_KEY}`]]);
if (!Array.isArray(members)) return [];
const ids = members.filter((m): m is string => typeof m === "string");
if (ids.length === 0) return [];
const values = await run(ids.map((id) => ["GET", `${prefix}:${REG_KEY_PREFIX}:${id}`]));
return values
.filter((v): v is string => typeof v === "string")
.map((v) => JSON.parse(v) as AgentRegistration);
},
};
}