@constructive-io/graphql-server 5.32.2 → 5.32.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,11 +57,11 @@ This starts the server with env defaults from `@constructive-io/graphql-env`.
57
57
  ## What it does
58
58
 
59
59
  Platform deployments connect through one pgpm-provisioned `NOSUPERUSER INHERIT
60
- BYPASSRLS` login with direct metadata privileges, configured only with
61
- `PGUSER`/`PGPASSWORD`. Startup and routing queries use that pool login directly.
62
- Requests continue to switch to `authenticated` or `anonymous`, so PostgreSQL
63
- enforces their RLS policies; the login's BYPASSRLS attribute is not inherited
64
- after `SET ROLE`.
60
+ NOBYPASSRLS` login, configured only with `PGUSER`/`PGPASSWORD`. Startup and
61
+ routing queries run as that login, so rows behind member RLS stay hidden from
62
+ it: standing and routing settings are read through static, login-only
63
+ `SECURITY DEFINER` readers in `function_resolution`. Requests switch to
64
+ `authenticated` or `anonymous`, so PostgreSQL enforces their RLS policies.
65
65
 
66
66
  Runs an Express server that wires CORS, uploads, domain parsing, auth, and PostGraphile into a single GraphQL endpoint. It serves `/graphql` and `/graphiql`, injects per-request `pgSettings`, and flushes cached schemas on demand or via database notifications. When meta API is enabled, it resolves API config (schemas, roles, modules) from the meta schema using the request host and enforces `api.isPublic`, with optional header overrides in private mode; when meta API is disabled, it serves the fixed schemas and roles from `api.exposedSchemas`, `api.anonRole`, and `api.roleName`.
67
67
 
@@ -122,7 +122,7 @@ Send `X-Constructive-Session-Cookie: omit` on every request. Sign-in mutations t
122
122
 
123
123
  This is a production-only server: every request is resolved through the scoped-routing plane. There is no static single-tenant mode and no flag to disable routing. For single-database local development without route resolution or a database id, use [`@constructive-io/graphql-dev-server`](../dev-server/README.md).
124
124
 
125
- - The server resolves the request host with a single `resolve_route()` call against the compiled route bindings in the scoped routing schema (`API_ROUTING_SCHEMA`, default `routing_public`), mapping host → tenant/api/database/role.
125
+ - The server resolves the request host with a single `resolve_route()` call against the compiled route bindings in `routing_public`, mapping host → tenant/api/database/role.
126
126
  - Only APIs where `api.is_public` matches `API_IS_PUBLIC` are served.
127
127
  - In private mode (`API_IS_PUBLIC=false`), you can override with headers:
128
128
  - `X-Api-Name` + `X-Database-Id`
@@ -145,7 +145,6 @@ Configuration is merged from defaults, config files, and env vars via `@construc
145
145
  | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` |
146
146
  | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` |
147
147
  | `FEATURES_POSTGIS` | Enable PostGIS support | `true` |
148
- | `API_ROUTING_SCHEMA` | Schema containing `resolve_route()` | `routing_public` |
149
148
  | `API_IS_PUBLIC` | Serve public APIs only | `true` |
150
149
  | `API_EXPOSED_SCHEMAS` | Additional schemas to expose | empty |
151
150
  | `API_META_SCHEMAS` | Meta schemas to query | `routing_public,metaschema_public,metaschema_modules_public` |
@@ -6,7 +6,7 @@ import { svcCache } from '@pgpmjs/server-utils';
6
6
  import { getPgPool } from 'pg-cache';
7
7
  import errorPage50x from '../errors/50x';
8
8
  import errorPage404Message from '../errors/404-message';
9
- import { getRoutingSchema, isValidSchemaName, requireApiRole, resolveRoute, routeToApiStructure } from './routing';
9
+ import { requireApiRole, resolveRouteAcrossPlanes, routeToApiStructure } from './routing';
10
10
  const log = new Logger('api');
11
11
  // =============================================================================
12
12
  // Module Loader Registry (replaces inline SQL queries for per-db config)
@@ -15,26 +15,16 @@ const defaultRegistry = createDefaultRegistry();
15
15
  // =============================================================================
16
16
  // SQL Queries (API resolution only — module queries now live in loaders)
17
17
  // =============================================================================
18
- // Private-header X-Api-Name lookup against the scoped routing plane.
19
- // X-Api-Name is a trusted internal selector (only honored when the server
20
- // runs with isPublic=false): it addresses a database's API surface by name.
18
+ // Private-header X-Api-Name lookup. X-Api-Name is a trusted internal selector
19
+ // (only honored when the server runs with isPublic=false): it addresses a
20
+ // database's API surface by name. The routing tables sit behind member RLS and
21
+ // no request identity exists yet, so the login reads through the login-only
22
+ // SECURITY DEFINER reader, which resolves the database's apis plane itself.
21
23
  // `is_published` governs cross-scope route visibility, not internal
22
24
  // addressability, so it does not filter this lookup.
23
- const scopedApiNameLookupSql = (routingSchema) => `
24
- SELECT
25
- a.id as api_id,
26
- a.database_id,
27
- a.dbname,
28
- a.role_name,
29
- a.anon_role,
30
- a.is_published as is_public,
31
- COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
32
- FROM "${routingSchema}".apis a
33
- LEFT JOIN "${routingSchema}".api_schemas aps ON a.id = aps.api_id
34
- LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
35
- WHERE a.database_id = $1
36
- AND a.name = $2
37
- GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_published
25
+ const API_BY_NAME_SQL = `
26
+ SELECT api_id, api_database_id AS database_id, dbname, role_name, anon_role, is_public, schemas
27
+ FROM function_resolution.api_by_name($1, $2)
38
28
  LIMIT 1
39
29
  `;
40
30
  /**
@@ -47,7 +37,6 @@ const buildLoaderContext = (routingPool, opts, row) => {
47
37
  const dbname = row.dbname || opts.pg?.database || '';
48
38
  return {
49
39
  routingPool,
50
- routingSchema: getRoutingSchema(opts),
51
40
  tenantPool: getPgPool({ ...opts.pg, database: dbname }),
52
41
  databaseId: row.database_id,
53
42
  apiId: row.api_id,
@@ -156,8 +145,8 @@ const toApiStructure = (row, opts, settings = {}) => ({
156
145
  });
157
146
  const createAdminStructure = (opts, schemas, databaseId) => ({
158
147
  dbname: opts.pg?.database ?? '',
159
- anonRole: 'administrator',
160
- roleName: 'administrator',
148
+ anonRole: 'anonymous',
149
+ roleName: 'authenticated',
161
150
  schema: schemas,
162
151
  domains: [],
163
152
  databaseId,
@@ -170,13 +159,8 @@ const validateSchemata = async (pool, schemas) => {
170
159
  const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
171
160
  return result.rows.map((row) => row.schema_name);
172
161
  };
173
- const queryByApiName = async (pool, opts, databaseId, name) => {
174
- const routingSchema = getRoutingSchema(opts);
175
- if (!isValidSchemaName(routingSchema)) {
176
- log.warn(`[api-name-lookup] invalid routing schema name: ${routingSchema}`);
177
- return null;
178
- }
179
- const result = await pool.query(scopedApiNameLookupSql(routingSchema), [databaseId, name]);
162
+ const queryByApiName = async (pool, databaseId, name) => {
163
+ const result = await pool.query(API_BY_NAME_SQL, [databaseId, name]);
180
164
  return result.rows[0] ?? null;
181
165
  };
182
166
  // =============================================================================
@@ -203,7 +187,7 @@ const resolveApiNameHeader = async (ctx) => {
203
187
  const { opts, pool, headers } = ctx;
204
188
  if (!headers.databaseId)
205
189
  return null;
206
- const row = await queryByApiName(pool, opts, headers.databaseId, headers.apiName);
190
+ const row = await queryByApiName(pool, headers.databaseId, headers.apiName);
207
191
  if (!row) {
208
192
  log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
209
193
  return null;
@@ -226,13 +210,17 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
226
210
  */
227
211
  const resolveScopedRoute = async (ctx) => {
228
212
  const { opts, pool, host } = ctx;
229
- const schema = getRoutingSchema(opts);
230
- const route = await resolveRoute(pool, schema, host);
231
- if (!route)
213
+ const match = await resolveRouteAcrossPlanes(pool, host);
214
+ if (!match)
232
215
  return null;
233
- const structure = routeToApiStructure(route, opts);
216
+ const structure = routeToApiStructure(match.route, opts);
234
217
  if (!structure)
235
218
  return null;
219
+ if (match.planeDatabaseId && structure.databaseId !== match.planeDatabaseId) {
220
+ const error = new Error(`host ${host} is owned by database ${match.planeDatabaseId}'s routing plane but routes to database ${structure.databaseId ?? '<none>'}`);
221
+ error.code = 'CROSS_DATABASE_ROUTE';
222
+ throw error;
223
+ }
236
224
  log.debug(`[scoped-routing] resolved host=${host} → api=${structure.apiId} db=${structure.dbname}`);
237
225
  if (!structure.databaseId || !structure.apiId)
238
226
  return structure;
@@ -5,7 +5,6 @@ import { createHash, timingSafeEqual } from 'crypto';
5
5
  import { graphileCache } from 'graphile-cache';
6
6
  import { clearStorageModuleCache } from 'graphile-presigned-url-plugin';
7
7
  import { getPgPool } from 'pg-cache';
8
- import { getRoutingSchema, isValidSchemaName } from './routing';
9
8
  const log = new Logger('flush');
10
9
  const bearerToken = (req) => {
11
10
  const header = req.get('authorization');
@@ -67,14 +66,9 @@ export const flushService = async (opts, databaseId) => {
67
66
  }
68
67
  });
69
68
  }
70
- const routingSchema = getRoutingSchema(opts);
71
- if (!isValidSchemaName(routingSchema)) {
72
- log.warn(`[flush] invalid routing schema name: ${routingSchema}`);
73
- return;
74
- }
75
- const svc = await pgPool.query(`SELECT hostname
76
- FROM "${routingSchema}".domains
77
- WHERE database_id = $1`, [databaseId]);
69
+ // The domains plane sits behind member RLS; the login reads it through the
70
+ // login-only SECURITY DEFINER reader, which resolves the plane itself.
71
+ const svc = await pgPool.query(`SELECT hostname FROM function_resolution.api_hostnames($1) AS hostname`, [databaseId]);
78
72
  if (svc.rowCount === 0)
79
73
  return;
80
74
  for (const row of svc.rows) {
@@ -1,4 +1,5 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
+ import { QuoteUtils } from '@pgsql/quotes';
2
3
  const log = new Logger('routing');
3
4
  // =============================================================================
4
5
  // Scoped routing plane (resolve_route contract)
@@ -26,13 +27,19 @@ export const requireApiRole = (column, value, apiId) => {
26
27
  error.apiId = apiId;
27
28
  throw error;
28
29
  }
30
+ // administrator bypasses RLS; an API never serves as it.
31
+ if (value === 'administrator') {
32
+ const error = new Error(`API ${apiId ?? '<unknown>'} ${column} is administrator; APIs serve as request roles under RLS.`);
33
+ error.code = 'API_ROLE_FORBIDDEN';
34
+ error.column = column;
35
+ error.apiId = apiId;
36
+ throw error;
37
+ }
29
38
  return value;
30
39
  };
31
40
  const RESOLVER_FUNCTION = 'resolve_route';
32
- /** Published logical name of the database-scope routing plane. */
33
- export const DEFAULT_ROUTING_SCHEMA = 'routing_public';
34
- /** The routing-plane schema in effect: configured override or the published default. */
35
- export const getRoutingSchema = (opts) => opts.api?.routingSchema || DEFAULT_ROUTING_SCHEMA;
41
+ /** The scoped routing plane the server resolves hosts on first. */
42
+ export const ROUTING_SCHEMA = 'routing_public';
36
43
  export const isValidSchemaName = (name) => /^[a-z_][a-z0-9_]*$/.test(name);
37
44
  /**
38
45
  * Resolve a hostname through the compiled scoped-routing plane (host-only:
@@ -66,6 +73,58 @@ export const resolveRoute = async (pool, schema, host) => {
66
73
  throw error;
67
74
  }
68
75
  };
76
+ /**
77
+ * The database-scope routing planes after the server's own `routing_public` plane, in
78
+ * the static gateway's order (by database id). The list spans every tenant,
79
+ * so it comes from a SECURITY DEFINER reader granted to the platform login
80
+ * alone, on the login's own connection: no role switch, no RLS bypass. A plane
81
+ * whose registration names no schema or resolver is an incomplete install and
82
+ * fails loud.
83
+ */
84
+ export const loadDatabaseRoutingPlanes = async (pool) => {
85
+ const { rows } = await pool.query(`SELECT database_id, schema_name, resolver_function_name
86
+ FROM function_resolution.routing_planes()`);
87
+ return rows
88
+ .filter((row) => row.schema_name !== ROUTING_SCHEMA)
89
+ .map((row) => {
90
+ if (!row.schema_name || !row.resolver_function_name) {
91
+ const error = new Error(`route_module for database ${row.database_id} names no ${row.schema_name ? 'resolver function' : 'public schema'}`);
92
+ error.code = 'ROUTING_PLANE_INCOMPLETE';
93
+ error.databaseId = row.database_id;
94
+ throw error;
95
+ }
96
+ return {
97
+ databaseId: row.database_id,
98
+ schema: row.schema_name,
99
+ resolver: row.resolver_function_name
100
+ };
101
+ });
102
+ };
103
+ const resolveOnPlane = async (pool, plane, host) => {
104
+ const resolver = QuoteUtils.quoteQualifiedIdentifier(plane.schema, plane.resolver);
105
+ const result = await pool.query(`SELECT * FROM ${resolver}($1, '/', NULL)`, [host]);
106
+ const row = result.rows[0];
107
+ return row && row.route_binding_id !== null ? row : null;
108
+ };
109
+ /**
110
+ * Resolve a host across routing planes: `routing_public` first, then
111
+ * each tenant database-scope plane. The first plane that matches owns the
112
+ * host; later planes are not consulted.
113
+ */
114
+ export const resolveRouteAcrossPlanes = async (pool, host) => {
115
+ const route = await resolveRoute(pool, ROUTING_SCHEMA, host);
116
+ if (route)
117
+ return { route, planeDatabaseId: null };
118
+ for (const plane of await loadDatabaseRoutingPlanes(pool)) {
119
+ const planeRoute = await resolveOnPlane(pool, plane, host);
120
+ if (planeRoute) {
121
+ log.debug(`[resolve-route] host=${host} owned by database plane ${plane.schema} (db=${plane.databaseId})`);
122
+ return { route: planeRoute, planeDatabaseId: plane.databaseId };
123
+ }
124
+ }
125
+ log.debug(`[resolve-route] no plane owns host=${host}`);
126
+ return null;
127
+ };
69
128
  /**
70
129
  * Map a resolved api-target route onto the ApiStructure shape consumed by the
71
130
  * rest of the middleware chain. Returns null when the route target is not an
@@ -3,7 +3,6 @@ import { QuoteUtils } from '@pgsql/quotes';
3
3
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
4
4
  import { isoBase64URL } from '@simplewebauthn/server/helpers';
5
5
  import { context as grafastContext, lambda, object } from 'grafast';
6
- import { SYSTEM_LANE_ROLE } from 'graphile-plugin-utils';
7
6
  import { extendSchema, gql } from 'graphile-utils';
8
7
  const CONTEXT_KEY = 'constructiveWebauthn';
9
8
  const REGISTER_CHALLENGE = 'webauthn_register_challenge';
@@ -16,11 +15,8 @@ const fail = (code, message) => {
16
15
  err.extensions = { code };
17
16
  throw err;
18
17
  };
19
- /** Apply the request's settings transaction-locally, optionally overriding its role. */
20
- const applySettings = async (tx, pgSettings, role) => {
21
- const entries = Object.entries(pgSettings ?? {}).filter(([key]) => !role || key !== 'role');
22
- if (role)
23
- entries.push(['role', role]);
18
+ /** Apply settings transaction-locally. */
19
+ const applySettings = async (tx, entries) => {
24
20
  if (entries.length === 0)
25
21
  return;
26
22
  await tx.query({
@@ -29,11 +25,18 @@ const applySettings = async (tx, pgSettings, role) => {
29
25
  });
30
26
  };
31
27
  const withRequestLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
32
- await applySettings(tx, pgSettings);
28
+ await applySettings(tx, Object.entries(pgSettings ?? {}));
33
29
  return cb(tx);
34
30
  }));
31
+ /**
32
+ * The verifier lane calls the server-only procedures as the connection's own
33
+ * login, which alone holds EXECUTE on them. It carries the request's
34
+ * `jwt.claims.*` but never its `role`: as a request role those procedures are
35
+ * revoked, so any SQL caller could otherwise mint a session.
36
+ */
37
+ const verifierSettings = (pgSettings) => Object.entries(pgSettings ?? {}).filter(([key]) => key !== 'role');
35
38
  const withVerifierLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
36
- await applySettings(tx, pgSettings, SYSTEM_LANE_ROLE);
39
+ await applySettings(tx, verifierSettings(pgSettings));
37
40
  return cb(tx);
38
41
  }));
39
42
  const procedure = (settings, name) => QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
@@ -87,11 +87,11 @@ export const recordRefusal = (req, reason, opts = {}) => {
87
87
  * that was missing named — rather than returning a partial identity.
88
88
  */
89
89
  export const resolvePlatformFlushIdentity = async (pool, principalName = PLATFORM_BOOTSTRAP_PRINCIPAL) => {
90
- const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
91
- if (database.rowCount !== 1) {
92
- throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
93
- }
94
- const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
90
+ // Both reads cross RLS before any request exists, so they go through
91
+ // SECURITY DEFINER resolvers granted to the login; the first raises when no
92
+ // platform database is registered.
93
+ const database = await pool.query(`SELECT app_scope.platform_database_id() AS id`);
94
+ const principal = await pool.query(`SELECT user_id, bypass_step_up FROM function_resolution.service_principal($1)`, [principalName]);
95
95
  if (principal.rowCount !== 1) {
96
96
  throw new Error(`refusals: no service principal named '${principalName}'`);
97
97
  }
package/esm/server.js CHANGED
@@ -32,7 +32,6 @@ import { debugMemory } from './middleware/observability/debug-memory';
32
32
  import { localObservabilityOnly } from './middleware/observability/guard';
33
33
  import { createRequestLogger } from './middleware/observability/request-logger';
34
34
  import { createRequestProtectionMiddleware } from './middleware/request-protection';
35
- import { getRoutingSchema } from './middleware/routing';
36
35
  import { createStandingMiddleware } from './middleware/standing';
37
36
  import { createPlatformRefusalRecorder, installRefusalRecorder } from './refusals/recorder';
38
37
  const log = new Logger('server');
@@ -91,7 +90,6 @@ class Server {
91
90
  serverHost: effectiveOpts.server?.host,
92
91
  serverPort: effectiveOpts.server?.port,
93
92
  apiIsPublic: apiOpts.isPublic,
94
- routingSchema: apiOpts.routingSchema,
95
93
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
96
94
  observabilityEnabled
97
95
  });
@@ -144,8 +142,7 @@ class Server {
144
142
  app.use(authenticate);
145
143
  app.use(createContextMiddleware({
146
144
  pg: effectiveOpts.pg,
147
- loaders: createDefaultRegistry(),
148
- routingSchema: getRoutingSchema(effectiveOpts)
145
+ loaders: createDefaultRegistry()
149
146
  }));
150
147
  // A suspended (or unknown) database is refused before anything is spent on
151
148
  // the request; billing and platform admins set that state, the loader reads it.
package/middleware/api.js CHANGED
@@ -21,26 +21,16 @@ const defaultRegistry = (0, express_context_1.createDefaultRegistry)();
21
21
  // =============================================================================
22
22
  // SQL Queries (API resolution only — module queries now live in loaders)
23
23
  // =============================================================================
24
- // Private-header X-Api-Name lookup against the scoped routing plane.
25
- // X-Api-Name is a trusted internal selector (only honored when the server
26
- // runs with isPublic=false): it addresses a database's API surface by name.
24
+ // Private-header X-Api-Name lookup. X-Api-Name is a trusted internal selector
25
+ // (only honored when the server runs with isPublic=false): it addresses a
26
+ // database's API surface by name. The routing tables sit behind member RLS and
27
+ // no request identity exists yet, so the login reads through the login-only
28
+ // SECURITY DEFINER reader, which resolves the database's apis plane itself.
27
29
  // `is_published` governs cross-scope route visibility, not internal
28
30
  // addressability, so it does not filter this lookup.
29
- const scopedApiNameLookupSql = (routingSchema) => `
30
- SELECT
31
- a.id as api_id,
32
- a.database_id,
33
- a.dbname,
34
- a.role_name,
35
- a.anon_role,
36
- a.is_published as is_public,
37
- COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
38
- FROM "${routingSchema}".apis a
39
- LEFT JOIN "${routingSchema}".api_schemas aps ON a.id = aps.api_id
40
- LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
41
- WHERE a.database_id = $1
42
- AND a.name = $2
43
- GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_published
31
+ const API_BY_NAME_SQL = `
32
+ SELECT api_id, api_database_id AS database_id, dbname, role_name, anon_role, is_public, schemas
33
+ FROM function_resolution.api_by_name($1, $2)
44
34
  LIMIT 1
45
35
  `;
46
36
  /**
@@ -53,7 +43,6 @@ const buildLoaderContext = (routingPool, opts, row) => {
53
43
  const dbname = row.dbname || opts.pg?.database || '';
54
44
  return {
55
45
  routingPool,
56
- routingSchema: (0, routing_1.getRoutingSchema)(opts),
57
46
  tenantPool: (0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname }),
58
47
  databaseId: row.database_id,
59
48
  apiId: row.api_id,
@@ -164,8 +153,8 @@ const toApiStructure = (row, opts, settings = {}) => ({
164
153
  });
165
154
  const createAdminStructure = (opts, schemas, databaseId) => ({
166
155
  dbname: opts.pg?.database ?? '',
167
- anonRole: 'administrator',
168
- roleName: 'administrator',
156
+ anonRole: 'anonymous',
157
+ roleName: 'authenticated',
169
158
  schema: schemas,
170
159
  domains: [],
171
160
  databaseId,
@@ -178,13 +167,8 @@ const validateSchemata = async (pool, schemas) => {
178
167
  const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
179
168
  return result.rows.map((row) => row.schema_name);
180
169
  };
181
- const queryByApiName = async (pool, opts, databaseId, name) => {
182
- const routingSchema = (0, routing_1.getRoutingSchema)(opts);
183
- if (!(0, routing_1.isValidSchemaName)(routingSchema)) {
184
- log.warn(`[api-name-lookup] invalid routing schema name: ${routingSchema}`);
185
- return null;
186
- }
187
- const result = await pool.query(scopedApiNameLookupSql(routingSchema), [databaseId, name]);
170
+ const queryByApiName = async (pool, databaseId, name) => {
171
+ const result = await pool.query(API_BY_NAME_SQL, [databaseId, name]);
188
172
  return result.rows[0] ?? null;
189
173
  };
190
174
  // =============================================================================
@@ -211,7 +195,7 @@ const resolveApiNameHeader = async (ctx) => {
211
195
  const { opts, pool, headers } = ctx;
212
196
  if (!headers.databaseId)
213
197
  return null;
214
- const row = await queryByApiName(pool, opts, headers.databaseId, headers.apiName);
198
+ const row = await queryByApiName(pool, headers.databaseId, headers.apiName);
215
199
  if (!row) {
216
200
  log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
217
201
  return null;
@@ -234,13 +218,17 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
234
218
  */
235
219
  const resolveScopedRoute = async (ctx) => {
236
220
  const { opts, pool, host } = ctx;
237
- const schema = (0, routing_1.getRoutingSchema)(opts);
238
- const route = await (0, routing_1.resolveRoute)(pool, schema, host);
239
- if (!route)
221
+ const match = await (0, routing_1.resolveRouteAcrossPlanes)(pool, host);
222
+ if (!match)
240
223
  return null;
241
- const structure = (0, routing_1.routeToApiStructure)(route, opts);
224
+ const structure = (0, routing_1.routeToApiStructure)(match.route, opts);
242
225
  if (!structure)
243
226
  return null;
227
+ if (match.planeDatabaseId && structure.databaseId !== match.planeDatabaseId) {
228
+ const error = new Error(`host ${host} is owned by database ${match.planeDatabaseId}'s routing plane but routes to database ${structure.databaseId ?? '<none>'}`);
229
+ error.code = 'CROSS_DATABASE_ROUTE';
230
+ throw error;
231
+ }
244
232
  log.debug(`[scoped-routing] resolved host=${host} → api=${structure.apiId} db=${structure.dbname}`);
245
233
  if (!structure.databaseId || !structure.apiId)
246
234
  return structure;
@@ -8,7 +8,6 @@ const crypto_1 = require("crypto");
8
8
  const graphile_cache_1 = require("graphile-cache");
9
9
  const graphile_presigned_url_plugin_1 = require("graphile-presigned-url-plugin");
10
10
  const pg_cache_1 = require("pg-cache");
11
- const routing_1 = require("./routing");
12
11
  const log = new logger_1.Logger('flush');
13
12
  const bearerToken = (req) => {
14
13
  const header = req.get('authorization');
@@ -71,14 +70,9 @@ const flushService = async (opts, databaseId) => {
71
70
  }
72
71
  });
73
72
  }
74
- const routingSchema = (0, routing_1.getRoutingSchema)(opts);
75
- if (!(0, routing_1.isValidSchemaName)(routingSchema)) {
76
- log.warn(`[flush] invalid routing schema name: ${routingSchema}`);
77
- return;
78
- }
79
- const svc = await pgPool.query(`SELECT hostname
80
- FROM "${routingSchema}".domains
81
- WHERE database_id = $1`, [databaseId]);
73
+ // The domains plane sits behind member RLS; the login reads it through the
74
+ // login-only SECURITY DEFINER reader, which resolves the plane itself.
75
+ const svc = await pgPool.query(`SELECT hostname FROM function_resolution.api_hostnames($1) AS hostname`, [databaseId]);
82
76
  if (svc.rowCount === 0)
83
77
  return;
84
78
  for (const row of svc.rows) {
@@ -25,14 +25,8 @@ export interface ResolvedRoute {
25
25
  tls_status: string | null;
26
26
  tls_secret_name: string | null;
27
27
  }
28
- /** Published logical name of the database-scope routing plane. */
29
- export declare const DEFAULT_ROUTING_SCHEMA = "routing_public";
30
- /** The routing-plane schema in effect: configured override or the published default. */
31
- export declare const getRoutingSchema: (opts: {
32
- api?: {
33
- routingSchema?: string;
34
- };
35
- }) => string;
28
+ /** The scoped routing plane the server resolves hosts on first. */
29
+ export declare const ROUTING_SCHEMA = "routing_public";
36
30
  export declare const isValidSchemaName: (name: string) => boolean;
37
31
  /**
38
32
  * Resolve a hostname through the compiled scoped-routing plane (host-only:
@@ -42,6 +36,39 @@ export declare const isValidSchemaName: (name: string) => boolean;
42
36
  * treats it as a hard no-match (→ 404); there is no legacy fallback.
43
37
  */
44
38
  export declare const resolveRoute: (pool: Pool, schema: string, host: string) => Promise<ResolvedRoute | null>;
39
+ /**
40
+ * Config keys the scoped api surface folds into `config` (and route
41
+ * `resolved_config`) that the server maps onto ApiStructure.
42
+ */
43
+ /**
44
+ * A tenant database's own database-scope routing plane: one `route_module`
45
+ * row whose resolver keeps the resolve_route contract.
46
+ */
47
+ export interface DatabaseRoutingPlane {
48
+ databaseId: string;
49
+ schema: string;
50
+ resolver: string;
51
+ }
52
+ /**
53
+ * The database-scope routing planes after the server's own `routing_public` plane, in
54
+ * the static gateway's order (by database id). The list spans every tenant,
55
+ * so it comes from a SECURITY DEFINER reader granted to the platform login
56
+ * alone, on the login's own connection: no role switch, no RLS bypass. A plane
57
+ * whose registration names no schema or resolver is an incomplete install and
58
+ * fails loud.
59
+ */
60
+ export declare const loadDatabaseRoutingPlanes: (pool: Pool) => Promise<DatabaseRoutingPlane[]>;
61
+ export interface PlaneRouteMatch {
62
+ route: ResolvedRoute;
63
+ /** Database whose own plane owns the host; null for `routing_public`. */
64
+ planeDatabaseId: string | null;
65
+ }
66
+ /**
67
+ * Resolve a host across routing planes: `routing_public` first, then
68
+ * each tenant database-scope plane. The first plane that matches owns the
69
+ * host; later planes are not consulted.
70
+ */
71
+ export declare const resolveRouteAcrossPlanes: (pool: Pool, host: string) => Promise<PlaneRouteMatch | null>;
45
72
  /**
46
73
  * Map a resolved api-target route onto the ApiStructure shape consumed by the
47
74
  * rest of the middleware chain. Returns null when the route target is not an
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.routeToApiStructure = exports.resolveRoute = exports.isValidSchemaName = exports.getRoutingSchema = exports.DEFAULT_ROUTING_SCHEMA = exports.requireApiRole = void 0;
3
+ exports.routeToApiStructure = exports.resolveRouteAcrossPlanes = exports.loadDatabaseRoutingPlanes = exports.resolveRoute = exports.isValidSchemaName = exports.ROUTING_SCHEMA = exports.requireApiRole = void 0;
4
4
  const logger_1 = require("@pgpmjs/logger");
5
+ const quotes_1 = require("@pgsql/quotes");
5
6
  const log = new logger_1.Logger('routing');
6
7
  // =============================================================================
7
8
  // Scoped routing plane (resolve_route contract)
@@ -29,15 +30,20 @@ const requireApiRole = (column, value, apiId) => {
29
30
  error.apiId = apiId;
30
31
  throw error;
31
32
  }
33
+ // administrator bypasses RLS; an API never serves as it.
34
+ if (value === 'administrator') {
35
+ const error = new Error(`API ${apiId ?? '<unknown>'} ${column} is administrator; APIs serve as request roles under RLS.`);
36
+ error.code = 'API_ROLE_FORBIDDEN';
37
+ error.column = column;
38
+ error.apiId = apiId;
39
+ throw error;
40
+ }
32
41
  return value;
33
42
  };
34
43
  exports.requireApiRole = requireApiRole;
35
44
  const RESOLVER_FUNCTION = 'resolve_route';
36
- /** Published logical name of the database-scope routing plane. */
37
- exports.DEFAULT_ROUTING_SCHEMA = 'routing_public';
38
- /** The routing-plane schema in effect: configured override or the published default. */
39
- const getRoutingSchema = (opts) => opts.api?.routingSchema || exports.DEFAULT_ROUTING_SCHEMA;
40
- exports.getRoutingSchema = getRoutingSchema;
45
+ /** The scoped routing plane the server resolves hosts on first. */
46
+ exports.ROUTING_SCHEMA = 'routing_public';
41
47
  const isValidSchemaName = (name) => /^[a-z_][a-z0-9_]*$/.test(name);
42
48
  exports.isValidSchemaName = isValidSchemaName;
43
49
  /**
@@ -73,6 +79,60 @@ const resolveRoute = async (pool, schema, host) => {
73
79
  }
74
80
  };
75
81
  exports.resolveRoute = resolveRoute;
82
+ /**
83
+ * The database-scope routing planes after the server's own `routing_public` plane, in
84
+ * the static gateway's order (by database id). The list spans every tenant,
85
+ * so it comes from a SECURITY DEFINER reader granted to the platform login
86
+ * alone, on the login's own connection: no role switch, no RLS bypass. A plane
87
+ * whose registration names no schema or resolver is an incomplete install and
88
+ * fails loud.
89
+ */
90
+ const loadDatabaseRoutingPlanes = async (pool) => {
91
+ const { rows } = await pool.query(`SELECT database_id, schema_name, resolver_function_name
92
+ FROM function_resolution.routing_planes()`);
93
+ return rows
94
+ .filter((row) => row.schema_name !== exports.ROUTING_SCHEMA)
95
+ .map((row) => {
96
+ if (!row.schema_name || !row.resolver_function_name) {
97
+ const error = new Error(`route_module for database ${row.database_id} names no ${row.schema_name ? 'resolver function' : 'public schema'}`);
98
+ error.code = 'ROUTING_PLANE_INCOMPLETE';
99
+ error.databaseId = row.database_id;
100
+ throw error;
101
+ }
102
+ return {
103
+ databaseId: row.database_id,
104
+ schema: row.schema_name,
105
+ resolver: row.resolver_function_name
106
+ };
107
+ });
108
+ };
109
+ exports.loadDatabaseRoutingPlanes = loadDatabaseRoutingPlanes;
110
+ const resolveOnPlane = async (pool, plane, host) => {
111
+ const resolver = quotes_1.QuoteUtils.quoteQualifiedIdentifier(plane.schema, plane.resolver);
112
+ const result = await pool.query(`SELECT * FROM ${resolver}($1, '/', NULL)`, [host]);
113
+ const row = result.rows[0];
114
+ return row && row.route_binding_id !== null ? row : null;
115
+ };
116
+ /**
117
+ * Resolve a host across routing planes: `routing_public` first, then
118
+ * each tenant database-scope plane. The first plane that matches owns the
119
+ * host; later planes are not consulted.
120
+ */
121
+ const resolveRouteAcrossPlanes = async (pool, host) => {
122
+ const route = await (0, exports.resolveRoute)(pool, exports.ROUTING_SCHEMA, host);
123
+ if (route)
124
+ return { route, planeDatabaseId: null };
125
+ for (const plane of await (0, exports.loadDatabaseRoutingPlanes)(pool)) {
126
+ const planeRoute = await resolveOnPlane(pool, plane, host);
127
+ if (planeRoute) {
128
+ log.debug(`[resolve-route] host=${host} owned by database plane ${plane.schema} (db=${plane.databaseId})`);
129
+ return { route: planeRoute, planeDatabaseId: plane.databaseId };
130
+ }
131
+ }
132
+ log.debug(`[resolve-route] no plane owns host=${host}`);
133
+ return null;
134
+ };
135
+ exports.resolveRouteAcrossPlanes = resolveRouteAcrossPlanes;
76
136
  /**
77
137
  * Map a resolved api-target route onto the ApiStructure shape consumed by the
78
138
  * rest of the middleware chain. Returns null when the route target is not an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.32.2",
3
+ "version": "5.32.3",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -43,11 +43,11 @@
43
43
  "@agentic-kit/ollama": "2.16.0",
44
44
  "@constructive-io/csrf": "^0.31.0",
45
45
  "@constructive-io/errors": "^0.25.0",
46
- "@constructive-io/express-context": "^0.35.0",
47
- "@constructive-io/graphql-env": "^3.37.1",
48
- "@constructive-io/graphql-types": "^3.36.0",
46
+ "@constructive-io/express-context": "^0.35.1",
47
+ "@constructive-io/graphql-env": "^3.37.2",
48
+ "@constructive-io/graphql-types": "^3.36.1",
49
49
  "@constructive-io/llm-env": "^0.16.0",
50
- "@constructive-io/query-builder": "^3.17.0",
50
+ "@constructive-io/query-builder": "^3.17.1",
51
51
  "@constructive-io/s3-utils": "^2.35.0",
52
52
  "@constructive-io/url-domains": "^2.32.0",
53
53
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
@@ -67,11 +67,11 @@
67
67
  "graphile-build-pg": "5.1.3",
68
68
  "graphile-cache": "^4.16.1",
69
69
  "graphile-config": "1.1.0",
70
- "graphile-function-bindings": "^1.18.1",
71
- "graphile-plugin-utils": "^1.14.0",
72
- "graphile-presigned-url-plugin": "^1.23.1",
73
- "graphile-scoped-introspection": "^0.5.0",
74
- "graphile-settings": "^6.26.2",
70
+ "graphile-function-bindings": "^1.18.2",
71
+ "graphile-plugin-utils": "^1.14.1",
72
+ "graphile-presigned-url-plugin": "^1.23.2",
73
+ "graphile-scoped-introspection": "^0.5.1",
74
+ "graphile-settings": "^6.26.3",
75
75
  "graphile-utils": "5.0.3",
76
76
  "graphql": "16.13.0",
77
77
  "graphql-upload": "^13.0.0",
@@ -94,11 +94,11 @@
94
94
  "@types/request-ip": "^0.0.41",
95
95
  "@types/supertest": "^7.2.1",
96
96
  "cookie-parser": "^1.4.7",
97
- "graphile-test": "5.18.1",
97
+ "graphile-test": "5.18.2",
98
98
  "makage": "^0.8.0",
99
99
  "nodemon": "^3.1.14",
100
100
  "supertest": "^7.2.2",
101
101
  "ts-node": "^10.9.2"
102
102
  },
103
- "gitHead": "91e18cc80da92331fb3aadd5baa43251c8934bc8"
103
+ "gitHead": "0b4ffa11a6d7958ba1068ce8e19616d4b018c0d6"
104
104
  }
@@ -6,7 +6,6 @@ const quotes_1 = require("@pgsql/quotes");
6
6
  const server_1 = require("@simplewebauthn/server");
7
7
  const helpers_1 = require("@simplewebauthn/server/helpers");
8
8
  const grafast_1 = require("grafast");
9
- const graphile_plugin_utils_1 = require("graphile-plugin-utils");
10
9
  const graphile_utils_1 = require("graphile-utils");
11
10
  const CONTEXT_KEY = 'constructiveWebauthn';
12
11
  const REGISTER_CHALLENGE = 'webauthn_register_challenge';
@@ -19,11 +18,8 @@ const fail = (code, message) => {
19
18
  err.extensions = { code };
20
19
  throw err;
21
20
  };
22
- /** Apply the request's settings transaction-locally, optionally overriding its role. */
23
- const applySettings = async (tx, pgSettings, role) => {
24
- const entries = Object.entries(pgSettings ?? {}).filter(([key]) => !role || key !== 'role');
25
- if (role)
26
- entries.push(['role', role]);
21
+ /** Apply settings transaction-locally. */
22
+ const applySettings = async (tx, entries) => {
27
23
  if (entries.length === 0)
28
24
  return;
29
25
  await tx.query({
@@ -32,11 +28,18 @@ const applySettings = async (tx, pgSettings, role) => {
32
28
  });
33
29
  };
34
30
  const withRequestLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
35
- await applySettings(tx, pgSettings);
31
+ await applySettings(tx, Object.entries(pgSettings ?? {}));
36
32
  return cb(tx);
37
33
  }));
34
+ /**
35
+ * The verifier lane calls the server-only procedures as the connection's own
36
+ * login, which alone holds EXECUTE on them. It carries the request's
37
+ * `jwt.claims.*` but never its `role`: as a request role those procedures are
38
+ * revoked, so any SQL caller could otherwise mint a session.
39
+ */
40
+ const verifierSettings = (pgSettings) => Object.entries(pgSettings ?? {}).filter(([key]) => key !== 'role');
38
41
  const withVerifierLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
39
- await applySettings(tx, pgSettings, graphile_plugin_utils_1.SYSTEM_LANE_ROLE);
42
+ await applySettings(tx, verifierSettings(pgSettings));
40
43
  return cb(tx);
41
44
  }));
42
45
  const procedure = (settings, name) => quotes_1.QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
@@ -95,11 +95,11 @@ exports.recordRefusal = recordRefusal;
95
95
  * that was missing named — rather than returning a partial identity.
96
96
  */
97
97
  const resolvePlatformFlushIdentity = async (pool, principalName = exports.PLATFORM_BOOTSTRAP_PRINCIPAL) => {
98
- const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
99
- if (database.rowCount !== 1) {
100
- throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
101
- }
102
- const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
98
+ // Both reads cross RLS before any request exists, so they go through
99
+ // SECURITY DEFINER resolvers granted to the login; the first raises when no
100
+ // platform database is registered.
101
+ const database = await pool.query(`SELECT app_scope.platform_database_id() AS id`);
102
+ const principal = await pool.query(`SELECT user_id, bypass_step_up FROM function_resolution.service_principal($1)`, [principalName]);
103
103
  if (principal.rowCount !== 1) {
104
104
  throw new Error(`refusals: no service principal named '${principalName}'`);
105
105
  }
package/server.js CHANGED
@@ -38,7 +38,6 @@ const debug_memory_1 = require("./middleware/observability/debug-memory");
38
38
  const guard_1 = require("./middleware/observability/guard");
39
39
  const request_logger_1 = require("./middleware/observability/request-logger");
40
40
  const request_protection_1 = require("./middleware/request-protection");
41
- const routing_1 = require("./middleware/routing");
42
41
  const standing_1 = require("./middleware/standing");
43
42
  const recorder_1 = require("./refusals/recorder");
44
43
  const log = new logger_1.Logger('server');
@@ -98,7 +97,6 @@ class Server {
98
97
  serverHost: effectiveOpts.server?.host,
99
98
  serverPort: effectiveOpts.server?.port,
100
99
  apiIsPublic: apiOpts.isPublic,
101
- routingSchema: apiOpts.routingSchema,
102
100
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
103
101
  observabilityEnabled
104
102
  });
@@ -151,8 +149,7 @@ class Server {
151
149
  app.use(authenticate);
152
150
  app.use((0, express_context_1.createContextMiddleware)({
153
151
  pg: effectiveOpts.pg,
154
- loaders: (0, express_context_1.createDefaultRegistry)(),
155
- routingSchema: (0, routing_1.getRoutingSchema)(effectiveOpts)
152
+ loaders: (0, express_context_1.createDefaultRegistry)()
156
153
  }));
157
154
  // A suspended (or unknown) database is refused before anything is spent on
158
155
  // the request; billing and platform admins set that state, the loader reads it.