npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

The 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 far

There 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 stack

Two 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 }}-secrets

No 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:

  1. 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, DELETE and anything declared access: 'write' are mandatory.
  2. 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.
  3. 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 send

Pass { 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. defineRoutes marks 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 injected listTools() first, then one the object offers, then @modelcontextprotocol/sdk's private _registeredTools (a zod schema per tool, which is converted with the same toJsonSchema the registry uses, so the comparison is like for like). Disabled tools are left out, as tools/list leaves 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

  1. An Actor an earlier middleware already resolved. Once per request, so every write in it agrees.
  2. The verified token decides the identity. sub is the principal, displayName the name, principalType: 'service' makes it a service account (cortena-auth signServiceToken). Nothing else may name a user.
  3. A signed claim decides delegation. The RFC 8693 act claim that cortena-auth's issueMcpAccessToken puts on every MCP access token — act: { sub, client_id, externally_routed, model? } — means via: 'agent' with act.sub as the agent; so does a service token minted with an agentId. Both are stamped delegationProven: true.
  4. The broker headers may only add the agent marker. x-cortena-via: agent and x-cortena-agent-id raise via to 'agent' and are stamped delegationProven: false. They can never lower a signed 'agent' back to 'user', and they can never name a principal.
  5. x-cortena-user-id names 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 declares principals does not.
  • principal_not_allowed — a service account on a route declaring principals: ['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 whatever verify left 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 not agent:<agentId>:…. The pod picks the agent from the session key, not from a header (the Controller forwards a five-header allowlist, so x-cortena-agent-id does not survive the hop), and this gateway holds one user's credential inside one extension. Without the check, Tasks' /api/agent would run agent:payroll:… and read its transcript back out of chat.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, mountRoutes
  • assertToolParity, toolParityReport, mountMcpTools, walkExpressRoutes, readServerTools, schemaDifferences, toolInputShape, actorFromMcpExtra, toolExclusion
  • buildOpenApiDocument, buildMcpTools, toJsonSchema, substituteServerUrl
  • RouteError, errorEnvelope, zodErrorEnvelope, envelopeFromThrown, internalErrorMessage, errorMiddleware, boundUpstreamBody
  • defaultOnRouteError, reportRouteError, recordExceptionOnActiveSpan, activeTraceIds and the OnRouteError / RouteErrorReport types
  • createAgentGatewayRouter and the AgentGatewayOptions type, with AGUI_ACTIONS, AGUI_LANE, CORE_LANE, SESSION_ROUTES, agentScopeGuard and writeWithBackpressure
  • serveRuntimeConfig, collectRuntimeConfig, runtimeConfigScript, RUNTIME_CONFIG_GLOBAL, RUNTIME_CONFIG_PATH — also on the ./runtime-config subpath
  • resolveActor, requireActor, actorLabel, actorColumns, withActor and the Actor type — also on the ./actor subpath
  • definePolicy, createAuthz, adminRoutes, authzTables, authzMigrationSql, drizzleAuthzStore, memoryAuthzStore, cortenaAuthGroups, adminCapabilities, matrixFromGrants, grantsFromMatrix, matrixProblems, orgRoleOf and the AuthzPolicy, AuthzStore, AuthzDecision, AuthzDirectory, AuthzGroups types — also on the ./authz subpath, with the migration template at ./templates/authz.sql
  • mcpAppResource, withMcpApp, mcpAppRequested (a tool result — the last reads the caller's Accept off the tool callback's extra) and withAppResource, hasAppResource, acceptsAppResource, appResourceExtensionId (a REST answer), with MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME, MCP_APP_MAX_HTML_BYTES and the structural McpRequestExtraLike
  • 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.