@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
336 lines • 13.2 kB
JavaScript
import { discover, HttpJsonCache } from './discovery.js';
import { DpopKey } from './dpop.js';
import { ConversationError, OAuthError, PoppyError, ResourceAuthError } from './errors.js';
import { PoppyHttp, readJson } from './http.js';
import { generateEs256Key } from './keys.js';
import { TokenClient } from './token_client.js';
import { randomId, scopeSet, wwwAuthError } from './util.js';
/** Entry point: one agent identity, one local state file, one local user. */
export class PoppyAgent {
opts;
http;
cache = new HttpJsonCache();
profile;
constructor(opts) {
this.opts = opts;
this.profile = opts.profile ?? 'default';
this.http = new PoppyHttp({
fetch: opts.fetch,
log: opts.log,
maxRetryAfterSeconds: opts.maxRetryAfterSeconds,
});
}
get identity() {
return this.opts.identity;
}
get store() {
return this.opts.store;
}
log(line) {
this.opts.log?.(line);
}
/** Discovers a Company and loads (or creates) this user's state there. */
async company(domain) {
const discovered = await discover(domain, {
fetch: this.http.fetch,
insecureDev: this.opts.insecureDev,
cache: this.cache,
});
if (!discovered.document.auth || !discovered.metadata) {
throw new PoppyError('no_auth', `${discovered.domain} publishes no auth: it can only be browsed as an ordinary website (section 5)`);
}
const issuer = discovered.document.auth.issuer;
const companies = this.store.profile(this.profile).companies;
let state = companies[issuer];
if (!state) {
state = {
issuer,
domains: [],
// 4.2: opaque, random, per Company. Never derived from personal information.
userId: randomId('usr_'),
dpopKey: await generateEs256Key(),
tokens: {},
conversations: {},
};
companies[issuer] = state;
}
const org = discovered.document.organization.domain.toLowerCase();
if (!state.domains.includes(org))
state.domains.push(org);
await this.store.save();
return new CompanyClient(this, discovered, state);
}
}
const EXPIRY_SKEW_MS = 30_000;
function tokenKey(opts) {
if (opts.bearer)
return `bearer|${opts.resource ?? ''}|${opts.scope ?? ''}`;
if (!opts.scope && !opts.resource)
return 'dpop';
return `dpop|${opts.resource ?? ''}|${opts.scope ?? ''}`;
}
/** One User at one Company: Sessions, Account Token, and authenticated requests. */
export class CompanyClient {
agent;
discovered;
state;
tokens;
dpop;
constructor(agent, discovered, state) {
this.agent = agent;
this.discovered = discovered;
this.state = state;
this.tokens = new TokenClient(agent.identity, discovered.metadata, agent.http, agent.opts.tokenClient);
this.dpop = new DpopKey(state.dpopKey);
}
get document() {
return this.discovered.document;
}
get metadata() {
return this.discovered.metadata;
}
get issuer() {
return this.document.auth.issuer;
}
get userId() {
return this.state.userId;
}
get http() {
return this.agent.http;
}
get security() {
return { insecureDev: this.agent.opts.insecureDev };
}
get accountToken() {
const a = this.state.accountToken;
if (a?.expiresAt && a.expiresAt <= Date.now())
return undefined;
return a;
}
async save() {
await this.agent.store.save();
}
/** Forget the current Session; the next request starts a new one. */
async newSession() {
this.state.sessionId = undefined;
this.state.tokens = {};
await this.save();
}
/**
* A valid Session Token. Starts a Session if there is none (signed in if an Account Token
* exists, 4.8), renews it when it is close to expiry, and handles `invalid_session`,
* `account_mismatch` and a revoked Account Token (`invalid_grant`) as 4.2/4.8/4.9 describe.
*/
async token(opts = {}) {
const key = tokenKey(opts);
const rec = this.state.tokens[key];
if (rec &&
!opts.forceRenew &&
rec.sessionId === this.state.sessionId &&
rec.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
return rec;
}
// Narrower and Bearer tokens are for an existing Session (4.3), so make sure there is one.
if (key !== 'dpop' && !this.state.sessionId)
await this.token();
const resp = await this.issue(opts);
return this.record(key, resp, opts.resource);
}
async issue(opts) {
const dpop = opts.bearer ? undefined : this.dpop;
const narrowing = { scope: opts.scope, resource: opts.resource };
const acct = this.accountToken;
if (acct) {
try {
return await this.tokens.refresh({
refreshToken: acct.refreshToken,
sessionId: this.state.sessionId,
dpop,
...narrowing,
});
}
catch (e) {
if (!(e instanceof OAuthError))
throw e;
if (e.code === 'invalid_grant') {
// 4.8/4.9: expired, revoked, or the User disconnected us. Continue signed out.
this.agent.log('Account Token rejected (invalid_grant); continuing signed out');
this.state.accountToken = undefined;
await this.save();
}
else if ((e.code === 'invalid_session' || e.code === 'account_mismatch') &&
this.state.sessionId) {
this.agent.log(`${e.code}: starting a new Session`);
await this.newSession();
return this.tokens.refresh({ refreshToken: acct.refreshToken, dpop, ...narrowing });
}
else {
throw e;
}
}
}
try {
return await this.tokens.jwtBearer({
userId: this.userId,
sessionId: this.state.sessionId,
dpop,
...narrowing,
});
}
catch (e) {
if (e instanceof OAuthError && e.code === 'invalid_session' && this.state.sessionId) {
this.agent.log('invalid_session: starting a new Session');
await this.newSession();
return this.tokens.jwtBearer({ userId: this.userId, dpop, ...narrowing });
}
throw e;
}
}
async record(key, resp, resource) {
if (resp.session_id !== this.state.sessionId) {
// A different Session: tokens of the old one don't apply any more.
this.state.tokens = {};
this.state.sessionId = resp.session_id;
}
if (resp.refresh_token && this.state.accountToken) {
// 4.8: the Company rotated the Account Token; MUST use the new one from now on.
this.state.accountToken = {
...this.state.accountToken,
refreshToken: resp.refresh_token,
expiresAt: typeof resp.refresh_token_expires_in === 'number'
? Date.now() + resp.refresh_token_expires_in * 1000
: this.state.accountToken.expiresAt,
};
}
const rec = {
accessToken: resp.access_token,
tokenType: resp.token_type,
expiresAt: Date.now() + resp.expires_in * 1000,
scope: resp.scope ?? '',
sessionId: resp.session_id,
signedIn: resp.signed_in,
resource,
};
this.state.tokens[key] = rec;
await this.save();
return rec;
}
/** Stores the result of any sign-in type (4.4): Account Token + signed-in Session Token. */
async applySignIn(resp, via, requestedScope) {
if (!resp.refresh_token) {
throw new OAuthError('invalid_response', {
description: 'sign-in returned no Account Token',
});
}
const old = this.state.accountToken;
this.state.accountToken = {
refreshToken: resp.refresh_token,
scope: resp.scope ?? '',
obtainedAt: Date.now(),
expiresAt: typeof resp.refresh_token_expires_in === 'number'
? Date.now() + resp.refresh_token_expires_in * 1000
: undefined,
via,
};
this.state.tokens = {};
this.state.sessionId = resp.session_id;
this.state.tokens.dpop = {
accessToken: resp.access_token,
tokenType: resp.token_type,
expiresAt: Date.now() + resp.expires_in * 1000,
scope: resp.scope ?? '',
sessionId: resp.session_id,
signedIn: resp.signed_in,
};
await this.save();
if (old && old.refreshToken !== resp.refresh_token) {
// 4.4: the new Account Token replaces the old one, which we SHOULD revoke.
try {
await this.tokens.revoke(old.refreshToken);
}
catch (e) {
this.agent.log(`could not revoke the replaced Account Token: ${e.message}`);
}
}
const granted = scopeSet(resp.scope);
return {
grantedScope: resp.scope ?? '',
missingScopes: [...scopeSet(requestedScope)].filter((s) => !granted.has(s)),
sessionId: resp.session_id,
};
}
/** Sign out (4.9): revoke the Account Token. Sessions carry on signed out. */
async signOut() {
const acct = this.state.accountToken;
if (!acct)
return false;
await this.tokens.revoke(acct.refreshToken, 'refresh_token');
this.state.accountToken = undefined;
// Signed-in Session Tokens may still be valid; drop them so the next request renews
// with the agent's assertion and gets a signed-out token (4.9).
this.state.tokens = {};
await this.save();
return true;
}
/** A Bearer Session Token for one MCP server (4.3 "Bearer tokens"). */
async bearerFor(resource, forceRenew = false) {
return (await this.token({ bearer: true, resource, forceRenew })).accessToken;
}
/**
* An API or conversation request with `Authorization: DPoP` + a proof (4.3). Retries once
* with a renewed token on `invalid_token`, and once with the Account Token on
* `sign_in_required` when the current token is signed out but an Account Token exists.
*/
async fetch(url, init = {}) {
const { scope, resource, ...rest } = init;
let rec = await this.token({ scope, resource });
for (let attempt = 0;; attempt++) {
const res = await this.http.request(url, {
...rest,
dpop: this.dpop,
accessToken: rec.accessToken,
scheme: 'DPoP',
});
if (attempt > 0)
return res;
const err = wwwAuthError(res.headers.get('www-authenticate'));
if (res.status === 401 && (!err.error || err.error === 'invalid_token')) {
rec = await this.token({ scope, resource, forceRenew: true });
continue;
}
// A signed-out token, or a signed-in one whose Session was signed out meanwhile (4.4
// "How sign-in is remembered"): the Account Token signs the Session (back) in.
if (res.status === 403 && err.error === 'sign_in_required' && this.accountToken) {
rec = await this.token({ scope, resource, forceRenew: true });
continue;
}
return res;
}
}
/** `fetch` + JSON, mapping failures to typed errors (section 6, 7.13). */
async json(url, init = {}) {
const res = await this.fetch(url, init);
if (!res.ok)
await throwForResponse(res);
return { status: res.status, body: (await readJson(res)) };
}
}
/** Maps a failed resource response to a typed error. */
export async function throwForResponse(res) {
const auth = wwwAuthError(res.headers.get('www-authenticate'));
if (auth.error) {
throw new ResourceAuthError(auth.error, {
status: res.status,
scope: auth.scope,
description: auth.description,
});
}
const body = await readJson(res);
const code = typeof body?.error === 'string' ? body.error : `http_${res.status}`;
throw new ConversationError(code, {
status: res.status,
description: typeof body?.error_description === 'string' ? body.error_description : undefined,
conversationId: typeof body?.conversation_id === 'string' ? body.conversation_id : undefined,
});
}
//# sourceMappingURL=company.js.map