@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
217 lines • 9.57 kB
JavaScript
import { DiscoveryError } from './errors.js';
import { isLoopbackHost, normalizeDomain, requireSecureUrl, } from './util.js';
/** Major protocol versions this client implements (3.1). */
export const SUPPORTED_MAJOR_VERSIONS = ['0'];
const MAX_REDIRECTS = 5;
/** In-memory cache of JSON documents by URL, by `Cache-Control: max-age`. */
export class HttpJsonCache {
entries = new Map();
get(url) {
const e = this.entries.get(url);
if (!e)
return undefined;
if (e.expires < Date.now()) {
this.entries.delete(url);
return undefined;
}
return e.value;
}
set(url, value, cacheControl) {
if (!cacheControl || /no-store|no-cache/i.test(cacheControl))
return;
const m = /max-age=(\d+)/i.exec(cacheControl);
if (!m)
return;
this.entries.set(url, { expires: Date.now() + Number(m[1]) * 1000, value });
}
}
/**
* Turns user input into the well-known URL. `example.com` → `https://example.com/...`.
* With `insecureDev`, an explicit `http://host:port` is kept, and a bare loopback host
* (`localhost:3333`) defaults to `http:`.
*/
export function wellKnownUrlFor(input, sec = {}) {
let base;
if (/^[a-z]+:\/\//i.test(input)) {
base = new URL(input);
}
else {
const guess = new URL(`https://${input}`);
base = sec.insecureDev && isLoopbackHost(guess.hostname) ? new URL(`http://${input}`) : guess;
}
requireSecureUrl(base.toString(), 'Company domain', sec);
return new URL('/.well-known/poppy.json', base.origin);
}
async function fetchJson(url, what, opts) {
const cached = opts.cache?.get(url.toString());
if (cached !== undefined)
return { json: cached, finalUrl: url };
const doFetch = opts.fetch ?? ((i, init) => globalThis.fetch(i, init));
let current = url;
for (let hop = 0;; hop++) {
const res = await doFetch(current.toString(), {
redirect: 'manual',
headers: { accept: 'application/json' },
});
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
if (hop >= MAX_REDIRECTS)
throw new DiscoveryError('too_many_redirects', `${what}: too many redirects`);
const next = new URL(res.headers.get('location'), current);
try {
// Every redirect MUST be to an HTTPS URL (3).
requireSecureUrl(next.toString(), `${what} redirect`, opts);
}
catch (e) {
throw new DiscoveryError('insecure_redirect', e.message);
}
current = next;
continue;
}
if (!res.ok) {
throw new DiscoveryError('fetch_failed', `${what}: HTTP ${res.status} from ${current}`);
}
let json;
try {
json = await res.json();
}
catch {
throw new DiscoveryError('invalid_json', `${what}: response is not JSON`);
}
opts.cache?.set(url.toString(), json, res.headers.get('cache-control'));
return { json, finalUrl: current };
}
}
function fail(code, message) {
throw new DiscoveryError(code, message);
}
function isObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function checkUrl(value, what, sec) {
try {
return requireSecureUrl(value, what, sec).toString();
}
catch (e) {
fail('invalid_document', e.message);
}
}
/** Validates `poppy.json` against 3.1. Unknown fields are ignored, never rejected. */
export function validatePoppyDocument(json, requestedHost, sec = {}) {
if (!isObject(json))
fail('invalid_document', 'poppy.json is not a JSON object');
const version = json.protocol_version;
if (typeof version !== 'string' || !/^\d+\.\d+$/.test(version)) {
fail('invalid_document', 'protocol_version must be "major.minor"');
}
const major = version.split('.')[0] ?? '';
if (!SUPPORTED_MAJOR_VERSIONS.includes(major)) {
fail('unsupported_protocol_version', `poppy.json protocol_version ${version} has major ${major}; this agent supports ${SUPPORTED_MAJOR_VERSIONS.join(', ')}`);
}
const org = json.organization;
if (!isObject(org) || typeof org.name !== 'string' || typeof org.domain !== 'string') {
fail('invalid_document', 'organization.name and organization.domain are required');
}
// organization.domain must match the host we requested, ignoring a leading www. The host
// after a redirect doesn't count (3.1). Ports only appear in --insecure-dev setups.
const requested = normalizeDomain(requestedHost);
const requestedNoPort = normalizeDomain(requestedHost.replace(/:\d+$/, ''));
const claimed = normalizeDomain(org.domain);
if (claimed !== requested && claimed !== requestedNoPort) {
fail('domain_mismatch', `organization.domain "${org.domain}" does not match the requested host "${requestedHost}"`);
}
const hasAgent = json.agent !== undefined;
const hasApis = json.apis !== undefined;
const hasWeb = json.web !== undefined;
if (!hasAgent && !hasApis && !hasWeb) {
fail('invalid_document', 'poppy.json must have at least one of agent, apis, web');
}
if (hasWeb) {
if (!isObject(json.web))
fail('invalid_document', 'web must be an object');
if (json.web.browser_session_endpoint !== undefined) {
checkUrl(json.web.browser_session_endpoint, 'web.browser_session_endpoint', sec);
}
}
const needsAuth = hasAgent || hasApis || (isObject(json.web) && json.web.browser_session_endpoint);
if (needsAuth && json.auth === undefined) {
fail('invalid_document', 'auth is required when agent, apis or web.browser_session_endpoint is present');
}
if (json.auth !== undefined) {
const auth = json.auth;
if (!isObject(auth))
fail('invalid_document', 'auth must be an object');
checkUrl(auth.issuer, 'auth.issuer', sec);
for (const type of ['direct', 'device', 'mediated']) {
const t = auth[type];
if (t === undefined)
continue;
if (!isObject(t) ||
!Array.isArray(t.scopes) ||
!t.scopes.every((s) => typeof s === 'string')) {
fail('invalid_document', `auth.${type}.scopes must be a list of strings`);
}
}
if (isObject(auth.mediated)) {
checkUrl(auth.mediated.endpoint, 'auth.mediated.endpoint', sec);
const fields = auth.mediated.fields;
if (!Array.isArray(fields) ||
!fields.every((f) => isObject(f) && typeof f.name === 'string' && typeof f.label === 'string')) {
fail('invalid_document', 'auth.mediated.fields must list { name, label, secret }');
}
}
}
if (hasAgent) {
if (!isObject(json.agent) || !Array.isArray(json.agent.protocols)) {
fail('invalid_document', 'agent.protocols must be a list');
}
}
if (hasApis && !Array.isArray(json.apis))
fail('invalid_document', 'apis must be a list');
return json;
}
/** RFC 8414 section 3: the well-known suffix goes between the host and the issuer's path. */
export function authServerMetadataUrl(issuer) {
const u = new URL(issuer);
const path = u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '');
return new URL(`${u.origin}/.well-known/oauth-authorization-server${path}`);
}
/** Validates the issuer metadata against 3.2. */
export function validateMetadata(json, doc, sec = {}) {
if (!isObject(json))
fail('invalid_metadata', 'authorization server metadata is not an object');
const auth = doc.auth;
if (json.issuer !== auth.issuer) {
fail('issuer_mismatch', `metadata issuer "${String(json.issuer)}" != auth.issuer "${auth.issuer}"`);
}
const domains = json.poppy_domains;
if (!Array.isArray(domains)) {
fail('domain_not_listed', 'metadata has no poppy_domains, so it does not vouch for this domain');
}
const wanted = doc.organization.domain.toLowerCase();
if (!domains.some((d) => typeof d === 'string' && d.toLowerCase() === wanted)) {
fail('domain_not_listed', `poppy_domains of ${auth.issuer} does not include "${doc.organization.domain}"`);
}
checkUrl(json.token_endpoint, 'token_endpoint', sec);
checkUrl(json.revocation_endpoint, 'revocation_endpoint', sec);
if (auth.direct)
checkUrl(json.authorization_endpoint, 'authorization_endpoint', sec);
if (auth.device)
checkUrl(json.device_authorization_endpoint, 'device_authorization_endpoint', sec);
return json;
}
/**
* Discovery (section 3): fetches `/.well-known/poppy.json` (HTTPS-only redirects), validates
* it, then fetches and validates the issuer's RFC 8414 metadata (3.2).
*/
export async function discover(input, opts = {}) {
const wellKnown = wellKnownUrlFor(input, opts);
const { json } = await fetchJson(wellKnown, 'poppy.json', opts);
const document = validatePoppyDocument(json, wellKnown.host, opts);
let metadata;
if (document.auth) {
const { json: meta } = await fetchJson(authServerMetadataUrl(document.auth.issuer), 'authorization server metadata', opts);
metadata = validateMetadata(meta, document, opts);
}
return { domain: wellKnown.host, wellKnownUrl: wellKnown.toString(), document, metadata };
}
//# sourceMappingURL=discovery.js.map