@constructive-io/graphql-server 5.32.1 → 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,17 +1,10 @@
1
1
  import { resolvePreset } from 'graphile-config';
2
+ import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection';
2
3
  import { makePgService } from 'graphile-settings';
3
- const SCOPED_INTROSPECTION_PLUGIN = 'ConstructivePgIntrospectionPlugin';
4
- let scopedIntrospectionPresetPromise;
5
- const loadScopedIntrospectionPreset = () => {
6
- scopedIntrospectionPresetPromise ??=
7
- import('graphile-scoped-introspection').then(({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset);
8
- return scopedIntrospectionPresetPromise;
9
- };
4
+ const SERVICE_NAME = 'main';
10
5
  /**
11
- * Resolve caller-provided Graphile configuration before deciding whether the
12
- * optional scoped introspection package is needed. Graphile config applies
13
- * nested `extends` entries before the containing preset, so this preserves the
14
- * same precedence used by the eventual schema build.
6
+ * Resolve caller-provided Graphile configuration with the same `extends`
7
+ * precedence used by the eventual schema build.
15
8
  */
16
9
  export const resolveCallerPreset = (graphileOptions) => {
17
10
  const preset = graphileOptions?.preset;
@@ -21,45 +14,29 @@ export const resolveCallerPreset = (graphileOptions) => {
21
14
  ];
22
15
  return presets.length > 0 ? resolvePreset({ extends: presets }) : undefined;
23
16
  };
24
- const hasLocalScopedIntrospectionPreset = (preset) => preset?.plugins.some((plugin) => plugin.name === SCOPED_INTROSPECTION_PLUGIN) ??
25
- false;
26
- const hasUpstreamIntrospectionDisabled = (preset) => preset?.disablePlugins.includes('PgIntrospectionPlugin') ?? false;
17
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
27
18
  /**
28
- * Select the stock or scoped introspection wiring once, while constructing a
29
- * server-owned schema handler. The stock branch returns before the scoped
30
- * package (and its upstream contract sentinel) is loaded.
19
+ * Every server handler introspects only its API's schemas and their dependency
20
+ * closure. Stock introspection reads the whole database into each cached
21
+ * handler, so it is not an option here: `main: false` is rejected. Caller
22
+ * options for `main` (catalog policy, capability extensions) are kept.
31
23
  */
32
- export const makeIntrospectionWiring = async (pool, schemas, graphileOptions, loadScopedPreset = loadScopedIntrospectionPreset, introspectionRole) => {
24
+ export const makeIntrospectionWiring = (pool, schemas, graphileOptions, introspectionRole) => {
33
25
  const pgSettingsForIntrospection = introspectionRole ? { role: introspectionRole } : undefined;
34
26
  const pgService = makePgService({ pool, schemas, pgSettingsForIntrospection });
35
- const callerPreset = resolveCallerPreset(graphileOptions);
36
- const scopedConfig = callerPreset?.gather?.pgScopedIntrospection;
37
- const hasScopedConfiguration = scopedConfig !== undefined;
38
- // An explicitly supplied local preset already installs the replacement
39
- // plugin. Keep that path synchronous and avoid importing the optional
40
- // package a second time. A raw plugin entry may omit the stock plugin's
41
- // disablement, so add that narrow preset to keep the replacement atomic.
42
- if (hasLocalScopedIntrospectionPreset(callerPreset)) {
43
- return {
44
- presets: hasUpstreamIntrospectionDisabled(callerPreset)
45
- ? []
46
- : [{ disablePlugins: ['PgIntrospectionPlugin'] }],
47
- pgService
48
- };
27
+ const configured = resolveCallerPreset(graphileOptions)?.gather?.pgScopedIntrospection;
28
+ if (configured !== undefined && !isPlainObject(configured)) {
29
+ throw new Error('pgScopedIntrospection must be an object keyed by PostgreSQL service name');
49
30
  }
50
- if (!hasScopedConfiguration) {
51
- return {
52
- presets: [],
53
- pgService
54
- };
31
+ if (configured?.[SERVICE_NAME] === false) {
32
+ throw new Error(`Schema-scoped introspection cannot be disabled for the server's '${SERVICE_NAME}' service: stock introspection retains the whole database in every cached handler`);
55
33
  }
56
- // Load the replacement whenever the map is supplied, including an empty map,
57
- // entries set to false, or entries keyed by an unknown service. The
58
- // replacement owns validation; leaving it out would cause Graphile's stock
59
- // plugin to silently ignore the gather option in those cases.
60
- const scopedPreset = await loadScopedPreset();
61
34
  return {
62
- presets: [scopedPreset],
63
- pgService
35
+ presets: [ScopedIntrospectionPreset],
36
+ pgService,
37
+ pgScopedIntrospection: {
38
+ ...configured,
39
+ [SERVICE_NAME]: configured?.[SERVICE_NAME] ?? true
40
+ }
64
41
  };
65
42
  };
@@ -2,7 +2,7 @@ import './types'; // for Request type
2
2
  import { errors } from '@constructive-io/errors';
3
3
  import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
4
4
  import { Logger } from '@pgpmjs/logger';
5
- import { createGraphileInstance, graphileCache } from 'graphile-cache';
5
+ import { cacheGraphileInstance, createGraphileInstance, graphileCache } from 'graphile-cache';
6
6
  import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
7
7
  import { createConstructivePreset, createGrafastCacheLimitsPreset } from 'graphile-settings';
8
8
  import { getPgPool } from 'pg-cache';
@@ -56,8 +56,8 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
56
56
  * plugin preset. Without settings the default preset is used
57
57
  * (everything on except aggregates).
58
58
  */
59
- const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
60
- const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole);
59
+ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
60
+ const introspection = makeIntrospectionWiring(pool, schemas, graphileOptions, introspectionRole);
61
61
  const configuredPreset = graphileOptions?.preset ?? {};
62
62
  const grafastCachePreset = createGrafastCacheLimitsPreset(graphileOptions?.grafastCache);
63
63
  return {
@@ -100,6 +100,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
100
100
  : [])
101
101
  ],
102
102
  pgServices: [introspection.pgService],
103
+ gather: {
104
+ ...configuredPreset.gather,
105
+ pgScopedIntrospection: introspection.pgScopedIntrospection
106
+ },
103
107
  grafserv: {
104
108
  graphqlPath: '/graphql',
105
109
  graphiqlPath: '/graphiql',
@@ -279,7 +283,7 @@ export const graphile = (opts) => {
279
283
  return instance.handler(req, res, next);
280
284
  }
281
285
  catch (error) {
282
- log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
286
+ log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`, error);
283
287
  // Fall through to Phase C to retry creation
284
288
  }
285
289
  }
@@ -311,8 +315,8 @@ export const graphile = (opts) => {
311
315
  // concurrent requests coalesce while optional plugin modules load.
312
316
  const creationPromise = (async () => {
313
317
  const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
314
- const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
315
- return observeGraphileBuild({
318
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
319
+ const instance = await observeGraphileBuild({
316
320
  cacheKey: key,
317
321
  serviceKey: key,
318
322
  databaseId: api.databaseId ?? null
@@ -321,11 +325,14 @@ export const graphile = (opts) => {
321
325
  cacheKey: key,
322
326
  enableRealtime: api.databaseSettings?.enableRealtime
323
327
  }), { enabled: observabilityEnabled });
328
+ // Admission is part of the shared promise: a handler the cache rejects
329
+ // has been released, so coalesced waiters must not be handed it.
330
+ await cacheGraphileInstance(key, instance);
331
+ return instance;
324
332
  })();
325
333
  creating.set(key, creationPromise);
326
334
  try {
327
335
  const instance = await creationPromise;
328
- graphileCache.set(key, instance);
329
336
  log.info(`${label} Cached PostGraphile v5 handler key=${key} db=${dbname}`);
330
337
  return instance.handler(req, res, next);
331
338
  }
@@ -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) {
@@ -1,23 +1,22 @@
1
- import type { GraphileOptions } from '@constructive-io/graphql-types';
1
+ import type { GraphileOptions, PgScopedIntrospectionConfig } from '@constructive-io/graphql-types';
2
2
  import { type GraphileConfig } from 'graphile-config';
3
3
  import type { Pool } from 'pg';
4
4
  export interface IntrospectionWiring {
5
5
  presets: GraphileConfig.Preset[];
6
6
  pgService: GraphileConfig.PgServiceConfiguration;
7
+ pgScopedIntrospection: PgScopedIntrospectionConfig;
7
8
  }
8
- export type ScopedIntrospectionPresetLoader = () => Promise<GraphileConfig.Preset>;
9
9
  type ResolvedCallerPreset = GraphileConfig.ResolvedPreset | undefined;
10
10
  /**
11
- * Resolve caller-provided Graphile configuration before deciding whether the
12
- * optional scoped introspection package is needed. Graphile config applies
13
- * nested `extends` entries before the containing preset, so this preserves the
14
- * same precedence used by the eventual schema build.
11
+ * Resolve caller-provided Graphile configuration with the same `extends`
12
+ * precedence used by the eventual schema build.
15
13
  */
16
14
  export declare const resolveCallerPreset: (graphileOptions: GraphileOptions | undefined) => ResolvedCallerPreset;
17
15
  /**
18
- * Select the stock or scoped introspection wiring once, while constructing a
19
- * server-owned schema handler. The stock branch returns before the scoped
20
- * package (and its upstream contract sentinel) is loaded.
16
+ * Every server handler introspects only its API's schemas and their dependency
17
+ * closure. Stock introspection reads the whole database into each cached
18
+ * handler, so it is not an option here: `main: false` is rejected. Caller
19
+ * options for `main` (catalog policy, capability extensions) are kept.
21
20
  */
22
- export declare const makeIntrospectionWiring: (pool: Pool, schemas: string[], graphileOptions: GraphileOptions | undefined, loadScopedPreset?: ScopedIntrospectionPresetLoader, introspectionRole?: string) => Promise<IntrospectionWiring>;
21
+ export declare const makeIntrospectionWiring: (pool: Pool, schemas: string[], graphileOptions: GraphileOptions | undefined, introspectionRole?: string) => IntrospectionWiring;
23
22
  export {};
@@ -2,19 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.makeIntrospectionWiring = exports.resolveCallerPreset = void 0;
4
4
  const graphile_config_1 = require("graphile-config");
5
+ const graphile_scoped_introspection_1 = require("graphile-scoped-introspection");
5
6
  const graphile_settings_1 = require("graphile-settings");
6
- const SCOPED_INTROSPECTION_PLUGIN = 'ConstructivePgIntrospectionPlugin';
7
- let scopedIntrospectionPresetPromise;
8
- const loadScopedIntrospectionPreset = () => {
9
- scopedIntrospectionPresetPromise ??=
10
- import('graphile-scoped-introspection').then(({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset);
11
- return scopedIntrospectionPresetPromise;
12
- };
7
+ const SERVICE_NAME = 'main';
13
8
  /**
14
- * Resolve caller-provided Graphile configuration before deciding whether the
15
- * optional scoped introspection package is needed. Graphile config applies
16
- * nested `extends` entries before the containing preset, so this preserves the
17
- * same precedence used by the eventual schema build.
9
+ * Resolve caller-provided Graphile configuration with the same `extends`
10
+ * precedence used by the eventual schema build.
18
11
  */
19
12
  const resolveCallerPreset = (graphileOptions) => {
20
13
  const preset = graphileOptions?.preset;
@@ -25,46 +18,30 @@ const resolveCallerPreset = (graphileOptions) => {
25
18
  return presets.length > 0 ? (0, graphile_config_1.resolvePreset)({ extends: presets }) : undefined;
26
19
  };
27
20
  exports.resolveCallerPreset = resolveCallerPreset;
28
- const hasLocalScopedIntrospectionPreset = (preset) => preset?.plugins.some((plugin) => plugin.name === SCOPED_INTROSPECTION_PLUGIN) ??
29
- false;
30
- const hasUpstreamIntrospectionDisabled = (preset) => preset?.disablePlugins.includes('PgIntrospectionPlugin') ?? false;
21
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
31
22
  /**
32
- * Select the stock or scoped introspection wiring once, while constructing a
33
- * server-owned schema handler. The stock branch returns before the scoped
34
- * package (and its upstream contract sentinel) is loaded.
23
+ * Every server handler introspects only its API's schemas and their dependency
24
+ * closure. Stock introspection reads the whole database into each cached
25
+ * handler, so it is not an option here: `main: false` is rejected. Caller
26
+ * options for `main` (catalog policy, capability extensions) are kept.
35
27
  */
36
- const makeIntrospectionWiring = async (pool, schemas, graphileOptions, loadScopedPreset = loadScopedIntrospectionPreset, introspectionRole) => {
28
+ const makeIntrospectionWiring = (pool, schemas, graphileOptions, introspectionRole) => {
37
29
  const pgSettingsForIntrospection = introspectionRole ? { role: introspectionRole } : undefined;
38
30
  const pgService = (0, graphile_settings_1.makePgService)({ pool, schemas, pgSettingsForIntrospection });
39
- const callerPreset = (0, exports.resolveCallerPreset)(graphileOptions);
40
- const scopedConfig = callerPreset?.gather?.pgScopedIntrospection;
41
- const hasScopedConfiguration = scopedConfig !== undefined;
42
- // An explicitly supplied local preset already installs the replacement
43
- // plugin. Keep that path synchronous and avoid importing the optional
44
- // package a second time. A raw plugin entry may omit the stock plugin's
45
- // disablement, so add that narrow preset to keep the replacement atomic.
46
- if (hasLocalScopedIntrospectionPreset(callerPreset)) {
47
- return {
48
- presets: hasUpstreamIntrospectionDisabled(callerPreset)
49
- ? []
50
- : [{ disablePlugins: ['PgIntrospectionPlugin'] }],
51
- pgService
52
- };
31
+ const configured = (0, exports.resolveCallerPreset)(graphileOptions)?.gather?.pgScopedIntrospection;
32
+ if (configured !== undefined && !isPlainObject(configured)) {
33
+ throw new Error('pgScopedIntrospection must be an object keyed by PostgreSQL service name');
53
34
  }
54
- if (!hasScopedConfiguration) {
55
- return {
56
- presets: [],
57
- pgService
58
- };
35
+ if (configured?.[SERVICE_NAME] === false) {
36
+ throw new Error(`Schema-scoped introspection cannot be disabled for the server's '${SERVICE_NAME}' service: stock introspection retains the whole database in every cached handler`);
59
37
  }
60
- // Load the replacement whenever the map is supplied, including an empty map,
61
- // entries set to false, or entries keyed by an unknown service. The
62
- // replacement owns validation; leaving it out would cause Graphile's stock
63
- // plugin to silently ignore the gather option in those cases.
64
- const scopedPreset = await loadScopedPreset();
65
38
  return {
66
- presets: [scopedPreset],
67
- pgService
39
+ presets: [graphile_scoped_introspection_1.ScopedIntrospectionPreset],
40
+ pgService,
41
+ pgScopedIntrospection: {
42
+ ...configured,
43
+ [SERVICE_NAME]: configured?.[SERVICE_NAME] ?? true
44
+ }
68
45
  };
69
46
  };
70
47
  exports.makeIntrospectionWiring = makeIntrospectionWiring;
@@ -62,8 +62,8 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
62
62
  * plugin preset. Without settings the default preset is used
63
63
  * (everything on except aggregates).
64
64
  */
65
- const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
66
- const introspection = await (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, undefined, introspectionRole);
65
+ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
66
+ const introspection = (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, introspectionRole);
67
67
  const configuredPreset = graphileOptions?.preset ?? {};
68
68
  const grafastCachePreset = (0, graphile_settings_1.createGrafastCacheLimitsPreset)(graphileOptions?.grafastCache);
69
69
  return {
@@ -106,6 +106,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
106
106
  : [])
107
107
  ],
108
108
  pgServices: [introspection.pgService],
109
+ gather: {
110
+ ...configuredPreset.gather,
111
+ pgScopedIntrospection: introspection.pgScopedIntrospection
112
+ },
109
113
  grafserv: {
110
114
  graphqlPath: '/graphql',
111
115
  graphiqlPath: '/graphiql',
@@ -285,7 +289,7 @@ const graphile = (opts) => {
285
289
  return instance.handler(req, res, next);
286
290
  }
287
291
  catch (error) {
288
- log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
292
+ log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`, error);
289
293
  // Fall through to Phase C to retry creation
290
294
  }
291
295
  }
@@ -317,8 +321,8 @@ const graphile = (opts) => {
317
321
  // concurrent requests coalesce while optional plugin modules load.
318
322
  const creationPromise = (async () => {
319
323
  const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
320
- const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
321
- return (0, graphile_build_stats_1.observeGraphileBuild)({
324
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
325
+ const instance = await (0, graphile_build_stats_1.observeGraphileBuild)({
322
326
  cacheKey: key,
323
327
  serviceKey: key,
324
328
  databaseId: api.databaseId ?? null
@@ -327,11 +331,14 @@ const graphile = (opts) => {
327
331
  cacheKey: key,
328
332
  enableRealtime: api.databaseSettings?.enableRealtime
329
333
  }), { enabled: observabilityEnabled });
334
+ // Admission is part of the shared promise: a handler the cache rejects
335
+ // has been released, so coalesced waiters must not be handed it.
336
+ await (0, graphile_cache_1.cacheGraphileInstance)(key, instance);
337
+ return instance;
330
338
  })();
331
339
  creating.set(key, creationPromise);
332
340
  try {
333
341
  const instance = await creationPromise;
334
- graphile_cache_1.graphileCache.set(key, instance);
335
342
  log.info(`${label} Cached PostGraphile v5 handler key=${key} db=${dbname}`);
336
343
  return instance.handler(req, res, next);
337
344
  }
@@ -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.1",
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.0",
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",
@@ -65,13 +65,13 @@
65
65
  "grafserv": "1.0.1",
66
66
  "graphile-build": "5.1.1",
67
67
  "graphile-build-pg": "5.1.3",
68
- "graphile-cache": "^4.16.0",
68
+ "graphile-cache": "^4.16.1",
69
69
  "graphile-config": "1.1.0",
70
- "graphile-function-bindings": "^1.18.0",
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.1",
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.0",
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": "6cbaa8605270a2e019439fd9f444a35b3f2a9de0"
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.