@heavybit/shared-lib 2.1.0 → 2.7.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 (296) hide show
  1. package/README.md +163 -2
  2. package/dist/cjs/access/index.js +9 -1
  3. package/dist/cjs/access/index.js.map +1 -1
  4. package/dist/cjs/approvals/index.js +137 -0
  5. package/dist/cjs/approvals/index.js.map +1 -0
  6. package/dist/cjs/audit/index.js +74 -0
  7. package/dist/cjs/audit/index.js.map +1 -0
  8. package/dist/cjs/cache/index.js +90 -16
  9. package/dist/cjs/cache/index.js.map +1 -1
  10. package/dist/cjs/cache/keyspace.js +101 -0
  11. package/dist/cjs/cache/keyspace.js.map +1 -0
  12. package/dist/cjs/common/circuit-breaker.js +74 -0
  13. package/dist/cjs/common/circuit-breaker.js.map +1 -0
  14. package/dist/cjs/common/index.js +4 -0
  15. package/dist/cjs/common/index.js.map +1 -1
  16. package/dist/cjs/common/internal-client.js +177 -0
  17. package/dist/cjs/common/internal-client.js.map +1 -0
  18. package/dist/cjs/common/internal-constants.js +12 -0
  19. package/dist/cjs/common/internal-constants.js.map +1 -0
  20. package/dist/cjs/common/internal-service-auth.js +160 -0
  21. package/dist/cjs/common/internal-service-auth.js.map +1 -0
  22. package/dist/cjs/common/money.js +215 -0
  23. package/dist/cjs/common/money.js.map +1 -0
  24. package/dist/cjs/common/phone.js +41 -22
  25. package/dist/cjs/common/phone.js.map +1 -1
  26. package/dist/cjs/common/time.js +28 -2
  27. package/dist/cjs/common/time.js.map +1 -1
  28. package/dist/cjs/config/index.js +139 -19
  29. package/dist/cjs/config/index.js.map +1 -1
  30. package/dist/cjs/contracts/notification-dispatch.js +1 -1
  31. package/dist/cjs/database/db.js +37 -0
  32. package/dist/cjs/database/db.js.map +1 -0
  33. package/dist/cjs/database/index.js +22 -51
  34. package/dist/cjs/database/index.js.map +1 -1
  35. package/dist/cjs/database/pagination.js +88 -0
  36. package/dist/cjs/database/pagination.js.map +1 -0
  37. package/dist/cjs/database/pool.js +60 -0
  38. package/dist/cjs/database/pool.js.map +1 -0
  39. package/dist/cjs/database/scoped-repository.js +132 -0
  40. package/dist/cjs/database/scoped-repository.js.map +1 -0
  41. package/dist/cjs/database/scopes.js +166 -0
  42. package/dist/cjs/database/scopes.js.map +1 -0
  43. package/dist/cjs/database/table-names.js +14 -0
  44. package/dist/cjs/database/table-names.js.map +1 -0
  45. package/dist/cjs/database/tables.js +71 -0
  46. package/dist/cjs/database/tables.js.map +1 -0
  47. package/dist/cjs/express/auth.js +64 -50
  48. package/dist/cjs/express/auth.js.map +1 -1
  49. package/dist/cjs/express/index.js +2 -0
  50. package/dist/cjs/express/index.js.map +1 -1
  51. package/dist/cjs/express/internal-caller.js +52 -0
  52. package/dist/cjs/express/internal-caller.js.map +1 -0
  53. package/dist/cjs/express/step-up.js +41 -0
  54. package/dist/cjs/express/step-up.js.map +1 -0
  55. package/dist/cjs/http/index.js +3 -71
  56. package/dist/cjs/http/index.js.map +1 -1
  57. package/dist/cjs/identity/index.js +269 -67
  58. package/dist/cjs/identity/index.js.map +1 -1
  59. package/dist/cjs/identity/step-up.js +52 -0
  60. package/dist/cjs/identity/step-up.js.map +1 -0
  61. package/dist/cjs/index.js +6 -0
  62. package/dist/cjs/index.js.map +1 -1
  63. package/dist/cjs/knex/columns.js +36 -0
  64. package/dist/cjs/knex/columns.js.map +1 -0
  65. package/dist/cjs/knex/index.js +26 -43
  66. package/dist/cjs/knex/index.js.map +1 -1
  67. package/dist/cjs/knex/migration-helpers.js +57 -0
  68. package/dist/cjs/knex/migration-helpers.js.map +1 -0
  69. package/dist/cjs/logging/redact.js +6 -11
  70. package/dist/cjs/logging/redact.js.map +1 -1
  71. package/dist/cjs/messaging/base-consumer.js +36 -0
  72. package/dist/cjs/messaging/base-consumer.js.map +1 -0
  73. package/dist/cjs/messaging/consumer.js +142 -73
  74. package/dist/cjs/messaging/consumer.js.map +1 -1
  75. package/dist/cjs/messaging/envelope.js +53 -10
  76. package/dist/cjs/messaging/envelope.js.map +1 -1
  77. package/dist/cjs/messaging/idempotency.js +95 -8
  78. package/dist/cjs/messaging/idempotency.js.map +1 -1
  79. package/dist/cjs/messaging/index.js +2 -1
  80. package/dist/cjs/messaging/index.js.map +1 -1
  81. package/dist/cjs/messaging/outbox.js +97 -55
  82. package/dist/cjs/messaging/outbox.js.map +1 -1
  83. package/dist/cjs/messaging/publisher.js +86 -38
  84. package/dist/cjs/messaging/publisher.js.map +1 -1
  85. package/dist/cjs/messaging/staff-notifier.js +4 -4
  86. package/dist/cjs/messaging/staff-notifier.js.map +1 -1
  87. package/dist/cjs/messaging/topology.js +41 -5
  88. package/dist/cjs/messaging/topology.js.map +1 -1
  89. package/dist/cjs/rate-limit/index.js +57 -6
  90. package/dist/cjs/rate-limit/index.js.map +1 -1
  91. package/dist/cjs/rate-limit/keys.js +47 -0
  92. package/dist/cjs/rate-limit/keys.js.map +1 -0
  93. package/dist/cjs/socket/index.js +127 -16
  94. package/dist/cjs/socket/index.js.map +1 -1
  95. package/dist/cjs/socket/ticket.js +124 -0
  96. package/dist/cjs/socket/ticket.js.map +1 -0
  97. package/dist/cjs/testing/index.js +23 -14
  98. package/dist/cjs/testing/index.js.map +1 -1
  99. package/dist/esm/access/index.js +9 -1
  100. package/dist/esm/access/index.js.map +1 -1
  101. package/dist/esm/approvals/index.js +127 -0
  102. package/dist/esm/approvals/index.js.map +1 -0
  103. package/dist/esm/audit/index.js +70 -0
  104. package/dist/esm/audit/index.js.map +1 -0
  105. package/dist/esm/cache/index.js +70 -13
  106. package/dist/esm/cache/index.js.map +1 -1
  107. package/dist/esm/cache/keyspace.js +95 -0
  108. package/dist/esm/cache/keyspace.js.map +1 -0
  109. package/dist/esm/common/circuit-breaker.js +70 -0
  110. package/dist/esm/common/circuit-breaker.js.map +1 -0
  111. package/dist/esm/common/index.js +4 -0
  112. package/dist/esm/common/index.js.map +1 -1
  113. package/dist/esm/common/internal-client.js +173 -0
  114. package/dist/esm/common/internal-client.js.map +1 -0
  115. package/dist/esm/common/internal-constants.js +9 -0
  116. package/dist/esm/common/internal-constants.js.map +1 -0
  117. package/dist/esm/common/internal-service-auth.js +150 -0
  118. package/dist/esm/common/internal-service-auth.js.map +1 -0
  119. package/dist/esm/common/money.js +207 -0
  120. package/dist/esm/common/money.js.map +1 -0
  121. package/dist/esm/common/phone.js +41 -22
  122. package/dist/esm/common/phone.js.map +1 -1
  123. package/dist/esm/common/time.js +27 -2
  124. package/dist/esm/common/time.js.map +1 -1
  125. package/dist/esm/config/index.js +135 -18
  126. package/dist/esm/config/index.js.map +1 -1
  127. package/dist/esm/contracts/notification-dispatch.js +1 -1
  128. package/dist/esm/database/db.js +32 -0
  129. package/dist/esm/database/db.js.map +1 -0
  130. package/dist/esm/database/index.js +9 -45
  131. package/dist/esm/database/index.js.map +1 -1
  132. package/dist/esm/database/pagination.js +81 -0
  133. package/dist/esm/database/pagination.js.map +1 -0
  134. package/dist/esm/database/pool.js +51 -0
  135. package/dist/esm/database/pool.js.map +1 -0
  136. package/dist/esm/database/scoped-repository.js +128 -0
  137. package/dist/esm/database/scoped-repository.js.map +1 -0
  138. package/dist/esm/database/scopes.js +157 -0
  139. package/dist/esm/database/scopes.js.map +1 -0
  140. package/dist/esm/database/table-names.js +11 -0
  141. package/dist/esm/database/table-names.js.map +1 -0
  142. package/dist/esm/database/tables.js +61 -0
  143. package/dist/esm/database/tables.js.map +1 -0
  144. package/dist/esm/express/auth.js +62 -48
  145. package/dist/esm/express/auth.js.map +1 -1
  146. package/dist/esm/express/index.js +2 -0
  147. package/dist/esm/express/index.js.map +1 -1
  148. package/dist/esm/express/internal-caller.js +49 -0
  149. package/dist/esm/express/internal-caller.js.map +1 -0
  150. package/dist/esm/express/step-up.js +38 -0
  151. package/dist/esm/express/step-up.js.map +1 -0
  152. package/dist/esm/http/index.js +3 -70
  153. package/dist/esm/http/index.js.map +1 -1
  154. package/dist/esm/identity/index.js +246 -64
  155. package/dist/esm/identity/index.js.map +1 -1
  156. package/dist/esm/identity/step-up.js +47 -0
  157. package/dist/esm/identity/step-up.js.map +1 -0
  158. package/dist/esm/index.js +6 -0
  159. package/dist/esm/index.js.map +1 -1
  160. package/dist/esm/knex/columns.js +28 -0
  161. package/dist/esm/knex/columns.js.map +1 -0
  162. package/dist/esm/knex/index.js +8 -34
  163. package/dist/esm/knex/index.js.map +1 -1
  164. package/dist/esm/knex/migration-helpers.js +51 -0
  165. package/dist/esm/knex/migration-helpers.js.map +1 -0
  166. package/dist/esm/logging/redact.js +3 -7
  167. package/dist/esm/logging/redact.js.map +1 -1
  168. package/dist/esm/messaging/base-consumer.js +32 -0
  169. package/dist/esm/messaging/base-consumer.js.map +1 -0
  170. package/dist/esm/messaging/consumer.js +139 -71
  171. package/dist/esm/messaging/consumer.js.map +1 -1
  172. package/dist/esm/messaging/envelope.js +50 -10
  173. package/dist/esm/messaging/envelope.js.map +1 -1
  174. package/dist/esm/messaging/idempotency.js +91 -7
  175. package/dist/esm/messaging/idempotency.js.map +1 -1
  176. package/dist/esm/messaging/index.js +2 -1
  177. package/dist/esm/messaging/index.js.map +1 -1
  178. package/dist/esm/messaging/outbox.js +97 -54
  179. package/dist/esm/messaging/outbox.js.map +1 -1
  180. package/dist/esm/messaging/publisher.js +86 -38
  181. package/dist/esm/messaging/publisher.js.map +1 -1
  182. package/dist/esm/messaging/staff-notifier.js +4 -4
  183. package/dist/esm/messaging/staff-notifier.js.map +1 -1
  184. package/dist/esm/messaging/topology.js +39 -4
  185. package/dist/esm/messaging/topology.js.map +1 -1
  186. package/dist/esm/rate-limit/index.js +41 -6
  187. package/dist/esm/rate-limit/index.js.map +1 -1
  188. package/dist/esm/rate-limit/keys.js +43 -0
  189. package/dist/esm/rate-limit/keys.js.map +1 -0
  190. package/dist/esm/socket/index.js +112 -17
  191. package/dist/esm/socket/index.js.map +1 -1
  192. package/dist/esm/socket/ticket.js +117 -0
  193. package/dist/esm/socket/ticket.js.map +1 -0
  194. package/dist/esm/testing/index.js +24 -15
  195. package/dist/esm/testing/index.js.map +1 -1
  196. package/dist/types/access/index.d.ts +9 -3
  197. package/dist/types/access/index.d.ts.map +1 -1
  198. package/dist/types/approvals/index.d.ts +99 -0
  199. package/dist/types/approvals/index.d.ts.map +1 -0
  200. package/dist/types/audit/index.d.ts +82 -0
  201. package/dist/types/audit/index.d.ts.map +1 -0
  202. package/dist/types/cache/index.d.ts +64 -14
  203. package/dist/types/cache/index.d.ts.map +1 -1
  204. package/dist/types/cache/keyspace.d.ts +84 -0
  205. package/dist/types/cache/keyspace.d.ts.map +1 -0
  206. package/dist/types/common/circuit-breaker.d.ts +51 -0
  207. package/dist/types/common/circuit-breaker.d.ts.map +1 -0
  208. package/dist/types/common/index.d.ts +4 -0
  209. package/dist/types/common/index.d.ts.map +1 -1
  210. package/dist/types/common/internal-client.d.ts +116 -0
  211. package/dist/types/common/internal-client.d.ts.map +1 -0
  212. package/dist/types/common/internal-constants.d.ts +9 -0
  213. package/dist/types/common/internal-constants.d.ts.map +1 -0
  214. package/dist/types/common/internal-service-auth.d.ts +102 -0
  215. package/dist/types/common/internal-service-auth.d.ts.map +1 -0
  216. package/dist/types/common/money.d.ts +104 -0
  217. package/dist/types/common/money.d.ts.map +1 -0
  218. package/dist/types/common/phone.d.ts +25 -15
  219. package/dist/types/common/phone.d.ts.map +1 -1
  220. package/dist/types/common/time.d.ts +20 -2
  221. package/dist/types/common/time.d.ts.map +1 -1
  222. package/dist/types/config/index.d.ts +251 -27
  223. package/dist/types/config/index.d.ts.map +1 -1
  224. package/dist/types/contracts/notification-dispatch.d.ts +13 -13
  225. package/dist/types/database/db.d.ts +46 -0
  226. package/dist/types/database/db.d.ts.map +1 -0
  227. package/dist/types/database/index.d.ts +9 -22
  228. package/dist/types/database/index.d.ts.map +1 -1
  229. package/dist/types/database/pagination.d.ts +68 -0
  230. package/dist/types/database/pagination.d.ts.map +1 -0
  231. package/dist/types/database/pool.d.ts +39 -0
  232. package/dist/types/database/pool.d.ts.map +1 -0
  233. package/dist/types/database/scoped-repository.d.ts +92 -0
  234. package/dist/types/database/scoped-repository.d.ts.map +1 -0
  235. package/dist/types/database/scopes.d.ts +159 -0
  236. package/dist/types/database/scopes.d.ts.map +1 -0
  237. package/dist/types/database/table-names.d.ts +13 -0
  238. package/dist/types/database/table-names.d.ts.map +1 -0
  239. package/dist/types/database/tables.d.ts +748 -0
  240. package/dist/types/database/tables.d.ts.map +1 -0
  241. package/dist/types/express/auth.d.ts +40 -24
  242. package/dist/types/express/auth.d.ts.map +1 -1
  243. package/dist/types/express/index.d.ts +2 -0
  244. package/dist/types/express/index.d.ts.map +1 -1
  245. package/dist/types/express/internal-caller.d.ts +53 -0
  246. package/dist/types/express/internal-caller.d.ts.map +1 -0
  247. package/dist/types/express/step-up.d.ts +38 -0
  248. package/dist/types/express/step-up.d.ts.map +1 -0
  249. package/dist/types/http/index.d.ts +3 -31
  250. package/dist/types/http/index.d.ts.map +1 -1
  251. package/dist/types/identity/index.d.ts +159 -21
  252. package/dist/types/identity/index.d.ts.map +1 -1
  253. package/dist/types/identity/step-up.d.ts +41 -0
  254. package/dist/types/identity/step-up.d.ts.map +1 -0
  255. package/dist/types/index.d.ts +6 -0
  256. package/dist/types/index.d.ts.map +1 -1
  257. package/dist/types/knex/columns.d.ts +15 -0
  258. package/dist/types/knex/columns.d.ts.map +1 -0
  259. package/dist/types/knex/index.d.ts +3 -11
  260. package/dist/types/knex/index.d.ts.map +1 -1
  261. package/dist/types/knex/migration-helpers.d.ts +28 -0
  262. package/dist/types/knex/migration-helpers.d.ts.map +1 -0
  263. package/dist/types/logging/redact.d.ts +2 -2
  264. package/dist/types/logging/redact.d.ts.map +1 -1
  265. package/dist/types/messaging/base-consumer.d.ts +33 -0
  266. package/dist/types/messaging/base-consumer.d.ts.map +1 -0
  267. package/dist/types/messaging/consumer.d.ts +59 -21
  268. package/dist/types/messaging/consumer.d.ts.map +1 -1
  269. package/dist/types/messaging/envelope.d.ts +50 -11
  270. package/dist/types/messaging/envelope.d.ts.map +1 -1
  271. package/dist/types/messaging/idempotency.d.ts +60 -3
  272. package/dist/types/messaging/idempotency.d.ts.map +1 -1
  273. package/dist/types/messaging/index.d.ts +7 -0
  274. package/dist/types/messaging/index.d.ts.map +1 -1
  275. package/dist/types/messaging/outbox.d.ts +62 -22
  276. package/dist/types/messaging/outbox.d.ts.map +1 -1
  277. package/dist/types/messaging/publisher.d.ts +54 -21
  278. package/dist/types/messaging/publisher.d.ts.map +1 -1
  279. package/dist/types/messaging/staff-notifier.d.ts +9 -9
  280. package/dist/types/messaging/staff-notifier.d.ts.map +1 -1
  281. package/dist/types/messaging/staff-recipients.d.ts +1 -1
  282. package/dist/types/messaging/topology.d.ts +40 -8
  283. package/dist/types/messaging/topology.d.ts.map +1 -1
  284. package/dist/types/rate-limit/index.d.ts +40 -4
  285. package/dist/types/rate-limit/index.d.ts.map +1 -1
  286. package/dist/types/rate-limit/keys.d.ts +52 -0
  287. package/dist/types/rate-limit/keys.d.ts.map +1 -0
  288. package/dist/types/socket/index.d.ts +86 -2
  289. package/dist/types/socket/index.d.ts.map +1 -1
  290. package/dist/types/socket/ticket.d.ts +90 -0
  291. package/dist/types/socket/ticket.d.ts.map +1 -0
  292. package/dist/types/testing/index.d.ts +31 -10
  293. package/dist/types/testing/index.d.ts.map +1 -1
  294. package/dist/types/types/index.d.ts +26 -14
  295. package/dist/types/types/index.d.ts.map +1 -1
  296. package/package.json +3 -1
package/README.md CHANGED
@@ -32,8 +32,8 @@ npx tsc --noEmit
32
32
  | Import path | What it holds |
33
33
  |---|---|
34
34
  | `@heavybit/shared-lib` | Root: `types`, `common`, `identity`, `access`, `validation`, `logging`, `config`, `contracts`, `alerting` (no optional peers) |
35
- | `@heavybit/shared-lib/express` | `correlationMiddleware`, `correlationId`, `validate`, `asyncHandler`, `errorHandler`, `notFoundHandler`, `ok`, `created`, `okPage`, gateway auth guards, health and security middleware |
36
- | `@heavybit/shared-lib/http` | SSRF-guarded `safeFetch` |
35
+ | `@heavybit/shared-lib/express` | `correlationMiddleware`, `correlationId`, `validate`, `asyncHandler`, `errorHandler`, `notFoundHandler`, `ok`, `created`, `okPage`, gateway auth guards, `createInternalCallerGuards`, health and security middleware |
36
+ | `@heavybit/shared-lib/http` | SSRF-guarded `safeFetch` (service-to-service calls use root `createInternalClient`) |
37
37
  | `@heavybit/shared-lib/messaging` | RabbitMQ connection, publisher, consumer, RPC, outbox, idempotency, topology |
38
38
  | `@heavybit/shared-lib/cache` · `/database` · `/knex` · `/rate-limit` · `/socket` · `/security` · `/metrics` · `/scheduling` · `/testing` | As named |
39
39
 
@@ -98,3 +98,164 @@ await runWithContext({ correlationId: msg.correlationId, service: 'worker' }, ()
98
98
  `[REDACTED]` at any depth (except `errorCode`, `statusCode`, `countryCode`, `currencyCode`, `postalCode`,
99
99
  `zipCode`); phone numbers are masked to their last 4 digits.
100
100
 
101
+
102
+ ### Gateway identity v2 and route guards (`identity`, `express`)
103
+
104
+ The gateway signs each request's identity; services verify it before trusting anything.
105
+
106
+ ```ts
107
+ import { defineIdentityProtocol, defineStepUpScopes } from '@heavybit/shared-lib';
108
+ import { authenticateFromGateway, createStepUpGuards, requirePermission, requireOtpVerified } from '@heavybit/shared-lib/express';
109
+
110
+ // App binding (lives in the app's contracts package, not here):
111
+ const protocol = defineIdentityProtocol({
112
+ domain: 'myapp-gateway-identity:v2', // domain separator: two apps never verify each other
113
+ scopeHeaders: { tenant: 'x-tenant-id' }, // → req.user.scopes.tenant
114
+ attrHeaders: { tenantSlug: 'x-tenant-slug' }, // → req.user.attrs.tenantSlug
115
+ });
116
+ const stepUpScopes = defineStepUpScopes({ 'record-delete': {}, 'secret-rotate': { singleUse: true } });
117
+ const { requireStepUp, declaredStepUpRoutes } = createStepUpGuards({ catalogue: stepUpScopes }); // window 5 min
118
+
119
+ router.use(authenticateFromGateway({ protocol, logger, onRejected: publishSecurityEvent })); // secret = env GATEWAY_SIGNING_SECRET (≥ 32 chars)
120
+ router.delete('/records/:id', requirePermission('records.delete'), requireStepUp('record-delete', 'DELETE /records/:id'), controller.remove);
121
+ ```
122
+
123
+ - **Signature:** `x-gateway-signature: v2=<hex>` = HMAC-SHA256 over `[domain, METHOD, path (no query), x-gateway-ts, …identity headers]`
124
+ (JSON-array encoded) in this order: `x-user-id, x-user-email, <mapped scope headers>, <mapped attr headers>,
125
+ x-user-roles, x-user-permissions, x-is-superadmin, x-otp-verified, x-step-up-at, x-session-id, x-user-scopes, x-user-attrs`.
126
+ Missing headers sign as empty strings. `x-gateway-ts` and `x-step-up-at` are epoch milliseconds.
127
+ - **Rejected (401 `UNAUTHENTICATED`):** missing signature, wrong version, timestamp > 30 s old or > 5 s ahead,
128
+ another method/path/secret/domain, any tampered header. No `x-user-id` → anonymous (`optionalAuthFromGateway` continues).
129
+ - **Guards:** `requirePermission(...)` (any), `requireAllPermissions([...])`, `requireRole`, `requireOtpVerified()`
130
+ → 403 `FORBIDDEN`; a signed super admin passes every permission guard. `requireStepUp(scope)` → 403
131
+ `STEP_UP_REQUIRED` with `{ scope }` unless `stepUpAt` is within the window. Single-use proofs are consumed by the gateway.
132
+ - **Gateway side:** `stripProtectedHeaders(req.headers, protocol)` on ingress, then `signedIdentityHeaders({ user, method, path, secret, protocol })`.
133
+ - **Tests:** `identityHeaders({ user, secret, protocol, method, path })` from `/testing`.
134
+
135
+ ### Internal service calls (`common`, `express`)
136
+
137
+ Each service has its own credential; each internal route lists its callers.
138
+
139
+ ```ts
140
+ import { assertInternalServiceAuthConfigured, createInternalClient } from '@heavybit/shared-lib';
141
+ import { createInternalCallerGuards } from '@heavybit/shared-lib/express';
142
+
143
+ assertInternalServiceAuthConfigured({ calls: true, serves: true }); // throws in staging/production if missing
144
+
145
+ // Serving side (credentials = env INTERNAL_SERVICE_CREDENTIALS, {"caller-name":"<secret>"}):
146
+ const { requireInternalCaller } = createInternalCallerGuards({ pathPrefix: '/internal', logger });
147
+ router.get('/internal/records/:id', requireInternalCaller(['billing-service']), controller.get);
148
+
149
+ // Calling side (caller = env INTERNAL_SERVICE_NAME + INTERNAL_SERVICE_CREDENTIAL):
150
+ const records = createInternalClient({ target: 'records-service', baseUrl: env.RECORDS_INTERNAL_URL, logger });
151
+ try {
152
+ const record = await records.get('/records/01J…', { schema: recordSchema });
153
+ } catch (error) {
154
+ if (error instanceof records.UnavailableError) { /* down: degrade or 503 */ } else throw error; // 4xx: AppError with remote code
155
+ }
156
+ ```
157
+
158
+ - **Guard:** 401 `UNAUTHENTICATED` (missing/unknown name, wrong secret; constant-time), 403 `FORBIDDEN` (known caller
159
+ not listed, or any request outside `pathPrefix`). Sets `req.internalCaller`. Logs reason, caller and path only.
160
+ - **Client:** timeout 3 s per attempt; GET retried twice (100 ms, 400 ms) on network errors, timeouts and 502/503/504;
161
+ breaker opens after 5 unavailable calls for 30 s, then one trial. Network/timeout/5xx/open breaker/unreadable or
162
+ schema-mismatched body → `{Target}UnavailableError` (503). 4xx → `AppError` with the remote status, code, message, errors.
163
+ - **Headers sent:** `x-internal-service-name`, `x-internal-service-secret`, `x-correlation-id` (from the request
164
+ context), `idempotency-key` (when given) and `identityHeaders` unchanged. Gateway signatures are bound to method and
165
+ path, so re-sign the user's identity for the exact internal call with `signedIdentityHeaders`.
166
+
167
+ ### Config, Redis keyspace and rate-limit keys (`config`, `cache`, `rate-limit`)
168
+
169
+ ```ts
170
+ import { z } from 'zod';
171
+ import { loadEnv, createRedisKeyspace, rateLimitKey, sensitivePart } from '@heavybit/shared-lib';
172
+ import { createCache, createRedisClient, setWithTtl } from '@heavybit/shared-lib/cache';
173
+
174
+ // Base env (NODE_ENV, PORT, SERVICE_NAME, DB_*, REDIS_*, RABBITMQ_*, QUEUE_PREFIX, CACHE_KEY_PREFIX,
175
+ // GATEWAY_SIGNING_SECRET, LOG_LEVEL, INTERNAL_SERVICE_*) + the service's own. Bad config → names on stderr, exit 1.
176
+ export const config = loadEnv(z.object({ UPSTREAM_INTERNAL_URL: z.string().url() }));
177
+
178
+ const ks = createRedisKeyspace({ app: APP_SLUG, service: config.SERVICE_NAME, env: config.CACHE_KEY_PREFIX });
179
+ const redis = createRedisClient(config, { logger }); // no keyPrefix: the keyspace prefixes
180
+ const cache = createCache({ redis, keyspace: ks, logger });
181
+ await cache.set(ks.key('widget', id), widget, { ttlSec: 300 }); // TTL required, > 0
182
+ await setWithTtl(redis, ks.key('otp', sensitivePart(email)), hash, 600);
183
+ const key = rateLimitKey(ks, { scope: 'sign-in', ip: req.ip, email });
184
+ ```
185
+
186
+ - Keys read `{app}:{env}:{service}:{purpose}:…`; the app slug is the caller's parameter. In staging and
187
+ production a missing `CACHE_KEY_PREFIX` stops startup (both `loadEnv` and `createRedisKeyspace`).
188
+ - Parts are encoded (`:`, `*`, `?`, `[`, `]` cannot add segments or widen a SCAN). Parts that are long,
189
+ email-like, phone-like or wrapped in `sensitivePart` are hashed to `h.<sha256>`.
190
+ - No `KEYS`/`FLUSH*` helpers exist; scan with `ks.pattern(purpose)` and `SCAN` if you must.
191
+ - `createLock(redis, prefix)` takes a string prefix: pass `` `${ks.key('lock')}:` `` so its keys stay inside the
192
+ keyspace. `RedisIdempotencyStore(redis, keyspace)` builds its keys from the keyspace itself.
193
+
194
+ ### Messaging: envelope, outbox, consumers, DLQ (`messaging`)
195
+
196
+ ```ts
197
+ // container.ts — app slug, prefix and service always come from config
198
+ const messaging = createMessaging({ url: amqpUrlFromEnv(config), app: APP_SLUG, prefix: config.QUEUE_PREFIX, service: config.SERVICE_NAME });
199
+ const relay = new OutboxRelay({ db, bus: messaging.publisher, onMetrics: (m) => lagGauge.set(m.lagMs) });
200
+
201
+ // publish: stage in the same transaction as the change; the relay publishes after commit
202
+ await db.transaction(async (tx) => { await repo.insert(ctx, row, { tx }); await enqueueOutbox(tx, 'thing.created', { tenantId, thingId: row.id }); });
203
+
204
+ // consume: validated, de-duplicated in the handler's transaction, scope and correlation restored
205
+ await messaging.consumer.subscribe({ queue: 'thing-created', bindings: [{ exchange: 'events', routingKey: 'thing.created' }],
206
+ schema, dedupe: new ProcessedEventsDeduper(db), scopeFrom: (d) => ({ tenantId: d.tenantId }),
207
+ handler: async ({ data, tx, scope }) => service.onCreated(scope!, data, tx!) });
208
+ ```
209
+
210
+ - Envelope `{ routingKey, data: { ...payload, eventId, version, actorUserId? }, timestamp }`; AMQP `messageId` =
211
+ `eventId`, persistent JSON, header `x-correlation-id`.
212
+ - Names: exchanges `{app}.{prefix}.events` (topic) / `.commands` (direct); queues `{prefix}.{service}.{purpose}`;
213
+ per-service DLX `{app}.{prefix}.{service}.dlx` → `{queue}.dlq`; retry queues `{queue}.retry.{ms}`.
214
+ - Consumer: manual ack; invalid JSON/payload/scope → DLQ at once; handler errors retry after 2, 4, 8, 16 s
215
+ (`maxAttempts` 5) then DLQ; `NonRetryableError` → DLQ at once. `BaseConsumer` is the class form.
216
+ - Relay: batches of 100 `FOR UPDATE SKIP LOCKED`, marks `published_at` after the broker confirm, keeps unroutable
217
+ commands pending, backs off exponentially while the broker is down.
218
+ - `publisher.scheduleDelayed(queue, payload, delayMs)` for delayed delivery (bucketed per-message TTL).
219
+
220
+ ### Data access: Drizzle, scopes, pagination, migrations (`database`, `knex`)
221
+
222
+ **Repositories use Drizzle through these bases; raw scoped queries and Knex in `src/` are forbidden; Knex is
223
+ for migrations and seeds only.**
224
+
225
+ ```ts
226
+ import { createDb, defineScope, definePlatformScope, OwnerScope, ScopedRepository, knexConfig } from '@heavybit/shared-lib/database';
227
+ import { parseCursorQuery } from '@heavybit/shared-lib';
228
+
229
+ // container.ts — no module-level singleton
230
+ const { db, ping, close } = createDb({ env: config, schema });
231
+
232
+ // Scope bindings are app parameters (the app's contracts package defines its own)
233
+ const workspaceScope = defineScope({ name: 'workspace', column: 'workspace_id', contextKey: 'workspaceId' });
234
+ const ownerScope = new OwnerScope({ name: 'owner', column: 'user_id' });
235
+ const platformScope = definePlatformScope({ name: 'platform', column: 'workspace_id', predicate: (u: AuthUser) => u.isSuperAdmin });
236
+
237
+ class NoteRepository extends ScopedRepository<typeof notes, ScopeContext<'workspaceId'>> {
238
+ findByTitle(ctx: ScopeContext<'workspaceId'>, title: string, tx?: DbExecutor) {
239
+ return this.executor(tx).select().from(this.table).where(this.scoped(ctx, eq(this.table.title, title)));
240
+ }
241
+ }
242
+ const notesRepo = new NoteRepository(db, notes, workspaceScope);
243
+ const page = await notesRepo.findPage(ctx, { ...parseCursorQuery(req.query), orderBy: { column: 'created_at' } });
244
+ await db.transaction(async (tx) => notesRepo.insert(ctx, row, { tx }));
245
+
246
+ // knexfile.js
247
+ module.exports = knexConfig(config); // ./migrations, ./seeds, .cjs only
248
+ ```
249
+
250
+ - `ScopedRepository` methods: `findById` (null when absent or out of scope), `findMany`, `findPage`, `count`,
251
+ `insert` (stamps the scope column; a row naming another scope → 403 `SCOPE_MISMATCH`), `update` (refuses to
252
+ change the scope column, `SCOPE_IMMUTABLE`) and `delete`. Every method takes the scope context first and an
253
+ optional `{ tx }` last. Subclasses build WHERE clauses only with `this.scoped(ctx, ...)`.
254
+ - A missing scope value is 403 `SCOPE_REQUIRED` (`OWNER_REQUIRED` for an owner scope). A platform grant comes only
255
+ from `platformScope.authorize(identity)` (403 `PLATFORM_SCOPE_DENIED` otherwise); its queries still name the scope
256
+ column (`IS NOT NULL`) and inserts must carry the scope value.
257
+ - Pagination: `parseCursorQuery` (limit 1–100, default 20), `encodeCursor`/`decodeCursor` (opaque base64url of
258
+ `{ sortKey, id }`; bad cursors → 422), `buildCursorPage`.
259
+ - Migrations (`/knex`): `outboxColumns(t, knex)` (incl. `exchange`, `correlation_id`), `processedEventsColumns(t, knex)` (unique `consumer` + `event_id`),
260
+ `schedulerRunsColumns(t, knex)` (unique `job` + `run_key`), `scopedIndex(t, scopeColumn, cols)` (always leads with
261
+ the scope column). Matching Drizzle tables: `outboxTable`, `processedEventsTable`, `schedulerRunsTable`.
@@ -26,25 +26,33 @@ function grants(granted, required) {
26
26
  return true;
27
27
  return granted.endsWith('.*') && required.startsWith(granted.slice(0, -1));
28
28
  }
29
- /** True when the user holds at least one of `required`. */
29
+ /** True when the user holds at least one of `required`. A super admin holds every permission. */
30
30
  function hasPermission(user, ...required) {
31
31
  if (!user)
32
32
  return false;
33
+ if (user.isSuperAdmin)
34
+ return true;
33
35
  return required.some((perm) => user.permissions.some((g) => grants(g, perm)));
34
36
  }
37
+ /** True when the user holds every one of `required`. A super admin holds every permission. */
35
38
  function hasAllPermissions(user, ...required) {
36
39
  if (!user)
37
40
  return false;
41
+ if (user.isSuperAdmin)
42
+ return true;
38
43
  return required.every((perm) => user.permissions.some((g) => grants(g, perm)));
39
44
  }
45
+ /** True when the user has at least one of `roles`. Prefer permission checks; roles are coarse. */
40
46
  function hasRole(user, ...roles) {
41
47
  return !!user && roles.some((role) => user.roles.includes(role));
42
48
  }
49
+ /** Returns the user, or throws UnauthorizedError (401) when there is none. */
43
50
  function requireUser(user) {
44
51
  if (!user)
45
52
  throw new errors_js_1.UnauthorizedError();
46
53
  return user;
47
54
  }
55
+ /** Returns the user when they hold any of `required` (super admin always does); throws 401/403 otherwise. */
48
56
  function assertPermission(user, ...required) {
49
57
  const u = requireUser(user);
50
58
  if (!hasPermission(u, ...required))
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/access/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/access/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.APPROVAL_STATES = void 0;
4
+ exports.canTransition = canTransition;
5
+ exports.assertTransition = assertTransition;
6
+ exports.isTerminalApprovalState = isTerminalApprovalState;
7
+ exports.assertCanDecide = assertCanDecide;
8
+ exports.assertCanCancel = assertCanCancel;
9
+ exports.approvalExpiresAt = approvalExpiresAt;
10
+ exports.createApprovalPolicy = createApprovalPolicy;
11
+ const index_js_1 = require("../access/index.js");
12
+ const errors_js_1 = require("../common/errors.js");
13
+ /**
14
+ * Two-person approval state machine. A requester asks for a sensitive operation; a different person who
15
+ * holds both the operation's permission and the app's "decide" permission approves or rejects it; the
16
+ * approved operation is then executed once (or fails). Pending requests expire after a TTL.
17
+ *
18
+ * ```
19
+ * pending ──► approved ──► executed
20
+ * │ └──────► failed
21
+ * ├──► rejected
22
+ * ├──► cancelled
23
+ * └──► expired
24
+ * ```
25
+ *
26
+ * App values (the decide permission and the TTL) are passed in through {@link createApprovalPolicy}.
27
+ */
28
+ /** Every approval state. */
29
+ exports.APPROVAL_STATES = ['pending', 'approved', 'rejected', 'cancelled', 'expired', 'executed', 'failed'];
30
+ const TRANSITIONS = Object.freeze({
31
+ pending: ['approved', 'rejected', 'cancelled', 'expired'],
32
+ approved: ['executed', 'failed'],
33
+ rejected: [],
34
+ cancelled: [],
35
+ expired: [],
36
+ executed: [],
37
+ failed: [],
38
+ });
39
+ /**
40
+ * True when `from → to` is an allowed move.
41
+ *
42
+ * @example canTransition('pending', 'approved') // true
43
+ */
44
+ function canTransition(from, to) {
45
+ return TRANSITIONS[from]?.includes(to) ?? false;
46
+ }
47
+ /**
48
+ * Throws unless `from → to` is allowed.
49
+ *
50
+ * @throws ConflictError (`APPROVAL_INVALID_TRANSITION`).
51
+ */
52
+ function assertTransition(from, to) {
53
+ if (!canTransition(from, to)) {
54
+ throw new errors_js_1.ConflictError(`This request can no longer move from ${from} to ${to}`, { code: 'APPROVAL_INVALID_TRANSITION' });
55
+ }
56
+ }
57
+ /** True when the state allows no further moves. */
58
+ function isTerminalApprovalState(state) {
59
+ return TRANSITIONS[state].length === 0;
60
+ }
61
+ function assertPending(approval, now) {
62
+ if (approval.state !== 'pending') {
63
+ throw new errors_js_1.ConflictError('This request has already been decided', { code: 'APPROVAL_NOT_PENDING' });
64
+ }
65
+ if (approval.expiresAt && new Date(approval.expiresAt).getTime() <= now.getTime()) {
66
+ throw new errors_js_1.ConflictError('This request has expired', { code: 'APPROVAL_EXPIRED' });
67
+ }
68
+ }
69
+ /**
70
+ * Throws unless `approver` may approve or reject `approval`: it is pending and not expired, the approver
71
+ * is not the requester (even a super admin cannot approve their own request), and holds both the
72
+ * operation permission and `decidePermission`.
73
+ *
74
+ * @param approval The request.
75
+ * @param approver The deciding user.
76
+ * @param decidePermission The app's "may decide approvals" permission.
77
+ * @param now Clock (tests).
78
+ * @throws ConflictError (`APPROVAL_NOT_PENDING`, `APPROVAL_EXPIRED`) or ForbiddenError
79
+ * (`APPROVAL_SELF_DECISION`, `APPROVAL_FORBIDDEN`).
80
+ */
81
+ function assertCanDecide(approval, approver, decidePermission, now = new Date()) {
82
+ assertPending(approval, now);
83
+ if (approver.id === approval.requesterUserId) {
84
+ throw new errors_js_1.ForbiddenError('You cannot decide your own request', { code: 'APPROVAL_SELF_DECISION' });
85
+ }
86
+ if (!(0, index_js_1.hasAllPermissions)(approver, approval.operationPermission, decidePermission)) {
87
+ throw new errors_js_1.ForbiddenError('You do not have permission to decide this request', { code: 'APPROVAL_FORBIDDEN' });
88
+ }
89
+ }
90
+ /**
91
+ * Throws unless `user` may cancel `approval`: only the requester, and only while it is pending.
92
+ *
93
+ * @throws ConflictError (`APPROVAL_NOT_PENDING`) or ForbiddenError (`APPROVAL_NOT_REQUESTER`).
94
+ */
95
+ function assertCanCancel(approval, user) {
96
+ if (user.id !== approval.requesterUserId) {
97
+ throw new errors_js_1.ForbiddenError('Only the person who made this request can cancel it', { code: 'APPROVAL_NOT_REQUESTER' });
98
+ }
99
+ if (approval.state !== 'pending') {
100
+ throw new errors_js_1.ConflictError('This request has already been decided', { code: 'APPROVAL_NOT_PENDING' });
101
+ }
102
+ }
103
+ /**
104
+ * When a request made at `from` expires.
105
+ *
106
+ * @param ttlSeconds Positive lifetime in seconds.
107
+ * @param from Creation time (default now).
108
+ * @returns The expiry instant.
109
+ * @example approvalExpiresAt(3600, new Date('2026-01-01T00:00:00Z')) // 2026-01-01T01:00:00Z
110
+ */
111
+ function approvalExpiresAt(ttlSeconds, from = new Date()) {
112
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
113
+ throw new RangeError('Approval TTL must be a positive number of seconds');
114
+ return new Date(from.getTime() + ttlSeconds * 1000);
115
+ }
116
+ /**
117
+ * Binds the approval rules to an app's decide permission and TTL.
118
+ *
119
+ * @param options Decide permission and TTL.
120
+ * @returns The bound policy.
121
+ * @example const approvals = createApprovalPolicy({ decidePermission: 'requests.decide', ttlSeconds: 86_400 });
122
+ */
123
+ function createApprovalPolicy(options) {
124
+ if (!options.decidePermission)
125
+ throw new RangeError('An approval policy needs a decide permission');
126
+ approvalExpiresAt(options.ttlSeconds); // validates the TTL up front
127
+ return Object.freeze({
128
+ decidePermission: options.decidePermission,
129
+ ttlSeconds: options.ttlSeconds,
130
+ canTransition,
131
+ assertTransition,
132
+ assertCanDecide: (approval, approver, now) => assertCanDecide(approval, approver, options.decidePermission, now),
133
+ assertCanCancel,
134
+ expiresAt: (from) => approvalExpiresAt(options.ttlSeconds, from),
135
+ });
136
+ }
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/approvals/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildAuditEvent = exports.MAX_USER_AGENT_LENGTH = void 0;
4
+ exports.createAuditEventBuilder = createAuditEventBuilder;
5
+ const phone_js_1 = require("../common/phone.js");
6
+ const time_js_1 = require("../common/time.js");
7
+ const redact_js_1 = require("../logging/redact.js");
8
+ /** User-agent strings are cut to this length. */
9
+ exports.MAX_USER_AGENT_LENGTH = 255;
10
+ /** Stable JSON form for deep comparison (object keys sorted; Dates as ISO strings). */
11
+ function canonical(value) {
12
+ return JSON.stringify(value ?? null, (_key, v) => {
13
+ if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) {
14
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)));
15
+ }
16
+ return v;
17
+ });
18
+ }
19
+ function diff(before, after, classifier) {
20
+ const changes = {};
21
+ const redactedFields = [];
22
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
23
+ for (const key of keys) {
24
+ const was = before[key] ?? null;
25
+ const now = after[key] ?? null;
26
+ if (canonical(was) === canonical(now))
27
+ continue;
28
+ if (classifier.isSecret(key)) {
29
+ redactedFields.push(key);
30
+ continue;
31
+ }
32
+ const clean = (value) => classifier.isPhone(key) && (typeof value === 'string' || typeof value === 'number') ? (0, phone_js_1.maskPhone)(String(value)) : (0, redact_js_1.redactValue)(value, classifier);
33
+ changes[key] = { before: clean(was), after: clean(now) };
34
+ }
35
+ return { changes, redactedFields };
36
+ }
37
+ /**
38
+ * Creates an audit-event builder with the app's extra redaction words.
39
+ *
40
+ * @param options Extra redaction words.
41
+ * @returns `buildAuditEvent` bound to that list.
42
+ * @example const buildAudit = createAuditEventBuilder({ redactKeys: ['nationalId'] });
43
+ */
44
+ function createAuditEventBuilder(options = {}) {
45
+ const classifier = (0, redact_js_1.createKeyClassifier)(options.redactKeys ?? []);
46
+ return (input) => {
47
+ if (!input.action || !input.entityType)
48
+ throw new RangeError('Audit events need an action and an entity type');
49
+ const { changes, redactedFields } = diff(input.before ?? {}, input.after ?? {}, classifier);
50
+ return {
51
+ action: input.action,
52
+ entityType: input.entityType,
53
+ entityId: input.entityId,
54
+ scope: input.scope ? { key: input.scope.key, id: input.scope.id } : null,
55
+ actorUserId: input.actor?.id ?? null,
56
+ outcome: input.outcome ?? 'success',
57
+ occurredAt: (0, time_js_1.toUtcIso)(input.occurredAt ?? new Date()),
58
+ changes,
59
+ redactedFields,
60
+ ip: input.ip ?? null,
61
+ userAgent: input.userAgent ? input.userAgent.slice(0, exports.MAX_USER_AGENT_LENGTH) : null,
62
+ };
63
+ };
64
+ }
65
+ /**
66
+ * Builds an audit event with the default redaction list: the diff holds only changed fields; secret
67
+ * fields are dropped (named in `redactedFields`); phone fields are masked to their last 4 digits.
68
+ *
69
+ * @param input Action, entity, scope, actor, before/after state and request facts.
70
+ * @returns The event.
71
+ * @example buildAuditEvent({ action: 'widget.updated', entityType: 'widget', entityId, scope: { key: 'tenant', id: tenantId }, actor: req.user ?? null, before, after, ip: req.ip, userAgent: req.get('user-agent') })
72
+ */
73
+ exports.buildAuditEvent = createAuditEventBuilder();
74
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/audit/index.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,47 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
2
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.CACHE_TAG_PURPOSE = void 0;
18
+ exports.assertTtl = assertTtl;
3
19
  exports.createCache = createCache;
4
20
  exports.createRedisClient = createRedisClient;
21
+ exports.setWithTtl = setWithTtl;
5
22
  const ioredis_1 = require("ioredis");
6
- const index_js_1 = require("../logging/index.js");
23
+ const index_js_1 = require("../config/index.js");
24
+ const index_js_2 = require("../logging/index.js");
25
+ __exportStar(require("./keyspace.js"), exports);
26
+ /** Purpose segment for tag index sets: `{prefix}cache-tag:{tag}`. */
27
+ exports.CACHE_TAG_PURPOSE = 'cache-tag';
28
+ /** Lower bound for a tag set's own TTL, so it outlives the keys it lists. */
29
+ const MIN_TAG_TTL_SEC = 86_400;
30
+ /**
31
+ * Throws unless `ttlSec` is a positive whole number of seconds.
32
+ *
33
+ * @param ttlSec Proposed TTL.
34
+ * @throws ConfigError for ttl ≤ 0, fractions, NaN or Infinity.
35
+ * @example assertTtl(60)
36
+ */
37
+ function assertTtl(ttlSec) {
38
+ if (!Number.isInteger(ttlSec) || ttlSec <= 0)
39
+ throw new index_js_1.ConfigError(`Redis TTL must be a positive whole number of seconds (got ${String(ttlSec)})`);
40
+ }
41
+ function assertOwned(keyspace, key) {
42
+ if (!keyspace.owns(key))
43
+ throw new index_js_1.ConfigError(`Cache key is outside the keyspace ${keyspace.prefix}`);
44
+ }
7
45
  const NULL_SENTINEL = '__null__';
8
46
  class MemoryStore {
9
47
  max;
@@ -37,14 +75,23 @@ class MemoryStore {
37
75
  * Redis-backed cache with tag invalidation. If Redis errors, reads fall through to the loader
38
76
  * and writes go to a bounded in-memory store, so a cache outage degrades latency rather than
39
77
  * availability.
78
+ *
79
+ * @param options Redis client (or null for memory only), the service keyspace, memory bound, logger.
80
+ * @returns The {@link Cache}.
81
+ * @example
82
+ * const cache = createCache({ redis, keyspace: ks, logger });
83
+ * const widget = await cache.wrap(ks.key('widget', id), () => repo.find(id), { ttlSec: 300 });
40
84
  */
41
85
  function createCache(options) {
42
- const logger = options.logger ?? index_js_1.silentLogger;
43
- const defaultTtl = options.defaultTtlSec ?? 60;
86
+ const logger = options.logger ?? index_js_2.silentLogger;
87
+ const { keyspace } = options;
44
88
  const memory = new MemoryStore(options.memoryMaxEntries ?? 5000);
45
89
  const inflight = new Map();
46
- const k = (key) => `${options.namespace}:${key}`;
47
- const tagKey = (tag) => `${options.namespace}:tag:${tag}`;
90
+ const k = (key) => {
91
+ assertOwned(keyspace, key);
92
+ return key;
93
+ };
94
+ const tagKey = (tag) => keyspace.key(exports.CACHE_TAG_PURPOSE, tag);
48
95
  let warned = false;
49
96
  const redisFailed = (error) => {
50
97
  if (!warned)
@@ -64,13 +111,14 @@ function createCache(options) {
64
111
  }
65
112
  return memory.get(k(key));
66
113
  };
67
- const writeRaw = async (key, raw, opts = {}) => {
68
- const ttl = opts.ttlSec ?? defaultTtl;
114
+ const writeRaw = async (key, raw, opts) => {
115
+ assertTtl(opts.ttlSec);
116
+ const ttl = opts.ttlSec;
69
117
  if (options.redis) {
70
118
  try {
71
119
  const pipeline = options.redis.multi().set(k(key), raw, 'EX', ttl);
72
120
  for (const tag of opts.tags ?? [])
73
- pipeline.sadd(tagKey(tag), k(key)).expire(tagKey(tag), Math.max(ttl, 86_400));
121
+ pipeline.sadd(tagKey(tag), k(key)).expire(tagKey(tag), Math.max(ttl, MIN_TAG_TTL_SEC));
74
122
  await pipeline.exec();
75
123
  return;
76
124
  }
@@ -96,7 +144,8 @@ function createCache(options) {
96
144
  if (options.redis && keys.length)
97
145
  await options.redis.del(...keys.map(k)).catch(redisFailed);
98
146
  },
99
- async wrap(key, loader, opts = {}) {
147
+ async wrap(key, loader, opts) {
148
+ assertTtl(opts.ttlSec);
100
149
  const raw = await readRaw(key);
101
150
  if (raw !== undefined) {
102
151
  if (raw === NULL_SENTINEL)
@@ -112,7 +161,7 @@ function createCache(options) {
112
161
  if (value !== null && value !== undefined)
113
162
  await writeRaw(key, JSON.stringify(value), opts);
114
163
  else if (opts.cacheNull)
115
- await writeRaw(key, NULL_SENTINEL, { ...opts, ttlSec: Math.min(opts.ttlSec ?? defaultTtl, 30) });
164
+ await writeRaw(key, NULL_SENTINEL, { ...opts, ttlSec: Math.min(opts.ttlSec, 30) });
116
165
  return value;
117
166
  }
118
167
  finally {
@@ -142,19 +191,30 @@ function createCache(options) {
142
191
  };
143
192
  return cache;
144
193
  }
145
- /** ioredis client that never crashes the process on connection errors and retries forever. */
146
- function createRedisClient(opts) {
147
- const logger = opts.logger ?? index_js_1.silentLogger;
194
+ /**
195
+ * ioredis client that never crashes the process on connection errors and retries forever.
196
+ * It sets no `keyPrefix`: keys carry their full `{app}:{env}:{service}:` prefix from the keyspace.
197
+ *
198
+ * @param env Connection env (`REDIS_URL`, or host/port/password, and `REDIS_DB`).
199
+ * @param opts Logger and extra ioredis options.
200
+ * @returns The client.
201
+ * @example const redis = createRedisClient(config, { logger });
202
+ */
203
+ function createRedisClient(env, opts = {}) {
204
+ const logger = opts.logger ?? index_js_2.silentLogger;
148
205
  const base = {
149
- db: opts.db ?? 0,
150
- keyPrefix: opts.keyPrefix || undefined,
206
+ host: env.REDIS_HOST,
207
+ port: env.REDIS_PORT,
208
+ password: env.REDIS_PASSWORD || undefined,
209
+ db: env.REDIS_DB ?? 0,
151
210
  lazyConnect: false,
152
211
  enableOfflineQueue: false,
153
212
  maxRetriesPerRequest: 1,
154
213
  retryStrategy: (times) => Math.min(times * 500, 10_000),
155
214
  ...opts.options,
215
+ keyPrefix: undefined,
156
216
  };
157
- const client = opts.url ? new ioredis_1.Redis(opts.url, base) : new ioredis_1.Redis(base);
217
+ const client = env.REDIS_URL ? new ioredis_1.Redis(env.REDIS_URL, base) : new ioredis_1.Redis(base);
158
218
  let lastError = '';
159
219
  client.on('error', (error) => {
160
220
  if (error.message !== lastError)
@@ -164,4 +224,18 @@ function createRedisClient(opts) {
164
224
  client.on('ready', () => { lastError = ''; logger.info('Redis connected', { db: base.db }); });
165
225
  return client;
166
226
  }
227
+ /**
228
+ * `SET key value EX ttlSec` for a keyspace key; refuses a TTL ≤ 0 so no key lives forever.
229
+ *
230
+ * @param redis Client (or anything with a compatible `set`).
231
+ * @param key Key from the service keyspace.
232
+ * @param value String value.
233
+ * @param ttlSec TTL in whole seconds (> 0).
234
+ * @throws ConfigError for an invalid TTL.
235
+ * @example await setWithTtl(redis, ks.key('session', id), payload, 900);
236
+ */
237
+ async function setWithTtl(redis, key, value, ttlSec) {
238
+ assertTtl(ttlSec);
239
+ await redis.set(key, value, 'EX', ttlSec);
240
+ }
167
241
  //# sourceMappingURL=index.js.map