UNPKG

@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
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