@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 +69 -0
- package/README.md +6 -3
- package/README_GRAPHQL.md +352 -0
- package/README_INTERFACES.md +6 -3
- package/README_MULTITENANCY.md +87 -0
- package/README_OPENAPI.md +8 -8
- package/dist/core/crudRouter.d.ts +38 -0
- package/dist/core/crudRouter.js +53 -1
- package/dist/core/handlerUtils.d.ts +1 -1
- package/dist/core/handlers/create.js +1 -1
- package/dist/core/handlers/deleteMany.js +1 -1
- package/dist/core/handlers/deleteOne.js +1 -1
- package/dist/core/handlers/query.js +1 -1
- package/dist/core/handlers/readMany.js +1 -1
- package/dist/core/handlers/readOne.js +1 -1
- package/dist/core/handlers/updateMany.js +1 -1
- package/dist/core/handlers/updateOne.js +1 -1
- package/dist/core/handlers/upsertOne.js +1 -1
- package/dist/core/types.d.ts +22 -0
- package/dist/graphql/graphiql.d.ts +5 -0
- package/dist/graphql/graphiql.js +29 -0
- package/dist/graphql/index.d.ts +4 -0
- package/dist/graphql/index.js +3 -0
- package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
- package/dist/graphql/registerGraphqlRoute.js +79 -0
- package/dist/graphql/scalars.d.ts +6 -0
- package/dist/graphql/scalars.js +32 -0
- package/dist/graphql/schema.d.ts +3 -0
- package/dist/graphql/schema.js +635 -0
- package/dist/graphql/types.d.ts +48 -0
- package/dist/graphql/types.js +1 -0
- package/dist/index.d.ts +1 -0
- package/package.json +8 -2
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
|
+
```
|
package/README_INTERFACES.md
CHANGED
|
@@ -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`
|
|
236
|
-
| `field`
|
|
237
|
-
| `strict`
|
|
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
|
|
package/README_MULTITENANCY.md
CHANGED
|
@@ -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": "=", "
|
|
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": "=", "
|
|
278
|
-
{ "field": "createdAt", "comparison": ">=", "
|
|
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", "
|
|
283
|
-
{ "field": "title", "comparison": "STARTS WITH", "
|
|
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": "=", "
|
|
321
|
+
{ "field": "active", "comparison": "=", "value1": true },
|
|
322
322
|
{
|
|
323
323
|
"operator": "OR",
|
|
324
324
|
"children": [
|
|
325
|
-
{ "field": "role", "comparison": "=", "
|
|
326
|
-
{ "field": "role", "comparison": "=", "
|
|
325
|
+
{ "field": "role", "comparison": "=", "value1": "admin" },
|
|
326
|
+
{ "field": "role", "comparison": "=", "value1": "moderator" }
|
|
327
327
|
]
|
|
328
328
|
}
|
|
329
329
|
]
|