@rapidrest/service-core 2.0.0 → 2.1.1

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 (85) hide show
  1. package/dist/lib/BackgroundServiceManager.js +51 -9
  2. package/dist/lib/BackgroundServiceManager.js.map +1 -1
  3. package/dist/lib/EventListenerManager.js +35 -2
  4. package/dist/lib/EventListenerManager.js.map +1 -1
  5. package/dist/lib/NetUtils.js +215 -21
  6. package/dist/lib/NetUtils.js.map +1 -1
  7. package/dist/lib/ObjectFactory.js +7 -2
  8. package/dist/lib/ObjectFactory.js.map +1 -1
  9. package/dist/lib/RateLimiter.js.map +1 -1
  10. package/dist/lib/Server.js +121 -80
  11. package/dist/lib/Server.js.map +1 -1
  12. package/dist/lib/auth/AuthMiddleware.js +160 -100
  13. package/dist/lib/auth/AuthMiddleware.js.map +1 -1
  14. package/dist/lib/auth/JWTStrategy.js +6 -2
  15. package/dist/lib/auth/JWTStrategy.js.map +1 -1
  16. package/dist/lib/database/ConnectionManager.js +50 -2
  17. package/dist/lib/database/ConnectionManager.js.map +1 -1
  18. package/dist/lib/database/DatabaseErrors.js +88 -0
  19. package/dist/lib/database/DatabaseErrors.js.map +1 -0
  20. package/dist/lib/database/MongoRepository.js +31 -3
  21. package/dist/lib/database/MongoRepository.js.map +1 -1
  22. package/dist/lib/database/MongoSchemaSync.js +7 -1
  23. package/dist/lib/database/MongoSchemaSync.js.map +1 -1
  24. package/dist/lib/database/TypeOrmSupport.js +49 -15
  25. package/dist/lib/database/TypeOrmSupport.js.map +1 -1
  26. package/dist/lib/database/index.js +1 -0
  27. package/dist/lib/database/index.js.map +1 -1
  28. package/dist/lib/decorators/PersistenceDecorators.js +23 -0
  29. package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
  30. package/dist/lib/decorators/RouteDecorators.js +4 -2
  31. package/dist/lib/decorators/RouteDecorators.js.map +1 -1
  32. package/dist/lib/http/bun/BunRouter.js +119 -9
  33. package/dist/lib/http/bun/BunRouter.js.map +1 -1
  34. package/dist/lib/http/index.js +1 -0
  35. package/dist/lib/http/index.js.map +1 -1
  36. package/dist/lib/http/session/sessionMiddleware.js +62 -14
  37. package/dist/lib/http/session/sessionMiddleware.js.map +1 -1
  38. package/dist/lib/http/types.js +10 -1
  39. package/dist/lib/http/types.js.map +1 -1
  40. package/dist/lib/http/uWS/Adapters.js +31 -13
  41. package/dist/lib/http/uWS/Adapters.js.map +1 -1
  42. package/dist/lib/http/uWS/Router.js +80 -16
  43. package/dist/lib/http/uWS/Router.js.map +1 -1
  44. package/dist/lib/http/uWS/WebSocket.js +4 -2
  45. package/dist/lib/http/uWS/WebSocket.js.map +1 -1
  46. package/dist/lib/models/ModelUtils.js +255 -81
  47. package/dist/lib/models/ModelUtils.js.map +1 -1
  48. package/dist/lib/models/RepoUtils.js +863 -266
  49. package/dist/lib/models/RepoUtils.js.map +1 -1
  50. package/dist/lib/routes/BaseAdminRoute.js +5 -4
  51. package/dist/lib/routes/BaseAdminRoute.js.map +1 -1
  52. package/dist/lib/routes/BasePushRoute.js +104 -40
  53. package/dist/lib/routes/BasePushRoute.js.map +1 -1
  54. package/dist/lib/routes/CRUDRoute.js +31 -17
  55. package/dist/lib/routes/CRUDRoute.js.map +1 -1
  56. package/dist/lib/routes/RouteUtils.js +120 -29
  57. package/dist/lib/routes/RouteUtils.js.map +1 -1
  58. package/dist/lib/security/ACLUtils.js +170 -34
  59. package/dist/lib/security/ACLUtils.js.map +1 -1
  60. package/dist/types/BackgroundServiceManager.d.ts +6 -0
  61. package/dist/types/EventListenerManager.d.ts +4 -0
  62. package/dist/types/NetUtils.d.ts +65 -6
  63. package/dist/types/RateLimiter.d.ts +4 -3
  64. package/dist/types/Server.d.ts +33 -2
  65. package/dist/types/auth/AuthMiddleware.d.ts +35 -4
  66. package/dist/types/database/ConnectionManager.d.ts +18 -0
  67. package/dist/types/database/DatabaseErrors.d.ts +26 -0
  68. package/dist/types/database/MongoRepository.d.ts +21 -2
  69. package/dist/types/database/TypeOrmSupport.d.ts +11 -2
  70. package/dist/types/database/index.d.ts +1 -0
  71. package/dist/types/decorators/PersistenceDecorators.d.ts +31 -0
  72. package/dist/types/decorators/RouteDecorators.d.ts +4 -2
  73. package/dist/types/http/bun/BunRouter.d.ts +23 -2
  74. package/dist/types/http/index.d.ts +2 -1
  75. package/dist/types/http/session/sessionMiddleware.d.ts +14 -4
  76. package/dist/types/http/types.d.ts +40 -0
  77. package/dist/types/http/uWS/Adapters.d.ts +10 -1
  78. package/dist/types/http/uWS/Router.d.ts +15 -2
  79. package/dist/types/models/ModelUtils.d.ts +96 -1
  80. package/dist/types/models/RepoUtils.d.ts +240 -4
  81. package/dist/types/routes/BasePushRoute.d.ts +5 -0
  82. package/dist/types/routes/CRUDRoute.d.ts +10 -0
  83. package/dist/types/routes/RouteUtils.d.ts +37 -1
  84. package/dist/types/security/ACLUtils.d.ts +68 -7
  85. package/package.json +1 -1
@@ -36,6 +36,25 @@ export interface RepoOperationOptions {
36
36
  export interface RepoCreateOptions extends RepoOperationOptions {
37
37
  /** The AccessControlList to use when creating a new object. */
38
38
  acl?: AccessControlList;
39
+ /**
40
+ * Set to `true` to let a record-level-ACL create adopt an ACL that already exists at the new record's uid even
41
+ * though no record of this model exists there yet and the caller doesn't already hold every creator right on
42
+ * it. The existing ACL is used as-is (the creator is not added to it). Defaults to `false`, in which case such
43
+ * a create is refused with `IDENTIFIER_EXISTS` - see `create()`.
44
+ *
45
+ * For trusted server-side code only (e.g. code that deliberately saves a record's ACL before the record
46
+ * itself) - this must never be derived from a client request.
47
+ */
48
+ allowExistingACL?: boolean;
49
+ /**
50
+ * Set to `true` to keep the `_id` carried by the object being created instead of discarding it. The object is
51
+ * still always inserted, never merged into an existing document: a create whose `_id` is already taken fails
52
+ * with `IDENTIFIER_EXISTS`. Defaults to `false`.
53
+ *
54
+ * For trusted server-side code only (e.g. restoring or migrating documents with their original `_id`) - this
55
+ * must never be derived from a client request.
56
+ */
57
+ preserveId?: boolean;
39
58
  }
40
59
  /**
41
60
  * The available options for the `RepoUtils.delete()` operation.
@@ -73,6 +92,18 @@ export interface RepoFindOptions extends RepoOperationOptions {
73
92
  export interface RepoUpdateOptions<T extends BaseEntity | SimpleEntity> extends RepoOperationOptions {
74
93
  /** The desired version number of the resource to update. */
75
94
  version?: number | string;
95
+ /**
96
+ * Set to `true` to let this update actually write `@ReadOnly` fields instead of unconditionally
97
+ * resetting them back to their existing persisted value. Defaults to `false` (the field is protected),
98
+ * matching this method's behavior prior to this option's introduction.
99
+ *
100
+ * For trusted server-side code only - this must never be derived from a client request. It exists for
101
+ * a caller that legitimately owns a `@ReadOnly` field's lifecycle outside the ordinary create/update
102
+ * path (e.g. a background job or route handler computing and persisting a system-managed value), where
103
+ * the alternative would be bypassing `RepoUtils.update()` entirely (losing its ACL/optimistic-locking/
104
+ * transaction handling) just to change that one field.
105
+ */
106
+ allowReadOnly?: boolean;
76
107
  }
77
108
  /**
78
109
  * @author Jean-Philippe Steinmetz
@@ -95,18 +126,46 @@ export declare class RepoUtils<T extends BaseEntity | SimpleEntity> {
95
126
  constructor(modelClass: any, repo?: Repository<T> | MongoRepository<T>);
96
127
  protected init(): Promise<void>;
97
128
  /**
98
- * Retrieves every uid matching the given (already-built) search query, ignoring any pagination `take`/`page`
99
- * baked into it by `ModelUtils.buildSearchQuery`. Used by `count`/`exists`/`truncate`, which must narrow by
100
- * record-level ACLs against the *entire* matching set rather than a single page of it — applying the default
101
- * `take` here would silently undercount, under-check existence for, or under-delete a large result set.
129
+ * Retrieves the uids matching the given (already-built) search query, ignoring any pagination `take`/`page`
130
+ * baked into it by `ModelUtils.buildSearchQuery`, unless `cap` is given.
131
+ *
132
+ * Without `cap` the whole matching set is returned. Pass `cap` whenever each uid will cost a per-record ACL check
133
+ * on behalf of a client that acts on the records (`truncate()` on a `recordACL` model): otherwise a single anonymous
134
+ * request can trigger unbounded ACL work, and on MongoDB a `distinct` over enough uids exceeds the 16MB reply limit.
135
+ * `count()` must report the true total, so it never uses this - see `countPermittedUids()`.
136
+ *
137
+ * @param cap The maximum number of uids to return.
102
138
  */
103
139
  private findAllUids;
140
+ /**
141
+ * Returns the maximum number of records a client request may have checked individually against record-level
142
+ * ACLs: the `take` that `ModelUtils.buildSearchQuerySQL()` already resolved onto the built SQL query. A MongoDB
143
+ * query carries no `take`, so the same rule is read from `ModelUtils.resolvePagination()`, the helper that
144
+ * documents it for MongoDB callers.
145
+ */
146
+ private recordACLCap;
147
+ /**
148
+ * Determines whether a built search query can match soft-deleted records: every OR branch must pin `deleted` to
149
+ * exactly `false` for it not to. Decided from the compiled query rather than the raw client value, so any
150
+ * spelling of the filter (`true`, `eq(true)`, `in(true,false)`, `ne(false)`, a repeated parameter, a `$or`
151
+ * branch) is recognized. Always `false` for a model that isn't recoverable.
152
+ */
153
+ private queryIncludesDeleted;
104
154
  /**
105
155
  * Filters the given uids down to those the user has `action` permission for, checking in bounded-size
106
156
  * batches rather than a single unbounded `Promise.all` so a large matching set can't fire an unbounded
107
157
  * number of concurrent permission-check round trips at once.
108
158
  */
109
159
  private filterPermittedUids;
160
+ /**
161
+ * Counts the records matched by an (already-built) search query that the caller holds the given permission(s) on,
162
+ * for a `recordACL` model. Unlike `findAllUids()` this is never capped - a count must be the true total - but it
163
+ * never holds the whole matching set either: MongoDB streams uids from a projected cursor instead of a `distinct`
164
+ * (whose single reply is limited to 16MB), and permissions are checked in bounded batches as the uids arrive.
165
+ *
166
+ * @param actions Every action the caller must hold on a record for it to be counted.
167
+ */
168
+ private countPermittedUids;
110
169
  count(query: any, options?: RepoFindOptions): Promise<number>;
111
170
  /**
112
171
  * Determines whether an object with the given unique identifier (and, optionally, a specific version) exists
@@ -153,6 +212,85 @@ export declare class RepoUtils<T extends BaseEntity | SimpleEntity> {
153
212
  * @param options The additional options to consider during the search.
154
213
  */
155
214
  findOne(id: string, options?: RepoFindOptions): Promise<T | undefined>;
215
+ /**
216
+ * Resolves the per-record ACL for a record about to be created by `create()` under `uid`. Returns `uid` when a
217
+ * fresh ACL was created for it (so the caller can clean it up if the create fails), or `undefined` when an
218
+ * existing ACL is legitimately reused as-is.
219
+ *
220
+ * ACLs live in one global collection keyed only by uid, shared by every model and by the class/route ACLs, and a
221
+ * create's uid can come from the client. An ACL that already exists at `uid` may guard a record of another model,
222
+ * a whole model or route, or have been planted there by another user ahead of time - adopting it would hand
223
+ * whoever holds rights on it the new record, or hand the creator whatever it protects. So:
224
+ * - a code-defined ACL uid (a class, route or endpoint ACL, or any `default_*` uid; see
225
+ * `ACLUtils.isReservedUid()`/`isProtectedACL()`) is never claimed or reused, whoever the caller is;
226
+ * - an existing ACL is reused, unchanged, only for a trackChanges "new version" of an existing record
227
+ * (`count > 0`; `create()` has already verified the caller's UPDATE right on that record), or when trusted
228
+ * server code passed `allowExistingACL`. Neither a trusted role nor already holding rights on it is enough.
229
+ *
230
+ * Otherwise the create is refused with `IDENTIFIER_EXISTS`, the same error as a record identifier collision.
231
+ * A genuinely orphaned ACL can't be reliably told apart from one guarding another model's record, so no attempt
232
+ * is made to replace it. A fresh ACL is claimed with `saveACL()`'s `createOnly` mode, so two concurrent creates
233
+ * (of the same or of different models) can't both claim the same uid either.
234
+ */
235
+ private claimRecordACL;
236
+ /**
237
+ * Converts every `Date`-typed property of `obj` that holds a string or number (e.g. an ISO 8601 string from a
238
+ * JSON request body) to a real `Date`, in place. Without this a MongoDB document stores the string itself, and
239
+ * date range queries (which compare against `Date` operands) never match it.
240
+ *
241
+ * A property counts as `Date`-typed when its `@Column` declares an explicit date/time `type`, or otherwise when
242
+ * TypeScript's emitted `design:type` is `Date`. TypeScript reflects a union-typed property (e.g. `Date | null`)
243
+ * as `Object`, so such a property is only converted when its `@Column` sets `type` explicitly. Properties that
244
+ * aren't `@Column`s are never touched.
245
+ *
246
+ * Accepted values (anything else, including a `Date` column holding a boolean or an object, is a 400):
247
+ * - an ISO 8601 date (`YYYY-MM-DD`) or date-time (`YYYY-MM-DDTHH:mm[:ss[.fff]]`, `T` or a space), with a zone
248
+ * designator of `Z`, `±HH`, `±HHmm` or `±HH:mm`. A date-time without a zone is read as UTC, never as the server's
249
+ * local time;
250
+ * - a finite number of epoch milliseconds between years 1 and 9999 whose magnitude is at least `1e11` (a smaller
251
+ * number is ambiguous with epoch seconds). Numeric strings are not accepted;
252
+ * - a `Date`, `null` or `undefined`, which are left as they are.
253
+ *
254
+ * A SQL date-only column (`@Column({ type: "date" })`) is validated as a `YYYY-MM-DD` string but kept as that
255
+ * string: TypeORM writes a `Date` into such a column using the server's local calendar date, so converting it
256
+ * would store the previous day on any server west of UTC. On MongoDB, which has no date-only type, it is
257
+ * converted to midnight UTC like any other date.
258
+ *
259
+ * @param obj The object whose properties to convert.
260
+ * @param clazz The model class describing `obj`.
261
+ * @throws ApiError `INVALID_REQUEST` (400) when a value isn't a valid date.
262
+ */
263
+ protected coerceDateProperties(obj: any, clazz: any): void;
264
+ /**
265
+ * Returns the date-typed `@Column`s of `clazz` (see `coerceDateProperties()`), computed once per class.
266
+ */
267
+ private static getDateColumns;
268
+ /**
269
+ * Parses a client-supplied date value: an ISO 8601 string or a number of epoch milliseconds (see
270
+ * `coerceDateProperties()` for the exact rules). Returns `undefined` for anything else.
271
+ */
272
+ private static parseDateInput;
273
+ /**
274
+ * Strictly parses an ISO 8601 date (`YYYY-MM-DD`) or, unless `dateOnly`, date-time string, rejecting impossible
275
+ * calendar values (e.g. `2026-02-30`) that `new Date()` would silently roll over. A date-time without a zone is
276
+ * read as UTC.
277
+ */
278
+ private static parseISODate;
279
+ /**
280
+ * Rejects update input with a top-level key that MongoDB would interpret as something other than a plain field
281
+ * name: a dotted path (`"aliases.3"`, which writes a nested element) or an operator (`"$inc"`). Such a key
282
+ * bypasses route/model validation (which only knows the model's real property names), so it is refused on
283
+ * every backend rather than passed through.
284
+ *
285
+ * @throws ApiError `INVALID_REQUEST` (400) naming the first offending key.
286
+ */
287
+ private assertPlainPropertyNames;
288
+ /**
289
+ * Determines whether `existing` is under optimistic locking: a `BaseEntity` instance or - for a `BaseEntity`
290
+ * model - any object carrying a numeric `version`, such as a plain document read straight from a
291
+ * `MongoRepository` (whose `find()`/`findOne()` return plain documents, not model instances).
292
+ */
293
+ private isVersioned;
156
294
  /**
157
295
  * Returns the default access control list governing the model type. Returning a value of `undefined` will grant
158
296
  * full acccess to any user (including unauthenticated anonymous users).
@@ -208,6 +346,104 @@ export declare class RepoUtils<T extends BaseEntity | SimpleEntity> {
208
346
  searchIdQuery(id: string, version?: number | string, includeDeleted?: boolean): any;
209
347
  truncate(query: any, options: RepoFindOptions): Promise<void>;
210
348
  update(obj: Partial<T>, existing: T, options?: RepoUpdateOptions<T>): Promise<T>;
349
+ /**
350
+ * Maps an error thrown by one of `update()`'s writes: a duplicate key on the record's identity (`_id`/primary key
351
+ * or `(uid, version)`) is a lost optimistic-lock race (409 `INVALID_OBJECT_VERSION`), and a duplicate value of any
352
+ * other unique column is a 400 `IDENTIFIER_EXISTS`. Any other error is returned as is.
353
+ *
354
+ * @param err The error thrown by the write.
355
+ * @param versionInsert Whether the write inserted a new version (`trackChanges`), which makes an unidentified
356
+ * duplicate key most likely a version clash.
357
+ */
358
+ private mapUpdateWriteError;
359
+ /**
360
+ * Counts the stored rows/documents (every version, deleted or not) whose identifier matches `id`, ignoring ACLs.
361
+ *
362
+ * @param id The identifier, or identifiers, to match.
363
+ * @param txInfo The active transaction, if any.
364
+ * @param clazz The model class to build the identifier query for.
365
+ */
366
+ private countById;
367
+ /**
368
+ * Reads the stored version of `existing` straight from the database, without ACL checks, caching or scoped
369
+ * property removal. Used by `update()` when `existing` lacks fields it must carry over.
370
+ */
371
+ private loadStoredRecord;
372
+ /** Matches a whole-word `me` anywhere in a serialized query (see `find()`'s cache key). */
373
+ private static readonly REGEX_ME;
374
+ /**
375
+ * Returns whether this model keeps every version of a record as its own immutable row/document (`trackChanges`),
376
+ * which is what makes a version-specific cache entry safe to keep until it expires.
377
+ */
378
+ private get versionsAreImmutable();
379
+ /** Parses a `version` option the way `searchIdQuery()` does. */
380
+ private parseVersion;
381
+ /**
382
+ * Returns the cache key of a single record: its latest version, or (only for a model whose versions are immutable)
383
+ * a specific version. Returns `undefined` for a specific version of any other model, which is never cached: its
384
+ * entry would outlive the in-place update that replaces that version.
385
+ *
386
+ * Record keys (`rec:`) and query result keys (`q:`) are distinct namespaces, so no client-chosen uid can ever name
387
+ * a query result entry (or the other way around).
388
+ */
389
+ private recordCacheKey;
390
+ /** Returns the cache key of a `find()` result for the given (JSON-serializable) key material. */
391
+ private queryCacheKey;
392
+ /**
393
+ * Determines whether a cached record really is the one identified by `id` (and `version`, when given): one of the
394
+ * model's identifier properties must hold `id`. Guards against a cache entry written under a colliding key.
395
+ */
396
+ private matchesId;
397
+ /**
398
+ * Returns a shallow copy of `record` with the same prototype, for handing out (or stripping) without altering the
399
+ * object a cache holds.
400
+ */
401
+ private copyRecord;
402
+ /**
403
+ * Stores a copy of `record` in the cache under its latest-version key, and under its version key when versions are
404
+ * immutable. Fire-and-forget: a cache failure never fails the write.
405
+ */
406
+ private cacheRecord;
407
+ /**
408
+ * Caches a page of `find()` results: each record under its own record key, and the query key as the list of those
409
+ * records' `[uid, version]` references. A record of a model whose versions are immutable is referenced by its
410
+ * version (a list may hold past versions); any other record by its latest-version key, which `update()` refreshes
411
+ * and `delete()` removes, so a cached list never serves a stale or deleted record.
412
+ */
413
+ private cacheResults;
414
+ /**
415
+ * Loads a cached page of `find()` results (see `cacheResults()`). Records that have since expired, been deleted, or
416
+ * whose entry isn't the referenced record are left out; an empty array means nothing usable was cached.
417
+ */
418
+ private loadCachedResults;
419
+ /**
420
+ * Returns the version-specific cache keys of every stored version of the given records (only for a model whose
421
+ * versions are cached individually; otherwise none). Must be called before the records are removed.
422
+ */
423
+ private versionCacheKeys;
424
+ /**
425
+ * Removes the cached entries of the given records: their latest-version keys plus `versionKeys` (see
426
+ * `versionCacheKeys()`). Fire-and-forget: a cache failure never fails the write.
427
+ */
428
+ private uncacheRecords;
429
+ /**
430
+ * Returns whether this is the ACL model itself (served by e.g. a `BaseACLRoute`), whose documents `ACLUtils`
431
+ * caches separately under their uids.
432
+ */
433
+ private get isACLModel();
434
+ /**
435
+ * After a write to the ACL model, drops `ACLUtils`' cached copies of the written ACLs so permission checks see the
436
+ * change right away (they would otherwise keep using the old ACL until its cache entry expired). A failure is
437
+ * logged rather than failing the write, which has already happened.
438
+ */
439
+ private invalidateACLCache;
440
+ /**
441
+ * Sends a push notification about a write, unless `skipPush` is set. The payload is a copy with every
442
+ * `@RequiresScope` property removed: subscribers only need READ on the record's channel, and the writer's scopes
443
+ * say nothing about theirs, so no scoped value is ever published. Consumers that need a scoped value must fetch
444
+ * the record, which applies their own scopes.
445
+ */
446
+ private publish;
211
447
  /**
212
448
  * Performs validation on the object(s) provided. This function first calls `ObjectUtils.validate()` to check
213
449
  * any class level defined validation functions. Second, it scans for any properties with the `@Reference`
@@ -48,6 +48,11 @@ export declare class BasePushRoute {
48
48
  * clobber (or exceed the cap on) each other's committed subscription list.
49
49
  */
50
50
  private runExclusive;
51
+ /**
52
+ * Returns `true` if the given socket is closing or closed. Works for the `ws` library's sockets as well as the
53
+ * uWS and Bun shims, which all use the WebSocket `readyState` values (2 = CLOSING, 3 = CLOSED).
54
+ */
55
+ private isSocketClosed;
51
56
  connect(sock: ws, user: any): Promise<void>;
52
57
  send(id: string, msg: any, user: any): Promise<void>;
53
58
  }
@@ -38,6 +38,16 @@ export declare abstract class CRUDRoute<T extends BaseEntity | SimpleEntity> ext
38
38
  */
39
39
  protected validateCreate(obj: Partial<T>, user?: JWTUser): Promise<void>;
40
40
  private validateCreateBulk;
41
+ /**
42
+ * Returns `err` if it is an `ApiError`, otherwise a generic `INVALID_REQUEST` error. A validator's `ApiError` is
43
+ * meant for the client, but any other error (e.g. a driver error from a custom validator) may expose internals.
44
+ */
45
+ private static toValidationError;
46
+ /**
47
+ * Throws a `BulkError` when at least one object in a bulk request failed validation. The `BulkError` holds one
48
+ * entry per object, in request order: `null` for an object that passed, otherwise the reason it failed.
49
+ */
50
+ private static throwBulkValidationErrors;
41
51
  create(obj: T | T[], req: HttpRequest, user?: JWTUser): Promise<T | Array<T>>;
42
52
  delete(id: string, version: string | undefined, purge: string | undefined, req: HttpRequest, user?: JWTUser): Promise<void>;
43
53
  exists(id: string, query: any, res: HttpResponse, user?: JWTUser): Promise<any>;
@@ -1,4 +1,4 @@
1
- import type { RequestHandler } from "../http/types.js";
1
+ import type { HttpRequest, RequestHandler } from "../http/types.js";
2
2
  import { RateLimiter } from "../RateLimiter.js";
3
3
  import { RateLimitOptions } from "../decorators/RouteDecorators.js";
4
4
  /**
@@ -13,13 +13,37 @@ export declare class RouteUtils {
13
13
  private logger?;
14
14
  protected rateLimiter?: RateLimiter;
15
15
  protected trustedRoles: string[];
16
+ protected trustedProxies: string[];
16
17
  /**
17
18
  * Creates a middleware function that checks if the user has elevated privileges and if not
18
19
  * throws the `AUTH_REQUIRES_ELEVATION` error.
19
20
  */
20
21
  checkElevation(lastStart?: number): RequestHandler;
22
+ /**
23
+ * Returns a canonical form of a request path for use in rate limit identifiers. The router percent-decodes
24
+ * `:param` values, so `/clients/%61bc` and `/clients/abc` reach the same handler. Keying on the raw path would
25
+ * give an attacker a separate bucket for every encoding variant. Each segment is decoded and then re-encoded
26
+ * (a malformed segment is kept as is), and empty segments from repeated or trailing slashes are dropped.
27
+ *
28
+ * @param path The request path to normalize.
29
+ */
30
+ static normalizeRateLimitPath(path: string): string;
31
+ /**
32
+ * Returns the path part of a rate limit identifier for `req`. When the router has set `req.routePattern`, the
33
+ * pattern is used with each `:param` segment replaced by its decoded value from `req.params`, re-encoded. A
34
+ * pattern with a `*` wildcard can't be rebuilt from params, so it and a request without a pattern fall back to
35
+ * `normalizeRateLimitPath(req.path)`.
36
+ *
37
+ * @param req The request to build the identifier path for.
38
+ */
39
+ static getRateLimitPath(req: HttpRequest): string;
21
40
  /**
22
41
  * Creates a middleware function that performs rate limiting on the request.
42
+ *
43
+ * The identifier is `options.id` when set. Otherwise it is the request method plus the matched route pattern with
44
+ * its decoded params, or the normalized request path (see `getRateLimitPath()`). With `options.perUser`, an authenticated caller gets a bucket keyed on their
45
+ * uid and an anonymous caller gets a bucket keyed on their client IP address, so one anonymous client can't
46
+ * exhaust the limit for every other anonymous caller.
23
47
  */
24
48
  checkRateLimiter(options: RateLimitOptions): RequestHandler;
25
49
  /**
@@ -63,6 +87,18 @@ export declare class RouteUtils {
63
87
  * @returns The list of `@Method` decorated functions that were found.
64
88
  */
65
89
  getRouteMethods(route: any): Map<string, any>;
90
+ /**
91
+ * Persists a `@Protect` ACL declared on a route class or handler.
92
+ *
93
+ * Registration fails closed. If the ACL can't be saved (for example the `acl` datastore is unavailable at
94
+ * startup), the error is rethrown so the route is never registered. If it saves but `saveDefaultACL()`
95
+ * returns no user-editable ACL, an error is logged. The route is still registered with its permission check
96
+ * keyed on the declared uid, and `ACLUtils.checkRequestPerms()` denies every request until that ACL exists.
97
+ *
98
+ * @param aclUtils The ACL utilities to save with.
99
+ * @param acl The ACL declared by `@Protect`.
100
+ */
101
+ private saveRouteACL;
66
102
  /**
67
103
  * Registers the provided route object containing a set of decorated endpoints to the server.
68
104
  *
@@ -13,6 +13,11 @@ export declare class ACLUtils {
13
13
  private cache?;
14
14
  private connMgr?;
15
15
  private logger?;
16
+ /**
17
+ * The uids of every code-defined (class, route and endpoint) ACL registered via `saveDefaultACL()` in this
18
+ * process, together with their `default_<uid>` counterparts. See `isReservedUid()`.
19
+ */
20
+ private reservedUids;
16
21
  private trustedRoles;
17
22
  private get repo();
18
23
  private init;
@@ -25,6 +30,30 @@ export declare class ACLUtils {
25
30
  * match, or `"none"` if the user doesn't match this id at all.
26
31
  */
27
32
  private matchSpecificity;
33
+ /**
34
+ * Determines whether `uid` names a code-defined ACL: any `default_<uid>` ACL, or a class/route/endpoint ACL (or
35
+ * its `default_` counterpart) registered through `saveDefaultACL()`. Such an ACL governs a whole model or route,
36
+ * so a record must never be created at, adopt, or remove it (see `RepoUtils.create()`/`delete()`/`truncate()`).
37
+ *
38
+ * @param uid The ACL uid to check.
39
+ */
40
+ isReservedUid(uid: string | undefined): boolean;
41
+ /**
42
+ * Determines whether `acl` is a code-defined ACL: its uid is reserved (see `isReservedUid()`), or it has the
43
+ * shape of the user-editable half of a default ACL (its parent is `default_<uid>`). The latter also catches one
44
+ * registered by a route or model that this process doesn't load.
45
+ *
46
+ * @param acl The ACL to check.
47
+ */
48
+ isProtectedACL(acl: AccessControlList | undefined | null): boolean;
49
+ /**
50
+ * Drops the cached copies of the given ACLs, so the next permission check reads them from the database. Any code
51
+ * that writes ACL documents without going through `saveACL()`/`removeACL()` must call this (e.g. `RepoUtils`
52
+ * serving the ACL REST routes).
53
+ *
54
+ * @param uids The uids of the ACLs to invalidate.
55
+ */
56
+ invalidateACLs(uids: string[]): Promise<void>;
28
57
  /**
29
58
  * Validates that the user has permission to perform the request operation against the URL path for the
30
59
  * provided request. If ACLUtils has not been initialized or the `acl` datasource has not been configured
@@ -55,11 +84,13 @@ export declare class ACLUtils {
55
84
  * @param entityId The unique identifier of the ACL to retrieve.
56
85
  * @param parentUids The list of already found parent UIDs. This is used to break circular dependencies.
57
86
  * @param options Set `skipCache: true` to bypass the cache and always read the current database state —
58
- * mirrors `RepoUtils`'s `RepoFindOptions.skipCache`. The result is still written back into the cache
59
- * afterward, same as a normal (non-skipped) lookup.
87
+ * mirrors `RepoUtils`'s `RepoFindOptions.skipCache` (applies to the whole parent chain). A database read is
88
+ * written back into the cache afterward. Set `skipParents: true` to not populate the parent chain at all, when
89
+ * only the ACL itself is needed.
60
90
  */
61
91
  findACL(entityId: string, parentUids?: string[], options?: {
62
92
  skipCache?: boolean;
93
+ skipParents?: boolean;
63
94
  }): Promise<AccessControlList | undefined>;
64
95
  /**
65
96
  * Deletes the ACL with the given identifier from the database, returning the document that was actually
@@ -77,9 +108,16 @@ export declare class ACLUtils {
77
108
  * actually belongs to the same physical connection this repo does. Reusing it directly used to throw
78
109
  * (Mongo) or silently target the wrong connection (SQL).
79
110
  *
111
+ * Pass `unlessProtected: true` to leave a code-defined ACL (see `isProtectedACL()`) in place, resolving to
112
+ * `undefined` as if there had been nothing to remove. `RepoUtils` uses this when cleaning up after a record, so a
113
+ * record that shares a class/route ACL's uid can never delete that ACL.
114
+ *
80
115
  * @param uid The unique identifier of the ACL to remove.
116
+ * @param options Set `unlessProtected: true` to never remove a code-defined ACL.
81
117
  */
82
- removeACL(uid: string): Promise<AccessControlList | undefined>;
118
+ removeACL(uid: string, options?: {
119
+ unlessProtected?: boolean;
120
+ }): Promise<AccessControlList | undefined>;
83
121
  /**
84
122
  * Compares two ACLs to see if they have been modified and returns the total number of changes between them.
85
123
  *
@@ -106,14 +144,27 @@ export declare class ACLUtils {
106
144
  * need the entity-side write and this ACL save to stay consistent should register a compensating action
107
145
  * via `registerRollbackHook()`.
108
146
  *
147
+ * Pass `createOnly: true` to claim a brand new ACL: it is only ever inserted (at version `0`), never merged
148
+ * into or overwritten on top of an ACL that already exists at the same uid. If one already exists - including
149
+ * one a concurrent caller inserted first, caught by the ACL collection's unique key - an `IDENTIFIER_EXISTS`
150
+ * `ApiError` (400) is thrown instead. Used by `RepoUtils.create()` so that a create can never adopt (and grant
151
+ * its creator rights on) an ACL that belongs to some other record.
152
+ *
109
153
  * @param acl The ACL to store.
110
154
  * @param options Set `preserveVersion: true` to restore `acl` exactly as given (see above) instead of the
111
- * normal optimistic-locking update semantics.
155
+ * normal optimistic-locking update semantics. Set `createOnly: true` to only ever insert a new ACL (see above).
112
156
  * @return Returns the ACL that was stored in the database.
113
157
  */
114
158
  saveACL(acl: AccessControlList, options?: {
115
159
  preserveVersion?: boolean;
160
+ createOnly?: boolean;
116
161
  }): Promise<AccessControlList | null>;
162
+ /**
163
+ * Implements `saveACL()`'s `createOnly` mode: inserts `acl` as a brand new version `0` document, refusing with
164
+ * `IDENTIFIER_EXISTS` if an ACL already exists at its uid (checked up front, and again via the unique key if a
165
+ * concurrent insert wins the race in between).
166
+ */
167
+ private insertNewACL;
117
168
  /**
118
169
  * Stores the given default access control list into the ACL database. A default ACL is a special type of ACL
119
170
  * that is primarily defined and maintained within the code but allows for user-specific overrides. To accomplish
@@ -123,8 +174,15 @@ export declare class ACLUtils {
123
174
  * overrides. As the `default_<uid>` record is always overwritten with the lastest version of the code, any
124
175
  * user-defined changes made to it are lost on service restart.
125
176
  *
126
- * @param defaultAcl
127
- * @returns
177
+ * Both uids are reserved (see `isReservedUid()`), so no record can be created at, adopt or remove either ACL.
178
+ *
179
+ * Contract: when RBAC is enabled and `acl` is given, this either resolves to the user-editable ACL (whose `uid`
180
+ * is `acl.uid`) or throws; it never resolves to `null` then. A missing user-editable ACL (e.g. one deleted since
181
+ * the last start) is recreated, with no records, under `default_<uid>`. It resolves to `null` only when RBAC is
182
+ * disabled or no `acl` is given, and then stores nothing.
183
+ *
184
+ * @param acl The code-defined ACL to store.
185
+ * @returns The user-editable ACL, or `null` when RBAC is disabled or no ACL was given.
128
186
  */
129
187
  saveDefaultACL(acl: AccessControlList): Promise<AccessControlList | null>;
130
188
  /**
@@ -138,8 +196,11 @@ export declare class ACLUtils {
138
196
  * would issue overlapping commands against one session, which drivers reject or misorder.
139
197
  *
140
198
  * @param uids The unique identifiers of the ACLs to remove.
199
+ * @param options Set `unlessProtected: true` to never remove a code-defined ACL (see `removeACL()`).
141
200
  */
142
- removeACLs(uids: string[]): Promise<AccessControlList[]>;
201
+ removeACLs(uids: string[], options?: {
202
+ unlessProtected?: boolean;
203
+ }): Promise<AccessControlList[]>;
143
204
  /**
144
205
  * Atomic save of multiple ACLs in a single `acl`-scoped transaction (see `Transactional`). Used to restore
145
206
  * a batch of ACL snapshots — e.g. by a `registerRollbackHook()` compensating action — if the entity-side
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidrest/service-core",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Provides all core functionality for RapidREST based backend services.",
5
5
  "repository": "https://github.com/rapidrest/service-core.git",
6
6
  "author": "RapidREST <rapidrests@gmail.com>",