soajs.controller 4.3.5 → 4.3.6

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.
@@ -18,11 +18,13 @@ registry.custom
18
18
  │ │ └── throttling # Service-level rate limiting
19
19
  │ ├── idempotency # Idempotency key configuration
20
20
  │ │ ├── model # Storage backend (memory/mongo)
21
- │ │ └── [service] # Per-service idempotency config
21
+ │ │ └── services # Per-service idempotency config
22
+ │ │ └── [service] # Service-specific settings
22
23
  │ ├── cache # GET response caching
23
24
  │ │ ├── model # Storage backend (memory/mongo)
24
25
  │ │ ├── defaultTTL # Default cache TTL
25
- │ │ └── [service] # Per-service cache config
26
+ │ │ └── services # Per-service cache config
27
+ │ │ └── [service] # Service-specific settings
26
28
  │ ├── maintenanceMode # Gateway maintenance mode
27
29
  │ ├── lastSeen # User activity tracking
28
30
  │ └── gotoService
@@ -141,22 +143,24 @@ Prevents duplicate write operations by tracking requests via `Idempotency-Key` h
141
143
  {
142
144
  "idempotency": {
143
145
  "model": "memory", // Storage: "memory" or "mongo"
144
- "av": { // Service name
145
- "enabled": true, // Enable for this service
146
- "ttl": 60000, // Key expiration (ms)
147
- "apis": [ // APIs to protect
148
- "POST /av/calls",
149
- "PUT /av/call/p2p/ended",
150
- "PUT /av/call/p2p/rejected"
151
- ]
152
- },
153
- "payment": {
154
- "enabled": true,
155
- "ttl": 120000,
156
- "apis": [
157
- "POST /payment/charge",
158
- "POST /payment/refund"
159
- ]
146
+ "services": { // Per-service configuration
147
+ "av": { // Service name
148
+ "enabled": true, // Enable for this service
149
+ "ttl": 60000, // Key expiration (ms)
150
+ "apis": [ // APIs to protect
151
+ "POST /calls",
152
+ "PUT /call/p2p/ended",
153
+ "PUT /call/p2p/rejected"
154
+ ]
155
+ },
156
+ "payment": {
157
+ "enabled": true,
158
+ "ttl": 120000,
159
+ "apis": [
160
+ "POST /charge",
161
+ "POST /refund"
162
+ ]
163
+ }
160
164
  }
161
165
  }
162
166
  }
@@ -165,9 +169,9 @@ Prevents duplicate write operations by tracking requests via `Idempotency-Key` h
165
169
  | Field | Type | Default | Description |
166
170
  |-------|------|---------|-------------|
167
171
  | `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
168
- | `[service].enabled` | boolean | `false` | Enable idempotency for this service |
169
- | `[service].ttl` | number | `60000` | Key expiration time in milliseconds |
170
- | `[service].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
172
+ | `services.[name].enabled` | boolean | `false` | Enable idempotency for this service |
173
+ | `services.[name].ttl` | number | `60000` | Key expiration time in milliseconds |
174
+ | `services.[name].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
171
175
 
172
176
  **Client Usage:**
173
177
  ```http
@@ -194,25 +198,27 @@ Caches GET API responses with per-API TTL configuration. Returns `X-Cache: HIT`
194
198
  "cache": {
195
199
  "model": "memory", // Storage: "memory" or "mongo"
196
200
  "defaultTTL": 300000, // Default TTL: 5 minutes
197
- "av": { // Service name
198
- "enabled": true, // Enable caching for this service
199
- "apis": {
200
- "GET /av/calls": {
201
- "enabled": true,
202
- "ttl": 30000 // 30 seconds
203
- },
204
- "GET /av/call/:id": {
205
- "enabled": true,
206
- "ttl": 60000 // 60 seconds
201
+ "services": { // Per-service configuration
202
+ "av": { // Service name
203
+ "enabled": true, // Enable caching for this service
204
+ "apis": {
205
+ "GET /calls": {
206
+ "enabled": true,
207
+ "ttl": 30000 // 30 seconds
208
+ },
209
+ "GET /call/:id": {
210
+ "enabled": true,
211
+ "ttl": 60000 // 60 seconds
212
+ }
207
213
  }
208
- }
209
- },
210
- "catalog": {
211
- "enabled": true,
212
- "apis": {
213
- "GET /catalog/products": {
214
- "enabled": true,
215
- "ttl": 600000 // 10 minutes
214
+ },
215
+ "catalog": {
216
+ "enabled": true,
217
+ "apis": {
218
+ "GET /products": {
219
+ "enabled": true,
220
+ "ttl": 600000 // 10 minutes
221
+ }
216
222
  }
217
223
  }
218
224
  }
@@ -224,10 +230,10 @@ Caches GET API responses with per-API TTL configuration. Returns `X-Cache: HIT`
224
230
  |-------|------|---------|-------------|
225
231
  | `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
226
232
  | `defaultTTL` | number | `300000` | Default cache TTL in milliseconds |
227
- | `[service].enabled` | boolean | `false` | Enable caching for this service |
228
- | `[service].apis` | object | `{}` | API-specific cache configuration |
229
- | `[service].apis[api].enabled` | boolean | `false` | Enable caching for this API |
230
- | `[service].apis[api].ttl` | number | `defaultTTL` | Cache TTL for this specific API |
233
+ | `services.[name].enabled` | boolean | `false` | Enable caching for this service |
234
+ | `services.[name].apis` | object | `{}` | API-specific cache configuration |
235
+ | `services.[name].apis[api].enabled` | boolean | `false` | Enable caching for this API |
236
+ | `services.[name].apis[api].ttl` | number | `defaultTTL` | Cache TTL for this specific API |
231
237
 
232
238
  **Response Headers:**
233
239
  - `X-Cache: HIT` - Response served from cache
@@ -480,20 +486,24 @@ Whitelists specific APIs that can be accessed when a user is PIN-locked (during
480
486
  },
481
487
  "idempotency": {
482
488
  "model": "mongo",
483
- "payment": {
484
- "enabled": true,
485
- "ttl": 120000,
486
- "apis": ["POST /payment/charge", "POST /payment/refund"]
489
+ "services": {
490
+ "payment": {
491
+ "enabled": true,
492
+ "ttl": 120000,
493
+ "apis": ["POST /charge", "POST /refund"]
494
+ }
487
495
  }
488
496
  },
489
497
  "cache": {
490
498
  "model": "mongo",
491
499
  "defaultTTL": 300000,
492
- "catalog": {
493
- "enabled": true,
494
- "apis": {
495
- "GET /catalog/products": { "enabled": true, "ttl": 600000 },
496
- "GET /catalog/categories": { "enabled": true, "ttl": 3600000 }
500
+ "services": {
501
+ "catalog": {
502
+ "enabled": true,
503
+ "apis": {
504
+ "GET /products": { "enabled": true, "ttl": 600000 },
505
+ "GET /categories": { "enabled": true, "ttl": 3600000 }
506
+ }
497
507
  }
498
508
  }
499
509
  },
@@ -555,7 +565,7 @@ Custom registry configurations are processed in this order:
555
565
  | 4 | gotoService/preRedirect | `gotoService.renewReqMonitorOff` |
556
566
  | 5 | gotoService/redirectToService | `gotoService.monitor` |
557
567
  | 6 | mt (security checks) | `mt.whitelist`, `oauth.value.pinWrapper`, `oauth.value.pinWhitelist` |
558
- | 7 | idempotency | `idempotency.model`, `idempotency.[service]` |
568
+ | 7 | idempotency | `idempotency.model`, `idempotency.services.[name]` |
559
569
  | 8 | traffic | `traffic.model`, `traffic.throttling` |
560
- | 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.[service]` |
570
+ | 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.services.[name]` |
561
571
  | 10 | lastSeen | `lastSeen` |
@@ -71,6 +71,10 @@ The Cache middleware provides response caching for GET APIs with per-API TTL con
71
71
  "GET /users": {
72
72
  "enabled": true
73
73
  // Uses defaultTTL (300000ms)
74
+ },
75
+ "GET /profile": {
76
+ "enabled": true,
77
+ "scope": "tenant_user" // Cache per tenant + logged-in user
74
78
  }
75
79
  }
76
80
  },
@@ -100,6 +104,27 @@ The Cache middleware provides response caching for GET APIs with per-API TTL con
100
104
  | `services.[name].apis` | object | `{}` | API-specific cache configuration |
101
105
  | `services.[name].apis[api].enabled` | boolean | `false` | Enable caching for this API |
102
106
  | `services.[name].apis[api].ttl` | number | `defaultTTL` | Cache TTL for this API |
107
+ | `services.[name].apis[api].scope` | string | inferred | Cache scope: `"tenant"` or `"tenant_user"`. Defaults to `"tenant"` for public APIs and `"tenant_user"` for private APIs. |
108
+
109
+ ## Caching Scope (Public vs Private APIs)
110
+
111
+ The cache key dimension depends on whether the API is **public** (no `access_token` required) or **private** (requires an `access_token`):
112
+
113
+ - **Public API** → the response is the same for the whole tenant, so it is cached per **tenant** (`scope: "tenant"`). The cache key contains the tenant id only.
114
+ - **Private API** → the response can differ per logged-in user, so it is cached per **tenant + user** (`scope: "tenant_user"`). The cache key also includes the user id, preventing one user from receiving another user's cached response.
115
+
116
+ The gateway determines public vs private automatically from `req.soajs.controller.serviceParams.isAPIPublic` and resolves the default scope accordingly. You can override the scope per API with the `scope` field.
117
+
118
+ | Scope | Cache key dimension | Default for |
119
+ |-------|---------------------|-------------|
120
+ | `tenant` | tenant id | Public APIs |
121
+ | `tenant_user` | tenant id + user id | Private APIs |
122
+
123
+ ### Safety Behavior
124
+
125
+ If an API resolves to `tenant_user` scope but **no logged-in user can be resolved** (no URAC user on the request), the response is **not cached**. This prevents a user-scoped entry from being stored without a user identity and served to the wrong user. A misconfiguration therefore causes a cache miss, never a data leak.
126
+
127
+ An unrecognized `scope` value is ignored (with a warning) and falls back to the inferred default.
103
128
 
104
129
  ## API Pattern Matching
105
130
 
@@ -116,16 +141,24 @@ APIs are matched using `GET /path` format with support for path parameters:
116
141
  ## Cache Key Structure
117
142
 
118
143
  ```javascript
144
+ // Public API (scope: "tenant")
119
145
  {
120
146
  "l1": "tenant_id",
121
147
  "l2": "serviceName:GET:/path:queryHash"
122
148
  }
149
+
150
+ // Private API (scope: "tenant_user")
151
+ {
152
+ "l1": "tenant_id",
153
+ "l2": "serviceName:GET:/path:queryHash:u:user_id"
154
+ }
123
155
  ```
124
156
 
125
157
  - **tenant_id**: Isolates cache per tenant
126
158
  - **serviceName**: The target service name
127
159
  - **path**: The API path
128
160
  - **queryHash**: MD5 hash of sorted query parameters
161
+ - **user_id**: The logged-in user id (only for `tenant_user` scope), isolating cache per user within the tenant
129
162
 
130
163
  ### Query Parameter Handling
131
164
 
@@ -199,7 +232,9 @@ Content-Type: application/json
199
232
  ## Usage Notes
200
233
 
201
234
  - Only GET requests are cached
202
- - Cache is tenant-isolated
235
+ - Cache is tenant-isolated; private APIs are additionally user-isolated (`tenant_user` scope)
236
+ - Public APIs default to `tenant` scope, private APIs default to `tenant_user` scope
237
+ - Private (`tenant_user`) requests without a resolved user are not cached (safety)
203
238
  - Query parameters affect cache key
204
239
  - Only 2xx responses are cached
205
240
  - Headers like `X-Cache` are not stored in cache
@@ -226,12 +261,15 @@ Content-Type: application/json
226
261
  }
227
262
  ```
228
263
 
229
- ### User-Specific Data
264
+ ### User-Specific Data (Private API)
230
265
 
231
266
  ```javascript
232
- // Cache key includes tenant ID, so each tenant has separate cache
267
+ // Private API: cache key includes tenant ID AND user ID,
268
+ // so each user within a tenant has a separate cache entry.
269
+ // "tenant_user" is the default scope for private APIs, shown here explicitly.
233
270
  "GET /users/profile": {
234
271
  "enabled": true,
235
- "ttl": 60000 // 1 minute
272
+ "ttl": 60000, // 1 minute
273
+ "scope": "tenant_user"
236
274
  }
237
275
  ```
package/mw/cache/index.js CHANGED
@@ -106,11 +106,36 @@ module.exports = function (configuration) {
106
106
  return next();
107
107
  }
108
108
 
109
+ // Determine caching scope.
110
+ // Public APIs (no access_token required) return the same response for the whole
111
+ // tenant, so they are cached per tenant ("tenant"). Private APIs may return a
112
+ // different response per logged-in user, so they must be cached per tenant + user
113
+ // ("tenant_user") to avoid serving one user's response to another.
114
+ // Scope can be overridden per API in the custom registry via apiConfig.scope.
115
+ // Default: public -> "tenant", private -> "tenant_user".
116
+ const isAPIPublic = req.soajs.controller.serviceParams.isAPIPublic;
117
+ const userId = req.soajs.uracDriver && req.soajs.uracDriver.id;
118
+
119
+ let scope = apiConfig.scope;
120
+ if (scope !== 'tenant' && scope !== 'tenant_user') {
121
+ if (scope) {
122
+ configuration.log.warn('Cache: Unknown scope [' + scope + '] for API [' + method + ' ' + apiPath + ']. It can only be [tenant || tenant_user]. Falling back to default.');
123
+ }
124
+ scope = isAPIPublic ? 'tenant' : 'tenant_user';
125
+ }
126
+
127
+ // Safety: a user-scoped entry without a resolved user would leak across users.
128
+ // Skip caching rather than risk serving the wrong user's response.
129
+ if (scope === 'tenant_user' && !userId) {
130
+ return next();
131
+ }
132
+
109
133
  const ttl = apiConfig.ttl || cacheConfig.defaultTTL || 300000;
110
134
  const queryHash = hashQuery(req.query);
135
+ const userPart = (scope === 'tenant_user') ? `:u:${userId}` : '';
111
136
  const key = {
112
137
  'l1': req.soajs.tenant.id,
113
- 'l2': `${serviceName}:GET:${apiPath}:${queryHash}`
138
+ 'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
114
139
  };
115
140
 
116
141
  const processCache = async () => {
@@ -25,6 +25,10 @@ let model = {
25
25
  cleanupInterval = setInterval(() => {
26
26
  model.cleanupExpiredEntries();
27
27
  }, CLEANUP_INTERVAL);
28
+ // Do not keep the event loop alive solely for the cleanup timer.
29
+ if (cleanupInterval.unref) {
30
+ cleanupInterval.unref();
31
+ }
28
32
  },
29
33
 
30
34
  'cleanupExpiredEntries': () => {
@@ -25,6 +25,10 @@ let model = {
25
25
  cleanupInterval = setInterval(() => {
26
26
  model.cleanupExpiredEntries();
27
27
  }, CLEANUP_INTERVAL);
28
+ // Do not keep the event loop alive solely for the cleanup timer.
29
+ if (cleanupInterval.unref) {
30
+ cleanupInterval.unref();
31
+ }
28
32
  },
29
33
 
30
34
  'cleanupExpiredEntries': () => {
@@ -25,6 +25,10 @@ let model = {
25
25
  cleanupInterval = setInterval(() => {
26
26
  model.cleanupExpiredEntries();
27
27
  }, CLEANUP_INTERVAL);
28
+ // Do not keep the event loop alive solely for the cleanup timer.
29
+ if (cleanupInterval.unref) {
30
+ cleanupInterval.unref();
31
+ }
28
32
  },
29
33
 
30
34
  'cleanupExpiredEntries': () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.5",
4
+ "version": "4.3.6",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -221,7 +221,7 @@ describe("Unit test for: mw - cache", () => {
221
221
  query: {},
222
222
  soajs: {
223
223
  tenant: { id: 'tenant-cache-miss' },
224
- controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
224
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
225
225
  registry: {
226
226
  custom: {
227
227
  gateway: {
@@ -264,7 +264,7 @@ describe("Unit test for: mw - cache", () => {
264
264
  query: {},
265
265
  soajs: {
266
266
  tenant: { id: 'tenant-param-cache' },
267
- controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'users', '123'] } },
267
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'users', '123'], isAPIPublic: true } },
268
268
  registry: {
269
269
  custom: {
270
270
  gateway: {
@@ -306,7 +306,7 @@ describe("Unit test for: mw - cache", () => {
306
306
  query: {},
307
307
  soajs: {
308
308
  tenant: { id: 'tenant-default-ttl' },
309
- controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
309
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
310
310
  registry: {
311
311
  custom: {
312
312
  gateway: {
@@ -345,7 +345,7 @@ describe("Unit test for: mw - cache", () => {
345
345
  query: { page: '1', limit: '10' },
346
346
  soajs: {
347
347
  tenant: { id: 'tenant-query' },
348
- controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
348
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
349
349
  registry: {
350
350
  custom: {
351
351
  gateway: {
@@ -381,7 +381,7 @@ describe("Unit test for: mw - cache", () => {
381
381
  query: {},
382
382
  soajs: {
383
383
  tenant: { id: 'tenant-empty-query' },
384
- controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
384
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
385
385
  registry: {
386
386
  custom: {
387
387
  gateway: {
@@ -458,4 +458,121 @@ describe("Unit test for: mw - cache", () => {
458
458
  });
459
459
  });
460
460
 
461
+ describe("scope handling (public vs private)", () => {
462
+ let noopRes = {
463
+ setHeader: function() {},
464
+ writeHead: function() { return this; },
465
+ write: function() { return true; },
466
+ end: function() {}
467
+ };
468
+
469
+ function buildReq(serviceParamsExtra, uracDriver, apiConfig) {
470
+ return {
471
+ method: 'GET',
472
+ query: {},
473
+ soajs: {
474
+ tenant: { id: 'tenant-scope' },
475
+ uracDriver: uracDriver,
476
+ controller: {
477
+ serviceParams: Object.assign({ name: 'test', serviceInfo: ['', 'test', 'test'] }, serviceParamsExtra)
478
+ },
479
+ registry: {
480
+ custom: {
481
+ gateway: {
482
+ value: {
483
+ cache: {
484
+ model: 'memory',
485
+ services: {
486
+ test: { enabled: true, apis: { 'GET /test': Object.assign({ enabled: true, ttl: 30000 }, apiConfig) } }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+ }
494
+ };
495
+ }
496
+
497
+ it("public API: should cache scoped to tenant only (no user in key)", (done) => {
498
+ let req = buildReq({ isAPIPublic: true }, { id: 'user-1' }, {});
499
+ let functionMw = mw(mockConfig);
500
+ functionMw(req, noopRes, (error) => {
501
+ assert.ifError(error);
502
+ assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
503
+ assert.strictEqual(req.soajs.cacheContext.key.l1, 'tenant-scope');
504
+ assert.strictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:'), -1, 'public key must not contain user part');
505
+ done();
506
+ });
507
+ });
508
+
509
+ it("private API: should default to tenant_user scope (user in key)", (done) => {
510
+ let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, {});
511
+ let functionMw = mw(mockConfig);
512
+ functionMw(req, noopRes, (error) => {
513
+ assert.ifError(error);
514
+ assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
515
+ assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'private key must contain user part');
516
+ done();
517
+ });
518
+ });
519
+
520
+ it("private API: different users get different cache keys", (done) => {
521
+ let functionMw = mw(mockConfig);
522
+ let reqA = buildReq({ isAPIPublic: false }, { id: 'user-A' }, {});
523
+ let reqB = buildReq({ isAPIPublic: false }, { id: 'user-B' }, {});
524
+ functionMw(reqA, noopRes, (errA) => {
525
+ assert.ifError(errA);
526
+ functionMw(reqB, noopRes, (errB) => {
527
+ assert.ifError(errB);
528
+ assert.notStrictEqual(reqA.soajs.cacheContext.key.l2, reqB.soajs.cacheContext.key.l2, 'different users must not share a key');
529
+ done();
530
+ });
531
+ });
532
+ });
533
+
534
+ it("private API without resolved user: should skip caching (no cacheContext)", (done) => {
535
+ let req = buildReq({ isAPIPublic: false }, null, {});
536
+ let functionMw = mw(mockConfig);
537
+ functionMw(req, noopRes, (error) => {
538
+ assert.ifError(error);
539
+ assert.strictEqual(req.soajs.cacheContext, undefined, 'must not set cacheContext when user is unresolved');
540
+ done();
541
+ });
542
+ });
543
+
544
+ it("scope override: private API forced to tenant scope", (done) => {
545
+ let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, { scope: 'tenant' });
546
+ let functionMw = mw(mockConfig);
547
+ functionMw(req, noopRes, (error) => {
548
+ assert.ifError(error);
549
+ assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
550
+ assert.strictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:'), -1, 'overridden tenant scope must not contain user part');
551
+ done();
552
+ });
553
+ });
554
+
555
+ it("scope override: public API forced to tenant_user scope", (done) => {
556
+ let req = buildReq({ isAPIPublic: true }, { id: 'user-1' }, { scope: 'tenant_user' });
557
+ let functionMw = mw(mockConfig);
558
+ functionMw(req, noopRes, (error) => {
559
+ assert.ifError(error);
560
+ assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
561
+ assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'overridden tenant_user scope must contain user part');
562
+ done();
563
+ });
564
+ });
565
+
566
+ it("invalid scope: should fall back to default (private -> tenant_user)", (done) => {
567
+ let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, { scope: 'bogus' });
568
+ let functionMw = mw(mockConfig);
569
+ functionMw(req, noopRes, (error) => {
570
+ assert.ifError(error);
571
+ assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
572
+ assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'invalid scope must fall back to tenant_user for private API');
573
+ done();
574
+ });
575
+ });
576
+ });
577
+
461
578
  });