Skip to content

Commit 0382193

Browse files
committed
v2.4.1
1 parent 681a830 commit 0382193

29 files changed

Lines changed: 1909 additions & 1427 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
node: ["24"]
18+
node: ["24", "26"]
1919
steps:
2020
- uses: actions/checkout@v4
2121

22+
# Version comes from the root package.json `packageManager` field.
2223
- name: Set up pnpm
2324
uses: pnpm/action-setup@v4
2425
with:
25-
version: 9
2626
run_install: false
2727

2828
- name: Set up Node.js
@@ -56,7 +56,6 @@ jobs:
5656
- name: Set up pnpm
5757
uses: pnpm/action-setup@v4
5858
with:
59-
version: 9
6059
run_install: false
6160

6261
- uses: actions/setup-node@v4

docs/access-control.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ ACL restricts which callers can invoke which methods on which topics. It is eval
77
```ts
88
interface AccessControlConfig {
99
rules?: AccessRule[]
10-
allowAllByDefault?: boolean // default: true (when no rules match)
10+
// Default: true with no rules, FALSE once any rule exists (secure default).
11+
// Set explicitly to opt out.
12+
allowAllByDefault?: boolean
1113
logDenied?: boolean
1214
jwtVerifier?: (token: string) => Promise<VerifiedClaims | null>
1315
}
@@ -37,7 +39,8 @@ export class UserController { ... }
3739

3840
Semantics:
3941

40-
- If `rules` is empty, `allowAllByDefault` (default `true`) decides
42+
- **Deny by default once rules exist.** When at least one rule is configured and `allowAllByDefault` is not set, any `(topic, method)` matched by no rule is **denied**. Set `allowAllByDefault: true` to restore the permissive behaviour. With no rules at all, everything is allowed.
43+
- Built-in `nevo.*` methods (contract, health probes) stay reachable under the deny-default; an explicit matching `deny` rule still blocks them.
4144
- Multiple rules can match a given `(topic, method)``deny` always wins over `allow`
4245
- Wildcards `"*"` match any value
4346

docs/basics-nats.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,18 @@ createNatsMicroservice({
104104

105105
## Subjects & wildcards
106106

107-
Subjects use dot-separated names (`user.created`, `orders.paid.eu-west`). The `@Signal` value is sent verbatim as the subject. NATS wildcards `*` (one token) and `>` (rest) are supported in subscription patterns:
107+
Pub/sub messages travel on method-scoped subjects: `publish("user", "user.updated", …)` goes to `user-events.sub.user.updated`, and `subscribe("user", "user.updated", …)` subscribes to exactly that subject — NATS filters server-side, so subscribers no longer receive (and discard) the whole service stream. The `@vN` version suffix is stripped from the subject; a subscriber of the base method receives every version.
108+
109+
NATS wildcards `*` (one token) and `>` (rest) work natively in the method position, and an empty method subscribes to everything the service publishes:
108110

109111
```ts
110-
await this.subscribe("user", "user.*", {}, (msg) => { ... })
111-
await this.subscribeWildcard("orders.>", (msg, ctx) => { ... })
112+
await this.subscribe("user", "user.*", {}, (msg) => { ... }) // user.created, user.updated, …
113+
await this.subscribe("user", "", {}, (msg) => { ... }) // every published method
114+
await this.subscribeWildcard("orders.>", (msg, ctx) => { ... }) // raw subject pattern
112115
```
113116

117+
> **Upgrading from ≤2.4:** publishers and subscribers must run the same version — older peers used the bare `<service>-events.sub` subject. JetStream streams capturing pub/sub traffic need their subject filter widened to `<service>-events.sub.>`.
118+
114119
## JetStream-backed `ack: true`
115120

116121
When you subscribe with `{ ack: true }` Nevo upgrades the subscription to a durable JetStream consumer with manual ack. Use this for at-least-once semantics:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@riaskov/nevo-messaging",
3-
"version": "2.4.0",
3+
"version": "2.4.1",
44
"description": "Microservices messaging framework for NestJS with NATS/Kafka/SocketIO/HTTP transport, MessagePack, retry/circuit-breaker, OTel, schema validation, DLQ, idempotency, saga, outbox, metrics, health, DevTools",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/common/access-control.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,25 @@ function compile(config: AccessControlConfig): CompiledAcl {
106106
const cached = COMPILED.get(config)
107107
if (cached) return cached
108108

109+
// Secure default: once any rule exists, unmatched methods are DENIED unless
110+
// allowAllByDefault is explicitly true. A config with no rules stays open.
111+
const hasRules = (config.rules?.length ?? 0) > 0
112+
const allowAllByDefault = config.allowAllByDefault ?? !hasRules
113+
if (hasRules && config.allowAllByDefault === undefined) {
114+
try {
115+
getDefaultLogger().warn(
116+
{ event: "acl.implicit_default_deny" },
117+
"[NevoMessaging][ACL] rules are configured without allowAllByDefault; methods not matched by any rule are DENIED. Set allowAllByDefault: true to opt out."
118+
)
119+
} catch {}
120+
}
121+
109122
const compiled: CompiledAcl = {
110123
globalDefault: [],
111124
byTopic: new Map(),
112125
byMethod: new Map(),
113126
byTopicMethod: new Map(),
114-
allowAllByDefault: config.allowAllByDefault !== false,
127+
allowAllByDefault,
115128
logDenied: config.logDenied !== false,
116129
jwtVerifier: config.jwtVerifier
117130
}
@@ -171,7 +184,9 @@ export function isAccessAllowed(config: AccessControlConfig | undefined, topic:
171184
if (mRules) candidates.push(...mRules)
172185
if (compiled.globalDefault.length) candidates.push(...compiled.globalDefault)
173186

174-
if (candidates.length === 0) return compiled.allowAllByDefault
187+
// Built-in introspection methods (contract, health) stay reachable under the
188+
// deny-default; an explicit matching rule still overrides this.
189+
if (candidates.length === 0) return compiled.allowAllByDefault || method.startsWith("nevo.")
175190

176191
// Deny is authoritative: a matching deny in any candidate rule wins over any allow.
177192
for (const rule of candidates) {

src/common/base.client.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,21 @@ export abstract class BaseMessagingClient {
186186
return this.codec.decode<T>(data)
187187
}
188188

189+
private readonly inflightCounts = new Map<string, number>()
190+
191+
private bumpInflight(key: string, labels: Record<string, string>, delta: number): void {
192+
const next = Math.max(0, (this.inflightCounts.get(key) ?? 0) + delta)
193+
if (next === 0) this.inflightCounts.delete(key)
194+
else this.inflightCounts.set(key, next)
195+
this.metrics.setGauge(NEVO_METRIC_NAMES.inflight, labels, next)
196+
}
197+
189198
protected async withClientPipeline<T>(serviceName: string, method: string, fn: () => Promise<T>, resilience?: CompiledResilience): Promise<T> {
190199
const key = `${serviceName}:${method}`
191200
// Version-stripped label so `foo@v1`/`foo@v2` don't split into separate series.
192201
const methodName = methodLabel(method)
193-
this.metrics.setGauge(NEVO_METRIC_NAMES.inflight, { service: serviceName, method: methodName }, 1)
202+
const labels = { service: serviceName, method: methodName }
203+
this.bumpInflight(key, labels, 1)
194204
try {
195205
return await runClientPipeline<T>(
196206
this.circuitBreaker,
@@ -203,7 +213,7 @@ export abstract class BaseMessagingClient {
203213
resilience
204214
)
205215
} finally {
206-
this.metrics.setGauge(NEVO_METRIC_NAMES.inflight, { service: serviceName, method: methodName }, 0)
216+
this.bumpInflight(key, labels, -1)
207217
}
208218
}
209219

0 commit comments

Comments
 (0)