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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
- name: Generate Prisma client
run: pnpm prisma:generate

- name: Check Prisma migration naming
run: pnpm run prisma:check-migrations

- name: Build
run: pnpm run build

Expand Down Expand Up @@ -64,3 +67,6 @@ jobs:

- name: Test
run: pnpm test

- name: Test scripts
run: pnpm run test:scripts
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ It handles wallet creation, transaction orchestration, fee sponsorship, and on-c

All routes below are served under the `/v1` prefix (e.g. `GET /v1/health`). See [docs/API-VERSIONING.md](docs/API-VERSIONING.md) for the versioning strategy.

### Error responses

Every error — thrown `HttpException`, unhandled exception, or validation
failure — is returned by a global exception filter in the same structured
envelope:

```json
{
"statusCode": 422,
"timestamp": "2026-07-30T12:34:56.789Z",
"path": "/v1/wallets/123/limits",
"method": "POST",
"message": "Per-transaction limit exceeded. Limit: 1000",
"error": "Unprocessable Entity",
"errorCode": "LIMIT_PER_TX_EXCEEDED",
"requestId": "..."
}
```

`error` and `message` are always present. `errorCode` (a stable, machine-readable
string) and `details` (a structured object) are included only when the thrown
exception provides them. `requestId` is echoed back from the `X-Request-ID`
request header when present. In production, `message` on unhandled 500 errors
is sanitized to strip connection strings, file paths, and secrets.

### Request body size

JSON and URL-encoded request bodies are limited to 100 KiB by default. Set
Expand Down
47 changes: 47 additions & 0 deletions docs/PRISMA-MIGRATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Prisma migration conventions

## Naming

Every migration must live in its own folder directly under `prisma/migrations/`:

```
prisma/migrations/20260730120000_add_thing/migration.sql
```

- Folder name: `<14-digit-timestamp>_<snake_case_description>` — the format
`prisma migrate dev` generates by default. The timestamp must be unique and
should reflect when the migration was authored (`YYYYMMDDHHMMSS`).
- The folder must contain a `migration.sql` file. Don't drop loose `.sql`
files directly under `prisma/migrations/` — Prisma silently ignores
anything that isn't inside a migration folder, so a stray file never gets
applied by `prisma migrate deploy` even though it looks like it's part of
the migration history.
- `migration_lock.toml` is the only file allowed directly under
`prisma/migrations/`.

A number of early migrations predate this convention — some use a bare
counter (`0_init`), a short date without a time component
(`20260602_add_wallet_key_version`), or reuse the same 14-digit timestamp as
another migration. They're already applied in every environment, so renaming
them would break Prisma's `_prisma_migrations` tracking table. They're listed
by name in the `LEGACY_EXCEPTIONS` set in `scripts/check-migration-naming.ts`
(the source of truth) and must not be used as a template for new migrations.

## CI check

`pnpm run prisma:check-migrations` (wired into `.github/workflows/ci.yml`)
verifies:

- no loose files under `prisma/migrations/` other than `migration_lock.toml`
- every migration folder contains a `migration.sql`
- every non-legacy folder matches the naming pattern above
- no two non-legacy migrations reuse the same timestamp

Run it locally before opening a PR that touches `prisma/migrations/`:

```
pnpm run prisma:check-migrations
```

The validation logic is unit tested in `scripts/check-migration-naming.spec.ts`
(`pnpm run test:scripts`).
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"preinstall": "npx only-allow pnpm",
"openapi:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts",
"openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml"
"openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml",
"prisma:check-migrations": "ts-node -r tsconfig-paths/register scripts/check-migration-naming.ts",
"test:scripts": "jest --config scripts/jest.config.js"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- AlterTable
ALTER TABLE "ApiKey" ADD COLUMN "network" TEXT;
ALTER TABLE "ApiKey" ADD COLUMN "network" "WalletNetwork";

-- CreateIndex
CREATE INDEX "ApiKey_network_idx" ON "ApiKey"("network");
78 changes: 78 additions & 0 deletions scripts/check-migration-naming.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { validateMigrationEntries, MigrationEntry } from './check-migration-naming';

function dir(name: string, hasMigrationSql = true): MigrationEntry {
return { name, isDirectory: true, hasMigrationSql };
}

function file(name: string): MigrationEntry {
return { name, isDirectory: false, hasMigrationSql: false };
}

describe('validateMigrationEntries', () => {
it('passes for a well-formed set of migrations plus the lock file', () => {
const errors = validateMigrationEntries([
file('migration_lock.toml'),
dir('20260601000000_add_thing'),
dir('20260602000000_add_other_thing'),
]);
expect(errors).toEqual([]);
});

it('grandfathers known legacy folder names without a timestamp prefix', () => {
const errors = validateMigrationEntries([
dir('0_init'),
dir('1_add_wallet_limit'),
dir('20260602_add_wallet_key_version'),
]);
expect(errors).toEqual([]);
});

it('fails on a loose file directly under prisma/migrations', () => {
const errors = validateMigrationEntries([
file('network_scoped_api_keys.sql'),
]);
expect(errors).toEqual([
expect.stringContaining('network_scoped_api_keys.sql'),
]);
});

it('fails on a migration folder missing migration.sql', () => {
const errors = validateMigrationEntries([
dir('20260601000000_add_thing', false),
]);
expect(errors).toEqual([
expect.stringContaining('missing a migration.sql file'),
]);
});

it('fails on a new (non-legacy) folder that does not match the naming pattern', () => {
const errors = validateMigrationEntries([dir('add_thing_without_timestamp')]);
expect(errors).toEqual([
expect.stringContaining('does not match the required'),
]);
});

it('fails on a non-legacy folder using an unpadded/short timestamp', () => {
const errors = validateMigrationEntries([dir('20260602_add_wallet_key_version_v2')]);
expect(errors.length).toBe(1);
expect(errors[0]).toContain('does not match the required');
});

it('fails when two non-legacy migrations reuse the same timestamp', () => {
const errors = validateMigrationEntries([
dir('20260601000000_add_thing'),
dir('20260601000000_add_other_thing'),
]);
expect(errors).toEqual([
expect.stringContaining('reuses timestamp 20260601000000'),
]);
});

it('does not flag duplicate timestamps between two legacy-exception folders', () => {
const errors = validateMigrationEntries([
dir('0_init'),
dir('1_add_wallet_limit'),
]);
expect(errors).toEqual([]);
});
});
120 changes: 120 additions & 0 deletions scripts/check-migration-naming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as fs from 'fs';
import * as path from 'path';

export const MIGRATIONS_DIR = path.join(__dirname, '..', 'prisma', 'migrations');

/** Files permitted to sit directly under prisma/migrations/ (not inside a migration folder). */
export const ALLOWED_TOP_LEVEL_FILES = new Set(['migration_lock.toml']);

/**
* Migration folders created before this naming convention was enforced.
* They are already applied to real databases, so they can't be renamed
* without breaking Prisma's `_prisma_migrations` tracking table. New
* migrations must not be added to this list.
*/
export const LEGACY_EXCEPTIONS = new Set([
'0_init',
'1_add_wallet_limit',
'20260601000000_add_spending_limits',
'20260601000000_add_transaction_idempotency_key',
'20260601000000_add_wallet_successor_id',
'20260602_add_wallet_key_version',
'20260723_add_asset_code_to_payment',
'20260724000000_add_user_default_network',
'20260724000000_add_user_last_login_metadata',
'20260724_add_soft_delete_to_wallet_limit',
'20260729000000_add_maintenance_state',
'20260729000000_add_wallet_nickname',
]);

const NAME_PATTERN = /^\d{14}_[a-z0-9_]+$/;

export interface MigrationEntry {
name: string;
isDirectory: boolean;
hasMigrationSql: boolean;
}

/**
* Pure validation over a directory listing so the rules can be unit tested
* without touching the filesystem.
*/
export function validateMigrationEntries(entries: MigrationEntry[]): string[] {
const errors: string[] = [];
const seenTimestamps = new Map<string, string>();

for (const entry of entries) {
if (!entry.isDirectory) {
if (!ALLOWED_TOP_LEVEL_FILES.has(entry.name)) {
errors.push(
`"${entry.name}" is a loose file directly under prisma/migrations/. ` +
`Every migration must live in its own "<timestamp>_<name>/migration.sql" folder.`,
);
}
continue;
}

if (!entry.hasMigrationSql) {
errors.push(
`"${entry.name}/" is missing a migration.sql file.`,
);
}

if (LEGACY_EXCEPTIONS.has(entry.name)) {
continue;
}

if (!NAME_PATTERN.test(entry.name)) {
errors.push(
`"${entry.name}" does not match the required "<14-digit-timestamp>_<snake_case_name>" ` +
`format (e.g. 20260730120000_add_thing). See docs/PRISMA-MIGRATIONS.md.`,
);
continue;
}

const timestamp = entry.name.slice(0, 14);
const clash = seenTimestamps.get(timestamp);
if (clash) {
errors.push(
`"${entry.name}" reuses timestamp ${timestamp} already used by "${clash}". ` +
`Migration timestamps must be unique and monotonically increasing.`,
);
} else {
seenTimestamps.set(timestamp, entry.name);
}
}

return errors;
}

function readEntries(dir: string): MigrationEntry[] {
return fs.readdirSync(dir).map((name) => {
const full = path.join(dir, name);
const isDirectory = fs.statSync(full).isDirectory();
const hasMigrationSql =
isDirectory && fs.existsSync(path.join(full, 'migration.sql'));
return { name, isDirectory, hasMigrationSql };
});
}

function main() {
const entries = readEntries(MIGRATIONS_DIR);
const errors = validateMigrationEntries(entries);

if (errors.length > 0) {
console.error('Prisma migration naming check failed:\n');
for (const error of errors) {
console.error(` - ${error}`);
}
console.error(
'\nSee docs/PRISMA-MIGRATIONS.md for the naming convention and how to fix this.',
);
process.exit(1);
}

console.log(`Prisma migration naming check passed (${entries.length} entries).`);
}

if (require.main === module) {
main();
}
8 changes: 8 additions & 0 deletions scripts/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
rootDir: '.',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.ts$': 'ts-jest',
},
testEnvironment: 'node',
};
21 changes: 21 additions & 0 deletions src/common/filters/http-exception.filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,27 @@ describe('HttpExceptionFilter', () => {
const jsonCall = mockResponse.json.mock.calls[0][0];
expect(jsonCall.details).toBeUndefined();
});

it('should include errorCode field when provided on the exception body', () => {
const exception = new HttpException(
{ errorCode: 'LIMIT_PER_TX_EXCEEDED', message: 'Per-transaction limit exceeded' },
HttpStatus.UNPROCESSABLE_ENTITY,
);

filter.catch(exception, mockArgumentsHost);

const jsonCall = mockResponse.json.mock.calls[0][0];
expect(jsonCall.errorCode).toBe('LIMIT_PER_TX_EXCEEDED');
});

it('should not include errorCode field when not provided', () => {
const exception = new NotFoundException('Not found');

filter.catch(exception, mockArgumentsHost);

const jsonCall = mockResponse.json.mock.calls[0][0];
expect(jsonCall.errorCode).toBeUndefined();
});
});

describe('HTTP status code mapping', () => {
Expand Down
10 changes: 6 additions & 4 deletions src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ErrorResponse {
method: string;
message: string | string[];
error?: string;
errorCode?: string;
details?: Record<string, any>;
requestId?: string;
}
Expand Down Expand Up @@ -69,10 +70,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
const exceptionResponse = exception.getResponse();

// Extract message and details from exception response
const { message, error, details } = this.parseHttpExceptionResponse(
exceptionResponse,
status,
);
const { message, error, errorCode, details } =
this.parseHttpExceptionResponse(exceptionResponse, status);

return {
statusCode: status,
Expand All @@ -81,6 +80,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
method,
message,
error,
...(errorCode && { errorCode }),
...(details && { details }),
...(request.headers['x-request-id'] && {
requestId: request.headers['x-request-id'] as string,
Expand Down Expand Up @@ -126,6 +126,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
): {
message: string | string[];
error: string;
errorCode?: string;
details?: Record<string, any>;
} {
// If response is a string, use it as the message
Expand All @@ -142,6 +143,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
return {
message: responseObj.message || 'An error occurred',
error: responseObj.error || this.getErrorNameFromStatus(status),
...(responseObj.errorCode && { errorCode: responseObj.errorCode }),
...(responseObj.details && { details: responseObj.details }),
};
}
Expand Down
Loading
Loading