@devopsplaybook.io/common-utils 1.10.1 → 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.
- package/README.md +73 -27
- package/dist/src/ConfigBase.d.ts +31 -0
- package/dist/src/ConfigBase.js +58 -16
- package/dist/src/DbUtils.d.ts +20 -3
- package/dist/src/DbUtils.js +93 -2
- package/dist/src/DbUtilsNoTelemetry.d.ts +4 -1
- package/dist/src/DbUtilsNoTelemetry.js +62 -6
- package/dist/src/PostgresDbUtils.d.ts +41 -10
- package/dist/src/PostgresDbUtils.js +357 -304
- package/dist/src/SqlDbUtils.d.ts +12 -4
- package/dist/src/SqlDbUtils.js +76 -30
- package/dist/src/users/Auth.d.ts +11 -1
- package/dist/src/users/Auth.js +160 -44
- package/dist/src/users/User.d.ts +10 -0
- package/dist/src/users/User.js +30 -10
- package/dist/src/users/UserApiToken.d.ts +4 -0
- package/dist/src/users/UserApiToken.js +11 -0
- package/dist/src/users/UsersApiTokensData.d.ts +12 -0
- package/dist/src/users/UsersApiTokensData.js +130 -33
- package/dist/src/users/UsersData.d.ts +20 -1
- package/dist/src/users/UsersData.js +150 -52
- package/dist/src/users/UsersRoutes.js +178 -61
- package/dist/src/users/index.d.ts +8 -0
- package/dist/src/users/index.js +24 -0
- package/package.json +58 -1
- package/.github/workflows/main-build.yml +0 -18
- package/.github/workflows/pr-check.yml +0 -27
- package/.github/workflows/reusable-merge-build.yml +0 -197
- package/.github/workflows/reusable-npm-merge.yml +0 -135
- package/.github/workflows/reusable-npm-pr.yml +0 -183
- package/.github/workflows/reusable-npm-upgrade.yml +0 -92
- package/.github/workflows/reusable-pr-verify.yml +0 -181
- package/AGENTS.md +0 -105
- package/index.ts +0 -18
- package/jest.config.js +0 -17
- package/prettierrc.json +0 -5
- package/src/ConfigBase.spec.ts +0 -108
- package/src/ConfigBase.ts +0 -297
- package/src/DbUtils.spec.ts +0 -23
- package/src/DbUtils.ts +0 -116
- package/src/DbUtilsNoTelemetry.spec.ts +0 -168
- package/src/DbUtilsNoTelemetry.ts +0 -117
- package/src/LLM.spec.ts +0 -303
- package/src/LLM.ts +0 -204
- package/src/Notifications.spec.ts +0 -265
- package/src/Notifications.ts +0 -201
- package/src/OTelContext.spec.ts +0 -58
- package/src/OTelContext.ts +0 -63
- package/src/PostgresDbUtils.spec.ts +0 -153
- package/src/PostgresDbUtils.ts +0 -666
- package/src/SqlDbUtils.spec.ts +0 -108
- package/src/SqlDbUtils.ts +0 -152
- package/src/SystemCommand.spec.ts +0 -18
- package/src/SystemCommand.ts +0 -23
- package/src/Timeout.spec.ts +0 -18
- package/src/Timeout.ts +0 -12
- package/src/users/Auth.spec.ts +0 -268
- package/src/users/Auth.ts +0 -202
- package/src/users/User.ts +0 -75
- package/src/users/UserApiToken.ts +0 -55
- package/src/users/UserPassword.spec.ts +0 -28
- package/src/users/UserPassword.ts +0 -20
- package/src/users/UserSession.ts +0 -9
- package/src/users/UsersApiTokensData.spec.ts +0 -158
- package/src/users/UsersApiTokensData.ts +0 -125
- package/src/users/UsersData.ts +0 -141
- package/src/users/UsersRoutes.ts +0 -374
- package/tsconfig.json +0 -15
- 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
|
-
**
|
|
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
|
|
98
|
-
|
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
|
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
201
|
-
const rows = await AuthDb.querySQL(span, "SELECT ...", [],
|
|
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 `
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
package/dist/src/ConfigBase.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/ConfigBase.js
CHANGED
|
@@ -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
|
|
45
|
-
* type of the default value (number →
|
|
46
|
-
* array → JSON.parse, etc.).
|
|
47
|
-
*
|
|
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
|
-
|
|
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) {
|
package/dist/src/DbUtils.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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";
|
package/dist/src/DbUtils.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
/**
|