UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

103 lines • 4.3 kB
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; /** How long a preview stays confirmable unless the tool says otherwise. */ export const DEFAULT_CONFIRM_TTL_MS = 15 * 60 * 1000; /** * JSON with object keys sorted at every depth, so two argument objects that differ only in key * order produce the same text. `undefined` properties are dropped (as `JSON.stringify` does); a * bare `undefined` and an `undefined` array element are `null`. */ export function canonicalJson(value) { if (value === undefined) return 'null'; if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (typeof value.toJSON === 'function') { return canonicalJson(value.toJSON()); } const entries = Object.entries(value) .filter(([, entry]) => entry !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}`; } /** The signed text is itself canonical JSON, so no field can bleed into its neighbour. */ function signature(subject, expiresAt, secret) { if (secret === '') { throw new Error('confirm token: the secret is empty'); } return createHmac('sha256', secret) .update(canonicalJson([ 'agent-confirm', subject.tool, subject.actorId, subject.tenantRef ?? '', expiresAt, subject.args, ])) .digest('base64url'); } /** * Issue a confirm token: `<expiresAt>.<signature>`, an HMAC-SHA256 (keyed by `secret`) over the tool, * the actor, the tenant, the expiry and the canonical arguments. Stateless — nothing is stored — and * useless for another actor, tenant, tool or argument, or after `expiresAt` (epoch-ms). */ export function signConfirmToken(subject, options) { return `${options.expiresAt}.${signature(subject, options.expiresAt, options.secret)}`; } /** The expiry (epoch-ms) a token carries, or `undefined` for a malformed one. Not a verification. */ export function confirmTokenExpiry(token) { const separator = token.indexOf('.'); if (separator <= 0) return undefined; const expiresAt = Number(token.slice(0, separator)); return Number.isSafeInteger(expiresAt) && expiresAt > 0 ? expiresAt : undefined; } /** Was this token issued for exactly this subject, with this secret, and is it still valid at `now`? */ export function verifyConfirmToken(token, subject, options) { if (typeof token !== 'string') return false; const expiresAt = confirmTokenExpiry(token); if (expiresAt === undefined || expiresAt < (options.now ?? Date.now())) return false; const expected = Buffer.from(signature(subject, expiresAt, options.secret)); const given = Buffer.from(token.slice(token.indexOf('.') + 1)); return expected.length === given.length && timingSafeEqual(expected, given); } /** SHA-256 (hex) of a token — what a {@link ConfirmTokenStore} keys on, so the token is never stored. */ export function hashConfirmToken(token) { return createHash('sha256').update(token).digest('hex'); } /** * A single-process {@link ConfirmTokenStore}: tests, scripts, and a deployment that runs ONE replica. * With more than one, a token spent on replica A is still fresh on replica B — use the Lucid store. * Expired marks are dropped lazily, on the next claim. */ export class InMemoryConfirmTokenStore { claims = new Map(); async claim(input) { await this.purgeExpired(); if (this.claims.has(input.hash)) return false; this.claims.set(input.hash, { ...input }); return true; } async release(hash) { this.claims.delete(hash); } async purgeExpired(now = Date.now()) { let purged = 0; for (const [hash, claim] of this.claims) { if (claim.expiresAt < now) { this.claims.delete(hash); purged += 1; } } return purged; } /** How many tokens are currently marked as spent. */ get size() { return this.claims.size; } } //# sourceMappingURL=confirm-token.js.map