diff --git a/graphql/server-test/__fixtures__/seed/error-events/events-module.sql b/graphql/server-test/__fixtures__/seed/error-events/events-module.sql index 0db01c24f..dceaeb90b 100644 --- a/graphql/server-test/__fixtures__/seed/error-events/events-module.sql +++ b/graphql/server-test/__fixtures__/seed/error-events/events-module.sql @@ -4,11 +4,11 @@ SET session_replication_role TO replica; INSERT INTO metaschema_modules_public.events_module - (id, database_id, scope, schema_id, private_schema_id, public_schema_name, private_schema_name, events_table_name, record_event) + (id, database_id, scope, schema_id, private_schema_id, public_schema_name, private_schema_name, events_table_name, record_event, record_error) VALUES ('6dba0004-0000-4000-8000-000000000001', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'app', '6dba0001-0000-4000-8000-000000000002', '6dba0001-0000-4000-8000-000000000003', - 'simple-pets-events-public', 'simple-pets-events-private', 'app_events', 'record_event') + 'simple-pets-events-public', 'simple-pets-events-private', 'app_events', 'record_event', 'record_error') ON CONFLICT (id) DO NOTHING; SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__fixtures__/seed/error-events/schema.sql b/graphql/server-test/__fixtures__/seed/error-events/schema.sql index edcfba6ce..ca34d56e1 100644 --- a/graphql/server-test/__fixtures__/seed/error-events/schema.sql +++ b/graphql/server-test/__fixtures__/seed/error-events/schema.sql @@ -1,8 +1,8 @@ -- Error-events fixture: a stand-in auth module, a mutation that is refused with -- a structured registry code (PRINCIPAL_CHILD_WIDENS, in the errors.raise_error -- shape: MESSAGE = code, DETAIL = {code, context, class}), and a stand-in --- events module with the tenant `record_event` the server resolves through --- metaschema_modules_public.events_module. +-- events module with the tenant `record_event`/`record_error` the server +-- resolves through metaschema_modules_public.events_module. -- -- Compose after app-schemas/simple-pets/schema.sql and scoped/test-data.sql. @@ -75,6 +75,16 @@ CREATE TABLE "simple-pets-events-public".app_events ( ); GRANT SELECT, INSERT ON "simple-pets-events-public".app_events TO administrator, authenticated; +-- Event classification: an unregistered name defaults to feeds_levels = true, +-- so `record_error` registers every code it records as an error that earns no +-- ladder progress. +CREATE TABLE "simple-pets-events-public".event_types ( + name text PRIMARY KEY, + category text NOT NULL, + feeds_levels boolean NOT NULL DEFAULT true +); +GRANT SELECT, INSERT ON "simple-pets-events-public".event_types TO administrator, authenticated; + CREATE FUNCTION "simple-pets-events-private".record_event( step text, actor_id uuid DEFAULT NULL, @@ -85,6 +95,21 @@ CREATE FUNCTION "simple-pets-events-private".record_event( $$ LANGUAGE sql VOLATILE; GRANT EXECUTE ON FUNCTION "simple-pets-events-private".record_event(text, uuid, jsonb) TO administrator, authenticated; +CREATE FUNCTION "simple-pets-events-private".record_error( + code text, + actor_id uuid DEFAULT NULL, + payload jsonb DEFAULT NULL +) RETURNS void AS $$ + WITH registered AS ( + INSERT INTO "simple-pets-events-public".event_types (name, category, feeds_levels) + VALUES (code, 'error', false) + ON CONFLICT (name) DO NOTHING + ) + INSERT INTO "simple-pets-events-public".app_events (name, actor_id, count, payload) + VALUES (code, actor_id, 1, payload); +$$ LANGUAGE sql VOLATILE; +GRANT EXECUTE ON FUNCTION "simple-pets-events-private".record_error(text, uuid, jsonb) TO administrator, authenticated; + -- ─── Metaschema registration ───────────────────────────────────────────────── SET session_replication_role TO replica; diff --git a/graphql/server-test/__tests__/error-events-no-module.integration.test.ts b/graphql/server-test/__tests__/error-events-no-module.integration.test.ts index 53998c2f6..cab325234 100644 --- a/graphql/server-test/__tests__/error-events-no-module.integration.test.ts +++ b/graphql/server-test/__tests__/error-events-no-module.integration.test.ts @@ -25,7 +25,7 @@ afterAll(async () => { await teardown(); }); -describe('graphql.error (endpoint without an events module)', () => { +describe('refusal events (endpoint without an events module)', () => { it('returns the refusal without error and records nothing', async () => { const res = await refuse(request, 'principal-token'); diff --git a/graphql/server-test/__tests__/error-events.integration.test.ts b/graphql/server-test/__tests__/error-events.integration.test.ts index abbe79de9..cee21ee6f 100644 --- a/graphql/server-test/__tests__/error-events.integration.test.ts +++ b/graphql/server-test/__tests__/error-events.integration.test.ts @@ -2,11 +2,12 @@ * Error-events integration tests * * When an authenticated mutation is refused with a structured registry code, - * the server records `graphql.error` for the actor (principal, else user) - * through the tenant's events module `record_event` (resolved from + * the server records an event named after that code for the actor (principal, + * else user) through the tenant's events module `record_error` (resolved from * metaschema_modules_public.events_module), in a fresh transaction after the * failed mutation. The client response is unchanged; the database decides what - * each code means (e.g. PRINCIPAL_CHILD_WIDENS demotes a principal). + * each code means (e.g. PRINCIPAL_CHILD_WIDENS demotes a principal) and + * classifies it as an error that earns no ladder progress. * * The no-events-module case lives in error-events-no-module.integration so * each suite has its own process-wide module-loader cache. @@ -18,7 +19,7 @@ import type { PgTestClient } from 'pgsql-test/test-client'; import type supertest from 'supertest'; -import { connect, events, HUMAN_ID, PRINCIPAL_ID, refuse } from './error-events.shared'; +import { connect, events, eventTypes, HUMAN_ID, PRINCIPAL_ID, refuse } from './error-events.shared'; jest.setTimeout(30000); @@ -34,7 +35,7 @@ afterAll(async () => { await teardown(); }); -describe('graphql.error (endpoint with an events module)', () => { +describe('refusal events (endpoint with an events module)', () => { it('records the refusal for a principal, with the principal as actor', async () => { const res = await refuse(request, 'principal-token'); @@ -46,12 +47,16 @@ describe('graphql.error (endpoint with an events module)', () => { const { rows } = await events(pg); expect(rows).toEqual([ { - name: 'graphql.error', + name: 'PRINCIPAL_CHILD_WIDENS', actor_id: PRINCIPAL_ID, - payload: { code: 'PRINCIPAL_CHILD_WIDENS', operation: 'WidenChild' }, + payload: { operation: 'WidenChild' }, request_id: 'refused-principal-token' } ]); + + expect((await eventTypes(pg)).rows).toEqual([ + { name: 'PRINCIPAL_CHILD_WIDENS', category: 'error', feeds_levels: false } + ]); }); it('records the refusal for a human, with the user as actor', async () => { @@ -63,11 +68,15 @@ describe('graphql.error (endpoint with an events module)', () => { const { rows } = await events(pg); expect(rows).toHaveLength(2); expect(rows[1]).toEqual({ - name: 'graphql.error', + name: 'PRINCIPAL_CHILD_WIDENS', actor_id: HUMAN_ID, - payload: { code: 'PRINCIPAL_CHILD_WIDENS', operation: 'WidenChild' }, + payload: { operation: 'WidenChild' }, request_id: 'refused-human-token' }); + + expect((await eventTypes(pg)).rows).toEqual([ + { name: 'PRINCIPAL_CHILD_WIDENS', category: 'error', feeds_levels: false } + ]); }); it('records nothing for an unauthenticated request', async () => { diff --git a/graphql/server-test/__tests__/error-events.shared.ts b/graphql/server-test/__tests__/error-events.shared.ts index e3103a92d..fa793d680 100644 --- a/graphql/server-test/__tests__/error-events.shared.ts +++ b/graphql/server-test/__tests__/error-events.shared.ts @@ -57,3 +57,6 @@ export const events = (pg: PgTestClient) => pg.query( `SELECT name, actor_id, payload, request_id FROM "simple-pets-events-public".app_events ORDER BY created_at` ); + +export const eventTypes = (pg: PgTestClient) => + pg.query(`SELECT name, category, feeds_levels FROM "simple-pets-events-public".event_types ORDER BY name`); diff --git a/graphql/server/src/plugins/__tests__/error-events-plugin.test.ts b/graphql/server/src/plugins/__tests__/error-events-plugin.test.ts index caa8813db..3ba76dcb6 100644 --- a/graphql/server/src/plugins/__tests__/error-events-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/error-events-plugin.test.ts @@ -1,15 +1,33 @@ -import { recordEventSql } from '../error-events-plugin'; +import { recordErrorSql } from '../error-events-plugin'; -describe('recordEventSql', () => { +describe('recordErrorSql', () => { it('quotes the events module identifiers', () => { - expect(recordEventSql({ privateSchemaName: 'app_events_private', recordEvent: 'record_event' })).toBe( - 'SELECT "app_events_private"."record_event"($1, $2::uuid, $3::jsonb)' - ); + expect( + recordErrorSql({ + privateSchemaName: 'app_events_private', + recordEvent: 'record_event', + recordError: 'record_error' + }) + ).toBe('SELECT "app_events_private"."record_error"($1, $2::uuid, $3::jsonb)'); }); it('escapes embedded quotes so identifiers cannot break out', () => { - expect(recordEventSql({ privateSchemaName: 'x"; DROP SCHEMA y; --', recordEvent: 'f' })).toBe( - 'SELECT "x""; DROP SCHEMA y; --"."f"($1, $2::uuid, $3::jsonb)' - ); + expect( + recordErrorSql({ + privateSchemaName: 'x"; DROP SCHEMA y; --', + recordEvent: 'record_event', + recordError: 'f' + }) + ).toBe('SELECT "x""; DROP SCHEMA y; --"."f"($1, $2::uuid, $3::jsonb)'); + }); + + it('throws for an events module without record_error, so the plugin logs it', () => { + expect(() => + recordErrorSql({ + privateSchemaName: 'app_events_private', + recordEvent: 'record_event', + recordError: null + }) + ).toThrow('app_events_private has no record_error function'); }); }); diff --git a/graphql/server/src/plugins/error-events-plugin.ts b/graphql/server/src/plugins/error-events-plugin.ts index 544a383a3..b2bb061be 100644 --- a/graphql/server/src/plugins/error-events-plugin.ts +++ b/graphql/server/src/plugins/error-events-plugin.ts @@ -13,8 +13,6 @@ import { normalizeError } from '../middleware/mask-error'; const log = new Logger('error-events'); -export const GRAPHQL_ERROR_EVENT = 'graphql.error'; - const getExpressRequest = ( requestContext: Partial | undefined ): Request | undefined => (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; @@ -32,25 +30,32 @@ const refusalCode = (errors: readonly GraphQLError[] | undefined): string | unde return undefined; }; -export const recordEventSql = (events: EventsConfig): string => - `SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordEvent)}($1, $2::uuid, $3::jsonb)`; +export const recordErrorSql = (events: EventsConfig): string => { + if (!events.recordError) { + throw new Error(`events module ${events.privateSchemaName} has no record_error function`); + } + return `SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordError)}($1, $2::uuid, $3::jsonb)`; +}; /** - * Records `graphql.error` when an authenticated mutation is refused with a - * structured registry code. The refusal rolled back the mutation's own - * transaction, so the event is written afterwards in a fresh transaction under - * the same request claims, via the tenant's events module `record_event`. + * Records a refused authenticated mutation as an event named after the error + * code raised by `errors.raise_error`. The refusal rolled back the mutation's + * own transaction, so the event is written afterwards in a fresh transaction + * under the same request claims, via the tenant's events module + * `record_error`, which classifies the code as an error that earns no ladder + * progress. * - * The server carries no policy about what a code means: the database reads - * `payload->>'code'` (e.g. PRINCIPAL_CHILD_WIDENS demoting a principal on the - * trust ladder). Endpoints without an events module record nothing. - * Unauthenticated requests are never recorded, so anonymous traffic cannot - * drive writes. The client response is never altered. + * The server carries no policy: the code is the event, and the database decides + * what it means — a ladder's `revoked_by` names the code directly (e.g. + * PRINCIPAL_CHILD_WIDENS demoting a principal on the trust ladder). Endpoints + * without an events module record nothing. Unauthenticated requests are never + * recorded, so anonymous traffic cannot drive writes. The client response is + * never altered. */ export const createErrorEventsPlugin = (pool: Pool): GraphileConfig.Plugin => ({ name: 'ErrorEventsPlugin', version: '0.0.0', - description: 'Records graphql.error through the tenant events module when an authenticated mutation is refused.', + description: 'Records a refused authenticated mutation as its error code through the tenant events module.', grafast: { middleware: { @@ -74,15 +79,11 @@ export const createErrorEventsPlugin = (pool: Pool): GraphileConfig.Plugin => ({ if (!events || !pgSettings) return result; const operation = args.operationName ?? getOperationAST(args.document)?.name?.value ?? null; await withPgClient(pool, pgSettings, (client) => - client.query(recordEventSql(events), [ - GRAPHQL_ERROR_EVENT, - actorId, - JSON.stringify({ code, operation }) - ]) + client.query(recordErrorSql(events), [code, actorId, JSON.stringify({ operation })]) ); } catch (err) { log.error( - `${label} failed to record ${GRAPHQL_ERROR_EVENT} (${code}) for ${actorId}: ${ + `${label} failed to record refusal ${code} for ${actorId}: ${ err instanceof Error ? err.message : String(err) }` ); diff --git a/packages/express-context/src/loaders/events.ts b/packages/express-context/src/loaders/events.ts index fe22bca56..bc45b359b 100644 --- a/packages/express-context/src/loaders/events.ts +++ b/packages/express-context/src/loaders/events.ts @@ -2,9 +2,9 @@ * Events Module Loader * * Resolves the tenant's app-scoped events module from - * metaschema_modules_public.events_module: the private schema and the name of - * its `record_event` function, so the server can record events without - * hard-coding the generated schema or function names. + * metaschema_modules_public.events_module: the private schema and the names of + * its `record_event` and `record_error` functions, so the server can record + * events without hard-coding the generated schema or function names. */ import type { EventsConfig } from '../types'; @@ -16,7 +16,8 @@ import type { LoaderContext, ModuleLoader } from './types'; const EVENTS_MODULE_SQL = ` SELECT s.schema_name AS private_schema_name, - em.record_event + em.record_event, + em.record_error FROM metaschema_modules_public.events_module em JOIN metaschema_public.schema s ON s.id = em.private_schema_id WHERE em.database_id = $1 @@ -29,6 +30,7 @@ const EVENTS_MODULE_SQL = ` interface EventsModuleRow { private_schema_name: string; record_event: string; + record_error: string | null; } // ─── Loader ───────────────────────────────────────────────────────────────── @@ -45,7 +47,8 @@ export const eventsLoader: ModuleLoader = createModuleLoader