@graphoria/server v0.4.0
The second hardening release. One line in it can lock you out, so read the first breaking change before upgrading. Nine PRs landed since v0.3.0 (#48, #50–#57), and the theme is accountability and credential hygiene rather than resource bounds: every privileged action now leaves a structured audit record, every secret rotates in two deploys with no cut-over, and the console, the AI agent and the MCP gate each take a credential that opens that one surface and nothing else. Alongside those, stored-procedure arguments are finally bound correctly on all three engines, the OpenAPI spec types its response fields from the schema it serves, and a dependency override that contradicted two manifests is gone.
Lockstep bump of the four workspace manifests to 0.4.0, per the scheme in CONTRIBUTING.md, plus the three workspace entries in bun.lock that mirror them — those had lagged at 0.2.2 through the whole v0.3.0 cycle. Minor rather than patch: two of the changes below alter what an existing deployment does with no configuration change at all.
⚠️ Breaking changes
Three, all from the two credential changes.
1. Secret environment variables are split on commas (#55)
ADMIN_SECRET, JWT_SECRET, PASETO_LOCAL_KEY and PASETO_PUBLIC_KEY are now read as comma-separated lists. If your ADMIN_SECRET or JWT_SECRET contains a ,, the server will read it as several secrets after upgrading, none of which equals the old whole value.
What you see: the admin header stops resolving to superadmin, and every existing JWT resolves to anonymous.
What to do: set a value without a comma before upgrading. For JWT_SECRET that invalidates outstanding tokens, so users log in again once; from then on rotations can overlap. PASETO keys are base64url and cannot contain a comma, so they are unaffected. PASETO_SECRET_KEY stays a single value.
2. The parsed Env shape carries secrets as arrays (#55)
This reaches code that passes secrets to createBunServer, createHandlers or createGraphQLEngine in place of the environment, and any caller of createMCPRoutes.
| Before | After |
|---|---|
admin.secret: string |
admin.secrets: string[] |
jwt.secret: string |
jwt.secrets: string[] |
paseto.localKey: string |
paseto.localKeys: string[] |
paseto.publicKey: string |
paseto.publicKeys: string[] |
createMCPRoutes({ adminSecret }) |
createMCPRoutes({ adminSecrets }) |
paseto.secretKey is unchanged. What to do: wrap each value in an array.
3. Console sessions issued before the upgrade are refused (#57)
A console session now carries the scope of the credential that opened it, and a session with no scope claim — every one minted by v0.3.0 — is refused rather than assumed to be full access.
What you see: the console asks for the secret again, once. What to do: log in again.
New
An audit log for every privileged action (#56)
One structured record per privileged action, through a pino child tagged component: "audit": any request that presents the admin secret (HTTP, websocket handshake, MCP gate), login success and failure, logout with the JTIs it revoked, queue publishes, AI agent invocations with the prompt, and every console login, logout, publish and cron action. Each record names the actor, the action and the target; pino supplies the timestamp.
Three things to know before it goes to production:
- The child's level is pinned to
info. A deployment running atLOG_LEVEL=warnstill gets its audit trail, which means newinfo-level output appears on every deployment after upgrading — the one behaviour change in this release that needs no configuration to show up. - The AI prompt is recorded verbatim. Secret-bearing keys (
password,secret,token,authorization,cookie, …) are redacted at any depth before the record is written, and a queue message body is never recorded — but the prompt is data an operator asked for, so it is kept whole.docs/AI.mdsays so. - Rate-limit rejections are not audit events. Under an attack they would flood the sink.
An admin-secret session now carries authMethod: "admin_secret", which is how a call site tells it apart from a real user named superadmin. Console logout reports whether it revoked a live session, so a logout with no session leaves no record. The package README carries the full catalogue.
Secrets rotate without a cut-over (#55)
The first entry of each list is the one in use — it signs JWTs and encrypts PASETO local tokens — and every entry is accepted on the way in. So a secret rotates in two deploys:
JWT_SECRET=new,old— roll. New tokens are signed withnew; tokens issued underoldstill verify.JWT_SECRET=new— once everything issued underoldhas expired (oneJWT_RT_EXPIRES_IN, default7d).
paseto_public rotates by generating a new pair: new secret key, PASETO_PUBLIC_KEY=new,old. The four admin-secret comparison sites — the bearer path in both token services, the console login, the MCP gate — share one timing-safe helper that checks the candidate against the whole set. A match against anything but the first entry is logged at debug, which is how you tell when the old entry can go.
Verification tries the next key only on a signature or decryption failure. A claim failure — expired, not yet valid, wrong audience — came from the key that did produce the token and is surfaced as-is rather than as a signature mismatch from the last key tried.
Before this, rotating a leaked or ageing secret meant every token issued under the old key failed the moment the new one went live. So rotation cost downtime, so it got deferred, so the old secret stayed live.
Scoped credentials for the console, the agent and MCP (#57)
Four new variables, each unset by default, each a comma-separated list rotated like ADMIN_SECRET and compared with timingSafeEqual:
| Variable | Opens |
|---|---|
CONSOLE_READ_SECRET |
the console's state pages |
CONSOLE_WRITE_SECRET |
the console, including queue publish and cron control |
AI_SECRET |
POST /ai |
AI_MCP_SECRET |
the MCP gate, when AI_MCP_REQUIRE_ADMIN_SECRET is on |
ADMIN_SECRET keeps opening all of them, so nothing changes until you set one — but it now logs a warn each time it is used where a scoped credential would have done. If your console operators sign in with the admin secret, expect that line on every login until they are moved to a console credential.
A read session gets 403 Console session is read-only from queues/publish and cron, and the UI hides the publish form and the cron actions rather than showing controls that would fail. The scoped agent credential stands in for the superadmin role on the REST route only; sent anywhere else it resolves to the anonymous role, so the GraphQL ask field stays out of its reach. Audit records at the three surfaces name the credential used: scope is all for the admin secret, the capability otherwise.
One place knows which secret grants what — authentication/capabilities.ts — and every gate asks it.
Fixes
Stored-procedure arguments are bound correctly on all three engines (#54)
Three defects, one per engine pair, each silent.
- SQL Server discarded falsy arguments.
null,0,"",falseandundefinedwere never bound, so the procedure fell back to its own parameter defaults instead of receiving what the caller passed — with no error anywhere. Every supplied argument is now bound. - PostgreSQL and MySQL numbered placeholders from one list and filled values from another. The placeholders came from the argument object's keys; the values came from the GraphQL operation's
$vars, which only coincide when every argument maps 1:1 to a distinct declared variable in the same order. When they disagreed, PostgreSQL bound the wrong values — orundefined— and MySQL threw from the$n→?rewrite, which the surroundingcatchturned intofalse: the call never reached the database. Both now order arguments from the introspected signature, so the list that numbers the placeholders is the list that orders the values. - PostgreSQL and MySQL returned rows through a
Boolean!field. Hidden only because the binding always failed and thecatchansweredfalse. Both now report whether the call ran.
What you see: a stored-procedure mutation on PostgreSQL or MySQL that used to answer false without running now runs and answers true. If you have code that treated that false as a routine outcome, it was never seeing the procedure execute.
variablesDefinition is gone from the DatabaseFunctions adapter contract, the dispatcher and the three engines — it was the list that caused the mismatch, and leaving it in was an invitation to reach for it again.
OpenAPI response fields are typed from the GraphQL schema (#52)
The generator resolved a field's type by looking its parent selection's name up in the entity map and reading that column's SQL type, which only works when the parent is a table. Under <table>_aggregate — whose key, count, items and min/max/sum/avg are not tables — the lookup missed and everything fell back to string, so grouping by an integer column published it as a string, count as a string, avg as a string. The generator now walks the built GraphQLSchema alongside the selection tree, with the same helpers the query analyzer uses.
Spec output changes beyond the wrong types: isRequired had been set on every selection without a directive, so nullable columns and nullable relationships came out required. It now reads the answer off the schema — required exactly when the field type is non-null — and every response field carries nullable. A generated client will see required arrays shrink and a new nullable flag; Int and Float still both map to number, so nothing else moves.
nanoid resolves per consumer again (#53)
A global nanoid override in the root overrides block, added to clear GHSA-28wg-ghj8-5hjv, applied to every nanoid in the graph: packages/server declared 6.0.1 and got 5.1.16, and postcss asked for ^3.3.17 and was pushed two majors to an ESM-only release. The override is gone; each declared range now resolves on its own, every one of them lands above the advisory's floor, and bun audit stays the enforcing gate in CI.
Dependencies
- #48 — the minor-and-patch group, ten updates:
graphiql5.3.0,@graphiql/plugin-explorer5.1.4,oxlint1.80.0,oxfmt0.65.0,happy-dom20.12.0,@happy-dom/global-registrator20.11.12,@testing-library/react16.3.3,@types/react-dom19.2.5,paseto-ts2.0.7,openai7.8.0. - #50 —
@anthropic-ai/sdk0.120.0 → 0.122.0. - #51 — the
examples/tasklyapp's dependencies, including@graphoria/serverand@graphoria/reactto0.3.0. Manifest and lockfile only.
Documentation
docs/AUTHENTICATION.mdgains a "Rotating secrets" section;.env.exampleand both READMEs point at it.- The package README gains an "Audit log" section with the full catalogue of records;
docs/SECURITY_MODEL.md,docs/CONSOLE.md,docs/AI.md,docs/AUTHENTICATION.md,docs/CONFIGURATION.mdanddocs/MCP.mdeach name the records their surface produces. docs/CONSOLE.md,docs/AI.md,docs/MCP.mdanddocs/CONFIGURATION.mddescribe the four scoped credentials, what each opens, and the read-only console session.docs/SECURITY_MODEL.mdno longer says rotating the admin secret needs a cut-over, and its caller table shows what a scoped credential reaches.
Verification
Measured on the tree tagged v0.4.0.
| Check | Result |
|---|---|
bun run lint |
clean |
bun run format:check |
clean, 418 files |
bun run type-check |
clean, both packages |
bun test |
1384 pass, 0 fail, 462 skip, across 108 files |
bun run test:integration |
384 pass, 0 fail, across 21 files |
The integration run is against real PostgreSQL, MySQL, SQL Server and Redis in containers, not mocks. Unit tests are up from 1288 at v0.3.0; integration from 350.
Every claim in this release was falsified before it landed. Each rotation property has a test that fails if the property is removed: a token under the previous key verifies, the first key signs, a token no key produced is rejected, an nbf failure under the old key surfaces as nbf, the previous admin secret reaches superadmin at every site, an empty set matches nothing — not even an empty header. The rotation integration file boots a server on the old key, logs in, restarts it on new,old, fires 25 requests with the pre-rotation token and counts any that does not resolve to the user's role, then restarts on new alone to show the old token now falls to anonymous — for jwt, paseto_local, paseto_public and the admin header. The audit integration file boots a server with auth, the agent and the console enabled, counts exactly one record per action, and asserts that no serialised record contains the admin secret, the password, the access token or the console session cookie. The seven stored-procedure tests were each written before the fix and watched fail, and reverting the OpenAPI fix reproduces the reported Expected: "number" / Received: "string".
📖 Quickstart · 🔑 Authentication · 🖥️ Console · 🔒 Security model · 📦 @graphoria/server · Full diff