Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
580ccd0
feat(backend): add typed error classes and global error middleware
woahwhattheheck Sep 24, 2026
0c90c79
fix(ci): drop invalid top-level retention-days from workflows
woahwhattheheck Sep 24, 2026
db37bd4
fix(ci): drop invalid top-level retention-days from workflows
woahwhattheheck Sep 24, 2026
99fd9db
fix(ci): drop invalid top-level retention-days from workflow files
woahwhattheheck Sep 24, 2026
eb41c7f
fix(ci): remove invalid top-level retention-days from workflow files
woahwhattheheck Sep 24, 2026
b6e71ff
chore: keep this pull request scoped to its issue
woahwhattheheck Sep 26, 2026
c664d0d
fix(errors): delegate once response headers are sent
woahwhattheheck Sep 26, 2026
57cf142
fix(errors): preserve request body parser client statuses
woahwhattheheck Oct 3, 2026
c36c1b0
fix: preserve body parser client error statuses
woahwhattheheck Oct 3, 2026
3388bfc
docs(backend): describe the shared error response boundary
woahwhattheheck Oct 4, 2026
8ff1e66
fix(backend): route payroll failures through shared error middleware
woahwhattheheck Oct 4, 2026
4f0b467
docs(backend): describe payroll shared error responses [skip ci]
woahwhattheheck Oct 4, 2026
4241499
fix(ci): restore executable build and secrets workflows [skip ci]
woahwhattheheck Oct 4, 2026
6ada9b2
fix(backend): preserve error responses when string conversion fails
woahwhattheheck Oct 4, 2026
4810543
fix(backend): align legacy 500 responses with shared error envelope
woahwhattheheck Oct 4, 2026
d88dae4
fix(backend): safely format unknown internal errors
woahwhattheheck Oct 4, 2026
2db6629
test(backend): cover unstringifiable internal errors
woahwhattheheck Oct 4, 2026
1f02cc9
fix(backend): redact typed server error details outside development
woahwhattheheck Oct 5, 2026
b7add81
ci: remove unsupported root retention settings
woahwhattheheck Oct 5, 2026
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
5 changes: 1 addition & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ on:
branches: ["main"]
types: [opened, synchronize, reopened, ready_for_review]

# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days
env:
CARGO_TERM_COLOR: always
PKG_CONFIG_PATH: /usr/lib/pkgconfig
Expand Down Expand Up @@ -44,6 +43,7 @@ jobs:
echo "stellar-scaffold already installed. Clear cache to force reinstall."
fi
- name: Build with Scaffold and build client packages
shell: bash
run: STELLAR_SCAFFOLD_ENV=development stellar-scaffold build --build-clients 2>&1 | tee build_clients.log
- name: Install official Stellar CLI
run: |
Expand Down Expand Up @@ -113,6 +113,3 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: npm test --if-present

# Workflow run retention settings
retention-days: 30
1 change: 0 additions & 1 deletion .github/workflows/contract-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,3 @@ jobs:
release_token: ${{ secrets.GITHUB_TOKEN }}

# Workflow run retention settings
retention-days: 90
1 change: 0 additions & 1 deletion .github/workflows/dapp-ipfs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,3 @@ jobs:
echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

# Workflow run retention settings
retention-days: 30
8 changes: 4 additions & 4 deletions .github/workflows/secrets-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ on:
branches: ["main"]
paths:
- "k8s/**"
- "scripts/check-k8s-secrets.sh"
- ".github/workflows/secrets-check.yml"
pull_request:
branches: ["main"]
paths:
- "k8s/**"
- "scripts/check-k8s-secrets.sh"
- ".github/workflows/secrets-check.yml"

# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days
jobs:
check-secrets-placeholders:
name: Verify no real secrets in k8s manifests
Expand All @@ -20,6 +23,3 @@ jobs:

- name: Check backend-secret.yaml for non-placeholder values
run: ./scripts/check-k8s-secrets.sh

# Workflow run retention settings
retention-days: 30
52 changes: 44 additions & 8 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,20 +289,56 @@ const stats = payrollQueryService.getCacheStats();

## Error Handling

All errors return consistent format:
Errors forwarded to the [global error middleware](src/middleware/errorHandler.ts)
use this response shape; for example, a malformed JSON request body produces:

```json
{
"error": "Error type",
"message": "Detailed error message"
"error": "ValidationError",
"message": "Invalid request body",
"code": "VALIDATION_ERROR",
"requestId": "example-request-id"
}
```

Standard HTTP status codes:

- **400**: Bad request (missing parameters)
- **404**: Not found (transaction/resource)
- **500**: Server error
`error` is the error type name, `code` is the machine-readable category,
and `message` is client-facing text. `requestId` is included when the request
has a string ID. [app.ts](src/app.ts) installs request-ID middleware before
the JSON/form parsers and registers the not-found and error handlers after
the routes.

Common mappings are:

| Error reaching the shared handler | HTTP status | Code |
| --- | --- | --- |
| `ValidationError` or malformed JSON/body syntax | `400` | `VALIDATION_ERROR` by default |
| `AuthError` | `401` by default, or `403` when supplied | `AUTH_ERROR` by default |
| `NotFoundError`, including the final unmatched-route handler | `404` | `NOT_FOUND` by default |
| Request body too large or too many form parameters | `413` | `PAYLOAD_TOO_LARGE` |
| Unsupported body charset or content encoding | `415` | `UNSUPPORTED_MEDIA_TYPE` |
| Unknown errors | `500` | `INTERNAL_ERROR` |

The [typed error classes](src/errors/AppError.ts) allow application-specific
messages and codes. Forward them with `next(error)` to use the shared handler.
Only recognized body-parser type/status pairs receive the parser mappings
above; other unknown errors remain `500`.

Set `NODE_ENV` before starting the process. Only `development` includes an
available `stack`; unknown errors use `InternalServerError` and the generic
message `An error occurred` outside development. In development their original
message and available stack are returned. Typed `AppError` messages remain
client-visible in every environment, so give those errors client-safe text.
Parser errors use fixed messages; their original stack is included only in
development.

The [payroll routes](src/routes/payroll.routes.ts) forward service failures to
this shared handler. Missing required query parameters use `ValidationError`;
a missing transaction uses `NotFoundError`. Their error responses therefore
include the same `error`, `message`, `code` and available `requestId` fields.
Successful payroll response bodies and service arguments are unchanged.
Authentication and other middleware retain their own response policies; this
does not claim that every direct response elsewhere in the application is
rewritten.

## Development

Expand Down
51 changes: 51 additions & 0 deletions backend/docs/validation/unknown-error-fallback-20261004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Unknown-error response fallback — 2026-10-04

Continuation of Protocol-Guild/PayD #621 for issue #550, based on commit
`4f0b467d6bb2fc5622a83b3467baebf21054d425`.

## Change

A thrown JavaScript value need not be an Error or support string conversion.
Previously, `String(err)` could throw before the global middleware logged the
failure or returned its standard JSON response. Null-prototype objects and
objects whose `toString` or `Symbol.toPrimitive` throws now use the existing
`An error occurred` fallback. Ordinary Error messages, valid string conversion,
typed errors, parser mappings, request IDs and headers-sent delegation are
unchanged. Conversion failures do not expose the secondary exception or stack,
including in development.

Three factory rows were added to the existing errorHandler Jest file. Each
exercises production and development, checks the exact 500 response and logged
fallback, and confirms that next is not called.

## Execution

Node v22.16.0 and TypeScript 5.8.3. The exported production middleware and the
unchanged AppError classes were transpiled to CommonJS with TypeScript's
transpileModule and executed with injected config, logger, response and next
adapters. This was a direct module execution, not a rewritten handler.

| Case group | Before | After |
| --- | --- | --- |
| Null-prototype, throwing toString and throwing Symbol.toPrimitive, each in production/development | 6 conversion exceptions; no JSON response | 6 exact standard 500 responses |
| Ordinary Error and ordinary string, each in production/development | 4 pass | 4 pass |
| Typed NotFoundError, recognized JSON parser error and headers-sent delegation | 3 pass | 3 pass |
| Total direct-handler cases | 7 pass, 6 fail | 13 pass, 0 fail |

Before source blob: `af5019c390d451233265492c452ee32408b7311c`.
After source blob: `dfd467c4ad672a65fd5b09f758ca99a441ff7b00`.
Both retrieved preimage files were verified against their native Git blob IDs
before editing. Existing regression cases were preserved.

## Limits

The cloud container could not resolve github.com for a checkout or fetch
project dependencies. No Express HTTP integration run, full Jest run, complete
typecheck, production performance measurement, hosted CI result or sponsor
acceptance is claimed here. The maintained Jest regression additions are
published but were not executed under Jest in this environment. TypeScript
transpilation of the changed source and regression file reported no syntax
errors; transpilation is not a typecheck.

The existing PR and claimant are retained. This change does not establish an
award or payment.
24 changes: 4 additions & 20 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import morgan from 'morgan';
import helmet from 'helmet';
import path from 'path';
import { fileURLToPath } from 'url';
import config from './config/index.js';
import logger from './utils/logger.js';
import passport from './config/passport.js';
import { apiVersionMiddleware } from './middlewares/apiVersionMiddleware.js';
import { requestIdMiddleware } from './middleware/requestId.js';
Expand Down Expand Up @@ -33,6 +31,7 @@ import contractEventRoutes from './routes/contractEventRoutes.js';
import certificateRoutes from './routes/certificateRoutes.js';
import cashFlowForecastRoutes from './routes/cashFlowForecastRoutes.js';
import { HealthController } from './controllers/healthController.js';
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';

// Part 49 — admin, audit integrity, per-tenant rate limits, quotas
import adminRoutes from './routes/adminRoutes.js';
Expand Down Expand Up @@ -176,23 +175,8 @@ app.use('/api/audit-analytics', auditAnalyticsRoutes);
app.use('/api/smart-rate-limit', smartRateLimitRoutes);
app.use('/api/tenant-security', tenantSecurityRoutes);

// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
path: req.path,
requestId: (req as any).requestId,
});
});

// Error handler
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error('Unhandled error', { err, requestId: (req as any).requestId });
res.status(500).json({
error: 'Internal Server Error',
message: config.nodeEnv === 'development' ? err.message : 'An error occurred',
requestId: (req as any).requestId,
});
});
// 404 + global error handler (typed AppError responses)
app.use(notFoundHandler);
app.use(errorHandler);

export default app;
49 changes: 49 additions & 0 deletions backend/src/errors/AppError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Base application error with HTTP status and machine-readable code.
* Controllers and services throw subclasses; the global error middleware
* maps them to a consistent JSON response shape.
*/
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly isOperational: boolean;

constructor(
message: string,
statusCode: number,
code: string,
isOperational = true
) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.isOperational = isOperational;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Resource was not found (HTTP 404). */
export class NotFoundError extends AppError {
constructor(message = 'Resource not found', code = 'NOT_FOUND') {
super(message, 404, code);
}
}

/** Request failed validation (HTTP 400). */
export class ValidationError extends AppError {
constructor(message = 'Validation failed', code = 'VALIDATION_ERROR') {
super(message, 400, code);
}
}

/** Authentication or authorization failure (HTTP 401 / 403). */
export class AuthError extends AppError {
constructor(
message = 'Authentication required',
statusCode: 401 | 403 = 401,
code = 'AUTH_ERROR'
) {
super(message, statusCode, code);
}
}
1 change: 1 addition & 0 deletions backend/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppError, NotFoundError, ValidationError, AuthError } from './AppError.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Request, Response } from 'express';
import { AppError, ValidationError } from '../../errors/index.js';
import config from '../../config/index.js';
import logger from '../../utils/logger.js';
import { errorHandler } from '../errorHandler.js';

jest.mock('../../config/index.js', () => ({
__esModule: true, default: { nodeEnv: 'test' },
}));
jest.mock('../../utils/logger.js', () => ({
__esModule: true, default: { error: jest.fn(), warn: jest.fn() },
}));

const cases = [
new AppError('private database detail', 500, 'DATABASE_ERROR'),
new AppError('private upstream detail', 502, 'UPSTREAM_ERROR'),
new AppError('private invariant detail', 400, 'INVARIANT_ERROR', false),
];

function respond(err: AppError) {
const req = { method: 'GET', originalUrl: '/api/example', requestId: 'redaction-request' } as Request;
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
errorHandler(err, req, { status } as unknown as Response, jest.fn());
expect(status).toHaveBeenCalledWith(err.statusCode);
return json.mock.calls[0][0];
}

beforeEach(() => { jest.clearAllMocks(); });

it.each(cases)('redacts server/non-operational messages outside development: %s', (err) => {
for (const nodeEnv of ['production', 'test', 'staging']) {
(config as { nodeEnv: string }).nodeEnv = nodeEnv;
expect(respond(err)).toEqual({
error: err.name,
message: 'An error occurred',
code: err.code,
requestId: 'redaction-request',
});
expect(logger.error).toHaveBeenLastCalledWith('Operational/server error', expect.objectContaining({ err }));
}
});

it('preserves operational client messages and development diagnostics', () => {
(config as { nodeEnv: string }).nodeEnv = 'production';
expect(respond(new ValidationError('Name is required')).message).toBe('Name is required');
(config as { nodeEnv: string }).nodeEnv = 'development';
for (const err of cases) {
expect(respond(err)).toMatchObject({ message: err.message, stack: err.stack });
}
});
Loading