@open-rlb/nestjs-amqp 2.0.3 → 2.0.5

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 (71) hide show
  1. package/README.md +4 -2
  2. package/amqp-lib/config/rabbitmq.config.d.ts +6 -0
  3. package/modules/acl/const.d.ts +1 -4
  4. package/modules/acl/const.js +1 -4
  5. package/modules/acl/const.js.map +1 -1
  6. package/modules/acl/models.d.ts +5 -7
  7. package/modules/acl/repository/acl-action.repository.d.ts +1 -5
  8. package/modules/acl/repository/acl-action.repository.js.map +1 -1
  9. package/modules/acl/repository/acl-grant.repository.d.ts +0 -1
  10. package/modules/acl/repository/acl-grant.repository.js.map +1 -1
  11. package/modules/acl/repository/acl-role.repository.d.ts +1 -5
  12. package/modules/acl/repository/acl-role.repository.js.map +1 -1
  13. package/modules/acl/services/acl-management.service.d.ts +6 -7
  14. package/modules/acl/services/acl-management.service.js +44 -61
  15. package/modules/acl/services/acl-management.service.js.map +1 -1
  16. package/modules/acl/services/acl.service.d.ts +1 -3
  17. package/modules/acl/services/acl.service.js +5 -47
  18. package/modules/acl/services/acl.service.js.map +1 -1
  19. package/modules/broker/broker.module.d.ts +2 -4
  20. package/modules/broker/broker.module.js +23 -5
  21. package/modules/broker/broker.module.js.map +1 -1
  22. package/modules/broker/config/route-discovery.config.d.ts +2 -0
  23. package/modules/broker/const.d.ts +1 -0
  24. package/modules/broker/const.js +2 -1
  25. package/modules/broker/const.js.map +1 -1
  26. package/modules/broker/services/broker.service.js +1 -1
  27. package/modules/broker/services/broker.service.js.map +1 -1
  28. package/modules/broker/services/route-discovery-publisher.service.js +7 -5
  29. package/modules/broker/services/route-discovery-publisher.service.js.map +1 -1
  30. package/modules/gateway-admin/config/gateway-admin.config.d.ts +4 -0
  31. package/modules/gateway-admin/const.d.ts +1 -1
  32. package/modules/gateway-admin/const.js +1 -1
  33. package/modules/gateway-admin/const.js.map +1 -1
  34. package/modules/gateway-admin/gateway-admin.module.d.ts +7 -1
  35. package/modules/gateway-admin/gateway-admin.module.js +13 -0
  36. package/modules/gateway-admin/gateway-admin.module.js.map +1 -1
  37. package/modules/gateway-admin/repository/auth-provider.repository.d.ts +3 -4
  38. package/modules/gateway-admin/repository/auth-provider.repository.js.map +1 -1
  39. package/modules/gateway-admin/services/gateway-auth.service.d.ts +3 -4
  40. package/modules/gateway-admin/services/gateway-auth.service.js +15 -23
  41. package/modules/gateway-admin/services/gateway-auth.service.js.map +1 -1
  42. package/modules/gateway-admin/services/gateway-metrics.service.d.ts +3 -0
  43. package/modules/gateway-admin/services/gateway-metrics.service.js +9 -0
  44. package/modules/gateway-admin/services/gateway-metrics.service.js.map +1 -1
  45. package/modules/gateway-admin/services/route-sync.service.d.ts +3 -1
  46. package/modules/gateway-admin/services/route-sync.service.js +14 -8
  47. package/modules/gateway-admin/services/route-sync.service.js.map +1 -1
  48. package/modules/proxy/services/http-handler.service.d.ts +3 -0
  49. package/modules/proxy/services/http-handler.service.js +28 -4
  50. package/modules/proxy/services/http-handler.service.js.map +1 -1
  51. package/package.json +5 -1
  52. package/schematics/nest-add/files/acl/src/cache/in-memory-acl-store.ts +54 -0
  53. package/schematics/nest-add/files/acl/src/modules/database/repository/acl.repository.ts +74 -0
  54. package/schematics/nest-add/files/db-core/src/modules/database/repository/in-memory-collection.ts +122 -0
  55. package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/gateway.repository.ts +151 -0
  56. package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/route-sync.repository.ts +15 -0
  57. package/schematics/nest-add/files/skills/rlb-amqp/SKILL.md +30 -5
  58. package/schematics/nest-add/files/skills/rlb-amqp/references/config-schema.md +182 -93
  59. package/schematics/nest-add/files/skills/rlb-amqp/references/gotchas.md +120 -79
  60. package/schematics/nest-add/files/skills/rlb-amqp-acl/SKILL.md +185 -0
  61. package/schematics/nest-add/files/skills/rlb-amqp-add-action/SKILL.md +49 -2
  62. package/schematics/nest-add/files/skills/rlb-amqp-add-route/SKILL.md +82 -19
  63. package/schematics/nest-add/files/skills/rlb-amqp-add-ws-event/SKILL.md +40 -18
  64. package/schematics/nest-add/files/skills/rlb-amqp-gateway-admin/SKILL.md +233 -0
  65. package/schematics/nest-add/files/skills/rlb-amqp-scaffold/SKILL.md +172 -42
  66. package/schematics/nest-add/index.js +612 -142
  67. package/schematics/nest-add/index.js.map +1 -1
  68. package/schematics/nest-add/index.ts +673 -241
  69. package/schematics/nest-add/init.schema.d.ts +10 -1
  70. package/schematics/nest-add/init.schema.ts +29 -3
  71. package/schematics/nest-add/schema.json +37 -8
@@ -1,104 +1,145 @@
1
1
  # Gotchas — bug-prone cases checklist
2
2
 
3
- Scan this before adding/changing a topic, queue, exchange, action, route, auth provider
4
- or WS event. Each item is a real failure mode in this codebase.
3
+ Scan this before adding/changing a topic, queue, exchange, action, route, auth provider,
4
+ WS event, or route-discovery wiring. Each item is a real failure mode in this codebase.
5
+ Ported from `docs/gotchas.md` (re-verified against post-2.0.5 code).
5
6
 
6
7
  ## Decorators & handlers
7
8
  1. **No destructuring in `@BrokerAction` parameters.** Param→message mapping parses the
8
- function source with a regex (`getParamNames`). `fn({a,b})` misaligns indices. Use flat
9
- params.
9
+ function source with a regex (`getParamNames`). `fn({a,b})` misaligns indices params
10
+ arrive `undefined`. Use flat params + an explicit `@BrokerParam` name on each.
10
11
  2. **Avoid default parameter values.** Only a basic `= value` strip exists
11
12
  (`removeDefaultsFromParams`); complex defaults misalign mapping. Always pass an explicit
12
13
  `name` to `@BrokerParam`.
13
- 3. **`(topic, action)` must be unique.** All `@BrokerAction` of a topic share ONE
14
- consumer/queue, dispatched by `action`. A duplicate `(topic, action)` overwrites the
15
- previous one silently.
14
+ 3. **`(topic, action)` must be unique.** All `@BrokerAction` of a topic share ONE consumer/queue,
15
+ dispatched by `action`. A duplicate `(topic, action)` overwrites the previous one silently —
16
+ no error, the old handler just stops being called.
16
17
  4. **Forwarded headers are UPPERCASE + prefixed.** Read `@BrokerParam('header',
17
- 'X-GTW-AUTH-USERID')`, not `'userId'`.
18
+ 'X-GTW-AUTH-USERID')`, not `'userId'`. The exact name = provider `headerPrefix` + `uidClaim`.
19
+ 5. **`handle`/`broadcast` handlers must return `void`.** A return value logs
20
+ `Subscribe handlers should only return void`. Only `rpc` handlers return data.
21
+ 6. **One method, multiple `@BrokerAction`s:** any `@BrokerHTTP` / `@BrokerAuth` on that method
22
+ MUST name its `action` to pair deterministically — decorator order is never used.
18
23
 
19
24
  ## Topic ↔ queue ↔ exchange wiring
20
- 5. **The topic `name` must match everywhere**: `@BrokerAction`, `topics[].name`,
25
+ 7. **The topic `name` must match everywhere**: `@BrokerAction`, `topics[].name`,
21
26
  `requestData`/`publishMessage`, `gateway.paths[].topic` / `events[]`. Typo →
22
27
  `Topic X not found in configuration`.
23
- 6. **`mode: rpc`/`handle` need `topics[].queue` in `broker.queues[]`**, and that queue's
28
+ 8. **`mode: rpc`/`handle` need `topics[].queue` in `broker.queues[]`**, and that queue's
24
29
  `exchange` in `broker.exchanges[]`. In `handle` a missing queue throws NPE at boot
25
30
  (`queue.exchange`).
26
- 7. **Exchange `type: topic` → queue MUST have `routingKey`**, else boot throws
27
- `Queue ... has no routing key`.
28
- 8. **`broadcast` + WebSocket gateway require `connection_name`** (`clientProperties`), else
29
- throw.
31
+ 9. **Exchange `type: topic` → queue MUST have `routingKey`**, else boot throws
32
+ `Queue ... has no routing key`. (The samples use a `direct` exchange with matching keys.)
33
+ 10. **The gateway can only forward to topics it declares.** Route auto-discovery teaches the
34
+ gateway the HTTP route, NOT how to reach the microservice's broker topic. That topic (+ its
35
+ queue/exchange) must ALSO exist in the **gateway's own** `broker` config, or the forwarded
36
+ request fails with `Topic ... not found in configuration`.
37
+
38
+ ## connection_name (broadcast / WebSocket / route-discovery)
39
+ 11. **`broadcast` + WebSocket require `connection_name`** (`clientProperties.connection_name`,
40
+ or `broker.routeDiscovery.serviceName` which fills it when unset), else throw at boot.
41
+ 12. **Every instance needs a DISTINCT `connection_name`.** Sharing it makes RabbitMQ treat the
42
+ per-instance queues as one consumer group and **round-robin** broadcast/WS messages — reloads
43
+ land on "every other" instance, WS clients miss events delivered to the other one.
30
44
 
31
45
  ## RPC / timeout / errors
32
- 9. **RPC reply routing**: `requestData` resolves `replyTo` from `broker.replyQueues[exchange]`;
33
- absent → RabbitMQ direct-reply-to. Wrong exchange key in `replyQueues` → no reply → timeout.
34
- 10. **Handler exceptions don't throw on the consumer**: returned as `{success:false,error}`;
35
- `requestData` re-throws to the caller. Gateway status derives from `error.name` — give
36
- errors a meaningful `name`.
37
- 11. **Default RPC timeout 10s** (or `broker.defaultRpcTimeout`). Set `timeout` per path /
38
- per `requestData` call for slow RPCs.
46
+ 13. **Wrong `replyQueues` key → silent timeout.** `requestData` resolves `replyTo` from
47
+ `broker.replyQueues[exchange]`; absent → RabbitMQ direct-reply-to. Wrong exchange key → no
48
+ reply routed back the call just times out.
49
+ 14. **Handler exceptions don't crash the consumer.** Returned as `{success:false,error}`;
50
+ `requestData` re-throws on the caller side. Gateway HTTP status derives from `error.name` —
51
+ give errors a meaningful `name` (`BadRequestError`, `NotFoundError`, `ConflictError`,
52
+ `ForbiddenError`, `UnauthorizedError`); anything unrecognized 500.
53
+ 15. **Default RPC timeout 10s** (`broker.defaultRpcTimeout`). Override per route (`paths[].timeout`)
54
+ or per `requestData` call for slow RPCs.
39
55
 
40
56
  ## Gateway HTTP
41
- 12. **`parseRaw: true` needs `NestFactory.create(AppModule, { rawBody: true })`** or `$raw`
42
- is `undefined`.
43
- 13. **Route params win over body/query** (re-applied last). Watch key collisions (`:id`
44
- vs `body.id`).
45
- 14. **Uploads are in `$files`** (multer `.any()`); buffers are converted to binary strings —
46
- handle re-encoding carefully on the consumer side.
57
+ 16. **Boolean RPCs return `200` with `true`/`false`, not `204`.** A *defined* result — including
58
+ falsy `false`/`0`/`""` — is real content, sent as `200` + JSON. Only `null`/`undefined`
59
+ collapses to `204`. So `GET /acl/check?...` answers `200 false` for "no" — don't treat any
60
+ 2xx as "allowed", read the body. (The old "always 204" bug is fixed.)
61
+ 17. **`parseRaw: true` needs `NestFactory.create(AppModule, { rawBody: true })`** or `$raw` is
62
+ `undefined`.
63
+ 18. **Route params win over body/query** (merged in last). Watch key collisions (`:id` vs `body.id`).
64
+ 19. **Uploads live in `$files`** (multer `.any()`); buffers are converted to **binary strings** —
65
+ re-encode carefully on the consumer (`Buffer.from(str, 'binary')`).
66
+ 20. **`/health` is a tiny liveness probe.** Action `gw-health` → `{ status: 'ok' }` (a real 200),
67
+ NOT a metrics dump. Use `/admin/metrics*` (`gw-metrics-*`) for metrics.
47
68
 
48
69
  ## Auth / ACL
49
- 15. **`roles` on an HTTP path require an `IAclRoleService`** registered via
50
- `RLB_GTW_ACL_ROLE_SERVICE` in `ProxyModule.forRootAsync({ providers: [...] })`. The
51
- gateway check is **role-based** (`canUserDoGtw(path.roles, userId)`): `path.roles` lists
52
- ROLE NAMES and the user passes if they hold AT LEAST ONE (resource-agnostic primary
53
- filter). The provider only needs `uidClaim` (+ `headerPrefix`) to extract the userId —
54
- no topic/action.
55
- 16. **Two role-based ACL checks** on `rlb-acl` (both cached, inputs = userId + roles only):
56
- `acl-can-user-do-gtw` → `canUserDoGtw(roles, userId)` (gateway primary filter, OR,
57
- resource-agnostic) and `acl-can-user-do` → `canUserDo(roles, userId, resourceId)`
58
- (**ms-side**; a global grant OR a grant on that resource satisfies it — the resource is
59
- known only to the target ms).
60
- 17. **Auth-providers + gateway config are passed to `ProxyModule`** (`authOptions` /
61
- `gatewayOptions`), not `BrokerModule`. `BrokerModule` owns only `options`/`topics`/`appOptions`.
70
+ 21. **`roles` require `auth` on the same path/event.** No `auth` no identity → fails closed
71
+ (every request `403`, logged at boot). Always pair `roles: [...]` with `auth: <provider>`.
72
+ 22. **`roles` require an `IAclRoleService`** registered via `RLB_GTW_ACL_ROLE_SERVICE` in
73
+ `ProxyModule.forRootAsync({ providers: [...] })`. Missing deny (403). The gateway check is
74
+ **role-based, OR, resource-agnostic** (`canUserDoGtw(roles, userId)`): `roles` lists ROLE NAMES,
75
+ the user passes holding AT LEAST ONE. The provider only needs `uidClaim` (+ `headerPrefix`).
76
+ 23. **Two ACL check actions on `rlb-acl`** (both cached, both HTTP GET `200` true/false):
77
+ `acl-can-user-do-gtw` → `canUserDoGtw(roles, userId)` (gateway filter, OR, resource-agnostic,
78
+ `GET /acl/check`) and `acl-can-user-do` → `canUserDo(roles, userId, resource)` (**ms-side**;
79
+ a global grant OR a grant on that resource passes, `GET /acl/check-resource`).
80
+ 24. **Actions, roles & auth-providers are NAME-KEYED. PUT upserts; there is NO POST.** The `name`
81
+ IS the key (no id). `PUT` creates-or-updates, `GET` lists, `GET .../get?name=` reads one,
82
+ `DELETE` removes by `name`. The old id-based ACL CRUD and `POST`-create endpoints are GONE.
83
+ (Gateway-admin **paths** are the exception — they keep id-keyed CRUD and a POST create.)
84
+ 25. **`acl-grant` / `acl-revoke` both REQUIRE `userId` + `roles`** (optional `resourceId` +
85
+ `companyId`). `grant` MERGES roles into the single `(userId, resourceId)` record (idempotent).
86
+ `revoke` REMOVES exactly those roles and **deletes the record once it has no roles left**.
87
+ `revoke` without `roles` throws `400 roles are required` — to wipe a grant, revoke all its roles.
88
+ 26. **`companyId` is grouping metadata only.** It replaced `resourceBusinessId` and plays NO part
89
+ in authorization — it only groups resources in `acl-list-resources-by-user`. Targeting is by
90
+ `(userId, resourceId)` only. Both grant/revoke validate every role exists (unknown → `400`).
91
+ 27. **Removed actions:** `acl-list-by-user` and `acl-verify-access` no longer exist. Use
92
+ `acl-can-user-do` for resource-scoped checks and `acl-list-resources-by-user` to list resources.
93
+ 28. **Auth & gateway config go to `ProxyModule`** (`authOptions` / `gatewayOptions`), not
94
+ `BrokerModule`. `BrokerModule` owns only `options` / `topics` / `appOptions`.
62
95
 
63
- ## WebSocket
64
- 16. **Auth is per-event, not global.** `events[].auth` names the provider that verifies the
65
- connection token AND maps its claims for THAT event (at subscribe time, memoized per
66
- provider). `scopeClaim` references the MAPPED claim (with `headerPrefix`, e.g.
67
- `X-GTW-AUTH-USERID`), not the raw token claim. `payloadKey` is the event-payload field.
68
- `scopeClaim` without `payloadKey` denies everything (safe default). `requireAuth: false`
69
- on an event makes `auth` optional (anon allowed, claims mapped if a token is present).
70
- `gateway.ws` only carries connection-level limits/heartbeatno auth fields.
71
- 17. **Don't use a fixed durable queue for WS events.** The lib creates a per-instance
72
- exclusive ephemeral queue for fan-out; a shared queue makes instances compete and clients
73
- on one instance miss messages.
74
- 18. **Token transport is the subprotocol**: `new WebSocket(url, [token])`. Browsers can't set
75
- custom handshake headers.
96
+ ## Auth providers (hardening)
97
+ 29. **JWKS verifies TLS by default.** `httpsAllowUnauthorized: true` only for self-signed dev issuers.
98
+ 30. **`algorithms` is REQUIRED for `jwt`/`jwks`.** Omitting denies verification (algorithm-confusion
99
+ guard). For `jwks` only asymmetric algs are allowed (`RS*`/`ES*`/`PS*`); `HS*`/`none` rejected.
100
+ 31. **Define `jwtMap` or NO claims are forwarded.** Without it the token is still accepted
101
+ (`success:true`) but no identity headers go downstream — fail-safe, not a leak. Declare it to
102
+ emit `X-GTW-AUTH-USERID` and friends.
103
+ 32. **`str-compare`/`basic` PASS THROUGH when their secret is unset by design.** A `str-compare`
104
+ with no `secret`, or a `basic` with no `clientSecret`, authenticates EVERY request (effectively
105
+ open/disabled). Set the secret to enforce it.
106
+ 33. **Credential `mechanism` must be `PLAIN` | `EXTERNAL` | `AMQPLAIN`** (case-insensitive). Unknown
107
+ value leaves SASL `response` unset AMQP auth fails.
76
108
 
77
- ## Publish / event
78
- 19. **`publishMessage` is `async` `await` it** for the publisher-confirm guarantee and to
79
- catch failures. Un-awaited = fire-and-forget without guarantee.
80
- 20. **`handle`/`broadcast` handlers must return `void`**; a return value logs
81
- `Subscribe handlers should only return void`.
109
+ ## WebSocket
110
+ 34. **Auth is per-event, not global.** `events[].auth` names the provider that verifies the
111
+ connection token AND maps its claims for THAT event (memoized per provider at subscribe). `scopeClaim`
112
+ references the MAPPED claim (prefixed, e.g. `X-GTW-AUTH-USERID`), `payloadKey` is the payload
113
+ field. **`scopeClaim` without `payloadKey` denies everything** (safe default). `requireAuth:false`
114
+ makes `auth` optional (anon allowed; claims mapped if a token is present). `gateway.ws` carries
115
+ only connection-level limits/heartbeat — no auth fields.
116
+ 35. **Don't bind a WS event to a fixed durable queue.** The lib creates a per-instance exclusive
117
+ ephemeral queue for fan-out; a shared/durable queue makes instances compete and clients on one
118
+ instance miss messages delivered to another.
119
+ 36. **Token rides in the subprotocol**: `new WebSocket(url, [token])` (browsers can't set handshake
120
+ headers). The session is bounded by the JWT `exp` — closed with `1008` on expiry, nothing
121
+ delivered afterward. Long-lived clients need token refresh + reconnect.
122
+ 37. **Set `gateway.ws.allowedOrigins`** to reject cross-site handshakes; omitted → all Origins
123
+ accepted (logged at boot). `maxMessageBytes` (default 16384) drops oversized client frames.
82
124
 
83
- ## TLS / credentials / provider hardening
84
- 21. **JWKS verifies TLS by default.** `httpsAllowUnauthorized: true` only for self-signed dev
85
- issuers.
86
- 22. **Credential `mechanism`**: `PLAIN` | `EXTERNAL` | `AMQPLAIN` (case-insensitive). Unknown
87
- value leaves `response` unset → auth fails.
88
- 23. **`algorithms` is REQUIRED for `jwt`/`jwks`.** If omitted, verification is denied
89
- (algorithm-confusion guard). For `jwks` only asymmetric algs are allowed (RS*/ES*/PS*);
90
- `HS*`/`none` are rejected.
91
- 24. **`str-compare`/`basic` PASS THROUGH when their secret is unset.** A `str-compare`
92
- without `secret` or a `basic` without `clientSecret` treats every request as authenticated
93
- (provider effectively open/disabled — by design). Set the secret to actually enforce it.
94
- 25. **Define `jwtMap`.** Without it NO claims are forwarded (the token is still accepted,
95
- `success:true`): the gateway fails safe instead of leaking the whole payload. Declare it
96
- to forward identity headers (e.g. `X-GTW-AUTH-USERID`).
125
+ ## Publish / events
126
+ 38. **`publishMessage` is `async` `await` it** for the publisher-confirm guarantee and to catch
127
+ failures. Un-awaited = fire-and-forget without guarantee. (For an `event`-mode route the gateway
128
+ awaits the confirm before returning the 2xx the success status is not optimistic.)
97
129
 
98
- ## WebSocket session/transport security
99
- 26. **WS sessions are bounded by the token `exp`.** The connection is closed (`1008`) when the
100
- JWT expires; no delivery happens afterward. Long-lived sockets need token refresh +
101
- reconnect.
102
- 27. **Set `gateway.ws.allowedOrigins`** to reject cross-site handshakes; if omitted, all
103
- Origins are accepted (logged at boot). `maxMessageBytes` (default 16384) drops oversized
104
- client frames.
130
+ ## Reload & route auto-discovery
131
+ 39. **The only reload action is `gw-reload`** (`GW_RELOAD_ACTION`). The control-topic subscriber
132
+ rebuilds routes ONLY for `gw-reload`; every other message on the control topic is ignored.
133
+ Seed then reload: `POST /admin/paths` → `POST /admin/reload` (publishes `gw-reload` on
134
+ `rlb-gateway-control`). No restart. Concurrent reloads are coalesced into one extra pass.
135
+ 40. **Route-discovery config is SPLIT; exchange/queue MUST match on both sides.**
136
+ - **Publisher (microservice):** `broker.routeDiscovery { serviceName, publishOnBoot, exchange?, queue? }`.
137
+ `serviceName` is required to publish and also fills `connection_name` when unset.
138
+ - **Consumer (gateway):** `GatewayAdminModule` `routeDiscovery { exchange?, queue? }` (NEST code,
139
+ no `serviceName` — the gateway only receives).
140
+ Both default to `exchange: rlb-route-discovery` / `queue: rlb-route-sync`. Override only to
141
+ namespace per env — but set the SAME values on BOTH sides or manifests never reach the gateway.
142
+ 41. **Topic NAMES `rlb-acl` / `rlb-gateway-admin` / `rlb-gateway-control` and all action strings
143
+ are decorator-bound and NOT configurable.** Only exchange/queue/routingKey and the
144
+ route-discovery exchange/queue are. The route-sync handler never throws (logs + acks, no poison
145
+ loop); an empty manifest soft-disables a service's existing routes (and logs a warning).
@@ -0,0 +1,185 @@
1
+ ---
2
+ name: rlb-amqp-acl
3
+ description: Manage role-based access control (ACL) with @open-rlb/nestjs-amqp — actions, roles, grants/revokes, and "can user do X" checks. Use when wiring AclModule, gating gateway routes by roles, granting/revoking a user's roles, listing a user's resources, or answering authorization/permission questions (roles, grants, acl-check).
4
+ ---
5
+
6
+ # Manage ACL (@open-rlb/nestjs-amqp)
7
+
8
+ Read first when you need depth:
9
+ - `docs/acl.md` (model, wiring, URL table)
10
+ - `libs/rlb-nestjs-amqp/src/modules/acl/const.ts` (`ACL_ACTIONS`, `ACL_TOPIC`)
11
+ - `sample/config-sample/acl.yaml` (annotated broker + gateway reference)
12
+ - `sample/config-sample/gateway-in-memory/src/app.module.ts` (forRoot wiring)
13
+
14
+ Use when: managing **actions/roles/grants**, wiring `AclModule`, role-gating routes
15
+ (`roles: [...]`), or answering "can user do X".
16
+
17
+ ## Model (3 entities)
18
+
19
+ - **Action** — atomic capability (`read-doc`). Name-keyed.
20
+ - **Role** — bundle of action names (`editor = [read-doc, write-doc]`). Name-keyed.
21
+ - **Grant** — binds a `userId` → role names; one record per `(userId, resourceId)`.
22
+ - **Checks** match on **roles, never action strings**.
23
+
24
+ ## Decorator-bound (NOT configurable)
25
+
26
+ Topic NAME `rlb-acl` (`ACL_TOPIC`) and every action string are bound in the library —
27
+ reference them literally. The queue / exchange / routingKey that carry the topic ARE yours.
28
+
29
+ `ACL_ACTIONS`: `acl-action-list`, `acl-action-get`, `acl-action-update`,
30
+ `acl-action-delete`, `acl-role-list`, `acl-role-get`, `acl-role-update`,
31
+ `acl-role-delete`, `acl-grant`, `acl-revoke`, `acl-can-user-do-gtw`,
32
+ `acl-can-user-do`, `acl-list-resources-by-user`, `acl-invalidate`.
33
+
34
+ > **Removed in 2.0.5:** `acl-list-by-user`, `acl-verify-access`, `acl-create` /
35
+ > id-based ACL CRUD. Entities are name-keyed: **PUT upserts, no POST.**
36
+
37
+ ## Actions & roles — name-keyed CRUD
38
+
39
+ No id, no POST. `PUT` upserts by `name` (idempotent), `GET` lists (`?page=&limit=`),
40
+ `GET …/get?name=` reads one, `DELETE` removes by `name`. Role upsert: every referenced
41
+ action must already exist (else **400**).
42
+
43
+ ## Grants — dual grant/revoke
44
+
45
+ One record per `(userId, resourceId)`. Both ops **require `userId` + `roles`**;
46
+ `resourceId` + `companyId` are **optional**.
47
+
48
+ - `acl-grant` — merges roles into the pair (creates if absent; idempotent).
49
+ - `acl-revoke` — removes roles; deletes the record once empty.
50
+ - Both validate every role exists (unknown role → **400**) and invalidate the user's cache.
51
+ - `companyId` (replaced `resourceBusinessId`) is **grouping metadata only** — it groups
52
+ `acl-list-resources-by-user` output and plays **no part** in authorization.
53
+
54
+ ## Checks — GET → 200 with `true`/`false`
55
+
56
+ `false` is real content; only `null`/`undefined` collapses to 204. Both return `false`
57
+ (never throw) on missing input or error.
58
+
59
+ - `acl-can-user-do-gtw` — resource-**agnostic**, the gateway's primary filter. `true` if
60
+ the user holds **≥1** requested role. Query: `?userId=&roles=user&roles=admin`.
61
+ - `acl-can-user-do` — resource-**scoped**: `true` if a **global** grant OR a grant bound
62
+ to that exact `resource` gives a matching role. Query: `?userId=&roles=admin&resource=doc-1`.
63
+ Normally called over the broker by the owning microservice.
64
+ - `acl-list-resources-by-user` — **auth-gated** (needs `auth`, no roles): reads `userId`
65
+ from the forwarded `X-GTW-AUTH-USERID` header; lists accessible resources grouped by
66
+ `companyId` with resolved actions.
67
+
68
+ ## Nest wiring
69
+
70
+ Backend — `AclModule.forRoot([bindings], { cache })`. Bind the abstract repo tokens to
71
+ your concrete impls + optional L2 store; second arg carries TTLs. Module is **global**,
72
+ exports `AclService` + `AclCacheService`.
73
+
74
+ ```ts
75
+ import {
76
+ AclModule, AclActionRepository, AclRoleRepository, AclGrantRepository,
77
+ RLB_ACL_CACHE_STORE,
78
+ } from '@open-rlb/nestjs-amqp';
79
+
80
+ AclModule.forRoot(
81
+ [
82
+ { provide: AclActionRepository, useClass: MyAclActionRepository },
83
+ { provide: AclRoleRepository, useClass: MyAclRoleRepository },
84
+ { provide: AclGrantRepository, useClass: MyAclGrantRepository },
85
+ { provide: RLB_ACL_CACHE_STORE, useClass: MyRedisAclCacheStore }, // OPTIONAL L2 (omit → RAM-only)
86
+ ],
87
+ { cache: { ramTtlMs: 30_000, l2TtlSec: 600 } }, // L1 RAM (default 30000) / L2 (default 600s)
88
+ );
89
+ ```
90
+
91
+ Gateway side — let route `roles: [...]` filters run **in-process** (no broker hop) by
92
+ binding the gateway token to the same `AclService`:
93
+
94
+ ```ts
95
+ import { ProxyModule, AclService, RLB_GTW_ACL_ROLE_SERVICE } from '@open-rlb/nestjs-amqp';
96
+
97
+ ProxyModule.forRoot({
98
+ providers: [{ provide: RLB_GTW_ACL_ROLE_SERVICE, useExisting: AclService }],
99
+ });
100
+ ```
101
+
102
+ Same process → `useExisting`. Separate services → gateway RPCs `acl-can-user-do-gtw` on
103
+ `rlb-acl` instead. A route's `roles` are ROLE NAMES; the user passes with **≥1**.
104
+
105
+ ## YAML — topic + queue (names fixed, transport yours)
106
+
107
+ ```yaml
108
+ broker:
109
+ queues:
110
+ - name: rlb-acl # consumed by the ACL backend handlers
111
+ exchange: rlb
112
+ routingKey: rlb-acl
113
+ createQueueIfNotExists: true
114
+ options: { durable: true }
115
+ topics:
116
+ - name: rlb-acl # ACL_TOPIC — must match exactly
117
+ mode: rpc
118
+ queue: rlb-acl
119
+ exchange: rlb
120
+ routingKey: rlb-acl
121
+ ```
122
+
123
+ ## Gateway paths[] — full ACL table
124
+
125
+ Every path forwards to topic `rlb-acl`, `mode: rpc`. `name` is a free label; `action` is
126
+ the fixed library string.
127
+
128
+ | name | method | path | dataSource | action |
129
+ |---|---|---|---|---|
130
+ | acl-action-list | GET | /acl/actions | query | acl-action-list |
131
+ | acl-action-get | GET | /acl/actions/get | query | acl-action-get |
132
+ | acl-action-upsert | PUT | /acl/actions | body | acl-action-update |
133
+ | acl-action-delete | DELETE | /acl/actions | body | acl-action-delete |
134
+ | acl-role-list | GET | /acl/roles | query | acl-role-list |
135
+ | acl-role-get | GET | /acl/roles/get | query | acl-role-get |
136
+ | acl-role-upsert | PUT | /acl/roles | body | acl-role-update |
137
+ | acl-role-delete | DELETE | /acl/roles | body | acl-role-delete |
138
+ | acl-grant | POST | /acl/grants | body | acl-grant |
139
+ | acl-revoke | DELETE | /acl/grants | body | acl-revoke |
140
+ | acl-check-gtw | GET | /acl/check | query | acl-can-user-do-gtw |
141
+ | acl-check-resource | GET | /acl/check-resource | query | acl-can-user-do |
142
+ | acl-list-resources-by-user | GET | /acl/resources | query | acl-list-resources-by-user (+ `auth:`) |
143
+
144
+ ```yaml
145
+ gateway:
146
+ mode: gateway
147
+ paths:
148
+ - name: acl-role-upsert # PUT upserts by name. body: { name, actions, description? }
149
+ method: PUT
150
+ path: /acl/roles
151
+ dataSource: body
152
+ topic: rlb-acl
153
+ action: acl-role-update
154
+ mode: rpc
155
+ - name: acl-grant # body: { userId, roles, resourceId?, companyId?, friendlyName? }
156
+ method: POST
157
+ path: /acl/grants
158
+ dataSource: body
159
+ topic: rlb-acl
160
+ action: acl-grant
161
+ mode: rpc
162
+ - name: acl-check-gtw # ?userId=&roles=user&roles=admin → 200 true/false
163
+ method: GET
164
+ path: /acl/check
165
+ dataSource: query
166
+ topic: rlb-acl
167
+ action: acl-can-user-do-gtw
168
+ mode: rpc
169
+ - name: acl-list-resources-by-user # auth-gated; userId from X-GTW-AUTH-USERID
170
+ method: GET
171
+ path: /acl/resources
172
+ dataSource: query
173
+ topic: rlb-acl
174
+ action: acl-list-resources-by-user
175
+ mode: rpc
176
+ auth: gateway-jwks
177
+ ```
178
+
179
+ ## Verify
180
+
181
+ - topic `rlb-acl` + its queue declared on the consuming service; gateway paths use the
182
+ literal `action` strings above.
183
+ - role-gated routes (`roles: [...]`) → `RLB_GTW_ACL_ROLE_SERVICE` bound to an
184
+ `IAclRoleService` (`AclService`). Auth-provider needs `uidClaim` (+ `headerPrefix`).
185
+ - a check returning `false` is a **200**, not an error.
@@ -12,13 +12,17 @@ First, read the shared reference (schema + gotchas):
12
12
  - `.claude/skills/rlb-amqp/references/config-schema.md`
13
13
  - `.claude/skills/rlb-amqp/references/gotchas.md`
14
14
 
15
+ Authoritative source of truth: `docs/broker.md` (decorators + modes) and the runnable
16
+ `sample/config-sample/calculator.ms` (its `src/app.service.ts` + `config/config.yaml`).
17
+
15
18
  Then locate the project's `config.yaml` (commonly `config/config.yaml`) and the service file.
16
19
 
17
20
  ## Inputs to determine (ask only if not inferable)
18
21
 
19
22
  - **topic** (logical name), **action** (string), **mode** intended: `rpc` (default) or `event`.
20
23
  - Payload fields the method needs, and any forwarded headers (e.g. `X-GTW-AUTH-USERID`).
21
- - Whether to also expose it over HTTP (if yes, see the `rlb-amqp-add-route` skill) or WS.
24
+ - Whether to also expose it over HTTP inline via `@BrokerHTTP` (auto-publish to a gateway,
25
+ see Step 1b) or via a `gateway.paths[]` entry (`rlb-amqp-add-route` skill), or over WS.
22
26
 
23
27
  ## Step 1 — the handler method
24
28
 
@@ -31,7 +35,7 @@ import { BrokerAction, BrokerParam } from '@open-rlb/nestjs-amqp';
31
35
 
32
36
  @Injectable()
33
37
  export class <Domain>ActionService {
34
- @BrokerAction('<topic>', '<action>', 'rpc')
38
+ @BrokerAction('<topic>', '<action>', 'rpc') // type? = 'rpc' (default) | 'event'
35
39
  async <method>(
36
40
  @BrokerParam('body', '<field>') field: string,
37
41
  @BrokerParam('header', 'X-GTW-AUTH-USERID') userId: string,
@@ -46,6 +50,48 @@ Ensure the service is a provider in a module that NestJS loads (the
46
50
  `MetadataScannerService` auto-discovers it at boot). Throwing an error whose `name` maps to
47
51
  an HTTP status (e.g. `NotFoundError`) yields the right gateway status.
48
52
 
53
+ ### `@BrokerAction(topic, action, type?)`
54
+
55
+ - `topic` must match a `topics:` entry (an `rpc` topic). `action` is the dispatch key.
56
+ - **`(topic, action)` must be unique** across the whole app — all actions of a topic share
57
+ ONE consumer/queue, dispatched by `action` (gotcha 3).
58
+ - A single method may carry **multiple** `@BrokerAction`s. When it does, any `@BrokerHTTP` /
59
+ `@BrokerAuth` on that method **must name its `action`** to pair deterministically (decorator
60
+ order is never used).
61
+
62
+ ### `@BrokerParam(source, name?, pipe?)` — one source per argument
63
+
64
+ No object destructuring; declare a separate param per field. A param with no `@BrokerParam`
65
+ defaults to `source: 'body'` keyed by its own name. Optional `pipe` is a `PipeTransform`.
66
+
67
+ | `source` | Resolves to |
68
+ |---|---|
69
+ | `body` | `payload[name ?? paramName]` — a single body field. |
70
+ | `body-full` | the entire `payload` object. |
71
+ | `header` | `headers[name ?? paramName]` — one AMQP/forwarded header (UPPERCASE+prefixed, gotcha 4). |
72
+ | `tag` | the consumer tag of the delivery. |
73
+ | `action` | the dispatched action string. |
74
+ | `topic` | the topic name. |
75
+
76
+ ## Step 1b (optional) — `@BrokerHTTP` for route auto-publish
77
+
78
+ To expose the same handler over HTTP **without** hand-editing the gateway, stack
79
+ `@BrokerHTTP("METHOD", "/path", dataSource, options?)` on top of the `@BrokerAction`. On boot
80
+ the microservice publishes its `@BrokerHTTP` routes as a manifest to the gateway (route
81
+ auto-discovery), which persists + registers them. This requires `broker.routeDiscovery`
82
+ (see `rlb-amqp` reference / `docs/gateway-admin.md`); the gateway must also declare this
83
+ service's topic so it can forward calls.
84
+
85
+ ```ts
86
+ @BrokerAction('calculator', 'sum')
87
+ @BrokerHTTP('POST', '/calculator/sum', 'body') // dataSource: body | query | params | ...
88
+ async sum(@BrokerParam('body', 'values') values: number[]) {
89
+ return values.reduce((a, v) => a + v, 0);
90
+ }
91
+ ```
92
+
93
+ (Pattern taken verbatim from `sample/config-sample/calculator.ms/src/app.service.ts`.)
94
+
49
95
  ## Step 2 — YAML sync (the critical part)
50
96
 
51
97
  Reconcile `config.yaml` so the topic resolves. Add ONLY what's missing (idempotent):
@@ -85,6 +131,7 @@ single consumer dispatches by `action`.
85
131
  - topic-type exchange ⇒ queue has `routingKey` (gotcha 7)
86
132
  - `(topic, action)` unique (gotcha 3)
87
133
  - header params read the prefixed/uppercased name (gotcha 4)
134
+ - if multiple `@BrokerAction` on one method ⇒ each `@BrokerHTTP`/`@BrokerAuth` names its `action`
88
135
 
89
136
  ## Step 4 — build
90
137
 
@@ -7,22 +7,46 @@ description: Expose a broker action over HTTP through the @open-rlb/nestjs-amqp
7
7
 
8
8
  Read first:
9
9
  - `.claude/skills/rlb-amqp/references/config-schema.md` (the `gateway.paths[]` section)
10
- - `.claude/skills/rlb-amqp/references/gotchas.md` (HTTP + auth items 11–15)
10
+ - `.claude/skills/rlb-amqp/references/gotchas.md` (HTTP + auth items)
11
+ - `docs/gateway.md` is the authority for the `PathDefinition` fields and status mapping.
11
12
 
12
13
  The target `topic`+`action` should already have a handler (otherwise also run
13
14
  `rlb-amqp-add-action`). The route only needs the topic to exist in `topics[]`.
15
+ Canonical example: `sample/config-sample/gateway-in-memory/config/config.yaml`.
14
16
 
15
17
  ## Decide
16
18
 
17
- - **mode**: `rpc` (return the handler's response) or `event` (202 after publisher confirm,
18
- 503 on failure).
19
- - **dataSource**: how to build the payload — `body` | `query` | `params` | `body-query` |
20
- `query-body` (see the composition table in the schema).
21
- - **auth**: an `auth-provider` name; `allowAnonymous: true` to permit unauthenticated access;
22
- `roles: [...]` for ACL.
19
+ - **mode**: `rpc` (return the handler's reply) or `event` (publish-and-confirm, no reply).
20
+ - **dataSource**: how the payload is assembled — `req.params` are ALWAYS merged in, plus:
21
+ `body` | `query` | `params` | `body-query` (body wins) | `query-body` (query wins).
22
+ - **auth**: an `auth-provider` name (validates the request, maps claims to `X-GTW-AUTH-*`
23
+ headers). `allowAnonymous: true` skips the gate. `roles: [...]` adds a role check.
23
24
  - Extras: `timeout` (rpc), `successStatusCode`, `binary`, `redirect`, `parseRaw`, static
24
25
  `headers`, `forwardHeaders`.
25
26
 
27
+ ## PathDefinition fields (all of them)
28
+
29
+ | Field | Notes |
30
+ | --- | --- |
31
+ | `name` | Unique; used in logs + metrics. |
32
+ | `method` | `GET` `POST` `PUT` `DELETE` `PATCH` … |
33
+ | `path` | Express route, may carry `:params` (e.g. `/users/:id`). |
34
+ | `topic` / `action` | Broker destination. Action strings are decorator-bound on the backend. |
35
+ | `mode` | `rpc` \| `event`. |
36
+ | `dataSource` | `body` \| `query` \| `params` \| `body-query` \| `query-body`. |
37
+ | `auth` | Auth-provider name; validates + maps claims. |
38
+ | `allowAnonymous` | `true` → gate skipped (token still mapped if present & valid). |
39
+ | `roles` | Role NAMES; caller passes with AT LEAST ONE. Requires `auth`. |
40
+ | `timeout` | RPC timeout (ms), `rpc` only. |
41
+ | `binary` | Treat a raw (non-JSON) RPC reply as base64 → binary body. |
42
+ | `parseRaw` | Adds the raw request body as `$raw` (needs `rawBody: true`). |
43
+ | `successStatusCode` | Override success status (default 200 rpc / 202 event / 204 empty). |
44
+ | `redirect` | On an `rpc` route, redirect with this status using the reply as the location. |
45
+ | `headers` | Static response headers `{ k: v }`. |
46
+ | `forwardHeaders` | `{ dest: srcHeader }` — copy request headers downstream (prefixed by `gateway.headerPrefix`). |
47
+
48
+ Uploaded multipart files (any field) are attached as `$files` (buffers → binary strings).
49
+
26
50
  ## YAML fragment
27
51
 
28
52
  ```yaml
@@ -36,28 +60,67 @@ gateway:
36
60
  action: <action>
37
61
  mode: rpc # or event
38
62
  auth: gateway-jwks # optional
39
- roles: [resource.write] # optional → needs IAclRoleService
63
+ roles: [resource.write] # optional → needs RLB_GTW_ACL_ROLE_SERVICE
40
64
  timeout: 7000 # rpc only
41
65
  successStatusCode: 201
42
66
  ```
43
67
 
68
+ ## The 3-case auth gate
69
+
70
+ For every request the gateway runs `processAuthData` (best-effort), then:
71
+
72
+ 1. **`allowAnonymous: true`** → gate SKIPPED. A valid token still gets its claims mapped &
73
+ forwarded; a missing/invalid token is NOT blocked.
74
+ 2. **`auth` set, no `roles`** → authentication only. Provider must validate (else `401`);
75
+ on success the `X-GTW-AUTH-*` headers are forwarded downstream.
76
+ 3. **`auth` + `roles`** → authn + role authz. After a valid token the gateway reads the user
77
+ id from the provider's `uidClaim` and calls `IAclRoleService.canUserDoGtw(roles, userId)`
78
+ in-process. Passes with at least one role, else `403`.
79
+
80
+ > `roles` WITHOUT `auth` is a misconfiguration: no identity → fails closed (every request
81
+ > `403`, logged loudly at boot). The resource-scoped check (`acl-can-user-do`) is NOT run by
82
+ > the gateway — it lives on the target microservice.
83
+
84
+ ## Status mapping
85
+
86
+ **`rpc`** reply → status:
87
+
88
+ | Reply | Status |
89
+ | --- | --- |
90
+ | Defined value (incl. falsy `false` / `0` / `''`) | `200` + body |
91
+ | `null` / `undefined` | `204 No Content` |
92
+
93
+ > ONLY `null`/`undefined` collapses to `204`. A defined falsy result is real content, so a
94
+ > boolean check route answers `200` with body `false` — not an empty `204`.
95
+
96
+ **`rpc`** error → status (by error `name`):
97
+
98
+ | Error name | Status |
99
+ | --- | --- |
100
+ | `BadRequestError`, `InvalidParamsErrror` | `400` |
101
+ | `UnauthorizedError` | `401` |
102
+ | `ForbiddenError` | `403` |
103
+ | `NotFoundError` | `404` |
104
+ | `ConflictError` | `409` |
105
+ | (any other) | `500` |
106
+
107
+ **`event`** route: successful publish → `successStatusCode || 202`; publish failure → `503`.
108
+
44
109
  ## Required wiring to flag
45
110
 
46
- - If `parseRaw: true` → the app must bootstrap with
47
- `NestFactory.create(AppModule, { rawBody: true })` (gotcha 12).
48
- - If `roles` is used an `IAclRoleService` must be registered via
49
- `RLB_GTW_ACL_ROLE_SERVICE` in `ProxyModule.forRootAsync({ providers: [...] })`. The check
50
- is role-based (`canUserDoGtw(path.roles, userId)`): `roles` are ROLE NAMES and the user
51
- passes with AT LEAST ONE. The auth-provider only needs `uidClaim` (+ `headerPrefix`) to
52
- extract the userId (gotcha 15). For a resource-scoped decision, the target ms calls
53
- `canUserDo(roles, userId, resourceId)` (RPC `acl-can-user-do`) itself.
111
+ - If `parseRaw: true` → bootstrap with `NestFactory.create(AppModule, { rawBody: true })`.
112
+ - If `roles` is used an `IAclRoleService` must be registered via `RLB_GTW_ACL_ROLE_SERVICE`
113
+ in `ProxyModule.forRootAsync({ providers: [{ provide: RLB_GTW_ACL_ROLE_SERVICE, useExisting: AclService }] })`.
114
+ If a path declares `roles` and the service is NOT registered → request DENIED (`403`) +
115
+ error logged. The auth-provider needs a `uidClaim` (+ `headerPrefix`) to resolve the userId.
54
116
  - Forwarded auth claims reach the handler as prefixed/uppercased headers
55
- (e.g. `X-GTW-AUTH-USERID`) — read them with `@BrokerParam('header', ...)`.
117
+ (e.g. `X-GTW-AUTH-USERID`) — read them with `@BrokerParam('header', ...)`. Request headers
118
+ can never override mapped claim headers (anti-spoofing).
56
119
 
57
120
  ## Verify
58
121
 
59
- - topic exists in `topics[]` and resolves (gotchas 5–7).
60
- - route-param vs body/query key collisions are intentional (gotcha 13).
122
+ - topic exists in `topics[]` and resolves.
123
+ - route-param vs body/query key collisions are intentional (params always merge in).
61
124
  - `npm run build`, then optionally curl the route once the broker is up.
62
125
 
63
126
  Output the YAML fragment (with parent path), plus any bootstrap/ACL action the user still