@devopsplaybook.io/common-utils 1.10.1-beta.23.b1906d7 → 1.11.0-beta.24.d8f9aef

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
package/README.md CHANGED
@@ -22,7 +22,7 @@ Shared utility modules for [devopsplaybook.io](https://github.com/devopsplaybook
22
22
  npm install @devopsplaybook.io/common-utils
23
23
  ```
24
24
 
25
- **Peer dependencies** (installed automatically):
25
+ **Dependencies** (regular dependencies, installed automatically with the package):
26
26
 
27
27
  | Package | Purpose |
28
28
  | ------------------------------- | --------------------------------------------------- |
@@ -38,6 +38,26 @@ npm install @devopsplaybook.io/common-utils
38
38
  | `jsonwebtoken` | JWT signing/verification for the auth module |
39
39
  | `fastify` | HTTP framework types for the users routes |
40
40
 
41
+ `fastify` is kept as a regular dependency (not a peer dependency) because `UsersRoutes` is typed against its `FastifyInstance` / `RequestGenericInterface` types: apps that register the routes already have fastify, and apps that don't use the routes must not be forced to add it.
42
+
43
+ **Subpath imports** -- every module is also exposed as a subpath export, so importing one module never pulls in the whole barrel (and with it the native database drivers):
44
+
45
+ | Subpath | Module |
46
+ | --------------------------------------------- | -------------------------------------------- |
47
+ | `@devopsplaybook.io/common-utils/otel` | `createOTelContext` |
48
+ | `@devopsplaybook.io/common-utils/config` | `ConfigBase` |
49
+ | `@devopsplaybook.io/common-utils/db` | `DbUtils` facade |
50
+ | `@devopsplaybook.io/common-utils/db/sqlite` | `SqlDbUtils` |
51
+ | `@devopsplaybook.io/common-utils/db/postgres` | `PostgresDbUtils` + `PostgresSchemaDbUtils` |
52
+ | `@devopsplaybook.io/common-utils/db/no-telemetry` | `DbUtilsNoTelemetry` |
53
+ | `@devopsplaybook.io/common-utils/users` | auth/users/routes module |
54
+ | `@devopsplaybook.io/common-utils/notifications` | `NotificationsClient` |
55
+ | `@devopsplaybook.io/common-utils/llm` | `LLMClient` |
56
+ | `@devopsplaybook.io/common-utils/system` | `SystemCommandExecute` |
57
+ | `@devopsplaybook.io/common-utils/timeout` | `TimeoutWait` |
58
+
59
+ The root import (`@devopsplaybook.io/common-utils`) works unchanged. The published tarball contains only `dist/` (no sources, specs or workflows); requiring `package.json` through `@devopsplaybook.io/common-utils/package.json` is also allowed.
60
+
41
61
  ### Modules
42
62
 
43
63
  #### `OTelContext` -- Telemetry Singleton Factory
@@ -94,21 +114,29 @@ await config.reload();
94
114
 
95
115
  **Built-in fields** (pre-registered, no `addConfigField` needed):
96
116
 
97
- | Field | Default | Sensitive |
98
- | -------------------------------------- | -------------------- | ----------------------------------- |
99
- | `API_PORT` | `8080` | No |
100
- | `JWT_VALIDITY_DURATION` | `8035200` (3 months) | No |
101
- | `CORS_POLICY_ORIGIN` | `""` | No |
102
- | `DATA_DIR` | `/data` | No |
103
- | `JWT_KEY` | `uuidv4()` | Yes |
104
- | `LOG_LEVEL` | `"info"` | No |
105
- | `DATABASE_TYPE` | `"sqlite"` | No |
106
- | `DATABASE_POSTGRES_HOST` | `""` | No |
107
- | `DATABASE_POSTGRES_PORT` | `5432` | No |
108
- | `DATABASE_POSTGRES_USER` | `""` | No |
109
- | `DATABASE_POSTGRES_PASSWORD` | `""` | Yes |
110
- | `DATABASE_POSTGRES_DATABASE` | `""` | No |
111
- | All `OPENTELEMETRY_COLLECTOR_*` fields | Various | No (except `_AUTHORIZATION_HEADER`) |
117
+ | Field | Default | Sensitive |
118
+ | ------------------------------------------------- | ------------------------------------ | ----------------------------------- |
119
+ | `VERSION` | library version (`package.json`) | No |
120
+ | `SERVICE_ID` | constructor argument | No |
121
+ | `API_PORT` | `8080` | No |
122
+ | `JWT_VALIDITY_DURATION` | `8035200` (3 months) | No |
123
+ | `JWT_REVOCATION_ENABLED` | `false` | No |
124
+ | `API_TOKENS_MAX_PER_USER` | `100` | No |
125
+ | `CORS_POLICY_ORIGIN` | `""` | No |
126
+ | `DATA_DIR` | `/data` | No |
127
+ | `JWT_KEY` | `uuidv4()` | Yes |
128
+ | `LOG_LEVEL` | `"info"` | No |
129
+ | `DATABASE_TYPE` | `"sqlite"` | No |
130
+ | `DATABASE_POSTGRES_HOST` | `""` | No |
131
+ | `DATABASE_POSTGRES_PORT` | `5432` | No |
132
+ | `DATABASE_POSTGRES_USER` | `""` | No |
133
+ | `DATABASE_POSTGRES_PASSWORD` | `""` | Yes |
134
+ | `DATABASE_POSTGRES_DATABASE` | `""` | No |
135
+ | `DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS` | `0` (disabled) | No |
136
+ | `DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS` | `0` (disabled) | No |
137
+ | All `OPENTELEMETRY_COLLECTOR_*` fields | Various | No (except `_AUTHORIZATION_HEADER`) |
138
+
139
+ `VERSION` is detected from the library's own `package.json` (correct also for the published layout) and can be overridden like any other field via the `VERSION` environment variable or the config file. `SERVICE_ID` can be overridden the same way. Values loaded from the environment or the config file are coerced to the type of the default value (numbers, booleans, arrays), so `"DATABASE_POSTGRES_PORT": "5433"` in `config.json` is applied as the number `5433`. An unsupported `DATABASE_TYPE` makes `DbUtilsInit` reject with an explicit error.
112
140
 
113
141
  ---
114
142
 
@@ -150,7 +178,7 @@ const changes = SqlDbUtilsExecSQL(
150
178
  | `SqlDbUtilsExecSQLFile` | `(span, filename)` | Execute an entire SQL file |
151
179
  | `SqlDbUtilsGetDatabase` | `()` | Returns the `better-sqlite3` `Database` instance |
152
180
 
153
- **Migration convention**: Files named `init-NNNN.sql` in `sqlDir`, applied in order. A `metadata` table tracks applied versions for idempotent re-runs. `init-0000.sql` must exist (creates the `metadata` table).
181
+ **Migration convention**: Files named `init-NNNN.sql` in `sqlDir`, applied in order. `init-0000.sql` must exist (it creates the `metadata` table) — a missing file rejects the init. Each migration file and its `db_version` row are applied inside a single transaction: a failing migration is rolled back, its version is **not** recorded, and it is retried on the next boot. Applied versions are compared numerically — `metadata.value` is a text-affinity column, so a plain `MAX(value)` would order `"9"` after `"10"` and re-apply the tenth and later migrations forever. SQLite has a single writer: never point two processes at the same database file while init/migrations run (Postgres is protected by an advisory lock instead).
154
182
 
155
183
  ---
156
184
 
@@ -169,7 +197,7 @@ Async (Promise-based) database operations using `pg.Pool`, with OTel tracing.
169
197
  | `PostgresDbUtilsTransactionStart` | `(span)` | Begin a transaction (`BEGIN`) |
170
198
  | `PostgresDbUtilsTransactionCommit` | `(span)` | Commit a transaction (`COMMIT`) |
171
199
 
172
- Pool defaults: `max: 20`, `idleTimeoutMillis: 30000`, `connectionTimeoutMillis: 10000`.
200
+ Pool defaults: `max: 20`, `idleTimeoutMillis: 30000`, `connectionTimeoutMillis: 10000`. `DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS` and `DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS` set the matching per-session settings on every pool (`0`, the default, keeps them disabled — the historical behaviour). Migrations run inside a transaction and are serialised across replicas with a Postgres advisory lock, so two pods booting at the same time cannot apply the same `init-NNNN.sql` twice. The same migration conventions as SQLite apply (`init-0000.sql` must exist, versions are compared numerically).
173
201
 
174
202
  ---
175
203
 
@@ -194,11 +222,11 @@ await DictionaryDb.initSchema(span, config, path.resolve(__dirname, "../sql/dict
194
222
  // Init shared runtime pool (call on any instance)
195
223
  AuthDb.initRuntimePool(config);
196
224
 
197
- // Query using the schema-specific pool
225
+ // Query using the shared runtime pool (the default)
198
226
  const users = await AuthDb.querySQL(span, "SELECT * FROM users WHERE id = $1", [userId]);
199
227
 
200
- // Or use the runtime pool for non-migration queries
201
- const rows = await AuthDb.querySQL(span, "SELECT ...", [], false);
228
+ // Pass useSchemaPool = true to target the schema-specific pool (migrations, admin tasks)
229
+ const rows = await AuthDb.querySQL(span, "SELECT ...", [], true);
202
230
 
203
231
  // Transactions
204
232
  await AuthDb.transaction(span, async (client) => {
@@ -222,7 +250,7 @@ await AuthDb.closeAll();
222
250
  | `transaction(context, callback, useSchemaPool?)` | Run callback inside a transaction |
223
251
  | `closeAll()` | Close all pools managed by this instance |
224
252
 
225
- `useSchemaPool` (default `true`) selects between the schema-specific pool (migrations) and the shared runtime pool (application queries).
253
+ `useSchemaPool` (default `false`) selects between the shared runtime pool (application queries — call `initRuntimePool(config)` first) and the schema-specific pool created by `initSchema` (migrations, schema administration). The default is `false`; pass `true` explicitly for schema-pool access.
226
254
 
227
255
  ---
228
256
 
@@ -257,6 +285,8 @@ const rows = DbUtilsQuerySQL(span, "SELECT * FROM users WHERE id = ?", [
257
285
  | `DbUtilsGetType()` | Returns `"sqlite"` or `"postgres"` |
258
286
  | `convertToPostgresPlaceholders(sql)` | Converts `?` to `$1, $2, ...` |
259
287
 
288
+ `convertToPostgresPlaceholders` rewrites only real placeholder `?` characters: single- and double-quoted literals (with `''` escapes), dollar-quoted strings (`$$…$$`, `$tag$…$tag$`) and `--` / `/* */` comments are skipped. The Postgres jsonb `?` operator is **not** supported — use the function form (`jsonb_exists(column, 'key')`). `DbUtilsInit` rejects an unsupported `DATABASE_TYPE` with an explicit error.
289
+
260
290
  ---
261
291
 
262
292
  #### `DbUtilsNoTelemetry` -- High-Throughput Path
@@ -292,6 +322,8 @@ DbUtilsNoTelemetryBatchInsert(
292
322
  | `DbUtilsNoTelemetryQuerySQL(sql, params?, debug?)` | Read without spans |
293
323
  | `DbUtilsNoTelemetryBatchInsert(tableCols, numCols, rows)` | Optimized multi-row INSERT |
294
324
 
325
+ Repeated statements are compiled once per connection: prepared statements are cached per SQL string (bounded cache, cleared on re-init), which removes the per-call `prepare` cost on ingestion hot paths. `DbUtilsNoTelemetryBatchInsert` chunks large row sets automatically so the generated statement stays below the driver parameter limit (65535 parameters for Postgres, 32766 for SQLite) instead of failing with an opaque driver error.
326
+
295
327
  ---
296
328
 
297
329
  #### `Notifications` -- Central Notifications Client
@@ -402,25 +434,39 @@ fastify.register(new UsersRoutes().getRoutes, { prefix: "/api/users" });
402
434
  | Export | Description |
403
435
  | ---------------------------- | ---------------------------------------------------------------------- |
404
436
  | `AuthSetOTel` | Injects the OTel tracer used by the auth module (before `AuthInit`) |
405
- | `AuthInit` | Registers app scopes, loads or generates the JWT key from `metadata` |
437
+ | `AuthInit` | Registers app scopes, loads or generates the JWT key from `metadata` (under an advisory lock) |
406
438
  | `AuthGenerateJWT` | Signs a JWT for a user (admins get all scopes) |
407
439
  | `AuthMustBeAuthenticated` | 403 guard: any valid JWT **or user API token** |
408
440
  | `AuthMustBeAdmin` | 403 guard: `role === "admin"` |
409
441
  | `AuthHasScope` | 403 guard: admin or credentials containing the requested scope |
410
442
  | `AuthGetUserSession` | Returns the `UserSession` decoded from the request credentials |
443
+ | `AuthJwtRevocationEnabled` / `AuthGetApiTokensMaxPerUser` | Current `JWT_REVOCATION_ENABLED` / `API_TOKENS_MAX_PER_USER` values |
411
444
  | `User`, `UserRole`, `UserScope` | User model; scopes are application-defined strings |
412
445
  | `UserSession` | Decoded session: `isAuthenticated`, `userId`, `userName`, `role`, `scopes` |
413
446
  | `UserApiToken` | API token model (only the SHA-256 hash is persisted) |
414
447
  | `UserPasswordSetPassword` / `UserPasswordCheckPassword` | bcrypt hashing and verification |
415
448
  | `UsersDataSetOTel` | Injects the OTel tracer used by the users data module |
416
- | `UsersData*` | Users table CRUD (`Get`, `GetByName`, `List`, `Add`, `UpdateUser`, `UpdatePassword`, `Delete`) |
449
+ | `UsersData*` | Users table CRUD (`Get`, `GetByName`, `List`, `Count`, `CountAdmins`, `Add`, `UpdateUser`, `UpdatePassword`, `Delete`, `BumpTokenVersion`) |
450
+ | `isUniqueViolationError` | Detects unique-constraint violations (SQLite/Postgres) |
417
451
  | `UsersApiTokensDataSetOTel` | Injects the OTel tracer used by the API tokens data module |
418
- | `UsersApiTokensData*` | API tokens table CRUD (`Get`, `GetByTokenHash`, `ListByUser`, `Add`, `Delete`, `DeleteByUser`) |
452
+ | `UsersApiTokensData*` | API tokens table CRUD (`Get`, `GetByTokenHash`, `ListByUser`, `CountByUser`, `Add`, `Delete`, `DeleteByUser`, `SetLastUsed`) |
419
453
  | `UsersRoutes` | Fastify routes: `GET /status/initialization`, `POST /session`, user CRUD, `PUT /password`, API tokens (`POST/GET /tokens`, `DELETE /tokens/:id`) |
420
454
 
421
- **Requirements**: a `users` table (columns `id`, `name`, `passwordEncrypted`, `role`, `scopes`), the standard `metadata` table created by `init-0000.sql`, and a `users_api_tokens` table (columns `id`, `name`, `userId`, `tokenHash`, `dateCreated`, with a unique index on `tokenHash` and an index on `userId`) for the API token feature. SQL is written SQLite-first; the `DbUtils` facade converts placeholders for Postgres.
455
+ **Schema requirements**
456
+
457
+ | Table | Columns | Notes |
458
+ | ------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
459
+ | `users` | `id`, `name`, `passwordEncrypted`, `role`, `scopes` | Add a **case-insensitive unique index** on the name (`CREATE UNIQUE INDEX ... ON users (LOWER("name"))`): duplicate names differing only by case (e.g. `"Admin"` / `"admin"`) are then rejected with 400. With `JWT_REVOCATION_ENABLED` also add `"tokenVersion" INTEGER DEFAULT 0`. |
460
+ | `users_api_tokens` | `id`, `name`, `userId`, `tokenHash`, `dateCreated` | Unique index on `tokenHash` and an index on `userId`. Optional additive migration for token expiry / last use: `"expiresAt" TEXT`, `"lastUsedAt" TEXT`. |
461
+ | `metadata` | created by `init-0000.sql` | Stores the JWT key, the first-admin bootstrap marker and the applied migration versions. |
462
+
463
+ SQL is written SQLite-first; the `DbUtils` facade converts placeholders for Postgres.
464
+
465
+ **API tokens**: users create their own API tokens via `POST /api/users/tokens` (body `{ name, expiresAt? }`); the plaintext token is returned exactly once and only its SHA-256 hash is stored. Names are limited to 255 characters and each user may hold at most `API_TOKENS_MAX_PER_USER` (default `100`) tokens. `GET /api/users/tokens` lists the caller's tokens and `DELETE /api/users/tokens/:id` revokes one (owner or admin). `expiresAt` must be a future ISO 8601 date and requires the optional `expiresAt` column — creating a token with an expiry before that migration answers 400. `lastUsedAt` is refreshed at most once per hour and requires its optional column (silently skipped otherwise). Requests authenticated with `Authorization: Bearer <api-token>` resolve the owning user's live role and scopes on every request, so role/scope changes apply immediately and revocation is instant; unknown credentials are negatively cached for a short time so a bad-token flood does not hit the database on every request.
466
+
467
+ **JWT revocation (opt-in)**: `role` and `scopes` are baked into a JWT at signing time, so a token stays valid (default 3 months) after the user is deleted or their role changes. With `JWT_REVOCATION_ENABLED=true` (default `false`, the default validity is unchanged) every JWT request re-reads the user and rejects tokens whose `tokenVersion` claim is stale; password changes, role/scope changes and deletion therefore invalidate previously issued JWTs. This requires the `users.tokenVersion` column. JWTs are always verified as HS256 (`algorithms: ["HS256"]` is pinned); `iss`/`aud` are not set.
422
468
 
423
- **API tokens**: users create their own API tokens via `POST /api/users/tokens` (body `{ name }`); the plaintext token is returned exactly once and only its SHA-256 hash is stored. `GET /api/users/tokens` lists the caller's tokens and `DELETE /api/users/tokens/:id` revokes one (owner or admin). Requests authenticated with `Authorization: Bearer <api-token>` resolve to the owning user's live role and scopes on every request, so role/scope changes apply immediately and revocation is instant. Tokens are valid until revoked (no expiry).
469
+ **Response conventions**: creation answers 201, reads and deletes answer 200. Malformed or bodyless writes answer 400 (`Missing: …`) instead of HTTP 500, and the last remaining admin cannot demote or delete themselves (`At least 1 admin must be defined`). `GET /` is paginated with `?limit=&offset=` (invalid values answer 400); the bootstrap emptiness check and the last-admin guards use `COUNT(*)` queries instead of loading every row. The 403 guards (`AuthMustBeAuthenticated`, `AuthMustBeAdmin`, `AuthHasScope`) **send the 403 response and then throw** — callers must wrap the guard in `try/catch` and `return` from the catch block (see the `UsersRoutes` implementations) so the route does not continue after the response.
424
470
 
425
471
  ---
426
472
 
@@ -32,6 +32,10 @@ export interface ConfigDatabaseInterface {
32
32
  DATABASE_POSTGRES_USER: string;
33
33
  DATABASE_POSTGRES_PASSWORD: string;
34
34
  DATABASE_POSTGRES_DATABASE: string;
35
+ /** Per-session `statement_timeout` in ms (0 = disabled). */
36
+ DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS: number;
37
+ /** Per-session `idle_in_transaction_session_timeout` in ms (0 = disabled). */
38
+ DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS: number;
35
39
  }
36
40
  /**
37
41
  * Common server configuration fields shared across projects.
@@ -40,6 +44,8 @@ export interface ConfigCommonInterface extends ConfigOTelInterface, ConfigDataba
40
44
  CONFIG_FILE: string;
41
45
  API_PORT: number;
42
46
  JWT_VALIDITY_DURATION: number;
47
+ JWT_REVOCATION_ENABLED: boolean;
48
+ API_TOKENS_MAX_PER_USER: number;
43
49
  CORS_POLICY_ORIGIN: string;
44
50
  DATA_DIR: string;
45
51
  JWT_KEY: string;
@@ -81,6 +87,14 @@ export declare abstract class ConfigBase implements ConfigCommonInterface {
81
87
  CONFIG_FILE: string;
82
88
  API_PORT: number;
83
89
  JWT_VALIDITY_DURATION: number;
90
+ /**
91
+ * Opt-in JWT revocation: when enabled, every JWT request re-reads the user
92
+ * and rejects tokens whose `tokenVersion` claim is stale. Requires the
93
+ * `users.tokenVersion` migration (see README).
94
+ */
95
+ JWT_REVOCATION_ENABLED: boolean;
96
+ /** Maximum number of API tokens a single user may create. */
97
+ API_TOKENS_MAX_PER_USER: number;
84
98
  CORS_POLICY_ORIGIN: string;
85
99
  DATA_DIR: string;
86
100
  JWT_KEY: string;
@@ -91,6 +105,16 @@ export declare abstract class ConfigBase implements ConfigCommonInterface {
91
105
  DATABASE_POSTGRES_USER: string;
92
106
  DATABASE_POSTGRES_PASSWORD: string;
93
107
  DATABASE_POSTGRES_DATABASE: string;
108
+ /**
109
+ * Per-session `statement_timeout` applied to every Postgres pool.
110
+ * `0` (default) disables the timeout, preserving the historical behaviour.
111
+ */
112
+ DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS: number;
113
+ /**
114
+ * Per-session `idle_in_transaction_session_timeout` applied to every
115
+ * Postgres pool. `0` (default) disables the timeout.
116
+ */
117
+ DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS: number;
94
118
  /**
95
119
  * Fields registered by subclasses (or the base) that {@link reload}
96
120
  * should process. Common / DB / OTel fields are pre-registered.
@@ -101,6 +125,13 @@ export declare abstract class ConfigBase implements ConfigCommonInterface {
101
125
  * @param configFile Optional path to the JSON config file. Defaults to `"config.json"`.
102
126
  */
103
127
  constructor(serviceId: string, configFile?: string);
128
+ /**
129
+ * Resolve the library's own version from its published manifest.
130
+ * `dist/src/ConfigBase.js` ships next to `../../package.json`; when running
131
+ * from the TypeScript sources the manifest is at `../package.json`. Falls
132
+ * back to `"1"` when neither resolves to this package.
133
+ */
134
+ private static detectVersion;
104
135
  /**
105
136
  * Register a configuration field so that {@link reload} processes it.
106
137
  * Call this in your subclass constructor for every project-specific field.
@@ -41,12 +41,16 @@ const fse = __importStar(require("fs-extra"));
41
41
  const uuid_1 = require("uuid");
42
42
  const path_1 = __importDefault(require("path"));
43
43
  /**
44
- * Coerce a string value read from an environment variable to match the
45
- * type of the default value (number → parseFloat, boolean → "true"/"1",
46
- * array → JSON.parse, etc.). When the default is already a string or
47
- * there is no default, the original string is returned as-is.
44
+ * Coerce a value read from an environment variable (always a string) or from
45
+ * the config file to match the type of the default value (number →
46
+ * parseFloat, boolean → "true"/"1", array → JSON.parse, etc.). Values that
47
+ * are already typed correctly (config file JSON) pass through unchanged; when
48
+ * the default is a string or there is no default, the value is returned as-is.
48
49
  */
49
50
  function coerceValue(value, defaultValue) {
51
+ if (typeof value !== "string") {
52
+ return value;
53
+ }
50
54
  if (defaultValue === undefined || defaultValue === null) {
51
55
  return value;
52
56
  }
@@ -120,6 +124,14 @@ class ConfigBase {
120
124
  this.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER = "";
121
125
  this.API_PORT = 8080;
122
126
  this.JWT_VALIDITY_DURATION = 3 * 31 * 24 * 3600;
127
+ /**
128
+ * Opt-in JWT revocation: when enabled, every JWT request re-reads the user
129
+ * and rejects tokens whose `tokenVersion` claim is stale. Requires the
130
+ * `users.tokenVersion` migration (see README).
131
+ */
132
+ this.JWT_REVOCATION_ENABLED = false;
133
+ /** Maximum number of API tokens a single user may create. */
134
+ this.API_TOKENS_MAX_PER_USER = 100;
123
135
  this.CORS_POLICY_ORIGIN = "";
124
136
  this.DATA_DIR = process.env.DATA_DIR || "/data";
125
137
  this.JWT_KEY = (0, uuid_1.v4)();
@@ -131,6 +143,16 @@ class ConfigBase {
131
143
  this.DATABASE_POSTGRES_USER = "";
132
144
  this.DATABASE_POSTGRES_PASSWORD = "";
133
145
  this.DATABASE_POSTGRES_DATABASE = "";
146
+ /**
147
+ * Per-session `statement_timeout` applied to every Postgres pool.
148
+ * `0` (default) disables the timeout, preserving the historical behaviour.
149
+ */
150
+ this.DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS = 0;
151
+ /**
152
+ * Per-session `idle_in_transaction_session_timeout` applied to every
153
+ * Postgres pool. `0` (default) disables the timeout.
154
+ */
155
+ this.DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS = 0;
134
156
  /**
135
157
  * Fields registered by subclasses (or the base) that {@link reload}
136
158
  * should process. Common / DB / OTel fields are pre-registered.
@@ -138,20 +160,15 @@ class ConfigBase {
138
160
  this._fields = [];
139
161
  this.SERVICE_ID = serviceId;
140
162
  this.CONFIG_FILE = configFile || process.env.CONFIG_FILE || "config.json";
141
- // Auto-detect version from nearest package.json
142
- try {
143
- const pkg = fse.readJsonSync(path_1.default.resolve(__dirname, "../package.json"));
144
- if (pkg && pkg.version) {
145
- this.VERSION = pkg.version;
146
- }
147
- // eslint-disable-next-line no-unused-vars -- catch binding kept so dist/ stays byte-identical to the TS6 build
148
- }
149
- catch (_e) {
150
- // fallback to "1"
151
- }
163
+ this.VERSION = ConfigBase.detectVersion();
152
164
  // Pre-register base + DB + OTel fields so reload() handles them
153
165
  const baseFields = [
166
+ { field: "VERSION" },
167
+ { field: "SERVICE_ID" },
168
+ { field: "API_PORT" },
154
169
  { field: "JWT_VALIDITY_DURATION" },
170
+ { field: "JWT_REVOCATION_ENABLED" },
171
+ { field: "API_TOKENS_MAX_PER_USER" },
155
172
  { field: "CORS_POLICY_ORIGIN" },
156
173
  { field: "DATA_DIR" },
157
174
  { field: "JWT_KEY", sensitive: true },
@@ -166,6 +183,8 @@ class ConfigBase {
166
183
  envAliases: ["POSTGRES_PASSWORD"],
167
184
  },
168
185
  { field: "DATABASE_POSTGRES_DATABASE", envAliases: ["POSTGRES_DB"] },
186
+ { field: "DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS" },
187
+ { field: "DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS" },
169
188
  { field: "OPENTELEMETRY_COLLECTOR_HTTP_TRACES" },
170
189
  { field: "OPENTELEMETRY_COLLECTOR_HTTP_METRICS" },
171
190
  { field: "OPENTELEMETRY_COLLECTOR_HTTP_LOGS" },
@@ -185,6 +204,29 @@ class ConfigBase {
185
204
  this.addConfigField(f);
186
205
  }
187
206
  }
207
+ /**
208
+ * Resolve the library's own version from its published manifest.
209
+ * `dist/src/ConfigBase.js` ships next to `../../package.json`; when running
210
+ * from the TypeScript sources the manifest is at `../package.json`. Falls
211
+ * back to `"1"` when neither resolves to this package.
212
+ */
213
+ static detectVersion() {
214
+ for (const candidate of [
215
+ path_1.default.resolve(__dirname, "../../package.json"),
216
+ path_1.default.resolve(__dirname, "../package.json"),
217
+ ]) {
218
+ try {
219
+ const pkg = fse.readJsonSync(candidate);
220
+ if (pkg && pkg.name === "@devopsplaybook.io/common-utils" && pkg.version) {
221
+ return pkg.version;
222
+ }
223
+ }
224
+ catch {
225
+ // try the next candidate
226
+ }
227
+ }
228
+ return "1";
229
+ }
188
230
  /**
189
231
  * Register a configuration field so that {@link reload} processes it.
190
232
  * Call this in your subclass constructor for every project-specific field.
@@ -246,7 +288,7 @@ class ConfigBase {
246
288
  // 4. Config file override (environment always wins, but if neither
247
289
  // environment nor alias matched, check config file)
248
290
  if (foundValue === undefined && content[field] !== undefined) {
249
- this[field] = content[field];
291
+ this[field] = coerceValue(content[field], defaultValue);
250
292
  from = "config";
251
293
  }
252
294
  if (sensitive) {
@@ -8,6 +8,12 @@ import * as PostgresDbUtils from "./PostgresDbUtils";
8
8
  export interface DbUtilsConfig extends SqlDbUtils.SqlDbConfig, PostgresDbUtils.PostgresDbConfig {
9
9
  DATABASE_TYPE: "sqlite" | "postgres";
10
10
  }
11
+ /**
12
+ * Advisory-lock purposes used by the facade. SQLite has a single writer and
13
+ * runs the callback directly; Postgres serialises concurrently booting
14
+ * replicas on the named lock.
15
+ */
16
+ export type DbUtilsLockName = "auth_token" | "users_bootstrap";
11
17
  /**
12
18
  * Injects the OTel tracer and logger instances used by the DB layer.
13
19
  * Must be called once at startup, before {@link DbUtilsInit}.
@@ -24,13 +30,24 @@ export declare function DbUtilsSetOTel(tracer: StandardTracer, logger: StandardL
24
30
  * @param sqlDir Absolute path to the directory containing SQL migration files.
25
31
  */
26
32
  export declare function DbUtilsInit(context: Span, config: DbUtilsConfig, sqlDir: string): Promise<void>;
33
+ /**
34
+ * Run a bootstrap callback while holding the named advisory lock. Replicas
35
+ * booting concurrently serialise the callback on Postgres; SQLite has a
36
+ * single writer and runs it directly.
37
+ */
38
+ export declare function DbUtilsWithLock<T>(lock: DbUtilsLockName, callback: () => Promise<T>): Promise<T>;
27
39
  /**
28
40
  * Returns the native database handle.
29
41
  * - SQLite: `better-sqlite3` `Database` instance
30
42
  * - Postgres: `pg` `Pool` instance
31
43
  */
32
44
  export declare function DbUtilsGetDatabase(): any;
33
- /** Convert SQLite `?` placeholders to PostgreSQL `$1, $2, ...` numbering. */
45
+ /**
46
+ * Convert SQLite `?` placeholders to PostgreSQL `$1, $2, ...` numbering.
47
+ * `?` characters inside single- or double-quoted values, dollar-quoted
48
+ * strings and `--` / block comments are left untouched. The Postgres jsonb
49
+ * `?` operator is not supported: use the function form (`jsonb_exists`).
50
+ */
34
51
  export declare function convertToPostgresPlaceholders(sql: string): string;
35
52
  /**
36
53
  * Execute a write SQL statement with OTel tracing.
@@ -38,13 +55,13 @@ export declare function convertToPostgresPlaceholders(sql: string): string;
38
55
  *
39
56
  * @returns Number of rows changed.
40
57
  */
41
- export declare function DbUtilsExecSQL(context: Span, sql: string, params?: unknown[]): number | Promise<number>;
58
+ export declare function DbUtilsExecSQL(context: Span | undefined, sql: string, params?: unknown[]): number | Promise<number>;
42
59
  /**
43
60
  * Execute a read SQL query with OTel tracing.
44
61
  * Automatically converts `?` placeholders to `$N` when using Postgres.
45
62
  *
46
63
  * @returns Array of row objects.
47
64
  */
48
- export declare function DbUtilsQuerySQL(context: Span, sql: string, params?: unknown[], debug?: boolean): any[] | Promise<any[]>;
65
+ export declare function DbUtilsQuerySQL(context: Span | undefined, sql: string, params?: unknown[], debug?: boolean): any[] | Promise<any[]>;
49
66
  /** Returns the active database type (`"sqlite"` or `"postgres"`). */
50
67
  export declare function DbUtilsGetType(): "sqlite" | "postgres";
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DbUtilsSetOTel = DbUtilsSetOTel;
37
37
  exports.DbUtilsInit = DbUtilsInit;
38
+ exports.DbUtilsWithLock = DbUtilsWithLock;
38
39
  exports.DbUtilsGetDatabase = DbUtilsGetDatabase;
39
40
  exports.convertToPostgresPlaceholders = convertToPostgresPlaceholders;
40
41
  exports.DbUtilsExecSQL = DbUtilsExecSQL;
@@ -62,6 +63,9 @@ function DbUtilsSetOTel(tracer, logger) {
62
63
  * @param sqlDir Absolute path to the directory containing SQL migration files.
63
64
  */
64
65
  async function DbUtilsInit(context, config, sqlDir) {
66
+ if (config.DATABASE_TYPE !== "sqlite" && config.DATABASE_TYPE !== "postgres") {
67
+ throw new Error(`Invalid DATABASE_TYPE: ${config.DATABASE_TYPE} (expected "sqlite" or "postgres")`);
68
+ }
65
69
  databaseType = config.DATABASE_TYPE;
66
70
  if (databaseType === "postgres") {
67
71
  await PostgresDbUtils.PostgresDbUtilsInit(context, config, sqlDir);
@@ -70,6 +74,17 @@ async function DbUtilsInit(context, config, sqlDir) {
70
74
  await SqlDbUtils.SqlDbUtilsInit(context, config, sqlDir);
71
75
  }
72
76
  }
77
+ /**
78
+ * Run a bootstrap callback while holding the named advisory lock. Replicas
79
+ * booting concurrently serialise the callback on Postgres; SQLite has a
80
+ * single writer and runs it directly.
81
+ */
82
+ async function DbUtilsWithLock(lock, callback) {
83
+ if (databaseType === "postgres") {
84
+ return PostgresDbUtils.PostgresDbUtilsWithAdvisoryLock(lock, callback);
85
+ }
86
+ return callback();
87
+ }
73
88
  /**
74
89
  * Returns the native database handle.
75
90
  * - SQLite: `better-sqlite3` `Database` instance
@@ -81,10 +96,86 @@ function DbUtilsGetDatabase() {
81
96
  }
82
97
  return SqlDbUtils.SqlDbUtilsGetDatabase();
83
98
  }
84
- /** Convert SQLite `?` placeholders to PostgreSQL `$1, $2, ...` numbering. */
99
+ /**
100
+ * Convert SQLite `?` placeholders to PostgreSQL `$1, $2, ...` numbering.
101
+ * `?` characters inside single- or double-quoted values, dollar-quoted
102
+ * strings and `--` / block comments are left untouched. The Postgres jsonb
103
+ * `?` operator is not supported: use the function form (`jsonb_exists`).
104
+ */
85
105
  function convertToPostgresPlaceholders(sql) {
106
+ let converted = "";
86
107
  let paramIndex = 1;
87
- return sql.replace(/\?/g, () => `$${paramIndex++}`);
108
+ let i = 0;
109
+ while (i < sql.length) {
110
+ const char = sql[i];
111
+ // Single-quoted literal: '' is an escaped quote.
112
+ if (char === "'") {
113
+ const end = skipQuoted(sql, i, "'");
114
+ converted += sql.slice(i, end);
115
+ i = end;
116
+ continue;
117
+ }
118
+ // Double-quoted identifier, same '' escaping rule.
119
+ if (char === '"') {
120
+ const end = skipQuoted(sql, i, '"');
121
+ converted += sql.slice(i, end);
122
+ i = end;
123
+ continue;
124
+ }
125
+ // Dollar-quoted string: $tag$ ... $tag$ (or $$ ... $$).
126
+ if (char === "$") {
127
+ const tag = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
128
+ if (tag) {
129
+ const closing = sql.indexOf(tag[0], i + tag[0].length);
130
+ const end = closing === -1 ? sql.length : closing + tag[0].length;
131
+ converted += sql.slice(i, end);
132
+ i = end;
133
+ continue;
134
+ }
135
+ converted += char;
136
+ i++;
137
+ continue;
138
+ }
139
+ // Line comment.
140
+ if (char === "-" && sql[i + 1] === "-") {
141
+ const newline = sql.indexOf("\n", i);
142
+ const end = newline === -1 ? sql.length : newline;
143
+ converted += sql.slice(i, end);
144
+ i = end;
145
+ continue;
146
+ }
147
+ // Block comment.
148
+ if (char === "/" && sql[i + 1] === "*") {
149
+ const closing = sql.indexOf("*/", i + 2);
150
+ const end = closing === -1 ? sql.length : closing + 2;
151
+ converted += sql.slice(i, end);
152
+ i = end;
153
+ continue;
154
+ }
155
+ if (char === "?") {
156
+ converted += `$${paramIndex++}`;
157
+ i++;
158
+ continue;
159
+ }
160
+ converted += char;
161
+ i++;
162
+ }
163
+ return converted;
164
+ }
165
+ /** Returns the index just past the closing quote (unterminated → end of string). */
166
+ function skipQuoted(sql, start, quote) {
167
+ let i = start + 1;
168
+ while (i < sql.length) {
169
+ if (sql[i] === quote) {
170
+ if (sql[i + 1] === quote) {
171
+ i += 2;
172
+ continue;
173
+ }
174
+ return i + 1;
175
+ }
176
+ i++;
177
+ }
178
+ return sql.length;
88
179
  }
89
180
  /**
90
181
  * Execute a write SQL statement with OTel tracing.
@@ -8,7 +8,10 @@ export declare function DbUtilsNoTelemetrySetLogger(loggerIn: StandardLogger): v
8
8
  * Execute a multi-row INSERT with a flat parameter array.
9
9
  * Builds: INSERT INTO <tableCols> VALUES (?,?...),(?,?...),...
10
10
  *
11
- * @returns Number of rows inserted.
11
+ * Large inputs are chunked so the statement never exceeds the backend's
12
+ * bound-parameter limit (65535 on Postgres, 32766 on SQLite).
13
+ *
14
+ * @returns Number of rows inserted (summed across chunks).
12
15
  */
13
16
  export declare function DbUtilsNoTelemetryBatchInsert(tableCols: string, numCols: number, rows: any[][]): number | Promise<number>;
14
17
  /**