@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/README_AUTH.md +172 -0
- package/README_AUTOCRUD.md +253 -0
- package/README_CACHE.md +164 -0
- package/README_HTTP_ADAPTERS.md +309 -0
- package/README_MULTITENANCY.md +162 -0
- package/README_QUERYBUILDER.md +219 -0
- package/README_REPO_ADAPTERS.md +266 -0
- package/dist/adapters/http/ExpressAdapter.d.ts +40 -0
- package/dist/adapters/http/ExpressAdapter.d.ts.map +1 -0
- package/dist/adapters/http/ExpressAdapter.js +109 -0
- package/dist/adapters/http/ExpressAdapter.js.map +1 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +143 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +1 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.js +277 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.js.map +1 -0
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts +15 -0
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +1 -0
- package/dist/adapters/orm/prisma/createPrismaResources.js +51 -0
- package/dist/adapters/orm/prisma/createPrismaResources.js.map +1 -0
- package/dist/adapters/orm/prisma/helpers.d.ts +27 -0
- package/dist/adapters/orm/prisma/helpers.d.ts.map +1 -0
- package/dist/adapters/orm/prisma/helpers.js +45 -0
- package/dist/adapters/orm/prisma/helpers.js.map +1 -0
- package/dist/adapters/orm/prisma/index.d.ts +4 -0
- package/dist/adapters/orm/prisma/index.d.ts.map +1 -0
- package/dist/adapters/orm/prisma/index.js +3 -0
- package/dist/adapters/orm/prisma/index.js.map +1 -0
- package/dist/adapters/orm/prisma/types.d.ts +49 -0
- package/dist/adapters/orm/prisma/types.d.ts.map +1 -0
- package/dist/adapters/orm/prisma/types.js +2 -0
- package/dist/adapters/orm/prisma/types.js.map +1 -0
- package/dist/auth/AuthStrategy.d.ts +198 -0
- package/dist/auth/AuthStrategy.d.ts.map +1 -0
- package/dist/auth/AuthStrategy.js +227 -0
- package/dist/auth/AuthStrategy.js.map +1 -0
- package/dist/classes/QueryBuilder.d.ts +33 -0
- package/dist/classes/QueryBuilder.d.ts.map +1 -0
- package/dist/classes/QueryBuilder.js +262 -0
- package/dist/classes/QueryBuilder.js.map +1 -0
- package/dist/core/crudRouter.d.ts +36 -0
- package/dist/core/crudRouter.d.ts.map +1 -0
- package/dist/core/crudRouter.js +391 -0
- package/dist/core/crudRouter.js.map +1 -0
- package/dist/core/queryString.d.ts +13 -0
- package/dist/core/queryString.d.ts.map +1 -0
- package/dist/core/queryString.js +89 -0
- package/dist/core/queryString.js.map +1 -0
- package/dist/core/types.d.ts +293 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +13 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validation.d.ts +75 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +206 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/enums/SqlComparison.d.ts +18 -0
- package/dist/enums/SqlComparison.d.ts.map +1 -0
- package/dist/enums/SqlComparison.js +19 -0
- package/dist/enums/SqlComparison.js.map +1 -0
- package/dist/enums/SqlOperator.d.ts +6 -0
- package/dist/enums/SqlOperator.d.ts.map +1 -0
- package/dist/enums/SqlOperator.js +7 -0
- package/dist/enums/SqlOperator.js.map +1 -0
- package/dist/enums/SqlOrder.d.ts +6 -0
- package/dist/enums/SqlOrder.d.ts.map +1 -0
- package/dist/enums/SqlOrder.js +7 -0
- package/dist/enums/SqlOrder.js.map +1 -0
- package/dist/errors/AuthenticationError.d.ts +10 -0
- package/dist/errors/AuthenticationError.d.ts.map +1 -0
- package/dist/errors/AuthenticationError.js +13 -0
- package/dist/errors/AuthenticationError.js.map +1 -0
- package/dist/errors/AuthorizationError.d.ts +10 -0
- package/dist/errors/AuthorizationError.d.ts.map +1 -0
- package/dist/errors/AuthorizationError.js +13 -0
- package/dist/errors/AuthorizationError.js.map +1 -0
- package/dist/errors/BadRequestError.d.ts +10 -0
- package/dist/errors/BadRequestError.d.ts.map +1 -0
- package/dist/errors/BadRequestError.js +13 -0
- package/dist/errors/BadRequestError.js.map +1 -0
- package/dist/errors/HttpError.d.ts +12 -0
- package/dist/errors/HttpError.d.ts.map +1 -0
- package/dist/errors/HttpError.js +17 -0
- package/dist/errors/HttpError.js.map +1 -0
- package/dist/errors/MethodNotAllowedError.d.ts +10 -0
- package/dist/errors/MethodNotAllowedError.d.ts.map +1 -0
- package/dist/errors/MethodNotAllowedError.js +13 -0
- package/dist/errors/MethodNotAllowedError.js.map +1 -0
- package/dist/errors/NotAcceptableError.d.ts +10 -0
- package/dist/errors/NotAcceptableError.d.ts.map +1 -0
- package/dist/errors/NotAcceptableError.js +13 -0
- package/dist/errors/NotAcceptableError.js.map +1 -0
- package/dist/errors/NotFoundError.d.ts +10 -0
- package/dist/errors/NotFoundError.d.ts.map +1 -0
- package/dist/errors/NotFoundError.js +13 -0
- package/dist/errors/NotFoundError.js.map +1 -0
- package/dist/errors/NotImplementedError.d.ts +10 -0
- package/dist/errors/NotImplementedError.d.ts.map +1 -0
- package/dist/errors/NotImplementedError.js +13 -0
- package/dist/errors/NotImplementedError.js.map +1 -0
- package/dist/errors/ServerError.d.ts +10 -0
- package/dist/errors/ServerError.d.ts.map +1 -0
- package/dist/errors/ServerError.js +13 -0
- package/dist/errors/ServerError.js.map +1 -0
- package/dist/errors/UnprocessableEntityError.d.ts +10 -0
- package/dist/errors/UnprocessableEntityError.d.ts.map +1 -0
- package/dist/errors/UnprocessableEntityError.js +13 -0
- package/dist/errors/UnprocessableEntityError.js.map +1 -0
- package/dist/errors/UnsupportedMediaTypeError.d.ts +10 -0
- package/dist/errors/UnsupportedMediaTypeError.d.ts.map +1 -0
- package/dist/errors/UnsupportedMediaTypeError.js +13 -0
- package/dist/errors/UnsupportedMediaTypeError.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/IParamQuery.d.ts +8 -0
- package/dist/interfaces/IParamQuery.d.ts.map +1 -0
- package/dist/interfaces/IParamQuery.js +2 -0
- package/dist/interfaces/IParamQuery.js.map +1 -0
- package/dist/interfaces/IQueryFilter.d.ts +18 -0
- package/dist/interfaces/IQueryFilter.d.ts.map +1 -0
- package/dist/interfaces/IQueryFilter.js +2 -0
- package/dist/interfaces/IQueryFilter.js.map +1 -0
- package/dist/interfaces/IQueryOptions.d.ts +20 -0
- package/dist/interfaces/IQueryOptions.d.ts.map +1 -0
- package/dist/interfaces/IQueryOptions.js +2 -0
- package/dist/interfaces/IQueryOptions.js.map +1 -0
- package/dist/interfaces/ISort.d.ts +9 -0
- package/dist/interfaces/ISort.d.ts.map +1 -0
- package/dist/interfaces/ISort.js +2 -0
- package/dist/interfaces/ISort.js.map +1 -0
- 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
|
+
[](https://github.com/splayfee/halifax/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@edium/halifax)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](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.
|