{"_id":"@ascendenceai/cortena-extensions-shared","_rev":"3-590b73670dd1bcad236ce7dc16f945b6","name":"@ascendenceai/cortena-extensions-shared","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.0":{"name":"@ascendenceai/cortena-extensions-shared","version":"0.1.0","license":"SEE LICENSE IN LICENSE","_id":"@ascendenceai/cortena-extensions-shared@0.1.0","maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"bin":{"cortena-openapi":"dist/routes/cli.js"},"dist":{"shasum":"2de0ed84bea878eaf6d0e71fba74266f9e02a49e","tarball":"https://registry.npmjs.org/@ascendenceai/cortena-extensions-shared/-/cortena-extensions-shared-0.1.0.tgz","fileCount":75,"integrity":"sha512-X8UIQYguJXGt9Z5Zj89T0gZf+rpi1mdG42vYgfs8RO7KO0n7ehwPJEQE7sMoB5hVHbDyDPMUOrtJtU08NyULYQ==","signatures":[{"sig":"MEQCIHyc1f5wnIlhznHWxh0Cf9kAnXCDGhMcePZVKBt2RZRjAiBGcB/eRa/6drSTjzsYpxj6sVQKxYK4/gPiUNyXnCBJCQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":416183},"main":"dist/index.js","type":"module","_from":"file:ascendenceai-cortena-extensions-shared-0.1.0.tgz","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./actor":{"types":"./dist/actor/index.d.ts","default":"./dist/actor/index.js"},"./routes":{"types":"./dist/routes/index.d.ts","default":"./dist/routes/index.js"},"./schemas":{"types":"./dist/schemas/common.d.ts","default":"./dist/schemas/common.js"},"./package.json":"./package.json","./schemas/table":{"types":"./dist/schemas/table.d.ts","default":"./dist/schemas/table.js"},"./runtime-config":{"types":"./dist/runtime-config.d.ts","default":"./dist/runtime-config.js"}},"scripts":{"lint":"tsc --noEmit -p tsconfig.test.json","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit -p tsconfig.test.json"},"_npmUser":{"name":"amit_ascendence","email":"connect@mindmentors.net"},"_resolved":"/private/var/folders/yz/wdclk8jx2l3c4bkzs3wg_0z80000gp/T/0e6d40250fbc99fd36e37f319ed5a4e5/ascendenceai-cortena-extensions-shared-0.1.0.tgz","_integrity":"sha512-X8UIQYguJXGt9Z5Zj89T0gZf+rpi1mdG42vYgfs8RO7KO0n7ehwPJEQE7sMoB5hVHbDyDPMUOrtJtU08NyULYQ==","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared"},"_npmVersion":"11.9.0","description":"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.","directories":{},"_nodeVersion":"25.6.1","dependencies":{"yaml":"^2.5.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^3.24.0","vitest":"^3.0.0","express":"^5.1.0","typescript":"^5.7.0","@types/node":"^22.0.0","drizzle-orm":"^0.39.0","@types/express":"^5.0.0"},"peerDependencies":{"zod":"^3.24.0","express":">=5.0.0","drizzle-orm":">=0.39.0"},"peerDependenciesMeta":{"express":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cortena-extensions-shared_0.1.0_1788785613919_0.8228097403895875","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@ascendenceai/cortena-extensions-shared","version":"0.2.0","license":"SEE LICENSE IN LICENSE","_id":"@ascendenceai/cortena-extensions-shared@0.2.0","maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"bin":{"cortena-openapi":"dist/routes/cli.js"},"dist":{"shasum":"6ee5de8fe35e4e934d39187b38b3030c6a7fed7d","tarball":"https://registry.npmjs.org/@ascendenceai/cortena-extensions-shared/-/cortena-extensions-shared-0.2.0.tgz","fileCount":106,"integrity":"sha512-DINOyFuhC5XEm/VNImZ/fghyArs3BoQN5QWfbAl4TTcC8OPQTEXmZHqSTYKVUKHJZzMgjcPENQ/VqvUSDY/dEg==","signatures":[{"sig":"MEUCIQDsUZd+w3YJ93NCQbu+1j3hCPQKy7k+/fmj9fuv6Vqg7gIgZ3OoTDR18UDmKHP4qZ5U9sDRgTpAkPLt5DlV4aw9wXI=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":667872},"main":"dist/index.js","type":"module","_from":"file:ascendenceai-cortena-extensions-shared-0.2.0.tgz","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./actor":{"types":"./dist/actor/index.d.ts","default":"./dist/actor/index.js"},"./authz":{"types":"./dist/authz/index.d.ts","default":"./dist/authz/index.js"},"./routes":{"types":"./dist/routes/index.d.ts","default":"./dist/routes/index.js"},"./schemas":{"types":"./dist/schemas/common.d.ts","default":"./dist/schemas/common.js"},"./package.json":"./package.json","./schemas/table":{"types":"./dist/schemas/table.d.ts","default":"./dist/schemas/table.js"},"./runtime-config":{"types":"./dist/runtime-config.d.ts","default":"./dist/runtime-config.js"},"./templates/authz.sql":"./templates/authz.sql"},"scripts":{"lint":"tsc --noEmit -p tsconfig.test.json","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit -p tsconfig.test.json"},"_npmUser":{"name":"amit_ascendence","email":"connect@mindmentors.net"},"_resolved":"/private/var/folders/yz/wdclk8jx2l3c4bkzs3wg_0z80000gp/T/32fdf5d5d8aef5b0c2ac5d913218d037/ascendenceai-cortena-extensions-shared-0.2.0.tgz","_integrity":"sha512-DINOyFuhC5XEm/VNImZ/fghyArs3BoQN5QWfbAl4TTcC8OPQTEXmZHqSTYKVUKHJZzMgjcPENQ/VqvUSDY/dEg==","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared"},"_npmVersion":"11.9.0","description":"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.","directories":{},"_nodeVersion":"25.6.1","dependencies":{"yaml":"^2.5.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^3.24.0","vitest":"^3.0.0","express":"^5.1.0","typescript":"^5.7.0","@types/node":"^22.0.0","drizzle-orm":"^0.39.0","@types/express":"^5.0.0"},"peerDependencies":{"zod":"^3.24.0","express":">=5.0.0","drizzle-orm":">=0.39.0"},"peerDependenciesMeta":{"express":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cortena-extensions-shared_0.2.0_1789364268516_0.5373162232587894","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@ascendenceai/cortena-extensions-shared@0.4.0","bin":{"cortena-openapi":"dist/routes/cli.js"},"bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"dist":{"shasum":"c28a3bf670c8a61e89649e04a7f0aab3b1173801","tarball":"https://registry.npmjs.org/@ascendenceai/cortena-extensions-shared/-/cortena-extensions-shared-0.4.0.tgz","fileCount":106,"integrity":"sha512-OW1NM98pQWlPXEu/QPwSlLrbeea597kNi8dcSHQpbP7ezvWx1pdCY3PpgLC0yxsZF3gZmcBVXY5DH417Exod7w==","signatures":[{"sig":"MEYCIQD/YBTL0/7IetsaMBkwKPmCKB3yeE5G1X1oy/wLnD6nigIhAPGxs5jq4UeobgmjR68yMBc2NIz75PioUAR9YfoKtqsN","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCO/dV+I+vnqO/JkiOu3oBXQW5CBnJen/MIIOcH8Cr0ggIhAKpaOiW14oH9ZZACSyGu66zNwqpKNU9Sgg/unv7E+jRS"}],"unpackedSize":709227},"main":"dist/index.js","name":"@ascendenceai/cortena-extensions-shared","type":"module","_from":"file:ascendenceai-cortena-extensions-shared-0.4.0.tgz","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./actor":{"types":"./dist/actor/index.d.ts","default":"./dist/actor/index.js"},"./authz":{"types":"./dist/authz/index.d.ts","default":"./dist/authz/index.js"},"./routes":{"types":"./dist/routes/index.d.ts","default":"./dist/routes/index.js"},"./schemas":{"types":"./dist/schemas/common.d.ts","default":"./dist/schemas/common.js"},"./package.json":"./package.json","./schemas/table":{"types":"./dist/schemas/table.d.ts","default":"./dist/schemas/table.js"},"./runtime-config":{"types":"./dist/runtime-config.d.ts","default":"./dist/runtime-config.js"},"./templates/authz.sql":"./templates/authz.sql"},"license":"SEE LICENSE IN LICENSE","scripts":{"lint":"tsc --noEmit -p tsconfig.test.json","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit -p tsconfig.test.json"},"version":"0.4.0","_npmUser":{"name":"amit_ascendence","email":"connect@mindmentors.net"},"homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","_resolved":"/private/var/folders/yz/wdclk8jx2l3c4bkzs3wg_0z80000gp/T/0a7e5d51319117863beb733e971a0029/ascendenceai-cortena-extensions-shared-0.4.0.tgz","_integrity":"sha512-OW1NM98pQWlPXEu/QPwSlLrbeea597kNi8dcSHQpbP7ezvWx1pdCY3PpgLC0yxsZF3gZmcBVXY5DH417Exod7w==","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared"},"_npmVersion":"11.9.0","description":"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.","directories":{},"maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"_nodeVersion":"25.6.1","dependencies":{"yaml":"^2.5.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^3.24.0","vitest":"^3.0.0","express":"^5.1.0","typescript":"^5.7.0","@types/node":"^22.0.0","drizzle-orm":"^0.39.0","@types/express":"^5.0.0"},"peerDependencies":{"zod":"^3.24.0","express":">=5.0.0","drizzle-orm":">=0.39.0"},"peerDependenciesMeta":{"express":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/cortena-extensions-shared_0.4.0_1790491378269_0.8649573729574114"}}},"time":{"created":"2026-09-07T12:53:33.769Z","modified":"2026-09-27T06:42:58.520Z","0.1.0":"2026-09-07T12:53:34.038Z","0.2.0":"2026-09-14T05:37:48.664Z","0.4.0":"2026-09-27T06:42:58.351Z"},"bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"license":"SEE LICENSE IN LICENSE","homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared"},"description":"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.","maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"readme":"# @ascendenceai/cortena-extensions-shared\n\nShared building blocks for a Cortena extension backend. What is documented here\nis the route definition and the actor; the package also carries the common zod\nschemas (`./schemas`, `./schemas/table`) and the MCP Apps resource builder.\n\n## Routes: one definition, four surfaces\n\nThe protocol (`cortena-docs/HOW-TOs/how-to-create-a-cortena-extension.html`,\n§14.6 and §15.2) asks for one object per route, from which the Express route,\nthe MCP tool, the OpenAPI 3.1 document and the Engram catalogue row are all\ngenerated. That is what `defineRoute` and `defineRoutes` are: not three\ngenerators reading three declarations, but one array read three ways.\n\nThe agent never reads your OpenAPI document or your `tools/list`. It reads an\nEngram catalogue row, ingested from the document you serve — so the document is\nnot paperwork, it is the only description of your extension the agent will ever\nsee.\n\n### Declaring a route\n\n```ts\nimport { defineRoute } from '@ascendenceai/cortena-extensions-shared/routes';\nimport { z } from 'zod';\n\nexport const listTasks = defineRoute({\n  method: 'GET',\n  path: '/v1/orgs/:orgId/projects/:projectId/tasks',\n  tags: ['tasks'],\n  summary: 'List tasks in a project',\n  description: 'Returns tasks newest first, keyset-paginated by `cursor`.',\n  whenToUse: 'Use to find a task id before updating, commenting on or closing it.',\n  request: z.object({\n    orgId: z.string().describe('Org id from the caller’s token'),\n    projectId: z.string().describe('Project id from tasks_project_list'),\n    status: TaskStatusSchema.optional().describe('Filter to one status'),\n    limit: z.coerce.number().int().max(100).default(20).describe('Rows per page, max 100'),\n  }),\n  response: z.object({\n    rows: z.array(TaskRow).describe('Matching tasks, newest first'),\n    cursor: z.string().nullable().describe('Pass as `cursor` for the next page'),\n  }),\n  examples: [{\n    request: { orgId: 'o-1', projectId: 'p-42', status: 'todo', limit: 2 },\n    response: { rows: [{ id: 't-1', title: 'Fix login', status: 'todo' }], cursor: null },\n  }],\n  mcp: { toolName: 'tasks_task_list' },\n  handler: async (input, ctx) => listTasksFor(ctx.actor, input.projectId, input.limit),\n});\n```\n\n| Field | Who reads it |\n| --- | --- |\n| `method`, `path` | the router, the document, and the catalogue's identity key (`component + method + canonicalPath`) |\n| `request` / `response` | the route's validation, the MCP tool schema, and the row's `params` and `returns` |\n| `summary` | the search row's one-line intent — about 400 bytes, and the agent pays for it every turn |\n| `whenToUse` | **mandatory.** The sentence that makes the agent pick this capability over the nine others in the search result |\n| `description` | the OpenAPI document, for a human |\n| `examples[]` | the document, one example into the describe row, and the conformance test |\n| `access` | derived from the method; an override is one explicit, reviewable line |\n\n`request` may be written flat, as above — keys named in the path become path\nparameters, the rest become the query on a read and the body on a write — or\nsplit explicitly as `{ params, query, body }`. Both are the same declaration.\n\n`handler` receives the validated input flat, and a context carrying the actor\n(§18), the framework `req`/`res`, and the same values split by where they came\nfrom. Throw a `RouteError(status, code, message)` to choose a status; anything\nelse becomes an opaque 500, with the cause bounded to 2 kB.\n\n### `whenToUse` is mandatory, and examples are checked when you declare them\n\n`defineRoute` throws at import time on a missing `whenToUse`, a missing\n`summary`, no example, or an example that does not validate against its own\nschema. An example that has quietly stopped being true is worse than none,\nbecause it is the one thing in the row the agent trusts literally.\n\n### `access` comes from the method\n\n`GET` and `HEAD` are `read`; `POST`, `PUT`, `PATCH` and `DELETE` are `write`.\nA read skips the broker's confirmation step, so a capability marked `write`\nthat only reads costs a user-visible confirmation and an extra model call to\nfetch something that changes nothing.\n\nThe one legitimate override is a `POST` that is really a query, and it must say\nwhy:\n\n```ts\naccess: 'read',\naccessReason: 'A search: the body is the query, and it changes nothing.',\n```\n\nA read `POST` also answers 200 rather than 201 — it creates nothing.\n\n### The registry\n\n```ts\nimport { defineRoutes } from '@ascendenceai/cortena-extensions-shared/routes';\n\nexport const registry = defineRoutes([listTasks, createTask, searchTasks], {\n  openapi: {\n    info: { title: 'CortenaTasks', version: '0.1.0', description: 'Tasks, projects and decisions for an org.' },\n    // servers is optional; omitted it is `${EXTENSION_BASE_URL}`, substituted at runtime\n    xCortena: {\n      id: 'tasks',\n      icon: 'list-checks',\n      mcpUrl: '${EXTENSION_BASE_URL}/mcp',\n      mcpApps: { exempt: [{ route: '/admin/*', reason: 'full permission matrix; not a chat-sized surface' }] },\n    },\n  },\n  middleware: [authenticate, requireOrgMatch, requireLicense('tasks')],\n});\n\napp.use(registry.router);   // every route, validated\nregistry.serve(app);        // GET /openapi.json, unauthenticated, beside /health\nregistry.tools();           // the MCP tool list, from the same definitions\nregistry.openapi();         // the OpenAPI 3.1 document\n```\n\nThe identity block **is** the manifest. There is no `cortena.plugin.json`; the\naudit fails an extension that ships one (P-40). `x-cortena.id` must match the id\nin the licence check, the cortena-auth catalogue row and the AgentTemplate slug.\n\n`GET /openapi.json` substitutes `${EXTENSION_BASE_URL}` from\n`serve({ baseUrl })`, then `process.env.EXTENSION_BASE_URL`, then the host the\nrequest arrived on.\n\n### Security: the bearer is declared for you\n\nEvery generated document carries the one scheme and requires it everywhere:\n\n```yaml\nsecurity:\n  - cortenaAuth: []\ncomponents:\n  securitySchemes:\n    cortenaAuth:\n      type: http\n      scheme: bearer\n      bearerFormat: JWT\n      description: |-\n        Issued by `cortena-auth`… A token may additionally carry an RFC 8693\n        `act` claim naming the software holding it: an agent acting on behalf of\n        the user in `sub`…\n```\n\nIt is emitted rather than declared because it is not the extension's decision —\nevery Cortena extension is behind the same `cortena-auth` bearer, and a document\nthat omits it is a document a broker reads as an open API.\n\nA route that is genuinely unauthenticated opts out, and the operation carries\n`security: []`:\n\n```ts\nsecurity: false,   // /health, and nothing else so far\n```\n\nThere is no `security: true`. A route that authenticates says nothing and\ninherits the document's requirement, because a per-route opt-in is a requirement\nthat is silently missing the day somebody forgets it.\n\n### Errors: everything that escapes is the envelope (§15.6, P-22)\n\nThe broker passes an extension's own envelope through untouched and wraps\nanything else — HTML, a stack trace, a bare string — in a generic\n`upstream_error` with the body cut to 2 kB. That wrapping path is a safety net\nthat should never trigger, and it triggers the moment an extension throws and\nExpress serialises the exception. So nothing leaves a mounted route as anything\nbut the envelope:\n\n| What happened | What the caller gets |\n| --- | --- |\n| The request failed the declared schema | `400 validation_failed`, `message` is `field: reason`, `details.fieldErrors` carries the zod `flatten()` issues |\n| The handler threw `RouteError(409, 'conflict', …)` | exactly that — a chosen status, a chosen code, the sentence it was given |\n| `express.json()` met a malformed body | `400 invalid_json`, `body: the request body is not valid JSON` |\n| **Anything else at all** | `500 internal_error`, `<Extension> could not complete <the route's summary>.` — and nothing else |\n\nThat last row is the point. No `details`, no `cause`, no stack, no driver\nmessage: `password authentication failed for user \"cortena_test_app\"` is what\n`pg` says out loud, and bounding it to 2 kB does not make it safe to hand to a\nmodel or paint on a user's screen. The exception goes to the **active span and\nthe log** instead (§15.7.4), which is where the person who can act on it will\nlook:\n\n```ts\napp.use(express.json());\napp.use('/v1', registry.router);          // mountRoutes adds its own catch-all\nregistry.serve(app);                      // …and one on the app, for express.json()\napp.get('*splat', serveSpa);\napp.use(errorMiddleware({ extension: 'CortenaTasks' }));   // last, if you mount your own stack\n```\n\nTwo handlers, not one, because they catch different things. The router's\ncatches its own routes and their middleware. The app's is the only one that can\ncatch `express.json()`, which throws *before* the router is reached — and\nExpress skips a `Router` (a three-argument middleware) once an error is in\nflight, so without an app-level handler a malformed body is answered with\nExpress's default HTML page. `registry.serve(app)` mounts it for you; pass\n`{ errorHandler: false }` if you would rather place it yourself, last.\n\n**Where the detail goes.** §15.7.1 puts the OpenTelemetry bootstrap in\n`@cortena/observability`, published from the `cortena` repository. That package\nis **not published yet** (checked 2026-09-07), so the sink is a hook rather than\nan import:\n\n```ts\ndefineRoutes(routes, { openapi: { … }, onError: (report) => logger.error(report) });\n```\n\nLeft out, the default sink records the exception on the active span through\n`@opentelemetry/api` when that package resolves at runtime — API only, so it is\na no-op until a bootstrap starts a tracer — and writes one structured JSON line\ncarrying `trace_id`, `span_id`, `http.route` and the stack. When\n`@cortena/observability` ships, pass its logger as `onError` and nothing else\nchanges.\n\nOnly a 5xx is reported. A `RouteError` chose its status deliberately and a\nmalformed body is the caller's to fix; logging either as an unhandled exception\nis how an error log becomes something nobody reads.\n\n### The committed document and the drift check\n\n```sh\ncortena-openapi write --entry ./dist/routes.js            # renders docs/openapi.yaml\ncortena-openapi check --entry ./dist/routes.js            # exits 1 with a diff on drift\ncortena-openapi write --out docs/openapi.json --json      # JSON instead of YAML\n```\n\n`--entry` is a module exporting the registry (as the default export, or as\n`registry`). Both flags can live in a `cortena-openapi.json` beside the package\ninstead:\n\n```json\n{ \"entry\": \"./dist/routes.js\", \"out\": \"docs/openapi.yaml\" }\n```\n\nWire `check` into CI (§22.3). It fails rather than regenerating and pushing: a\nroute's parameters moving is an API change and belongs in the diff a human\napproves, and a generated file anyone may edit stops being generated within\nabout two commits.\n\n## Runtime configuration for the SPA (§19.2, P-27)\n\n`VITE_AUTH_SERVICE_URL` is substituted by Vite **at build time** and hashed into\nthe bundle's filename, so a ConfigMap cannot reach it. Two environments then\nneed two images of the same commit — which is the thing §29's immutable-SHA tag\nexists to prevent. The fix is the one cortenaweb already ships: the API serves\nthe configuration, the SPA reads it on boot.\n\n```ts\n// functions/src/index.ts — beside /health, unauthenticated\nimport { serveRuntimeConfig } from '@ascendenceai/cortena-extensions-shared';\n\nserveRuntimeConfig(app, {\n  keys: ['AUTH_SERVICE_URL', 'CORTENAWEB_ORIGIN', 'FIREBASE_AUTH_DOMAIN'],\n  required: ['AUTH_SERVICE_URL'],\n});\n```\n\n```html\n<!-- web/index.html — before the module bundle, and not deferred -->\n<script src=\"/runtime-config.js\"></script>\n<script type=\"module\" src=\"/src/main.tsx\"></script>\n```\n\n```ts\n// web/src/config.ts\nimport { z } from 'zod';\nimport { readRuntimeConfig } from '@ascendenceai/cortena-extensions-shared-web';\n\nexport const config = readRuntimeConfig(\n  z.object({\n    AUTH_SERVICE_URL: z.string().url().describe('cortena-auth, for sign-in and refresh'),\n    CORTENAWEB_ORIGIN: z.string().url().describe('The parent frame, for postMessage and CSP'),\n  }),\n  { fallback: { AUTH_SERVICE_URL: 'http://localhost:3200' } },   // `pnpm dev`, no backend\n);\n```\n\n**Allowlist only, and names that look like credentials are refused at mount\ntime.** `keys` is the whole security model — there is no \"everything with a\nprefix\" mode, because a prefix rule publishes whatever somebody names with that\nprefix next year. A key matching `SECRET`, `TOKEN`, `PASSWORD`, `JWT`,\n`DATABASE_URL` and the rest throws when the route is mounted, not in a browser.\nSecrets stay in a Kubernetes Secret and are read by the backend (§19.3).\n\nThe response is `no-store`. A CDN or a service worker holding yesterday's\nconfiguration is exactly the failure this file removes, and it would be\nindistinguishable from a bad deploy.\n\n`readRuntimeConfig` throws at boot naming the key and where it comes from. The\nfailure it replaces is silent: an unset `VITE_` variable becomes the string\n`undefined` in the bundle, and the first symptom is a fetch to\n`undefined/v1/orgs/…` on a screen three clicks in, blamed on the screen.\n\n### The ConfigMap, and the annotation without which nothing happens\n\nAll non-secret configuration is rendered into a ConfigMap and consumed with\n`envFrom`, so a change is a `helm upgrade` and never an image rebuild. **A\nConfigMap edit does not restart pods** — without the `checksum/config`\nannotation, the new values appear whenever some unrelated thing restarts the\npod, which is to say they appear to do nothing, for a while, and then work.\n\n```yaml\n# CortenaEnterprise/k8s/helm/extensions/<name>/templates/configmap.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: ext-{{ .Chart.Name }}-config\ndata:\n  AUTH_SERVICE_URL: {{ .Values.authServiceUrl | quote }}\n  CORTENAWEB_ORIGIN: {{ .Values.cortenawebOrigin | quote }}\n  EXTENSION_BASE_URL: {{ .Values.publicHostname | printf \"https://%s\" | quote }}\n  OTEL_EXPORTER_OTLP_ENDPOINT: {{ .Values.otelEndpoint | quote }}\n```\n\n```yaml\n# …/templates/deployment.yaml\nspec:\n  template:\n    metadata:\n      annotations:\n        # The line that makes a ConfigMap edit take effect: the pod template\n        # hash changes, so `helm upgrade` rolls the deployment. Without it a\n        # config change is invisible until something else restarts the pod.\n        checksum/config: {{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}\n    spec:\n      containers:\n        - name: api\n          envFrom:\n            - configMapRef:\n                name: ext-{{ .Chart.Name }}-config\n            # Secrets are their own reference and never inlined here (§19.3).\n            - secretRef:\n                name: cortena-{{ .Values.environment }}-{{ .Chart.Name }}-secrets\n```\n\nNo environment-specific literal belongs in the template either (§19.1, P-27).\n`.Values` is the seam: a new environment — or a new customer tenant — is a\nvalues file, not a code change. A hostname literal in the non-test branch of a\nchart is the line that breaks a tenant deployment, because the branch is never\nexecuted until the day it matters.\n\n## MCP tool parity, and the test that holds it (§14.6, P-17)\n\nEvery action the UI can take is available over MCP, barring none. One tool per\nroute, generated from the same definition, and a test that fails when a route is\nadded without one.\n\n### One line to satisfy it\n\n```ts\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { mountMcpTools } from '@ascendenceai/cortena-extensions-shared';\n\nexport const mcpServer = new McpServer({ name: 'tasks', version: '0.2.0' });\nmountMcpTools(mcpServer, registry, { actor: (extra) => actorFor(extra) });\n```\n\n`mountMcpTools` registers one tool per non-waived route: the route's own zod\nschemas as the tool's input (the schemas themselves, not a rendering of them),\nthe route's `summary` and `whenToUse` as the description, and a handler that\nsplits the flat params object the agent sends back into path, query and body,\nvalidates each against the same schema the router uses, and calls the route's\nhandler.\n\n`actor` is where the caller comes from. §18 requires every write to record its\nactor, and on this lane the \"request\" is a tool call: a server built per session\n(what Tasks does) has the user in a closure and passes `() => actor`; one that\nauthenticates per call reads `extra.authInfo`, which is what the default\n`actorFromMcpExtra` does. Either way the actor is stamped `via: 'agent'` unless\na signed claim says otherwise — a tool call is an agent acting, and recording it\nas the person acting directly is the one attribution error §18 cannot tolerate.\n\n`mountMcpTools` is duck-typed against the server: `@modelcontextprotocol/sdk` is\ndeliberately **not** a dependency of this package.\n\n### The assertion\n\n```ts\nimport { assertToolParity } from '@ascendenceai/cortena-extensions-shared';\n\nit('every mutating route has an MCP tool', () => {\n  assertToolParity(createApp(), mcpServer);\n});\n```\n\nIt walks the *running* app against the *running* server. That is the point: the\nOpenAPI document and the tool list come out of one generator, so they agree with\neach other by construction and would agree just as happily about a route neither\nhas heard of. Three things are asserted, and each catches a different way of\ngetting it wrong:\n\n1. **Every mutating route on the app is a registry route.** A hand-written\n   `app.post('/v1/orgs/:orgId/audit', …)` fails with its path. It works, it\n   ships, and the agent cannot reach it — which is the failure §14.6 exists to\n   prevent. Read routes are advisory; `POST`, `PUT`, `PATCH`, `DELETE` and\n   anything declared `access: 'write'` are mandatory.\n2. **Every registry route has a tool on the server with an identical input\n   schema**, or a recorded waiver. The JSON Schema the registry emits is\n   deep-compared with the one the server holds, property by property, so a\n   `.max(200)` added to the route and not to a hand-written tool is named\n   rather than merely counted.\n3. **Every tool on the server comes from the registry.** A tool registered by\n   hand fails by name: it has no OpenAPI operation, no examples and no catalogue\n   row, and its schema drifts the first time the route it wraps changes.\n\nEvery violation is reported at once, in one `AssertionError`. A parity failure\nis usually a batch — a branch that added four routes and registered none of them\n— and an assertion that stops at the first turns that into four runs.\n\n```ts\nconst report = assertToolParity(app, mcpServer);\n// { routes, tools, exclusions: [{ method, path, reason }], violations }\n```\n\n`toolParityReport(app, server, options)` is the same walk without the throw.\n\n### Waivers\n\n```ts\ndefineRoute({\n  method: 'POST',\n  path: '/v1/orgs/:orgId/tasks/import',\n  mcp: { exclude: true, reason: 'multipart upload; there is no file for an agent to send' },\n  …\n});\n```\n\nThe reason is mandatory: `exclude: true` on its own is a **definition-time\nerror**, not a quiet omission. It reaches the OpenAPI operation as\n`x-cortena-tool-exempt-reason`, which is the field the conformance audit reads\nfor P-17, and the parity test prints the waived list on every run:\n\n```\n[tool-parity] 1 route(s) waived from MCP tool parity (§14.6):\n[tool-parity]   POST /v1/orgs/:orgId/tasks/import — multipart upload; there is no file for an agent to send\n```\n\nPass `{ print: false }` to silence it; the exclusions are in the returned report\neither way. `mcp: { exempt: { reason } }` is the older spelling of the same\ndeclaration and still works.\n\n### `destructiveHint`: say so, do not let the method guess\n\n`readOnlyHint` comes from `access` and needs nothing. `destructiveHint` is\nderived from `method === 'DELETE'`, which is right for a CRUD API and wrong for\nthis one: the acts a Cortena extension cannot undo are mostly `POST`s to a verb\n— retiring a case, signing a release, authorising security testing — so a host\nthat confirms only deletes confirms none of them.\n\n```ts\nmcp: { toolName: 'assure_case_retire', destructive: true },\n```\n\n`mcp.destructive` overrides the derivation in both directions. Set it where the\nact cannot be undone by calling something else; it is not \"this writes\", which\nis what `access` already says.\n\n### Options, and the two escape hatches\n\n```ts\nassertToolParity(app, server, { registry, listTools: () => tools, print: false });\n```\n\n- `registry` — normally unnecessary. `defineRoutes` marks the router it builds,\n  so the walker finds every registry the app has mounted, by *identity* rather\n  than by matching path strings. Pass it for a registry mounted some other way.\n- `listTools` — how the server's tools are read when this module cannot\n  introspect it. It tries an injected `listTools()` first, then one the object\n  offers, then `@modelcontextprotocol/sdk`'s private `_registeredTools` (a zod\n  schema per tool, which is converted with the same `toJsonSchema` the registry\n  uses, so the comparison is like for like). Disabled tools are left out, as\n  `tools/list` leaves them out.\n\nOne caveat worth knowing: Express 5's `Layer` no longer keeps the path it was\nmounted at, so a route inside a hand-made sub-router is reported with `…` in\nplace of the prefix and `exactPath: false`. It never affects the verdict —\nregistry routes are recognised by the identity of the router they came out of —\nonly how a violating path is spelled.\n\n## The actor: on behalf of the user (§18, P-26)\n\nAgent parity is worthless if nobody can tell afterwards what the agent did, and\nattribution is worthless if each extension invents its own column. So there is\none resolver, four columns and one label, and none of them is a per-extension\ncopy (§18.1).\n\n```ts\nimport { resolveActor, requireActor, withActor, actorLabel } from '@ascendenceai/cortena-extensions-shared';\n\napp.use(authenticate);        // yours: verifies the cortena-auth JWT onto req.user\napp.use(requireActor());      // no unattributed writes\n\nconst actor = resolveActor(req);\nawait db.insert(tasks).values(withActor({ title }, actor));\nactorLabel(actor);            // \"Ashish (Agent)\"\n```\n\n### Principal and via are two questions\n\n`principalKind` is *who* — a person, or a service account such as a channel\nruntime. `via` is *how* — itself, or through an agent. A service account is a\nprincipal like a user and never a third `via`, because the case the channel lane\nis made of is a service account acting *through* an agent, and collapsing the\ntwo questions leaves nowhere to put it.\n\n| principalKind | via | rendered |\n| --- | --- | --- |\n| `user` | `user` | Ashish |\n| `user` | `agent` | Ashish (Agent) |\n| `service` | `user` | Channel runtime |\n| `service` | `agent` | Channel runtime (Agent) |\n\n### Where the actor comes from, in order\n\n1. **An `Actor` an earlier middleware already resolved.** Once per request, so\n   every write in it agrees.\n2. **The verified token decides the identity.** `sub` is the principal,\n   `displayName` the name, `principalType: 'service'` makes it a service\n   account (cortena-auth `signServiceToken`). *Nothing else may name a user.*\n3. **A signed claim decides delegation.** The RFC 8693 `act` claim that\n   cortena-auth's `issueMcpAccessToken` puts on every MCP access token —\n   `act: { sub, client_id, externally_routed, model? }` — means `via: 'agent'`\n   with `act.sub` as the agent; so does a service token minted with an\n   `agentId`. Both are stamped `delegationProven: true`.\n4. **The broker headers may only *add* the agent marker.** `x-cortena-via:\n   agent` and `x-cortena-agent-id` raise `via` to `'agent'` and are stamped\n   `delegationProven: false`. They can never lower a signed `'agent'` back to\n   `'user'`, and they can never name a principal.\n5. **`x-cortena-user-id` names nobody** unless you pass\n   `{ trustBrokerHeaders: true }`, and then only when no verified token\n   contradicts it.\n\nThe token wins over the headers because the token is signed and the headers are\nnot. `x-cortena-user-id` is one half of a pair — `x-cortenacore-token` is the\nother — and the pairing is checked by the *Controller* against its session\nstore, on the pod → Controller hop. By the time a call reaches an extension the\nController has replaced the pair with a real credential\n(`Authorization: Bearer <principal jwt>`), so on our hop the header is unsigned\nand unpaired: honouring it for identity would be honouring the caller's own\nclaim about who they are.\n\nThe agent *marker* is the exception, and only in the direction that narrows.\nAn agent gets exactly the permissions of the principal it acts for and never\nmore (§13.2), so forging `x-cortena-via: agent` mislabels your own write rather\nthan escalating anything — while the in-product lane (`/agui/run`,\n`/tools/invoke`) still carries no `act` claim (§18.1), which makes the header\nthe only marker it has. `delegationProven` records which of the two it was, so\n§14.5's \"record what you know, and stamp the record with the fact that you could\nnot prove it\" is a field rather than a convention.\n\n### Refusals\n\n`requireActor()` is a 403 with a named code, twice, because they are different\nmistakes fixed differently:\n\n- `actor_required` — a mutating request nobody can be named for. Reads pass\n  through unattributed; a route that declares `principals` does not.\n- `principal_not_allowed` — a service account on a route declaring\n  `principals: ['user']`.\n\n`principals` is declared on the route, not checked in the handler, so the same\nrefusal applies over REST and over MCP (§13.2) — and `mountRoutes` enforces it\ntoo, so the declaration is a rule even before `requireActor()` is mounted. Use\nit where a person is the point: accepting an invitation, agreeing to something,\nanything whose record has to name someone who can be asked about it afterwards.\n\n```ts\ndefineRoute({\n  method: 'POST',\n  path: '/v1/orgs/:orgId/invites/:inviteId/accept',\n  principals: ['user'],\n  // ...\n});\n```\n\n`checkActor(actor, { method, principals })` is the same two checks as a plain\nfunction, for an MCP tool handler that is not in an Express chain.\n\n### The four columns\n\n```ts\nexport const tasks = pgTable('tasks', {\n  id: text('id').primaryKey(),\n  title: text('title').notNull(),\n  ...actorColumns(),\n}, (t) => actorTableExtras('tasks', t));\n```\n\n`actor_user_id` and `actor_via` are `not null` — a row that cannot say who made\nit is what the rule exists to prevent. `actor_agent_id` and `granted_by` are\nnullable: there is no agent on a direct write, and `granted_by` — which\nassignment allowed it, `'direct'` or the group that carried the role (§13.4) —\nis stamped by the policy function once `may()` has answered, with\n`withGrantedBy(actor, ...)`. `actorTableExtras` adds the index on\n`actor_user_id` and the `check (actor_via in ('agent','user'))`.\n\n`withActor(row, actor)` fills all four from one object, so three of the four\ncannot be passed and the fourth lost silently. Drizzle is an *optional* peer\ndependency, loaded on demand the way `mountRoutes` loads Express: a backend with\nno ORM can still import the rest of the package, and can write the four columns\nby hand.\n\n### The label\n\n`actorLabel(actor)` is `\"Name (Agent)\"` when `via === 'agent'` and the plain\nname otherwise, with the id as the fallback name so a row never renders as an\nempty cell. §18.2 wants it in the activity feed, comments and history and not\nonly in the audit table — the user reads the feed, so attribution that exists\nonly in the audit table is not attribution.\n\n## Authorisation: who may do what (§14, P-14, P-42, P-43)\n\ncortena-auth provides identity only — who the user is, which org they are in and\nwhether that org holds a licence. It does not define, store or enforce any\nextension role or permission (DESIGN-D11). Authorisation is yours.\n\nWhat is **not** yours is a seventh implementation of it. Seven extensions each\nwriting a role table, a matrix, a `may()`, seven admin routes and a `grantedBy`\nstamp is seven chances to disagree about the one thing that decides who may act.\nSo the vocabulary stays the extension's and the deciding lives here (EXTBP-29).\n\n```ts\nimport {\n  definePolicy, createAuthz, authzTables, drizzleAuthzStore, adminRoutes,\n} from '@ascendenceai/cortena-extensions-shared';\n\n// 1. Your vocabulary, declared once. The matrix an org starts with.\nexport const policy = definePolicy({\n  extensionId: 'tasks',\n  roles: ['admin', 'editor', 'viewer'],\n  capabilities: ['tasks.task.read', 'tasks.task.write'],\n  defaults: {\n    admin:  ['tasks.task.read', 'tasks.task.write'],\n    editor: ['tasks.task.read', 'tasks.task.write'],\n    viewer: ['tasks.task.read'],\n  },\n});\n\n// 2. Your tables, in your database. `authzTables` is the drizzle fragment;\n//    `authzMigrationSql()` is the same two tables as the migration you apply.\nexport const { roleAssignments, permissions } = authzTables(schema);\n\n// 3. One decider.\nexport const authz = createAuthz({\n  policy,\n  store: drizzleAuthzStore({ db, tables: { roleAssignments, permissions } }),\n});\nexport const { may, requireCapability, withCapability } = authz;\n```\n\n### One policy function, two callers\n\n```ts\nrouter.patch('/tasks/:id', requireCapability('tasks.task.write'), handler);\n// …and nothing else, because `mountMcpTools` runs a route's own middleware.\n```\n\n`requireCapability` is declared in a route's `middleware`, and that is the whole\nof it for a route mounted through `defineRoutes`: `mountRoutes` runs it on the\nREST request and `mountMcpTools` runs it on the tool call, so both lanes ask one\n`may()` and refuse with one envelope. `withCapability(capability, tool)` is the\nsame decision for a tool registered outside the registry — §14.6's parity test\nrefuses a tool with no route, so reach for it only with that exemption declared.\n\nOn an allow, the guard stamps `granted_by` onto `req.actor` (and `res.locals`)\nthrough the actor module's `withGrantedBy`, so `withActor(row, ctx.actor)`\nrecords *which assignment allowed it* without the handler asking twice (§20.2).\n\n### `may(actor, capability, target?)`\n\n```ts\nconst { allowed, grantedBy, role } = await authz.may(actor, 'tasks.task.write', { orgId });\n```\n\n`grantedBy` is `'direct'`, the id of the group that carried the role, or\n`'org-role:owner'`; `null` when the answer is no. Every read is org-scoped —\n`may()` refuses rather than deciding when no org is in scope, because an\nassignment table without an org filter is the shape of every cross-tenant read\nthere has ever been (§4).\n\nThe `target` is threaded, not merely carried: whatever you pass beyond `orgId`\nand `orgRole` reaches `AuthzStore.assignmentsFor(orgId, principals, target)`.\nThe two tables here have no scope column, so `drizzleAuthzStore` ignores it\nunless you give it a `scope`:\n\n```ts\ndrizzleAuthzStore({\n  db, tables,\n  scope: (target) =>\n    typeof target['projectId'] === 'string'\n      ? eq(roleAssignments.projectId, target['projectId'])\n      : undefined,\n});\n\nrouter.patch('/projects/:projectId/tasks/:id',\n  requireCapability('tasks.task.write', { target: (req) => ({ projectId: req.params.projectId }) }),\n  handler);\n```\n\n### Groups are cortena-auth's, and they are off until PLATFORM-85\n\n```ts\ncreateAuthz({\n  policy, store,\n  groups: {\n    enabled: config.TASKS_AUTHZ_GROUPS === 'on',\n    fetchUserGroups: cortenaAuthGroups({ baseUrl: config.AUTH_SERVICE_URL, token }),\n  },\n});\n```\n\nGroup definitions and membership live in cortena-auth and are read, never\nwritten (§14.4, P-42). Until PLATFORM-85 ships the directory the flag stays off\nand effective roles are direct assignments alone — an extension shipping today\nis correct, and the day the endpoint exists one flag unions group roles in.\n\nNothing is ever flattened into stored per-user rows. The union happens inside\n`may()`, on every call, because the copy is right until somebody joins or leaves\na group and then it is wrong silently and everywhere at once (P-43). The read is\ncached for the life of a request always, and across requests only if you ask:\n`groups.ttlMs` is **0** by default, because PLATFORM-85's acceptance is that\nremoving somebody from a group refuses their *next* request, and a window\nnobody configured is a window nobody knows about.\n\nA directory that cannot be reached fails the decision. `may()` rejects, the\nguard hands it to the error middleware, and the caller gets a 500 — it does not\nfall back to direct assignments (a permission silently lost mid-outage) and it\ndoes not fall back to allowing (an outage in cortena-auth widening every\nextension at once).\n\n### The org owner gets nothing, unless you say so\n\nBeing the org owner or admin in cortena-auth grants **no** capability here. §14\nis that cortena-auth does not decide, and a default that made every org owner an\nadministrator of every extension they licensed would be it deciding.\n\n§14.4's bootstrap — a freshly licensed extension has an empty assignment table,\nso nobody can open the admin screen to grant anybody anything — is an explicit\noption, written where a reviewer sees it:\n\n```ts\ndefinePolicy({ …, orgRoles: { owner: ['admin'] } });\n```\n\nIt stops applying the moment the extension has an assignment of its own\n(`orgRolesUntilFirstAssignment`, default true), and a role reached this way is\nreported as `org-role:owner` rather than `direct` — an administrator must not be\nshown a row to remove that is not in the table.\n\n### The §14.3 admin routes\n\n```ts\nexport const registry = defineRoutes(\n  [...mine, ...adminRoutes({ authz, directory, licence })],\n  { openapi: { … } },\n);\n```\n\nSeven route definitions, mounted under `/v1/orgs/:orgId/admin`, with the\nrequest and response schemas cortena-ui's `AdminPermissions` reads: the members\nlist (a join — the person and their groups are cortena-auth's and read-only, the\nroles beside them are yours), the two `PATCH`es that set a user's or a group's\n`extensionRoles`, the licence read-through, and the matrix read and write.\n\n`effectiveRoles` is rendered as `{ role, source }`, where `source` is `'direct'`\nor `{ group: { id, name } }` — the composition's `AdminRoleSource`, because an\nadministrator shown `g-7f2a` cannot tell what to remove. It is computed per\nrequest and is never a stored table.\n\nThe three capabilities that guard the console itself — `<ext>.admin.read`,\n`<ext>.admin.permissions.write`, `<ext>.admin.roles.write` — are added to your\npolicy by `definePolicy`, so there is no way to ship a console anybody can open.\n\n`directory.listGroups` is optional, and its absence removes the two group\nroutes: an extension whose tenant has no group directory gets a screen with no\ngroup affordances rather than an empty tab, which is what `AdminPermissions`\ndoes with an absent `api.groups`.\n\n### The schema\n\n`authzTables(schema)` and `authzMigrationSql({ schema })` are the same two\ntables, and `templates/authz.sql` in this package is that function's output with\n`%%snake%%` where the schema name goes — a test fails when the committed file\nstops matching, the same arrangement `cortena-openapi check` has with the\ncommitted document.\n\n| table | what it holds |\n| --- | --- |\n| `role_assignments` | `(org_id, role, principal_kind in ('user','group'), principal_id)`, unique together, plus §20.2's actor columns and `granted_at` |\n| `permissions` | `(org_id, capability, roles text[])`, unique together — one row per capability |\n\nThe matrix is stored keyed by capability and rendered keyed by role (§14.3's\n`grants`); `matrixFromGrants` and `grantsFromMatrix` are the only two places\nthat transpose it.\n\n`memoryAuthzStore()` is the same interface over two arrays, for testing a policy\nwithout standing up Postgres.\n\n### What this module refuses, and why it will not say so (EXTBP-37)\n\n**The org a caller names is checked against the org on their token.** A request\nwhose path says one org and whose token says another is refused, and so is a\ntool called with an `orgId` argument the token does not match. Over REST your\napp's `requireOrgMatch` has usually said this already; over MCP nothing runs\nahead of the guard, so the guard says it. Before this, an owner of one org could\nname a freshly licensed second org in a tool call and be handed the role its\n§14.4 bootstrap grants, because the bootstrap exists precisely where no\nassignment has been made yet.\n\n**The refusal is deliberately indistinguishable from an ordinary denial** —\nsame `capability_denied`, same wording, byte for byte. Telling the caller their\norg id was the problem would confirm that the org exists, which is the same\nreason `errors/record-error.ts` answers a cross-org id with 404 rather than 403.\nThe operator is told instead: `createAuthz` takes an `onCrossOrg` sink, and\n`defaultOnCrossOrgCall` writes one warn line carrying the capability, the\nsurface, both org ids and the token's `sub`. No name, no email. If you are\ndebugging a refusal that looks wrong, read that line — the envelope will not\nhelp you, by design.\n\n**The org role never travels.** `orgRole` is cortena-auth's word about the org\non the caller's token, so §14.4's bootstrap fires only when the decision is\nagainst that same org, and `effectiveRoles` renders it only when told which org\nthe claim is about. The two arguments are a compiler-enforced pair: passing\n`orgRole` without `orgRoleOrgId` is a type error, not a silently missing role.\n\n**The parameter name is literal.** Only `orgId` is checked, on both lanes. A\nroute or tool that calls it `organisationId` or `tenantId` is not checked here\nand the handler receives whatever the caller wrote.\n\n**If your org-match middleware normalises the org id, normalise it everywhere.**\nThe comparison is exact. Middleware that rewrites `req.user.orgId` to a padded\nor canonical form while leaving `req.params.orgId` raw will refuse every call.\nIt fails closed, so this is an outage rather than a hole, but it is an\nunpleasant way to spend an afternoon.\n\n## The agent gateway (§19.2, P-46)\n\nEvery extension surfaces its own agent as a chat pop-up (§19), and the pop-up —\n`AgentChatPopup` from `cortena-ui/agent-chat` — talks to exactly one origin:\nthe extension's OWN gateway, with the signed-in user's JWT on every call. It\nnever reaches cortenacore and it never holds a service credential. So the\nextension has to serve those routes, and this is them:\n\n```ts\napp.use(\n  '/api/agent',\n  createAgentGatewayRouter({\n    controllerUrl: config.CONTROLLER_URL,\n    agentId: 'tasks',        // = the AgentTemplate slug = the extension id\n    verify: authenticate,    // your own JWT middleware, not a second opinion\n  }),\n);\n```\n\nMount it **before** `express.json()`. An AG-UI `RunAgentInput` then goes\nupstream byte-for-byte; mounted after a parser it still works, but the bytes\nthe pod sees are the parser's rather than the client's.\n\n| this router | the Controller | what it is |\n| --- | --- | --- |\n| `POST /agui/run` | `POST /api/session/agui/run` | the run stream: `RunAgentInput` in, `text/event-stream` out |\n| `POST /agui/abort` | `POST /api/session/agui/abort` | `{ runId, threadId }` |\n| `POST /agui/approval` | `POST /api/session/agui/approval` | `{ id, decision, threadId }` |\n| `GET\\|POST /api/core/*` | `/api/session/core/*` | `sessions/list`, `chat/history`, `sessions/patch`, `sessions/delete` |\n| `GET /session/status` | `GET /api/session/status` | |\n| `DELETE /session/disconnect` | `DELETE /api/session/disconnect` | |\n\n`POST /session/connect` is deliberately absent. The Controller's connect\nhandler reads `vaultPassphrase`, `vaultBundle` and `refreshToken` off the\nrequest body, and an extension must not be the pipe those travel down. The\npop-up's client never calls it — it assumes a pod, and cortenaweb is where one\nis created.\n\nAnything else is a 404, and every one of them is behind `verify`.\n\nThree refusals, and each is a different mistake:\n\n- **401 `unauthenticated`** — no bearer. The gateway proxies a person; there is\n  nobody to proxy.\n- **401 `service_token_not_accepted`** — a service-account token. The Controller\n  resolves *the token's own user's* pod, so a service token either finds none or\n  finds the wrong one, and every message in the chat would be attributed to\n  nobody. It is refused rather than mapped, because there is no user to map it\n  to. The check reads the token as well as whatever `verify` left on the\n  request, because an extension's middleware maps the payload into a shape of\n  its own and drops the field that answers this.\n- **403 `session_agent_mismatch`** — a session key that is not `agent:<agentId>:…`.\n  The pod picks the agent from the session key, not from a header (the\n  Controller forwards a five-header allowlist, so `x-cortena-agent-id` does not\n  survive the hop), and this gateway holds one user's credential inside one\n  extension. Without the check, Tasks' `/api/agent` would run `agent:payroll:…`\n  and read its transcript back out of `chat.history`.\n\nThe stream is piped, never buffered: the head goes out before the first event,\neach chunk is written as it arrives, the client's backpressure pauses the\nupstream, and a browser that hangs up destroys the upstream request so the pod\nstops generating into a dead socket. The upstream has 30 s to produce *headers*\n— not to finish, because an AG-UI run is idle between events by design.\n\n## Exports\n\nEverything is exported from the package root and from the `./routes` subpath:\n\n- `defineRoute`, `defineAction`, `defineRoutes`, `mountRoutes`\n- `assertToolParity`, `toolParityReport`, `mountMcpTools`, `walkExpressRoutes`,\n  `readServerTools`, `schemaDifferences`, `toolInputShape`, `actorFromMcpExtra`,\n  `toolExclusion`\n- `buildOpenApiDocument`, `buildMcpTools`, `toJsonSchema`, `substituteServerUrl`\n- `RouteError`, `errorEnvelope`, `zodErrorEnvelope`, `envelopeFromThrown`,\n  `internalErrorMessage`, `errorMiddleware`, `boundUpstreamBody`\n- `defaultOnRouteError`, `reportRouteError`, `recordExceptionOnActiveSpan`,\n  `activeTraceIds` and the `OnRouteError` / `RouteErrorReport` types\n- `createAgentGatewayRouter` and the `AgentGatewayOptions` type, with\n  `AGUI_ACTIONS`, `AGUI_LANE`, `CORE_LANE`, `SESSION_ROUTES`, `agentScopeGuard`\n  and `writeWithBackpressure`\n- `serveRuntimeConfig`, `collectRuntimeConfig`, `runtimeConfigScript`,\n  `RUNTIME_CONFIG_GLOBAL`, `RUNTIME_CONFIG_PATH` — also on the\n  `./runtime-config` subpath\n- `resolveActor`, `requireActor`, `actorLabel`, `actorColumns`, `withActor`\n  and the `Actor` type — also on the `./actor` subpath\n- `definePolicy`, `createAuthz`, `adminRoutes`, `authzTables`,\n  `authzMigrationSql`, `drizzleAuthzStore`, `memoryAuthzStore`,\n  `cortenaAuthGroups`, `adminCapabilities`, `matrixFromGrants`,\n  `grantsFromMatrix`, `matrixProblems`, `orgRoleOf` and the `AuthzPolicy`,\n  `AuthzStore`, `AuthzDecision`, `AuthzDirectory`, `AuthzGroups` types — also on\n  the `./authz` subpath, with the migration template at\n  `./templates/authz.sql`\n- `mcpAppResource`, `withMcpApp`, `mcpAppRequested` (a tool result — the\n  last reads the caller's `Accept` off the tool callback's `extra`) and\n  `withAppResource`, `hasAppResource`, `acceptsAppResource`,\n  `appResourceExtensionId` (a REST answer), with `MCP_APP_MIME_TYPE`,\n  `MCP_APP_URI_SCHEME`, `MCP_APP_MAX_HTML_BYTES` and the structural\n  `McpRequestExtraLike`\n- types: `RouteDefinition`, `RouteContext`, `NormalisedRoute`, `RouteRegistry`,\n  `OpenApiDocument`, `XCortena`, `McpToolDefinition`, `ErrorEnvelope`,\n  `ParityReport`, `ParityViolation`, `ToolParityOptions`, `McpServerLike`,\n  `McpAppResourceBlock`, `AppResourceEnvelope`\n\nThe CLI is reached as the `cortena-openapi` bin rather than through the barrel,\nso `node:fs` and the YAML writer stay out of a backend's import graph.\n","readmeFilename":"README.md"}