@harshankur/viewcounter 3.4.0 → 3.5.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/.env.example CHANGED
@@ -127,6 +127,17 @@ RATE_LIMIT_MAX=100
127
127
  # Set 0 to disable (sensible for a single-tenant deployment).
128
128
  APP_RATE_LIMIT_MAX=1000
129
129
 
130
+ # An app's own figures, where the general ones above do not fit it: a list of
131
+ # appId:number. A site that records far more per visitor than the others (every
132
+ # step inside a single-page app, say) gets the room it needs without loosening
133
+ # the limits for every other app. An app listed in RATE_LIMIT_MAX_BY_APP is
134
+ # counted on its own per address, so its traffic does not use up the address's
135
+ # budget for other apps. Zero means no limit, here as in the general figures
136
+ # above. A malformed entry, or an app listed twice, stops the server from
137
+ # starting; a figure for an app that is not configured is warned about.
138
+ #RATE_LIMIT_MAX_BY_APP=homepage:600
139
+ #APP_RATE_LIMIT_MAX_BY_APP=homepage:5000
140
+
130
141
  # How long a visitor counts as "the same visitor" for deduplication, in hours.
131
142
  # Also the rotation period for the visitor hash: after this window the same
132
143
  # person hashes differently, so their visits cannot be linked across windows.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Documentation](https://img.shields.io/badge/docs-viewcounter.harshankur.com-blueviolet)](https://viewcounter.harshankur.com)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Test Suite](https://github.com/harshankur/viewcounter/actions/workflows/test.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
6
- [![Tests](https://img.shields.io/badge/tests-1134%20passing-success)](TEST_REPORT.md)
6
+ [![Tests](https://img.shields.io/badge/tests-1164%20passing-success)](TEST_REPORT.md)
7
7
  [![npm](https://img.shields.io/npm/v/@harshankur/viewcounter?logo=npm)](https://www.npmjs.com/package/@harshankur/viewcounter)
8
8
  [![provenance](https://img.shields.io/badge/provenance-signed-brightgreen?logo=github)](https://www.npmjs.com/package/@harshankur/viewcounter#provenance)
9
9
 
@@ -190,7 +190,7 @@ than running on a guessable default:
190
190
  - `ADMIN_PASSWORD`: turns on the [admin UI](#admin-ui) at `/admin`. At least 16 characters. Unset means the admin UI does not exist.
191
191
 
192
192
  **Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
193
- `RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
193
+ `RATE_LIMIT_MAX`, `RATE_LIMIT_MAX_BY_APP`, `APP_RATE_LIMIT_MAX_BY_APP`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
194
194
  `TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS`, `ADMIN_SESSION_IDLE_TIMEOUT`,
195
195
  `ADMIN_SESSION_MAX_AGE`, `GEOIP_CITY_DB`.
196
196
 
@@ -867,6 +867,26 @@ Two independent limits apply to writes:
867
867
  everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
868
868
  be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
869
869
 
870
+ Either can be set for one app where the general figure does not fit it, as a
871
+ list of `appId:number`:
872
+
873
+ - `RATE_LIMIT_MAX_BY_APP=homepage:600`: tracking requests a minute from one
874
+ address to that app. Such an app is counted on its own, so its traffic does
875
+ not use up the address's budget for your other apps, nor the other way round.
876
+ - `APP_RATE_LIMIT_MAX_BY_APP=homepage:5000`: that app's whole budget.
877
+
878
+ Use these for a site that records far more per visitor than the others, such
879
+ as a single-page app that counts every step inside it.
880
+
881
+ - **Zero means no limit**, in all four settings: as a general figure it
882
+ switches that limit off, as an app's own figure it lifts it for that app.
883
+ - **Only tracking requests** (`/registerView`, `/event`, `/engage`) are limited
884
+ as an app, and always as the app they are stored under. A read or admin call
885
+ is limited by the general figure whatever it names.
886
+ - **Mistakes are not silent.** A malformed entry, or an app listed twice, stops
887
+ the server from starting. A figure for an app that is not configured is
888
+ logged as a warning at start, since it would otherwise never apply.
889
+
870
890
  Each limit is applied twice, as two separate budgets of that size: one for
871
891
  engagement reports (`/engage`) and one for everything else. A page being read
872
892
  reports every half minute, so each open, active tab costs two reports a minute;
@@ -930,7 +950,10 @@ app.use('/analytics', createAnalyticsRouter({
930
950
  privacy: { visitorSecret: process.env.VISITOR_SECRET },
931
951
  server: {
932
952
  uniqueVisitorWindowHours: 24,
933
- // omit to disable the per-app write budget
953
+ // perAppMax: the per-app write budget (omit to disable it).
954
+ // max: per address, applied by the router to engagement reports only;
955
+ // every other request is yours to limit, as below.
956
+ // perAppMaxByApp / maxByApp: { appId: number } for apps with their own figures.
934
957
  rateLimit: { windowMs: 60000, perAppMax: 1000 },
935
958
  },
936
959
  },
@@ -940,8 +963,10 @@ app.use('/analytics', createAnalyticsRouter({
940
963
  Endpoints then live under the prefix: `POST /analytics/event`,
941
964
  `GET /analytics/stats/blog`, and so on.
942
965
 
943
- Two things the host application owns in this mode, because the router does not
944
- install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
966
+ Three things the host application owns in this mode, because the router does
967
+ not install them itself: `helmet()` and the CORS allowlist, a per-address rate
968
+ limit (the router limits only engagement reports per address, and only when
969
+ `rateLimit.max` is given), and `trust proxy`. Set
945
970
  `app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
946
971
  their own IP through `X-Forwarded-For`.
947
972
 
package/config/index.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  SCOPE_ALL,
13
13
  SERVER,
14
14
  } = require('../constants');
15
- const { filterValidAppIds } = require('../utils/appIdUtils');
15
+ const { filterValidAppIds, isValidAppId } = require('../utils/appIdUtils');
16
16
  const { parseDuration, formatDuration } = require('../utils/durationUtils');
17
17
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
18
18
  const { LogLevel } = require('../utils/logger');
@@ -47,6 +47,30 @@ function parseList(raw) {
47
47
  .filter(Boolean);
48
48
  }
49
49
 
50
+ /**
51
+ * Parse per-app figures, `appId:number` separated by commas
52
+ * (`homepage:600,shop:0`), into a map. A malformed entry is an error: a
53
+ * limit silently not applied is worse than a refusal to start.
54
+ * @param {string|undefined} raw
55
+ * @param {string} field the setting's name, for the error
56
+ * @returns {Record<string, number>}
57
+ */
58
+ function parseAppNumbers(raw, field) {
59
+ const figures = {};
60
+ for (const entry of parseList(raw)) {
61
+ const [appId, value, ...rest] = entry.split(':').map((part) => part.trim());
62
+ if (rest.length || !isValidAppId(appId) || !/^\d+$/.test(value || '')) {
63
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, { field, reason: `'${entry}' is not appId:number, such as homepage:600` });
64
+ }
65
+ // Two figures for one app is a mistake, and guessing which was meant is not ours to do.
66
+ if (Object.hasOwn(figures, appId)) {
67
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, { field, reason: `'${appId}' is listed twice` });
68
+ }
69
+ figures[appId] = Number(value);
70
+ }
71
+ return figures;
72
+ }
73
+
50
74
  /** Parse an integer env var, falling back when absent or unparseable. */
51
75
  function parseIntOr(raw, fallback) {
52
76
  const parsed = Number.parseInt(raw, 10);
@@ -130,6 +154,9 @@ class Config {
130
154
  // Per-app ceiling on writes, so one tenant cannot exhaust the
131
155
  // budget the others depend on.
132
156
  perAppMax: parseIntOr(this.env.APP_RATE_LIMIT_MAX, SERVER.DEFAULT_APP_RATE_LIMIT_MAX),
157
+ // An app's own figures, where the general ones do not fit it.
158
+ maxByApp: parseAppNumbers(this.env.RATE_LIMIT_MAX_BY_APP, 'RATE_LIMIT_MAX_BY_APP'),
159
+ perAppMaxByApp: parseAppNumbers(this.env.APP_RATE_LIMIT_MAX_BY_APP, 'APP_RATE_LIMIT_MAX_BY_APP'),
133
160
  },
134
161
  uniqueVisitorWindowHours: parseIntOr(
135
162
  this.env.UNIQUE_VISITOR_WINDOW_HOURS,
@@ -391,6 +418,7 @@ class Config {
391
418
  void this.privacy.visitorSecret;
392
419
 
393
420
  this.validateAdmin();
421
+ this.warnAboutUnknownRateLimitApps();
394
422
 
395
423
  if (!this.server.isProduction) {
396
424
  this.warnAboutDevelopmentDefaults();
@@ -457,6 +485,21 @@ class Config {
457
485
  }
458
486
 
459
487
  /** Surface the same problems as warnings outside production. */
488
+ /**
489
+ * A per-app rate limit figure for an app this configuration does not
490
+ * list is most likely a typo, and would otherwise never apply without a
491
+ * word. It is a warning, not an error: an app may also be registered
492
+ * while the server runs.
493
+ */
494
+ warnAboutUnknownRateLimitApps() {
495
+ const { maxByApp = {}, perAppMaxByApp = {} } = this.server.rateLimit;
496
+ for (const [field, figures] of [['RATE_LIMIT_MAX_BY_APP', maxByApp], ['APP_RATE_LIMIT_MAX_BY_APP', perAppMaxByApp]]) {
497
+ for (const appId of Object.keys(figures)) {
498
+ if (!this.allowed.appId.includes(appId)) logWarning(WarningType.RATE_LIMIT_UNKNOWN_APP, { field, appId });
499
+ }
500
+ }
501
+ }
502
+
460
503
  warnAboutDevelopmentDefaults() {
461
504
  if (Object.keys(this.auth.readKeyScopes).length === 0) {
462
505
  logWarning(WarningType.READ_API_UNPROTECTED);
package/index.js CHANGED
@@ -7,7 +7,7 @@ const config = require('./config');
7
7
  const DatabaseManager = require('./db/DatabaseManager');
8
8
  const logger = require('./utils/logger');
9
9
  const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
10
- const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
10
+ const { createAnalyticsRouter, buildPerIpLimiter, trackingSourceFor } = require('./routes/analytics');
11
11
  const { createAdminRouter } = require('./routes/admin');
12
12
  const { startRetention } = require('./db/retention');
13
13
  const { createDbSessionStore } = require('./db/adminSessionStore');
@@ -80,7 +80,8 @@ function createApp() {
80
80
 
81
81
  // A tracking request turned away here is counted in the tracking log like
82
82
  // any other refusal (in memory, written in batches).
83
- app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
83
+ // (Engagement reports have a limiter of their own, on their route.)
84
+ app.use(buildPerIpLimiter(config.server.rateLimit, (req) => {
84
85
  if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
85
86
  }));
86
87
 
@@ -70,6 +70,23 @@ function requestOrigin(req) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * The app a tracking request is for, read from the one place its route
75
+ * validates and stores: the query of a GET, the body of anything else.
76
+ *
77
+ * Everything that decides by app before validation (the origin check, the
78
+ * rate limits) must read it here. Reading "query, or else body" let a POST
79
+ * name one app in its query, be checked and limited as that app, and then be
80
+ * stored under the other app named in its body.
81
+ *
82
+ * @param {import('express').Request} req
83
+ * @returns {string} the appId, or '' when absent or not a string
84
+ */
85
+ function requestedAppId(req) {
86
+ const appId = req.method === 'GET' ? req.query?.appId : req.body?.appId;
87
+ return typeof appId === 'string' ? appId : '';
88
+ }
89
+
73
90
  /**
74
91
  * Bind writes for an appId to the site origins registered for it.
75
92
  *
@@ -92,8 +109,8 @@ function requireRegisteredOrigin(allowed, { onReject = () => {} } = {}) {
92
109
  const origins = allowed?.origins || {};
93
110
 
94
111
  return (req, res, next) => {
95
- const appId = req.query.appId || req.body?.appId;
96
- const registered = origins[appId];
112
+ const appId = requestedAppId(req);
113
+ const registered = Object.hasOwn(origins, appId) ? origins[appId] : undefined;
97
114
 
98
115
  if (!Array.isArray(registered) || registered.length === 0) {
99
116
  return next();
@@ -128,6 +145,7 @@ module.exports = {
128
145
  buildCorsOptions,
129
146
  countRefusedPreflights,
130
147
  requireRegisteredOrigin,
148
+ requestedAppId,
131
149
  requestOrigin,
132
150
  noStore,
133
151
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@harshankur/viewcounter",
3
3
  "description": "A middleware backend server that registers views to my db server when requested to register a view from my other projects.",
4
- "version": "3.4.0",
4
+ "version": "3.5.0",
5
5
  "main": "index.js",
6
6
  "engines": {
7
7
  "node": ">=24"
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const express = require('express');
5
5
  const rateLimit = require('express-rate-limit');
6
+ const { ipKeyGenerator } = rateLimit;
6
7
  const geoip = require('geoip-country');
7
8
 
8
9
  const {
@@ -21,7 +22,7 @@ const PrivacyUtils = require('../utils/privacyUtils');
21
22
  const logger = require('../utils/logger');
22
23
  const { getClientIp, isValidIP, normalizeIp } = require('../utils/ipUtils');
23
24
  const { requireReadApiKey, requireAppScope, requireAdminApiKey, appsInScope } = require('../middleware/auth');
24
- const { requireRegisteredOrigin, requestOrigin, noStore } = require('../middleware/security');
25
+ const { requireRegisteredOrigin, requestedAppId, requestOrigin, noStore } = require('../middleware/security');
25
26
  const { createRejectionCounter } = require('../db/rejectionCounter');
26
27
  const { hostnameOf, primaryLanguage, utmTags } = require('../utils/visitorContext');
27
28
  const {
@@ -132,21 +133,24 @@ function intQuery(req, name, fallback) {
132
133
  * @returns {import('express').RequestHandler}
133
134
  */
134
135
  function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
135
- const { perAppMax, windowMs } = rateLimitConfig || {};
136
- // Zero disables it, for single-tenant deployments where the per-IP limit
137
- // is the only bound that means anything.
138
- if (!perAppMax || perAppMax <= 0) return (req, res, next) => next();
136
+ const { perAppMax, windowMs, perAppMaxByApp = {} } = rateLimitConfig || {};
137
+ // An app's own figure wins over the general one; zero means no ceiling.
138
+ const limitOf = (req) => (Object.hasOwn(perAppMaxByApp, appOf(req)) ? perAppMaxByApp[appOf(req)] : perAppMax) || 0;
139
+ // Nothing set anywhere disables it, for single-tenant deployments where
140
+ // the per-IP limit is the only bound that means anything.
141
+ if (!(perAppMax > 0) && !Object.values(perAppMaxByApp).some((value) => value > 0)) return (req, res, next) => next();
139
142
 
140
143
  return rateLimit({
141
144
  windowMs,
142
- limit: perAppMax,
145
+ limit: limitOf,
146
+ skip: (req) => limitOf(req) <= 0,
143
147
  standardHeaders: true,
144
148
  legacyHeaders: false,
145
149
  message: { message: 'This app has exceeded its request budget, please try again later.' },
146
150
  // A request with no appId lands in one shared bucket rather than
147
151
  // falling back to the IP, which would reintroduce the address-rotation
148
152
  // bypass this limiter exists to be immune to.
149
- keyGenerator: (req) => String(req.query?.appId || req.body?.appId || '__unattributed__'),
153
+ keyGenerator: (req) => appOf(req) || '__unattributed__',
150
154
  validate: { keyGeneratorIpFallback: false },
151
155
  handler: (req, res, next, options) => {
152
156
  onLimit(req);
@@ -159,34 +163,71 @@ function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
159
163
  const isEngagement = (req) => trackingSourceFor(req.path) === VIEW_LOG_SOURCE.ENGAGE;
160
164
 
161
165
  /**
162
- * The per-IP limiters: one budget for engagement reports, one for everything
163
- * else, each of `max` requests per window.
166
+ * The app a request is limited as: for a tracking request, the app its route
167
+ * goes on to validate and store under; for anything else, none. A read or
168
+ * admin call cannot name an app to borrow that app's figures.
169
+ */
170
+ const appOf = (req) => (trackingSourceFor(req.path) ? requestedAppId(req) : '');
171
+
172
+ /**
173
+ * A per-IP limiter: `max` requests per window from one address, or the app's
174
+ * own figure (`maxByApp`) for tracking requests to an app that has one. Zero
175
+ * means no limit, as the general figure or as an app's own.
164
176
  *
165
- * They are separate because a page being read reports its engagement every
177
+ * There are two of these, with separate budgets: one for engagement reports
178
+ * and one for everything else. A page being read reports its engagement every
166
179
  * half minute. On one shared budget, a few dozen readers behind one address
167
180
  * (an office, a campus) would use it up with those reports alone, and the page
168
181
  * views of everyone at that address would be refused. Apart, reports can only
169
182
  * ever crowd out other reports.
170
183
  *
171
- * @param {{ max: number, windowMs: number }} rateLimitConfig
184
+ * An app with its own figure is counted on its own, per address: a site that
185
+ * records far more per visitor than the others (every step inside a
186
+ * single-page app, say) gets the room it needs without loosening the limit
187
+ * for every other app, and without using up the address's budget for them.
188
+ *
189
+ * @param {{ max: number, windowMs: number, maxByApp?: Record<string, number> }} rateLimitConfig
172
190
  * @param {(req: import('express').Request) => void} [onLimit] called for a refused request
173
- * @returns {import('express').RequestHandler[]}
191
+ * @param {{ engagement?: boolean }} [options] true for the limiter of engagement
192
+ * reports, which is mounted on that route so it can know the app; false
193
+ * (the default) for the limiter of everything else, mounted app-wide
194
+ * @returns {import('express').RequestHandler}
174
195
  */
175
- function buildPerIpLimiters(rateLimitConfig, onLimit = () => {}) {
176
- const { max, windowMs } = rateLimitConfig || {};
177
- const limiter = (skip) => rateLimit({
196
+ function buildPerIpLimiter(rateLimitConfig, onLimit = () => {}, { engagement = false } = {}) {
197
+ const { max = 0, windowMs, maxByApp = {} } = rateLimitConfig || {};
198
+ const ownFigure = (req) => (Object.hasOwn(maxByApp, appOf(req)) ? maxByApp[appOf(req)] : null);
199
+ const pass = (req, res, next) => next();
200
+ const shared = {
178
201
  windowMs,
179
- limit: max,
180
202
  message: { message: 'Too many requests, please try again later.' },
181
203
  standardHeaders: true,
182
204
  legacyHeaders: false,
183
- skip,
184
205
  handler: (req, res, next, options) => {
185
206
  onLimit(req);
186
207
  res.status(options.statusCode).json(options.message);
187
208
  },
188
- });
189
- return [limiter(isEngagement), limiter((req) => !isEngagement(req))];
209
+ };
210
+
211
+ // The general budget keeps the library's own key (the address) and, with
212
+ // it, the library's checks for a misconfigured proxy.
213
+ const general = max > 0 ? rateLimit({ ...shared, limit: max }) : pass;
214
+ // Apps with a figure of their own: one counter per address and app.
215
+ const own = Object.values(maxByApp).some((value) => value > 0)
216
+ ? rateLimit({
217
+ ...shared,
218
+ limit: (req) => ownFigure(req),
219
+ keyGenerator: (req) => `${ipKeyGenerator(req.ip)}|${appOf(req)}`,
220
+ })
221
+ : pass;
222
+
223
+ return (req, res, next) => {
224
+ // The reports' limiter is the one on their route: POST /engage.
225
+ const isReport = isEngagement(req) && req.method === 'POST';
226
+ if (isReport !== engagement) return next();
227
+ const figure = ownFigure(req);
228
+ if (figure === null) return general(req, res, next);
229
+ return figure > 0 ? own(req, res, next) : next();
230
+ };
190
231
  }
191
232
 
192
233
  /**
@@ -271,6 +312,11 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
271
312
  // views refused because of the reports those readers' pages send.
272
313
  const limitEngagePerApp = buildPerAppLimiter(config.server?.rateLimit,
273
314
  (req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
315
+ // Per address too. This one sits on the route, not app-wide with the
316
+ // other, so it runs after the beacon's body is read and knows the app.
317
+ const readBeacon = express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES });
318
+ const limitEngagePerIp = buildPerIpLimiter(config.server?.rateLimit,
319
+ (req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' }), { engagement: true });
274
320
  const trackingValidation = handleTrackingValidation(reject);
275
321
 
276
322
  /** Views from these are counted in the tracking log and never stored. */
@@ -466,8 +512,12 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
466
512
  * far it was scrolled, sent by the tracker when the page is hidden or left.
467
513
  */
468
514
  router.post('/engage',
469
- express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES }),
515
+ // A body that cannot be read (too large, say) still counts against
516
+ // the address before it is refused: the app is unknown, so by the
517
+ // general figure.
518
+ (req, res, next) => readBeacon(req, res, (error) => (error ? limitEngagePerIp(req, res, () => next(error)) : next())),
470
519
  parseBeaconBody,
520
+ limitEngagePerIp,
471
521
  limitEngagePerApp,
472
522
  requireOrigin,
473
523
  validateEngage(config.allowed),
@@ -680,4 +730,4 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
680
730
  return router;
681
731
  }
682
732
 
683
- module.exports = { createAnalyticsRouter, buildPerIpLimiters, handleRouteError, logContext, withRequestId, trackingSourceFor };
733
+ module.exports = { createAnalyticsRouter, buildPerIpLimiter, handleRouteError, logContext, withRequestId, trackingSourceFor };
@@ -49,6 +49,7 @@ const WarningType = {
49
49
  ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
50
50
  TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
51
51
  SALT_PRUNE_FAILED: 'SALT_PRUNE_FAILED',
52
+ RATE_LIMIT_UNKNOWN_APP: 'RATE_LIMIT_UNKNOWN_APP',
52
53
  };
53
54
 
54
55
  /**
@@ -125,6 +126,8 @@ const WARNING_MESSAGES = {
125
126
  `Automatic view log pruning failed: ${info?.cause}`,
126
127
  [WarningType.TRASH_PURGE_FAILED]: (info) =>
127
128
  `Automatic trash purge failed for '${info?.appId}': ${info?.cause}`,
129
+ [WarningType.RATE_LIMIT_UNKNOWN_APP]: (info) =>
130
+ `${info?.field} names '${info?.appId}', which is not one of the configured apps: its figure applies only if an app of that name is registered later`,
128
131
  [WarningType.SALT_PRUNE_FAILED]: (info) =>
129
132
  `Could not delete the visitor salts of ended windows (it is tried again shortly): ${info?.cause}`,
130
133
  };