Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
],
"dependencies": {
"@constructive-io/csrf": "workspace:^",
"@constructive-io/errors": "workspace:^",
"@constructive-io/express-context": "workspace:^",
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
Expand Down
75 changes: 62 additions & 13 deletions graphql/server/src/middleware/graphile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { classify, type ErrorContext, parse } from '@constructive-io/errors';
import { getNodeEnv } from '@pgpmjs/env';
import type { ComputeConfig } from '@constructive-io/express-context';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
Expand All @@ -20,6 +21,14 @@ import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';

const maskErrorLog = new Logger('graphile:maskError');

/**
* Transitional allowlist of transport/framework codes that are safe to surface
* but are not yet in the `@constructive-io/errors` registry (GraphQL protocol
* errors, plus auth codes pending registry generation in a later phase). A code
* is treated as public when it is classified `public` by the registry OR listed
* here. Once the registry is generated from constructive-db these entries move
* into the registry and this set shrinks to just the GraphQL protocol codes.
*/
const SAFE_ERROR_CODES = new Set([
// GraphQL standard
'GRAPHQL_VALIDATION_FAILED',
Expand Down Expand Up @@ -126,27 +135,67 @@ const SAFE_ERROR_CODES = new Set([
'23P01', // exclusion_violation
]);

/** A code is safe to surface when the registry classifies it public, or it is
* on the transitional transport allowlist. */
const isPublicCode = (code: string | null | undefined): boolean =>
Boolean(code) && (classify(code) === 'public' || SAFE_ERROR_CODES.has(code as string));

/**
* Production-aware error masking function.
* Normalize any GraphQL/database error into a canonical Constructive shape.
*
* In development: returns errors as-is for debugging.
* In production: returns errors with explicit codes from the SAFE_ERROR_CODES
* allowlist as-is, but masks unexpected/database errors with a reference ID
* and logs the original.
* Database errors surface through Grafast without a populated `extensions.code`
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
* underlying pg error at `originalError`). We parse `originalError` first so we
* can recover the structured code, then fall back to the GraphQL error itself.
*/
const normalizeError = (
error: GraphQLError,
): { code: string | null; context: ErrorContext; class: 'public' | 'internal' } => {
const original = (error as { originalError?: unknown }).originalError;
const fromOriginal = original ? parse(original) : null;
const parsed = fromOriginal?.code ? fromOriginal : parse(error);
return { code: parsed.code, context: parsed.context, class: parsed.class };
};

/**
* Production-aware error handling backed by `@constructive-io/errors`.
*
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
* always receive a machine-readable code (fixing the gap where database
* errors reached clients as a bare message with empty `extensions`).
* 2. Surface public (registered/allowlisted) errors as-is.
* 3. In development, pass everything through (enriched) for debugging.
* 4. In production, mask internal/unknown errors behind a reference ID and log
* the original.
*/
const maskError = (error: GraphQLError): GraphQLError | GraphQLFormattedError => {
if (getNodeEnv() === 'development') {
return error;
const { code, context, class: errorClass } = normalizeError(error);

// Lift the structured code onto extensions for every recognized error so
// clients always receive a machine-readable code (`extensions` is read-only
// on GraphQLError, so we build a formatted error rather than mutating it).
const extensions: Record<string, unknown> = { ...error.extensions };
if (code) {
extensions.code = code;
extensions.class = errorClass;
if (Object.keys(context).length > 0) {
extensions.context = context;
}
}

// Only expose errors with codes on the safe allowlist.
// Note: grafserv strips originalError and internal extensions before
// serializing to the client, so returning the full error object is safe here.
if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code as string)) {
return error;
const effectiveCode = code ?? (error.extensions?.code as string | undefined);
if (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
// Note: grafserv strips originalError and internal extensions before
// serializing to the client, so returning the enriched error is safe.
return {
message: error.message,
...(error.locations ? { locations: error.locations } : {}),
...(error.path ? { path: error.path } : {}),
extensions,
} as GraphQLFormattedError;
}

// Mask unexpected/database errors with a reference ID
// Mask internal/unknown errors with a reference ID.
const errorId = crypto.randomBytes(8).toString('hex');
maskErrorLog.error(`[masked-error:${errorId}]`, error);

Expand Down
61 changes: 61 additions & 0 deletions packages/errors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# @constructive-io/errors

Canonical Constructive error system. Zero runtime dependencies — usable by any
service or client without pulling in pgpm.

- **Registry** — the single source of truth mapping each stable `code` to its
classification (`public` vs `internal`), HTTP hint, and default message.
- **`parse(anyError)`** — normalize an error from any source (a
`ConstructiveError`, a node-postgres `DatabaseError`, a GraphQL error or
`{ errors: [...] }` wrapper, a plain `Error`, or a string) into a canonical
`{ code, context, class, known }`.
- **`format(code, context, locale)`** — render a localized, interpolated
message. `{{var}}` placeholders + registerable per-locale catalogs (i18n).
- **`errors.*` factory** — type-safe throwable builders derived from the
registry, e.g. `throw errors.MODULE_NOT_FOUND({ name })`.
- **`classify(code)`** — `public` or `internal`; unknown codes are `internal`
(fail safe) so transports never leak unregistered errors.

```ts
import { parse, format, errors, classify } from '@constructive-io/errors';

// Normalize a DB error surfaced through GraphQL
const parsed = parse(graphqlError);
if (parsed.class === 'public') {
showUser(format(parsed.code!, parsed.context, locale));
}

// Throw a structured error
throw errors.ACCOUNT_EXISTS();
```

## Design notes

- The machine `code` is deliberately separate from user-facing copy so codes
are stable and messages are localizable.
- `parse()` recovers structure from the code's precedence: structured `DETAIL`
JSON → GraphQL `extensions.code` → a leading ALL_CAPS token in the message
(legacy DB `RAISE`, incl. `CODE (arg, arg)` positional args) → native
SQLSTATE constraint mapping.
- The registry has two layers, merged at lookup time (curated wins):
- **Generated** (`src/generated/registry.generated.ts`) — every code raised via
`EXCEPTION`/`THROW` across constructive-db (deploy sources + generated output),
each with a `public`/`internal` class, HTTP hint, and default message.
- **Curated** (`src/registry.ts`) — refined, typed-context entries for the codes
that matter most (public auth/limit copy, native PostgreSQL constraint codes,
pgpm CLI codes). These override the generated entries.
- Unregistered codes still `parse()` and are classified `internal` (masked).

## Regenerating the full registry

The generated layer is produced from a committed audit snapshot:

```bash
python3 scripts/generate-registry.py
```

It reads `scripts/db-error-inventory.json` (the constructive-db error audit) and,
when a `constructive-io/dashboard` checkout is found (via `DASHBOARD_DIR` or a
sibling `../dashboard`), seeds public copy from its error catalogs. Re-run the
constructive-db audit and overwrite `scripts/db-error-inventory.json` before
regenerating when DB error codes change. Never hand-edit the generated file.
150 changes: 150 additions & 0 deletions packages/errors/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { allCodes, classify, ConstructiveError, errors, format, parse, registerCatalog } from '../src';
import {
GENERATED_CODE_COUNT,
GENERATED_CODE_META,
generatedRegistry
} from '../src/generated/registry.generated';

describe('parse', () => {
it('parses a bare ALL_CAPS DB message (legacy RAISE)', () => {
const result = parse({ message: 'ACCOUNT_EXISTS', code: 'P0001' });
expect(result.code).toBe('ACCOUNT_EXISTS');
expect(result.class).toBe('public');
expect(result.known).toBe(true);
});

it('recovers positional args from a dynamic DB message', () => {
const result = parse({ message: 'LIMIT_REACHED (api_keys, 5)', code: 'P0001' });
expect(result.code).toBe('LIMIT_REACHED');
expect(result.context).toEqual({ resource: 'api_keys', limit: 5 });
});

it('prefers structured DETAIL json over the message', () => {
const result = parse({
message: 'ACCOUNT_EXISTS',
code: 'CX001',
detail: '{"code":"ACCOUNT_EXISTS","context":{"email":"a@b.com"}}'
});
expect(result.code).toBe('ACCOUNT_EXISTS');
expect(result.context).toEqual({ email: 'a@b.com' });
});

it('reads GraphQL extensions.code', () => {
const result = parse({ message: 'nope', extensions: { code: 'FORBIDDEN' } });
expect(result.code).toBe('FORBIDDEN');
expect(result.class).toBe('public');
});

it('unwraps a GraphQL { errors: [...] } request wrapper', () => {
const result = parse({ errors: [{ message: 'ACCOUNT_EXISTS' }] });
expect(result.code).toBe('ACCOUNT_EXISTS');
});

it('maps native SQLSTATE constraint violations', () => {
const result = parse({ message: 'duplicate key', code: '23505', constraint: 'users_email_key' });
expect(result.code).toBe('UNIQUE_VIOLATION');
expect(result.context.constraint).toBe('users_email_key');
});

it('classifies unknown codes as internal (masked)', () => {
const result = parse({ message: 'DATA_INVARIANT_BROKEN', code: 'P0001' });
expect(result.code).toBe('DATA_INVARIANT_BROKEN');
expect(result.known).toBe(false);
expect(result.class).toBe('internal');
});

it('treats a bare P0001 with no semantic token as unknown', () => {
const result = parse({ message: 'some free text', code: 'P0001' });
expect(result.code).toBeNull();
expect(result.class).toBe('internal');
});

it('round-trips a ConstructiveError', () => {
const err = errors.ACCOUNT_EXISTS();
const result = parse(err);
expect(result.code).toBe('ACCOUNT_EXISTS');
expect(result.class).toBe('public');
});
});

describe('classify', () => {
it('is internal for null/unknown', () => {
expect(classify(null)).toBe('internal');
expect(classify('NOT_A_REAL_CODE')).toBe('internal');
});
});

describe('errors factory', () => {
it('builds a typed ConstructiveError', () => {
const err = errors.MODULE_NOT_FOUND({ name: 'auth' });
expect(err).toBeInstanceOf(ConstructiveError);
expect(err.code).toBe('MODULE_NOT_FOUND');
expect(err.message).toBe('Module "auth" not found in modules list.');
expect(err.http).toBe(404);
});

it('supports an override message', () => {
const err = errors.MODULE_NOT_FOUND({ name: 'auth' }, 'custom');
expect(err.message).toBe('custom');
});

it('exposes GraphQL extensions', () => {
const err = errors.LIMIT_REACHED({ resource: 'api_keys', limit: 5 });
expect(err.toExtensions()).toMatchObject({
code: 'LIMIT_REACHED',
class: 'public',
context: { resource: 'api_keys', limit: 5 }
});
});
});

describe('format / i18n', () => {
it('interpolates templates', () => {
expect(format('MODULE_NOT_FOUND', { name: 'auth' })).toBe('Module "auth" not found in modules list.');
});

it('uses a registered locale catalog with fallback to en', () => {
registerCatalog('es', { ACCOUNT_EXISTS: 'Ya existe una cuenta con este correo.' });
expect(format('ACCOUNT_EXISTS', {}, 'es')).toBe('Ya existe una cuenta con este correo.');
// falls back to en for codes not in the es catalog
expect(format('FORBIDDEN', {}, 'es')).toBe('You do not have permission to do that.');
});

it('humanizes unknown codes as a last resort', () => {
expect(format('SOME_UNKNOWN_CODE')).toBe('Some unknown code');
});
});

describe('generated registry (full constructive-db audit)', () => {
it('collects every audited constructive-db code', () => {
expect(GENERATED_CODE_COUNT).toBe(287);
expect(Object.keys(generatedRegistry)).toHaveLength(287);
// curated + generated codes are all reachable
expect(allCodes().length).toBeGreaterThanOrEqual(287);
});

it('classifies a generator-only auth code as public', () => {
// SIGN_UP_DISABLED is emitted only by DB generators (no hand-written source)
expect(GENERATED_CODE_META.SIGN_UP_DISABLED.generatedOnly).toBe(true);
expect(classify('SIGN_UP_DISABLED')).toBe('public');
});

it('classifies internal invariant codes as internal (masked)', () => {
expect(classify('DATA_ID')).toBe('internal');
expect(classify('APPLY_RLS')).toBe('internal');
expect(classify('ALTER_TABLE_ADD_COLUMN')).toBe('internal');
});

it('parses a generated public code end-to-end', () => {
const result = parse({ message: 'API_KEY_LIMIT_REACHED', code: 'P0001' });
expect(result.code).toBe('API_KEY_LIMIT_REACHED');
expect(result.class).toBe('public');
expect(result.known).toBe(true);
});

it('exposes generated codes on the errors factory', () => {
const err = errors.SIGN_UP_DISABLED();
expect(err.code).toBe('SIGN_UP_DISABLED');
expect(err.isPublic).toBe(true);
});
});
8 changes: 8 additions & 0 deletions packages/errors/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: false }]
},
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
};
44 changes: 44 additions & 0 deletions packages/errors/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@constructive-io/errors",
"version": "0.1.0",
"author": "Constructive <developers@constructive.io>",
"description": "Canonical Constructive error system: registry, codes, i18n messages, and cross-source (PostgreSQL/GraphQL/client) parsing. Zero runtime dependencies.",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"keywords": [
"errors",
"error-codes",
"i18n",
"constructive",
"postgresql",
"graphql"
],
"devDependencies": {
"@types/node": "^22.19.11",
"makage": "^0.3.0",
"ts-node": "^10.9.2"
}
}
Loading
Loading