@harshankur/viewcounter 3.0.1 → 3.1.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 (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Schema for the admin feature: per-app admin columns, the two log tables, and
3
+ * the idempotent migration that brings an existing deployment up to date.
4
+ *
5
+ * Every statement here is additive. Nothing is dropped or rewritten, so a
6
+ * deployment can move to this version and back without losing a row.
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+
11
+ const {
12
+ ADMIN_LOG_TABLE,
13
+ DATABASE,
14
+ FIELD_MAX_LENGTH,
15
+ VIEW_LOG_TABLE,
16
+ } = require('../constants');
17
+ const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
18
+ const { isValidAppId } = require('../utils/appIdUtils');
19
+
20
+ /**
21
+ * Columns added to every app table.
22
+ *
23
+ * `public_id` is the identity the admin API and UI use (CODE_STANDARDS.md §8):
24
+ * the auto-increment `id` is enumerable and reveals how many rows exist, so it
25
+ * never leaves the server. It starts nullable only so an existing table can be
26
+ * backfilled; the migration then makes it NOT NULL and unique.
27
+ *
28
+ * `admin_modified_at` is the "modified by an admin" marker: NULL means the row
29
+ * is exactly as observed, a timestamp says when an admin last changed its
30
+ * content. `deleted_at` is the soft-delete marker.
31
+ */
32
+ const ADMIN_COLUMNS = [
33
+ { name: 'public_id', ddl: `CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL` },
34
+ { name: 'note', ddl: `VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL` },
35
+ { name: 'admin_modified_at', ddl: 'DATETIME DEFAULT NULL' },
36
+ { name: 'deleted_at', ddl: 'DATETIME DEFAULT NULL' },
37
+ ];
38
+
39
+ /** Indexes the admin columns need, keyed by index name. */
40
+ const ADMIN_INDEXES = {
41
+ uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
42
+ idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
43
+ idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
44
+ };
45
+
46
+ /**
47
+ * The admin operation log: who did what, when, to which rows.
48
+ *
49
+ * Deliberately records WHICH fields an edit touched, never their values, and
50
+ * never an IP beyond its masked form. A log that kept copies of row content
51
+ * would survive the row's permanent erasure and defeat it (GDPR Art. 17).
52
+ */
53
+ const ADMIN_LOG_DDL = `
54
+ CREATE TABLE IF NOT EXISTS \`${ADMIN_LOG_TABLE}\` (
55
+ \`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
56
+ \`created_at\` DATETIME(3) NOT NULL,
57
+ \`action\` VARCHAR(32) NOT NULL,
58
+ \`session_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL,
59
+ \`masked_ip\` VARCHAR(${FIELD_MAX_LENGTH.MASKED_IP}) DEFAULT NULL,
60
+ \`app_id\` VARCHAR(64) DEFAULT NULL,
61
+ \`target_count\` INT NOT NULL DEFAULT 0,
62
+ \`target_ids\` JSON DEFAULT NULL,
63
+ \`fields\` JSON DEFAULT NULL,
64
+ INDEX \`idx_created_at\` (\`created_at\`),
65
+ INDEX \`idx_action\` (\`action\`),
66
+ INDEX \`idx_app_id\` (\`app_id\`)
67
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
68
+ `;
69
+
70
+ /**
71
+ * The view register log: one entry per accepted view or event.
72
+ *
73
+ * Independent of the app tables on purpose, so it still says a view was
74
+ * reported, and when, after an admin edits, deletes, or erases that view. It
75
+ * holds no IP, hash, or User-Agent, so it contains no personal data to erase.
76
+ */
77
+ const VIEW_LOG_DDL = `
78
+ CREATE TABLE IF NOT EXISTS \`${VIEW_LOG_TABLE}\` (
79
+ \`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
80
+ \`created_at\` DATETIME(3) NOT NULL,
81
+ \`app_id\` VARCHAR(64) NOT NULL,
82
+ \`source\` VARCHAR(16) NOT NULL,
83
+ \`view_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
84
+ \`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT NULL,
85
+ \`is_unique\` TINYINT(1) NOT NULL,
86
+ INDEX \`idx_created_at\` (\`created_at\`),
87
+ INDEX \`idx_app_created\` (\`app_id\`, \`created_at\`)
88
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
89
+ `;
90
+
91
+ /**
92
+ * Column and index definitions for a brand-new app table, spliced into
93
+ * `appTableDDL` so a fresh table is born in the migrated shape.
94
+ */
95
+ const NEW_TABLE_ADMIN_COLUMNS = [
96
+ `\`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`,
97
+ `\`note\` VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL`,
98
+ '`admin_modified_at` DATETIME DEFAULT NULL',
99
+ '`deleted_at` DATETIME DEFAULT NULL',
100
+ ];
101
+
102
+ const NEW_TABLE_ADMIN_INDEXES = Object.values(ADMIN_INDEXES);
103
+
104
+ /**
105
+ * @param {object} pool mysql2 promise pool
106
+ * @param {string} table
107
+ * @returns {Promise<Map<string, {nullable: boolean}>>} existing columns
108
+ */
109
+ async function readColumns(pool, table) {
110
+ const [rows] = await pool.query(
111
+ `SELECT COLUMN_NAME AS name, IS_NULLABLE AS nullable
112
+ FROM information_schema.COLUMNS
113
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
114
+ [table]
115
+ );
116
+ return new Map(rows.map((row) => [row.name, { nullable: row.nullable === 'YES' }]));
117
+ }
118
+
119
+ /**
120
+ * @param {object} pool
121
+ * @param {string} table
122
+ * @returns {Promise<Set<string>>} existing index names
123
+ */
124
+ async function readIndexes(pool, table) {
125
+ const [rows] = await pool.query(
126
+ `SELECT DISTINCT INDEX_NAME AS name
127
+ FROM information_schema.STATISTICS
128
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
129
+ [table]
130
+ );
131
+ return new Set(rows.map((row) => row.name));
132
+ }
133
+
134
+ /**
135
+ * Give every row without a public_id a fresh UUID, a batch at a time.
136
+ *
137
+ * Generated in Node with a CSPRNG rather than MySQL's UUID(), which is a
138
+ * time-and-host based v1 value (CODE_STANDARDS.md §8), and batched so a large
139
+ * table is not rewritten in one giant statement. Each batch resumes after the
140
+ * last id it saw (keyset pagination on the primary key), so the whole backfill
141
+ * reads every row once: restarting from the top each time would rescan all
142
+ * rows already done, quadratic in table size, and on a large table a single
143
+ * scan would outlast the pool's statement timeout and fail the migration.
144
+ *
145
+ * @param {object} pool
146
+ * @param {string} table already validated
147
+ * @returns {Promise<number>} rows backfilled
148
+ */
149
+ async function backfillPublicIds(pool, table) {
150
+ let total = 0;
151
+ let lastId = 0;
152
+
153
+ for (;;) {
154
+ const [rows] = await pool.query(
155
+ `SELECT id FROM \`${table}\` WHERE id > ? AND public_id IS NULL ORDER BY id LIMIT ?`,
156
+ [lastId, DATABASE.BACKFILL_BATCH_SIZE]
157
+ );
158
+ if (rows.length === 0) return total;
159
+
160
+ const ids = rows.map((row) => row.id);
161
+ lastId = ids[ids.length - 1];
162
+ const cases = ids.map(() => 'WHEN ? THEN ?').join(' ');
163
+ const params = ids.flatMap((id) => [id, crypto.randomUUID()]);
164
+
165
+ await pool.query(
166
+ `UPDATE \`${table}\` SET public_id = CASE id ${cases} END WHERE id IN (?)`,
167
+ [...params, ids]
168
+ );
169
+ total += ids.length;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Bring one app table up to the admin schema. Idempotent: a table already in
175
+ * shape is read and left alone.
176
+ *
177
+ * @param {object} pool
178
+ * @param {string} appId
179
+ * @returns {Promise<{migrated: boolean, backfilled: number}>}
180
+ * @throws {Error} ErrorType.MIGRATION_FAILED, so startup fails loudly rather
181
+ * than serving an admin UI over a half-migrated table
182
+ */
183
+ async function migrateAppTable(pool, appId) {
184
+ if (!isValidAppId(appId)) {
185
+ throw getError(ErrorType.INVALID_APP_ID, { appId });
186
+ }
187
+
188
+ try {
189
+ const columns = await readColumns(pool, appId);
190
+ if (columns.size === 0) {
191
+ logWarning(WarningType.MIGRATION_TABLE_MISSING, { table: appId });
192
+ return { migrated: false, backfilled: 0 };
193
+ }
194
+
195
+ for (const column of ADMIN_COLUMNS) {
196
+ if (!columns.has(column.name)) {
197
+ await pool.query(`ALTER TABLE \`${appId}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
198
+ }
199
+ }
200
+
201
+ const backfilled = await backfillPublicIds(pool, appId);
202
+
203
+ const publicId = columns.get('public_id');
204
+ if (!publicId || publicId.nullable) {
205
+ await pool.query(
206
+ `ALTER TABLE \`${appId}\` MODIFY \`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`
207
+ );
208
+ }
209
+
210
+ const indexes = await readIndexes(pool, appId);
211
+ for (const [name, definition] of Object.entries(ADMIN_INDEXES)) {
212
+ if (!indexes.has(name)) {
213
+ await pool.query(`ALTER TABLE \`${appId}\` ADD ${definition}`);
214
+ }
215
+ }
216
+
217
+ return { migrated: true, backfilled };
218
+ } catch (cause) {
219
+ throw getError(ErrorType.MIGRATION_FAILED, { table: appId, cause: cause.message });
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Create the log tables. Safe in `connect` mode for the same reason the app
225
+ * registry is: they are the service's own bookkeeping, not the operator's
226
+ * schema.
227
+ * @param {object} pool
228
+ */
229
+ async function ensureLogTables(pool) {
230
+ await pool.query(ADMIN_LOG_DDL);
231
+ await pool.query(VIEW_LOG_DDL);
232
+ }
233
+
234
+ module.exports = {
235
+ ADMIN_COLUMNS,
236
+ ADMIN_INDEXES,
237
+ ADMIN_LOG_DDL,
238
+ VIEW_LOG_DDL,
239
+ NEW_TABLE_ADMIN_COLUMNS,
240
+ NEW_TABLE_ADMIN_INDEXES,
241
+ backfillPublicIds,
242
+ ensureLogTables,
243
+ migrateAppTable,
244
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Scheduled retention: expired trash, and old view-log entries.
3
+ *
4
+ * Trash (GDPR Art. 5(1)(e), storage limitation): a soft-deleted view is kept
5
+ * for TRASH_RETENTION_DAYS so a mistaken delete can be undone, then erased for
6
+ * good.
7
+ *
8
+ * View log: the register of accepted views gains a row per view. It holds no
9
+ * personal data, but unbounded it grows as large as every app table together,
10
+ * so entries older than VIEW_LOG_RETENTION_DAYS are removed.
11
+ *
12
+ * Every run that removes anything leaves an entry in the admin operation log,
13
+ * attributed to the system rather than a session, so nothing is removed
14
+ * silently. The admin log itself is never pruned.
15
+ */
16
+
17
+ const { ADMIN, ADMIN_ACTION } = require('../constants');
18
+ const { logWarning, WarningType } = require('../utils/errorUtils');
19
+
20
+ /**
21
+ * Erase expired trash in every app once.
22
+ *
23
+ * One app failing does not stop the others; the failure is reported and the
24
+ * next run tries again.
25
+ *
26
+ * @param {{ adminRepo: object, logRepo: object, appIds: string[], days: number }} deps
27
+ * @returns {Promise<Record<string, number>>} rows erased per app
28
+ */
29
+ async function purgeExpiredTrash({ adminRepo, logRepo, appIds, days }) {
30
+ const erased = {};
31
+ if (!(days > 0)) return erased;
32
+
33
+ for (const appId of appIds) {
34
+ try {
35
+ const count = await adminRepo.purgeExpired(appId, days);
36
+ erased[appId] = count;
37
+ if (count > 0) {
38
+ await logRepo.writeAdminLog({
39
+ action: ADMIN_ACTION.TRASH_AUTO_PURGED,
40
+ appId,
41
+ targetCount: count,
42
+ });
43
+ }
44
+ } catch (cause) {
45
+ logWarning(WarningType.TRASH_PURGE_FAILED, { appId, cause: cause.message });
46
+ }
47
+ }
48
+ return erased;
49
+ }
50
+
51
+ /**
52
+ * Remove view-log entries older than `days` once. A failure is reported and
53
+ * the next run tries again.
54
+ *
55
+ * @param {{ logRepo: object, days: number }} deps
56
+ * @returns {Promise<number>} entries removed
57
+ */
58
+ async function pruneViewLog({ logRepo, days }) {
59
+ if (!(days > 0)) return 0;
60
+ try {
61
+ const count = await logRepo.pruneViewLog(days);
62
+ if (count > 0) {
63
+ await logRepo.writeAdminLog({ action: ADMIN_ACTION.VIEW_LOG_PRUNED, targetCount: count });
64
+ }
65
+ return count;
66
+ } catch (cause) {
67
+ logWarning(WarningType.VIEW_LOG_PRUNE_FAILED, { cause: cause.message });
68
+ return 0;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run both jobs now and then on an interval. A job whose retention is zero
74
+ * (keep forever) is skipped; when both are, nothing is scheduled.
75
+ *
76
+ * `getAppIds` is called on every run, so apps provisioned at runtime are
77
+ * covered without a restart. The timer is unref'd so it never keeps the
78
+ * process alive on its own.
79
+ *
80
+ * @param {{ adminRepo: object, logRepo: object, getAppIds: () => string[],
81
+ * trashDays: number, viewLogDays: number, intervalMs?: number }} deps
82
+ * @returns {() => void} stops the schedule
83
+ */
84
+ function startRetention({ adminRepo, logRepo, getAppIds, trashDays, viewLogDays, intervalMs = ADMIN.RETENTION_INTERVAL_MS }) {
85
+ if (!(trashDays > 0) && !(viewLogDays > 0)) return () => {};
86
+
87
+ const run = async () => {
88
+ await purgeExpiredTrash({ adminRepo, logRepo, appIds: getAppIds(), days: trashDays });
89
+ await pruneViewLog({ logRepo, days: viewLogDays });
90
+ };
91
+ run();
92
+ const timer = setInterval(run, intervalMs);
93
+ if (typeof timer.unref === 'function') timer.unref();
94
+ return () => clearInterval(timer);
95
+ }
96
+
97
+ module.exports = { purgeExpiredTrash, pruneViewLog, startRetention };
package/index.js CHANGED
@@ -3,18 +3,21 @@ const cors = require('cors');
3
3
  const helmet = require('helmet');
4
4
  const rateLimit = require('express-rate-limit');
5
5
 
6
- const { APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
6
+ const { ADMIN, APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
7
7
  const config = require('./config');
8
8
  const DatabaseManager = require('./db/DatabaseManager');
9
9
  const logger = require('./utils/logger');
10
10
  const { buildCorsOptions } = require('./middleware/security');
11
11
  const { createAnalyticsRouter } = require('./routes/analytics');
12
+ const { createAdminRouter } = require('./routes/admin');
13
+ const { startRetention } = require('./db/retention');
12
14
 
13
15
  logger.configure({ level: config.server.logLevel });
14
16
 
15
17
  const dbManager = new DatabaseManager(config.dbInfo);
16
18
  let isServerReady = false;
17
19
  let httpServer = null;
20
+ let stopRetention = () => {};
18
21
 
19
22
  /**
20
23
  * Build the Express application.
@@ -27,17 +30,30 @@ function createApp() {
27
30
 
28
31
  app.disable('x-powered-by');
29
32
  app.use(helmet());
33
+
34
+ // Trusting the proxy is needed before the admin router sees a request: it
35
+ // decides whether the session cookie is marked Secure from req.secure.
36
+ app.set('trust proxy', config.server.trustProxy);
37
+
38
+ // The admin surface exists only when a password is configured, and is
39
+ // mounted ahead of CORS and the public rate limit: it is same-origin only,
40
+ // must never carry the analytics sites' CORS headers, and has its own
41
+ // limits.
42
+ if (config.admin.enabled) {
43
+ app.use(ADMIN.PATH_PREFIX, createAdminRouter({
44
+ config,
45
+ adminRepo: dbManager.admin,
46
+ logRepo: dbManager.logs,
47
+ isReady: () => isServerReady,
48
+ }));
49
+ }
50
+
30
51
  app.use(cors(buildCorsOptions(config.server.corsOrigins)));
31
52
 
32
53
  // Bounded well below body-parser's 100kb default; /event is the only
33
54
  // endpoint taking a body and its payload is small.
34
55
  app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
35
56
 
36
- // Never bare `true`. Trusting every hop lets any caller set
37
- // X-Forwarded-For and be believed, which forges geolocation and rotates
38
- // the rate-limiter key at will.
39
- app.set('trust proxy', config.server.trustProxy);
40
-
41
57
  app.use(rateLimit({
42
58
  windowMs: config.server.rateLimit.windowMs,
43
59
  limit: config.server.rateLimit.max,
@@ -108,6 +124,20 @@ const initializeServer = async () => {
108
124
  // anyone editing allowed.json.
109
125
  await mergeRegisteredApps();
110
126
 
127
+ // initialize() migrated the configured apps; this also covers the ones
128
+ // registered at runtime and merged in above. Idempotent, so apps
129
+ // already in shape cost one information_schema read. Fails startup
130
+ // rather than serving over a half-migrated table.
131
+ await dbManager.migrate(config.allowed.appId);
132
+
133
+ stopRetention = startRetention({
134
+ adminRepo: dbManager.admin,
135
+ logRepo: dbManager.logs,
136
+ getAppIds: () => config.allowed.appId,
137
+ trashDays: config.admin.trashRetentionDays,
138
+ viewLogDays: config.admin.viewLogRetentionDays,
139
+ });
140
+
111
141
  isServerReady = true;
112
142
 
113
143
  if (require.main === module) {
@@ -147,6 +177,7 @@ const shutdown = async (signal, exitCode = 0) => {
147
177
  forceExit.unref();
148
178
 
149
179
  try {
180
+ stopRetention();
150
181
  if (httpServer) {
151
182
  await new Promise((resolve) => httpServer.close(resolve));
152
183
  }
@@ -179,6 +210,8 @@ process.on('uncaughtException', (error) => {
179
210
  module.exports = app;
180
211
  module.exports.createApp = createApp;
181
212
  module.exports.createAnalyticsRouter = createAnalyticsRouter;
213
+ module.exports.createAdminRouter = createAdminRouter;
214
+ module.exports.startRetention = startRetention;
182
215
  module.exports.DatabaseManager = DatabaseManager;
183
216
  module.exports.dbManager = dbManager;
184
217
  module.exports.initializeServer = initializeServer;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Admin UI authentication: password login, server-side sessions, and CSRF.
3
+ *
4
+ * Sessions live in memory. A restart signs every admin out, which is the safe
5
+ * direction to fail in and costs one login. The browser holds only an opaque
6
+ * random token in an HttpOnly cookie; the store is keyed by that token's
7
+ * SHA-256, so a memory dump of the store does not yield usable cookies.
8
+ *
9
+ * CSRF is defeated three ways, each sufficient on its own in a modern browser:
10
+ * the cookie is SameSite=Strict, every mutating request must echo a per-session
11
+ * token in a header that a cross-site form cannot set, and a present Origin
12
+ * header must match this server.
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+
17
+ const { ADMIN, ADMIN_ERROR_CODE, HTTP_STATUS } = require('../constants');
18
+ const { safeEqual } = require('./auth');
19
+ const { parseCookies } = require('../utils/cookieUtils');
20
+ const { WarningType, logWarning } = require('../utils/errorUtils');
21
+
22
+ /** Methods that never change state and so need no CSRF token. */
23
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
24
+
25
+ /** @param {string} token @returns {string} */
26
+ function hashToken(token) {
27
+ return crypto.createHash('sha256').update(token).digest('hex');
28
+ }
29
+
30
+ /**
31
+ * @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
32
+ * now?: () => number }} [options]
33
+ */
34
+ function createSessionStore({
35
+ idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
36
+ absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
37
+ maxSessions = ADMIN.MAX_SESSIONS,
38
+ now = Date.now,
39
+ } = {}) {
40
+ /** @type {Map<string, {id: string, csrfToken: string, createdAt: number, lastSeenAt: number}>} */
41
+ const sessions = new Map();
42
+
43
+ function isExpired(session, at) {
44
+ return at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs;
45
+ }
46
+
47
+ return {
48
+ /**
49
+ * Start a session.
50
+ * @returns {{ token: string, session: {id: string, csrfToken: string, createdAt: number, lastSeenAt: number} }}
51
+ */
52
+ create() {
53
+ const at = now();
54
+ const token = crypto.randomBytes(ADMIN.SESSION_TOKEN_BYTES).toString('base64url');
55
+ const session = {
56
+ id: crypto.randomUUID(),
57
+ csrfToken: crypto.randomBytes(ADMIN.CSRF_TOKEN_BYTES).toString('base64url'),
58
+ createdAt: at,
59
+ lastSeenAt: at,
60
+ };
61
+
62
+ // Bounded: the oldest session goes first. Map preserves insertion
63
+ // order, so the first key is always the oldest.
64
+ while (sessions.size >= maxSessions) {
65
+ sessions.delete(sessions.keys().next().value);
66
+ }
67
+ sessions.set(hashToken(token), session);
68
+ return { token, session };
69
+ },
70
+
71
+ /**
72
+ * Look up a live session and extend its idle window.
73
+ * @param {string|undefined} token
74
+ */
75
+ get(token) {
76
+ if (typeof token !== 'string' || token.length === 0) return null;
77
+ const key = hashToken(token);
78
+ const session = sessions.get(key);
79
+ if (!session) return null;
80
+
81
+ const at = now();
82
+ if (isExpired(session, at)) {
83
+ sessions.delete(key);
84
+ return null;
85
+ }
86
+ session.lastSeenAt = at;
87
+ return session;
88
+ },
89
+
90
+ /** @param {string|undefined} token */
91
+ destroy(token) {
92
+ if (typeof token !== 'string' || token.length === 0) return false;
93
+ return sessions.delete(hashToken(token));
94
+ },
95
+
96
+ get size() {
97
+ return sessions.size;
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Read the session token from the request's cookies. */
103
+ function readToken(req) {
104
+ return parseCookies(req.headers.cookie)[ADMIN.SESSION_COOKIE];
105
+ }
106
+
107
+ /**
108
+ * Cookie attributes. `Secure` follows the connection: a deployment behind a
109
+ * TLS-terminating proxy gets it through `trust proxy`, and plain-HTTP local
110
+ * development still works. Scoped to wherever the admin router is mounted
111
+ * (`req.adminBasePath`), so the cookie never travels to the public analytics
112
+ * endpoints and still works when another app mounts the router elsewhere.
113
+ */
114
+ function cookieOptions(req) {
115
+ return {
116
+ httpOnly: true,
117
+ sameSite: 'strict',
118
+ secure: req.secure,
119
+ path: req.adminBasePath || ADMIN.PATH_PREFIX,
120
+ maxAge: ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
121
+ };
122
+ }
123
+
124
+ /** @returns {import('express').RequestHandler} */
125
+ function requireAdminSession(store) {
126
+ return (req, res, next) => {
127
+ const token = readToken(req);
128
+ const session = store.get(token);
129
+ if (!session) {
130
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.UNAUTHENTICATED });
131
+ }
132
+ req.adminSession = session;
133
+ req.adminToken = token;
134
+ return next();
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The origin this request was addressed to, as the browser would write it.
140
+ * @param {import('express').Request} req
141
+ */
142
+ function expectedOrigin(req) {
143
+ return `${req.protocol}://${req.get('host')}`;
144
+ }
145
+
146
+ /**
147
+ * Whether a browser-supplied Origin (absent for same-origin GETs and non-browser
148
+ * clients) matches this server. A mismatch is logged with both values: behind a
149
+ * misconfigured proxy every admin request is refused, and the log is the only
150
+ * place that says why.
151
+ * @param {import('express').Request} req
152
+ */
153
+ function originAllowed(req) {
154
+ const presented = req.get('origin');
155
+ if (!presented) return true;
156
+ const expected = expectedOrigin(req);
157
+ if (presented === expected) return true;
158
+ logWarning(WarningType.ADMIN_ORIGIN_REJECTED, { presented, expected });
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * Reject a state-changing request that does not carry this session's CSRF
164
+ * token, or that a browser says came from another origin.
165
+ * @returns {import('express').RequestHandler}
166
+ */
167
+ function requireCsrf() {
168
+ return (req, res, next) => {
169
+ if (SAFE_METHODS.has(req.method)) return next();
170
+
171
+ const presented = req.get(ADMIN.CSRF_HEADER) || '';
172
+ const session = req.adminSession;
173
+
174
+ const originOk = originAllowed(req);
175
+ const tokenOk = Boolean(session) && safeEqual(presented, session.csrfToken);
176
+
177
+ if (!originOk || !tokenOk) {
178
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
179
+ }
180
+ return next();
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Constant-time password check. An unset password never matches anything,
186
+ * including an empty submission.
187
+ */
188
+ function verifyPassword(presented, configured) {
189
+ if (!configured) return false;
190
+ return safeEqual(String(presented ?? ''), configured);
191
+ }
192
+
193
+ module.exports = {
194
+ createSessionStore,
195
+ requireAdminSession,
196
+ requireCsrf,
197
+ verifyPassword,
198
+ cookieOptions,
199
+ expectedOrigin,
200
+ originAllowed,
201
+ readToken,
202
+ hashToken,
203
+ SAFE_METHODS,
204
+ };