@sundaysf/cli-v3 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +302 -0
  2. package/dist/cli.js +1327 -0
  3. package/package.json +55 -0
  4. package/templates/api/.claude/agents/knex-table-implementer.md +36 -0
  5. package/templates/api/.claude/agents/sundays-backend-builder.md +32 -0
  6. package/templates/api/.env.example +25 -0
  7. package/templates/api/.github/workflows/ci.yaml +45 -0
  8. package/templates/api/.github/workflows/deploy.yaml +72 -0
  9. package/templates/api/.prettierignore +5 -0
  10. package/templates/api/.prettierrc +9 -0
  11. package/templates/api/.sundaysrc +8 -0
  12. package/templates/api/CLAUDE.md +81 -0
  13. package/templates/api/Dockerfile +17 -0
  14. package/templates/api/README.md +164 -0
  15. package/templates/api/_dockerignore +8 -0
  16. package/templates/api/_gitignore +23 -0
  17. package/templates/api/_package.json +58 -0
  18. package/templates/api/docker-compose.yml +21 -0
  19. package/templates/api/eslint.config.js +27 -0
  20. package/templates/api/jest.config.js +33 -0
  21. package/templates/api/jest.setup.js +25 -0
  22. package/templates/api/knexfile.ts +5 -0
  23. package/templates/api/src/app.ts +48 -0
  24. package/templates/api/src/common/__tests__/common.test.ts +116 -0
  25. package/templates/api/src/common/config/env.ts +53 -0
  26. package/templates/api/src/common/errors/http.error.ts +30 -0
  27. package/templates/api/src/common/logger/index.ts +25 -0
  28. package/templates/api/src/common/utils/environment.resolver.ts +7 -0
  29. package/templates/api/src/common/utils/pagination.ts +25 -0
  30. package/templates/api/src/common/utils/version.resolver.ts +24 -0
  31. package/templates/api/src/common/validation/parse-dto.ts +20 -0
  32. package/templates/api/src/controllers/health/__tests__/health.controller.test.ts +52 -0
  33. package/templates/api/src/controllers/health/health.controller.ts +26 -0
  34. package/templates/api/src/db/BaseDAO.ts +92 -0
  35. package/templates/api/src/db/KnexConnection.ts +59 -0
  36. package/templates/api/src/db/__tests__/base-dao.test.ts +73 -0
  37. package/templates/api/src/db/__tests__/index.barrel.test.ts +10 -0
  38. package/templates/api/src/db/__tests__/knex-connection.test.ts +90 -0
  39. package/templates/api/src/db/d.types.ts +42 -0
  40. package/templates/api/src/db/dao/sundays-package-version/sundays-package-version.dao.ts +12 -0
  41. package/templates/api/src/db/index.ts +17 -0
  42. package/templates/api/src/db/interfaces/sundays-package-version/sundays-package-version.interfaces.ts +5 -0
  43. package/templates/api/src/db/knex.config.ts +46 -0
  44. package/templates/api/src/dto/input/.gitkeep +0 -0
  45. package/templates/api/src/jobs/.gitkeep +0 -0
  46. package/templates/api/src/middlewares/error/__tests__/error.middleware.test.ts +117 -0
  47. package/templates/api/src/middlewares/error/error.middleware.ts +70 -0
  48. package/templates/api/src/middlewares/not-found/__tests__/not-found.middleware.test.ts +54 -0
  49. package/templates/api/src/middlewares/not-found/not-found.middleware.ts +51 -0
  50. package/templates/api/src/middlewares/request-id/__tests__/request-id.middleware.test.ts +31 -0
  51. package/templates/api/src/middlewares/request-id/request-id.middleware.ts +20 -0
  52. package/templates/api/src/migrations/20240101000000_create_sundays_package_version.ts +15 -0
  53. package/templates/api/src/routes/__tests__/index-router.test.ts +61 -0
  54. package/templates/api/src/routes/health/__tests__/health.routes.test.ts +22 -0
  55. package/templates/api/src/routes/health/health.router.ts +18 -0
  56. package/templates/api/src/routes/index.ts +77 -0
  57. package/templates/api/src/seeds/001_sundays_package_version.ts +14 -0
  58. package/templates/api/src/server.ts +56 -0
  59. package/templates/api/src/services/.gitkeep +0 -0
  60. package/templates/api/tsconfig.json +20 -0
  61. package/templates/api/tsconfig.spec.json +10 -0
  62. package/templates/api-auth/overlay.json +95 -0
  63. package/templates/api-auth/src/controllers/auth/__tests__/auth.controller.test.ts +194 -0
  64. package/templates/api-auth/src/controllers/auth/auth.controller.ts +109 -0
  65. package/templates/api-auth/src/db/dao/auth/auth.dao.ts +21 -0
  66. package/templates/api-auth/src/db/dao/user/user.dao.ts +24 -0
  67. package/templates/api-auth/src/db/interfaces/auth/auth.interfaces.ts +8 -0
  68. package/templates/api-auth/src/db/interfaces/user/user.interfaces.ts +11 -0
  69. package/templates/api-auth/src/dto/input/auth/auth.login.dto.ts +14 -0
  70. package/templates/api-auth/src/dto/input/auth/auth.register.dto.ts +19 -0
  71. package/templates/api-auth/src/middlewares/auth/__tests__/auth.middleware.test.ts +52 -0
  72. package/templates/api-auth/src/middlewares/auth/auth.middleware.ts +56 -0
  73. package/templates/api-auth/src/migrations/20240101000001_create_user.ts +18 -0
  74. package/templates/api-auth/src/migrations/20240101000002_create_auth.ts +24 -0
  75. package/templates/api-auth/src/routes/auth/__tests__/auth.routes.test.ts +83 -0
  76. package/templates/api-auth/src/routes/auth/auth.router.ts +28 -0
  77. package/templates/api-auth/src/services/jwt/__tests__/jwt.service.test.ts +32 -0
  78. package/templates/api-auth/src/services/jwt/jwt.service.ts +36 -0
  79. package/templates/api-auth/src/services/password/__tests__/password.service.test.ts +13 -0
  80. package/templates/api-auth/src/services/password/password.service.ts +14 -0
@@ -0,0 +1,59 @@
1
+ import { knex, type Knex } from 'knex';
2
+ import { types } from 'pg';
3
+ import { logger } from '../common/logger';
4
+ import { buildKnexConfig } from './knex.config';
5
+
6
+ // Postgres DATE (OID 1082) stays a plain 'YYYY-MM-DD' string instead of a Date object
7
+ // (which would drag a timezone into a date-only value).
8
+ types.setTypeParser(1082, (value: string) => value);
9
+ // NUMERIC (1700) and BIGINT (20) arrive as strings by default; the API works with numbers.
10
+ // Precision beyond 2^53 is not a concern for money/decimal(15,2) columns and counts.
11
+ types.setTypeParser(1700, (value: string) => parseFloat(value));
12
+ types.setTypeParser(20, (value: string) => parseInt(value, 10));
13
+
14
+ /**
15
+ * Process-wide knex singleton. `connect()` once at boot (server.ts) or in a test's
16
+ * beforeAll; DAOs resolve the connection lazily through `getConnection()`.
17
+ */
18
+ class KnexManager {
19
+ private static instance: Knex | null = null;
20
+
21
+ static async connect(config?: Knex.Config, poolMax?: number): Promise<Knex> {
22
+ if (!KnexManager.instance) {
23
+ const instance = knex(config ?? buildKnexConfig(undefined, poolMax));
24
+ try {
25
+ await instance.raw('SELECT 1');
26
+ logger.info('Knex connection established');
27
+ } catch (error) {
28
+ await instance.destroy();
29
+ logger.error({ err: error }, 'Failed to establish Knex connection');
30
+ throw error;
31
+ }
32
+ KnexManager.instance = instance;
33
+ }
34
+ return KnexManager.instance;
35
+ }
36
+
37
+ static getConnection(): Knex {
38
+ if (!KnexManager.instance) {
39
+ throw new Error(
40
+ 'Knex connection has not been established. Call KnexManager.connect() first.'
41
+ );
42
+ }
43
+ return KnexManager.instance;
44
+ }
45
+
46
+ static isConnected(): boolean {
47
+ return KnexManager.instance !== null;
48
+ }
49
+
50
+ static async disconnect(): Promise<void> {
51
+ if (KnexManager.instance) {
52
+ await KnexManager.instance.destroy();
53
+ KnexManager.instance = null;
54
+ logger.info('Knex connection closed');
55
+ }
56
+ }
57
+ }
58
+
59
+ export default KnexManager;
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { KnexManager, SundaysPackageVersionDAO } from '..';
3
+ import type { ISundaysPackageVersion } from '..';
4
+
5
+ // Exercises BaseDAO through the framework table (requires the local Postgres + migrations).
6
+ describe('BaseDAO (via SundaysPackageVersionDAO)', () => {
7
+ const dao = new SundaysPackageVersionDAO();
8
+ const marker = `test-${randomUUID().slice(0, 8)}`;
9
+ let created: ISundaysPackageVersion;
10
+
11
+ beforeAll(async () => {
12
+ await KnexManager.connect();
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await KnexManager.getConnection()('sundays_package_version')
17
+ .where('versionName', 'like', 'test-%')
18
+ .delete();
19
+ await KnexManager.disconnect();
20
+ });
21
+
22
+ it('creates with a generated uuid and timestamps', async () => {
23
+ created = await dao.create({ versionName: marker });
24
+ expect(created.id).toEqual(expect.any(Number));
25
+ expect(created.uuid).toMatch(/^[0-9a-f-]{36}$/);
26
+ expect(created.createdAt).toBeDefined();
27
+ });
28
+
29
+ it('reads by id and uuid', async () => {
30
+ expect((await dao.getById(created.id!))?.versionName).toBe(marker);
31
+ expect((await dao.getByUuid(created.uuid!))?.versionName).toBe(marker);
32
+ expect(await dao.getById(-1)).toBeNull();
33
+ expect(await dao.getByUuid(randomUUID())).toBeNull();
34
+ expect((await dao.getLatest())?.id).toBeGreaterThanOrEqual(created.id!);
35
+ });
36
+
37
+ it('updates and bumps updatedAt', async () => {
38
+ const updated = await dao.update(created.id!, {
39
+ versionName: `${marker}-2`,
40
+ });
41
+ expect(updated?.versionName).toBe(`${marker}-2`);
42
+ expect(await dao.update(-1, { versionName: 'x' })).toBeNull();
43
+ });
44
+
45
+ it('paginates with the standard envelope', async () => {
46
+ const page = await dao.getAll(1, 5);
47
+ expect(page.success).toBe(true);
48
+ expect(page.page).toBe(1);
49
+ expect(page.limit).toBe(5);
50
+ expect(page.count).toBe(page.data.length);
51
+ expect(page.totalCount).toBeGreaterThanOrEqual(1);
52
+ expect(page.totalPages).toBe(Math.ceil(page.totalCount / 5));
53
+ const clamped = await dao.getAll(0, 0);
54
+ expect(clamped.page).toBe(1);
55
+ expect(clamped.limit).toBe(1);
56
+ });
57
+
58
+ it('works inside a transaction', async () => {
59
+ const knex = KnexManager.getConnection();
60
+ await knex.transaction(async (trx) => {
61
+ const row = await dao.create({ versionName: `${marker}-trx` }, trx);
62
+ expect((await dao.getById(row.id!, trx))?.versionName).toBe(
63
+ `${marker}-trx`
64
+ );
65
+ expect(await dao.delete(row.id!, trx)).toBe(true);
66
+ });
67
+ });
68
+
69
+ it('deletes', async () => {
70
+ expect(await dao.delete(created.id!)).toBe(true);
71
+ expect(await dao.delete(created.id!)).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,10 @@
1
+ import * as db from '..';
2
+
3
+ describe('db barrel', () => {
4
+ it('exports the framework pieces', () => {
5
+ expect(db.KnexManager).toBeDefined();
6
+ expect(db.BaseDAO).toBeDefined();
7
+ expect(db.buildKnexConfig).toBeDefined();
8
+ expect(db.SundaysPackageVersionDAO).toBeDefined();
9
+ });
10
+ });
@@ -0,0 +1,90 @@
1
+ import KnexManager from '../KnexConnection';
2
+ import { buildKnexConfig, isLocalhost } from '../knex.config';
3
+ import { loadEnv } from '../../common/config/env';
4
+
5
+ // Requires the local Postgres from docker-compose.yml (npm run test:unit skips this suite).
6
+ describe('KnexManager', () => {
7
+ afterAll(async () => {
8
+ await KnexManager.disconnect();
9
+ });
10
+
11
+ it('throws before connect()', async () => {
12
+ await KnexManager.disconnect();
13
+ expect(KnexManager.isConnected()).toBe(false);
14
+ expect(() => KnexManager.getConnection()).toThrow(/connect\(\) first/);
15
+ });
16
+
17
+ it('connects once and reuses the instance', async () => {
18
+ const a = await KnexManager.connect();
19
+ const b = await KnexManager.connect();
20
+ expect(a).toBe(b);
21
+ expect(KnexManager.isConnected()).toBe(true);
22
+ const { rows } = await KnexManager.getConnection().raw('SELECT 1 AS one');
23
+ expect(rows[0].one).toBe(1);
24
+ });
25
+
26
+ it('disconnects idempotently', async () => {
27
+ await KnexManager.disconnect();
28
+ await KnexManager.disconnect();
29
+ expect(KnexManager.isConnected()).toBe(false);
30
+ });
31
+
32
+ it('rejects and leaves no instance when the database is unreachable', async () => {
33
+ const bad = buildKnexConfig(
34
+ loadEnv({
35
+ ...process.env,
36
+ SQL_HOST: '127.0.0.1',
37
+ SQL_PORT: '1',
38
+ SQL_USER: 'x',
39
+ SQL_PASSWORD: 'x',
40
+ SQL_DB_NAME: 'x',
41
+ }),
42
+ 1
43
+ );
44
+ const silent = {
45
+ warn: () => {},
46
+ error: () => {},
47
+ deprecate: () => {},
48
+ debug: () => {},
49
+ };
50
+ await expect(
51
+ KnexManager.connect({
52
+ ...bad,
53
+ acquireConnectionTimeout: 1000,
54
+ log: silent,
55
+ })
56
+ ).rejects.toBeDefined();
57
+ expect(KnexManager.isConnected()).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe('buildKnexConfig', () => {
62
+ const base = {
63
+ ...process.env,
64
+ SQL_USER: 'u',
65
+ SQL_PASSWORD: 'p',
66
+ SQL_DB_NAME: 'db',
67
+ };
68
+
69
+ it('disables ssl for localhost', () => {
70
+ const cfg = buildKnexConfig(loadEnv({ ...base, SQL_HOST: 'localhost' }));
71
+ expect((cfg.connection as { ssl: unknown }).ssl).toBe(false);
72
+ expect(isLocalhost('::1')).toBe(true);
73
+ });
74
+
75
+ it('enables ssl with rejectUnauthorized for remote hosts', () => {
76
+ const cfg = buildKnexConfig(
77
+ loadEnv({
78
+ ...base,
79
+ SQL_HOST: 'db.example.com',
80
+ SQL_REJECT_UNAUTHORIZED: 'false',
81
+ }),
82
+ 3
83
+ );
84
+ expect((cfg.connection as { ssl: unknown }).ssl).toEqual({
85
+ rejectUnauthorized: false,
86
+ });
87
+ expect(cfg.pool?.max).toBe(3);
88
+ expect(cfg.migrations?.loadExtensions).toEqual(['.ts']);
89
+ });
90
+ });
@@ -0,0 +1,42 @@
1
+ import type { Knex } from 'knex';
2
+
3
+ /** Columns every table created by sundaysf has. */
4
+ export interface IEntity {
5
+ id?: number;
6
+ uuid?: string;
7
+ createdAt?: Date | string;
8
+ updatedAt?: Date | string;
9
+ }
10
+
11
+ /** Payload accepted by `create`: everything but the server-generated columns. */
12
+ export type CreateInput<T extends IEntity> = Omit<
13
+ T,
14
+ 'id' | 'uuid' | 'createdAt' | 'updatedAt'
15
+ >;
16
+
17
+ export interface IDataPaginator<T> {
18
+ success: boolean;
19
+ data: T[];
20
+ page: number;
21
+ limit: number;
22
+ count: number;
23
+ totalCount: number;
24
+ totalPages: number;
25
+ }
26
+
27
+ export interface IBaseDAO<T extends IEntity> {
28
+ create(item: CreateInput<T>, trx?: Knex.Transaction): Promise<T>;
29
+ getById(id: number, trx?: Knex.Transaction): Promise<T | null>;
30
+ getByUuid(uuid: string, trx?: Knex.Transaction): Promise<T | null>;
31
+ update(
32
+ id: number,
33
+ item: Partial<T>,
34
+ trx?: Knex.Transaction
35
+ ): Promise<T | null>;
36
+ delete(id: number, trx?: Knex.Transaction): Promise<boolean>;
37
+ getAll(
38
+ page: number,
39
+ limit: number,
40
+ trx?: Knex.Transaction
41
+ ): Promise<IDataPaginator<T>>;
42
+ }
@@ -0,0 +1,12 @@
1
+ import { BaseDAO } from '../../BaseDAO';
2
+ import type { ISundaysPackageVersion } from '../../interfaces/sundays-package-version/sundays-package-version.interfaces';
3
+
4
+ /** Framework bookkeeping table: which Sundays version generated this project. */
5
+ export class SundaysPackageVersionDAO extends BaseDAO<ISundaysPackageVersion> {
6
+ protected readonly table = 'sundays_package_version';
7
+
8
+ async getLatest(): Promise<ISundaysPackageVersion | null> {
9
+ const row = await this.q().orderBy('id', 'desc').first();
10
+ return (row as ISundaysPackageVersion | undefined) ?? null;
11
+ }
12
+ }
@@ -0,0 +1,17 @@
1
+ // Database barrel. Controllers and services import DAOs and entity types from here:
2
+ // import { ProductDAO, IProduct } from '../../db';
3
+ // `sundaysf generate entity` appends to this file at the @sundays markers. Keep them.
4
+
5
+ import KnexManager from './KnexConnection';
6
+ export { KnexManager };
7
+ export { BaseDAO } from './BaseDAO';
8
+ export { buildKnexConfig } from './knex.config';
9
+ export type { IBaseDAO, IDataPaginator, IEntity, CreateInput } from './d.types';
10
+
11
+ // Interfaces
12
+ export type { ISundaysPackageVersion } from './interfaces/sundays-package-version/sundays-package-version.interfaces';
13
+ // @sundays:interfaces
14
+
15
+ // DAOs
16
+ export { SundaysPackageVersionDAO } from './dao/sundays-package-version/sundays-package-version.dao';
17
+ // @sundays:daos
@@ -0,0 +1,5 @@
1
+ import type { IEntity } from '../../d.types';
2
+
3
+ export interface ISundaysPackageVersion extends IEntity {
4
+ versionName: string;
5
+ }
@@ -0,0 +1,46 @@
1
+ import path from 'path';
2
+ import type { Knex } from 'knex';
3
+ import { env, type Env } from '../common/config/env';
4
+
5
+ export const isLocalhost = (host: string): boolean =>
6
+ host === 'localhost' || host === '127.0.0.1' || host === '::1';
7
+
8
+ /**
9
+ * Single source of truth for the knex configuration: used by KnexManager (runtime) and
10
+ * by knexfile.ts (knex CLI). Migrations and seeds live in src/ and are compiled with the
11
+ * app, so their extension follows the extension this file is running with
12
+ * ('.ts' under tsx / ts-jest, '.js' from dist/).
13
+ */
14
+ export const buildKnexConfig = (e: Env = env, poolMax = 15): Knex.Config => {
15
+ const ext = path.extname(__filename) || '.js';
16
+ return {
17
+ client: 'pg',
18
+ connection: {
19
+ host: e.SQL_HOST,
20
+ port: e.SQL_PORT,
21
+ user: e.SQL_USER,
22
+ password: e.SQL_PASSWORD,
23
+ database: e.SQL_DB_NAME,
24
+ ssl: isLocalhost(e.SQL_HOST)
25
+ ? false
26
+ : { rejectUnauthorized: e.SQL_REJECT_UNAUTHORIZED },
27
+ },
28
+ pool: {
29
+ min: 1,
30
+ max: poolMax,
31
+ idleTimeoutMillis: 20000,
32
+ acquireTimeoutMillis: 30000,
33
+ },
34
+ migrations: {
35
+ tableName: 'knex_migrations',
36
+ directory: path.join(__dirname, '..', 'migrations'),
37
+ extension: 'ts',
38
+ loadExtensions: [ext],
39
+ },
40
+ seeds: {
41
+ directory: path.join(__dirname, '..', 'seeds'),
42
+ extension: 'ts',
43
+ loadExtensions: [ext],
44
+ },
45
+ };
46
+ };
File without changes
File without changes
@@ -0,0 +1,117 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import * as envConfig from '../../../common/config/env';
3
+ import { HttpError } from '../../../common/errors/http.error';
4
+ import { errorMiddleware } from '../error.middleware';
5
+
6
+ const mockRes = (): Response => {
7
+ const res: Partial<Response> = {};
8
+ res.status = jest.fn().mockReturnValue(res);
9
+ res.json = jest.fn().mockReturnValue(res);
10
+ return res as Response;
11
+ };
12
+ const req = {} as Request;
13
+ const next = jest.fn() as NextFunction;
14
+
15
+ describe('errorMiddleware', () => {
16
+ it('renders HttpError with its status and details', () => {
17
+ const res = mockRes();
18
+ errorMiddleware(
19
+ new HttpError(400, 'Validation failed', { name: ['Required'] }),
20
+ req,
21
+ res,
22
+ next
23
+ );
24
+ expect(res.status).toHaveBeenCalledWith(400);
25
+ expect(res.json).toHaveBeenCalledWith({
26
+ success: false,
27
+ message: 'Validation failed',
28
+ errors: { name: ['Required'] },
29
+ });
30
+ });
31
+
32
+ it('honours statusCode / status on plain errors', () => {
33
+ const res = mockRes();
34
+ errorMiddleware(
35
+ Object.assign(new Error('nope'), { status: 418 }),
36
+ req,
37
+ res,
38
+ next
39
+ );
40
+ expect(res.status).toHaveBeenCalledWith(418);
41
+ expect(res.json).toHaveBeenCalledWith({ success: false, message: 'nope' });
42
+ });
43
+
44
+ it('defaults to 500 and keeps the message outside production', () => {
45
+ const res = mockRes();
46
+ errorMiddleware(new Error('db exploded'), req, res, next);
47
+ expect(res.status).toHaveBeenCalledWith(500);
48
+ expect(res.json).toHaveBeenCalledWith({
49
+ success: false,
50
+ message: 'db exploded',
51
+ });
52
+ });
53
+
54
+ it('hides 500 messages in production', () => {
55
+ const spy = jest.spyOn(envConfig, 'isProduction').mockReturnValue(true);
56
+ const res = mockRes();
57
+ errorMiddleware(new Error('db exploded'), req, res, next);
58
+ expect(res.status).toHaveBeenCalledWith(500);
59
+ expect(res.json).toHaveBeenCalledWith({
60
+ success: false,
61
+ message: 'Internal server error',
62
+ });
63
+ spy.mockRestore();
64
+ });
65
+
66
+ it.each([
67
+ ['23505', 409, 'A record with the same unique value already exists'],
68
+ ['23503', 409, 'Referenced record does not exist or is still referenced'],
69
+ ['23502', 400, 'A required column is missing'],
70
+ ['22P02', 400, 'Invalid value for a column'],
71
+ ])('maps Postgres error %s to %i', (code, status, message) => {
72
+ const res = mockRes();
73
+ const pgError = Object.assign(new Error('insert into ... violates'), {
74
+ code,
75
+ constraint: 'product_name_unique',
76
+ });
77
+ errorMiddleware(pgError, req, res, next);
78
+ expect(res.status).toHaveBeenCalledWith(status);
79
+ expect(res.json).toHaveBeenCalledWith({
80
+ success: false,
81
+ message,
82
+ errors: { constraint: 'product_name_unique' },
83
+ });
84
+ });
85
+
86
+ it('ignores unknown Postgres codes and missing constraint names', () => {
87
+ const res = mockRes();
88
+ errorMiddleware(
89
+ Object.assign(new Error('weird'), { code: '58000' }),
90
+ req,
91
+ res,
92
+ next
93
+ );
94
+ expect(res.status).toHaveBeenCalledWith(500);
95
+ const res2 = mockRes();
96
+ errorMiddleware(
97
+ Object.assign(new Error('dup'), { code: '23505' }),
98
+ req,
99
+ res2,
100
+ next
101
+ );
102
+ expect(res2.json).toHaveBeenCalledWith({
103
+ success: false,
104
+ message: 'A record with the same unique value already exists',
105
+ });
106
+ });
107
+
108
+ it('survives a non-Error value', () => {
109
+ const res = mockRes();
110
+ errorMiddleware(undefined, req, res, next);
111
+ expect(res.status).toHaveBeenCalledWith(500);
112
+ expect(res.json).toHaveBeenCalledWith({
113
+ success: false,
114
+ message: 'Internal server error',
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,70 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import { isProduction } from '../../common/config/env';
3
+ import { logger } from '../../common/logger';
4
+ import { isHttpError } from '../../common/errors/http.error';
5
+
6
+ interface ErrorLike {
7
+ statusCode?: number;
8
+ status?: number;
9
+ message?: string;
10
+ type?: string;
11
+ /** Postgres SQLSTATE (set by pg on database errors). */
12
+ code?: string;
13
+ constraint?: string;
14
+ detail?: string;
15
+ }
16
+
17
+ /** Postgres constraint violations mapped to client errors instead of a raw 500. */
18
+ const PG_ERRORS: Record<string, { statusCode: number; message: string }> = {
19
+ '23505': {
20
+ statusCode: 409,
21
+ message: 'A record with the same unique value already exists',
22
+ },
23
+ '23503': {
24
+ statusCode: 409,
25
+ message: 'Referenced record does not exist or is still referenced',
26
+ },
27
+ '23502': { statusCode: 400, message: 'A required column is missing' },
28
+ '22P02': { statusCode: 400, message: 'Invalid value for a column' },
29
+ };
30
+
31
+ /**
32
+ * Last middleware in the chain. Renders every error as `{ success: false, message }`
33
+ * (plus `errors` for validation failures). 500s hide their message in production.
34
+ */
35
+ export const errorMiddleware = (
36
+ err: unknown,
37
+ req: Request,
38
+ res: Response,
39
+ _next: NextFunction
40
+ ): void => {
41
+ const e = (err ?? {}) as ErrorLike;
42
+ const pg = typeof e.code === 'string' ? PG_ERRORS[e.code] : undefined;
43
+ const statusCode = isHttpError(err)
44
+ ? err.statusCode
45
+ : (pg?.statusCode ?? e.statusCode ?? e.status ?? 500);
46
+ const details = isHttpError(err)
47
+ ? err.details
48
+ : pg && e.constraint
49
+ ? { constraint: e.constraint }
50
+ : undefined;
51
+ const log = req.log ?? logger;
52
+
53
+ if (statusCode >= 500) {
54
+ log.error({ err }, 'Unhandled error');
55
+ } else {
56
+ log.warn({ err: { message: e.message, statusCode } }, 'Request failed');
57
+ }
58
+
59
+ const message = pg
60
+ ? pg.message
61
+ : statusCode >= 500 && isProduction()
62
+ ? 'Internal server error'
63
+ : (e.message ?? 'Internal server error');
64
+
65
+ res.status(statusCode).json({
66
+ success: false,
67
+ message,
68
+ ...(details !== undefined ? { errors: details } : {}),
69
+ });
70
+ };
@@ -0,0 +1,54 @@
1
+ import type { Request, Response } from 'express';
2
+ import request from 'supertest';
3
+ import app from '../../../app';
4
+ import { notFoundMiddleware } from '../not-found.middleware';
5
+
6
+ describe('notFoundMiddleware', () => {
7
+ it('returns the JSON envelope for API clients', async () => {
8
+ const res = await request(app)
9
+ .get('/api/nope')
10
+ .set('Accept', 'application/json');
11
+ expect(res.status).toBe(404);
12
+ expect(res.body).toEqual({
13
+ success: false,
14
+ message: 'Route GET /api/nope does not exist',
15
+ });
16
+ });
17
+
18
+ it('returns JSON when no Accept header is sent', async () => {
19
+ const res = await request(app).delete('/whatever');
20
+ expect(res.status).toBe(404);
21
+ expect(res.body.success).toBe(false);
22
+ });
23
+
24
+ it('returns an HTML page for browsers', async () => {
25
+ const res = await request(app).get('/nope').set('Accept', 'text/html');
26
+ expect(res.status).toBe(404);
27
+ expect(res.headers['content-type']).toMatch(/text\/html/);
28
+ expect(res.text).toContain('404');
29
+ expect(res.text).toContain('GET /nope');
30
+ });
31
+
32
+ it('escapes the requested path in the HTML page', () => {
33
+ const req = {
34
+ method: 'GET',
35
+ originalUrl: '/<script>alert("x")</script>',
36
+ accepts: jest.fn((types?: string | string[]) =>
37
+ Array.isArray(types) ? 'html' : 'text/html'
38
+ ),
39
+ } as unknown as Request;
40
+ const res = {
41
+ status: jest.fn().mockReturnThis(),
42
+ type: jest.fn().mockReturnThis(),
43
+ send: jest.fn().mockReturnThis(),
44
+ json: jest.fn().mockReturnThis(),
45
+ } as unknown as Response;
46
+
47
+ notFoundMiddleware(req, res);
48
+
49
+ expect(res.status).toHaveBeenCalledWith(404);
50
+ const html = (res.send as jest.Mock).mock.calls[0][0] as string;
51
+ expect(html).toContain('&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
52
+ expect(html).not.toContain('<script>alert');
53
+ });
54
+ });
@@ -0,0 +1,51 @@
1
+ import type { Request, Response } from 'express';
2
+
3
+ const PROJECT_NAME = '__SF_PROJECT_NAME__';
4
+
5
+ const escapeHtml = (value: string): string =>
6
+ value
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&#39;');
12
+
13
+ /**
14
+ * 404 catch-all, mounted after every router and before the error middleware.
15
+ * Browsers (Accept: text/html) get a small HTML page; API clients get the standard
16
+ * `{ success: false, message }` envelope.
17
+ */
18
+ export const notFoundMiddleware = (req: Request, res: Response): void => {
19
+ const method = escapeHtml(req.method);
20
+ const url = escapeHtml(req.originalUrl);
21
+
22
+ if (!req.accepts('html') || req.accepts(['json', 'html']) === 'json') {
23
+ res.status(404).json({
24
+ success: false,
25
+ message: `Route ${req.method} ${req.originalUrl} does not exist`,
26
+ });
27
+ return;
28
+ }
29
+
30
+ res.status(404).type('html').send(`<!DOCTYPE html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
35
+ <title>404 · ${escapeHtml(PROJECT_NAME)}</title>
36
+ </head>
37
+ <body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;">
38
+ <main style="max-width:560px;padding:32px;">
39
+ <p style="margin:0;font-size:72px;font-weight:800;line-height:1;color:#38bdf8;">404</p>
40
+ <h1 style="margin:16px 0 8px;font-size:22px;">Nothing here</h1>
41
+ <p style="margin:0 0 16px;color:#94a3b8;line-height:1.6;">
42
+ <code style="background:#1e293b;padding:2px 8px;border-radius:6px;">${method} ${url}</code>
43
+ is not a route of the ${escapeHtml(PROJECT_NAME)} API.
44
+ </p>
45
+ <p style="margin:0;font-size:13px;color:#64748b;">
46
+ API clients receive <code>{ success: false, message }</code> with <code>Accept: application/json</code>.
47
+ </p>
48
+ </main>
49
+ </body>
50
+ </html>`);
51
+ };