Skip to content
Merged
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
29 changes: 29 additions & 0 deletions packages/errors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ service or client without pulling in pgpm.
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.
- **`httpStatusFor(code)`** — the canonical code → HTTP status mapping, so no
transport keeps its own table. Unregistered codes answer `500` *and are
reported*, never silently.

```ts
import { parse, format, errors, classify } from '@constructive-io/errors';
Expand Down Expand Up @@ -46,6 +49,32 @@ throw errors.ACCOUNT_EXISTS();
pgpm CLI codes). These override the generated entries.
- Unregistered codes still `parse()` and are classified `internal` (masked).

## HTTP status

Every registry entry carries `http`, so an HTTP surface never needs its own
code → status table: `toError(err).http`, or `httpStatusFor(code)` when all you
have is a code.

```ts
import { httpStatusFor, setUnmappedStatusReporter, toError } from '@constructive-io/errors';

setUnmappedStatusReporter(code => log.warn({ code }, 'error code has no HTTP status'));

const err = toError(caught);
res.status(err.http).json(err.toExtensions());

httpStatusFor('ACCOUNT_DISABLED'); // { status: 403, mapped: true }
httpStatusFor('BRAND_NEW_CODE'); // { status: 500, mapped: false } + one report
```

`mapped: false` is the one case where a 500 does not mean "the server broke" —
it means the code never reached the registry. That is the failure mode this
exists to make loud: a refusal that is plainly a 403 or a 409 answering 500
looks like a crash, and the codes most likely to be missing are the newest ones.
When a constructive-db release adds codes, refresh the registry (below);
`__tests__/registry-sync.test.ts` fails if the snapshot and the generated
registry disagree.

## Regenerating the full registry

The generated layer is produced from a committed audit snapshot:
Expand Down
69 changes: 69 additions & 0 deletions packages/errors/__tests__/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
httpStatusFor,
resetUnmappedStatusReports,
setUnmappedStatusReporter,
toError,
UNMAPPED_HTTP_STATUS
} from '../src';

const reported: string[] = [];

beforeEach(() => {
reported.length = 0;
resetUnmappedStatusReports();
setUnmappedStatusReporter(code => reported.push(code));
});

afterAll(() => {
setUnmappedStatusReporter(null);
});

describe('httpStatusFor', () => {
it('resolves registered codes from the registry', () => {
expect(httpStatusFor('ACCOUNT_DISABLED')).toEqual({ status: 403, mapped: true });
expect(httpStatusFor('ACCOUNT_EXISTS')).toEqual({ status: 409, mapped: true });
expect(httpStatusFor('INVALID_CREDENTIALS')).toEqual({ status: 401, mapped: true });
expect(reported).toEqual([]);
});

it('reports an unregistered code instead of silently answering 500', () => {
expect(httpStatusFor('SSO_LINK_TICKET_ALREADY_USED_NOT_REGISTERED')).toEqual({
status: UNMAPPED_HTTP_STATUS,
mapped: false
});
expect(reported).toEqual(['SSO_LINK_TICKET_ALREADY_USED_NOT_REGISTERED']);
});

it('reports each unmapped code once, not once per request', () => {
httpStatusFor('NOISY_UNREGISTERED_CODE');
httpStatusFor('NOISY_UNREGISTERED_CODE');
httpStatusFor('NOISY_UNREGISTERED_CODE');
expect(reported).toEqual(['NOISY_UNREGISTERED_CODE']);
});

it('does not report when there is no code at all', () => {
expect(httpStatusFor(null)).toEqual({ status: UNMAPPED_HTTP_STATUS, mapped: false });
expect(httpStatusFor(undefined)).toEqual({ status: UNMAPPED_HTTP_STATUS, mapped: false });
expect(reported).toEqual([]);
});
});

describe('toError', () => {
it('carries the registry status for a database refusal', () => {
const pgError = Object.assign(new Error('ACCOUNT_DISABLED'), {
code: 'P0001',
detail: JSON.stringify({ code: 'ACCOUNT_DISABLED', context: {}, class: 'public' })
});
expect(toError(pgError).http).toBe(403);
expect(reported).toEqual([]);
});

it('reports the unmapped code behind a 500', () => {
const pgError = Object.assign(new Error('UNREGISTERED_REFUSAL'), {
code: 'P0001',
detail: JSON.stringify({ code: 'UNREGISTERED_REFUSAL', context: {}, class: 'public' })
});
expect(toError(pgError).http).toBe(UNMAPPED_HTTP_STATUS);
expect(reported).toEqual(['UNREGISTERED_REFUSAL']);
});
});
5 changes: 3 additions & 2 deletions packages/errors/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,12 @@ describe('generated registry (full constructive-db audit)', () => {
// require_step_up() raises one code per factor so the client knows which
// re-verification to prompt for; a humanized code would prompt for the
// wrong one, so each needs its own copy.
// STEP_UP_REQUIRED_PASSWORD_OR_MFA is deliberately absent: constructive-db
// split it into the per-factor codes below (require_step_up.sql).
const factors = [
'STEP_UP_REQUIRED_PASSWORD',
'STEP_UP_REQUIRED_MFA',
'STEP_UP_REQUIRED_FRESH_AUTH',
'STEP_UP_REQUIRED_PASSWORD_OR_MFA'
'STEP_UP_REQUIRED_FRESH_AUTH'
];
for (const code of factors) {
expect(classify(code)).toBe('public');
Expand Down
33 changes: 33 additions & 0 deletions packages/errors/__tests__/registry-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { readFileSync } from 'fs';
import { join } from 'path';

import { generatedRegistry } from '../src/generated/registry.generated';
import { getDefinition } from '../src/registry';

const inventory: Record<string, unknown> = JSON.parse(
readFileSync(join(__dirname, '../scripts/db-error-inventory.json'), 'utf-8')
);

/**
* The generated registry is the only thing standing between a database refusal
* and a silent 500, so a refreshed inventory that was never regenerated (or the
* reverse) has to fail here rather than in production.
*/
describe('generated registry is in sync with the audit inventory', () => {
it('covers every audited code', () => {
const missing = Object.keys(inventory).filter(code => !generatedRegistry[code]);
expect(missing).toEqual([]);
});

it('carries no code the audit no longer knows about', () => {
const stale = Object.keys(generatedRegistry).filter(code => !(code in inventory));
expect(stale).toEqual([]);
});

it('gives every audited code a resolvable HTTP status', () => {
const unstatused = Object.keys(inventory).filter(
code => typeof getDefinition(code)?.http !== 'number'
);
expect(unstatused).toEqual([]);
});
});
Loading
Loading