UNPKG

@adonis-agora/agent

Version:

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

126 lines • 6.51 kB
import { resolveAccessDeniedPage } from './access_denied_page.js'; /** * Decide whether a request may reach the dashboard, WITHOUT touching the response — so it is unit * testable free of the AdonisJS router/app: resolve the actor through the agent config's resolver * (missing/failed → `401`), then run the optional `authorize` gate over the resolved actor * (denied/threw → `403`). * * This gates the SPA **shell**, and only the shell. It is NOT what decides whether the console's data * is readable, and it is no longer the last line of defence it once had to be: * * - Whether the console mounts at all is decided earlier, by {@link decideDashboardMount} — the * routes are not registered unless the agent config carries a `governanceAuthorize` gate AND its * `governanceQueries` read-model exists. So this function never runs for a console that could only * 404 its own panels. * - Whether the DATA is readable is decided by the agent's own `governanceAuthorize` on the * `/agent/governance/*` routes, not here. Omitting `authorize` while a gate is configured leaves * the shell open to any resolved actor but exposes no cross-actor data. * * Omitting BOTH the resolver and `authorize` would expose the shell to any request; the provider is * expected to short-circuit on a missing resolver via the `401` here. * * `debug` controls whether a THROWN error's `.message` reaches the client, for the two per-request * failure paths (the resolver rejecting a caller, `authorize` throwing): the resolver's job is * exactly to reject unauthenticated/unauthorized callers, and its message is meant for the developer * wiring the config (e.g. `AuthActorResolver`'s "no authenticated user on ctx.auth.user...") — not * for whoever is making the request, which in production is an untrusted, possibly anonymous, * possibly adversarial caller. Default `false` (generic `'unauthorized'`/`'forbidden'`, matching * `@adonis-agora/durable`'s dashboard convention of a uniform message on every credential failure); * the provider passes `!app.inProduction` so local/dev boots keep the diagnostic detail. The "no * actor resolver configured" branch is unaffected — that is a static config error, not a per-request * one, identical for every caller, and useful for the operator to see even in production. * * `onUnauthenticated`, when set, runs on EITHER failure-to-resolve branch (no resolver configured, or * `resolve(ctx)` threw) before this returns its verdict — it never sees a fabricated actor, since * there isn't one. The provider (not this function) is what actually decides whether to skip its * default JSON write in favour of whatever the hook did to `ctx.response` (redirect, custom render); * this function only guarantees the hook runs before the verdict is handed back. */ export async function evaluateDashboardGate(ctx, actorResolver, authorize, debug = false, onUnauthenticated) { if (actorResolver === undefined) { await onUnauthenticated?.(ctx); return { ok: false, status: 401, error: 'no actor resolver configured' }; } let actor; try { actor = await actorResolver.resolve(ctx); } catch (error) { await onUnauthenticated?.(ctx); return { ok: false, status: 401, error: debug && error instanceof Error ? error.message : 'unauthorized', }; } if (authorize !== undefined) { try { if (!(await authorize(actor, ctx))) { return { ok: false, status: 403, error: 'forbidden' }; } } catch (error) { return { ok: false, status: 403, error: debug && error instanceof Error ? error.message : 'forbidden', }; } } return { ok: true }; } /** * Write a refused {@link DashboardGateVerdict} to the response — shared by BOTH dashboard providers * (embedded and standalone) so they refuse identically. A browser is what hits these routes (the * SPA shell and its assets), so the body is the built-in access-denied page (or the host's * `accessDenied` customisation of it), not the JSON the governance API answers with. * * Stands down when the request is already answered: `onUnauthenticated`/`authorize` (or the * renderer) writing a redirect keeps it — the `location`-header rule the providers have always * honoured. The verdict's `error` is shown on the page as a developer detail only when it is more * than the generic word (i.e. the `debug` message outside production, or the static "no actor * resolver configured" operator hint). */ export async function answerDashboardDenial(ctx, verdict, options) { const answered = () => responseAnswered(ctx); if (answered()) return; const nonce = cspNonce(ctx); const generic = verdict.error === 'unauthorized' || verdict.error === 'forbidden'; const info = { status: verdict.status, reason: verdict.status === 401 ? 'unauthenticated' : 'forbidden', basePath: options.basePath, ...(generic ? {} : { detail: verdict.error }), ...(nonce !== undefined ? { nonce } : {}), }; const html = await resolveAccessDeniedPage(info, options.accessDenied, ctx, answered); if (html === null) return; ctx.response .status(info.status) .header('content-type', 'text/html; charset=utf-8') .header('cache-control', 'no-store, must-revalidate') .send(html); } /** * Whether something already answered this request: a redirect (`location` header) or a body queued * on the response. The body check reads AdonisJS's `response.hasLazyBody` structurally so a * plain-object `ctx` double in a unit test (which has neither) still works. */ function responseAnswered(ctx) { if (ctx.response.getHeader('location')) return true; const response = ctx.response; return response.hasLazyBody === true || response.headersSent === true; } /** * The request's CSP nonce when the host runs `@adonisjs/shield` with `@nonce` in its policy (shield * exposes it as `response.nonce`). Read structurally: this package neither depends on shield nor * cares which middleware minted the nonce — only that the page's inline `<style>` carries it. */ function cspNonce(ctx) { const nonce = ctx.response.nonce; return typeof nonce === 'string' && nonce !== '' ? nonce : undefined; } //# sourceMappingURL=gate.js.map