@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.
- package/CHANGELOG.md +202 -0
- package/README.md +6 -3
- package/README_AUTH.md +2 -2
- package/README_AUTOCRUD.md +5 -4
- package/README_CACHE.md +6 -0
- package/README_CLASSES.md +13 -6
- package/README_GRAPHQL.md +352 -0
- package/README_INTERFACES.md +19 -14
- package/README_MULTITENANCY.md +87 -0
- package/README_OPENAPI.md +9 -9
- package/README_REPO_ADAPTERS.md +10 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
- package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
- package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
- package/dist/auth/strategies/PassportStrategies.js +3 -9
- package/dist/auth/strategies/types.d.ts +7 -0
- package/dist/auth/strategies/types.js +13 -1
- package/dist/core/cache/CacheStore.d.ts +12 -0
- package/dist/core/cache/createCachingRepository.js +10 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
- package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
- package/dist/core/cache/redis/RedisCacheStore.js +14 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
- package/dist/core/crudRouter.d.ts +38 -0
- package/dist/core/crudRouter.js +55 -21
- package/dist/core/fields.d.ts +11 -1
- package/dist/core/fields.js +19 -0
- package/dist/core/handlerUtils.d.ts +7 -1
- package/dist/core/handlerUtils.js +15 -11
- package/dist/core/handlers/create.js +4 -3
- package/dist/core/handlers/deleteMany.js +1 -1
- package/dist/core/handlers/deleteOne.js +1 -1
- package/dist/core/handlers/query.js +4 -6
- package/dist/core/handlers/readMany.js +4 -6
- package/dist/core/handlers/readOne.js +4 -7
- package/dist/core/handlers/updateMany.js +4 -5
- package/dist/core/handlers/updateOne.js +1 -1
- package/dist/core/handlers/upsertOne.js +1 -1
- package/dist/core/queryString.d.ts +10 -0
- package/dist/core/queryString.js +23 -0
- package/dist/core/types.d.ts +22 -0
- package/dist/core/validation.js +5 -11
- 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/dist/openapi/specGenerator.js +19 -19
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
//
|
|
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:
|
|
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.
|
|
376
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
package/dist/core/crudRouter.js
CHANGED
|
@@ -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:
|
|
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';
|