@vsaas/loopback 12.1.1 → 12.2.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 (117) hide show
  1. package/README.md +6 -0
  2. package/common/models/rate-limit-state.json +121 -0
  3. package/common/models/two-factor-challenge.json +112 -0
  4. package/common/models/two-factor-credential.json +117 -0
  5. package/common/models/two-factor-recovery-code.json +63 -0
  6. package/common/models/user.json +30 -0
  7. package/dist/_virtual/_rolldown/runtime.cjs +9 -2
  8. package/dist/common/models/access-token.d.cts +3 -0
  9. package/dist/common/models/acl.cjs +1 -3
  10. package/dist/common/models/acl.d.cts +1 -0
  11. package/dist/common/models/application.d.cts +3 -0
  12. package/dist/common/models/change.cjs +21 -18
  13. package/dist/common/models/change.d.cts +1 -0
  14. package/dist/common/models/checkpoint.d.cts +3 -0
  15. package/dist/common/models/email.d.cts +3 -0
  16. package/dist/common/models/key-value-model.d.cts +3 -0
  17. package/dist/common/models/rate-limit-state.cjs +7 -0
  18. package/dist/common/models/rate-limit-state.d.cts +3 -0
  19. package/dist/common/models/rate-limit-state2.cjs +109 -0
  20. package/dist/common/models/role-mapping.d.cts +3 -0
  21. package/dist/common/models/role.cjs +12 -8
  22. package/dist/common/models/role.d.cts +1 -0
  23. package/dist/common/models/scope.d.cts +3 -0
  24. package/dist/common/models/two-factor-challenge.cjs +7 -0
  25. package/dist/common/models/two-factor-challenge.d.cts +3 -0
  26. package/dist/common/models/two-factor-challenge2.cjs +108 -0
  27. package/dist/common/models/two-factor-credential.cjs +7 -0
  28. package/dist/common/models/two-factor-credential.d.cts +3 -0
  29. package/dist/common/models/two-factor-credential2.cjs +103 -0
  30. package/dist/common/models/two-factor-recovery-code.cjs +7 -0
  31. package/dist/common/models/two-factor-recovery-code.d.cts +3 -0
  32. package/dist/common/models/two-factor-recovery-code2.cjs +57 -0
  33. package/dist/common/models/user.cjs +258 -21
  34. package/dist/common/models/user.d.cts +3 -0
  35. package/dist/common/models/user2.cjs +30 -0
  36. package/dist/index.cjs +2 -0
  37. package/dist/index.d.cts +3 -0
  38. package/dist/lib/access-context.d.cts +33 -0
  39. package/dist/lib/application.d.cts +1 -0
  40. package/dist/lib/authentication-security-initializer.cjs +351 -0
  41. package/dist/lib/authentication-security-initializer.d.cts +4 -0
  42. package/dist/lib/builtin-models.cjs +12 -0
  43. package/dist/lib/builtin-models.d.cts +3 -0
  44. package/dist/lib/configure-shared-methods.cjs +7 -4
  45. package/dist/lib/configure-shared-methods.d.cts +3 -0
  46. package/dist/lib/connectors/base-connector.d.cts +6 -0
  47. package/dist/lib/connectors/mail-direct-transport.cjs +26 -23
  48. package/dist/lib/connectors/mail-direct-transport.d.cts +22 -0
  49. package/dist/lib/connectors/mail-stub-transport.d.cts +14 -0
  50. package/dist/lib/connectors/mail.d.cts +7 -0
  51. package/dist/lib/connectors/memory.d.cts +6 -0
  52. package/dist/lib/current-context.d.cts +3 -0
  53. package/dist/lib/globalize.d.cts +10 -0
  54. package/dist/lib/loopback.cjs +6 -0
  55. package/dist/lib/loopback.d.cts +1 -0
  56. package/dist/lib/model.d.cts +1 -0
  57. package/dist/lib/persisted-model.cjs +7 -12
  58. package/dist/lib/persisted-model.d.cts +1 -0
  59. package/dist/lib/rate-limit/algorithms/common.cjs +37 -0
  60. package/dist/lib/rate-limit/algorithms/failure-lockout.cjs +70 -0
  61. package/dist/lib/rate-limit/algorithms/fixed-window.cjs +61 -0
  62. package/dist/lib/rate-limit/algorithms/index.cjs +55 -0
  63. package/dist/lib/rate-limit/config.cjs +107 -0
  64. package/dist/lib/rate-limit/errors.cjs +58 -0
  65. package/dist/lib/rate-limit/index.cjs +15 -0
  66. package/dist/lib/rate-limit/index.d.cts +1 -0
  67. package/dist/lib/rate-limit/initializer.cjs +92 -0
  68. package/dist/lib/rate-limit/initializer.d.cts +4 -0
  69. package/dist/lib/rate-limit/limiter.cjs +368 -0
  70. package/dist/lib/rate-limit/registry.cjs +92 -0
  71. package/dist/lib/rate-limit/remote.cjs +180 -0
  72. package/dist/lib/rate-limit/remote.d.cts +6 -0
  73. package/dist/lib/rate-limit/stores/persisted-model.cjs +353 -0
  74. package/dist/lib/rate-limit/subject.cjs +127 -0
  75. package/dist/lib/rate-limit/types.cjs +1 -0
  76. package/dist/lib/rate-limit/types.d.cts +167 -0
  77. package/dist/lib/registry.d.cts +1 -0
  78. package/dist/lib/runtime.d.cts +10 -0
  79. package/dist/lib/server-app.d.cts +3 -0
  80. package/dist/lib/two-factor/config.cjs +199 -0
  81. package/dist/lib/two-factor/config.d.cts +55 -0
  82. package/dist/lib/two-factor/crypto.cjs +68 -0
  83. package/dist/lib/two-factor/crypto.d.cts +16 -0
  84. package/dist/lib/two-factor/errors.cjs +58 -0
  85. package/dist/lib/two-factor/errors.d.cts +18 -0
  86. package/dist/lib/two-factor/rate-limit.cjs +153 -0
  87. package/dist/lib/two-factor/rate-limit.d.cts +22 -0
  88. package/dist/lib/two-factor/recovery-codes.cjs +49 -0
  89. package/dist/lib/two-factor/recovery-codes.d.cts +16 -0
  90. package/dist/lib/two-factor/service.cjs +1108 -0
  91. package/dist/lib/two-factor/service.d.cts +78 -0
  92. package/dist/lib/two-factor/totp.cjs +50 -0
  93. package/dist/lib/two-factor/totp.d.cts +14 -0
  94. package/dist/lib/utils.d.cts +36 -0
  95. package/dist/package.cjs +46 -10
  96. package/dist/server/middleware/context.d.cts +3 -0
  97. package/dist/server/middleware/error-handler.d.cts +3 -0
  98. package/dist/server/middleware/favicon.d.cts +7 -0
  99. package/dist/server/middleware/rest.d.cts +6 -0
  100. package/dist/server/middleware/static.d.cts +3 -0
  101. package/dist/server/middleware/status.d.cts +19 -0
  102. package/dist/server/middleware/token.d.cts +3 -0
  103. package/dist/server/middleware/url-not-found.d.cts +13 -0
  104. package/docs/api-explorer-details.md +110 -0
  105. package/docs/application-guide.md +1110 -0
  106. package/docs/assets/explorer-api.png +0 -0
  107. package/docs/assets/explorer-endpoint.png +0 -0
  108. package/docs/assets/explorer-listing.png +0 -0
  109. package/docs/assets/explorer-req-res.png +0 -0
  110. package/docs/assets/lb-modules.png +0 -0
  111. package/docs/assets/loopback-architecture.png +0 -0
  112. package/docs/assets/loopback-concepts.png +0 -0
  113. package/docs/assets/loopback_ov.png +0 -0
  114. package/docs/rate-limiting.md +879 -0
  115. package/docs/two-factor-authentication.md +611 -0
  116. package/docs/two-factor-authenticator-loss-runbook.md +117 -0
  117. package/package.json +56 -20
@@ -0,0 +1,1110 @@
1
+ # Application Guide
2
+
3
+ This guide shows how to build LoopBack 3 style applications on top of the
4
+ `@vsaas/*` forks in this monorepo using the promise-capable APIs provided by
5
+ the packages.
6
+
7
+ ## Package roles
8
+
9
+ - `@vsaas/loopback`: application factory, REST middleware, built-in models,
10
+ ACL integration, and remote method helpers
11
+ - `@vsaas/loopback-boot`: loads datasources, models, mixins, components, and
12
+ boot scripts from a conventional app layout
13
+ - `@vsaas/loopback-datasource-juggler`: model definitions, relations,
14
+ persistence, validation, and LoopBack filters
15
+ - `@vsaas/remoting`: the remoting layer behind REST endpoints and custom remote
16
+ methods
17
+ - `@vsaas/loopback-component-storage`: optional filesystem and S3-compatible
18
+ storage component
19
+ - `@vsaas/loopback-softdelete-mixin4`: optional soft-delete mixin
20
+
21
+ ## Bootstrap an application
22
+
23
+ `@vsaas/loopback-boot` is promise-capable, so the normal app bootstrap can use
24
+ `async/await` directly.
25
+
26
+ ```js
27
+ const loopback = require('@vsaas/loopback');
28
+ const boot = require('@vsaas/loopback-boot');
29
+
30
+ async function createApp() {
31
+ const app = loopback();
32
+
33
+ await boot(app, __dirname);
34
+
35
+ app.use('/api', loopback.rest());
36
+ return app;
37
+ }
38
+
39
+ module.exports = createApp;
40
+ ```
41
+
42
+ Applications using two-factor authentication or authentication rate limiting
43
+ must initialize authentication security after boot has connected all models
44
+ and before the process starts listening:
45
+
46
+ ```js
47
+ async function createApp() {
48
+ const app = loopback();
49
+
50
+ await boot(app, __dirname);
51
+ await app.initializeAuthenticationSecurity();
52
+
53
+ app.use('/api', loopback.rest());
54
+ return app;
55
+ }
56
+
57
+ createApp()
58
+ .then((app) => app.listen(3000))
59
+ .catch((error) => {
60
+ console.error(error);
61
+ process.exitCode = 1;
62
+ });
63
+ ```
64
+
65
+ The initializer is idempotent and supports both Promise and callback forms.
66
+ Authentication configured on a `User` model fails closed with
67
+ `AUTHENTICATION_SECURITY_UNAVAILABLE` until initialization succeeds.
68
+ Applications using only general rate limiting call
69
+ `app.initializeRateLimiting()` at the same point instead.
70
+
71
+ ## General rate limiting
72
+
73
+ See [Rate limiting](./rate-limiting.md) for the complete option reference,
74
+ duration units, remote and programmatic rule contracts, error behavior,
75
+ cleanup, and key rotation. This section is a compact application example.
76
+
77
+ Configure infrastructure once per application. Subject values are HMACed
78
+ before persistence; provide at least 32 bytes of random key material.
79
+
80
+ ```js
81
+ app.set('rateLimit', {
82
+ limits: {
83
+ maxCostPerConsume: 100000,
84
+ maxBatchEntries: 1000,
85
+ },
86
+ subjectKeyrings: {
87
+ default: {
88
+ currentKeyId: 'v1',
89
+ keys: {
90
+ v1: process.env.RATE_LIMIT_HMAC_KEY_V1,
91
+ },
92
+ },
93
+ },
94
+ stores: {
95
+ api: {
96
+ provider: 'persisted-model',
97
+ model: 'RateLimitState',
98
+ namespace: 'my-api-production',
99
+ },
100
+ },
101
+ });
102
+ ```
103
+
104
+ `RateLimitState` must be attached to a shared durable datasource and configured
105
+ as `public: false`. The memory connector is rejected unless
106
+ `allowMemory: true` is explicitly set for tests. A Redis provider is not
107
+ bundled; applications can register another provider before initialization with
108
+ `app.registerRateLimitProvider(name, factory)`.
109
+
110
+ The bundled provider is the persisted-model MVP. Redis delivery is explicitly
111
+ outside the bundled release scope; deployments that require Redis must supply
112
+ and integration-test a provider implementing the same atomic store contract.
113
+ At startup, the persisted provider runs a short create/update/stale-update/delete
114
+ probe and refuses readiness when the connector does not provide conditional
115
+ compare-and-set semantics.
116
+
117
+ Named programmatic rules can derive the limit, time window, subject, and cost
118
+ from domain context without knowing anything about persistence:
119
+
120
+ ```js
121
+ app.rateLimiter.defineRule({
122
+ id: 'sensor-data-write',
123
+ algorithm: 'fixed-window',
124
+ store: 'api',
125
+ subjectKeyring: 'default',
126
+ bucket: 'sensor-data-write',
127
+ onStoreError: 'deny',
128
+
129
+ subject({ sensor }) {
130
+ return {
131
+ namespace: 'my-api-production',
132
+ scope: 'sensor',
133
+ subjectType: 'sensor',
134
+ canonicalValue: String(sensor.id),
135
+ };
136
+ },
137
+
138
+ async limit({ sensor }) {
139
+ const policy = await resolveSensorPolicy(sensor);
140
+ if (!policy.enabled) return null;
141
+ return {
142
+ limit: policy.maxDataPoints,
143
+ windowMs: policy.windowMs,
144
+ blockDurationMs: policy.windowMs,
145
+ revision: String(policy.version),
146
+ };
147
+ },
148
+
149
+ cost({ dataPoints }) {
150
+ return dataPoints.length;
151
+ },
152
+ });
153
+ ```
154
+
155
+ An individual upload and a bulk `/Data` endpoint should use the same rule,
156
+ bucket, and sensor subject. For a validated, deduplicated batch:
157
+
158
+ ```js
159
+ const decisions = await app.rateLimiter.consumeBatch(
160
+ 'sensor-data-write',
161
+ sensorGroups.map((group) => ({
162
+ entryId: group.sensorId,
163
+ context: {
164
+ sensor: group.sensor,
165
+ dataPoints: group.data,
166
+ },
167
+ })),
168
+ );
169
+ ```
170
+
171
+ Repeated sensor IDs are grouped into one atomic cost. Results retain input
172
+ order. One denied sensor does not roll back or deny unrelated sensor groups.
173
+ Validate and deduplicate data before calculating cost.
174
+
175
+ Remote methods can use named static policies:
176
+
177
+ ```js
178
+ app.set('rateLimitPolicies', {
179
+ faceSearch: {
180
+ policyId: 'face-search',
181
+ policyVersion: '1',
182
+ algorithm: 'fixed-window',
183
+ maxAttempts: 3,
184
+ observationWindow: 60,
185
+ blockDuration: 60,
186
+ stateTtl: 3600,
187
+ },
188
+ });
189
+
190
+ Face.remoteMethod('search', {
191
+ accepts: [],
192
+ returns: { arg: 'result', type: 'object', root: true },
193
+ http: { verb: 'post' },
194
+ rateLimit: {
195
+ policy: 'faceSearch',
196
+ store: 'api',
197
+ subjectKeyring: 'default',
198
+ onStoreError: 'deny',
199
+ },
200
+ });
201
+ ```
202
+
203
+ For credential, tenant, source, or resource rules, register a resolver before
204
+ initialization and reference its name in `rateLimit.resolver`. It receives the
205
+ model, logical method name, normalized arguments, request, access token, and
206
+ `req.ip`; it returns one or more fully resolved rules. Returning several rules
207
+ composes them without allowing a later result to overwrite an earlier denial.
208
+
209
+ HTTP denials use `429 RATE_LIMITED`, `Retry-After`, and
210
+ `Cache-Control: no-store`. Store failures use `503 RATE_LIMIT_UNAVAILABLE`.
211
+ Programmatic callers receive the same typed errors.
212
+
213
+ Use `app.cleanupRateLimitStates()` from a scheduled job to remove expired
214
+ PersistedModel state. When rotating the subject HMAC, add the new key, make it
215
+ current, retain old keys for at least the longest state TTL, and remove them
216
+ only after old state expires. Changing namespace, store, policy semantics, or
217
+ policy version is a coordinated migration, not a hot configuration change.
218
+ Static rules and named remote policies are snapshotted during registration or
219
+ startup. A dynamic rule must change its `revision` whenever its effective
220
+ limit, window, block duration, or state TTL changes; reusing a revision with a
221
+ different fingerprint fails closed.
222
+
223
+ ## Two-factor authentication
224
+
225
+ See [Two-factor authentication](./two-factor-authentication.md) for the complete
226
+ configuration reference, authentication rate-limit defaults, endpoint
227
+ workflows, errors, cleanup, and key rotation. This section is a compact
228
+ application example.
229
+
230
+ Two-factor settings belong to each concrete `User` model. Supported modes are:
231
+
232
+ - setting absent: legacy behavior;
233
+ - `disabled`: installation mode, allowed only with no enabled credentials;
234
+ - `enroll-and-verify`: enrollment and verification enabled;
235
+ - `verify-only`: safe rollback that stops enrollment but continues enforcing
236
+ existing credentials.
237
+
238
+ ```js
239
+ SecureUser.settings.twoFactor = {
240
+ mode: 'enroll-and-verify',
241
+ issuer: 'Example',
242
+ challengeTtl: 300,
243
+ verificationLeaseTtl: 30,
244
+ enrollmentTtl: 600,
245
+ maxAttempts: 5,
246
+ maxClockSkewSteps: 1,
247
+ totp: {
248
+ algorithm: 'SHA1',
249
+ digits: 6,
250
+ period: 30,
251
+ secretBytes: 20,
252
+ },
253
+ recoveryCodeCount: 10,
254
+ recoveryCodeBytes: 16,
255
+ encryption: {
256
+ currentKeyId: 'v1',
257
+ keys: { v1: process.env.TWO_FACTOR_ENCRYPTION_KEY_V1 },
258
+ },
259
+ recoveryCodeHmac: {
260
+ currentKeyId: 'v1',
261
+ keys: { v1: process.env.TWO_FACTOR_RECOVERY_HMAC_KEY_V1 },
262
+ },
263
+ principalBindingHmac: {
264
+ currentKeyId: 'v1',
265
+ keys: { v1: process.env.TWO_FACTOR_BINDING_HMAC_KEY_V1 },
266
+ },
267
+ };
268
+ ```
269
+
270
+ The encryption key must decode to exactly 32 bytes. HMAC keys must contain at
271
+ least 32 bytes and the recovery/binding keys must be distinct.
272
+ `TwoFactorCredential`, `TwoFactorChallenge`, and
273
+ `TwoFactorRecoveryCode` must be attached to a durable datasource as private
274
+ models. The framework encrypts TOTP secrets using AES-256-GCM, stores recovery
275
+ codes only as HMAC-derived IDs, binds challenges to the current password hash,
276
+ and prevents TOTP/recovery replay with conditional updates.
277
+
278
+ Provision the private schemas before enabling traffic. For existing
279
+ datasources, run the application's normal migration process for
280
+ `RateLimitState`, `TwoFactorCredential`, `TwoFactorChallenge`, and
281
+ `TwoFactorRecoveryCode`; do not rely on runtime automigration in production.
282
+ The database must contain the unique
283
+ `TwoFactorCredential(principalType, principalId, provider)` index and the
284
+ primary/unique IDs declared by the models. The initializer checks that the
285
+ unique credential index is present in model metadata, but connector-independent
286
+ code cannot prove that an out-of-band database migration was actually applied.
287
+ Verify the physical indexes in deployment validation.
288
+
289
+ Authentication rate limiting remains independent of 2FA and can use an
290
+ application store:
291
+
292
+ ```js
293
+ SecureUser.settings.authenticationRateLimit = {
294
+ enabled: true,
295
+ store: 'api',
296
+ subjectHash: {
297
+ currentKeyId: 'v1',
298
+ keys: { v1: process.env.AUTH_RATE_LIMIT_HMAC_KEY_V1 },
299
+ },
300
+ policies: {
301
+ password: {
302
+ policyId: 'authentication-password',
303
+ policyVersion: '1',
304
+ maxFailedAttempts: 5,
305
+ observationWindow: 900,
306
+ blockDuration: 60,
307
+ strategy: 'exponential',
308
+ multiplier: 2,
309
+ maxBlockDuration: 3600,
310
+ backoffResetAfter: 86400,
311
+ stateTtl: 604800,
312
+ },
313
+ },
314
+ };
315
+ ```
316
+
317
+ Password-only login stays unchanged for users without an enabled credential.
318
+ For enrolled users, `User.login` accepts optional `twoFactorMethod` and
319
+ `twoFactorCode`. A valid password without a code returns
320
+ `403 TWO_FACTOR_REQUIRED` with a short-lived opaque `challengeId`; complete it
321
+ at `POST /Users/two-factor/verify`. Enrollment and recovery management are:
322
+
323
+ - `POST /Users/two-factor/setup`;
324
+ - `POST /Users/two-factor/confirm`;
325
+ - `POST /Users/two-factor/recovery-codes`;
326
+ - `POST /Users/two-factor/disable`.
327
+
328
+ The management endpoints require an authenticated token and reauthentication.
329
+ Enrollment secrets and recovery codes are returned once. Sensitive responses
330
+ and challenges use `Cache-Control: no-store`. Rate-limit responses use
331
+ `429 AUTHENTICATION_RATE_LIMITED`; a failed limiter store or unready security
332
+ initializer fails closed.
333
+
334
+ Override `getTwoFactorPrincipalBindingMaterial` when password verification
335
+ lives outside `User.password`, and override
336
+ `validateTwoFactorLoginPrincipal` for custom disabled/locked account state.
337
+ Neither hook may derive its result from request-controlled data.
338
+
339
+ Run `app.cleanupAuthenticationSecurity()` periodically. It recovers expired
340
+ verification leases only after claiming them with CAS, reconciles known issued
341
+ tokens before releasing recovery-code reservations, retains ambiguous
342
+ reservations in quarantine, removes non-active recovery sets, and deletes
343
+ orphaned or retention-expired credentials. Monitor
344
+ `authenticationSecurityCleanupError` and any challenge left in
345
+ `token_revocation_pending`; a row without a durable issued-token identifier
346
+ requires operator reconciliation and is intentionally not released.
347
+
348
+ Rotate AES keys by deploying the added key everywhere, selecting it as current,
349
+ and running `app.rotateAuthenticationSecurityEncryption({batchSize: 100})`
350
+ until every model reports zero remaining credentials and live challenges. The
351
+ framework migration uses compare-and-set and is safe to rerun. Retire the old
352
+ key only after no live envelope references it and terminal challenge retention
353
+ has elapsed. Existing recovery
354
+ codes cannot be re-HMACed; regenerate their sets before removing an old
355
+ recovery HMAC key. Keep old binding keys for at least the challenge TTL plus
356
+ lease and rollout margin. A safe rollback uses `verify-only`, never
357
+ password-only bypass.
358
+
359
+ Typical app layout:
360
+
361
+ ```text
362
+ server/
363
+ server.js
364
+ model-config.json
365
+ datasources.json
366
+ boot/
367
+ common/
368
+ models/
369
+ book.json
370
+ book.js
371
+ ```
372
+
373
+ ## Define models
374
+
375
+ The most common style is a boot-loaded model made of:
376
+
377
+ 1. a JSON definition in `common/models/*.json`
378
+ 2. a model module in `common/models/*.js`
379
+ 3. an entry in `server/model-config.json`
380
+
381
+ ### File-based models
382
+
383
+ `server/model-config.json`
384
+
385
+ ```json
386
+ {
387
+ "_meta": {
388
+ "sources": [
389
+ "@vsaas/loopback/common/models",
390
+ "@vsaas/loopback/server/models",
391
+ "../common/models",
392
+ "./models"
393
+ ],
394
+ "mixins": [
395
+ "@vsaas/loopback/common/mixins",
396
+ "@vsaas/loopback/server/mixins",
397
+ "../common/mixins",
398
+ "./mixins"
399
+ ]
400
+ },
401
+ "Book": {
402
+ "dataSource": "db",
403
+ "public": true
404
+ }
405
+ }
406
+ ```
407
+
408
+ `common/models/book.json`
409
+
410
+ ```json
411
+ {
412
+ "name": "Book",
413
+ "base": "PersistedModel",
414
+ "plural": "Books",
415
+ "idInjection": true,
416
+ "options": {
417
+ "validateUpsert": true
418
+ },
419
+ "properties": {
420
+ "title": {
421
+ "type": "string",
422
+ "required": true
423
+ },
424
+ "isbn": {
425
+ "type": "string",
426
+ "required": true
427
+ },
428
+ "status": {
429
+ "type": "string",
430
+ "default": "draft"
431
+ },
432
+ "metadata": {
433
+ "type": "object"
434
+ }
435
+ },
436
+ "relations": {
437
+ "author": {
438
+ "type": "belongsTo",
439
+ "model": "Author"
440
+ },
441
+ "chapters": {
442
+ "type": "hasMany",
443
+ "model": "Chapter"
444
+ }
445
+ }
446
+ }
447
+ ```
448
+
449
+ `common/models/book.js`
450
+
451
+ ```js
452
+ module.exports = function (Book) {
453
+ Book.validatePromise(
454
+ 'isbn',
455
+ async function () {
456
+ const duplicate = await Book.findOne({
457
+ where: {
458
+ isbn: this.isbn,
459
+ id: { neq: this.id },
460
+ },
461
+ });
462
+
463
+ if (duplicate) {
464
+ throw new Error('ISBN already exists');
465
+ }
466
+ },
467
+ { message: 'ISBN must be unique' },
468
+ );
469
+ };
470
+ ```
471
+
472
+ Notes:
473
+
474
+ - `public: true` exposes the model through REST. Use `public: false` for
475
+ internal models and service-only models.
476
+ - `base` controls inheritance. If you omit it, the registry falls back to
477
+ `PersistedModel`.
478
+ - `options.validateUpsert` is useful when you rely on `upsert` and want normal
479
+ model validation to run there too.
480
+
481
+ ### Programmatic models
482
+
483
+ For tests, dynamic models, or embedded types, creating models in code is often
484
+ the clearest option.
485
+
486
+ ```js
487
+ const Address = app.registry.createModel('Address', {
488
+ street: String,
489
+ city: String,
490
+ zipCode: String,
491
+ });
492
+
493
+ const Customer = app.registry.createModel(
494
+ 'Customer',
495
+ {
496
+ name: String,
497
+ address: { type: Address },
498
+ tags: [String],
499
+ },
500
+ { strict: true },
501
+ );
502
+
503
+ app.model(Customer, {
504
+ dataSource: 'db',
505
+ public: true,
506
+ });
507
+ ```
508
+
509
+ You can also create models directly from a datasource:
510
+
511
+ ```js
512
+ const AuditLog = app.dataSources.db.createModel('AuditLog', {
513
+ action: String,
514
+ actorId: String,
515
+ createdAt: Date,
516
+ });
517
+ ```
518
+
519
+ ## Define endpoints
520
+
521
+ ### Built-in CRUD endpoints
522
+
523
+ When a model is public and attached to a datasource, LoopBack exposes its CRUD
524
+ surface automatically. Relations expose their own REST routes too.
525
+
526
+ Examples for a public `Book` model:
527
+
528
+ - `GET /api/Books`
529
+ - `GET /api/Books/:id`
530
+ - `POST /api/Books`
531
+ - `PATCH /api/Books/:id`
532
+ - `DELETE /api/Books/:id`
533
+ - `GET /api/Books/:id/chapters`
534
+ - `POST /api/Books/:id/chapters`
535
+
536
+ ### Custom static remote methods
537
+
538
+ Custom endpoints are defined in the model module with an implementation plus
539
+ `remoteMethod(...)`.
540
+
541
+ ```js
542
+ module.exports = function (Book) {
543
+ Book.summary = async function (filter, options) {
544
+ const books = await Book.find(filter || {});
545
+
546
+ return {
547
+ count: books.length,
548
+ requestedBy: options?.accessToken?.userId ?? null,
549
+ };
550
+ };
551
+
552
+ Book.remoteMethod('summary', {
553
+ http: { verb: 'get', path: '/summary' },
554
+ accepts: [
555
+ { arg: 'filter', type: 'object' },
556
+ { arg: 'options', type: 'object', http: 'optionsFromRequest' },
557
+ ],
558
+ returns: { arg: 'result', type: 'object', root: true },
559
+ });
560
+ };
561
+ ```
562
+
563
+ ### Custom instance remote methods
564
+
565
+ Use the `prototype.` prefix when you want an endpoint on a model instance.
566
+
567
+ ```js
568
+ module.exports = function (Book) {
569
+ Book.prototype.publish = async function (options) {
570
+ await this.updateAttributes({
571
+ status: 'published',
572
+ publishedBy: options?.accessToken?.userId ?? null,
573
+ publishedAt: new Date(),
574
+ });
575
+
576
+ return this;
577
+ };
578
+
579
+ Book.remoteMethod('prototype.publish', {
580
+ http: { verb: 'post', path: '/publish' },
581
+ accepts: [{ arg: 'options', type: 'object', http: 'optionsFromRequest' }],
582
+ returns: { arg: 'result', type: 'Book', root: true },
583
+ });
584
+ };
585
+ ```
586
+
587
+ With the example above, the endpoint is exposed at:
588
+
589
+ - `POST /api/Books/:id/publish`
590
+
591
+ ### Remote hooks
592
+
593
+ Remote hooks can be synchronous, callback-based, or promise-returning. In
594
+ promise-first applications, returning a value or a promise keeps the code much
595
+ cleaner than chaining `next(...)`.
596
+
597
+ ```js
598
+ module.exports = function (Book) {
599
+ Book.beforeRemote('summary', async (ctx) => {
600
+ const filter = ctx.args.filter || {};
601
+ const limit = Math.min(filter.limit || 50, 100);
602
+
603
+ ctx.args.filter = {
604
+ ...filter,
605
+ limit,
606
+ };
607
+ });
608
+
609
+ Book.afterRemote('summary', async (ctx) => {
610
+ ctx.result.generatedAt = new Date().toISOString();
611
+ });
612
+ };
613
+ ```
614
+
615
+ ### Access the request context as `options`
616
+
617
+ LoopBack supports a special HTTP mapping named `optionsFromRequest`. By
618
+ default, it injects `accessToken`, and you can enrich it from the remoting
619
+ context.
620
+
621
+ ```js
622
+ module.exports = function (Book) {
623
+ Book.createOptionsFromRemotingContext = function (ctx) {
624
+ return {
625
+ accessToken: ctx.req?.accessToken ?? null,
626
+ requestId: ctx.req?.headers['x-request-id'] ?? null,
627
+ };
628
+ };
629
+ };
630
+ ```
631
+
632
+ ## ACLs
633
+
634
+ ACLs are declared in the model JSON under `acls`.
635
+
636
+ ```json
637
+ {
638
+ "acls": [
639
+ {
640
+ "principalType": "ROLE",
641
+ "principalId": "$everyone",
642
+ "permission": "DENY"
643
+ },
644
+ {
645
+ "principalType": "ROLE",
646
+ "principalId": "authenticated",
647
+ "permission": "ALLOW",
648
+ "property": ["find", "findById", "count"]
649
+ },
650
+ {
651
+ "principalType": "ROLE",
652
+ "principalId": "editor",
653
+ "permission": "ALLOW",
654
+ "property": ["create", "prototype.publish", "deleteById"]
655
+ },
656
+ {
657
+ "principalType": "ROLE",
658
+ "principalId": "$owner",
659
+ "permission": "ALLOW",
660
+ "accessType": "*"
661
+ }
662
+ ]
663
+ }
664
+ ```
665
+
666
+ Common ACL patterns:
667
+
668
+ - deny everything for `$everyone`, then allow the exact roles or methods you
669
+ want to expose
670
+ - use `property` for exact method names such as `find`, `deleteById`,
671
+ `prototype.publish`, or relation methods like `__get__chapters`
672
+ - use `accessType` when you want broader rules such as `READ`, `WRITE`,
673
+ `EXECUTE`, or `*`
674
+ - keep service models private with `public: false` in `model-config.json`
675
+
676
+ If you need custom roles, register them in a boot script with
677
+ `Role.registerResolver(...)` and keep the model ACL JSON focused on the role
678
+ names themselves.
679
+
680
+ ## Filters
681
+
682
+ LoopBack filters are plain objects passed to methods such as `find`,
683
+ `findOne`, `count`, relation scopes, and custom remote methods.
684
+
685
+ This section focuses on the filter shapes confirmed against
686
+ `@vsaas/loopback-connector-mongodb`.
687
+
688
+ The supported top-level keys are:
689
+
690
+ - `where`
691
+ - `fields`
692
+ - `order`
693
+ - `limit`
694
+ - `skip`
695
+ - `include`
696
+
697
+ ### `where`
698
+
699
+ ```js
700
+ const filter = {
701
+ where: {
702
+ status: 'published',
703
+ priority: { gte: 3 },
704
+ or: [{ archived: false }, { archived: { exists: false } }],
705
+ },
706
+ };
707
+
708
+ const books = await Book.find(filter);
709
+ ```
710
+
711
+ Common `where` operators supported by the MongoDB connector:
712
+
713
+ - equality: `{ status: 'published' }`
714
+ - `neq`: `{ status: { neq: 'archived' } }`
715
+ - `gt`, `gte`, `lt`, `lte`: `{ priority: { gte: 3 } }`
716
+ - `exists`: `{ archivedAt: { exists: true } }`
717
+ - `inq`: `{ status: { inq: ['draft', 'published'] } }`
718
+ - `nin`: `{ status: { nin: ['deleted', 'archived'] } }`
719
+ - `between`: `{ createdAt: { between: [fromDate, toDate] } }`
720
+ - `like`: `{ title: { like: '^LoopBack' } }`
721
+ - `nlike`: `{ title: { nlike: '^Internal' } }`
722
+ - `regexp`: `{ email: { regexp: /@example\\.com$/i } }`
723
+ - boolean composition: `{ and: [ ... ] }`, `{ or: [ ... ] }`,
724
+ `{ nor: [ ... ] }`
725
+
726
+ Examples:
727
+
728
+ ```js
729
+ const filter = {
730
+ where: {
731
+ status: { inq: ['draft', 'published'] },
732
+ category: { nin: ['internal', 'deleted'] },
733
+ createdAt: {
734
+ between: [new Date('2026-01-01T00:00:00.000Z'), new Date('2026-12-31T23:59:59.999Z')],
735
+ },
736
+ },
737
+ };
738
+ ```
739
+
740
+ ```js
741
+ const filter = {
742
+ where: {
743
+ and: [{ title: { like: 'LoopBack' } }, { authorName: { nlike: '^deprecated-' } }],
744
+ },
745
+ };
746
+ ```
747
+
748
+ Notes:
749
+
750
+ - `inq` and `nin` require arrays
751
+ - `between` requires an array with exactly two values
752
+ - `like` and `nlike` accept either a string pattern or a `RegExp`
753
+ - when `like` or `nlike` receives a string, `options` is passed as the regexp
754
+ flags argument used to build `new RegExp(pattern, options)`
755
+ - the most common `options` value is `'i'` for case-insensitive matching
756
+ - when you pass a `RegExp` value instead, put the flags in the regexp itself
757
+ and omit `options`
758
+
759
+ Example with `options`:
760
+
761
+ ```js
762
+ const filter = {
763
+ where: {
764
+ title: {
765
+ like: '^loopback',
766
+ options: 'i',
767
+ },
768
+ },
769
+ };
770
+ ```
771
+
772
+ Important:
773
+
774
+ - `like`, `nlike`, and `regexp` become MongoDB regex queries (`$regex` or
775
+ `$not`)
776
+ - do not recommend regex-based queries by default; they can be expensive and
777
+ can easily defeat normal index usage depending on the pattern
778
+ - only use them when you are fully sure about data volume, selectivity, and
779
+ indexing strategy
780
+ - avoid the `g` flag; MongoDB does not respect it for regex queries
781
+
782
+ The MongoDB connector in this monorepo enables nested-property queries, so dot
783
+ paths work in `where` too:
784
+
785
+ ```js
786
+ const users = await User.find({
787
+ where: {
788
+ 'address.city': 'San Jose',
789
+ 'address.tags.tag': 'business',
790
+ },
791
+ });
792
+ ```
793
+
794
+ Confirmed examples for MongoDB-style nested queries:
795
+
796
+ - `'address.city': 'San Jose'`
797
+ - `'address.city': { gt: 'San' }`
798
+ - `'friends.name': { regexp: /^Ringo/ }`
799
+ - `'address.tags.tag': 'business'`
800
+
801
+ ### `include`
802
+
803
+ Use `include` to fetch related models, and `scope` to filter the included data.
804
+
805
+ ```js
806
+ const posts = await Post.find({
807
+ include: [
808
+ {
809
+ relation: 'author',
810
+ scope: { fields: ['name'] },
811
+ },
812
+ ],
813
+ });
814
+
815
+ const users = await User.find({
816
+ include: [
817
+ {
818
+ relation: 'posts',
819
+ scope: {
820
+ where: { published: true },
821
+ order: 'createdAt DESC',
822
+ limit: 5,
823
+ },
824
+ },
825
+ ],
826
+ });
827
+ ```
828
+
829
+ ### `fields`
830
+
831
+ Use `fields` when you want a partial projection. It accepts an array, a string,
832
+ or an object map.
833
+
834
+ ```js
835
+ const devices = await Device.find({
836
+ fields: ['name', 'status'],
837
+ });
838
+ ```
839
+
840
+ ```js
841
+ const books = await Book.find({
842
+ fields: {
843
+ title: true,
844
+ status: true,
845
+ },
846
+ });
847
+ ```
848
+
849
+ For embedded documents, the MongoDB connector also supports dot-path
850
+ projection:
851
+
852
+ ```js
853
+ const devices = await Device.find({
854
+ fields: ['name', '_config.address'],
855
+ });
856
+ ```
857
+
858
+ That produces a MongoDB projection equivalent to:
859
+
860
+ ```js
861
+ {
862
+ name: 1,
863
+ '_config.address': 1,
864
+ }
865
+ ```
866
+
867
+ and is the right way to request only specific fields from embedded models or
868
+ nested objects.
869
+
870
+ ### `order` (sorting)
871
+
872
+ LoopBack uses the key `order`, not `sort`.
873
+
874
+ ```js
875
+ const books = await Book.find({
876
+ order: ['publishedAt DESC', 'title ASC'],
877
+ });
878
+ ```
879
+
880
+ You can also pass a single string:
881
+
882
+ ```js
883
+ const books = await Book.find({
884
+ order: 'publishedAt DESC, title ASC',
885
+ });
886
+ ```
887
+
888
+ The MongoDB connector in this monorepo also supports dot paths in `order`:
889
+
890
+ ```js
891
+ const users = await User.find({
892
+ where: { 'address.state': 'CA' },
893
+ order: 'address.city DESC',
894
+ });
895
+ ```
896
+
897
+ ### `limit` and `skip`
898
+
899
+ ```js
900
+ const page = await Book.find({
901
+ where: { status: 'published' },
902
+ order: 'publishedAt DESC',
903
+ limit: 20,
904
+ skip: 40,
905
+ });
906
+ ```
907
+
908
+ Notes:
909
+
910
+ - `limit` must be a positive integer
911
+ - `skip` must be a non-negative integer
912
+
913
+ ### Filters over REST
914
+
915
+ For `GET` requests, the filter is sent as one JSON-encoded `filter` query
916
+ parameter.
917
+
918
+ ```bash
919
+ curl --get 'http://localhost:3000/api/Books' \
920
+ --data-urlencode 'filter={"where":{"status":"published"},"include":[{"relation":"author","scope":{"fields":["name"]}}],"order":"publishedAt DESC","limit":10}'
921
+ ```
922
+
923
+ TypeScript example using the native `fetch` API:
924
+
925
+ ```ts
926
+ type LoopBackFilter = {
927
+ where?: Record<string, unknown>;
928
+ include?: unknown[];
929
+ order?: string | string[];
930
+ limit?: number;
931
+ skip?: number;
932
+ fields?: string[] | Record<string, boolean>;
933
+ };
934
+
935
+ const filter: LoopBackFilter = {
936
+ where: { status: 'published' },
937
+ include: [{ relation: 'author', scope: { fields: ['name'] } }],
938
+ order: 'publishedAt DESC',
939
+ limit: 10,
940
+ };
941
+
942
+ const params = new URLSearchParams({
943
+ filter: JSON.stringify(filter),
944
+ });
945
+
946
+ const response = await fetch(`http://localhost:3000/api/Books?${params.toString()}`);
947
+
948
+ const books = await response.json();
949
+ ```
950
+
951
+ The important part is sending `filter` as one JSON-encoded query parameter.
952
+
953
+ When you define a custom `POST` endpoint, you can also accept the filter in the
954
+ body instead of encoding it in the query string.
955
+
956
+ ## Validation and async/await
957
+
958
+ The modernized packages are callback-compatible, but the application-facing
959
+ CRUD methods can be used directly with promises.
960
+
961
+ ```js
962
+ const created = await Book.create({
963
+ title: 'Designing APIs',
964
+ isbn: '978-1-23456-789-0',
965
+ });
966
+
967
+ const found = await Book.find({
968
+ where: { id: created.id },
969
+ });
970
+
971
+ await created.updateAttributes({
972
+ status: 'published',
973
+ });
974
+ ```
975
+
976
+ ### `validatePromise`
977
+
978
+ `validatePromise(...)` lets you write async validation logic without callback
979
+ plumbing inside the validator itself.
980
+
981
+ ```js
982
+ module.exports = function (Book) {
983
+ Book.validatePromise(
984
+ 'isbn',
985
+ async function (options) {
986
+ if (!this.isbn?.startsWith('978-') && !this.isbn?.startsWith('979-')) {
987
+ throw new Error('ISBN must start with 978- or 979-');
988
+ }
989
+
990
+ if (options?.allowDuplicateIsbn === true) {
991
+ return;
992
+ }
993
+
994
+ const duplicate = await Book.findOne({
995
+ where: {
996
+ isbn: this.isbn,
997
+ id: { neq: this.id },
998
+ },
999
+ });
1000
+
1001
+ if (duplicate) {
1002
+ throw new Error('ISBN already exists');
1003
+ }
1004
+ },
1005
+ { message: 'ISBN is invalid' },
1006
+ );
1007
+ };
1008
+ ```
1009
+
1010
+ Persisted model methods such as `create`, `save`, `updateAttributes`,
1011
+ `replaceOrCreate`, and `patchOrCreate` trigger model validation automatically,
1012
+ so the normal pattern is to validate through the same `await` flow you use for
1013
+ data persistence.
1014
+
1015
+ ```js
1016
+ try {
1017
+ await Book.create({
1018
+ title: 'Bad example',
1019
+ isbn: 'invalid',
1020
+ });
1021
+ } catch (error) {
1022
+ if (error.statusCode === 422) {
1023
+ console.error(error.details.messages);
1024
+ }
1025
+ throw error;
1026
+ }
1027
+ ```
1028
+
1029
+ If you manually call `isValid()` with async validators configured, keep in mind
1030
+ that the validation entrypoint itself remains callback-oriented. In
1031
+ promise-first codebases, persisting with `await` is usually the cleanest way to
1032
+ run `validatePromise` rules.
1033
+
1034
+ ## Optional packages
1035
+
1036
+ ### `@vsaas/loopback-component-storage`
1037
+
1038
+ Storage datasources are commonly mounted as private service models. A typical
1039
+ filesystem setup looks like this:
1040
+
1041
+ `server/datasources.json`
1042
+
1043
+ ```json
1044
+ {
1045
+ "AssetStorage": {
1046
+ "name": "AssetStorage",
1047
+ "connector": "@vsaas/loopback-component-storage",
1048
+ "provider": "filesystem",
1049
+ "root": "/var/lib/app/Asset"
1050
+ }
1051
+ }
1052
+ ```
1053
+
1054
+ `server/model-config.json`
1055
+
1056
+ ```json
1057
+ {
1058
+ "AssetStorageService": {
1059
+ "dataSource": "AssetStorage",
1060
+ "public": false
1061
+ }
1062
+ }
1063
+ ```
1064
+
1065
+ For S3-compatible backends, the same package supports `provider: "s3"` and
1066
+ `provider: "amazon"`, plus signed URL configuration via `signedUrl`.
1067
+
1068
+ See the package README for the full provider matrix:
1069
+ [loopback-component-storage/README.md](../../loopback-component-storage/README.md).
1070
+
1071
+ ### `@vsaas/loopback-softdelete-mixin4`
1072
+
1073
+ Register the mixin directory in `_meta.mixins` and enable it per model.
1074
+
1075
+ `server/model-config.json`
1076
+
1077
+ ```json
1078
+ {
1079
+ "_meta": {
1080
+ "mixins": ["../node_modules/@vsaas/loopback-softdelete-mixin4", "../common/mixins", "./mixins"]
1081
+ }
1082
+ }
1083
+ ```
1084
+
1085
+ `common/models/book.json`
1086
+
1087
+ ```json
1088
+ {
1089
+ "mixins": {
1090
+ "SoftDelete": true
1091
+ }
1092
+ }
1093
+ ```
1094
+
1095
+ By default, soft-deleted rows are excluded. To include them in a query, pass
1096
+ `deleted: true` at the top level of the filter:
1097
+
1098
+ ```js
1099
+ const books = await Book.find({
1100
+ where: { status: 'archived' },
1101
+ deleted: true,
1102
+ });
1103
+ ```
1104
+
1105
+ If your application enables soft delete broadly, you can opt out on a specific
1106
+ model with `"SoftDelete": false`.
1107
+
1108
+ See the package README for supported mixin options such as `deletedAt`,
1109
+ `deletedById`, `scrub`, and `index`:
1110
+ [loopback-softdelete-mixin/README.md](../../loopback-softdelete-mixin/README.md).