@edium/halifax 2.3.0 → 2.4.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 CHANGED
@@ -3,6 +3,75 @@
3
3
  All notable changes to this project are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [2.4.0]
7
+
8
+ ### Breaking
9
+
10
+ - **`RouteHandlerContext.resolveRepo` and `GraphQLResourceContext.resolveRepo` now require a
11
+ third `action: CrudAction` argument** — the resolver function signature has changed from
12
+ `(req: HttpRequest, auth: AuthContext) => Promise<Repository>` to
13
+ `(req: HttpRequest, auth: AuthContext, action: CrudAction) => Promise<Repository>`. The
14
+ `action` parameter carries the name of the CRUD operation being performed (e.g. `'readMany'`,
15
+ `'create'`) so that action-aware logic (such as the new admin bypass) can branch correctly
16
+ inside the closure. All built-in handlers and resolvers pass the correct action. Custom
17
+ integrations that construct a `RouteHandlerContext` or `GraphQLResourceContext` directly must
18
+ update their `resolveRepo` implementation to accept and handle the third argument.
19
+
20
+ - **`GraphQLOptions.enabled` must now be explicitly set to `true`** — previously, supplying a
21
+ `graphql: { ... }` object without `enabled: false` activated the endpoint. The new default is
22
+ off: the endpoint is registered only when `graphql: { enabled: true, ... }` is passed. This
23
+ makes GraphQL a deliberate opt-in and avoids importing the optional `graphql` peer dependency
24
+ unless it is actually used. Existing configurations that relied on the implicit default must
25
+ add `enabled: true`.
26
+
27
+ ### Added
28
+
29
+ - **GraphQL endpoint** — Halifax can now expose a full GraphQL API alongside REST. The schema
30
+ is built at startup from the same resource definitions that drive REST: no separate schema
31
+ file, no annotations. For each resource Halifax generates `get<T>`, `list<T>`, and
32
+ `query<T>` query fields plus `create<T>`, `createMany<T>`, `update<T>`, `updateMany<T>`,
33
+ `upsert<T>`, `delete<T>`, and `deleteMany<T>` mutation fields — only those allowed by
34
+ `resource.permissions` are included. The same auth strategy, tenant scoping, field-level
35
+ security (`readRoles`/`writeRoles`), lifecycle hooks, and caching that apply to REST apply
36
+ identically to GraphQL resolvers. Requires the optional `graphql` (≥ 16.0.0) peer dependency.
37
+
38
+ ```ts
39
+ createExpressCrudRouter(resources, {
40
+ graphql: { enabled: true, path: '/graphql', graphiql: true }
41
+ })
42
+ ```
43
+
44
+ - `GET /graphql` serves the GraphiQL browser IDE (disable with `graphiql: false`).
45
+ - `requireAuth: true` gates even introspection behind `authStrategy.authenticate`.
46
+ - Set `graphql: false` on any `ResourceDefinition` to exclude it from the schema while
47
+ keeping it on REST.
48
+ - See [README_GRAPHQL.md](./README_GRAPHQL.md) for full documentation and examples.
49
+
50
+ - **Admin tenant bypass** — callers whose `auth.roles` or `auth.permissions` matches any entry
51
+ in `TenantOptions.bypassRoles` receive an unscoped repository for read operations
52
+ (`readOne`, `readMany`, `readManyWithQueryBuilder`), allowing them to query across all
53
+ tenants. Write operations (`create`, `updateOne`, `updateMany`, `upsertOne`, `deleteOne`,
54
+ `deleteMany`) are never bypassed — the tenant value on writes continues to come from
55
+ `resolveId`, keeping write provenance tied to authentication rather than client input.
56
+ Admins who want to read a single tenant's data use the standard filter syntax
57
+ (`?companyId=42` on REST, `filter: { companyId: 42 }` in GraphQL) rather than any special
58
+ override mechanism.
59
+
60
+ ```ts
61
+ tenant: {
62
+ resolveId: ({ auth }) => auth.claims?.companyId,
63
+ bypassRoles: ['super_admin', 'support:read-all'], // role OR permission slug
64
+ }
65
+ ```
66
+
67
+ Per-resource override via `ResourceDefinition.bypassTenantRoles` takes precedence over the
68
+ API-wide list. Set it to `[]` on a resource to prevent bypass entirely for that model even
69
+ when a global `bypassRoles` is configured.
70
+
71
+ - **`README_GRAPHQL.md`** — new reference document covering GraphQL opt-in setup, the
72
+ auto-generated schema (queries, mutations, types), per-resource opt-out, the GraphiQL IDE,
73
+ authentication, tenant bypass behaviour, and annotated query/mutation examples.
74
+
6
75
  ## [2.3.0]
7
76
 
8
77
  ### Security
package/README.md CHANGED
@@ -17,9 +17,10 @@ The package is split into small, replaceable layers — nothing is imported into
17
17
  - 🗄️ **Two ORM adapters, six databases** — `PrismaAdapter` covers PostgreSQL, MySQL, MariaDB, SQL Server, CockroachDB, and SQLite; `DrizzleAdapter` (sub-path `@edium/halifax/drizzle`) covers PostgreSQL, MySQL, SQLite, and LibSQL. Both compile to ORM calls (never raw SQL) so the same query behaves identically across engines.
18
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
19
  - 📄 **OpenAPI 3.1 generation** — zero-annotation spec generated from your resource definitions at startup. Prisma and Drizzle types are introspected automatically; custom repos annotate individual fields. Swagger UI at `/docs`, raw spec at `/openapi.json`. Disable with `enabled: false` for zero production overhead.
20
- - 🏢 **Multi-tenancy built in** — per-resource tenant scoping with fail-closed guarantees; one tenant can never read or write another's rows.
20
+ - 🏢 **Multi-tenancy built in** — per-resource tenant scoping with fail-closed guarantees; one tenant can never read or write another's rows. Privileged roles (admin, super-admin) can optionally bypass scoping to read across all tenants, with per-resource granularity. See [README_MULTITENANCY.md](./README_MULTITENANCY.md).
21
21
  - ⚡ **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.
22
- - 🔐 **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action permissions; `filterable`/`sortable`/`selectable`/`writable` field flags; and per-field `readRoles`/`writeRoles` for role-based column visibility.
22
+ - 🔐 **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action permissions (matched against roles OR permission slugs); `filterable`/`sortable`/`selectable`/`writable` field flags; and per-field `readRoles`/`writeRoles` for role-based column visibility.
23
+ - 🔷 **GraphQL endpoint** — opt-in GraphQL API auto-generated from the same resource definitions as REST. Every query, mutation, filter, sort, and field-level permission works identically. Includes a GraphiQL IDE, per-resource opt-out, and full tenant bypass support. See [README_GRAPHQL.md](./README_GRAPHQL.md).
23
24
  - 🪝 **Lifecycle hooks** — inject custom logic before or after any CRUD operation per resource (`beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, …). Stamp audit fields, emit events, enforce ownership, or transform results without writing a custom repository. See [README_HOOKS.md](./README_HOOKS.md).
24
25
  - 📦 **Companion browser client** — [`@edium/halifax-client`](https://www.npmjs.com/package/@edium/halifax-client) is a typed, zero-dependency client with a fluent query builder and built-in TanStack Query helpers (queries + mutation auto-invalidation). Bring your own HTTP library (fetch, axios, ky, ofetch, superagent).
25
26
  - 🧪 **Type-safe & battle-tested** — strict TypeScript, ESM, ships full `.d.ts`; hundreds of unit tests plus the full integration suite run against six real databases + Redis in CI.
@@ -30,9 +31,10 @@ The package is split into small, replaceable layers — nothing is imported into
30
31
  | -------------- | -------------------------------------------------------------------------------------------------------------------- |
31
32
  | HTTP server | Express 4/5, Fastify, HyperExpress, Ultimate Express |
32
33
  | ORM / database | Prisma 6 or 7 (Postgres, MySQL, MariaDB, SQL Server, CockroachDB, SQLite); Drizzle (Postgres, MySQL, SQLite, LibSQL) |
33
- | Auth | API key, JWT/Bearer, Passport + JWT; per-field `readRoles`/`writeRoles` |
34
+ | Auth | API key, JWT/Bearer, Passport + JWT; per-field `readRoles`/`writeRoles`; role or permission slug |
34
35
  | Caching | Pluggable read-through cache (in-memory default; bring Redis, etc.) |
35
36
  | API docs | OpenAPI 3.1 spec + Swagger UI (optional, zero overhead when disabled) |
37
+ | GraphQL | Auto-generated schema from resource definitions; opt-in; GraphiQL IDE; requires `graphql` ≥ 16 peer dep |
36
38
 
37
39
  Every HTTP adapter is interchangeable and behaves identically — same routes, status codes,
38
40
  error-body shape, and content negotiation — so you can switch frameworks without touching
@@ -168,6 +170,7 @@ app.listen(3000)
168
170
  | [README_QUERYBUILDER.md](./README_QUERYBUILDER.md) | Query-builder payload, comparisons, nested filters, portable execution |
169
171
  | [README_CACHE.md](./README_CACHE.md) | Read-through caching: in-memory & Redis stores, never-expire, cache-bust header |
170
172
  | [README_HOOKS.md](./README_HOOKS.md) | Lifecycle hooks: `beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, and every other hook |
173
+ | [README_GRAPHQL.md](./README_GRAPHQL.md) | GraphQL endpoint: opt-in setup, auto-generated schema, GraphiQL IDE, auth, tenant bypass for admins |
171
174
  | [README_OPENAPI.md](./README_OPENAPI.md) | OpenAPI 3.1 spec generation, Swagger UI, type introspection, security schemes, programmatic use |
172
175
  | [README_TYPES.md](./README_TYPES.md) | All exported type aliases, enums (`SqlComparison`, `SqlOperator`, `SqlOrder`), and constants |
173
176
  | [README_INTERFACES.md](./README_INTERFACES.md) | All exported interfaces — resource, auth, HTTP, repository, cache, Prisma, Drizzle, query AST |
@@ -0,0 +1,352 @@
1
+ # Halifax GraphQL
2
+
3
+ Halifax can automatically expose a GraphQL API alongside your REST endpoints. The schema is generated at startup from the same resource definitions that drive REST — no extra annotation or separate schema file required.
4
+
5
+ GraphQL is **opt-in**: nothing happens unless you explicitly set `enabled: true`.
6
+
7
+ ## Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Enabling GraphQL](#enabling-graphql)
11
+ - [What gets generated](#what-gets-generated)
12
+ - [Per-resource opt-out](#per-resource-opt-out)
13
+ - [GraphiQL IDE](#graphiql-ide)
14
+ - [Authentication](#authentication)
15
+ - [Tenant scoping and admin bypass](#tenant-scoping-and-admin-bypass)
16
+ - [Query examples](#query-examples)
17
+ - [Mutation examples](#mutation-examples)
18
+ - [Types reference](#types-reference)
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ The `graphql` package is a peer dependency — install it alongside Halifax:
25
+
26
+ ```bash
27
+ npm install graphql
28
+ # or
29
+ pnpm add graphql
30
+ ```
31
+
32
+ Halifax is tested against `graphql` v16.
33
+
34
+ ---
35
+
36
+ ## Enabling GraphQL
37
+
38
+ Pass `graphql: { enabled: true }` to `registerCrudApi` (or `createExpressCrudRouter`):
39
+
40
+ ```ts
41
+ import { createExpressCrudRouter } from '@edium/halifax'
42
+
43
+ const app = createExpressCrudRouter(resources, {
44
+ graphql: {
45
+ enabled: true, // required — GraphQL is off by default
46
+ path: '/graphql', // default: '/graphql'
47
+ graphiql: true, // default: true — serve the GraphiQL IDE at GET /graphql
48
+ requireAuth: false, // default: false — set true to require auth before any operation
49
+ title: 'My API' // browser tab title in GraphiQL
50
+ }
51
+ })
52
+ ```
53
+
54
+ Two routes are registered:
55
+
56
+ | Route | Purpose |
57
+ |---|---|
58
+ | `POST /graphql` | Execute GraphQL queries and mutations |
59
+ | `GET /graphql` | GraphiQL browser IDE (disable with `graphiql: false`) |
60
+
61
+ ---
62
+
63
+ ## What gets generated
64
+
65
+ For every resource Halifax generates:
66
+
67
+ **Query fields**
68
+
69
+ | Field | REST equivalent | Description |
70
+ |---|---|---|
71
+ | `get<Resource>(id: ID!)` | `GET /<resource>/:id` | Fetch one record by ID |
72
+ | `list<Resource>(filter, limit, offset, orderBy, include)` | `GET /<resource>` | Paginated list with equality filters |
73
+ | `query<Resource>(where, fields, distinct, limit, offset, orderBy, include)` | `POST /<resource>/query` | Advanced query with full filter expressions |
74
+
75
+ **Mutation fields**
76
+
77
+ | Field | REST equivalent | Description |
78
+ |---|---|---|
79
+ | `create<Resource>(input)` | `POST /<resource>` | Create one record |
80
+ | `createMany<Resource>(input)` | `POST /<resource>` (array body) | Create multiple records |
81
+ | `update<Resource>(id, input)` | `PATCH /<resource>/:id` | Partial update |
82
+ | `updateMany<Resource>(where, update)` | `PATCH /<resource>` | Bulk update |
83
+ | `upsert<Resource>(id, input)` | `PUT /<resource>/:id` | Create or replace |
84
+ | `delete<Resource>(id)` | `DELETE /<resource>/:id` | Delete one record |
85
+ | `deleteMany<Resource>(where)` | `DELETE /<resource>` | Bulk delete |
86
+
87
+ Only operations allowed by `resource.permissions` are included. If `allowDeleteMany: false`, no `deleteMany<Resource>` mutation is generated.
88
+
89
+ ---
90
+
91
+ ## Per-resource opt-out
92
+
93
+ Set `graphql: false` on any resource to exclude it from the GraphQL schema entirely:
94
+
95
+ ```ts
96
+ const resources = [
97
+ {
98
+ routePrefix: 'users',
99
+ repository: userRepo,
100
+ // visible in REST and GraphQL (default)
101
+ },
102
+ {
103
+ routePrefix: 'audit-logs',
104
+ repository: auditRepo,
105
+ graphql: false, // REST only — excluded from GraphQL
106
+ }
107
+ ]
108
+ ```
109
+
110
+ ---
111
+
112
+ ## GraphiQL IDE
113
+
114
+ When `graphiql: true` (the default), visiting `GET /graphql` in a browser opens the GraphiQL IDE. You can explore the auto-generated schema using introspection, run queries interactively, and inspect the full type system.
115
+
116
+ To disable the IDE (e.g. in production):
117
+
118
+ ```ts
119
+ graphql: { enabled: true, graphiql: false }
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Authentication
125
+
126
+ GraphQL uses the same `authStrategy` as REST. Every resolver calls `authenticate` before executing, so your JWT/API-key validation fires automatically.
127
+
128
+ To require authentication before even responding to introspection queries, set `requireAuth: true`:
129
+
130
+ ```ts
131
+ graphql: { enabled: true, requireAuth: true }
132
+ ```
133
+
134
+ Fine-grained permissions (`resource.requiredPermissions`) apply per resolver, identical to REST. Per-field `readRoles`/`writeRoles` restrictions are also enforced — restricted fields are stripped from resolver results.
135
+
136
+ ---
137
+
138
+ ## Tenant scoping and admin bypass
139
+
140
+ The GraphQL endpoint respects the same tenant isolation that REST does. Each resolver resolves the tenant from the caller's auth context (JWT claims, session, etc.) and scopes the repository automatically — regular users can only see their own tenant's data. The tenant value is **always derived from auth**, never from client-supplied input.
141
+
142
+ ### Admin bypass
143
+
144
+ Admins who need to query across all tenants can be granted bypass access by listing their roles or permission slugs in `TenantOptions.bypassRoles` (API-wide default) or `ResourceDefinition.bypassTenantRoles` (per-resource override).
145
+
146
+ ```ts
147
+ createExpressCrudRouter(resources, {
148
+ tenant: {
149
+ resolveId: (ctx) => ctx.auth.claims?.companyId,
150
+ bypassRoles: ['super_admin', 'support:read-all'], // role OR permission slug
151
+ }
152
+ })
153
+ ```
154
+
155
+ **How it works:**
156
+
157
+ - **Bypass role → all records.** An admin whose `auth.roles` or `auth.permissions` matches any entry in `bypassRoles` gets an unscoped read — all rows across all tenants.
158
+ - **No bypass role → normal scoping.** Regular users are always scoped to their tenant. The bypass path is unreachable for them.
159
+ - **Writes are never bypassed.** Write operations always resolve the tenant through `resolveId`. An admin whose token carries a `companyId` writes to that company; one without a tenant receives 403 (unless `strict: false`). The tenant field on writes always comes from auth — never from the client.
160
+
161
+ **Narrowing to a single tenant (admin reads):**
162
+
163
+ When an admin wants to see just one tenant's data, they use the same filter mechanism as any other caller — the tenant field is just another filterable column:
164
+
165
+ ```graphql
166
+ # All tenants (bypass active, no filter)
167
+ { listOrders { count results { id companyId total } } }
168
+
169
+ # One specific tenant — admin narrows with a filter
170
+ { listOrders(filter: { companyId: 42 }) { count results { id companyId total } } }
171
+
172
+ # Or with the full query builder
173
+ {
174
+ queryOrders(where: [
175
+ { field: "companyId", comparison: "=", value1: 42 }
176
+ ]) { count results { id companyId total } }
177
+ }
178
+ ```
179
+
180
+ On REST it works the same way:
181
+ ```
182
+ # All tenants
183
+ GET /orders
184
+
185
+ # One tenant
186
+ GET /orders?companyId=42
187
+ ```
188
+
189
+ No special header or query parameter needed — the normal filter API handles it.
190
+
191
+ #### Per-resource bypass override
192
+
193
+ Override the global `bypassRoles` for a single resource — or disable bypass entirely for sensitive models:
194
+
195
+ ```ts
196
+ const resources = [
197
+ {
198
+ routePrefix: 'orders',
199
+ repository: orderRepo,
200
+ // inherits bypassRoles from TenantOptions
201
+ },
202
+ {
203
+ routePrefix: 'payment-methods',
204
+ repository: paymentRepo,
205
+ bypassTenantRoles: [], // no one bypasses tenant on this resource
206
+ },
207
+ {
208
+ routePrefix: 'users',
209
+ repository: userRepo,
210
+ bypassTenantRoles: ['super_admin'], // only super_admin, not support:read-all
211
+ }
212
+ ]
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Query examples
218
+
219
+ ### Fetch one record
220
+
221
+ ```graphql
222
+ query {
223
+ getUser(id: "42") {
224
+ id
225
+ name
226
+ email
227
+ }
228
+ }
229
+ ```
230
+
231
+ ### Paginated list with filters
232
+
233
+ ```graphql
234
+ query {
235
+ listUsers(
236
+ filter: { status: "active" }
237
+ limit: 20
238
+ offset: 0
239
+ orderBy: [{ field: "createdAt", direction: desc }]
240
+ ) {
241
+ count
242
+ results {
243
+ id
244
+ name
245
+ email
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ ### Advanced query (full filter expressions)
252
+
253
+ ```graphql
254
+ query {
255
+ queryUsers(
256
+ where: [
257
+ { field: "status", comparison: "IN", value1: ["active", "trial"] },
258
+ { operator: "AND" },
259
+ { field: "createdAt", comparison: ">=", value1: "2025-01-01" }
260
+ ]
261
+ orderBy: [{ field: "name", direction: asc }]
262
+ limit: 50
263
+ ) {
264
+ count
265
+ results {
266
+ id
267
+ name
268
+ email
269
+ }
270
+ }
271
+ }
272
+ ```
273
+
274
+ ### With variables
275
+
276
+ ```graphql
277
+ query ListActiveUsers($status: String!, $limit: Int) {
278
+ listUsers(filter: { status: $status }, limit: $limit) {
279
+ count
280
+ results { id name }
281
+ }
282
+ }
283
+ ```
284
+
285
+ Variables payload:
286
+ ```json
287
+ { "status": "active", "limit": 10 }
288
+ ```
289
+
290
+ ---
291
+
292
+ ## Mutation examples
293
+
294
+ ### Create
295
+
296
+ ```graphql
297
+ mutation {
298
+ createUser(input: { name: "Alice", email: "alice@example.com" }) {
299
+ id
300
+ name
301
+ email
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### Partial update
307
+
308
+ ```graphql
309
+ mutation {
310
+ updateUser(id: "42", input: { name: "Alice Smith" }) {
311
+ id
312
+ name
313
+ }
314
+ }
315
+ ```
316
+
317
+ ### Bulk update
318
+
319
+ ```graphql
320
+ mutation {
321
+ updateManyUser(
322
+ where: [{ field: "status", comparison: "=", value1: "trial" }]
323
+ update: { status: "active" }
324
+ ) {
325
+ updated
326
+ }
327
+ }
328
+ ```
329
+
330
+ ### Delete
331
+
332
+ ```graphql
333
+ mutation {
334
+ deleteUser(id: "42")
335
+ }
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Types reference
341
+
342
+ | Type | Description |
343
+ |---|---|
344
+ | `GraphQLOptions` | Configuration object passed to `registerCrudApi` / `createExpressCrudRouter` |
345
+ | `GraphQLResourceContext` | Internal per-resource context (available if building custom integrations) |
346
+ | `GraphQLResolverContext` | The `contextValue` available in every resolver: `{ req: HttpRequest }` |
347
+
348
+ All types are re-exported from `@edium/halifax`:
349
+
350
+ ```ts
351
+ import type { GraphQLOptions, GraphQLResourceContext, GraphQLResolverContext } from '@edium/halifax'
352
+ ```
@@ -27,6 +27,8 @@ Full definition of a Halifax resource. Pass an array of these to `createExpressC
27
27
  | `maxFilterDepth` | `number` | no | Maximum nesting depth for `where` clause children. Defaults to 4. |
28
28
  | `cache` | `ResourceCacheConfig \| false` | no | Per-resource cache TTL. `false` disables caching even when an API-wide default is set. |
29
29
  | `envelope` | `string \| null` | no | Wraps every success response body under this key (e.g. `'data'`). Overrides the API-wide `envelope` option. |
30
+ | `graphql` | `boolean` | no | When `false`, excludes this resource from the auto-generated GraphQL schema while keeping it on REST. Defaults to `true` when GraphQL is enabled. |
31
+ | `bypassTenantRoles` | `string[]` | no | Roles or permission slugs whose holders bypass tenant scoping for read operations on this resource. Overrides `TenantOptions.bypassRoles`. Set to `[]` to prevent bypass on this resource even when a global list is configured. |
30
32
 
31
33
  ---
32
34
 
@@ -232,9 +234,10 @@ Configures multi-tenant isolation API-wide. Set on `CrudApiOptions.tenant`.
232
234
 
233
235
  | Property | Type | Default | Description |
234
236
  | ----------- | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
- | `resolveId` | `(ctx: TenantResolveContext) => unknown \| Promise<unknown>` | required | Resolves the tenant key from the auth context and request. Must come from the token/session — never from client input. Return `null`/`undefined` when none applies. |
236
- | `field` | `string` | `'tenantId'` | Default column name for auto-detection: resources with a field of this name are automatically scoped on it. |
237
- | `strict` | `boolean` | `true` | When `true`, a tenant-scoped resource whose `resolveId` returns no value rejects with 403 rather than serving unscoped. |
237
+ | `resolveId` | `(ctx: TenantResolveContext) => unknown \| Promise<unknown>` | required | Resolves the tenant key from the auth context and request. Must come from the token/session — never from client input. Return `null`/`undefined` when none applies. |
238
+ | `field` | `string` | `'tenantId'` | Default column name for auto-detection: resources with a field of this name are automatically scoped on it. |
239
+ | `strict` | `boolean` | `true` | When `true`, a tenant-scoped resource whose `resolveId` returns no value rejects with 403 rather than serving unscoped. |
240
+ | `bypassRoles` | `string[]` | — | Roles or permission slugs (matched against `auth.roles` OR `auth.permissions`) whose holders receive unscoped reads across all tenants. Writes are never bypassed. Per-resource `ResourceDefinition.bypassTenantRoles` takes precedence. |
238
241
 
239
242
  ---
240
243
 
@@ -84,6 +84,11 @@ interface TenantOptions {
84
84
  field?: string
85
85
  /** Fail-closed when no tenant resolves. Defaults to true. */
86
86
  strict?: boolean
87
+ /**
88
+ * Roles or permission slugs whose holders bypass tenant scoping on read operations.
89
+ * See "Admin bypass" below.
90
+ */
91
+ bypassRoles?: string[]
87
92
  }
88
93
  ```
89
94
 
@@ -113,6 +118,88 @@ createPrismaResources(prisma, models, {
113
118
  })
114
119
  ```
115
120
 
121
+ ## Admin bypass
122
+
123
+ Sometimes a super-admin or support role needs to read across all tenants — for reporting,
124
+ debugging, or backoffice tooling — without being confined to a single company's data.
125
+
126
+ ### Enabling bypass
127
+
128
+ Add `bypassRoles` to `TenantOptions`. Any caller whose `auth.roles` **or** `auth.permissions`
129
+ matches at least one entry is granted an unscoped read:
130
+
131
+ ```ts
132
+ createExpressCrudRouter(resources, {
133
+ tenant: {
134
+ resolveId: ({ auth }) => auth.claims?.companyId,
135
+ bypassRoles: ['super_admin', 'support:read-all'],
136
+ }
137
+ })
138
+ ```
139
+
140
+ Values are matched against both `auth.roles` and `auth.permissions` — either paradigm works.
141
+
142
+ ### What bypass does (and does not do)
143
+
144
+ | Operation | Behaviour with bypass |
145
+ | --- | --- |
146
+ | `GET /resource` — list | Returns rows from **all** tenants (unscoped) |
147
+ | `GET /resource/:id` | Returns the record regardless of which tenant owns it |
148
+ | `POST /resource/query` (query builder) | Returns rows from all tenants |
149
+ | `POST /resource` (create) | **Not bypassed** — tenant value still comes from `resolveId` |
150
+ | `PATCH`, `PUT`, `DELETE` | **Not bypassed** — tenant value still comes from `resolveId` |
151
+
152
+ Writes are deliberately excluded. Tenant on writes comes from the authenticated session —
153
+ never from the client and never from the bypass path. An admin writing through the API either has a `companyId` in their token (and writes to that company) or receives 403.
154
+
155
+ ### Narrowing a bypass read to one tenant
156
+
157
+ When a super-admin wants data for a specific company they use the **normal filter mechanism**
158
+ — the tenant field is just another filterable column from their perspective. No special header or query parameter is needed:
159
+
160
+ ```
161
+ # All tenants (bypass active, no filter)
162
+ GET /orders
163
+
164
+ # One specific tenant
165
+ GET /orders?companyId=42
166
+ ```
167
+
168
+ In GraphQL:
169
+
170
+ ```graphql
171
+ # All tenants
172
+ { listOrders { count results { id companyId total } } }
173
+
174
+ # One tenant
175
+ { listOrders(filter: { companyId: 42 }) { count results { id companyId total } } }
176
+ ```
177
+
178
+ ### Per-resource bypass override
179
+
180
+ `ResourceDefinition.bypassTenantRoles` overrides the global `bypassRoles` for one resource.
181
+ Set it to `[]` to prevent bypass on a particularly sensitive model:
182
+
183
+ ```ts
184
+ const resources = [
185
+ {
186
+ routePrefix: 'orders',
187
+ repository: orderRepo,
188
+ // inherits bypassRoles from TenantOptions → super_admin gets all
189
+ },
190
+ {
191
+ routePrefix: 'payment-methods',
192
+ repository: paymentRepo,
193
+ bypassTenantRoles: [], // always scoped — even super_admin sees only their tenant
194
+ },
195
+ {
196
+ routePrefix: 'users',
197
+ repository: userRepo,
198
+ bypassTenantRoles: ['super_admin'], // only super_admin bypasses; support:read-all does not
199
+ }
200
+ ]
201
+ ```
202
+
116
203
  ## Security model
117
204
 
118
205
  Tenant scoping is designed to **fail closed**. The guarantees:
package/README_OPENAPI.md CHANGED
@@ -240,7 +240,7 @@ The body combines `QueryOptions` fields (to select records) with an `update` key
240
240
 
241
241
  ```json
242
242
  {
243
- "where": [{ "field": "status", "comparison": "=", "value": "draft" }],
243
+ "where": [{ "field": "status", "comparison": "=", "value1": "draft" }],
244
244
  "update": { "status": "archived" }
245
245
  }
246
246
  ```
@@ -274,13 +274,13 @@ The query builder endpoint accepts a `QueryOptions` body for full-featured filte
274
274
  ```json
275
275
  {
276
276
  "where": [
277
- { "field": "published", "comparison": "=", "value": true },
278
- { "field": "createdAt", "comparison": ">=", "value": "2024-01-01T00:00:00Z" },
277
+ { "field": "published", "comparison": "=", "value1": true },
278
+ { "field": "createdAt", "comparison": ">=", "value1": "2024-01-01T00:00:00Z" },
279
279
  {
280
280
  "operator": "OR",
281
281
  "children": [
282
- { "field": "title", "comparison": "CONTAINS", "value": "Halifax" },
283
- { "field": "title", "comparison": "STARTS WITH", "value": "Hello" }
282
+ { "field": "title", "comparison": "CONTAINS", "value1": "Halifax" },
283
+ { "field": "title", "comparison": "STARTS WITH", "value1": "Hello" }
284
284
  ]
285
285
  }
286
286
  ],
@@ -318,12 +318,12 @@ Flat `where` arrays use **AND-precedence over OR** (same as SQL). Use `children`
318
318
  ```json
319
319
  {
320
320
  "where": [
321
- { "field": "active", "comparison": "=", "value": true },
321
+ { "field": "active", "comparison": "=", "value1": true },
322
322
  {
323
323
  "operator": "OR",
324
324
  "children": [
325
- { "field": "role", "comparison": "=", "value": "admin" },
326
- { "field": "role", "comparison": "=", "value": "moderator" }
325
+ { "field": "role", "comparison": "=", "value1": "admin" },
326
+ { "field": "role", "comparison": "=", "value1": "moderator" }
327
327
  ]
328
328
  }
329
329
  ]