@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/README_CACHE.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Caching
|
|
2
|
+
|
|
3
|
+
Halifax can serve reads from a **pluggable read-through cache**. It sits at the repository
|
|
4
|
+
layer — _below_ the router's auth checks — so every request is still authenticated and
|
|
5
|
+
authorized; only the database round-trip is skipped. Writes automatically invalidate the
|
|
6
|
+
cache, and clients can force a refresh with a header.
|
|
7
|
+
|
|
8
|
+
- **Read-through**: `getOne`, `getMany`, and the query-builder route are cached.
|
|
9
|
+
- **Auto-invalidation**: any write (`create`/`update`/`upsert`/`delete`, single or bulk) to a
|
|
10
|
+
resource instantly invalidates that resource's cached reads.
|
|
11
|
+
- **Tenant-safe**: cache keys embed the tenant scope, so one tenant can never read another's
|
|
12
|
+
cached rows.
|
|
13
|
+
- **Pluggable store**: in-memory by default; ship a Redis (or any) store by implementing a
|
|
14
|
+
tiny interface.
|
|
15
|
+
- **Never-expire** option and a **cache-bust header** for on-demand refresh.
|
|
16
|
+
|
|
17
|
+
## Enabling caching
|
|
18
|
+
|
|
19
|
+
Turn it on per-resource, or set an API-wide default. Per-resource config wins.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createExpressCrudRouter, InMemoryCacheStore } from '@edium/halifax'
|
|
23
|
+
|
|
24
|
+
// Per-resource:
|
|
25
|
+
const countryResource: ResourceDefinition = {
|
|
26
|
+
name: 'Country',
|
|
27
|
+
routePrefix: 'countries',
|
|
28
|
+
fields: [{ name: 'id' }, { name: 'name', filterable: true }],
|
|
29
|
+
cache: { ttlSeconds: 300 }, // cache reads for 5 minutes
|
|
30
|
+
repository: countryRepo
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// …or an API-wide default applied to every resource:
|
|
34
|
+
createExpressCrudRouter([countryResource, postResource], {
|
|
35
|
+
cache: { ttlSeconds: 60 }
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Setting | Meaning |
|
|
40
|
+
| -------------------------- | ------------------------------------------------------------------------------ |
|
|
41
|
+
| `cache: { ttlSeconds: N }` | Cache reads for `N` seconds (per resource). |
|
|
42
|
+
| `cache: { ttlSeconds: 0 }` | **Never expire** — cache forever (until a write invalidates it). |
|
|
43
|
+
| `cache: false` | Disable caching for this resource even when an API-wide default is set. |
|
|
44
|
+
| _(omitted)_ | Inherit the API-wide `cache.ttlSeconds` default, if any; otherwise no caching. |
|
|
45
|
+
|
|
46
|
+
### Lookup tables (a common use case)
|
|
47
|
+
|
|
48
|
+
Reference/lookup tables — countries, categories, currencies — are read constantly and change
|
|
49
|
+
rarely, so they are the ideal cache target. Cache them **forever** and let writes invalidate:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const categoryResource: ResourceDefinition = {
|
|
53
|
+
name: 'Category',
|
|
54
|
+
routePrefix: 'categories',
|
|
55
|
+
fields: [{ name: 'id' }, { name: 'name', filterable: true, writable: true }],
|
|
56
|
+
cache: { ttlSeconds: 0 }, // never expire — refreshed only when a category is written
|
|
57
|
+
repository: categoryRepo
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## In-memory store (default)
|
|
62
|
+
|
|
63
|
+
When you enable caching without supplying a store, Halifax uses an in-process
|
|
64
|
+
`InMemoryCacheStore`. It's perfect for a single instance and for tests, with no dependencies:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createExpressCrudRouter, InMemoryCacheStore } from '@edium/halifax'
|
|
68
|
+
|
|
69
|
+
createExpressCrudRouter(resources, {
|
|
70
|
+
cache: { ttlSeconds: 60, store: new InMemoryCacheStore() } // store is optional here
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
> For multi-instance deployments use a shared store (below) so the cache and its invalidation
|
|
75
|
+
> are consistent across processes.
|
|
76
|
+
|
|
77
|
+
## Redis store
|
|
78
|
+
|
|
79
|
+
`RedisCacheStore` wraps any Redis client matching a tiny duck-typed interface
|
|
80
|
+
(`get` / `set(key, value, { EX })` / `del`) — the [`redis`](https://www.npmjs.com/package/redis)
|
|
81
|
+
v4 client matches it directly, so Halifax doesn't depend on a specific Redis package.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createClient } from 'redis'
|
|
85
|
+
import { createExpressCrudRouter, RedisCacheStore } from '@edium/halifax'
|
|
86
|
+
|
|
87
|
+
const redis = createClient({ url: process.env.REDIS_URL })
|
|
88
|
+
await redis.connect()
|
|
89
|
+
|
|
90
|
+
createExpressCrudRouter(resources, {
|
|
91
|
+
cache: {
|
|
92
|
+
ttlSeconds: 300,
|
|
93
|
+
store: new RedisCacheStore(redis, { keyPrefix: 'halifax:' }) // keyPrefix is optional
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Values are JSON-serialised; a TTL of `0` stores the key with no Redis expiry (never-expire).
|
|
99
|
+
The optional `keyPrefix` namespaces a shared Redis instance.
|
|
100
|
+
|
|
101
|
+
## Busting the cache (force refresh)
|
|
102
|
+
|
|
103
|
+
A client can bypass the cache for a single request with a header. By default Halifax honours
|
|
104
|
+
the standard **`Cache-Control: no-cache`** (or `no-store`) request header:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
curl https://api.example.com/api/countries -H 'Cache-Control: no-cache'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That request reads fresh from the database **and** repopulates the cache, so subsequent
|
|
111
|
+
requests are fast again. Writes still invalidate the cache on their own — busting is only for
|
|
112
|
+
on-demand reads.
|
|
113
|
+
|
|
114
|
+
Use a custom header instead via `cache.bustHeader`; with a custom header, the cache busts
|
|
115
|
+
whenever the header is present with any non-empty value:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
createExpressCrudRouter(resources, {
|
|
119
|
+
cache: { ttlSeconds: 60, bustHeader: 'X-Cache-Bust' }
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
curl https://api.example.com/api/countries -H 'X-Cache-Bust: 1'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## How invalidation works
|
|
128
|
+
|
|
129
|
+
Each resource (per tenant) has a version counter in the store. Cached read keys embed the
|
|
130
|
+
current version; a write bumps the version, so all previously-cached reads for that
|
|
131
|
+
resource/tenant are instantly unreachable and fall out by TTL. There is no key scanning, which
|
|
132
|
+
keeps invalidation cheap on Redis.
|
|
133
|
+
|
|
134
|
+
## Custom store
|
|
135
|
+
|
|
136
|
+
Implement the `CacheStore` interface to back the cache with anything (Memcached, a CDN KV,
|
|
137
|
+
etc.):
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import type { CacheStore } from '@edium/halifax'
|
|
141
|
+
|
|
142
|
+
class MyCacheStore implements CacheStore {
|
|
143
|
+
async get(key: string): Promise<unknown> {
|
|
144
|
+
/* … */
|
|
145
|
+
}
|
|
146
|
+
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
147
|
+
/* TTL of 0/undefined = no expiry */
|
|
148
|
+
}
|
|
149
|
+
async delete(key: string): Promise<void> {
|
|
150
|
+
/* … */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can also wrap a repository directly with `createCachingRepository(repo, { store, ttlSeconds, namespace })`
|
|
156
|
+
if you need caching outside the HTTP layer.
|
|
157
|
+
|
|
158
|
+
## Testing
|
|
159
|
+
|
|
160
|
+
- **Unit** (`pnpm test:unit`) — `InMemoryCacheStore`, the caching decorator, and the
|
|
161
|
+
`Cache-Control` header wiring are covered with no external services.
|
|
162
|
+
- **Integration** (`pnpm test:integration`) — a Redis-backed lookup-table scenario runs
|
|
163
|
+
against a real Redis container (set `REDIS_URL`), proving cache hits, never-expire, key
|
|
164
|
+
presence, header busting, and write invalidation. CI runs it on a `redis:7` service.
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# HTTP Adapters
|
|
2
|
+
|
|
3
|
+
Halifax's HTTP layer is swappable. Every transport implements the same interface:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
interface HttpServer {
|
|
7
|
+
registerRoute(method: string, path: string, handler: HttpHandler): void
|
|
8
|
+
start(port: number, host?: string): Promise<void> | void
|
|
9
|
+
}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Halifax ships **four** first-party adapters. They are interchangeable: every adapter
|
|
13
|
+
produces the same routes, status codes, error-body shape (`{ errors: [{ code, message }] }`),
|
|
14
|
+
`Allow`/`X-Correlation-ID` headers, content negotiation (406/415), and method-not-allowed
|
|
15
|
+
(405) behaviour. Switching frameworks does not require any change to your resource
|
|
16
|
+
definitions, auth strategy, or query logic.
|
|
17
|
+
|
|
18
|
+
| Framework | Import path | Factory | Mount style |
|
|
19
|
+
| ---------------- | --------------------------------- | --------------------------------- | ---------------------------------- |
|
|
20
|
+
| Express 4 / 5 | `@edium/halifax/express` | `createExpressCrudRouter` | `app.use('/api', router)` |
|
|
21
|
+
| Fastify | `@edium/halifax/fastify` | `createFastifyCrudPlugin` | `app.register(plugin, { prefix })` |
|
|
22
|
+
| HyperExpress | `@edium/halifax/hyper-express` | `createHyperExpressCrudRouter` | `app.use('/api', router)` |
|
|
23
|
+
| Ultimate Express | `@edium/halifax/ultimate-express` | `createUltimateExpressCrudRouter` | `app.use('/api', router)` |
|
|
24
|
+
|
|
25
|
+
Each adapter is published as its own subpath entry point, so you only ever load the
|
|
26
|
+
framework you actually use — the others are optional peer dependencies and are never
|
|
27
|
+
imported unless you import their subpath.
|
|
28
|
+
|
|
29
|
+
> **A note on routing parity.** Express, HyperExpress, and Ultimate Express all use the
|
|
30
|
+
> same `:id` named-parameter routing and `app.all` / `app.any` catch-all, so behaviour is
|
|
31
|
+
> identical. Fastify uses a radix-tree router (static segments beat parameters) rather than
|
|
32
|
+
> registration order; the adapter compensates so all documented CRUD, 404, 400, 405, 406,
|
|
33
|
+
> and 415 behaviour matches the others exactly. The only observable difference is for
|
|
34
|
+
> contrived paths that collide a static segment with `:id` (e.g. `GET /posts/query`),
|
|
35
|
+
> which is not a real-world CRUD scenario.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Express Adapter
|
|
40
|
+
|
|
41
|
+
Works seamlessly with both **Express 4 and Express 5** — the same code runs unchanged on
|
|
42
|
+
either major.
|
|
43
|
+
|
|
44
|
+
### Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Express 5
|
|
48
|
+
pnpm add @edium/halifax express
|
|
49
|
+
pnpm add -D @types/express
|
|
50
|
+
|
|
51
|
+
# …or Express 4 — equally supported
|
|
52
|
+
pnpm add @edium/halifax express@4
|
|
53
|
+
pnpm add -D @types/express@4
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `createExpressCrudRouter`
|
|
57
|
+
|
|
58
|
+
Returns a standard Express `Router` that you mount wherever you like.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import express from 'express'
|
|
62
|
+
import { createExpressCrudRouter } from '@edium/halifax/express'
|
|
63
|
+
import { authStrategy } from './auth.js'
|
|
64
|
+
import { postResource } from './resources/post.js'
|
|
65
|
+
|
|
66
|
+
export function createApp() {
|
|
67
|
+
const app = express()
|
|
68
|
+
app.use(express.json())
|
|
69
|
+
app.use('/api/v1', createExpressCrudRouter([postResource], { authStrategy }))
|
|
70
|
+
return app
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### Options
|
|
75
|
+
|
|
76
|
+
All four factories accept the same options object:
|
|
77
|
+
|
|
78
|
+
| Option | Type | Description |
|
|
79
|
+
| ------------------ | -------------------------------------- | ----------------------------------------------------------- |
|
|
80
|
+
| `authStrategy` | `AuthStrategy` | Auth strategy applied to every route (default: `AllowAll`) |
|
|
81
|
+
| `tenant` | `TenantOptions` | Multi-tenant isolation config (see README_MULTITENANCY.md) |
|
|
82
|
+
| `queryBuilderPath` | `string` | Path segment for the query-builder route (default: `query`) |
|
|
83
|
+
| `cache` | `{ store?, ttlSeconds?, bustHeader? }` | Read-through caching (see README_CACHE.md) |
|
|
84
|
+
|
|
85
|
+
### `ExpressHttpServer` (lower-level)
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { ExpressHttpServer } from '@edium/halifax/express'
|
|
89
|
+
import { registerCrudApi } from '@edium/halifax'
|
|
90
|
+
|
|
91
|
+
const server = new ExpressHttpServer(app)
|
|
92
|
+
registerCrudApi(server, [postResource], { authStrategy })
|
|
93
|
+
server.start(3000)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Fastify Adapter
|
|
99
|
+
|
|
100
|
+
Fastify's mounting mechanism is plugins-with-prefixes rather than mountable routers, so
|
|
101
|
+
the idiomatic equivalent of `createExpressCrudRouter` is a **plugin**.
|
|
102
|
+
|
|
103
|
+
### Install
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm add @edium/halifax fastify
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `createFastifyCrudPlugin`
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import Fastify from 'fastify'
|
|
113
|
+
import { createFastifyCrudPlugin } from '@edium/halifax/fastify'
|
|
114
|
+
import { authStrategy } from './auth.js'
|
|
115
|
+
import { postResource } from './resources/post.js'
|
|
116
|
+
|
|
117
|
+
const app = Fastify()
|
|
118
|
+
await app.register(createFastifyCrudPlugin([postResource], { authStrategy }), {
|
|
119
|
+
prefix: '/api/v1'
|
|
120
|
+
})
|
|
121
|
+
await app.listen({ port: 3000 })
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Fastify parses JSON request bodies automatically — no body-parser registration is needed.
|
|
125
|
+
The adapter installs a catch-all content-type parser inside its plugin scope so non-JSON
|
|
126
|
+
payloads produce Halifax's structured `415` rather than Fastify's default error page.
|
|
127
|
+
|
|
128
|
+
### `FastifyHttpServer` (lower-level)
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import Fastify from 'fastify'
|
|
132
|
+
import { FastifyHttpServer } from '@edium/halifax/fastify'
|
|
133
|
+
import { registerCrudApi } from '@edium/halifax'
|
|
134
|
+
|
|
135
|
+
const app = Fastify()
|
|
136
|
+
registerCrudApi(new FastifyHttpServer(app), [postResource], { authStrategy })
|
|
137
|
+
await app.listen({ port: 3000 })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## HyperExpress Adapter
|
|
143
|
+
|
|
144
|
+
[HyperExpress](https://github.com/kartikk221/hyper-express) is a high-performance,
|
|
145
|
+
Express-flavoured framework built on uWebSockets.js.
|
|
146
|
+
|
|
147
|
+
### Install
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pnpm add @edium/halifax hyper-express
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `createHyperExpressCrudRouter`
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import HyperExpress from 'hyper-express'
|
|
157
|
+
import { createHyperExpressCrudRouter } from '@edium/halifax/hyper-express'
|
|
158
|
+
import { authStrategy } from './auth.js'
|
|
159
|
+
import { postResource } from './resources/post.js'
|
|
160
|
+
|
|
161
|
+
const server = new HyperExpress.Server()
|
|
162
|
+
server.use('/api/v1', createHyperExpressCrudRouter([postResource], { authStrategy }))
|
|
163
|
+
await server.listen(3000)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
No body-parsing middleware is required: the adapter downloads and parses the JSON body for
|
|
167
|
+
you (and leaves non-JSON bodies unparsed so the router can return the structured `415`).
|
|
168
|
+
|
|
169
|
+
### `HyperExpressHttpServer` (lower-level)
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import HyperExpress from 'hyper-express'
|
|
173
|
+
import { HyperExpressHttpServer } from '@edium/halifax/hyper-express'
|
|
174
|
+
import { registerCrudApi } from '@edium/halifax'
|
|
175
|
+
|
|
176
|
+
const server = new HyperExpress.Server()
|
|
177
|
+
registerCrudApi(new HyperExpressHttpServer(server), [postResource], { authStrategy })
|
|
178
|
+
await server.listen(3000)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Ultimate Express Adapter
|
|
184
|
+
|
|
185
|
+
[Ultimate Express](https://github.com/dimdenGD/ultimate-express) is a drop-in
|
|
186
|
+
reimplementation of the Express API on top of uWebSockets.js. Usage is identical to the
|
|
187
|
+
Express adapter.
|
|
188
|
+
|
|
189
|
+
### Install
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
pnpm add @edium/halifax ultimate-express
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### `createUltimateExpressCrudRouter`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import express from 'ultimate-express'
|
|
199
|
+
import { createUltimateExpressCrudRouter } from '@edium/halifax/ultimate-express'
|
|
200
|
+
import { authStrategy } from './auth.js'
|
|
201
|
+
import { postResource } from './resources/post.js'
|
|
202
|
+
|
|
203
|
+
const app = express()
|
|
204
|
+
app.use(express.json())
|
|
205
|
+
app.use('/api/v1', createUltimateExpressCrudRouter([postResource], { authStrategy }))
|
|
206
|
+
app.listen(3000)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `UltimateExpressHttpServer` (lower-level)
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import express from 'ultimate-express'
|
|
213
|
+
import { UltimateExpressHttpServer } from '@edium/halifax/ultimate-express'
|
|
214
|
+
import { registerCrudApi } from '@edium/halifax'
|
|
215
|
+
|
|
216
|
+
const app = express()
|
|
217
|
+
registerCrudApi(new UltimateExpressHttpServer(app), [postResource], { authStrategy })
|
|
218
|
+
app.listen(3000)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
> **Note** — `ultimate-express` and `hyper-express` depend on `uWebSockets.js`, which is
|
|
222
|
+
> distributed only as a git repository (not on the npm registry). If your package manager
|
|
223
|
+
> blocks "exotic" git sub-dependencies (pnpm does by default), allow them — this repo's
|
|
224
|
+
> `.npmrc` sets `block-exotic-subdeps=false` for exactly this reason.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## With Passport
|
|
229
|
+
|
|
230
|
+
If you are using `PassportJwtStrategy` with Express or Ultimate Express, initialize
|
|
231
|
+
Passport before mounting the router:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import passport from 'passport'
|
|
235
|
+
|
|
236
|
+
app.use(passport.initialize())
|
|
237
|
+
app.use('/api/v1', createExpressCrudRouter([postResource], { authStrategy }))
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Implementing a Custom HTTP Adapter
|
|
243
|
+
|
|
244
|
+
To support any other framework, implement `HttpServer`. The contract is small: map the
|
|
245
|
+
framework's request/response onto Halifax's `HttpRequest` / `HttpResponse`, route `'*'` to
|
|
246
|
+
the framework's "any method" handler (for 405 fallbacks), and make `start()` listen.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import type { HttpServer, HttpRouteHandler, HttpMethod } from '@edium/halifax'
|
|
250
|
+
import { registerCrudApi } from '@edium/halifax'
|
|
251
|
+
|
|
252
|
+
class MyAdapter implements HttpServer {
|
|
253
|
+
constructor(private app: MyFramework) {}
|
|
254
|
+
|
|
255
|
+
registerRoute(method: HttpMethod, path: string, handler: HttpRouteHandler) {
|
|
256
|
+
const run = (req, res) =>
|
|
257
|
+
handler(
|
|
258
|
+
{
|
|
259
|
+
method: req.method,
|
|
260
|
+
params: req.params,
|
|
261
|
+
query: req.query,
|
|
262
|
+
body: req.body,
|
|
263
|
+
headers: req.headers,
|
|
264
|
+
raw: req
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
raw: res,
|
|
268
|
+
status(code) {
|
|
269
|
+
res.statusCode = code
|
|
270
|
+
return this
|
|
271
|
+
},
|
|
272
|
+
json(payload) {
|
|
273
|
+
res.json(payload)
|
|
274
|
+
},
|
|
275
|
+
send(payload) {
|
|
276
|
+
res.send(payload)
|
|
277
|
+
},
|
|
278
|
+
setHeader(name, value) {
|
|
279
|
+
res.setHeader(name, value)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
if (method === '*') this.app.any(path, run)
|
|
284
|
+
else this.app[method.toLowerCase()](path, run)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async start(port: number, host?: string) {
|
|
288
|
+
await this.app.listen(port, host)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
registerCrudApi(new MyAdapter(app), [postResource], { authStrategy })
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The four first-party adapters in `src/adapters/http/` are the best reference
|
|
296
|
+
implementations — each is a small, self-contained file.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Testing
|
|
301
|
+
|
|
302
|
+
Every adapter is verified against a single shared **Express-parity contract**
|
|
303
|
+
(`tests/helpers/adapterContract.ts`): the identical set of HTTP assertions is run against
|
|
304
|
+
all frameworks so "behaves exactly like Express" is enforced by construction.
|
|
305
|
+
|
|
306
|
+
- **Unit** (`pnpm test:unit`) — runs the contract against each adapter with an in-memory
|
|
307
|
+
repository; no database required.
|
|
308
|
+
- **Integration** (`pnpm test:integration`) — runs a Prisma + PostgreSQL HTTP contract
|
|
309
|
+
against each adapter. Requires `DATABASE_URL` in `.env.test`; skipped when unset.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Multi-Tenancy
|
|
2
|
+
|
|
3
|
+
Halifax can confine every generated endpoint to a single tenant — a company, organization,
|
|
4
|
+
workspace, or any row-ownership boundary — so that one customer can never read or write
|
|
5
|
+
another customer's data through the API. Tenant isolation is **optional**, **opt-in**, and
|
|
6
|
+
enforced at the data-access layer where it cannot be bypassed by crafted query strings or
|
|
7
|
+
request bodies.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
Two pieces combine to produce a per-request constraint:
|
|
12
|
+
|
|
13
|
+
1. **The tenant field** — a column on the model that stores the tenant key (e.g. `companyId`).
|
|
14
|
+
Declared per-resource via `ResourceDefinition.tenant`, or auto-detected (see below).
|
|
15
|
+
2. **The tenant value** — the key the _current caller_ is bound to (e.g. their `company.id`).
|
|
16
|
+
Produced per-request by `tenant.resolveId(ctx)`, which reads it from the authenticated
|
|
17
|
+
session/token — **never** from client-supplied input.
|
|
18
|
+
|
|
19
|
+
For each request, Halifax resolves the value and asks the repository for a **scoped clone**
|
|
20
|
+
(`repository.withScope({ field, value })`). That clone applies the constraint to every
|
|
21
|
+
operation:
|
|
22
|
+
|
|
23
|
+
| Operation | What the scope does |
|
|
24
|
+
| -------------------------------------- | --------------------------------------------------------------------------------- |
|
|
25
|
+
| `GET /resource` (list & query-builder) | AND-s `field = value` into the `WHERE` (and the count) |
|
|
26
|
+
| `GET /resource/:id` | Reads via `findFirst` with the tenant filter → foreign rows return `404` |
|
|
27
|
+
| `POST /resource` | **Stamps** `field = value` onto the body, overriding any client-supplied value |
|
|
28
|
+
| `PATCH /resource/:id` | Verifies ownership first; **strips** the tenant field so rows can't change owners |
|
|
29
|
+
| `PATCH /resource` (updateMany) | AND-s the tenant filter into the `WHERE`; strips the tenant field from the `SET` |
|
|
30
|
+
| `PUT /resource/:id` (upsert) | Refuses to overwrite a row owned by another tenant (`404`); stamps on create |
|
|
31
|
+
| `DELETE /resource/:id` | Deletes via a scoped `deleteMany` → foreign rows report "not found" |
|
|
32
|
+
| `DELETE /resource` (deleteMany) | AND-s the tenant filter into the `WHERE` |
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import express from 'express'
|
|
38
|
+
import { PrismaClient } from '@prisma/client'
|
|
39
|
+
import { PrismaPg } from '@prisma/adapter-pg'
|
|
40
|
+
import {
|
|
41
|
+
createPrismaResources,
|
|
42
|
+
createExpressCrudRouter,
|
|
43
|
+
PassportSessionStrategy
|
|
44
|
+
} from '@edium/halifax'
|
|
45
|
+
import { Prisma } from '@prisma/client'
|
|
46
|
+
|
|
47
|
+
const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL!) })
|
|
48
|
+
|
|
49
|
+
// Generate resources for every model; mark any model that has a `companyId` column
|
|
50
|
+
// as tenant-scoped on it.
|
|
51
|
+
const resources = createPrismaResources(prisma, Prisma.dmmf.datamodel.models, {
|
|
52
|
+
tenantField: 'companyId'
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const app = express()
|
|
56
|
+
app.use(express.json())
|
|
57
|
+
// ...session + passport middleware run before Halifax...
|
|
58
|
+
|
|
59
|
+
app.use(
|
|
60
|
+
'/api/v3',
|
|
61
|
+
createExpressCrudRouter(resources, {
|
|
62
|
+
authStrategy: new PassportSessionStrategy(),
|
|
63
|
+
tenant: {
|
|
64
|
+
// Derive the tenant key from the authenticated session — never from the request body.
|
|
65
|
+
resolveId: ({ req }) => (req.raw as any).session?.company?.id ?? null
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's the whole integration: one `tenantField` to auto-detect scoped models, and one
|
|
72
|
+
`resolveId` to supply the caller's tenant. Models without a `companyId` column are left
|
|
73
|
+
global (unscoped) automatically.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
### API-level: `tenant` on `CrudApiOptions`
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
interface TenantOptions {
|
|
81
|
+
/** Resolve the caller's tenant key from the authenticated request. */
|
|
82
|
+
resolveId: (ctx: { auth; req; resource }) => unknown | Promise<unknown>
|
|
83
|
+
/** Default column to auto-detect scoping on. Defaults to 'tenantId'. */
|
|
84
|
+
field?: string
|
|
85
|
+
/** Fail-closed when no tenant resolves. Defaults to true. */
|
|
86
|
+
strict?: boolean
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> `resolveId` runs **after** authentication, so `ctx.auth` is the resolved `AuthContext`
|
|
91
|
+
> and `ctx.req.raw` is the underlying framework request (e.g. the Express `Request`, with
|
|
92
|
+
> `session` / `user` populated). Pull the tenant from there.
|
|
93
|
+
|
|
94
|
+
### Resource-level: `tenant` on `ResourceDefinition`
|
|
95
|
+
|
|
96
|
+
| Value | Meaning |
|
|
97
|
+
| ---------------- | ------------------------------------------------------------------------------------------- |
|
|
98
|
+
| `{ field: 'x' }` | Scope this resource on column `x`. |
|
|
99
|
+
| `false` | Opt this resource **out** of an otherwise tenant-scoped API. |
|
|
100
|
+
| _omitted_ | Auto-scope on the API's default `field` **if** the model has that column; otherwise global. |
|
|
101
|
+
|
|
102
|
+
When generating with `createPrismaResources`, set it per model:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
createPrismaResources(prisma, models, {
|
|
106
|
+
tenantField: 'companyId',
|
|
107
|
+
models: {
|
|
108
|
+
// Override the column for one model:
|
|
109
|
+
Invoice: { tenant: { field: 'orgId' } },
|
|
110
|
+
// Expose a global reference table to all tenants:
|
|
111
|
+
Country: { tenant: false }
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Security model
|
|
117
|
+
|
|
118
|
+
Tenant scoping is designed to **fail closed**. The guarantees:
|
|
119
|
+
|
|
120
|
+
- **Enforced at the data layer.** The constraint is applied inside the repository on every
|
|
121
|
+
read, write, and bulk operation — not as middleware a route could skip.
|
|
122
|
+
- **Server-derived value only.** The tenant value comes from `resolveId` (your auth/session),
|
|
123
|
+
never from the URL, query string, or body. A client-supplied `?companyId=` or a `companyId`
|
|
124
|
+
in the body is **overridden** (writes) or **AND-ed against** (reads), so it cannot widen access.
|
|
125
|
+
- **No `OR` escape in the query builder.** Caller-supplied filters are nested as a
|
|
126
|
+
parenthesised group AND-ed beneath the tenant predicate — `companyId = $1 AND ( ...your filters... )`
|
|
127
|
+
— so an `OR` in the advanced query can never break out of the tenant boundary.
|
|
128
|
+
- **Rows can't change owners.** The tenant field is stripped from update/upsert payloads.
|
|
129
|
+
- **Cross-tenant rows are invisible.** Reads/updates/deletes of another tenant's row behave
|
|
130
|
+
as "not found" (`404`/no-op), never leaking existence.
|
|
131
|
+
- **Misconfiguration is fatal, not silent.** If a resource is tenant-scoped but its repository
|
|
132
|
+
doesn't implement `withScope`, `createExpressCrudRouter` throws at startup rather than
|
|
133
|
+
serving the resource unscoped.
|
|
134
|
+
- **Strict by default.** If `resolveId` returns `null`/`undefined` for a scoped resource, the
|
|
135
|
+
request is rejected with `403`. Set `strict: false` only to deliberately allow cross-tenant
|
|
136
|
+
("god mode") access for callers with no tenant.
|
|
137
|
+
|
|
138
|
+
### Defense in depth
|
|
139
|
+
|
|
140
|
+
API-level scoping does not replace database-level controls. For a hard guarantee even against
|
|
141
|
+
bugs elsewhere in your stack, pair Halifax scoping with PostgreSQL **Row-Level Security**
|
|
142
|
+
policies keyed on the same tenant column.
|
|
143
|
+
|
|
144
|
+
### What is _not_ scoped
|
|
145
|
+
|
|
146
|
+
Relations eager-loaded via `?include=` are returned as Prisma resolves them; if a related
|
|
147
|
+
model also needs isolation, expose it as its own scoped resource rather than relying on the
|
|
148
|
+
include.
|
|
149
|
+
|
|
150
|
+
## Custom repositories
|
|
151
|
+
|
|
152
|
+
Any repository can support scoping by implementing the optional `withScope` method, which must
|
|
153
|
+
return a **new** instance bound to the scope (never mutate the original):
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
withScope(scope: TenantScope): Repository<...> {
|
|
157
|
+
return new MyRepository({ ...this.options, scope })
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`PrismaAdapter` implements this for you. A repository that omits `withScope` simply cannot be
|
|
162
|
+
used for a tenant-scoped resource — the router refuses to register it, by design.
|