@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 +6 -7
- package/esm/middleware/api.js +22 -34
- package/esm/middleware/flush.js +3 -9
- package/esm/middleware/routing.js +63 -4
- package/esm/plugins/webauthn-plugin.js +11 -8
- package/esm/refusals/recorder.js +5 -5
- package/esm/server.js +1 -4
- package/middleware/api.js +21 -33
- package/middleware/flush.js +3 -9
- package/middleware/routing.d.ts +35 -8
- package/middleware/routing.js +66 -6
- package/package.json +12 -12
- package/plugins/webauthn-plugin.js +11 -8
- package/refusals/recorder.js +5 -5
- package/server.js +1 -4
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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` |
|
package/esm/middleware/api.js
CHANGED
|
@@ -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 {
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
24
|
-
SELECT
|
|
25
|
-
|
|
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: '
|
|
160
|
-
roleName: '
|
|
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,
|
|
174
|
-
const
|
|
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,
|
|
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
|
|
230
|
-
|
|
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;
|
package/esm/middleware/flush.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
33
|
-
export const
|
|
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
|
|
20
|
-
const applySettings = async (tx,
|
|
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
|
|
39
|
+
await applySettings(tx, verifierSettings(pgSettings));
|
|
37
40
|
return cb(tx);
|
|
38
41
|
}));
|
|
39
42
|
const procedure = (settings, name) => QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
|
package/esm/refusals/recorder.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const principal = await pool.query(`SELECT user_id, bypass_step_up FROM
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
30
|
-
SELECT
|
|
31
|
-
|
|
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: '
|
|
168
|
-
roleName: '
|
|
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,
|
|
182
|
-
const
|
|
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,
|
|
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
|
|
238
|
-
|
|
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;
|
package/middleware/flush.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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) {
|
package/middleware/routing.d.ts
CHANGED
|
@@ -25,14 +25,8 @@ export interface ResolvedRoute {
|
|
|
25
25
|
tls_status: string | null;
|
|
26
26
|
tls_secret_name: string | null;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
29
|
-
export declare const
|
|
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
|
package/middleware/routing.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.routeToApiStructure = exports.
|
|
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
|
-
/**
|
|
37
|
-
exports.
|
|
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.
|
|
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.
|
|
47
|
-
"@constructive-io/graphql-env": "^3.37.
|
|
48
|
-
"@constructive-io/graphql-types": "^3.36.
|
|
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.
|
|
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.
|
|
71
|
-
"graphile-plugin-utils": "^1.14.
|
|
72
|
-
"graphile-presigned-url-plugin": "^1.23.
|
|
73
|
-
"graphile-scoped-introspection": "^0.5.
|
|
74
|
-
"graphile-settings": "^6.26.
|
|
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.
|
|
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": "
|
|
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
|
|
23
|
-
const applySettings = async (tx,
|
|
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
|
|
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);
|
package/refusals/recorder.js
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const principal = await pool.query(`SELECT user_id, bypass_step_up FROM
|
|
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.
|