Skip to content

feat(graphql): refuse requests to suspended databases via a standing loader - #1815

Merged
pyramation merged 2 commits into
mainfrom
feat/graphql-database-standing
Sep 8, 2026
Merged

pyramation merged 2 commits into
mainfrom
feat/graphql-database-standing

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

GraphQL is the one serving lane that does not use @constructive-db/module-loader, so it gets the same database-standing check through its own loader registry (@constructive-io/express-context). Companion to constructive-db#3701 (state) / #3705 (pg-wire enforcement) and the lane-consumers PR in constructive-db.

Loaderpackages/express-context/src/loaders/standing.ts, registered as standing in createDefaultRegistry() and typed on BuiltinModuleMap:

SELECT suspended_at, suspended_reason FROM metaschema_public.database WHERE id = $1   -- routingPool, 5s TTL
 DatabaseStanding { exists, suspended, suspendedAt, reason }
  • no row → exists: false (a request pinned to an unknown database has nothing to run against)
  • read failure → propagates (no fail-open fallback); a plane without the table resolves to undefined via the factory's existing 42P01 handling like any absent module.

Middlewaregraphql/server/src/middleware/standing.ts, mounted in server.ts right after createContextMiddleware and before request-protection, so a suspended tenant spends nothing:

standing = await req.constructive?.useModule('standing')   // throws → next(e), never next()
if (standing && (!standing.exists || standing.suspended))
  respondWithGraphQLError(res, errors.ACCESS_SUSPENDED({ reason }), { status: 403 })

Uses the existing public ACCESS_SUSPENDED error (#1814); reason (billing/admin) travels in extensions.context.

Tests: loader (scoping, suspended/missing rows, propagation, cache+invalidate, tenant isolation) and middleware (pass/refuse/unknown/no-module/fail-closed).

Link to Devin session: https://app.devin.ai/sessions/86a73d903b3546c1afbf5361bfa384b6
Open in Devin Desktop: https://app.devin.ai/desktop/session/86a73d903b3546c1afbf5361bfa384b6?variant=devin
Requested by: @pyramation

…loader

Adds a 'standing' module to @constructive-io/express-context that reads the
system-controlled suspended_at/suspended_reason on metaschema_public.database
(5s TTL), and a GraphQL middleware mounted after context creation that answers
ACCESS_SUSPENDED (HTTP 403) for a suspended or unknown database and hands
lookup failures to the error handler so verification failure never means
allowed.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review complete. No blocking issues — approved ✅; 2 nitpicks below.

🧹 Nitpicks (2) — 🟢 2 low
  • 🟢 Key standing cache by databaseId only (standing.ts:48) — The standing loader's value is a pure function of databaseId (DATABASE_STANDING_SQL filters only on id = $1), yet the shared createModuleLoader cache keys entries by ${databaseId}:${apiId} whenever apiId is present (create-loader.ts:48).
  • 🟢 Standing middleware returns GraphQL error on REST/SSE routes (server.ts:179) — The standing middleware is mounted globally with app.use(createStandingMiddleware()) (server.ts:179), so it also gates the REST/SSE routers mounted later (createAgenticRouter() at server.ts:223, createFnRouter() at server.ts:226).

This PR introduces a database standing/suspension enforcement layer. It adds a standing loader in packages/express-context that queries metaschema_public.database for suspended_at/suspended_reason, registers it in the loader registry, and mounts a new createStandingMiddleware globally in graphql/server so suspended or unknown databases are refused with a 403 (ACCESS_SUSPENDED). Two low-severity issues were noted: the standing cache is keyed per API rather than per database, and the globally-mounted middleware answers REST/SSE routes with a GraphQL-shaped error body.

Files Change
packages/express-context/src/loaders/standing.ts, loaders/index.ts, types.ts, index.ts Adds the standing loader, its types, and registry wiring for the suspension query.
graphql/server/src/middleware/standing.ts, server.ts Implements the standing middleware and mounts it globally to fail closed on suspended/unknown databases.
graphql/server/src/middleware/__tests__/standing.test.ts, packages/express-context/__tests__/loaders/standing.test.ts Adds tests covering happy path, suspension, missing modules, and error propagation.

Reviewed commit: ecb3659

@blacksmith-sh

This comment has been minimized.

@pyramation
pyramation merged commit c5b07d4 into main Sep 8, 2026
20 checks passed
@pyramation
pyramation deleted the feat/graphql-database-standing branch September 8, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant