@devopsplaybook.io/common-utils 1.10.1 → 1.11.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 (69) hide show
  1. package/README.md +73 -27
  2. package/dist/src/ConfigBase.d.ts +31 -0
  3. package/dist/src/ConfigBase.js +58 -16
  4. package/dist/src/DbUtils.d.ts +20 -3
  5. package/dist/src/DbUtils.js +93 -2
  6. package/dist/src/DbUtilsNoTelemetry.d.ts +4 -1
  7. package/dist/src/DbUtilsNoTelemetry.js +62 -6
  8. package/dist/src/PostgresDbUtils.d.ts +41 -10
  9. package/dist/src/PostgresDbUtils.js +357 -304
  10. package/dist/src/SqlDbUtils.d.ts +12 -4
  11. package/dist/src/SqlDbUtils.js +76 -30
  12. package/dist/src/users/Auth.d.ts +11 -1
  13. package/dist/src/users/Auth.js +160 -44
  14. package/dist/src/users/User.d.ts +10 -0
  15. package/dist/src/users/User.js +30 -10
  16. package/dist/src/users/UserApiToken.d.ts +4 -0
  17. package/dist/src/users/UserApiToken.js +11 -0
  18. package/dist/src/users/UsersApiTokensData.d.ts +12 -0
  19. package/dist/src/users/UsersApiTokensData.js +130 -33
  20. package/dist/src/users/UsersData.d.ts +20 -1
  21. package/dist/src/users/UsersData.js +150 -52
  22. package/dist/src/users/UsersRoutes.js +178 -61
  23. package/dist/src/users/index.d.ts +8 -0
  24. package/dist/src/users/index.js +24 -0
  25. package/package.json +58 -1
  26. package/.github/workflows/main-build.yml +0 -18
  27. package/.github/workflows/pr-check.yml +0 -27
  28. package/.github/workflows/reusable-merge-build.yml +0 -197
  29. package/.github/workflows/reusable-npm-merge.yml +0 -135
  30. package/.github/workflows/reusable-npm-pr.yml +0 -183
  31. package/.github/workflows/reusable-npm-upgrade.yml +0 -92
  32. package/.github/workflows/reusable-pr-verify.yml +0 -181
  33. package/AGENTS.md +0 -105
  34. package/index.ts +0 -18
  35. package/jest.config.js +0 -17
  36. package/prettierrc.json +0 -5
  37. package/src/ConfigBase.spec.ts +0 -108
  38. package/src/ConfigBase.ts +0 -297
  39. package/src/DbUtils.spec.ts +0 -23
  40. package/src/DbUtils.ts +0 -116
  41. package/src/DbUtilsNoTelemetry.spec.ts +0 -168
  42. package/src/DbUtilsNoTelemetry.ts +0 -117
  43. package/src/LLM.spec.ts +0 -303
  44. package/src/LLM.ts +0 -204
  45. package/src/Notifications.spec.ts +0 -265
  46. package/src/Notifications.ts +0 -201
  47. package/src/OTelContext.spec.ts +0 -58
  48. package/src/OTelContext.ts +0 -63
  49. package/src/PostgresDbUtils.spec.ts +0 -153
  50. package/src/PostgresDbUtils.ts +0 -666
  51. package/src/SqlDbUtils.spec.ts +0 -108
  52. package/src/SqlDbUtils.ts +0 -152
  53. package/src/SystemCommand.spec.ts +0 -18
  54. package/src/SystemCommand.ts +0 -23
  55. package/src/Timeout.spec.ts +0 -18
  56. package/src/Timeout.ts +0 -12
  57. package/src/users/Auth.spec.ts +0 -268
  58. package/src/users/Auth.ts +0 -202
  59. package/src/users/User.ts +0 -75
  60. package/src/users/UserApiToken.ts +0 -55
  61. package/src/users/UserPassword.spec.ts +0 -28
  62. package/src/users/UserPassword.ts +0 -20
  63. package/src/users/UserSession.ts +0 -9
  64. package/src/users/UsersApiTokensData.spec.ts +0 -158
  65. package/src/users/UsersApiTokensData.ts +0 -125
  66. package/src/users/UsersData.ts +0 -141
  67. package/src/users/UsersRoutes.ts +0 -374
  68. package/tsconfig.json +0 -15
  69. package/tsconfig.spec.json +0 -8
@@ -17,7 +17,12 @@ export declare function SqlDbUtilsSetOTel(tracerIn: StandardTracer, loggerIn: St
17
17
  *
18
18
  * Migration files must follow the naming convention `init-NNNN.sql` and are
19
19
  * applied in lexicographic order. A `metadata` table tracks which migrations
20
- * have already been applied so they are idempotent.
20
+ * have already been applied so they are idempotent. Each migration file and
21
+ * its `db_version` row are applied inside a single transaction; a failing
22
+ * migration is rolled back and never recorded.
23
+ *
24
+ * SQLite only supports a single writer: run exactly one instance against a
25
+ * given database file.
21
26
  *
22
27
  * @param context Parent OTel span.
23
28
  * @param config Configuration with `DATA_DIR`.
@@ -30,11 +35,14 @@ export declare function SqlDbUtilsGetDatabase(): Database.Database;
30
35
  * Execute a write SQL statement with OTel tracing.
31
36
  * @returns Number of rows changed.
32
37
  */
33
- export declare function SqlDbUtilsExecSQL(context: Span, sql: string, params?: unknown[]): number;
34
- /** Execute an entire SQL file (used for migrations). */
38
+ export declare function SqlDbUtilsExecSQL(context: Span | undefined, sql: string, params?: unknown[]): number;
39
+ /**
40
+ * Execute an entire SQL file (used for migrations).
41
+ * Migration files must not contain their own transaction control statements.
42
+ */
35
43
  export declare function SqlDbUtilsExecSQLFile(context: Span, filename: string): void;
36
44
  /**
37
45
  * Execute a read SQL query with OTel tracing.
38
46
  * @returns Array of row objects.
39
47
  */
40
- export declare function SqlDbUtilsQuerySQL(context: Span, sql: string, params?: unknown[], debug?: boolean): any[];
48
+ export declare function SqlDbUtilsQuerySQL(context: Span | undefined, sql: string, params?: unknown[], debug?: boolean): any[];
@@ -48,6 +48,27 @@ const api_1 = require("@opentelemetry/api");
48
48
  let database;
49
49
  let tracer;
50
50
  let logger;
51
+ /**
52
+ * Compiled statements are cached: `better-sqlite3` has no internal cache and
53
+ * `prepare()` is the dominant cost on hot paths. The cache is bounded and is
54
+ * reset by {@link SqlDbUtilsInit} (statements belong to one `Database` handle).
55
+ */
56
+ const PREPARED_STATEMENT_CACHE_MAX = 100;
57
+ let preparedStatements = new Map();
58
+ function prepareCached(sql) {
59
+ let statement = preparedStatements.get(sql);
60
+ if (!statement) {
61
+ if (preparedStatements.size >= PREPARED_STATEMENT_CACHE_MAX) {
62
+ const oldest = preparedStatements.keys().next().value;
63
+ if (oldest !== undefined) {
64
+ preparedStatements.delete(oldest);
65
+ }
66
+ }
67
+ statement = database.prepare(sql);
68
+ preparedStatements.set(sql, statement);
69
+ }
70
+ return statement;
71
+ }
51
72
  /**
52
73
  * Injects the OTel tracer and logger instances used by all SQL operations.
53
74
  * Must be called once at startup, before {@link SqlDbUtilsInit}.
@@ -61,7 +82,12 @@ function SqlDbUtilsSetOTel(tracerIn, loggerIn) {
61
82
  *
62
83
  * Migration files must follow the naming convention `init-NNNN.sql` and are
63
84
  * applied in lexicographic order. A `metadata` table tracks which migrations
64
- * have already been applied so they are idempotent.
85
+ * have already been applied so they are idempotent. Each migration file and
86
+ * its `db_version` row are applied inside a single transaction; a failing
87
+ * migration is rolled back and never recorded.
88
+ *
89
+ * SQLite only supports a single writer: run exactly one instance against a
90
+ * given database file.
65
91
  *
66
92
  * @param context Parent OTel span.
67
93
  * @param config Configuration with `DATA_DIR`.
@@ -69,29 +95,43 @@ function SqlDbUtilsSetOTel(tracerIn, loggerIn) {
69
95
  */
70
96
  async function SqlDbUtilsInit(context, config, sqlDir) {
71
97
  const span = tracer.startSpan("SqlDbUtilsInit", context);
72
- await fs.ensureDir(config.DATA_DIR);
73
- database = new better_sqlite3_1.default(`${config.DATA_DIR}/database.db`);
74
- SqlDbUtilsExecSQLFile(span, `${sqlDir}/init-0000.sql`);
75
- const initFiles = (await fs.readdir(sqlDir)).sort();
76
- let dbVersionApplied = 0;
77
- const rows = SqlDbUtilsQuerySQL(span, "SELECT MAX(value) as maxVersion FROM metadata WHERE type='db_version'");
78
- if (rows.length > 0 && rows[0].maxVersion) {
79
- dbVersionApplied = Number(rows[0].maxVersion);
80
- }
81
- logger.info(`Current DB Version: ${dbVersionApplied}`, span);
82
- for (const initFile of initFiles) {
83
- const regex = /init-(\d+).sql/g;
84
- const match = regex.exec(initFile);
85
- if (match) {
86
- const dbVersionInitFile = Number(match[1]);
87
- if (dbVersionInitFile > dbVersionApplied) {
88
- logger.info(`Loading init file: ${initFile}`, span);
89
- SqlDbUtilsExecSQLFile(span, `${sqlDir}/${initFile}`);
90
- SqlDbUtilsExecSQL(span, "INSERT INTO metadata (type, value, dateCreated) VALUES ('db_version',?,?)", [dbVersionInitFile, new Date().toISOString()]);
98
+ try {
99
+ await fs.ensureDir(config.DATA_DIR);
100
+ database = new better_sqlite3_1.default(`${config.DATA_DIR}/database.db`);
101
+ preparedStatements = new Map();
102
+ SqlDbUtilsExecSQLFile(span, `${sqlDir}/init-0000.sql`);
103
+ const initFiles = (await fs.readdir(sqlDir)).sort();
104
+ let dbVersionApplied = 0;
105
+ // `metadata.value` has text affinity: a plain MAX(value) is lexicographic and
106
+ // ranks "9" above "10", which re-applies init-0010.sql on every boot.
107
+ const rows = SqlDbUtilsQuerySQL(span, "SELECT MAX(CAST(value AS INTEGER)) as maxVersion FROM metadata WHERE type='db_version'");
108
+ if (rows.length > 0 && rows[0].maxVersion !== null && rows[0].maxVersion !== undefined) {
109
+ dbVersionApplied = Number(rows[0].maxVersion);
110
+ }
111
+ logger.info(`Current DB Version: ${dbVersionApplied}`, span);
112
+ for (const initFile of initFiles) {
113
+ const regex = /init-(\d+)\.sql/g;
114
+ const match = regex.exec(initFile);
115
+ if (match) {
116
+ const dbVersionInitFile = Number(match[1]);
117
+ if (dbVersionInitFile > dbVersionApplied) {
118
+ logger.info(`Loading init file: ${initFile}`, span);
119
+ applyMigration(span, `${sqlDir}/${initFile}`, dbVersionInitFile);
120
+ }
91
121
  }
92
122
  }
93
123
  }
94
- span.end();
124
+ finally {
125
+ span.end();
126
+ }
127
+ }
128
+ /** Apply one migration file and record its version atomically. */
129
+ function applyMigration(context, filename, version) {
130
+ const apply = database.transaction(() => {
131
+ SqlDbUtilsExecSQLFile(context, filename);
132
+ SqlDbUtilsExecSQL(context, "INSERT INTO metadata (type, value, dateCreated) VALUES ('db_version',?,?)", [version, new Date().toISOString()]);
133
+ });
134
+ apply();
95
135
  }
96
136
  /** Returns the underlying `better-sqlite3` Database instance. */
97
137
  function SqlDbUtilsGetDatabase() {
@@ -104,33 +144,38 @@ function SqlDbUtilsGetDatabase() {
104
144
  function SqlDbUtilsExecSQL(context, sql, params = []) {
105
145
  const span = tracer.startSpan("SqlDbUtilsExecSQL", context);
106
146
  try {
107
- const stmt = database.prepare(sql);
147
+ const stmt = prepareCached(sql);
108
148
  const result = stmt.run(params);
109
149
  span.addEvent(`Impacted Rows: ${result.changes}`);
110
- span.end();
111
150
  return result.changes;
112
151
  }
113
152
  catch (error) {
114
153
  const err = error;
115
154
  span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
116
- span.end();
117
155
  throw error;
118
156
  }
157
+ finally {
158
+ span.end();
159
+ }
119
160
  }
120
- /** Execute an entire SQL file (used for migrations). */
161
+ /**
162
+ * Execute an entire SQL file (used for migrations).
163
+ * Migration files must not contain their own transaction control statements.
164
+ */
121
165
  function SqlDbUtilsExecSQLFile(context, filename) {
122
166
  const span = tracer.startSpan("SqlDbUtilsExecSQLFile", context);
123
167
  try {
124
168
  const sql = fs.readFileSync(filename).toString();
125
169
  database.exec(sql);
126
- span.end();
127
170
  }
128
171
  catch (error) {
129
172
  const err = error;
130
173
  span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
131
- span.end();
132
174
  throw error;
133
175
  }
176
+ finally {
177
+ span.end();
178
+ }
134
179
  }
135
180
  /**
136
181
  * Execute a read SQL query with OTel tracing.
@@ -142,15 +187,16 @@ function SqlDbUtilsQuerySQL(context, sql, params = [], debug = false) {
142
187
  console.log(sql);
143
188
  }
144
189
  try {
145
- const stmt = database.prepare(sql);
190
+ const stmt = prepareCached(sql);
146
191
  const rows = stmt.all(params);
147
- span.end();
148
192
  return rows;
149
193
  }
150
194
  catch (error) {
151
195
  const err = error;
152
196
  span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
153
- span.end();
154
197
  throw error;
155
198
  }
199
+ finally {
200
+ span.end();
201
+ }
156
202
  }
@@ -9,18 +9,28 @@ export interface AuthConfig {
9
9
  JWT_KEY: string;
10
10
  JWT_VALIDITY_DURATION: number;
11
11
  DATABASE_TYPE: "sqlite" | "postgres";
12
+ /** Opt-in JWT revocation (requires the `users.tokenVersion` migration). */
13
+ JWT_REVOCATION_ENABLED?: boolean;
14
+ /** Per-user API token cap (defaults to 100 when unset). */
15
+ API_TOKENS_MAX_PER_USER?: number;
12
16
  }
13
17
  /**
14
18
  * Injects the OTel tracer instance used by the auth module.
15
19
  * Must be called once at startup, before {@link AuthInit}.
16
20
  */
17
21
  export declare function AuthSetOTel(tracerIn: StandardTracer): void;
22
+ /** Whether the opt-in `JWT_REVOCATION_ENABLED` config flag is on. */
23
+ export declare function AuthJwtRevocationEnabled(): boolean;
24
+ /** Configured per-user API token cap (`API_TOKENS_MAX_PER_USER`). */
25
+ export declare function AuthGetApiTokensMaxPerUser(): number;
18
26
  /**
19
27
  * Initialise the auth module.
20
28
  *
21
29
  * Registers the full scope set of the host application and loads the JWT
22
30
  * signing key from the `metadata` table. When no key is stored yet, a fresh
23
- * one is generated and persisted.
31
+ * one is generated and persisted. The read/create sequence runs under an
32
+ * advisory lock on Postgres so concurrently booting replicas agree on a
33
+ * single key; SQLite has a single writer (see README).
24
34
  *
25
35
  * @param context Parent OTel span.
26
36
  * @param configIn Server configuration (JWT_KEY is updated in place).
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AuthSetOTel = AuthSetOTel;
37
+ exports.AuthJwtRevocationEnabled = AuthJwtRevocationEnabled;
38
+ exports.AuthGetApiTokensMaxPerUser = AuthGetApiTokensMaxPerUser;
37
39
  exports.AuthInit = AuthInit;
38
40
  exports.AuthGenerateJWT = AuthGenerateJWT;
39
41
  exports.AuthMustBeAuthenticated = AuthMustBeAuthenticated;
@@ -47,6 +49,20 @@ const DbUtils_1 = require("../DbUtils");
47
49
  const User_1 = require("./User");
48
50
  const UsersApiTokensData_1 = require("./UsersApiTokensData");
49
51
  const UsersData_1 = require("./UsersData");
52
+ /** Default per-user API token cap when the config field is not set. */
53
+ const DEFAULT_API_TOKENS_MAX_PER_USER = 100;
54
+ /**
55
+ * Rejected non-JWT credentials are remembered for a short time so that a
56
+ * flood of bad tokens does not hit the database twice per request. Only the
57
+ * SHA-256 hash of the presented value is stored, never the plaintext.
58
+ */
59
+ const REJECTED_CREDENTIALS_MAX = 1024;
60
+ const REJECTED_CREDENTIALS_TTL_MS = 60000;
61
+ const rejectedCredentials = new Map();
62
+ /** `lastUsedAt` is written at most once per hour per token. */
63
+ const LAST_USED_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
64
+ const LAST_USED_TRACKED_MAX = 4096;
65
+ const lastUsedWrites = new Map();
50
66
  let tracer;
51
67
  let config;
52
68
  /**
@@ -56,12 +72,23 @@ let config;
56
72
  function AuthSetOTel(tracerIn) {
57
73
  tracer = tracerIn;
58
74
  }
75
+ /** Whether the opt-in `JWT_REVOCATION_ENABLED` config flag is on. */
76
+ function AuthJwtRevocationEnabled() {
77
+ return (config === null || config === void 0 ? void 0 : config.JWT_REVOCATION_ENABLED) === true;
78
+ }
79
+ /** Configured per-user API token cap (`API_TOKENS_MAX_PER_USER`). */
80
+ function AuthGetApiTokensMaxPerUser() {
81
+ var _a;
82
+ return (_a = config === null || config === void 0 ? void 0 : config.API_TOKENS_MAX_PER_USER) !== null && _a !== void 0 ? _a : DEFAULT_API_TOKENS_MAX_PER_USER;
83
+ }
59
84
  /**
60
85
  * Initialise the auth module.
61
86
  *
62
87
  * Registers the full scope set of the host application and loads the JWT
63
88
  * signing key from the `metadata` table. When no key is stored yet, a fresh
64
- * one is generated and persisted.
89
+ * one is generated and persisted. The read/create sequence runs under an
90
+ * advisory lock on Postgres so concurrently booting replicas agree on a
91
+ * single key; SQLite has a single writer (see README).
65
92
  *
66
93
  * @param context Parent OTel span.
67
94
  * @param configIn Server configuration (JWT_KEY is updated in place).
@@ -71,87 +98,176 @@ async function AuthInit(context, configIn, allScopes = []) {
71
98
  config = configIn;
72
99
  User_1.User.ALL_SCOPES = [...allScopes];
73
100
  const span = tracer.startSpan("AuthInit", context);
74
- const authKeyRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_AUTH_TOKEN);
75
- if (authKeyRaw.length == 0) {
76
- configIn.JWT_KEY = (0, uuid_1.v4)();
77
- await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
78
- configIn.JWT_KEY,
79
- new Date().toISOString(),
80
- ]);
81
- }
82
- else {
83
- configIn.JWT_KEY = authKeyRaw[0].value;
84
- }
85
- span.end();
101
+ try {
102
+ await (0, DbUtils_1.DbUtilsWithLock)("auth_token", async () => {
103
+ const authKeyRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_AUTH_TOKEN);
104
+ if (authKeyRaw.length == 0) {
105
+ configIn.JWT_KEY = (0, uuid_1.v4)();
106
+ await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
107
+ configIn.JWT_KEY,
108
+ new Date().toISOString(),
109
+ ]);
110
+ }
111
+ else {
112
+ configIn.JWT_KEY = authKeyRaw[0].value;
113
+ }
114
+ });
115
+ }
116
+ finally {
117
+ span.end();
118
+ }
86
119
  }
87
120
  async function AuthGenerateJWT(user) {
121
+ var _a;
88
122
  return jwt.sign({
89
123
  exp: Math.floor(Date.now() / 1000) + config.JWT_VALIDITY_DURATION,
90
124
  userId: user.id,
91
125
  userName: user.name,
92
126
  role: user.role,
93
127
  scopes: user.role === "admin" ? User_1.User.ALL_SCOPES : user.scopes,
94
- }, config.JWT_KEY);
128
+ tokenVersion: (_a = user.tokenVersion) !== null && _a !== void 0 ? _a : 0,
129
+ }, config.JWT_KEY, { algorithm: "HS256" });
95
130
  }
96
131
  /**
97
132
  * Decode credentials from request, caching result on req._jwtPayload to
98
133
  * avoid redundant resolution when multiple auth functions are called per
99
134
  * request.
100
135
  *
101
- * A `Bearer` credential is first verified as a JWT. When JWT verification
102
- * fails, the credential is resolved as a user API token: the value is
103
- * SHA-256 hashed and looked up in `users_api_tokens`; on match, a payload
104
- * mirroring the owning user's live role and scopes is built (valid until
105
- * the token is revoked).
136
+ * A `Bearer` credential is first verified as a JWT (HS256 only). When JWT
137
+ * verification fails, the credential is resolved as a user API token: the
138
+ * value is SHA-256 hashed and looked up in `users_api_tokens`; on match, a
139
+ * payload mirroring the owning user's live role and scopes is built (valid
140
+ * until the token is revoked or expires).
141
+ *
142
+ * When `JWT_REVOCATION_ENABLED` is on, the live user is re-read after
143
+ * verification and the token is rejected when the user is gone or its
144
+ * `tokenVersion` differs from the claim.
106
145
  */
107
146
  async function jwtDecodeCached(req) {
147
+ var _a;
108
148
  if (req._jwtPayload) {
109
149
  return req._jwtPayload;
110
150
  }
111
- if (!req.headers.authorization) {
151
+ const authorization = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.authorization;
152
+ if (!authorization) {
112
153
  return null;
113
154
  }
155
+ const token = authorization.split(" ")[1];
156
+ if (!token) {
157
+ return null;
158
+ }
159
+ let info;
114
160
  try {
115
- const info = jwt.verify(req.headers.authorization.split(" ")[1], config.JWT_KEY);
116
- req._jwtPayload = info;
117
- return info;
161
+ info = jwt.verify(token, config.JWT_KEY, { algorithms: ["HS256"] });
118
162
  }
119
163
  catch {
120
- const info = await resolveApiToken(req.headers.authorization);
121
- if (info) {
122
- req._jwtPayload = info;
123
- return info;
164
+ const apiInfo = await resolveApiToken(token);
165
+ if (apiInfo) {
166
+ req._jwtPayload = apiInfo;
167
+ return apiInfo;
124
168
  }
125
169
  return null;
126
170
  }
171
+ if (config.JWT_REVOCATION_ENABLED && !(await isTokenVersionCurrent(info))) {
172
+ return null;
173
+ }
174
+ req._jwtPayload = info;
175
+ return info;
176
+ }
177
+ /** Re-read the user and compare its live `tokenVersion` with the claim. */
178
+ async function isTokenVersionCurrent(info) {
179
+ var _a, _b;
180
+ const span = tracer.startSpan("AuthCheckTokenVersion");
181
+ try {
182
+ const user = await (0, UsersData_1.UsersDataGet)(span, info.userId);
183
+ if (!user) {
184
+ return false;
185
+ }
186
+ return Number((_a = user.tokenVersion) !== null && _a !== void 0 ? _a : 0) === Number((_b = info.tokenVersion) !== null && _b !== void 0 ? _b : 0);
187
+ }
188
+ finally {
189
+ span.end();
190
+ }
191
+ }
192
+ function isKnownRejectedCredential(tokenHash) {
193
+ const expiresAt = rejectedCredentials.get(tokenHash);
194
+ if (expiresAt === undefined) {
195
+ return false;
196
+ }
197
+ if (expiresAt <= Date.now()) {
198
+ rejectedCredentials.delete(tokenHash);
199
+ return false;
200
+ }
201
+ return true;
202
+ }
203
+ function rememberRejectedCredential(tokenHash) {
204
+ if (rejectedCredentials.size >= REJECTED_CREDENTIALS_MAX) {
205
+ const oldest = rejectedCredentials.keys().next().value;
206
+ if (oldest !== undefined) {
207
+ rejectedCredentials.delete(oldest);
208
+ }
209
+ }
210
+ rejectedCredentials.set(tokenHash, Date.now() + REJECTED_CREDENTIALS_TTL_MS);
211
+ }
212
+ function shouldWriteLastUsed(tokenId) {
213
+ const lastWrite = lastUsedWrites.get(tokenId);
214
+ const now = Date.now();
215
+ if (lastWrite !== undefined && now - lastWrite < LAST_USED_UPDATE_INTERVAL_MS) {
216
+ return false;
217
+ }
218
+ if (lastUsedWrites.size >= LAST_USED_TRACKED_MAX) {
219
+ const oldest = lastUsedWrites.keys().next().value;
220
+ if (oldest !== undefined) {
221
+ lastUsedWrites.delete(oldest);
222
+ }
223
+ }
224
+ lastUsedWrites.set(tokenId, now);
225
+ return true;
127
226
  }
128
227
  /**
129
228
  * Resolve an API token bearer credential to a user-backed payload.
130
229
  * Permissions are read from the user record at resolution time, so
131
230
  * role/scope changes apply to existing tokens immediately.
132
231
  */
133
- async function resolveApiToken(authorizationHeader) {
134
- const token = authorizationHeader.split(" ")[1];
135
- if (!token) {
232
+ async function resolveApiToken(token) {
233
+ const tokenHash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
234
+ if (isKnownRejectedCredential(tokenHash)) {
136
235
  return null;
137
236
  }
138
237
  const span = tracer.startSpan("AuthResolveApiToken");
139
- const tokenHash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
140
- const apiToken = await (0, UsersApiTokensData_1.UsersApiTokensDataGetByTokenHash)(span, tokenHash);
141
- let payload = null;
142
- if (apiToken) {
238
+ try {
239
+ const apiToken = await (0, UsersApiTokensData_1.UsersApiTokensDataGetByTokenHash)(span, tokenHash);
240
+ if (!apiToken) {
241
+ rememberRejectedCredential(tokenHash);
242
+ return null;
243
+ }
244
+ if (apiToken.expiresAt && Date.parse(apiToken.expiresAt) <= Date.now()) {
245
+ rememberRejectedCredential(tokenHash);
246
+ return null;
247
+ }
143
248
  const user = await (0, UsersData_1.UsersDataGet)(span, apiToken.userId);
144
- if (user) {
145
- payload = {
146
- userId: user.id,
147
- userName: user.name,
148
- role: user.role,
149
- scopes: user.role === "admin" ? [...User_1.User.ALL_SCOPES] : user.scopes,
150
- };
249
+ if (!user) {
250
+ rememberRejectedCredential(tokenHash);
251
+ return null;
252
+ }
253
+ if (shouldWriteLastUsed(apiToken.id)) {
254
+ try {
255
+ await (0, UsersApiTokensData_1.UsersApiTokensDataSetLastUsed)(span, apiToken.id, new Date().toISOString());
256
+ }
257
+ catch {
258
+ // Best effort: a failed lastUsedAt write never breaks authentication.
259
+ }
151
260
  }
261
+ return {
262
+ userId: user.id,
263
+ userName: user.name,
264
+ role: user.role,
265
+ scopes: user.role === "admin" ? [...User_1.User.ALL_SCOPES] : user.scopes,
266
+ };
267
+ }
268
+ finally {
269
+ span.end();
152
270
  }
153
- span.end();
154
- return payload;
155
271
  }
156
272
  async function AuthMustBeAuthenticated(req, res) {
157
273
  if (!(await jwtDecodeCached(req))) {
@@ -199,6 +315,6 @@ async function AuthGetUserSession(req) {
199
315
  // Written SQLite-first with quoted identifiers (valid for both backends);
200
316
  // the DbUtils facade converts `?` placeholders for Postgres.
201
317
  const SQL_QUERIES = {
202
- GET_AUTH_TOKEN: "SELECT value FROM metadata WHERE \"type\" = 'auth_token' LIMIT 1",
318
+ GET_AUTH_TOKEN: "SELECT value FROM metadata WHERE \"type\" = 'auth_token' ORDER BY \"dateCreated\" DESC LIMIT 1",
203
319
  INSERT_AUTH_TOKEN: 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES (\'auth_token\', ?, ?)',
204
320
  };
@@ -13,12 +13,22 @@ export declare class User {
13
13
  static DEFAULT_SCOPES: UserScope[];
14
14
  /** Full scope set of the host application, registered via `AuthInit`. */
15
15
  static ALL_SCOPES: UserScope[];
16
+ /**
17
+ * Coerce a stored scope value to an array: JSON string and array columns
18
+ * are both accepted, anything else falls back to the default scopes.
19
+ */
20
+ static normalizeScopes(value: unknown): UserScope[];
16
21
  static fromJson(json: any): User | null;
17
22
  id: string;
18
23
  name: string;
19
24
  passwordEncrypted: string;
20
25
  role: UserRole;
21
26
  scopes: UserScope[];
27
+ /**
28
+ * Incremented on password/role/scope changes when JWT revocation is
29
+ * enabled; JWTs carry the value they were issued with.
30
+ */
31
+ tokenVersion: number;
22
32
  constructor();
23
33
  toJson(): any;
24
34
  toTransportJson(): any;
@@ -3,7 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.User = void 0;
4
4
  const uuid_1 = require("uuid");
5
5
  class User {
6
+ /**
7
+ * Coerce a stored scope value to an array: JSON string and array columns
8
+ * are both accepted, anything else falls back to the default scopes.
9
+ */
10
+ static normalizeScopes(value) {
11
+ if (Array.isArray(value)) {
12
+ return [...value];
13
+ }
14
+ if (typeof value === "string") {
15
+ try {
16
+ const parsed = JSON.parse(value);
17
+ if (Array.isArray(parsed)) {
18
+ return parsed;
19
+ }
20
+ }
21
+ catch {
22
+ // fall through to the default scopes
23
+ }
24
+ }
25
+ return [...User.DEFAULT_SCOPES];
26
+ }
6
27
  static fromJson(json) {
28
+ var _a;
7
29
  if (!json) {
8
30
  return null;
9
31
  }
@@ -11,26 +33,23 @@ class User {
11
33
  if (json.id) {
12
34
  user.id = json.id;
13
35
  }
14
- user.id = json.id;
15
36
  user.name = json.name;
16
37
  user.passwordEncrypted = json.passwordEncrypted;
17
38
  user.role = json.role || "user";
39
+ user.tokenVersion = Number((_a = json.tokenVersion) !== null && _a !== void 0 ? _a : 0) || 0;
18
40
  if (json.scopes) {
19
- try {
20
- user.scopes =
21
- typeof json.scopes === "string"
22
- ? JSON.parse(json.scopes)
23
- : json.scopes;
24
- }
25
- catch {
26
- user.scopes = [...User.DEFAULT_SCOPES];
27
- }
41
+ user.scopes = User.normalizeScopes(json.scopes);
28
42
  }
29
43
  return user;
30
44
  }
31
45
  constructor() {
32
46
  this.role = "user";
33
47
  this.scopes = [...User.DEFAULT_SCOPES];
48
+ /**
49
+ * Incremented on password/role/scope changes when JWT revocation is
50
+ * enabled; JWTs carry the value they were issued with.
51
+ */
52
+ this.tokenVersion = 0;
34
53
  this.id = (0, uuid_1.v4)();
35
54
  }
36
55
  toJson() {
@@ -40,6 +59,7 @@ class User {
40
59
  passwordEncrypted: this.passwordEncrypted,
41
60
  role: this.role,
42
61
  scopes: this.scopes,
62
+ tokenVersion: this.tokenVersion,
43
63
  };
44
64
  }
45
65
  toTransportJson() {
@@ -13,6 +13,10 @@ export declare class UserApiToken {
13
13
  userId: string;
14
14
  tokenHash: string;
15
15
  dateCreated: string;
16
+ /** Optional ISO expiry (`null` = never expires). */
17
+ expiresAt: string | null;
18
+ /** Last successful authentication with this token (best effort). */
19
+ lastUsedAt: string | null;
16
20
  constructor();
17
21
  toJson(): any;
18
22
  toTransportJson(): any;
@@ -13,6 +13,7 @@ const uuid_1 = require("uuid");
13
13
  class UserApiToken {
14
14
  //
15
15
  static fromJson(json) {
16
+ var _a, _b;
16
17
  if (!json) {
17
18
  return null;
18
19
  }
@@ -22,9 +23,15 @@ class UserApiToken {
22
23
  apiToken.userId = json.userId;
23
24
  apiToken.tokenHash = json.tokenHash;
24
25
  apiToken.dateCreated = json.dateCreated;
26
+ apiToken.expiresAt = (_a = json.expiresAt) !== null && _a !== void 0 ? _a : null;
27
+ apiToken.lastUsedAt = (_b = json.lastUsedAt) !== null && _b !== void 0 ? _b : null;
25
28
  return apiToken;
26
29
  }
27
30
  constructor() {
31
+ /** Optional ISO expiry (`null` = never expires). */
32
+ this.expiresAt = null;
33
+ /** Last successful authentication with this token (best effort). */
34
+ this.lastUsedAt = null;
28
35
  this.id = (0, uuid_1.v4)();
29
36
  }
30
37
  toJson() {
@@ -34,6 +41,8 @@ class UserApiToken {
34
41
  userId: this.userId,
35
42
  tokenHash: this.tokenHash,
36
43
  dateCreated: this.dateCreated,
44
+ expiresAt: this.expiresAt,
45
+ lastUsedAt: this.lastUsedAt,
37
46
  };
38
47
  }
39
48
  // Transport representation: never exposes the token hash.
@@ -43,6 +52,8 @@ class UserApiToken {
43
52
  name: this.name,
44
53
  userId: this.userId,
45
54
  dateCreated: this.dateCreated,
55
+ expiresAt: this.expiresAt,
56
+ lastUsedAt: this.lastUsedAt,
46
57
  };
47
58
  }
48
59
  }