@ascendenceai/cortena-extensions-shared
v0.2.0
Published
The route layer every Cortena extension backend imports: one definition, four surfaces (§15.2) — the Express router, the OpenAPI 3.1 document, the MCP tool list, the error envelope, the actor and the shared table schemas.
Readme
@ascendenceai/cortena-extensions-shared
Shared building blocks for a Cortena extension backend. What is documented here
is the route definition and the actor; the package also carries the common zod
schemas (./schemas, ./schemas/table) and the MCP Apps resource builder.
Routes: one definition, four surfaces
The protocol (cortena-docs/HOW-TOs/how-to-create-a-cortena-extension.html,
§14.6 and §15.2) asks for one object per route, from which the Express route,
the MCP tool, the OpenAPI 3.1 document and the Engram catalogue row are all
generated. That is what defineRoute and defineRoutes are: not three
generators reading three declarations, but one array read three ways.
The agent never reads your OpenAPI document or your tools/list. It reads an
Engram catalogue row, ingested from the document you serve — so the document is
not paperwork, it is the only description of your extension the agent will ever
see.
Declaring a route
import { defineRoute } from '@ascendenceai/cortena-extensions-shared/routes';
import { z } from 'zod';
export const listTasks = defineRoute({
method: 'GET',
path: '/v1/orgs/:orgId/projects/:projectId/tasks',
tags: ['tasks'],
summary: 'List tasks in a project',
description: 'Returns tasks newest first, keyset-paginated by `cursor`.',
whenToUse: 'Use to find a task id before updating, commenting on or closing it.',
request: z.object({
orgId: z.string().describe('Org id from the caller’s token'),
projectId: z.string().describe('Project id from tasks_project_list'),
status: TaskStatusSchema.optional().describe('Filter to one status'),
limit: z.coerce.number().int().max(100).default(20).describe('Rows per page, max 100'),
}),
response: z.object({
rows: z.array(TaskRow).describe('Matching tasks, newest first'),
cursor: z.string().nullable().describe('Pass as `cursor` for the next page'),
}),
examples: [{
request: { orgId: 'o-1', projectId: 'p-42', status: 'todo', limit: 2 },
response: { rows: [{ id: 't-1', title: 'Fix login', status: 'todo' }], cursor: null },
}],
mcp: { toolName: 'tasks_task_list' },
handler: async (input, ctx) => listTasksFor(ctx.actor, input.projectId, input.limit),
});| Field | Who reads it |
| --- | --- |
| method, path | the router, the document, and the catalogue's identity key (component + method + canonicalPath) |
| request / response | the route's validation, the MCP tool schema, and the row's params and returns |
| summary | the search row's one-line intent — about 400 bytes, and the agent pays for it every turn |
| whenToUse | mandatory. The sentence that makes the agent pick this capability over the nine others in the search result |
| description | the OpenAPI document, for a human |
| examples[] | the document, one example into the describe row, and the conformance test |
| access | derived from the method; an override is one explicit, reviewable line |
request may be written flat, as above — keys named in the path become path
parameters, the rest become the query on a read and the body on a write — or
split explicitly as { params, query, body }. Both are the same declaration.
handler receives the validated input flat, and a context carrying the actor
(§18), the framework req/res, and the same values split by where they came
from. Throw a RouteError(status, code, message) to choose a status; anything
else becomes an opaque 500, with the cause bounded to 2 kB.
whenToUse is mandatory, and examples are checked when you declare them
defineRoute throws at import time on a missing whenToUse, a missing
summary, no example, or an example that does not validate against its own
schema. An example that has quietly stopped being true is worse than none,
because it is the one thing in the row the agent trusts literally.
access comes from the method
GET and HEAD are read; POST, PUT, PATCH and DELETE are write.
A read skips the broker's confirmation step, so a capability marked write
that only reads costs a user-visible confirmation and an extra model call to
fetch something that changes nothing.
The one legitimate override is a POST that is really a query, and it must say
why:
access: 'read',
accessReason: 'A search: the body is the query, and it changes nothing.',A read POST also answers 200 rather than 201 — it creates nothing.
The registry
import { defineRoutes } from '@ascendenceai/cortena-extensions-shared/routes';
export const registry = defineRoutes([listTasks, createTask, searchTasks], {
openapi: {
info: { title: 'CortenaTasks', version: '0.1.0', description: 'Tasks, projects and decisions for an org.' },
// servers is optional; omitted it is `${EXTENSION_BASE_URL}`, substituted at runtime
xCortena: {
id: 'tasks',
icon: 'list-checks',
mcpUrl: '${EXTENSION_BASE_URL}/mcp',
mcpApps: { exempt: [{ route: '/admin/*', reason: 'full permission matrix; not a chat-sized surface' }] },
},
},
middleware: [authenticate, requireOrgMatch, requireLicense('tasks')],
});
app.use(registry.router); // every route, validated
registry.serve(app); // GET /openapi.json, unauthenticated, beside /health
registry.tools(); // the MCP tool list, from the same definitions
registry.openapi(); // the OpenAPI 3.1 documentThe identity block is the manifest. There is no cortena.plugin.json; the
audit fails an extension that ships one (P-40). x-cortena.id must match the id
in the licence check, the cortena-auth catalogue row and the AgentTemplate slug.
GET /openapi.json substitutes ${EXTENSION_BASE_URL} from
serve({ baseUrl }), then process.env.EXTENSION_BASE_URL, then the host the
request arrived on.
Security: the bearer is declared for you
Every generated document carries the one scheme and requires it everywhere:
security:
- cortenaAuth: []
components:
securitySchemes:
cortenaAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |-
Issued by `cortena-auth`… A token may additionally carry an RFC 8693
`act` claim naming the software holding it: an agent acting on behalf of
the user in `sub`…It is emitted rather than declared because it is not the extension's decision —
every Cortena extension is behind the same cortena-auth bearer, and a document
that omits it is a document a broker reads as an open API.
A route that is genuinely unauthenticated opts out, and the operation carries
security: []:
security: false, // /health, and nothing else so farThere is no security: true. A route that authenticates says nothing and
inherits the document's requirement, because a per-route opt-in is a requirement
that is silently missing the day somebody forgets it.
Errors: everything that escapes is the envelope (§15.6, P-22)
The broker passes an extension's own envelope through untouched and wraps
anything else — HTML, a stack trace, a bare string — in a generic
upstream_error with the body cut to 2 kB. That wrapping path is a safety net
that should never trigger, and it triggers the moment an extension throws and
Express serialises the exception. So nothing leaves a mounted route as anything
but the envelope:
| What happened | What the caller gets |
| --- | --- |
| The request failed the declared schema | 400 validation_failed, message is field: reason, details.fieldErrors carries the zod flatten() issues |
| The handler threw RouteError(409, 'conflict', …) | exactly that — a chosen status, a chosen code, the sentence it was given |
| express.json() met a malformed body | 400 invalid_json, body: the request body is not valid JSON |
| Anything else at all | 500 internal_error, <Extension> could not complete <the route's summary>. — and nothing else |
That last row is the point. No details, no cause, no stack, no driver
message: password authentication failed for user "cortena_test_app" is what
pg says out loud, and bounding it to 2 kB does not make it safe to hand to a
model or paint on a user's screen. The exception goes to the active span and
the log instead (§15.7.4), which is where the person who can act on it will
look:
app.use(express.json());
app.use('/v1', registry.router); // mountRoutes adds its own catch-all
registry.serve(app); // …and one on the app, for express.json()
app.get('*splat', serveSpa);
app.use(errorMiddleware({ extension: 'CortenaTasks' })); // last, if you mount your own stackTwo handlers, not one, because they catch different things. The router's
catches its own routes and their middleware. The app's is the only one that can
catch express.json(), which throws before the router is reached — and
Express skips a Router (a three-argument middleware) once an error is in
flight, so without an app-level handler a malformed body is answered with
Express's default HTML page. registry.serve(app) mounts it for you; pass
{ errorHandler: false } if you would rather place it yourself, last.
Where the detail goes. §15.7.1 puts the OpenTelemetry bootstrap in
@cortena/observability, published from the cortena repository. That package
is not published yet (checked 2026-09-07), so the sink is a hook rather than
an import:
defineRoutes(routes, { openapi: { … }, onError: (report) => logger.error(report) });Left out, the default sink records the exception on the active span through
@opentelemetry/api when that package resolves at runtime — API only, so it is
a no-op until a bootstrap starts a tracer — and writes one structured JSON line
carrying trace_id, span_id, http.route and the stack. When
@cortena/observability ships, pass its logger as onError and nothing else
changes.
Only a 5xx is reported. A RouteError chose its status deliberately and a
malformed body is the caller's to fix; logging either as an unhandled exception
is how an error log becomes something nobody reads.
The committed document and the drift check
cortena-openapi write --entry ./dist/routes.js # renders docs/openapi.yaml
cortena-openapi check --entry ./dist/routes.js # exits 1 with a diff on drift
cortena-openapi write --out docs/openapi.json --json # JSON instead of YAML--entry is a module exporting the registry (as the default export, or as
registry). Both flags can live in a cortena-openapi.json beside the package
instead:
{ "entry": "./dist/routes.js", "out": "docs/openapi.yaml" }Wire check into CI (§22.3). It fails rather than regenerating and pushing: a
route's parameters moving is an API change and belongs in the diff a human
approves, and a generated file anyone may edit stops being generated within
about two commits.
Runtime configuration for the SPA (§19.2, P-27)
VITE_AUTH_SERVICE_URL is substituted by Vite at build time and hashed into
the bundle's filename, so a ConfigMap cannot reach it. Two environments then
need two images of the same commit — which is the thing §29's immutable-SHA tag
exists to prevent. The fix is the one cortenaweb already ships: the API serves
the configuration, the SPA reads it on boot.
// functions/src/index.ts — beside /health, unauthenticated
import { serveRuntimeConfig } from '@ascendenceai/cortena-extensions-shared';
serveRuntimeConfig(app, {
keys: ['AUTH_SERVICE_URL', 'CORTENAWEB_ORIGIN', 'FIREBASE_AUTH_DOMAIN'],
required: ['AUTH_SERVICE_URL'],
});<!-- web/index.html — before the module bundle, and not deferred -->
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>// web/src/config.ts
import { z } from 'zod';
import { readRuntimeConfig } from '@ascendenceai/cortena-extensions-shared-web';
export const config = readRuntimeConfig(
z.object({
AUTH_SERVICE_URL: z.string().url().describe('cortena-auth, for sign-in and refresh'),
CORTENAWEB_ORIGIN: z.string().url().describe('The parent frame, for postMessage and CSP'),
}),
{ fallback: { AUTH_SERVICE_URL: 'http://localhost:3200' } }, // `pnpm dev`, no backend
);Allowlist only, and names that look like credentials are refused at mount
time. keys is the whole security model — there is no "everything with a
prefix" mode, because a prefix rule publishes whatever somebody names with that
prefix next year. A key matching SECRET, TOKEN, PASSWORD, JWT,
DATABASE_URL and the rest throws when the route is mounted, not in a browser.
Secrets stay in a Kubernetes Secret and are read by the backend (§19.3).
The response is no-store. A CDN or a service worker holding yesterday's
configuration is exactly the failure this file removes, and it would be
indistinguishable from a bad deploy.
readRuntimeConfig throws at boot naming the key and where it comes from. The
failure it replaces is silent: an unset VITE_ variable becomes the string
undefined in the bundle, and the first symptom is a fetch to
undefined/v1/orgs/… on a screen three clicks in, blamed on the screen.
The ConfigMap, and the annotation without which nothing happens
All non-secret configuration is rendered into a ConfigMap and consumed with
envFrom, so a change is a helm upgrade and never an image rebuild. A
ConfigMap edit does not restart pods — without the checksum/config
annotation, the new values appear whenever some unrelated thing restarts the
pod, which is to say they appear to do nothing, for a while, and then work.
# CortenaEnterprise/k8s/helm/extensions/<name>/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: ext-{{ .Chart.Name }}-config
data:
AUTH_SERVICE_URL: {{ .Values.authServiceUrl | quote }}
CORTENAWEB_ORIGIN: {{ .Values.cortenawebOrigin | quote }}
EXTENSION_BASE_URL: {{ .Values.publicHostname | printf "https://%s" | quote }}
OTEL_EXPORTER_OTLP_ENDPOINT: {{ .Values.otelEndpoint | quote }}# …/templates/deployment.yaml
spec:
template:
metadata:
annotations:
# The line that makes a ConfigMap edit take effect: the pod template
# hash changes, so `helm upgrade` rolls the deployment. Without it a
# config change is invisible until something else restarts the pod.
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
containers:
- name: api
envFrom:
- configMapRef:
name: ext-{{ .Chart.Name }}-config
# Secrets are their own reference and never inlined here (§19.3).
- secretRef:
name: cortena-{{ .Values.environment }}-{{ .Chart.Name }}-secretsNo environment-specific literal belongs in the template either (§19.1, P-27).
.Values is the seam: a new environment — or a new customer tenant — is a
values file, not a code change. A hostname literal in the non-test branch of a
chart is the line that breaks a tenant deployment, because the branch is never
executed until the day it matters.
MCP tool parity, and the test that holds it (§14.6, P-17)
Every action the UI can take is available over MCP, barring none. One tool per route, generated from the same definition, and a test that fails when a route is added without one.
One line to satisfy it
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { mountMcpTools } from '@ascendenceai/cortena-extensions-shared';
export const mcpServer = new McpServer({ name: 'tasks', version: '0.2.0' });
mountMcpTools(mcpServer, registry, { actor: (extra) => actorFor(extra) });mountMcpTools registers one tool per non-waived route: the route's own zod
schemas as the tool's input (the schemas themselves, not a rendering of them),
the route's summary and whenToUse as the description, and a handler that
splits the flat params object the agent sends back into path, query and body,
validates each against the same schema the router uses, and calls the route's
handler.
actor is where the caller comes from. §18 requires every write to record its
actor, and on this lane the "request" is a tool call: a server built per session
(what Tasks does) has the user in a closure and passes () => actor; one that
authenticates per call reads extra.authInfo, which is what the default
actorFromMcpExtra does. Either way the actor is stamped via: 'agent' unless
a signed claim says otherwise — a tool call is an agent acting, and recording it
as the person acting directly is the one attribution error §18 cannot tolerate.
mountMcpTools is duck-typed against the server: @modelcontextprotocol/sdk is
deliberately not a dependency of this package.
The assertion
import { assertToolParity } from '@ascendenceai/cortena-extensions-shared';
it('every mutating route has an MCP tool', () => {
assertToolParity(createApp(), mcpServer);
});It walks the running app against the running server. That is the point: the OpenAPI document and the tool list come out of one generator, so they agree with each other by construction and would agree just as happily about a route neither has heard of. Three things are asserted, and each catches a different way of getting it wrong:
- Every mutating route on the app is a registry route. A hand-written
app.post('/v1/orgs/:orgId/audit', …)fails with its path. It works, it ships, and the agent cannot reach it — which is the failure §14.6 exists to prevent. Read routes are advisory;POST,PUT,PATCH,DELETEand anything declaredaccess: 'write'are mandatory. - Every registry route has a tool on the server with an identical input
schema, or a recorded waiver. The JSON Schema the registry emits is
deep-compared with the one the server holds, property by property, so a
.max(200)added to the route and not to a hand-written tool is named rather than merely counted. - Every tool on the server comes from the registry. A tool registered by hand fails by name: it has no OpenAPI operation, no examples and no catalogue row, and its schema drifts the first time the route it wraps changes.
Every violation is reported at once, in one AssertionError. A parity failure
is usually a batch — a branch that added four routes and registered none of them
— and an assertion that stops at the first turns that into four runs.
const report = assertToolParity(app, mcpServer);
// { routes, tools, exclusions: [{ method, path, reason }], violations }toolParityReport(app, server, options) is the same walk without the throw.
Waivers
defineRoute({
method: 'POST',
path: '/v1/orgs/:orgId/tasks/import',
mcp: { exclude: true, reason: 'multipart upload; there is no file for an agent to send' },
…
});The reason is mandatory: exclude: true on its own is a definition-time
error, not a quiet omission. It reaches the OpenAPI operation as
x-cortena-tool-exempt-reason, which is the field the conformance audit reads
for P-17, and the parity test prints the waived list on every run:
[tool-parity] 1 route(s) waived from MCP tool parity (§14.6):
[tool-parity] POST /v1/orgs/:orgId/tasks/import — multipart upload; there is no file for an agent to sendPass { print: false } to silence it; the exclusions are in the returned report
either way. mcp: { exempt: { reason } } is the older spelling of the same
declaration and still works.
destructiveHint: say so, do not let the method guess
readOnlyHint comes from access and needs nothing. destructiveHint is
derived from method === 'DELETE', which is right for a CRUD API and wrong for
this one: the acts a Cortena extension cannot undo are mostly POSTs to a verb
— retiring a case, signing a release, authorising security testing — so a host
that confirms only deletes confirms none of them.
mcp: { toolName: 'assure_case_retire', destructive: true },mcp.destructive overrides the derivation in both directions. Set it where the
act cannot be undone by calling something else; it is not "this writes", which
is what access already says.
Options, and the two escape hatches
assertToolParity(app, server, { registry, listTools: () => tools, print: false });registry— normally unnecessary.defineRoutesmarks the router it builds, so the walker finds every registry the app has mounted, by identity rather than by matching path strings. Pass it for a registry mounted some other way.listTools— how the server's tools are read when this module cannot introspect it. It tries an injectedlistTools()first, then one the object offers, then@modelcontextprotocol/sdk's private_registeredTools(a zod schema per tool, which is converted with the sametoJsonSchemathe registry uses, so the comparison is like for like). Disabled tools are left out, astools/listleaves them out.
One caveat worth knowing: Express 5's Layer no longer keeps the path it was
mounted at, so a route inside a hand-made sub-router is reported with … in
place of the prefix and exactPath: false. It never affects the verdict —
registry routes are recognised by the identity of the router they came out of —
only how a violating path is spelled.
The actor: on behalf of the user (§18, P-26)
Agent parity is worthless if nobody can tell afterwards what the agent did, and attribution is worthless if each extension invents its own column. So there is one resolver, four columns and one label, and none of them is a per-extension copy (§18.1).
import { resolveActor, requireActor, withActor, actorLabel } from '@ascendenceai/cortena-extensions-shared';
app.use(authenticate); // yours: verifies the cortena-auth JWT onto req.user
app.use(requireActor()); // no unattributed writes
const actor = resolveActor(req);
await db.insert(tasks).values(withActor({ title }, actor));
actorLabel(actor); // "Ashish (Agent)"Principal and via are two questions
principalKind is who — a person, or a service account such as a channel
runtime. via is how — itself, or through an agent. A service account is a
principal like a user and never a third via, because the case the channel lane
is made of is a service account acting through an agent, and collapsing the
two questions leaves nowhere to put it.
| principalKind | via | rendered |
| --- | --- | --- |
| user | user | Ashish |
| user | agent | Ashish (Agent) |
| service | user | Channel runtime |
| service | agent | Channel runtime (Agent) |
Where the actor comes from, in order
- An
Actoran earlier middleware already resolved. Once per request, so every write in it agrees. - The verified token decides the identity.
subis the principal,displayNamethe name,principalType: 'service'makes it a service account (cortena-authsignServiceToken). Nothing else may name a user. - A signed claim decides delegation. The RFC 8693
actclaim that cortena-auth'sissueMcpAccessTokenputs on every MCP access token —act: { sub, client_id, externally_routed, model? }— meansvia: 'agent'withact.subas the agent; so does a service token minted with anagentId. Both are stampeddelegationProven: true. - The broker headers may only add the agent marker.
x-cortena-via: agentandx-cortena-agent-idraiseviato'agent'and are stampeddelegationProven: false. They can never lower a signed'agent'back to'user', and they can never name a principal. x-cortena-user-idnames nobody unless you pass{ trustBrokerHeaders: true }, and then only when no verified token contradicts it.
The token wins over the headers because the token is signed and the headers are
not. x-cortena-user-id is one half of a pair — x-cortenacore-token is the
other — and the pairing is checked by the Controller against its session
store, on the pod → Controller hop. By the time a call reaches an extension the
Controller has replaced the pair with a real credential
(Authorization: Bearer <principal jwt>), so on our hop the header is unsigned
and unpaired: honouring it for identity would be honouring the caller's own
claim about who they are.
The agent marker is the exception, and only in the direction that narrows.
An agent gets exactly the permissions of the principal it acts for and never
more (§13.2), so forging x-cortena-via: agent mislabels your own write rather
than escalating anything — while the in-product lane (/agui/run,
/tools/invoke) still carries no act claim (§18.1), which makes the header
the only marker it has. delegationProven records which of the two it was, so
§14.5's "record what you know, and stamp the record with the fact that you could
not prove it" is a field rather than a convention.
Refusals
requireActor() is a 403 with a named code, twice, because they are different
mistakes fixed differently:
actor_required— a mutating request nobody can be named for. Reads pass through unattributed; a route that declaresprincipalsdoes not.principal_not_allowed— a service account on a route declaringprincipals: ['user'].
principals is declared on the route, not checked in the handler, so the same
refusal applies over REST and over MCP (§13.2) — and mountRoutes enforces it
too, so the declaration is a rule even before requireActor() is mounted. Use
it where a person is the point: accepting an invitation, agreeing to something,
anything whose record has to name someone who can be asked about it afterwards.
defineRoute({
method: 'POST',
path: '/v1/orgs/:orgId/invites/:inviteId/accept',
principals: ['user'],
// ...
});checkActor(actor, { method, principals }) is the same two checks as a plain
function, for an MCP tool handler that is not in an Express chain.
The four columns
export const tasks = pgTable('tasks', {
id: text('id').primaryKey(),
title: text('title').notNull(),
...actorColumns(),
}, (t) => actorTableExtras('tasks', t));actor_user_id and actor_via are not null — a row that cannot say who made
it is what the rule exists to prevent. actor_agent_id and granted_by are
nullable: there is no agent on a direct write, and granted_by — which
assignment allowed it, 'direct' or the group that carried the role (§13.4) —
is stamped by the policy function once may() has answered, with
withGrantedBy(actor, ...). actorTableExtras adds the index on
actor_user_id and the check (actor_via in ('agent','user')).
withActor(row, actor) fills all four from one object, so three of the four
cannot be passed and the fourth lost silently. Drizzle is an optional peer
dependency, loaded on demand the way mountRoutes loads Express: a backend with
no ORM can still import the rest of the package, and can write the four columns
by hand.
The label
actorLabel(actor) is "Name (Agent)" when via === 'agent' and the plain
name otherwise, with the id as the fallback name so a row never renders as an
empty cell. §18.2 wants it in the activity feed, comments and history and not
only in the audit table — the user reads the feed, so attribution that exists
only in the audit table is not attribution.
Authorisation: who may do what (§14, P-14, P-42, P-43)
cortena-auth provides identity only — who the user is, which org they are in and whether that org holds a licence. It does not define, store or enforce any extension role or permission (DESIGN-D11). Authorisation is yours.
What is not yours is a seventh implementation of it. Seven extensions each
writing a role table, a matrix, a may(), seven admin routes and a grantedBy
stamp is seven chances to disagree about the one thing that decides who may act.
So the vocabulary stays the extension's and the deciding lives here (EXTBP-29).
import {
definePolicy, createAuthz, authzTables, drizzleAuthzStore, adminRoutes,
} from '@ascendenceai/cortena-extensions-shared';
// 1. Your vocabulary, declared once. The matrix an org starts with.
export const policy = definePolicy({
extensionId: 'tasks',
roles: ['admin', 'editor', 'viewer'],
capabilities: ['tasks.task.read', 'tasks.task.write'],
defaults: {
admin: ['tasks.task.read', 'tasks.task.write'],
editor: ['tasks.task.read', 'tasks.task.write'],
viewer: ['tasks.task.read'],
},
});
// 2. Your tables, in your database. `authzTables` is the drizzle fragment;
// `authzMigrationSql()` is the same two tables as the migration you apply.
export const { roleAssignments, permissions } = authzTables(schema);
// 3. One decider.
export const authz = createAuthz({
policy,
store: drizzleAuthzStore({ db, tables: { roleAssignments, permissions } }),
});
export const { may, requireCapability, withCapability } = authz;One policy function, two callers
router.patch('/tasks/:id', requireCapability('tasks.task.write'), handler);
// …and nothing else, because `mountMcpTools` runs a route's own middleware.requireCapability is declared in a route's middleware, and that is the whole
of it for a route mounted through defineRoutes: mountRoutes runs it on the
REST request and mountMcpTools runs it on the tool call, so both lanes ask one
may() and refuse with one envelope. withCapability(capability, tool) is the
same decision for a tool registered outside the registry — §14.6's parity test
refuses a tool with no route, so reach for it only with that exemption declared.
On an allow, the guard stamps granted_by onto req.actor (and res.locals)
through the actor module's withGrantedBy, so withActor(row, ctx.actor)
records which assignment allowed it without the handler asking twice (§20.2).
may(actor, capability, target?)
const { allowed, grantedBy, role } = await authz.may(actor, 'tasks.task.write', { orgId });grantedBy is 'direct', the id of the group that carried the role, or
'org-role:owner'; null when the answer is no. Every read is org-scoped —
may() refuses rather than deciding when no org is in scope, because an
assignment table without an org filter is the shape of every cross-tenant read
there has ever been (§4).
The target is threaded, not merely carried: whatever you pass beyond orgId
and orgRole reaches AuthzStore.assignmentsFor(orgId, principals, target).
The two tables here have no scope column, so drizzleAuthzStore ignores it
unless you give it a scope:
drizzleAuthzStore({
db, tables,
scope: (target) =>
typeof target['projectId'] === 'string'
? eq(roleAssignments.projectId, target['projectId'])
: undefined,
});
router.patch('/projects/:projectId/tasks/:id',
requireCapability('tasks.task.write', { target: (req) => ({ projectId: req.params.projectId }) }),
handler);Groups are cortena-auth's, and they are off until PLATFORM-85
createAuthz({
policy, store,
groups: {
enabled: config.TASKS_AUTHZ_GROUPS === 'on',
fetchUserGroups: cortenaAuthGroups({ baseUrl: config.AUTH_SERVICE_URL, token }),
},
});Group definitions and membership live in cortena-auth and are read, never written (§14.4, P-42). Until PLATFORM-85 ships the directory the flag stays off and effective roles are direct assignments alone — an extension shipping today is correct, and the day the endpoint exists one flag unions group roles in.
Nothing is ever flattened into stored per-user rows. The union happens inside
may(), on every call, because the copy is right until somebody joins or leaves
a group and then it is wrong silently and everywhere at once (P-43). The read is
cached for the life of a request always, and across requests only if you ask:
groups.ttlMs is 0 by default, because PLATFORM-85's acceptance is that
removing somebody from a group refuses their next request, and a window
nobody configured is a window nobody knows about.
A directory that cannot be reached fails the decision. may() rejects, the
guard hands it to the error middleware, and the caller gets a 500 — it does not
fall back to direct assignments (a permission silently lost mid-outage) and it
does not fall back to allowing (an outage in cortena-auth widening every
extension at once).
The org owner gets nothing, unless you say so
Being the org owner or admin in cortena-auth grants no capability here. §14 is that cortena-auth does not decide, and a default that made every org owner an administrator of every extension they licensed would be it deciding.
§14.4's bootstrap — a freshly licensed extension has an empty assignment table, so nobody can open the admin screen to grant anybody anything — is an explicit option, written where a reviewer sees it:
definePolicy({ …, orgRoles: { owner: ['admin'] } });It stops applying the moment the extension has an assignment of its own
(orgRolesUntilFirstAssignment, default true), and a role reached this way is
reported as org-role:owner rather than direct — an administrator must not be
shown a row to remove that is not in the table.
The §14.3 admin routes
export const registry = defineRoutes(
[...mine, ...adminRoutes({ authz, directory, licence })],
{ openapi: { … } },
);Seven route definitions, mounted under /v1/orgs/:orgId/admin, with the
request and response schemas cortena-ui's AdminPermissions reads: the members
list (a join — the person and their groups are cortena-auth's and read-only, the
roles beside them are yours), the two PATCHes that set a user's or a group's
extensionRoles, the licence read-through, and the matrix read and write.
effectiveRoles is rendered as { role, source }, where source is 'direct'
or { group: { id, name } } — the composition's AdminRoleSource, because an
administrator shown g-7f2a cannot tell what to remove. It is computed per
request and is never a stored table.
The three capabilities that guard the console itself — <ext>.admin.read,
<ext>.admin.permissions.write, <ext>.admin.roles.write — are added to your
policy by definePolicy, so there is no way to ship a console anybody can open.
directory.listGroups is optional, and its absence removes the two group
routes: an extension whose tenant has no group directory gets a screen with no
group affordances rather than an empty tab, which is what AdminPermissions
does with an absent api.groups.
The schema
authzTables(schema) and authzMigrationSql({ schema }) are the same two
tables, and templates/authz.sql in this package is that function's output with
%%snake%% where the schema name goes — a test fails when the committed file
stops matching, the same arrangement cortena-openapi check has with the
committed document.
| table | what it holds |
| --- | --- |
| role_assignments | (org_id, role, principal_kind in ('user','group'), principal_id), unique together, plus §20.2's actor columns and granted_at |
| permissions | (org_id, capability, roles text[]), unique together — one row per capability |
The matrix is stored keyed by capability and rendered keyed by role (§14.3's
grants); matrixFromGrants and grantsFromMatrix are the only two places
that transpose it.
memoryAuthzStore() is the same interface over two arrays, for testing a policy
without standing up Postgres.
The agent gateway (§19.2, P-46)
Every extension surfaces its own agent as a chat pop-up (§19), and the pop-up —
AgentChatPopup from cortena-ui/agent-chat — talks to exactly one origin:
the extension's OWN gateway, with the signed-in user's JWT on every call. It
never reaches cortenacore and it never holds a service credential. So the
extension has to serve those routes, and this is them:
app.use(
'/api/agent',
createAgentGatewayRouter({
controllerUrl: config.CONTROLLER_URL,
agentId: 'tasks', // = the AgentTemplate slug = the extension id
verify: authenticate, // your own JWT middleware, not a second opinion
}),
);Mount it before express.json(). An AG-UI RunAgentInput then goes
upstream byte-for-byte; mounted after a parser it still works, but the bytes
the pod sees are the parser's rather than the client's.
| this router | the Controller | what it is |
| --- | --- | --- |
| POST /agui/run | POST /api/session/agui/run | the run stream: RunAgentInput in, text/event-stream out |
| POST /agui/abort | POST /api/session/agui/abort | { runId, threadId } |
| POST /agui/approval | POST /api/session/agui/approval | { id, decision, threadId } |
| GET\|POST /api/core/* | /api/session/core/* | sessions/list, chat/history, sessions/patch, sessions/delete |
| GET /session/status | GET /api/session/status | |
| DELETE /session/disconnect | DELETE /api/session/disconnect | |
POST /session/connect is deliberately absent. The Controller's connect
handler reads vaultPassphrase, vaultBundle and refreshToken off the
request body, and an extension must not be the pipe those travel down. The
pop-up's client never calls it — it assumes a pod, and cortenaweb is where one
is created.
Anything else is a 404, and every one of them is behind verify.
Three refusals, and each is a different mistake:
- 401
unauthenticated— no bearer. The gateway proxies a person; there is nobody to proxy. - 401
service_token_not_accepted— a service-account token. The Controller resolves the token's own user's pod, so a service token either finds none or finds the wrong one, and every message in the chat would be attributed to nobody. It is refused rather than mapped, because there is no user to map it to. The check reads the token as well as whateververifyleft on the request, because an extension's middleware maps the payload into a shape of its own and drops the field that answers this. - 403
session_agent_mismatch— a session key that is notagent:<agentId>:…. The pod picks the agent from the session key, not from a header (the Controller forwards a five-header allowlist, sox-cortena-agent-iddoes not survive the hop), and this gateway holds one user's credential inside one extension. Without the check, Tasks'/api/agentwould runagent:payroll:…and read its transcript back out ofchat.history.
The stream is piped, never buffered: the head goes out before the first event, each chunk is written as it arrives, the client's backpressure pauses the upstream, and a browser that hangs up destroys the upstream request so the pod stops generating into a dead socket. The upstream has 30 s to produce headers — not to finish, because an AG-UI run is idle between events by design.
Exports
Everything is exported from the package root and from the ./routes subpath:
defineRoute,defineAction,defineRoutes,mountRoutesassertToolParity,toolParityReport,mountMcpTools,walkExpressRoutes,readServerTools,schemaDifferences,toolInputShape,actorFromMcpExtra,toolExclusionbuildOpenApiDocument,buildMcpTools,toJsonSchema,substituteServerUrlRouteError,errorEnvelope,zodErrorEnvelope,envelopeFromThrown,internalErrorMessage,errorMiddleware,boundUpstreamBodydefaultOnRouteError,reportRouteError,recordExceptionOnActiveSpan,activeTraceIdsand theOnRouteError/RouteErrorReporttypescreateAgentGatewayRouterand theAgentGatewayOptionstype, withAGUI_ACTIONS,AGUI_LANE,CORE_LANE,SESSION_ROUTES,agentScopeGuardandwriteWithBackpressureserveRuntimeConfig,collectRuntimeConfig,runtimeConfigScript,RUNTIME_CONFIG_GLOBAL,RUNTIME_CONFIG_PATH— also on the./runtime-configsubpathresolveActor,requireActor,actorLabel,actorColumns,withActorand theActortype — also on the./actorsubpathdefinePolicy,createAuthz,adminRoutes,authzTables,authzMigrationSql,drizzleAuthzStore,memoryAuthzStore,cortenaAuthGroups,adminCapabilities,matrixFromGrants,grantsFromMatrix,matrixProblems,orgRoleOfand theAuthzPolicy,AuthzStore,AuthzDecision,AuthzDirectory,AuthzGroupstypes — also on the./authzsubpath, with the migration template at./templates/authz.sqlmcpAppResource,withMcpApp,mcpAppRequested(a tool result — the last reads the caller'sAcceptoff the tool callback'sextra) andwithAppResource,hasAppResource,acceptsAppResource,appResourceExtensionId(a REST answer), withMCP_APP_MIME_TYPE,MCP_APP_URI_SCHEME,MCP_APP_MAX_HTML_BYTESand the structuralMcpRequestExtraLike- types:
RouteDefinition,RouteContext,NormalisedRoute,RouteRegistry,OpenApiDocument,XCortena,McpToolDefinition,ErrorEnvelope,ParityReport,ParityViolation,ToolParityOptions,McpServerLike,McpAppResourceBlock,AppResourceEnvelope
The CLI is reached as the cortena-openapi bin rather than through the barrel,
so node:fs and the YAML writer stay out of a backend's import graph.
