@vsaas/loopback 12.1.0 → 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.
- package/README.md +6 -0
- package/common/models/rate-limit-state.json +121 -0
- package/common/models/two-factor-challenge.json +112 -0
- package/common/models/two-factor-credential.json +117 -0
- package/common/models/two-factor-recovery-code.json +63 -0
- package/common/models/user.json +30 -0
- package/dist/_virtual/_rolldown/runtime.cjs +9 -2
- package/dist/common/models/access-token.d.cts +3 -0
- package/dist/common/models/acl.cjs +1 -3
- package/dist/common/models/acl.d.cts +1 -0
- package/dist/common/models/application.d.cts +3 -0
- package/dist/common/models/change.cjs +21 -18
- package/dist/common/models/change.d.cts +1 -0
- package/dist/common/models/checkpoint.d.cts +3 -0
- package/dist/common/models/email.d.cts +3 -0
- package/dist/common/models/key-value-model.d.cts +3 -0
- package/dist/common/models/rate-limit-state.cjs +7 -0
- package/dist/common/models/rate-limit-state.d.cts +3 -0
- package/dist/common/models/rate-limit-state2.cjs +109 -0
- package/dist/common/models/role-mapping.d.cts +3 -0
- package/dist/common/models/role.cjs +12 -8
- package/dist/common/models/role.d.cts +1 -0
- package/dist/common/models/scope.d.cts +3 -0
- package/dist/common/models/two-factor-challenge.cjs +7 -0
- package/dist/common/models/two-factor-challenge.d.cts +3 -0
- package/dist/common/models/two-factor-challenge2.cjs +108 -0
- package/dist/common/models/two-factor-credential.cjs +7 -0
- package/dist/common/models/two-factor-credential.d.cts +3 -0
- package/dist/common/models/two-factor-credential2.cjs +103 -0
- package/dist/common/models/two-factor-recovery-code.cjs +7 -0
- package/dist/common/models/two-factor-recovery-code.d.cts +3 -0
- package/dist/common/models/two-factor-recovery-code2.cjs +57 -0
- package/dist/common/models/user.cjs +258 -21
- package/dist/common/models/user.d.cts +3 -0
- package/dist/common/models/user2.cjs +30 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +3 -0
- package/dist/lib/access-context.d.cts +33 -0
- package/dist/lib/application.d.cts +1 -0
- package/dist/lib/authentication-security-initializer.cjs +351 -0
- package/dist/lib/authentication-security-initializer.d.cts +4 -0
- package/dist/lib/builtin-models.cjs +12 -0
- package/dist/lib/builtin-models.d.cts +3 -0
- package/dist/lib/configure-shared-methods.cjs +7 -4
- package/dist/lib/configure-shared-methods.d.cts +3 -0
- package/dist/lib/connectors/base-connector.d.cts +6 -0
- package/dist/lib/connectors/mail-direct-transport.cjs +26 -23
- package/dist/lib/connectors/mail-direct-transport.d.cts +22 -0
- package/dist/lib/connectors/mail-stub-transport.d.cts +14 -0
- package/dist/lib/connectors/mail.d.cts +7 -0
- package/dist/lib/connectors/memory.d.cts +6 -0
- package/dist/lib/current-context.d.cts +3 -0
- package/dist/lib/globalize.d.cts +10 -0
- package/dist/lib/loopback.cjs +6 -0
- package/dist/lib/loopback.d.cts +1 -0
- package/dist/lib/model.d.cts +1 -0
- package/dist/lib/persisted-model.cjs +7 -12
- package/dist/lib/persisted-model.d.cts +1 -0
- package/dist/lib/rate-limit/algorithms/common.cjs +37 -0
- package/dist/lib/rate-limit/algorithms/failure-lockout.cjs +70 -0
- package/dist/lib/rate-limit/algorithms/fixed-window.cjs +61 -0
- package/dist/lib/rate-limit/algorithms/index.cjs +55 -0
- package/dist/lib/rate-limit/config.cjs +107 -0
- package/dist/lib/rate-limit/errors.cjs +58 -0
- package/dist/lib/rate-limit/index.cjs +15 -0
- package/dist/lib/rate-limit/index.d.cts +1 -0
- package/dist/lib/rate-limit/initializer.cjs +92 -0
- package/dist/lib/rate-limit/initializer.d.cts +4 -0
- package/dist/lib/rate-limit/limiter.cjs +368 -0
- package/dist/lib/rate-limit/registry.cjs +92 -0
- package/dist/lib/rate-limit/remote.cjs +180 -0
- package/dist/lib/rate-limit/remote.d.cts +6 -0
- package/dist/lib/rate-limit/stores/persisted-model.cjs +353 -0
- package/dist/lib/rate-limit/subject.cjs +127 -0
- package/dist/lib/rate-limit/types.cjs +1 -0
- package/dist/lib/rate-limit/types.d.cts +167 -0
- package/dist/lib/registry.d.cts +1 -0
- package/dist/lib/runtime.d.cts +10 -0
- package/dist/lib/server-app.d.cts +3 -0
- package/dist/lib/two-factor/config.cjs +199 -0
- package/dist/lib/two-factor/config.d.cts +55 -0
- package/dist/lib/two-factor/crypto.cjs +68 -0
- package/dist/lib/two-factor/crypto.d.cts +16 -0
- package/dist/lib/two-factor/errors.cjs +58 -0
- package/dist/lib/two-factor/errors.d.cts +18 -0
- package/dist/lib/two-factor/rate-limit.cjs +153 -0
- package/dist/lib/two-factor/rate-limit.d.cts +22 -0
- package/dist/lib/two-factor/recovery-codes.cjs +49 -0
- package/dist/lib/two-factor/recovery-codes.d.cts +16 -0
- package/dist/lib/two-factor/service.cjs +1108 -0
- package/dist/lib/two-factor/service.d.cts +78 -0
- package/dist/lib/two-factor/totp.cjs +50 -0
- package/dist/lib/two-factor/totp.d.cts +14 -0
- package/dist/lib/utils.d.cts +36 -0
- package/dist/package.cjs +46 -10
- package/dist/server/middleware/context.d.cts +3 -0
- package/dist/server/middleware/error-handler.d.cts +3 -0
- package/dist/server/middleware/favicon.d.cts +7 -0
- package/dist/server/middleware/rest.d.cts +6 -0
- package/dist/server/middleware/static.d.cts +3 -0
- package/dist/server/middleware/status.d.cts +19 -0
- package/dist/server/middleware/token.d.cts +3 -0
- package/dist/server/middleware/url-not-found.d.cts +13 -0
- package/docs/api-explorer-details.md +110 -0
- package/docs/application-guide.md +1110 -0
- package/docs/assets/explorer-api.png +0 -0
- package/docs/assets/explorer-endpoint.png +0 -0
- package/docs/assets/explorer-listing.png +0 -0
- package/docs/assets/explorer-req-res.png +0 -0
- package/docs/assets/lb-modules.png +0 -0
- package/docs/assets/loopback-architecture.png +0 -0
- package/docs/assets/loopback-concepts.png +0 -0
- package/docs/assets/loopback_ov.png +0 -0
- package/docs/rate-limiting.md +879 -0
- package/docs/two-factor-authentication.md +611 -0
- package/docs/two-factor-authenticator-loss-runbook.md +117 -0
- package/package.json +55 -19
|
@@ -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).
|