@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.
- package/README.md +163 -2
- package/dist/cjs/access/index.js +9 -1
- package/dist/cjs/access/index.js.map +1 -1
- package/dist/cjs/approvals/index.js +137 -0
- package/dist/cjs/approvals/index.js.map +1 -0
- package/dist/cjs/audit/index.js +74 -0
- package/dist/cjs/audit/index.js.map +1 -0
- package/dist/cjs/cache/index.js +90 -16
- package/dist/cjs/cache/index.js.map +1 -1
- package/dist/cjs/cache/keyspace.js +101 -0
- package/dist/cjs/cache/keyspace.js.map +1 -0
- package/dist/cjs/common/circuit-breaker.js +74 -0
- package/dist/cjs/common/circuit-breaker.js.map +1 -0
- package/dist/cjs/common/index.js +4 -0
- package/dist/cjs/common/index.js.map +1 -1
- package/dist/cjs/common/internal-client.js +177 -0
- package/dist/cjs/common/internal-client.js.map +1 -0
- package/dist/cjs/common/internal-constants.js +12 -0
- package/dist/cjs/common/internal-constants.js.map +1 -0
- package/dist/cjs/common/internal-service-auth.js +160 -0
- package/dist/cjs/common/internal-service-auth.js.map +1 -0
- package/dist/cjs/common/money.js +215 -0
- package/dist/cjs/common/money.js.map +1 -0
- package/dist/cjs/common/phone.js +41 -22
- package/dist/cjs/common/phone.js.map +1 -1
- package/dist/cjs/common/time.js +28 -2
- package/dist/cjs/common/time.js.map +1 -1
- package/dist/cjs/config/index.js +139 -19
- package/dist/cjs/config/index.js.map +1 -1
- package/dist/cjs/contracts/notification-dispatch.js +1 -1
- package/dist/cjs/database/db.js +37 -0
- package/dist/cjs/database/db.js.map +1 -0
- package/dist/cjs/database/index.js +22 -51
- package/dist/cjs/database/index.js.map +1 -1
- package/dist/cjs/database/pagination.js +88 -0
- package/dist/cjs/database/pagination.js.map +1 -0
- package/dist/cjs/database/pool.js +60 -0
- package/dist/cjs/database/pool.js.map +1 -0
- package/dist/cjs/database/scoped-repository.js +132 -0
- package/dist/cjs/database/scoped-repository.js.map +1 -0
- package/dist/cjs/database/scopes.js +166 -0
- package/dist/cjs/database/scopes.js.map +1 -0
- package/dist/cjs/database/table-names.js +14 -0
- package/dist/cjs/database/table-names.js.map +1 -0
- package/dist/cjs/database/tables.js +71 -0
- package/dist/cjs/database/tables.js.map +1 -0
- package/dist/cjs/express/auth.js +64 -50
- package/dist/cjs/express/auth.js.map +1 -1
- package/dist/cjs/express/index.js +2 -0
- package/dist/cjs/express/index.js.map +1 -1
- package/dist/cjs/express/internal-caller.js +52 -0
- package/dist/cjs/express/internal-caller.js.map +1 -0
- package/dist/cjs/express/step-up.js +41 -0
- package/dist/cjs/express/step-up.js.map +1 -0
- package/dist/cjs/http/index.js +3 -71
- package/dist/cjs/http/index.js.map +1 -1
- package/dist/cjs/identity/index.js +269 -67
- package/dist/cjs/identity/index.js.map +1 -1
- package/dist/cjs/identity/step-up.js +52 -0
- package/dist/cjs/identity/step-up.js.map +1 -0
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/knex/columns.js +36 -0
- package/dist/cjs/knex/columns.js.map +1 -0
- package/dist/cjs/knex/index.js +26 -43
- package/dist/cjs/knex/index.js.map +1 -1
- package/dist/cjs/knex/migration-helpers.js +57 -0
- package/dist/cjs/knex/migration-helpers.js.map +1 -0
- package/dist/cjs/logging/redact.js +6 -11
- package/dist/cjs/logging/redact.js.map +1 -1
- package/dist/cjs/messaging/base-consumer.js +36 -0
- package/dist/cjs/messaging/base-consumer.js.map +1 -0
- package/dist/cjs/messaging/consumer.js +142 -73
- package/dist/cjs/messaging/consumer.js.map +1 -1
- package/dist/cjs/messaging/envelope.js +53 -10
- package/dist/cjs/messaging/envelope.js.map +1 -1
- package/dist/cjs/messaging/idempotency.js +95 -8
- package/dist/cjs/messaging/idempotency.js.map +1 -1
- package/dist/cjs/messaging/index.js +2 -1
- package/dist/cjs/messaging/index.js.map +1 -1
- package/dist/cjs/messaging/outbox.js +97 -55
- package/dist/cjs/messaging/outbox.js.map +1 -1
- package/dist/cjs/messaging/publisher.js +86 -38
- package/dist/cjs/messaging/publisher.js.map +1 -1
- package/dist/cjs/messaging/staff-notifier.js +4 -4
- package/dist/cjs/messaging/staff-notifier.js.map +1 -1
- package/dist/cjs/messaging/topology.js +41 -5
- package/dist/cjs/messaging/topology.js.map +1 -1
- package/dist/cjs/rate-limit/index.js +57 -6
- package/dist/cjs/rate-limit/index.js.map +1 -1
- package/dist/cjs/rate-limit/keys.js +47 -0
- package/dist/cjs/rate-limit/keys.js.map +1 -0
- package/dist/cjs/socket/index.js +127 -16
- package/dist/cjs/socket/index.js.map +1 -1
- package/dist/cjs/socket/ticket.js +124 -0
- package/dist/cjs/socket/ticket.js.map +1 -0
- package/dist/cjs/testing/index.js +23 -14
- package/dist/cjs/testing/index.js.map +1 -1
- package/dist/esm/access/index.js +9 -1
- package/dist/esm/access/index.js.map +1 -1
- package/dist/esm/approvals/index.js +127 -0
- package/dist/esm/approvals/index.js.map +1 -0
- package/dist/esm/audit/index.js +70 -0
- package/dist/esm/audit/index.js.map +1 -0
- package/dist/esm/cache/index.js +70 -13
- package/dist/esm/cache/index.js.map +1 -1
- package/dist/esm/cache/keyspace.js +95 -0
- package/dist/esm/cache/keyspace.js.map +1 -0
- package/dist/esm/common/circuit-breaker.js +70 -0
- package/dist/esm/common/circuit-breaker.js.map +1 -0
- package/dist/esm/common/index.js +4 -0
- package/dist/esm/common/index.js.map +1 -1
- package/dist/esm/common/internal-client.js +173 -0
- package/dist/esm/common/internal-client.js.map +1 -0
- package/dist/esm/common/internal-constants.js +9 -0
- package/dist/esm/common/internal-constants.js.map +1 -0
- package/dist/esm/common/internal-service-auth.js +150 -0
- package/dist/esm/common/internal-service-auth.js.map +1 -0
- package/dist/esm/common/money.js +207 -0
- package/dist/esm/common/money.js.map +1 -0
- package/dist/esm/common/phone.js +41 -22
- package/dist/esm/common/phone.js.map +1 -1
- package/dist/esm/common/time.js +27 -2
- package/dist/esm/common/time.js.map +1 -1
- package/dist/esm/config/index.js +135 -18
- package/dist/esm/config/index.js.map +1 -1
- package/dist/esm/contracts/notification-dispatch.js +1 -1
- package/dist/esm/database/db.js +32 -0
- package/dist/esm/database/db.js.map +1 -0
- package/dist/esm/database/index.js +9 -45
- package/dist/esm/database/index.js.map +1 -1
- package/dist/esm/database/pagination.js +81 -0
- package/dist/esm/database/pagination.js.map +1 -0
- package/dist/esm/database/pool.js +51 -0
- package/dist/esm/database/pool.js.map +1 -0
- package/dist/esm/database/scoped-repository.js +128 -0
- package/dist/esm/database/scoped-repository.js.map +1 -0
- package/dist/esm/database/scopes.js +157 -0
- package/dist/esm/database/scopes.js.map +1 -0
- package/dist/esm/database/table-names.js +11 -0
- package/dist/esm/database/table-names.js.map +1 -0
- package/dist/esm/database/tables.js +61 -0
- package/dist/esm/database/tables.js.map +1 -0
- package/dist/esm/express/auth.js +62 -48
- package/dist/esm/express/auth.js.map +1 -1
- package/dist/esm/express/index.js +2 -0
- package/dist/esm/express/index.js.map +1 -1
- package/dist/esm/express/internal-caller.js +49 -0
- package/dist/esm/express/internal-caller.js.map +1 -0
- package/dist/esm/express/step-up.js +38 -0
- package/dist/esm/express/step-up.js.map +1 -0
- package/dist/esm/http/index.js +3 -70
- package/dist/esm/http/index.js.map +1 -1
- package/dist/esm/identity/index.js +246 -64
- package/dist/esm/identity/index.js.map +1 -1
- package/dist/esm/identity/step-up.js +47 -0
- package/dist/esm/identity/step-up.js.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/knex/columns.js +28 -0
- package/dist/esm/knex/columns.js.map +1 -0
- package/dist/esm/knex/index.js +8 -34
- package/dist/esm/knex/index.js.map +1 -1
- package/dist/esm/knex/migration-helpers.js +51 -0
- package/dist/esm/knex/migration-helpers.js.map +1 -0
- package/dist/esm/logging/redact.js +3 -7
- package/dist/esm/logging/redact.js.map +1 -1
- package/dist/esm/messaging/base-consumer.js +32 -0
- package/dist/esm/messaging/base-consumer.js.map +1 -0
- package/dist/esm/messaging/consumer.js +139 -71
- package/dist/esm/messaging/consumer.js.map +1 -1
- package/dist/esm/messaging/envelope.js +50 -10
- package/dist/esm/messaging/envelope.js.map +1 -1
- package/dist/esm/messaging/idempotency.js +91 -7
- package/dist/esm/messaging/idempotency.js.map +1 -1
- package/dist/esm/messaging/index.js +2 -1
- package/dist/esm/messaging/index.js.map +1 -1
- package/dist/esm/messaging/outbox.js +97 -54
- package/dist/esm/messaging/outbox.js.map +1 -1
- package/dist/esm/messaging/publisher.js +86 -38
- package/dist/esm/messaging/publisher.js.map +1 -1
- package/dist/esm/messaging/staff-notifier.js +4 -4
- package/dist/esm/messaging/staff-notifier.js.map +1 -1
- package/dist/esm/messaging/topology.js +39 -4
- package/dist/esm/messaging/topology.js.map +1 -1
- package/dist/esm/rate-limit/index.js +41 -6
- package/dist/esm/rate-limit/index.js.map +1 -1
- package/dist/esm/rate-limit/keys.js +43 -0
- package/dist/esm/rate-limit/keys.js.map +1 -0
- package/dist/esm/socket/index.js +112 -17
- package/dist/esm/socket/index.js.map +1 -1
- package/dist/esm/socket/ticket.js +117 -0
- package/dist/esm/socket/ticket.js.map +1 -0
- package/dist/esm/testing/index.js +24 -15
- package/dist/esm/testing/index.js.map +1 -1
- package/dist/types/access/index.d.ts +9 -3
- package/dist/types/access/index.d.ts.map +1 -1
- package/dist/types/approvals/index.d.ts +99 -0
- package/dist/types/approvals/index.d.ts.map +1 -0
- package/dist/types/audit/index.d.ts +82 -0
- package/dist/types/audit/index.d.ts.map +1 -0
- package/dist/types/cache/index.d.ts +64 -14
- package/dist/types/cache/index.d.ts.map +1 -1
- package/dist/types/cache/keyspace.d.ts +84 -0
- package/dist/types/cache/keyspace.d.ts.map +1 -0
- package/dist/types/common/circuit-breaker.d.ts +51 -0
- package/dist/types/common/circuit-breaker.d.ts.map +1 -0
- package/dist/types/common/index.d.ts +4 -0
- package/dist/types/common/index.d.ts.map +1 -1
- package/dist/types/common/internal-client.d.ts +116 -0
- package/dist/types/common/internal-client.d.ts.map +1 -0
- package/dist/types/common/internal-constants.d.ts +9 -0
- package/dist/types/common/internal-constants.d.ts.map +1 -0
- package/dist/types/common/internal-service-auth.d.ts +102 -0
- package/dist/types/common/internal-service-auth.d.ts.map +1 -0
- package/dist/types/common/money.d.ts +104 -0
- package/dist/types/common/money.d.ts.map +1 -0
- package/dist/types/common/phone.d.ts +25 -15
- package/dist/types/common/phone.d.ts.map +1 -1
- package/dist/types/common/time.d.ts +20 -2
- package/dist/types/common/time.d.ts.map +1 -1
- package/dist/types/config/index.d.ts +251 -27
- package/dist/types/config/index.d.ts.map +1 -1
- package/dist/types/contracts/notification-dispatch.d.ts +13 -13
- package/dist/types/database/db.d.ts +46 -0
- package/dist/types/database/db.d.ts.map +1 -0
- package/dist/types/database/index.d.ts +9 -22
- package/dist/types/database/index.d.ts.map +1 -1
- package/dist/types/database/pagination.d.ts +68 -0
- package/dist/types/database/pagination.d.ts.map +1 -0
- package/dist/types/database/pool.d.ts +39 -0
- package/dist/types/database/pool.d.ts.map +1 -0
- package/dist/types/database/scoped-repository.d.ts +92 -0
- package/dist/types/database/scoped-repository.d.ts.map +1 -0
- package/dist/types/database/scopes.d.ts +159 -0
- package/dist/types/database/scopes.d.ts.map +1 -0
- package/dist/types/database/table-names.d.ts +13 -0
- package/dist/types/database/table-names.d.ts.map +1 -0
- package/dist/types/database/tables.d.ts +748 -0
- package/dist/types/database/tables.d.ts.map +1 -0
- package/dist/types/express/auth.d.ts +40 -24
- package/dist/types/express/auth.d.ts.map +1 -1
- package/dist/types/express/index.d.ts +2 -0
- package/dist/types/express/index.d.ts.map +1 -1
- package/dist/types/express/internal-caller.d.ts +53 -0
- package/dist/types/express/internal-caller.d.ts.map +1 -0
- package/dist/types/express/step-up.d.ts +38 -0
- package/dist/types/express/step-up.d.ts.map +1 -0
- package/dist/types/http/index.d.ts +3 -31
- package/dist/types/http/index.d.ts.map +1 -1
- package/dist/types/identity/index.d.ts +159 -21
- package/dist/types/identity/index.d.ts.map +1 -1
- package/dist/types/identity/step-up.d.ts +41 -0
- package/dist/types/identity/step-up.d.ts.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/knex/columns.d.ts +15 -0
- package/dist/types/knex/columns.d.ts.map +1 -0
- package/dist/types/knex/index.d.ts +3 -11
- package/dist/types/knex/index.d.ts.map +1 -1
- package/dist/types/knex/migration-helpers.d.ts +28 -0
- package/dist/types/knex/migration-helpers.d.ts.map +1 -0
- package/dist/types/logging/redact.d.ts +2 -2
- package/dist/types/logging/redact.d.ts.map +1 -1
- package/dist/types/messaging/base-consumer.d.ts +33 -0
- package/dist/types/messaging/base-consumer.d.ts.map +1 -0
- package/dist/types/messaging/consumer.d.ts +59 -21
- package/dist/types/messaging/consumer.d.ts.map +1 -1
- package/dist/types/messaging/envelope.d.ts +50 -11
- package/dist/types/messaging/envelope.d.ts.map +1 -1
- package/dist/types/messaging/idempotency.d.ts +60 -3
- package/dist/types/messaging/idempotency.d.ts.map +1 -1
- package/dist/types/messaging/index.d.ts +7 -0
- package/dist/types/messaging/index.d.ts.map +1 -1
- package/dist/types/messaging/outbox.d.ts +62 -22
- package/dist/types/messaging/outbox.d.ts.map +1 -1
- package/dist/types/messaging/publisher.d.ts +54 -21
- package/dist/types/messaging/publisher.d.ts.map +1 -1
- package/dist/types/messaging/staff-notifier.d.ts +9 -9
- package/dist/types/messaging/staff-notifier.d.ts.map +1 -1
- package/dist/types/messaging/staff-recipients.d.ts +1 -1
- package/dist/types/messaging/topology.d.ts +40 -8
- package/dist/types/messaging/topology.d.ts.map +1 -1
- package/dist/types/rate-limit/index.d.ts +40 -4
- package/dist/types/rate-limit/index.d.ts.map +1 -1
- package/dist/types/rate-limit/keys.d.ts +52 -0
- package/dist/types/rate-limit/keys.d.ts.map +1 -0
- package/dist/types/socket/index.d.ts +86 -2
- package/dist/types/socket/index.d.ts.map +1 -1
- package/dist/types/socket/ticket.d.ts +90 -0
- package/dist/types/socket/ticket.d.ts.map +1 -0
- package/dist/types/testing/index.d.ts +31 -10
- package/dist/types/testing/index.d.ts.map +1 -1
- package/dist/types/types/index.d.ts +26 -14
- package/dist/types/types/index.d.ts.map +1 -1
- 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`.
|
package/dist/cjs/access/index.js
CHANGED
|
@@ -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":";;;
|
|
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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"}
|
package/dist/cjs/cache/index.js
CHANGED
|
@@ -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("../
|
|
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 ??
|
|
43
|
-
const
|
|
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) =>
|
|
47
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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 =
|
|
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
|