@edium/halifax 2.2.3 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +6 -3
  3. package/README_AUTH.md +2 -2
  4. package/README_AUTOCRUD.md +5 -4
  5. package/README_CACHE.md +6 -0
  6. package/README_CLASSES.md +13 -6
  7. package/README_GRAPHQL.md +352 -0
  8. package/README_INTERFACES.md +19 -14
  9. package/README_MULTITENANCY.md +87 -0
  10. package/README_OPENAPI.md +9 -9
  11. package/README_REPO_ADAPTERS.md +10 -0
  12. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  13. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
  16. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  17. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  18. package/dist/auth/strategies/PassportStrategies.js +3 -9
  19. package/dist/auth/strategies/types.d.ts +7 -0
  20. package/dist/auth/strategies/types.js +13 -1
  21. package/dist/core/cache/CacheStore.d.ts +12 -0
  22. package/dist/core/cache/createCachingRepository.js +10 -1
  23. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  24. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  25. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  26. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  27. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  28. package/dist/core/crudRouter.d.ts +38 -0
  29. package/dist/core/crudRouter.js +55 -21
  30. package/dist/core/fields.d.ts +11 -1
  31. package/dist/core/fields.js +19 -0
  32. package/dist/core/handlerUtils.d.ts +7 -1
  33. package/dist/core/handlerUtils.js +15 -11
  34. package/dist/core/handlers/create.js +4 -3
  35. package/dist/core/handlers/deleteMany.js +1 -1
  36. package/dist/core/handlers/deleteOne.js +1 -1
  37. package/dist/core/handlers/query.js +4 -6
  38. package/dist/core/handlers/readMany.js +4 -6
  39. package/dist/core/handlers/readOne.js +4 -7
  40. package/dist/core/handlers/updateMany.js +4 -5
  41. package/dist/core/handlers/updateOne.js +1 -1
  42. package/dist/core/handlers/upsertOne.js +1 -1
  43. package/dist/core/queryString.d.ts +10 -0
  44. package/dist/core/queryString.js +23 -0
  45. package/dist/core/types.d.ts +22 -0
  46. package/dist/core/validation.js +5 -11
  47. package/dist/graphql/graphiql.d.ts +5 -0
  48. package/dist/graphql/graphiql.js +29 -0
  49. package/dist/graphql/index.d.ts +4 -0
  50. package/dist/graphql/index.js +3 -0
  51. package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
  52. package/dist/graphql/registerGraphqlRoute.js +79 -0
  53. package/dist/graphql/scalars.d.ts +6 -0
  54. package/dist/graphql/scalars.js +32 -0
  55. package/dist/graphql/schema.d.ts +3 -0
  56. package/dist/graphql/schema.js +635 -0
  57. package/dist/graphql/types.d.ts +48 -0
  58. package/dist/graphql/types.js +1 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/openapi/specGenerator.js +19 -19
  61. package/package.json +9 -3
@@ -1,8 +1,9 @@
1
1
  import { ConflictError } from '../../../errors/ConflictError.js';
2
- import { NotImplementedError } from '../../../errors/NotImplementedError.js';
3
2
  import { NotFoundError } from '../../../errors/NotFoundError.js';
3
+ import { NotImplementedError } from '../../../errors/NotImplementedError.js';
4
4
  import { ServerError } from '../../../errors/ServerError.js';
5
- import { astToPrismaWhere, astToPrismaOrderBy } from './astToPrisma.js';
5
+ import { astToPrismaOrderBy, astToPrismaWhere } from './astToPrisma.js';
6
+ import { toInclude, toOrderBy, toSelect } from './helpers.js';
6
7
  /** Returns true for Prisma's P2025 "record not found" error. */
7
8
  function isNotFoundError(error) {
8
9
  return (typeof error === 'object' &&
@@ -17,7 +18,19 @@ function isDuplicateError(error) {
17
18
  'code' in error &&
18
19
  error.code === 'P2002');
19
20
  }
20
- import { toSelect, toInclude, toOrderBy } from './helpers.js';
21
+ /**
22
+ * Returns true for SQL Server's "IDENTITY_INSERT is set to OFF" error (code 544).
23
+ * MSSQL IDENTITY columns reject any explicit-value INSERT via the driver adapter rather
24
+ * than surfacing a P2002 duplicate — so this must be caught separately.
25
+ */
26
+ function isIdentityInsertError(error) {
27
+ if (typeof error !== 'object' || error === null)
28
+ return false;
29
+ const cause = error.cause;
30
+ return (typeof cause === 'object' &&
31
+ cause !== null &&
32
+ cause.code === 544);
33
+ }
21
34
  function prismaTypeToOpenApi(prismaType) {
22
35
  switch (prismaType) {
23
36
  case 'Int':
@@ -293,19 +306,24 @@ export class PrismaAdapter {
293
306
  * @throws ServerError if the Prisma delegate does not support the update method.
294
307
  */
295
308
  async updateOne(id, data) {
296
- // When scoped, confirm the row belongs to the caller's tenant before touching it,
297
- // and strip the tenant field from the payload so the row can't be moved tenants.
298
309
  if (this.scope) {
299
- if (!this.delegate.findFirst) {
300
- throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
310
+ const scopedWhere = this.scopedWhere({ [this.idField]: id });
311
+ // Preferred path: delegate.updateMany lets us do a single atomic statement whose
312
+ // WHERE enforces the tenant boundary, eliminating the TOCTOU window.
313
+ if (this.delegate.updateMany && this.delegate.findFirst) {
314
+ const { count } = await this.delegate.updateMany({
315
+ where: scopedWhere,
316
+ data: this.stripTenant(data)
317
+ });
318
+ if (count === 0)
319
+ return null;
320
+ return (await this.delegate.findFirst({ where: scopedWhere }));
301
321
  }
302
- const owned = await this.delegate.findFirst({
303
- where: this.scopedWhere({ [this.idField]: id }),
304
- select: { [this.idField]: true }
305
- });
306
- if (!owned)
307
- return null;
308
- data = this.stripTenant(data);
322
+ // updateMany is unavailable — we cannot perform a single atomic scoped update.
323
+ // A two-step findFirst + unscoped update would introduce a TOCTOU window where a
324
+ // record could be transferred to another tenant between the check and the write.
325
+ // Refuse rather than risk a cross-tenant modification.
326
+ throw new ServerError('Prisma delegate does not support updateMany (required for safe tenant-scoped updateOne).');
309
327
  }
310
328
  try {
311
329
  return (await this.delegate.update({ where: { [this.idField]: id }, data }));
@@ -355,35 +373,95 @@ export class PrismaAdapter {
355
373
  * @throws ServerError if the Prisma delegate does not support the required methods for upserting records.
356
374
  */
357
375
  async upsertOne(id, data) {
358
- if (!this.delegate.upsert) {
359
- throw new NotImplementedError('Prisma delegate does not support upsert.');
360
- }
361
- // When scoped, an upsert keyed on a unique id could otherwise overwrite a row owned
362
- // by another tenant. Reject that case (hidden as "not found"), stamp the tenant on
363
- // create, and forbid reassigning the tenant on update.
376
+ // Scoped upsert: do NOT use delegate.upsert with a bare id where-clause — Prisma's upsert
377
+ // would execute its `update` branch against any matching record regardless of tenant, giving
378
+ // a cross-tenant write if a race places another tenant's row at that id. Instead we decompose
379
+ // into a scoped findFirst + a scoped updateMany (create on miss) so the tenant constraint is
380
+ // enforced at every statement.
364
381
  if (this.scope) {
365
382
  if (!this.delegate.findFirst) {
366
383
  throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
367
384
  }
385
+ const scopedWhere = this.scopedWhere({ [this.idField]: id });
368
386
  const existing = (await this.delegate.findFirst({
369
- where: { [this.idField]: id }
387
+ where: scopedWhere
370
388
  }));
389
+ // Defense-in-depth: even though scopedWhere already filters by tenant, verify the
390
+ // returned record actually belongs to this tenant before treating it as owned.
371
391
  if (existing && existing[this.scope.field] !== this.scope.value) {
372
392
  throw new NotFoundError();
373
393
  }
394
+ if (existing) {
395
+ // Record exists for this tenant — update it atomically via updateMany(scopedWhere)
396
+ // so the tenant constraint is enforced in the same SQL statement as the write.
397
+ if (this.delegate.updateMany) {
398
+ const { count } = await this.delegate.updateMany({
399
+ where: scopedWhere,
400
+ data: this.stripTenant(data)
401
+ });
402
+ if (count === 0) {
403
+ // Deleted in the tiny window between findFirst and updateMany — treat as a
404
+ // fresh create so the caller gets a record back (consistent with upsert semantics).
405
+ try {
406
+ return (await this.delegate.create({
407
+ data: this.stampTenant({ ...data, [this.idField]: id })
408
+ }));
409
+ }
410
+ catch (error) {
411
+ if (isDuplicateError(error))
412
+ throw new ConflictError();
413
+ if (isIdentityInsertError(error)) {
414
+ const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
415
+ if (anyMatch)
416
+ throw new ConflictError();
417
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+ return (await this.delegate.findFirst({ where: scopedWhere }));
423
+ }
424
+ // Fallback when updateMany is unavailable (non-standard delegate). The update is still
425
+ // scoped via the earlier findFirst; the TOCTOU window here is only closeable with a
426
+ // transaction, which we cannot guarantee across providers.
427
+ try {
428
+ return (await this.delegate.update({
429
+ where: { [this.idField]: id },
430
+ data: this.stripTenant(data)
431
+ }));
432
+ }
433
+ catch (error) {
434
+ if (isNotFoundError(error))
435
+ throw new NotFoundError();
436
+ if (isDuplicateError(error))
437
+ throw new ConflictError();
438
+ throw error;
439
+ }
440
+ }
441
+ // Record does not exist for this tenant — create it with the tenant stamped.
374
442
  try {
375
- return (await this.delegate.upsert({
376
- where: { [this.idField]: id },
377
- create: this.stampTenant(data),
378
- update: this.stripTenant(data)
443
+ return (await this.delegate.create({
444
+ data: this.stampTenant({ ...data, [this.idField]: id })
379
445
  }));
380
446
  }
381
447
  catch (error) {
382
448
  if (isDuplicateError(error))
383
449
  throw new ConflictError();
450
+ if (isIdentityInsertError(error)) {
451
+ // MSSQL IDENTITY columns reject any explicit-ID insert. Probe to distinguish a
452
+ // cross-tenant ID collision (another tenant owns this ID → ConflictError) from a
453
+ // genuinely new row (let the DB assign the ID instead).
454
+ const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
455
+ if (anyMatch)
456
+ throw new ConflictError();
457
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
458
+ }
384
459
  throw error;
385
460
  }
386
461
  }
462
+ if (!this.delegate.upsert) {
463
+ throw new NotImplementedError('Prisma delegate does not support upsert.');
464
+ }
387
465
  try {
388
466
  return (await this.delegate.upsert({
389
467
  where: { [this.idField]: id },
@@ -416,15 +494,9 @@ export class PrismaAdapter {
416
494
  });
417
495
  return (result?.count ?? 0) > 0;
418
496
  }
419
- if (!this.delegate.findFirst) {
420
- throw new ServerError('Prisma delegate does not support deleteMany or findFirst (required for tenant scoping).');
421
- }
422
- const owned = await this.delegate.findFirst({
423
- where: this.scopedWhere({ [this.idField]: id }),
424
- select: { [this.idField]: true }
425
- });
426
- if (!owned)
427
- return false;
497
+ // deleteMany is unavailable — we cannot do an atomic scoped delete. A two-step
498
+ // findFirst + unscoped delete would introduce a TOCTOU cross-tenant deletion risk.
499
+ throw new ServerError('Prisma delegate does not support deleteMany (required for safe tenant-scoped deleteOne).');
428
500
  }
429
501
  try {
430
502
  await this.delegate.delete({ where: { [this.idField]: id } });
@@ -3,6 +3,12 @@ import { SqlComparison, SqlOperator, SqlOrder } from '@edium/halifax-types';
3
3
  * Splits a `LIKE` pattern into a Prisma string operator based on its `%` wildcards.
4
4
  * `%x%` → `contains`, `x%` → `startsWith`, `%x` → `endsWith`, and a wildcard-free value
5
5
  * collapses to `equals` (matching SQL `LIKE 'x'` semantics).
6
+ *
7
+ * **Known limitation:** interior wildcards (`'foo%bar'`) cannot be expressed with Prisma's
8
+ * string operators — they fall through to `equals`, which is an exact match, not a
9
+ * wildcard match. Prisma has no `matches`/`glob` operator without raw SQL. Use `CONTAINS`,
10
+ * `STARTS WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
11
+ *
6
12
  * @param value - The raw LIKE pattern.
7
13
  * @returns A Prisma string-filter object.
8
14
  */
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError } from '../../errors/AuthenticationError.js';
2
+ import { checkRequiredPermissions } from './types.js';
2
3
  /** Authenticates via a Bearer JWT and authorises using roles/permissions embedded in its claims. */
3
4
  export class JwtClaimsAuthStrategy {
4
5
  verifyToken;
@@ -31,14 +32,7 @@ export class JwtClaimsAuthStrategy {
31
32
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
32
33
  */
33
34
  authorize(params) {
34
- if (!params.requiredPermissions.length) {
35
- return true;
36
- }
37
- const permissions = new Set(params.auth.permissions ?? []);
38
- const roles = new Set(params.auth.roles ?? []);
39
- return params.requiredPermissions.every((permission) => {
40
- return permissions.has(permission) || roles.has(permission);
41
- });
35
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
42
36
  }
43
37
  openApiScheme() {
44
38
  return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError } from '../../errors/AuthenticationError.js';
2
+ import { checkRequiredPermissions } from './types.js';
2
3
  /**
3
4
  * Extracts `userId`, `roles`, `permissions`, and `claims` from a raw Passport user payload.
4
5
  * @param user - The raw user object returned by Passport (typically a decoded JWT payload).
@@ -17,13 +18,6 @@ function defaultMapUser(user) {
17
18
  ctx.userId = userId;
18
19
  return ctx;
19
20
  }
20
- function checkPermissions(auth, requiredPermissions) {
21
- if (!requiredPermissions.length)
22
- return true;
23
- const permissions = new Set(auth.permissions ?? []);
24
- const roles = new Set(auth.roles ?? []);
25
- return requiredPermissions.every((p) => permissions.has(p) || roles.has(p));
26
- }
27
21
  /** Delegates authentication to a caller-provided Passport authenticate wrapper. */
28
22
  export class PassportAuthStrategy {
29
23
  authenticateWithPassport;
@@ -85,7 +79,7 @@ export class PassportJwtStrategy {
85
79
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
86
80
  */
87
81
  authorize(params) {
88
- return checkPermissions(params.auth, params.requiredPermissions);
82
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
89
83
  }
90
84
  openApiScheme() {
91
85
  return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
@@ -129,7 +123,7 @@ export class PassportSessionStrategy {
129
123
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
130
124
  */
131
125
  authorize(params) {
132
- return checkPermissions(params.auth, params.requiredPermissions);
126
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
133
127
  }
134
128
  openApiScheme() {
135
129
  return {
@@ -45,6 +45,13 @@ export type SecurityScheme = {
45
45
  scheme: 'basic';
46
46
  description?: string;
47
47
  };
48
+ /**
49
+ * Checks whether an auth context satisfies `requiredPermissions`.
50
+ * Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
51
+ * (i.e. the list is an OR — "user must have at least one of these"). This mirrors
52
+ * the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
53
+ */
54
+ export declare function checkRequiredPermissions(auth: AuthContext, requiredPermissions: string[]): boolean;
48
55
  /** Contract for pluggable authentication and authorisation strategies. */
49
56
  export interface AuthStrategy {
50
57
  /**
@@ -1 +1,13 @@
1
- export {};
1
+ /**
2
+ * Checks whether an auth context satisfies `requiredPermissions`.
3
+ * Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
4
+ * (i.e. the list is an OR — "user must have at least one of these"). This mirrors
5
+ * the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
6
+ */
7
+ export function checkRequiredPermissions(auth, requiredPermissions) {
8
+ if (!requiredPermissions.length)
9
+ return true;
10
+ const permissions = new Set(auth.permissions ?? []);
11
+ const roles = new Set(auth.roles ?? []);
12
+ return requiredPermissions.some((p) => permissions.has(p) || roles.has(p));
13
+ }
@@ -22,4 +22,16 @@ export interface CacheStore {
22
22
  * @param key - Cache key to remove.
23
23
  */
24
24
  delete(key: string): Promise<void> | void;
25
+ /**
26
+ * Atomically increment an integer counter and return the new value.
27
+ * When the key does not exist, it is initialised to `0` before incrementing.
28
+ *
29
+ * Implementing this method is **strongly recommended** for multi-process stores
30
+ * (e.g. Redis) — without it, `createCachingRepository` falls back to a non-atomic
31
+ * read-then-write version-bump that can lose invalidations under concurrent writes.
32
+ *
33
+ * Single-process stores (`InMemoryCacheStore`) can implement this synchronously and
34
+ * are immune to the race regardless, but should still implement it for correctness.
35
+ */
36
+ increment?(key: string): Promise<number> | number;
25
37
  }
@@ -34,7 +34,16 @@ export function createCachingRepository(repo, options) {
34
34
  return typeof v === 'number' ? v : 0;
35
35
  };
36
36
  const bumpVersion = async () => {
37
- await store.set(versionKey, (await readVersion()) + 1);
37
+ // Use store.increment when available (atomic on Redis via INCR; race-free on
38
+ // InMemoryCacheStore because Node.js is single-threaded). Fall back to a non-atomic
39
+ // read-then-write only when the store does not expose increment — acceptable for
40
+ // custom single-process stores where concurrent writes cannot interleave.
41
+ if (store.increment) {
42
+ await store.increment(versionKey);
43
+ }
44
+ else {
45
+ await store.set(versionKey, (await readVersion()) + 1);
46
+ }
38
47
  };
39
48
  const keyFor = async (op, payload) => `${namespace}:v${await readVersion()}:${op}:${stableStringify(payload)}`;
40
49
  const cachedRead = async (op, payload, run) => {
@@ -5,15 +5,28 @@ import type { CacheStore } from '../CacheStore.js';
5
5
  * Suitable for single-process deployments and tests. For multi-instance deployments,
6
6
  * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
7
7
  * consistent across processes.
8
+ *
9
+ * Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
10
+ * write-heavy workloads, a sweep of all expired entries runs automatically every
11
+ * `sweepEvery` writes (default: 200). Adjust via the constructor option.
8
12
  */
9
13
  export declare class InMemoryCacheStore implements CacheStore {
10
14
  private readonly now;
11
15
  private readonly map;
16
+ private setCount;
17
+ private readonly sweepEvery;
12
18
  /**
13
19
  * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
20
+ * @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
14
21
  */
15
- constructor(now?: () => number);
22
+ constructor(now?: () => number, sweepEvery?: number);
16
23
  get(key: string): unknown;
17
24
  set(key: string, value: unknown, ttlSeconds?: number): void;
18
25
  delete(key: string): void;
26
+ /**
27
+ * Atomically increments an integer counter and returns the new value.
28
+ * Because Node.js is single-threaded this is race-free without a lock.
29
+ */
30
+ increment(key: string): number;
31
+ private purgeExpired;
19
32
  }
@@ -4,15 +4,23 @@
4
4
  * Suitable for single-process deployments and tests. For multi-instance deployments,
5
5
  * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
6
6
  * consistent across processes.
7
+ *
8
+ * Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
9
+ * write-heavy workloads, a sweep of all expired entries runs automatically every
10
+ * `sweepEvery` writes (default: 200). Adjust via the constructor option.
7
11
  */
8
12
  export class InMemoryCacheStore {
9
13
  now;
10
14
  map = new Map();
15
+ setCount = 0;
16
+ sweepEvery;
11
17
  /**
12
18
  * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
19
+ * @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
13
20
  */
14
- constructor(now = () => Date.now()) {
21
+ constructor(now = () => Date.now(), sweepEvery = 200) {
15
22
  this.now = now;
23
+ this.sweepEvery = sweepEvery;
16
24
  }
17
25
  get(key) {
18
26
  const entry = this.map.get(key);
@@ -27,8 +35,28 @@ export class InMemoryCacheStore {
27
35
  set(key, value, ttlSeconds) {
28
36
  const expiresAt = ttlSeconds && ttlSeconds > 0 ? this.now() + ttlSeconds * 1000 : null;
29
37
  this.map.set(key, { value, expiresAt });
38
+ if (++this.setCount % this.sweepEvery === 0)
39
+ this.purgeExpired();
30
40
  }
31
41
  delete(key) {
32
42
  this.map.delete(key);
33
43
  }
44
+ /**
45
+ * Atomically increments an integer counter and returns the new value.
46
+ * Because Node.js is single-threaded this is race-free without a lock.
47
+ */
48
+ increment(key) {
49
+ const entry = this.map.get(key);
50
+ const current = typeof entry?.value === 'number' ? entry.value : 0;
51
+ const next = current + 1;
52
+ this.map.set(key, { value: next, expiresAt: null });
53
+ return next;
54
+ }
55
+ purgeExpired() {
56
+ const now = this.now();
57
+ for (const [key, entry] of this.map) {
58
+ if (entry.expiresAt !== null && entry.expiresAt <= now)
59
+ this.map.delete(key);
60
+ }
61
+ }
34
62
  }
@@ -25,4 +25,10 @@ export declare class RedisCacheStore implements CacheStore {
25
25
  get(key: string): Promise<unknown>;
26
26
  set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
27
27
  delete(key: string): Promise<void>;
28
+ /**
29
+ * Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
30
+ * the new value. Falls back to a non-atomic get-then-set when the underlying client
31
+ * does not expose `incr` — this should not occur with standard Redis clients.
32
+ */
33
+ increment(key: string): Promise<number>;
28
34
  }
@@ -39,4 +39,18 @@ export class RedisCacheStore {
39
39
  async delete(key) {
40
40
  await this.client.del(this.prefix + key);
41
41
  }
42
+ /**
43
+ * Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
44
+ * the new value. Falls back to a non-atomic get-then-set when the underlying client
45
+ * does not expose `incr` — this should not occur with standard Redis clients.
46
+ */
47
+ async increment(key) {
48
+ if (this.client.incr) {
49
+ return await this.client.incr(this.prefix + key);
50
+ }
51
+ const raw = await this.client.get(this.prefix + key);
52
+ const next = (raw !== null ? parseInt(raw, 10) : 0) + 1;
53
+ await this.client.set(this.prefix + key, String(next));
54
+ return next;
55
+ }
42
56
  }
@@ -9,4 +9,6 @@ export interface RedisLikeClient {
9
9
  EX?: number;
10
10
  }): Promise<unknown>;
11
11
  del(key: string): Promise<unknown>;
12
+ /** Atomic integer increment — maps to Redis `INCR`. Optional but strongly recommended. */
13
+ incr?(key: string): Promise<number>;
12
14
  }
@@ -1,6 +1,7 @@
1
1
  import { type AuthContext, type AuthStrategy } from '../auth/AuthStrategy.js';
2
2
  import { type CacheStore } from '../core/cache/index.js';
3
3
  import { type OpenApiOptions } from '../openapi/index.js';
4
+ import { type GraphQLOptions } from '../graphql/index.js';
4
5
  import { type ResourceDefinition } from '../core/types.js';
5
6
  import type { HttpRequest, HttpServer } from '../core/types.js';
6
7
  import { normalizeError } from '../core/handlerUtils.js';
@@ -40,6 +41,29 @@ export interface TenantOptions {
40
41
  * cross-tenant ("god mode") access for callers with no tenant.
41
42
  */
42
43
  strict?: boolean;
44
+ /**
45
+ * Roles or permission slugs whose holders may bypass tenant scoping for **read** operations
46
+ * (`getOne`, `getMany`, and the query builder), allowing them to see records across all tenants.
47
+ * Any single match in `auth.roles` or `auth.permissions` grants the bypass.
48
+ *
49
+ * When a bypass caller wants to see only one tenant's data they use the normal filter
50
+ * mechanism — `?companyId=42` on REST or `filter: { companyId: 42 }` in GraphQL.
51
+ * No special header or query parameter is needed; the tenant field is just another filterable
52
+ * column from the admin's perspective.
53
+ *
54
+ * Write operations (create / update / delete) are **never** bypassed: the tenant value
55
+ * continues to come from `resolveId`, keeping write provenance tied to auth — never to
56
+ * client-supplied input. An admin whose token carries no tenant will receive 403 on writes
57
+ * unless `strict` is `false`.
58
+ *
59
+ * Per-resource {@link ResourceDefinition.bypassTenantRoles} takes precedence over this list.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * bypassRoles: ['super_admin', 'support:read-all']
64
+ * ```
65
+ */
66
+ bypassRoles?: string[];
43
67
  }
44
68
  /** Options for {@link registerCrudApi} / {@link createExpressCrudRouter}. */
45
69
  export interface CrudApiOptions {
@@ -60,6 +84,20 @@ export interface CrudApiOptions {
60
84
  * additional routes: `GET /openapi.json` (raw spec) and `GET /docs` (Swagger UI).
61
85
  */
62
86
  openapi?: OpenApiOptions;
87
+ /**
88
+ * Enable a GraphQL endpoint. GraphQL is **disabled by default** — you must set
89
+ * `enabled: true` to activate it. When enabled, Halifax registers `POST <path>` (execution)
90
+ * and optionally `GET <path>` (GraphiQL IDE). The schema is auto-generated from all
91
+ * resources that have `graphql !== false`. Requires the `graphql` peer dependency.
92
+ *
93
+ * See [README_GRAPHQL.md](./README_GRAPHQL.md) for full docs and examples.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * graphql: { enabled: true, path: '/graphql', graphiql: true }
98
+ * ```
99
+ */
100
+ graphql?: GraphQLOptions;
63
101
  /**
64
102
  * API-wide read-through caching. Provide a `store` (defaults to an in-process
65
103
  * {@link InMemoryCacheStore}) and/or a default `ttlSeconds` applied to every resource that
@@ -1,12 +1,14 @@
1
1
  import { AllowAllAuthStrategy } from '../auth/AuthStrategy.js';
2
+ import { checkRequiredPermissions } from '../auth/strategies/types.js';
2
3
  import { createCachingRepository, InMemoryCacheStore } from '../core/cache/index.js';
3
4
  import { generateOpenApiSpec, generateDocsHtml } from '../openapi/index.js';
5
+ import { registerGraphqlRoute } from '../graphql/index.js';
4
6
  import { defaultCrudPermissions } from '../core/types.js';
5
7
  import { ServerError } from '../errors/ServerError.js';
6
8
  import { AuthorizationError } from '../errors/AuthorizationError.js';
7
9
  import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
8
10
  import { normalizeError, sendError, wantsCacheBust } from '../core/handlerUtils.js';
9
- import { mergeFieldDefinitions } from '../core/fields.js';
11
+ import { mergeFieldDefinitions, mergeRelationDefinitions, normalizeEnvelope } from '../core/fields.js';
10
12
  import { registerCreate } from '../core/handlers/create.js';
11
13
  import { registerReadMany } from '../core/handlers/readMany.js';
12
14
  import { registerReadOne } from '../core/handlers/readOne.js';
@@ -53,17 +55,6 @@ function resolveFields(resource, idField) {
53
55
  ...(field.writeRoles?.length ? { writeRoles: field.writeRoles } : {})
54
56
  }));
55
57
  }
56
- /**
57
- * Merges the repository's relation schema with the resource's own relations, by name.
58
- */
59
- function resolveRelations(resource) {
60
- const byName = new Map();
61
- for (const relation of resource.repository?.relations ?? [])
62
- byName.set(relation.name, relation);
63
- for (const relation of resource.relations ?? [])
64
- byName.set(relation.name, relation);
65
- return [...byName.values()];
66
- }
67
58
  /**
68
59
  * Produces a fully-resolved resource: `name` filled in, and `fields`/`relations` resolved
69
60
  * from the repository schema + the resource's own entries. Every downstream stage operates
@@ -75,16 +66,9 @@ function normalizeResource(resource) {
75
66
  ...resource,
76
67
  name: resource.name ?? deriveResourceName(resource.routePrefix),
77
68
  fields: resolveFields(resource, idField),
78
- relations: resolveRelations(resource)
69
+ relations: mergeRelationDefinitions(resource)
79
70
  };
80
71
  }
81
- /**
82
- * Resolves the effective envelope key. A non-empty string enables wrapping; `null`, `undefined`,
83
- * and `''` all mean "no envelope".
84
- */
85
- function normalizeEnvelope(value) {
86
- return typeof value === 'string' && value.length > 0 ? value : null;
87
- }
88
72
  /**
89
73
  * Determines the column a resource is tenant-scoped on, with this precedence:
90
74
  * explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
@@ -102,6 +86,8 @@ function effectiveTenantField(resource, tenant) {
102
86
  }
103
87
  /** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
104
88
  const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
89
+ /** Read-only actions that admin bypass applies to. Writes always enforce tenant scoping. */
90
+ const READ_ACTIONS = new Set(['readOne', 'readMany', 'readManyWithQueryBuilder']);
105
91
  /**
106
92
  * Registers all CRUD routes for every resource on the given HTTP server.
107
93
  *
@@ -150,10 +136,16 @@ export function registerCrudApi(server, resources, options = {}) {
150
136
  bust
151
137
  })
152
138
  : repo;
153
- const resolveRepo = async (req, auth) => {
139
+ const resolveRepo = async (req, auth, action) => {
154
140
  const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
155
141
  if (!tenantField || !options.tenant)
156
142
  return withCache(repository, 'global', bust);
143
+ // Admin bypass: callers with a privileged role/permission get unscoped reads.
144
+ // Writes always fall through to resolveId — tenant on writes comes from auth, never bypass.
145
+ const bypassRoles = resource.bypassTenantRoles ?? options.tenant.bypassRoles ?? [];
146
+ if (READ_ACTIONS.has(action) && bypassRoles.length > 0 && checkRequiredPermissions(auth, bypassRoles)) {
147
+ return withCache(repository, 'global', bust);
148
+ }
157
149
  const value = await options.tenant.resolveId({ auth, req, resource });
158
150
  if (value === undefined || value === null || value === '') {
159
151
  if (options.tenant.strict !== false)
@@ -217,6 +209,48 @@ export function registerCrudApi(server, resources, options = {}) {
217
209
  });
218
210
  }
219
211
  });
212
+ // ─── GraphQL endpoint ──────────────────────────────────────────────────────
213
+ if (options.graphql?.enabled === true) {
214
+ // Build per-resource contexts carrying the already-resolved resolveRepo closures.
215
+ // We re-iterate resources to capture the closure variables that were set up above.
216
+ const graphqlContexts = resources.map((rawResource) => {
217
+ const resource = normalizeResource(rawResource);
218
+ const repository = rawResource.repository;
219
+ const tenantField = effectiveTenantField(resource, options.tenant);
220
+ const cacheTtl = resource.cache === false
221
+ ? undefined
222
+ : (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
223
+ const cachingEnabled = cacheTtl !== undefined;
224
+ const withCacheLocal = (repo, scopeKey, bust) => cachingEnabled
225
+ ? createCachingRepository(repo, {
226
+ store: cacheStore,
227
+ ttlSeconds: cacheTtl,
228
+ namespace: `${resource.name}:${scopeKey}`,
229
+ bust
230
+ })
231
+ : repo;
232
+ const resolveRepoLocal = async (req, auth, action) => {
233
+ const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
234
+ if (!tenantField || !options.tenant)
235
+ return withCacheLocal(repository, 'global', bust);
236
+ const bypassRoles = resource.bypassTenantRoles ?? options.tenant.bypassRoles ?? [];
237
+ if (READ_ACTIONS.has(action) && bypassRoles.length > 0 && checkRequiredPermissions(auth, bypassRoles)) {
238
+ return withCacheLocal(repository, 'global', bust);
239
+ }
240
+ const value = await options.tenant.resolveId({ auth, req, resource });
241
+ if (value === undefined || value === null || value === '') {
242
+ if (options.tenant.strict !== false)
243
+ throw new AuthorizationError('No tenant is associated with this request.');
244
+ return withCacheLocal(repository, 'global', bust);
245
+ }
246
+ return withCacheLocal(repository.withScope({ field: tenantField, value }), String(value), bust);
247
+ };
248
+ const hooks = resource.hooks;
249
+ return { resource, authStrategy, hooks, resolveRepo: resolveRepoLocal };
250
+ });
251
+ registerGraphqlRoute(server, graphqlContexts, options.graphql, authStrategy);
252
+ }
253
+ // ─── OpenAPI spec + docs ───────────────────────────────────────────────────
220
254
  if (options.openapi && options.openapi.enabled !== false) {
221
255
  const specPath = options.openapi.specPath ?? '/openapi.json';
222
256
  const docsPath = options.openapi.docsPath ?? '/docs';