@edium/halifax 1.0.0

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 (135) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/README_AUTH.md +172 -0
  5. package/README_AUTOCRUD.md +253 -0
  6. package/README_CACHE.md +164 -0
  7. package/README_HTTP_ADAPTERS.md +309 -0
  8. package/README_MULTITENANCY.md +162 -0
  9. package/README_QUERYBUILDER.md +219 -0
  10. package/README_REPO_ADAPTERS.md +266 -0
  11. package/dist/adapters/http/ExpressAdapter.d.ts +40 -0
  12. package/dist/adapters/http/ExpressAdapter.d.ts.map +1 -0
  13. package/dist/adapters/http/ExpressAdapter.js +109 -0
  14. package/dist/adapters/http/ExpressAdapter.js.map +1 -0
  15. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +143 -0
  16. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +1 -0
  17. package/dist/adapters/orm/prisma/PrismaAdapter.js +277 -0
  18. package/dist/adapters/orm/prisma/PrismaAdapter.js.map +1 -0
  19. package/dist/adapters/orm/prisma/createPrismaResources.d.ts +15 -0
  20. package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +1 -0
  21. package/dist/adapters/orm/prisma/createPrismaResources.js +51 -0
  22. package/dist/adapters/orm/prisma/createPrismaResources.js.map +1 -0
  23. package/dist/adapters/orm/prisma/helpers.d.ts +27 -0
  24. package/dist/adapters/orm/prisma/helpers.d.ts.map +1 -0
  25. package/dist/adapters/orm/prisma/helpers.js +45 -0
  26. package/dist/adapters/orm/prisma/helpers.js.map +1 -0
  27. package/dist/adapters/orm/prisma/index.d.ts +4 -0
  28. package/dist/adapters/orm/prisma/index.d.ts.map +1 -0
  29. package/dist/adapters/orm/prisma/index.js +3 -0
  30. package/dist/adapters/orm/prisma/index.js.map +1 -0
  31. package/dist/adapters/orm/prisma/types.d.ts +49 -0
  32. package/dist/adapters/orm/prisma/types.d.ts.map +1 -0
  33. package/dist/adapters/orm/prisma/types.js +2 -0
  34. package/dist/adapters/orm/prisma/types.js.map +1 -0
  35. package/dist/auth/AuthStrategy.d.ts +198 -0
  36. package/dist/auth/AuthStrategy.d.ts.map +1 -0
  37. package/dist/auth/AuthStrategy.js +227 -0
  38. package/dist/auth/AuthStrategy.js.map +1 -0
  39. package/dist/classes/QueryBuilder.d.ts +33 -0
  40. package/dist/classes/QueryBuilder.d.ts.map +1 -0
  41. package/dist/classes/QueryBuilder.js +262 -0
  42. package/dist/classes/QueryBuilder.js.map +1 -0
  43. package/dist/core/crudRouter.d.ts +36 -0
  44. package/dist/core/crudRouter.d.ts.map +1 -0
  45. package/dist/core/crudRouter.js +391 -0
  46. package/dist/core/crudRouter.js.map +1 -0
  47. package/dist/core/queryString.d.ts +13 -0
  48. package/dist/core/queryString.d.ts.map +1 -0
  49. package/dist/core/queryString.js +89 -0
  50. package/dist/core/queryString.js.map +1 -0
  51. package/dist/core/types.d.ts +293 -0
  52. package/dist/core/types.d.ts.map +1 -0
  53. package/dist/core/types.js +13 -0
  54. package/dist/core/types.js.map +1 -0
  55. package/dist/core/validation.d.ts +75 -0
  56. package/dist/core/validation.d.ts.map +1 -0
  57. package/dist/core/validation.js +206 -0
  58. package/dist/core/validation.js.map +1 -0
  59. package/dist/enums/SqlComparison.d.ts +18 -0
  60. package/dist/enums/SqlComparison.d.ts.map +1 -0
  61. package/dist/enums/SqlComparison.js +19 -0
  62. package/dist/enums/SqlComparison.js.map +1 -0
  63. package/dist/enums/SqlOperator.d.ts +6 -0
  64. package/dist/enums/SqlOperator.d.ts.map +1 -0
  65. package/dist/enums/SqlOperator.js +7 -0
  66. package/dist/enums/SqlOperator.js.map +1 -0
  67. package/dist/enums/SqlOrder.d.ts +6 -0
  68. package/dist/enums/SqlOrder.d.ts.map +1 -0
  69. package/dist/enums/SqlOrder.js +7 -0
  70. package/dist/enums/SqlOrder.js.map +1 -0
  71. package/dist/errors/AuthenticationError.d.ts +10 -0
  72. package/dist/errors/AuthenticationError.d.ts.map +1 -0
  73. package/dist/errors/AuthenticationError.js +13 -0
  74. package/dist/errors/AuthenticationError.js.map +1 -0
  75. package/dist/errors/AuthorizationError.d.ts +10 -0
  76. package/dist/errors/AuthorizationError.d.ts.map +1 -0
  77. package/dist/errors/AuthorizationError.js +13 -0
  78. package/dist/errors/AuthorizationError.js.map +1 -0
  79. package/dist/errors/BadRequestError.d.ts +10 -0
  80. package/dist/errors/BadRequestError.d.ts.map +1 -0
  81. package/dist/errors/BadRequestError.js +13 -0
  82. package/dist/errors/BadRequestError.js.map +1 -0
  83. package/dist/errors/HttpError.d.ts +12 -0
  84. package/dist/errors/HttpError.d.ts.map +1 -0
  85. package/dist/errors/HttpError.js +17 -0
  86. package/dist/errors/HttpError.js.map +1 -0
  87. package/dist/errors/MethodNotAllowedError.d.ts +10 -0
  88. package/dist/errors/MethodNotAllowedError.d.ts.map +1 -0
  89. package/dist/errors/MethodNotAllowedError.js +13 -0
  90. package/dist/errors/MethodNotAllowedError.js.map +1 -0
  91. package/dist/errors/NotAcceptableError.d.ts +10 -0
  92. package/dist/errors/NotAcceptableError.d.ts.map +1 -0
  93. package/dist/errors/NotAcceptableError.js +13 -0
  94. package/dist/errors/NotAcceptableError.js.map +1 -0
  95. package/dist/errors/NotFoundError.d.ts +10 -0
  96. package/dist/errors/NotFoundError.d.ts.map +1 -0
  97. package/dist/errors/NotFoundError.js +13 -0
  98. package/dist/errors/NotFoundError.js.map +1 -0
  99. package/dist/errors/NotImplementedError.d.ts +10 -0
  100. package/dist/errors/NotImplementedError.d.ts.map +1 -0
  101. package/dist/errors/NotImplementedError.js +13 -0
  102. package/dist/errors/NotImplementedError.js.map +1 -0
  103. package/dist/errors/ServerError.d.ts +10 -0
  104. package/dist/errors/ServerError.d.ts.map +1 -0
  105. package/dist/errors/ServerError.js +13 -0
  106. package/dist/errors/ServerError.js.map +1 -0
  107. package/dist/errors/UnprocessableEntityError.d.ts +10 -0
  108. package/dist/errors/UnprocessableEntityError.d.ts.map +1 -0
  109. package/dist/errors/UnprocessableEntityError.js +13 -0
  110. package/dist/errors/UnprocessableEntityError.js.map +1 -0
  111. package/dist/errors/UnsupportedMediaTypeError.d.ts +10 -0
  112. package/dist/errors/UnsupportedMediaTypeError.d.ts.map +1 -0
  113. package/dist/errors/UnsupportedMediaTypeError.js +13 -0
  114. package/dist/errors/UnsupportedMediaTypeError.js.map +1 -0
  115. package/dist/index.d.ts +27 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +27 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/interfaces/IParamQuery.d.ts +8 -0
  120. package/dist/interfaces/IParamQuery.d.ts.map +1 -0
  121. package/dist/interfaces/IParamQuery.js +2 -0
  122. package/dist/interfaces/IParamQuery.js.map +1 -0
  123. package/dist/interfaces/IQueryFilter.d.ts +18 -0
  124. package/dist/interfaces/IQueryFilter.d.ts.map +1 -0
  125. package/dist/interfaces/IQueryFilter.js +2 -0
  126. package/dist/interfaces/IQueryFilter.js.map +1 -0
  127. package/dist/interfaces/IQueryOptions.d.ts +20 -0
  128. package/dist/interfaces/IQueryOptions.d.ts.map +1 -0
  129. package/dist/interfaces/IQueryOptions.js +2 -0
  130. package/dist/interfaces/IQueryOptions.js.map +1 -0
  131. package/dist/interfaces/ISort.d.ts +9 -0
  132. package/dist/interfaces/ISort.d.ts.map +1 -0
  133. package/dist/interfaces/ISort.js +2 -0
  134. package/dist/interfaces/ISort.js.map +1 -0
  135. package/package.json +169 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [1.0.0]
7
+
8
+ First public release.
9
+
10
+ ### Added
11
+
12
+ - **Auto-CRUD engine** — generate standards-compliant REST endpoints (list, read, create,
13
+ update, upsert, delete, and bulk update/delete) from a single `ResourceDefinition`, with
14
+ correct status codes, a consistent `{ errors: [...] }` body, content negotiation (406/415),
15
+ method-not-allowed (405 + `Allow`), and `X-Correlation-ID` / `Idempotency-Key` support.
16
+ - **HTTP adapters** — Express 4/5, Fastify, HyperExpress, and Ultimate Express, each published
17
+ as its own subpath entry point and verified against one shared conformance suite.
18
+ - **Prisma repository adapter** — one `PrismaAdapter` for every Prisma provider (PostgreSQL,
19
+ MySQL/MariaDB, SQL Server, SQLite, CockroachDB, MongoDB).
20
+ - **Dynamic query-builder endpoint** (`POST /:resource/query`) — a validated query AST
21
+ (filter/sort/paginate/project, `AND`/`OR`/nesting, `IN`, `BETWEEN`, `CONTAINS`,
22
+ `STARTS WITH`, `ENDS WITH`, …) compiled to portable Prisma Client calls — no raw SQL, so
23
+ the same request behaves identically on every database.
24
+ - **Multi-tenancy** — per-resource tenant scoping with fail-closed guarantees.
25
+ - **Read-through caching** — pluggable `CacheStore` (in-memory default, `RedisCacheStore`
26
+ provided), per-resource TTLs, never-expire mode, automatic write-invalidation, tenant-safe
27
+ keys, and a `Cache-Control` cache-bust header.
28
+ - **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action
29
+ required permissions; and `filterable`/`sortable`/`selectable`/`writable` field flags.
30
+
31
+ [1.0.0]: https://github.com/splayfee/halifax/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # Halifax
2
+
3
+ [![CI](https://github.com/splayfee/halifax/actions/workflows/ci.yml/badge.svg)](https://github.com/splayfee/halifax/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@edium/halifax.svg)](https://www.npmjs.com/package/@edium/halifax)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Halifax is an adapter-driven TypeScript framework for building standardized REST CRUD APIs automatically from resource definitions. It generates standards-compliant REST endpoints from your data models, wires up authentication, and exposes a dynamic query-builder endpoint for advanced read/update/delete operations.
9
+
10
+ The package is split into small, replaceable layers — nothing is imported into the core engine. Your ORM, HTTP server, and auth provider are all injected at startup.
11
+
12
+ ## Why Halifax?
13
+
14
+ - 🚀 **Zero-boilerplate CRUD** — define a resource once and get standards-compliant REST endpoints (list, read, create, update, upsert, delete, bulk) with correct status codes and a consistent error shape.
15
+ - 🧩 **Adapter-driven & swappable** — your HTTP framework, ORM/database, and auth provider are injected, not baked in. Switch any layer without touching your resource definitions.
16
+ - 🌐 **4 HTTP frameworks, identical behavior** — Express 4/5, Fastify, HyperExpress, and Ultimate Express, all verified against one shared conformance suite.
17
+ - 🗄️ **Many databases, one adapter** — PostgreSQL, MySQL, MariaDB, SQL Server, SQLite, CockroachDB, and MongoDB via [Prisma](https://www.prisma.io/). The query builder compiles to portable Prisma calls (never raw SQL), so the **same client request behaves identically on every database** — switch engines by changing one line. (PostgreSQL, MySQL, and SQLite run in CI; the rest use the same adapter and test harness.)
18
+ - 🔎 **Dynamic query-builder endpoint** — let the front-end compose rich filtered/sorted/paginated queries "for free" (`AND`/`OR`/nesting, `IN`, `BETWEEN`, `CONTAINS`, …) without hand-writing endpoints. Fully validated — bad fields/operators return structured `4xx` errors, never leaked DB internals.
19
+ - 🏢 **Multi-tenancy built in** — per-resource tenant scoping with fail-closed guarantees; one tenant can never read or write another's rows.
20
+ - ⚡ **Pluggable read-through caching** — in-memory or Redis, per-resource TTLs, never-expire mode, automatic write-invalidation, tenant-safe keys, and a `Cache-Control` bust header.
21
+ - 🔐 **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action permissions; and `filterable`/`sortable`/`selectable`/`writable` flags enforced on every request.
22
+ - 🧪 **Type-safe & battle-tested** — strict TypeScript, ESM, ships full `.d.ts`; hundreds of unit tests plus real-database + Redis integration tests in CI.
23
+
24
+ ## Current Support
25
+
26
+ | Layer | Supported |
27
+ | -------------- | ----------------------------------------------------------------------------- |
28
+ | HTTP server | Express 4/5, Fastify, HyperExpress, Ultimate Express |
29
+ | ORM / database | Prisma 7 + Postgres, MySQL, MariaDB, SQL Server, SQLite, MongoDB, CockroachDB |
30
+ | Auth | API key, JWT/Bearer, Passport + JWT |
31
+ | Caching | Pluggable read-through cache (in-memory default; bring Redis, etc.) |
32
+
33
+ Every HTTP adapter is interchangeable and behaves identically — same routes, status codes,
34
+ error-body shape, and content negotiation — so you can switch frameworks without touching
35
+ your resource definitions, auth, or query logic. See
36
+ [README_HTTP_ADAPTERS.md](./README_HTTP_ADAPTERS.md) for per-framework usage.
37
+
38
+ The same is true across databases: the dynamic query-builder endpoint and all CRUD compile
39
+ to portable Prisma Client calls (never raw SQL), so the **same client request behaves
40
+ identically on every database** — switch engines by changing only the Prisma `provider`. The
41
+ integration suite runs unchanged against Postgres, MySQL, and SQLite in CI to keep that honest.
42
+
43
+ > **Roadmap** — This project welcomes community-written adapters for Drizzle, Sequelize, etc.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pnpm add @edium/halifax
49
+ pnpm add express @prisma/client
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```ts
55
+ import express from 'express'
56
+ import { PrismaClient } from '@prisma/client'
57
+ import { PrismaPg } from '@prisma/adapter-pg'
58
+ import {
59
+ PrismaAdapter,
60
+ ApiKeyAuthStrategy,
61
+ createExpressCrudRouter,
62
+ type ResourceDefinition
63
+ } from '@edium/halifax'
64
+
65
+ const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL!) })
66
+
67
+ const posts: ResourceDefinition = {
68
+ name: 'Post',
69
+ routePrefix: 'posts',
70
+ defaultLimit: 50,
71
+ maxLimit: 200,
72
+ fields: [
73
+ { name: 'id', filterable: true, sortable: true },
74
+ { name: 'title', filterable: true, sortable: true, writable: true },
75
+ { name: 'content', writable: true },
76
+ { name: 'published', filterable: true, writable: true }
77
+ ],
78
+ permissions: {
79
+ allowUpdateMany: false,
80
+ allowDeleteMany: false
81
+ },
82
+ repository: new PrismaAdapter({ delegate: prisma.post })
83
+ }
84
+
85
+ const app = express()
86
+ app.use(express.json())
87
+ app.use(
88
+ '/api/v1',
89
+ createExpressCrudRouter([posts], { authStrategy: new ApiKeyAuthStrategy(process.env.API_KEY!) })
90
+ )
91
+ app.listen(3000)
92
+ ```
93
+
94
+ ## Documentation
95
+
96
+ | Guide | Contents |
97
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
98
+ | [README_AUTOCRUD.md](./README_AUTOCRUD.md) | Resource definitions, field flags, ID types, pagination, query-string filtering, error shapes |
99
+ | [README_REPO_ADAPTERS.md](./README_REPO_ADAPTERS.md) | Prisma 7 setup, `PrismaAdapter` options, capabilities, custom repositories |
100
+ | [README_HTTP_ADAPTERS.md](./README_HTTP_ADAPTERS.md) | Express, Fastify, HyperExpress & Ultimate Express adapters, and custom HTTP adapters |
101
+ | [README_AUTH.md](./README_AUTH.md) | Auth strategies (`ApiKey`, `JWT`, `Passport`), `requiredPermissions`, custom `authorize` |
102
+ | [README_MULTITENANCY.md](./README_MULTITENANCY.md) | Tenant isolation: `tenant` options, auto-detection, scoping guarantees, fail-closed behaviour |
103
+ | [README_QUERYBUILDER.md](./README_QUERYBUILDER.md) | Query-builder payload, comparisons, nested filters, portable Prisma execution |
104
+ | [README_CACHE.md](./README_CACHE.md) | Read-through caching: in-memory & Redis stores, never-expire, cache-bust header |
105
+
106
+ ## Running Integration Tests
107
+
108
+ The integration suite tests the full stack against a real PostgreSQL database.
109
+
110
+ ### 1. Start a Postgres container
111
+
112
+ ```bash
113
+ docker run -d \
114
+ --name halifax-test-db \
115
+ --restart unless-stopped \
116
+ -e POSTGRES_USER=postgres \
117
+ -e POSTGRES_PASSWORD=postgres \
118
+ -e POSTGRES_DB=halifax_test \
119
+ -p 5432:5432 \
120
+ postgres:17
121
+ ```
122
+
123
+ ### 2. Create `.env.test`
124
+
125
+ ```bash
126
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/halifax_test"
127
+ ```
128
+
129
+ ### 3. Run
130
+
131
+ ```bash
132
+ pnpm test:integration
133
+ ```
134
+
135
+ `globalSetup` runs `prisma generate` and `prisma db push` automatically before any test executes.
136
+
137
+ ### Subsequent runs
138
+
139
+ ```bash
140
+ docker start halifax-test-db
141
+ pnpm test:integration
142
+ ```
143
+
144
+ ### Tear down
145
+
146
+ ```bash
147
+ docker stop halifax-test-db && docker rm halifax-test-db
148
+ ```
package/README_AUTH.md ADDED
@@ -0,0 +1,172 @@
1
+ # Authentication
2
+
3
+ Halifax auth is handled by an `AuthStrategy` injected at router creation time. The interface is:
4
+
5
+ ```ts
6
+ interface AuthStrategy {
7
+ authenticate(req: HttpRequest): AuthContext | Promise<AuthContext>
8
+ authorize?(params: AuthorizeParams): boolean | Promise<boolean>
9
+ }
10
+ ```
11
+
12
+ `authenticate` runs on every request and returns an `AuthContext`. `authorize` is optional — when present it gates each action against the context. When absent, Halifax falls back to checking `requiredPermissions` directly against `auth.roles` and `auth.permissions`.
13
+
14
+ ## Built-in Strategies
15
+
16
+ ### `AllowAllAuthStrategy`
17
+
18
+ No authentication — every request is admitted. For local development only.
19
+
20
+ ```ts
21
+ import { AllowAllAuthStrategy } from '@edium/halifax'
22
+
23
+ createExpressCrudRouter([resource], { authStrategy: new AllowAllAuthStrategy() })
24
+ ```
25
+
26
+ ### `ApiKeyAuthStrategy`
27
+
28
+ Reads a header and compares it to a shared secret.
29
+
30
+ ```ts
31
+ import { ApiKeyAuthStrategy } from '@edium/halifax'
32
+
33
+ // Default header: x-api-key
34
+ const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '')
35
+
36
+ // Custom header name
37
+ const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '', 'x-token')
38
+ ```
39
+
40
+ Wrong or missing key → 403.
41
+
42
+ ### `JwtClaimsAuthStrategy`
43
+
44
+ Extracts a Bearer token from `Authorization` and calls your verify callback. No Passport dependency.
45
+
46
+ ```ts
47
+ import { JwtClaimsAuthStrategy } from '@edium/halifax'
48
+ import { verify } from 'jsonwebtoken'
49
+
50
+ export const authStrategy = new JwtClaimsAuthStrategy(async (token) => {
51
+ const payload = verify(token, process.env.JWT_SECRET!) as Record<string, unknown>
52
+ return {
53
+ isAuthenticated: true,
54
+ userId: payload.sub as string,
55
+ roles: (payload.roles ?? []) as string[],
56
+ permissions: (payload.permissions ?? []) as string[],
57
+ claims: payload
58
+ }
59
+ })
60
+ ```
61
+
62
+ Missing or non-Bearer `Authorization` header → 401. A verify callback that throws → 401.
63
+
64
+ ### `PassportSessionStrategy`
65
+
66
+ Drop-in for apps that use Passport with session cookies (as opposed to JWT Bearer tokens). Passport's session middleware runs at the Express app level before Halifax, so `req.user` is already populated by the time Halifax sees the request. This strategy just reads it.
67
+
68
+ **Prerequisites in your Express app (before mounting Halifax):**
69
+
70
+ ```ts
71
+ import session from 'express-session'
72
+ import passport from 'passport'
73
+
74
+ app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }))
75
+ app.use(passport.initialize())
76
+ app.use(passport.session())
77
+ ```
78
+
79
+ **Usage:**
80
+
81
+ ```ts
82
+ import { PassportSessionStrategy } from '@edium/halifax'
83
+
84
+ // Default: reads id/sub → userId, roles, permissions from req.user
85
+ export const authStrategy = new PassportSessionStrategy()
86
+
87
+ // Custom mapping for non-standard user shapes
88
+ export const authStrategy = new PassportSessionStrategy((user) => {
89
+ const u = user as { username: string; groups: string[] }
90
+ return { isAuthenticated: true, userId: u.username, roles: u.groups }
91
+ })
92
+ ```
93
+
94
+ No passport instance is passed — Halifax never calls `passport.authenticate()`. If `req.user` is absent (session expired, not logged in), the request is rejected with 401.
95
+
96
+ ### `PassportJwtStrategy`
97
+
98
+ Drop-in for an existing Passport + `passport-jwt` setup.
99
+
100
+ ```ts
101
+ import passport from 'passport'
102
+ import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
103
+ import { PassportJwtStrategy } from '@edium/halifax'
104
+
105
+ passport.use(
106
+ new JwtStrategy(
107
+ {
108
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
109
+ secretOrKey: process.env.JWT_SECRET
110
+ },
111
+ (payload, done) => done(null, payload)
112
+ )
113
+ )
114
+
115
+ // Default: reads sub/id → userId, roles, permissions, full payload → claims
116
+ export const authStrategy = new PassportJwtStrategy({ passport })
117
+
118
+ // Custom payload mapping
119
+ export const authStrategy = new PassportJwtStrategy({
120
+ passport,
121
+ mapUser: (user) => {
122
+ const u = user as { userId: string; role: string }
123
+ return { isAuthenticated: true, userId: u.userId, roles: [u.role] }
124
+ }
125
+ })
126
+ ```
127
+
128
+ ## Per-Action Permission Requirements
129
+
130
+ `requiredPermissions` on a resource maps each CRUD action to a list of roles or permission strings. The authenticated user must possess at least one entry from the list (matched against both `auth.roles` and `auth.permissions`).
131
+
132
+ ```ts
133
+ const postResource: ResourceDefinition = {
134
+ ...
135
+ requiredPermissions: {
136
+ readMany: ['posts.read'],
137
+ readOne: ['posts.read'],
138
+ create: ['posts.create'],
139
+ updateOne: ['posts.update'],
140
+ deleteOne: ['posts.delete'],
141
+ }
142
+ }
143
+ ```
144
+
145
+ Actions not listed in `requiredPermissions` are allowed for any authenticated user. If `authorize` is implemented on the strategy, it overrides this fallback entirely.
146
+
147
+ ## Custom `authorize` Logic
148
+
149
+ Implement `authorize` on your strategy for full control:
150
+
151
+ ```ts
152
+ class RoleBasedStrategy implements AuthStrategy {
153
+ authenticate(req) {
154
+ /* ... verify token ... */
155
+ }
156
+
157
+ authorize({ auth, action, resource, requiredPermissions }) {
158
+ if (auth.roles.includes('admin')) return true
159
+ return requiredPermissions.every((p) => auth.permissions.includes(p) || auth.roles.includes(p))
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Environment Variables
165
+
166
+ ```bash
167
+ API_KEY="your-api-key"
168
+ # or
169
+ JWT_SECRET="your-secret-key"
170
+ # or
171
+ SESSION_SECRET="your-secret-key"
172
+ ```
@@ -0,0 +1,253 @@
1
+ # Auto CRUD
2
+
3
+ Halifax generates REST endpoints automatically from a `ResourceDefinition`. Define the resource once; the router registers the routes, validates inputs, enforces field-level security, and handles authentication and authorization.
4
+
5
+ ## Resource Definition
6
+
7
+ ```ts
8
+ import type { ResourceDefinition } from '@edium/halifax'
9
+ import { postRepository } from './repositories/post.js'
10
+
11
+ export const postResource: ResourceDefinition = {
12
+ name: 'Post',
13
+ routePrefix: 'posts',
14
+ defaultLimit: 50, // applied when the caller omits ?limit=
15
+ maxLimit: 200, // requests above this are silently capped
16
+ fields: [
17
+ { name: 'id', filterable: true, sortable: true },
18
+ { name: 'title', filterable: true, sortable: true, writable: true },
19
+ { name: 'content', writable: true },
20
+ { name: 'published', filterable: true, writable: true },
21
+ { name: 'authorId', filterable: true },
22
+ { name: 'createdAt', filterable: false, sortable: true, selectable: true }
23
+ ],
24
+ relations: [{ name: 'author', includable: true }],
25
+ permissions: {
26
+ allowDeleteMany: false
27
+ },
28
+ requiredPermissions: {
29
+ readMany: ['posts.read'],
30
+ readOne: ['posts.read'],
31
+ create: ['posts.create'],
32
+ updateOne: ['posts.update'],
33
+ deleteOne: ['posts.delete']
34
+ },
35
+ repository: postRepository
36
+ }
37
+ ```
38
+
39
+ ## Generated Routes
40
+
41
+ Each `permissions` flag enables one route:
42
+
43
+ | Flag | Method | Path |
44
+ | ------------------------------- | -------- | ---------------- |
45
+ | `allowReadMany` | `GET` | `../posts` |
46
+ | `allowReadOne` | `GET` | `../posts/:id` |
47
+ | `allowCreate` | `POST` | `../posts` |
48
+ | `allowUpdateOne` | `PATCH` | `../posts/:id` |
49
+ | `allowUpdateMany` | `PATCH` | `../posts` |
50
+ | `allowUpsertOne` | `PUT` | `../posts/:id` |
51
+ | `allowDeleteOne` | `DELETE` | `../posts/:id` |
52
+ | `allowDeleteMany` | `DELETE` | `../posts` |
53
+ | `allowReadManyWithQueryBuilder` | `POST` | `../posts/query` |
54
+
55
+ All endpoint flags default to `true` — only set them explicitly to `false` to restrict access.
56
+
57
+ ## The `:id` Parameter
58
+
59
+ `:id` accepts an **integer** (1–2,147,483,647), a **UUID** (RFC 4122, any version), or a **MongoDB ObjectId** (24 hex chars). The format is detected automatically — nothing to configure. Anything else returns 400.
60
+
61
+ ```
62
+ GET /api/v1/posts/42
63
+ GET /api/v1/posts/550e8400-e29b-41d4-a716-446655440000
64
+ GET /api/v1/posts/507f1f77bcf86cd799439011
65
+ ```
66
+
67
+ ## Field Flags
68
+
69
+ Each entry in `fields` accepts four optional boolean flags. All default to `true` — only set them explicitly to `false` to restrict access.
70
+
71
+ | Flag | Effect when `false` |
72
+ | ------------ | --------------------------------------------------------------------- |
73
+ | `filterable` | Rejects the field as a query-string filter (`?fieldName=value`) → 400 |
74
+ | `sortable` | Rejects the field in `?order=` and query-builder `orderBy` → 400 |
75
+ | `selectable` | Rejects the field in `?fields=` and query-builder `fields` → 400 |
76
+ | `writable` | Silently strips the field from POST / PATCH request bodies |
77
+
78
+ Example — `role` is fully locked down; `createdAt` can be read and sorted but never written or filtered:
79
+
80
+ ```ts
81
+ fields: [
82
+ { name: 'id', filterable: true, sortable: true, selectable: true },
83
+ { name: 'email', filterable: true, sortable: true, selectable: true, writable: true },
84
+ { name: 'role', filterable: false, sortable: false, selectable: false, writable: false },
85
+ { name: 'createdAt', filterable: false, sortable: true, selectable: true }
86
+ ]
87
+ ```
88
+
89
+ ## Pagination
90
+
91
+ Set `defaultLimit` and `maxLimit` on the resource:
92
+
93
+ ```ts
94
+ {
95
+ defaultLimit: 50, // used when the caller omits ?limit=
96
+ maxLimit: 200, // requests above this are silently capped to 200
97
+ }
98
+ ```
99
+
100
+ Neither is required. Without them, page size is unlimited and fully caller-controlled.
101
+
102
+ ## Query-String Filtering and Pagination
103
+
104
+ ```
105
+ GET /api/v1/posts?limit=25&offset=0&order=-createdAt&published=true&fields=id,title
106
+ ```
107
+
108
+ | Parameter | Description |
109
+ | --------------- | ---------------------------------------------------------------------- |
110
+ | `limit` | Page size. Capped by `maxLimit`; defaults to `defaultLimit` if set. |
111
+ | `offset` | Number of rows to skip. |
112
+ | `order` | Comma-separated field names. Prefix `-` for descending. |
113
+ | `fields` | Comma-separated field names to include in the response. |
114
+ | `include` | Comma-separated relation names to eager-load (e.g. `?include=author`). |
115
+ | `<field>=value` | Exact-match filter on any `filterable` field. |
116
+
117
+ Multiple values for a field filter produce an `IN` query:
118
+
119
+ ```
120
+ GET /api/v1/posts?authorId=1,2,3
121
+ ```
122
+
123
+ ## Relation Includes
124
+
125
+ Declare includable relations in the resource definition, then request them with `?include=`:
126
+
127
+ ```ts
128
+ relations: [
129
+ { name: 'author', includable: true },
130
+ { name: 'comments', includable: false } // blocked
131
+ ]
132
+ ```
133
+
134
+ ```
135
+ GET /api/v1/posts/42?include=author
136
+ GET /api/v1/posts?include=author&limit=10
137
+ ```
138
+
139
+ ## Batch Create
140
+
141
+ Send an array body to create multiple records in one request:
142
+
143
+ ```ts
144
+ POST /
145
+ api /
146
+ v1 /
147
+ posts[({ title: 'First', published: false }, { title: 'Second', published: true })]
148
+ ```
149
+
150
+ Returns 201. Whether the created records are returned depends on the repository's `supportsCreateManyReturn` capability (see [README_REPO_ADAPTERS.md](./README_REPO_ADAPTERS.md)).
151
+
152
+ ## HTTP Headers
153
+
154
+ ### Content Negotiation (Accept → 406)
155
+
156
+ All routes check the `Accept` header. Requests that explicitly exclude `application/json` receive **406 Not Acceptable**. Requests with no `Accept` header, `*/*`, or `application/*` proceed normally.
157
+
158
+ ```
159
+ Accept: application/json ✓
160
+ Accept: */* ✓
161
+ Accept: application/json, text/html;q=0.5 ✓
162
+ (no header) ✓
163
+ Accept: text/html → 406
164
+ ```
165
+
166
+ ### Content Type (Content-Type → 415)
167
+
168
+ Requests with a body (`POST`, `PATCH`, `PUT`, `DELETE`) must use `Content-Type: application/json`. Any other value returns **415 Unsupported Media Type**.
169
+
170
+ ```
171
+ Content-Type: application/json ✓
172
+ Content-Type: application/json; utf-8 ✓
173
+ (no header, no body) ✓
174
+ Content-Type: text/plain → 415
175
+ Content-Type: application/x-www-form-urlencoded → 415
176
+ ```
177
+
178
+ ### Method Not Allowed (405)
179
+
180
+ If a resource has at least one method enabled, all other methods on that path return **405 Method Not Allowed** with an `Allow` response header listing the permitted methods.
181
+
182
+ ```
183
+ # Resource has allowReadMany + allowCreate only:
184
+ GET /api/v1/posts → 200
185
+ POST /api/v1/posts → 201
186
+ PUT /api/v1/posts → 405 Allow: GET, POST
187
+ DELETE /api/v1/posts → 405 Allow: GET, POST
188
+ ```
189
+
190
+ ### X-Correlation-ID
191
+
192
+ If the request includes an `X-Correlation-ID` header, the same value is echoed back in the response. Use this to correlate log entries across services.
193
+
194
+ ```
195
+ # Request
196
+ X-Correlation-ID: 550e8400-e29b-41d4-a716-446655440000
197
+
198
+ # Response includes:
199
+ X-Correlation-ID: 550e8400-e29b-41d4-a716-446655440000
200
+ ```
201
+
202
+ ### Idempotency-Key (POST create)
203
+
204
+ Pass an `Idempotency-Key` header on POST create requests. The key is forwarded to the repository via `CreateOptions.idempotencyKey` — repositories that support idempotent creation can use it to deduplicate concurrent or retried requests.
205
+
206
+ ```
207
+ POST /api/v1/posts
208
+ Idempotency-Key: my-client-generated-uuid
209
+ ```
210
+
211
+ The router itself does not enforce uniqueness; deduplication is the repository's responsibility.
212
+
213
+ ## Filter Depth Controls
214
+
215
+ The `?where` / query-builder `children` filter lets callers nest conditions. To prevent abuse, nesting is capped at depth **4** by default. Set `maxFilterDepth` on the resource to override:
216
+
217
+ ```ts
218
+ {
219
+ maxFilterDepth: 1 // only one level of children allowed
220
+ }
221
+ ```
222
+
223
+ Requests that exceed the limit receive **400 VALIDATION_ERROR**.
224
+
225
+ ## Error Response Shape
226
+
227
+ All errors follow the same envelope — an `errors` array where each item has a machine-readable `code` and a human-readable `message`:
228
+
229
+ ```json
230
+ {
231
+ "errors": [
232
+ {
233
+ "code": "VALIDATION_ERROR",
234
+ "message": "Field(s) not filterable: role."
235
+ }
236
+ ]
237
+ }
238
+ ```
239
+
240
+ | Status | Code | Error class | Typical cause |
241
+ | ------ | ------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------- |
242
+ | 400 | `BAD_REQUEST` | `BadRequestError` | Malformed `:id` — not an integer (1–2147483647) or UUID |
243
+ | 401 | `UNAUTHORIZED` | `AuthenticationError` | Missing or invalid auth token |
244
+ | 403 | `FORBIDDEN` | `AuthorizationError` | Authenticated but lacks required permission |
245
+ | 404 | `NOT_FOUND` | `NotFoundError` | Record not found |
246
+ | 405 | `METHOD_NOT_ALLOWED` | `MethodNotAllowedError` | HTTP method not enabled for this resource |
247
+ | 406 | `NOT_ACCEPTABLE` | `NotAcceptableError` | `Accept` header excludes `application/json` |
248
+ | 415 | `UNSUPPORTED_MEDIA_TYPE` | `UnsupportedMediaTypeError` | Request body is not `application/json` |
249
+ | 422 | `UNPROCESSABLE_ENTITY` | `UnprocessableEntityError` | Semantic validation failure — unknown field, invalid filter, sort/select restriction, depth exceeded |
250
+ | 500 | `INTERNAL_ERROR` | `ServerError` | Repository misconfigured or unhandled internal error |
251
+ | 501 | `NOT_IMPLEMENTED` | `NotImplementedError` | Repository does not support this operation |
252
+
253
+ When extra context is available (e.g. Prisma error details), a `details` field is included in the error item.