@twinsearth/w2m-dsh-plugin 0.29.0 → 0.31.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/CHANGELOG.md CHANGED
@@ -4,6 +4,134 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.31.0] — 2026-10-15
8
+
9
+ **The approval gate got a caller, and a draft acceptance definition for 1.0.0.**
10
+
11
+ ### The defect this round started by finding, in the release that had just criticised it
12
+
13
+ `approval-gate.mjs` shipped in 0.30.0 with a full suite and **no caller** — which is exactly the shape of the
14
+ two defects 0.30.0 had just finished writing into `ROADMAP.md`. **Building a mechanism and wiring it are
15
+ separate acts**, and a mechanism with no caller cannot be told from a finished feature by reading either the
16
+ tests or the source.
17
+
18
+ It is now on the raise path, and the wiring is asserted rather than described: a real dispatch raises once,
19
+ the queue holds one request, a second raise is **refused by the front door before a request exists**, and the
20
+ queue still holds exactly one. Two more tests pin the asymmetry — reading the queue five times past any raise
21
+ budget, and settling the request — because a gate that also metered *those* would pass a flooding test and
22
+ quietly break the queue.
23
+
24
+ `approvalRaiseLimitPerMinute` is configurable so a host can set it and so a test can reach the refusal without
25
+ raising thirty requests first. Unset, the gate's own default governs.
26
+
27
+ ### `docs/1.0.0-ACCEPTANCE.md` — a draft for objection
28
+
29
+ The goal names **分模块 / 分层级 / 分权限**, and a version number means nothing without a definition that can
30
+ fail. The draft lists 21 criteria across the three, each with **where it is checked and whether that check is
31
+ actually running**, under one rule: **a criterion nothing measures is not a criterion.**
32
+
33
+ Its two honest admissions are the point of writing it:
34
+
35
+ * **§1.5 — reachability is NOT checked.** Three mechanisms shipped fully tested and completely uncalled in
36
+ this span. The `claims` suite catches *documentation* drift and **cannot** catch this, because the document
37
+ and the code can agree while neither is reachable.
38
+ * **§3.11 — the permission gate is OFF by default.** An unset `actorAuthorityLevel` means `enforced: false`
39
+ and no change, so **a default install dispatches exactly as it did before the permission model existed.**
40
+ Treating unset as `0` would break every machine that never opted in, so the current behaviour is defensible
41
+ — but **"off unless configured" cannot be part of a 1.0.0 that claims 分权限.** The draft puts the choice to
42
+ a reader rather than resolving it: **require a level and refuse to start without one, or ship with the gate
43
+ off and say so in the README.** Silence is the one option that is not defensible.
44
+
45
+ It also records what is **not** claimed: no scale statement (3 usable machines of 5), no restart durability for
46
+ the queue, no MacMini verification beyond the four suites measured in 0.28.0, and `P2P_PUNCH_FAILED` still
47
+ UNVERIFIED.
48
+
49
+ **2 new tests, 0 failures.**
50
+
51
+ ## [0.30.0] — 2026-10-15
52
+
53
+ **The same ruler on the other two entries: v4.9.0 had the same defect, v4.10.0 did not.**
54
+
55
+ After 0.29.0 found that v4.8.0's *"driven by the authority model"* was a phrase rather than a mechanism, the
56
+ next two ROADMAP sentences were checked the same way rather than assumed sound.
57
+
58
+ | Entry | Claim | Verdict |
59
+ |---|---|---|
60
+ | v4.9.0 | cross-session pending-approval index | **real**, substantial |
61
+ | v4.9.0 | **"with the already-shipped rate limiter as its front door"** | **never built** |
62
+ | v4.10.0 | ordered phases, a phase cannot be skipped | **real**, asserted by name |
63
+ | v4.10.0 | weights reproducible from stored inputs | **real**, plus tamper detection |
64
+ | v4.10.0 | dissent retrievable *with* the decision | **real**, and the dissent is inside the seal |
65
+
66
+ Measured for the missing half: **`approval-index.mjs` imports `authority.mjs` and nothing else** — no
67
+ limiter, no window, no cooldown anywhere in the file — while the shipped `SlidingWindowRateLimiter` sat in
68
+ `relay/server.mjs` with **no approval caller**. Two entries, one defect, and the third entry was genuine:
69
+ **the pattern was not assumed, it was tested.**
70
+
71
+ ### `src/agent/approval-gate.mjs`
72
+
73
+ **It limits raising and nothing else.** That asymmetry is the design:
74
+
75
+ * limiting the **view** would limit *knowing what is waiting*, which is the one thing the queue exists for;
76
+ * limiting **settling** would punish the supervisor clearing the backlog — a limit there is a limit on fixing
77
+ the problem;
78
+ * **raising** is the surface, because the threat is not brute force but **queue flooding**: an agent in a
79
+ retry loop can raise hundreds of requests and drown out the work that needs attention.
80
+
81
+ Two more decisions worth naming:
82
+
83
+ * **The key is an identity, not an address.** A fleet's agents frequently share a host, so an address-keyed
84
+ budget would apply one allowance to everybody on it — refusing the well-behaved because a neighbour
85
+ misbehaved. An anonymous caller gets a **shared named budget rather than none**, because *"no id, no key, no
86
+ limit"* would make forgetting to identify yourself the way to escape the limit.
87
+ * **The default is not the pairing limit.** `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE` is 5 because pairing is rare
88
+ and a wrong guess is an attack; raising approvals is ordinary work done in bursts. Inheriting a number built
89
+ for a different threat would have been the easy mistake.
90
+
91
+ It composes the shipped `SlidingWindowRateLimiter` rather than writing a second sliding window — the roadmap's
92
+ word was *"already-shipped"*, and two windows in one codebase are two chances to disagree about what a minute
93
+ is. A test asserts the composition.
94
+
95
+ The absence of `mayList` / `maySettle` / `mayDecide` is asserted **as an absence**, because their presence is
96
+ the defect and a test about flooding would never notice them.
97
+
98
+ ### One error of mine
99
+
100
+ I looked for the "shared anonymous budget" wording on the **refusal**; it is on the **allow** branch, which is
101
+ where a caller learns which budget they are spending from. The implementation was right and the assertion was
102
+ in the wrong half — seventh time in this project, and the same remedy worked: measuring instead of predicting.
103
+
104
+ ### The layer checker refused my first version, and it was right
105
+
106
+ Importing the limiter from `relay/server.mjs` made an **`agent` module depend on the `relay` layer**:
107
+
108
+ ```
109
+ relay -> util is not in ALLOWED_EDGES (undeclared) ← after the first fix attempt
110
+ agent -> relay ← the original refusal
111
+ ```
112
+
113
+ `agent` is rank 2 and `relay` is rank 3, so that edge **points up**. The tempting fix was to add
114
+ `['agent', 'relay']` to the allow-list, which would have **legalised an inversion**. The right fix was to
115
+ notice that `SlidingWindowRateLimiter` **never used anything from the relay** — it holds a `Map` and reads a
116
+ clock — so it belonged in `util` (rank 0, a leaf) all along.
117
+
118
+ The move cost nothing at runtime, and that is measured rather than assumed: **the relay's own construction
119
+ site already passed `limit` explicitly**, so the class's defaults had never been used. Its `limit` is now
120
+ **required** instead of defaulting to `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE` — keeping that default would mean
121
+ this leaf importing a relay constant, **the same inversion one level down**.
122
+
123
+ Two edges are declared, both downward: `['agent', 'util']` and `['relay', 'util']`. The layer model is now
124
+ `clean: every cross-layer edge is declared`.
125
+
126
+ ### Two more of my own errors
127
+
128
+ * `export { DEFAULT_WINDOW_MS as RATE_LIMIT_WINDOW_MS } from '...'` **re-exports without creating a local
129
+ binding**, so the relay's constructor threw `RATE_LIMIT_WINDOW_MS is not defined` while the module's public
130
+ surface looked perfectly correct. **106 tests failed at once**, which is how it was found.
131
+ * I looked for the "shared anonymous budget" wording on the refusal; it is on the allow branch.
132
+
133
+ **12 tests, 0 failures**, and 197 across the five affected suites.
134
+
7
135
  ## [0.29.0] — 2026-10-15
8
136
 
9
137
  **v4.8.0 was half shipped, and the ROADMAP said otherwise. The missing half is now built.**
package/ROADMAP.md CHANGED
@@ -81,14 +81,25 @@ chooser is ever asked about them.
81
81
  The lesson is recorded rather than tidied away: **a roadmap sentence is a claim, and this one was checkable
82
82
  in one grep.** It stood for several releases because nothing tested the sentence itself.
83
83
 
84
- ### v4.9.0 — approval queue and scheduling
84
+ ### v4.9.0 — approval queue and scheduling **shipped in 0.10.0 (index); the rate-limiter front door added in 0.30.0**
85
85
 
86
86
  **Deliverable.** A cross-session pending-approval index (§4.2's "supervisor reviews every agent's pending
87
87
  approvals from one queue"), with the already-shipped rate limiter as its front door. Scheduling stays
88
88
  out of scope here — MARL is not buildable at five machines, and pretending otherwise would be the most
89
89
  expensive kind of dishonesty in this roadmap.
90
90
 
91
- ### v4.10.0 — committee governance
91
+ **Correction, 0.30.0.** The index shipped and is substantial. **"With the already-shipped rate limiter as its
92
+ front door" did not**: measured, `approval-index.mjs` imports `authority.mjs` and nothing else, and the file
93
+ contains no limiter, window or cooldown — while the shipped `SlidingWindowRateLimiter` sat in
94
+ `relay/server.mjs` with no approval caller. The front door was a phrase, exactly as v4.8.0's authority half
95
+ was.
96
+
97
+ It now exists in `src/agent/approval-gate.mjs`, and **it limits raising and nothing else**. That asymmetry is
98
+ the design, not an omission: limiting the *view* would limit knowing what is waiting, and limiting *settling*
99
+ would punish the supervisor clearing the backlog. The key is the requester's identity rather than an address,
100
+ because a fleet's agents often share a host and an address-keyed budget would blame the wrong machine.
101
+
102
+ ### v4.10.0 — committee governance **shipped in 0.11.0, and verified against this entry in 0.30.0**
92
103
 
93
104
  **Deliverable.** The AURA six-role council as a *single* decision engine with the seven phases, weighted
94
105
  voting whose weights come from v4.7.0's authority plus reputation, and **dissent retention** — the losing
@@ -98,6 +109,16 @@ a decision auditable later.
98
109
  **Falsifying tests.** a phase cannot be skipped; a vote's weights are reproducible from stored inputs; a
99
110
  decision with recorded dissent is retrievable *with* the dissent.
100
111
 
112
+ **Verification, 0.30.0.** All three are asserted in `test/council.test.mjs`, by name: *"starts in convene and
113
+ moves exactly one phase per advance"* and *"exposes no way to name a destination phase"*; *"reproduces a
114
+ recorded outcome from the stored ballots alone"*, *"is a pure function: same inputs, same weight, no hidden
115
+ state"* and *"detects a tampered weight instead of reproducing it"*; *"retains the losing arguments alongside
116
+ the outcome"* and *"keeps the dissent in the value the seal covers, so it cannot be dropped later"*.
117
+
118
+ **This entry is the reason the two above it were checked.** v4.8.0 and v4.9.0 each described something that
119
+ was not built; this one described something that was. Rather than assume the pattern, each sentence was
120
+ treated as a claim and tested — which is the practice the two corrections exist to record.
121
+
101
122
  ### v4.11.0–v4.13.0 — scale, defence, integration
102
123
 
103
124
  Named for completeness, not planned in detail, because planning them now would produce fiction. The
@@ -0,0 +1,109 @@
1
+ # What 1.0.0 would have to prove — a draft for objection
2
+
3
+ **Status: DRAFT, and deliberately incomplete.** Nothing here is an announcement that 1.0.0 is close. This file
4
+ exists because the goal names three properties — **分模块 / 分层级 / 分权限** — and a version number means
5
+ nothing without a definition that can fail. It is written to be argued with, and every row says **where the
6
+ claim is checked and whether that check is actually running**.
7
+
8
+ The rule applied throughout: **a criterion that nothing measures is not a criterion.** Where a check exists
9
+ but has no caller, that is stated in its own column rather than hidden behind a tick.
10
+
11
+ ---
12
+
13
+ ## 1. 分模块 — modules with a declared identity
14
+
15
+ | # | Criterion | Checked by | Enforced? |
16
+ |---|---|---|---|
17
+ | 1.1 | Every source module belongs to exactly one declared layer | `scripts/layer-check.mjs` | **yes** — `clean: every cross-layer edge is declared` |
18
+ | 1.2 | Every dependency edge is declared, and every declared edge is used | `scripts/layer-check.mjs` | **yes** |
19
+ | 1.3 | Every suite on disk is named by a script | pre-flight `test wiring`, `claims` | **yes** — reported, not silently ignored |
20
+ | 1.4 | Every document's claim about the artifact matches the artifact | `test/claims.test.mjs` | **yes** — 48 claims, and it has caught real drift |
21
+ | 1.5 | No module is unreachable — every declared thing has a caller | *nothing* | **NO — see §4** |
22
+
23
+ `1.5` is the row that matters most and is the one **not** satisfied. This span shipped three mechanisms that
24
+ were, for a round each, fully tested and completely uncalled: `field-authority.mjs`, `approval-gate.mjs`, and
25
+ before them `createApprovalIndex`. **A tested mechanism with no caller is indistinguishable from a finished
26
+ feature by reading either the tests or the source.** `1.4` catches *documentation* drift and cannot catch this,
27
+ because the document and the code can agree while neither is reachable.
28
+
29
+ ---
30
+
31
+ ## 2. 分层级 — layers, with a direction
32
+
33
+ | # | Criterion | Checked by | Enforced? |
34
+ |---|---|---|---|
35
+ | 2.1 | Ranked layer model, declared as data | `scripts/layer-model.mjs` | **yes** |
36
+ | 2.2 | An allow-list of edges, not a denylist | `layer-model.mjs` | **yes** — a new edge is refused and forces a decision |
37
+ | 2.3 | No upward edge | `layer-check.mjs` | **yes** — it refused `agent → relay` in 0.30.0 |
38
+ | 2.4 | The layer a module sits in matches what it uses | `layer-check.mjs` | **yes** — it is how `SlidingWindowRateLimiter` was found mis-layered |
39
+
40
+ **`2.3` and `2.4` have each caught a real defect in this span**, which is the only evidence that a check is
41
+ worth its place. `2.3` fired when the approval gate reached for a relay module; the fix was to move the shared
42
+ limiter down to `util`, **not** to add the edge.
43
+
44
+ ---
45
+
46
+ ## 3. 分权限 — declared capabilities, and something that consults them
47
+
48
+ | # | Criterion | Checked by | Enforced? |
49
+ |---|---|---|---|
50
+ | 3.1 | Capabilities declared with a cost and a destructiveness flag | `authority.mjs` (`CAPABILITIES`) | **yes** |
51
+ | 3.2 | Every declared capability is reachable by some band | `auditCapabilities()`, asserted | **yes** |
52
+ | 3.3 | A decision is reached from a real dispatch's own facts | `gateDispatch`, `capabilityForDispatch` | **yes** |
53
+ | 3.4 | The dispatch path consults it | `tools.mjs`, asserted end to end | **yes** — since 0.23.0 |
54
+ | 3.5 | An undeclared capability is refused, not granted or escalated | `permission.test.mjs` | **yes** |
55
+ | 3.6 | Escalation is exactly one band | asserted across the whole table | **yes** |
56
+ | 3.7 | Nothing destructive auto-settles, at any level | `mayAutoSettle`, asserted | **yes** |
57
+ | 3.8 | An escalated dispatch reaches a queue an approver can answer | `w2m_approvals`, asserted end to end | **yes** |
58
+ | 3.9 | The queue's front door meters **raising only** | `approval-gate.mjs`, wired in 0.31.0 | **yes** |
59
+ | 3.10 | Field-level memory acceptance is driven by the same model | `field-authority.mjs` | **mechanism yes, caller NO — see §4** |
60
+ | 3.11 | **The gate is ON by default** | *nothing — it is off* | **NO — see below** |
61
+
62
+ ### `3.11` is the load-bearing gap, and it is a decision rather than an oversight
63
+
64
+ `actorAuthorityLevel` is **unset by default**, and an unset level means the gate reports `enforced: false` and
65
+ changes nothing. So today, **a default install dispatches exactly as it did before the permission model
66
+ existed.** The reasoning is in `permission.mjs` and it is sound as far as it goes: treating an unset level as
67
+ `0` would refuse every writing dispatch on every machine that never opted in, breaking a working fleet to
68
+ enforce a policy nobody asked for.
69
+
70
+ **But "off unless configured" cannot be part of a 1.0.0 that claims 分权限**, because a permission system that
71
+ is off is a permission system that is untested in production. The question this file puts to a reader is
72
+ therefore concrete:
73
+
74
+ > **Should 1.0.0 require `actorAuthorityLevel` to be set, refusing to start without one — or should it ship
75
+ > with the gate off and say so in the README?**
76
+
77
+ Both are defensible. Silence is not, and neither is claiming 分权限 while it is off.
78
+
79
+ ---
80
+
81
+ ## 4. What is explicitly NOT claimed
82
+
83
+ Recorded here so that absence is a decision rather than an omission:
84
+
85
+ | Not claimed | Why |
86
+ |---|---|
87
+ | **Reachability of every module** | nothing measures it (§1.5). Three uncalled mechanisms were shipped in this span. |
88
+ | **The gate is active** | `actorAuthorityLevel` is unset by default (§3.11). |
89
+ | **Field authority reaches the memory layer** | `field-authority.mjs` has no production caller; `w2m_memory` does not use it. |
90
+ | **The queue survives a restart** | it is per-plugin-lifetime by design, and is **not** an audit log. |
91
+ | **Any scale claim** | this fleet is 3 usable machines of 5 paired. `ROADMAP.md` refuses to plan v4.11.0+ for exactly this reason, and `fleet-scale.mjs` refuses to extrapolate from five. |
92
+ | **MacMini verification of everything** | four suites were verified on that machine in 0.28.0; the other 69 were not. |
93
+ | **`P2P_PUNCH_FAILED`** | cause UNVERIFIED; three hypotheses measured and excluded. |
94
+
95
+ ---
96
+
97
+ ## 5. What would make this file finished
98
+
99
+ 1. **A reachability check** — every exported function has a non-test caller, or is declared as
100
+ interface-only with a reason. Without it, §1.5 stays open and the class of defect that produced three
101
+ dead mechanisms stays invisible.
102
+ 2. **A decision on §3.11**, whichever way it goes, reflected in `README.md` and in the pre-flight.
103
+ 3. **Every row above promoted to a check that runs**, so this document becomes a description of the
104
+ pre-flight rather than a parallel list that can drift from it. **A document that restates what the
105
+ pre-flight does is a second source of truth, and this project has already paid for one of those.**
106
+
107
+ This draft deliberately stops short of those three, because each is a decision about what 1.0.0 must be
108
+ rather than a defect to fix, and the person who owns it has not answered the question this session asked in
109
+ round 12: **write the acceptance definition, or keep advancing the roadmap?**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
117
117
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
118
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -147,7 +147,8 @@
147
147
  "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
148
148
  "test:permission": "node --test --test-force-exit test/permission.test.mjs",
149
149
  "test:packet": "node --test --test-force-exit test/machine-test-packet.test.mjs",
150
- "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs"
150
+ "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
151
+ "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs"
151
152
  },
152
153
  "keywords": [
153
154
  "dsh-bundle",
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The approval queue's front door — the rate limiter v4.9.0 promised and did not have.
3
+ *
4
+ * ## What was missing
5
+ *
6
+ * `ROADMAP.md` describes v4.9.0 as *"a cross-session pending-approval index …, **with the already-shipped
7
+ * rate limiter as its front door**"*. The index is real and substantial. Measured: **`approval-index.mjs`
8
+ * imports `authority.mjs` and nothing else** — the file contains no limiter, no window, no cooldown — while
9
+ * the shipped `SlidingWindowRateLimiter` sat in `relay/server.mjs` with no approval caller. The front door
10
+ * was a phrase rather than a mechanism, the same defect as v4.8.0's missing authority half.
11
+ *
12
+ * The limiter now lives in `src/util/rate-limit.mjs`. Reaching for it where it was written would have made
13
+ * this `agent` module depend on the `relay` layer -- an upward edge the layer model refuses, correctly -- so
14
+ * the shared piece moved down to `util` instead of the edge being legalised.
15
+ *
16
+ * ## Why the limit is on raising and not on reading or answering
17
+ *
18
+ * The threat is not brute force; it is **queue flooding**. A misbehaving agent in a retry loop can raise
19
+ * hundreds of requests, and the queue's whole purpose is that the work needing attention is visible. So the
20
+ * surface to protect is `register`.
21
+ *
22
+ * Deliberately **not** limited:
23
+ *
24
+ * * **reading the queue** — limiting the *view* limits *knowing what is waiting*, which is the one thing
25
+ * the queue exists to provide;
26
+ * * **settling a request** — a supervisor working through a backlog should be able to clear it, and a
27
+ * limit there would punish the person fixing the problem.
28
+ *
29
+ * ## Why the key is an identity and not an address
30
+ *
31
+ * A fleet's agents are frequently on one host, so an address-keyed limit would apply one budget to everybody
32
+ * on it — refusing the well-behaved because a neighbour misbehaved, which is the wrong machine to blame. The
33
+ * key is therefore the requester's identity, and an anonymous requester gets its own shared budget rather
34
+ * than none: **an unidentifiable caller must not be the one caller with no limit.**
35
+ *
36
+ * ## Why the default is not the pairing limit
37
+ *
38
+ * `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE` is **5**: pairing is rare and a wrong guess is an attack. Raising
39
+ * approvals is ordinary work, done in bursts when a machine comes back from being offline. The default here
40
+ * is higher, and the number is stated as a decision rather than inherited from a limiter built for something
41
+ * else.
42
+ */
43
+ // `DEFAULT_WINDOW_MS` is deliberately NOT imported: the limiter applies its own default window when the
44
+ // caller does not name one, and importing it here only to pass it straight back through would be a second
45
+ // place for the same number to live. eslint's no-unused-vars is what surfaced the redundancy.
46
+ import { SlidingWindowRateLimiter } from '../util/rate-limit.mjs';
47
+
48
+ /** Raising approvals is ordinary work; pairing is not. The two do not share a number. */
49
+ export const DEFAULT_RAISE_LIMIT_PER_MINUTE = 30;
50
+
51
+ /** The key used for a caller that did not say who it is. Named so it cannot collide with a real identity. */
52
+ export const ANONYMOUS_KEY = 'anonymous';
53
+
54
+ /** Why a raise was refused. */
55
+ export const GATE_REFUSAL = Object.freeze({
56
+ RATE_LIMITED: 'APPROVAL_RAISE_RATE_LIMITED',
57
+ });
58
+
59
+ /**
60
+ * The front door to an approval index.
61
+ *
62
+ * Composes the shipped limiter rather than reimplementing a window — the promise in the roadmap was that the
63
+ * *already-shipped* limiter would be this, and two sliding windows in one codebase is two chances to disagree
64
+ * about what a minute is.
65
+ *
66
+ * @param {{limit?: number, windowMs?: number, now?: () => number}} [options]
67
+ */
68
+ export function createApprovalGate(options = {}) {
69
+ const limiter = new SlidingWindowRateLimiter({
70
+ limit: Number.isFinite(options.limit) ? options.limit : DEFAULT_RAISE_LIMIT_PER_MINUTE,
71
+ ...(Number.isFinite(options.windowMs) ? { windowMs: options.windowMs } : {}),
72
+ ...(typeof options.now === 'function' ? { now: options.now } : {}),
73
+ });
74
+
75
+ /**
76
+ * Whether one more request may be raised by this requester.
77
+ *
78
+ * @param {{id?: string|null, level?: number|null}} requester
79
+ * @returns {{allowed: boolean, reason_code: string|null, retry_after_seconds: number, detail: string}}
80
+ */
81
+ function mayRaise(requester) {
82
+ const id = typeof requester?.id === 'string' && requester.id.trim() !== '' ? requester.id.trim() : ANONYMOUS_KEY;
83
+ const verdict = limiter.hit(id);
84
+ if (verdict.allowed) {
85
+ return {
86
+ allowed: true,
87
+ reason_code: null,
88
+ retry_after_seconds: 0,
89
+ detail: id === ANONYMOUS_KEY
90
+ ? `raised under the shared anonymous budget (${verdict.remaining} left in this window)`
91
+ : `${id} may raise (${verdict.remaining} left in this window)`,
92
+ };
93
+ }
94
+ return {
95
+ allowed: false,
96
+ reason_code: GATE_REFUSAL.RATE_LIMITED,
97
+ retry_after_seconds: verdict.retry_after_seconds,
98
+ detail:
99
+ `${id} has raised ${verdict.limit} request(s) in the last ${limiter.windowSeconds}s; ` +
100
+ `wait ${verdict.retry_after_seconds}s. The queue is not cleared -- existing requests are untouched and still answerable.`,
101
+ };
102
+ }
103
+
104
+ /** How much of this requester's budget is left, without spending any of it. */
105
+ function budgetFor(requester) {
106
+ const id = typeof requester?.id === 'string' && requester.id.trim() !== '' ? requester.id.trim() : ANONYMOUS_KEY;
107
+ const hits = limiter.buckets.get(id) ?? [];
108
+ return { key: id, limit: limiter.limit, window_seconds: limiter.windowSeconds, used: hits.length, remaining: Math.max(0, limiter.limit - hits.length) };
109
+ }
110
+
111
+ return { mayRaise, budgetFor, limiter, raiseLimitPerMinute: limiter.limit, windowSeconds: limiter.windowSeconds };
112
+ }
@@ -129,6 +129,8 @@ import { gateDispatch, approvalRequestFor, raiseApproval } from '../agent/permis
129
129
  // v0.24.0: the queue an escalation is raised into. Imported from `src/`, never from `scripts/` -- a
130
130
  // `src/` module reaching above itself breaks the published package (see CHANGELOG 0.23.0).
131
131
  import { createApprovalIndex } from '../agent/approval-index.mjs';
132
+ // v0.31.0: the approval queue's front door. Written in 0.30.0 and given a caller here.
133
+ import { createApprovalGate } from '../agent/approval-gate.mjs';
132
134
  import { AUTHORITY_MAX, isAuthorityLevel } from '../agent/authority.mjs';
133
135
  // v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
134
136
  // rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
@@ -193,7 +195,7 @@ const MAX_STAGES = 16;
193
195
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
194
196
  * overrides it at runtime for tests and packagers.
195
197
  */
196
- const PLUGIN_VERSION = '0.29.0';
198
+ const PLUGIN_VERSION = '0.31.0';
197
199
 
198
200
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
199
201
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3246,6 +3248,28 @@ export async function apply(ctx, config = {}) {
3246
3248
  return approvals;
3247
3249
  };
3248
3250
 
3251
+ /**
3252
+ * v0.31.0: the rate limiter that is the approval queue's front door.
3253
+ *
3254
+ * ## Why this took a round to arrive
3255
+ *
3256
+ * `approval-gate.mjs` was written in 0.30.0 with a full suite and **no caller** — which is precisely the
3257
+ * defect that same release had just finished documenting in `ROADMAP.md` twice. Building the mechanism and
3258
+ * wiring it are separate acts, and a mechanism with no caller is the thing a reader cannot tell from a
3259
+ * finished feature.
3260
+ *
3261
+ * It guards **raising only**. Reading the queue and settling a request are deliberately unmetered: limiting
3262
+ * the view would limit knowing what is waiting, and limiting the answer would punish the supervisor
3263
+ * clearing the backlog. The threat is queue flooding by a runaway retry loop, and that is a threat against
3264
+ * the raise path.
3265
+ */
3266
+ const approvalGate = createApprovalGate(
3267
+ // Configurable so a host can tighten or loosen it, and so a test can reach the refusal without raising
3268
+ // thirty requests first. Omitted entirely when unset, which leaves the gate's own default in charge --
3269
+ // passing `undefined` through would be the same thing with more ways to be wrong.
3270
+ Number.isInteger(config.approvalRaiseLimitPerMinute) ? { limit: config.approvalRaiseLimitPerMinute } : {},
3271
+ );
3272
+
3249
3273
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3250
3274
  // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
3251
3275
  // composition and does not need a warning on every headless run.
@@ -3778,6 +3802,24 @@ export async function apply(ctx, config = {}) {
3778
3802
  machineId: null,
3779
3803
  requestedBy: cfg.machineName,
3780
3804
  });
3805
+ // v0.31.0: the raise path passes the front door BEFORE a request exists. A flood of raises is
3806
+ // what the limiter is for, so a refused raise must not have already created the request it was
3807
+ // refused for.
3808
+ const permission = approvalGate.mayRaise({ id: cfg.machineName, level: cfg.actorAuthorityLevel });
3809
+ if (!permission.allowed) {
3810
+ throw new W2MError(
3811
+ 'W2M_PERMISSION_APPROVAL_RATE_LIMITED',
3812
+ 'W2M_PERMISSION_APPROVAL_RATE_LIMITED: ' + permission.detail,
3813
+ {
3814
+ hint: 'wait for the window to pass and dispatch again; the queue was not cleared and existing requests are still answerable',
3815
+ detail: {
3816
+ reason_code: permission.reason_code,
3817
+ retry_after_seconds: permission.retry_after_seconds,
3818
+ budget: approvalGate.budgetFor({ id: cfg.machineName, level: cfg.actorAuthorityLevel }),
3819
+ },
3820
+ },
3821
+ );
3822
+ }
3781
3823
  const approvalId = randomUUID();
3782
3824
  const raised = raiseApproval(approvalQueue(), request, { approvalId, toolName: 'w2m_run' });
3783
3825
  if (!raised.ok) {
@@ -38,7 +38,12 @@ import {
38
38
  export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
39
39
  export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
40
40
  export const DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE = 5; // v0.1.2 §6
41
- export const RATE_LIMIT_WINDOW_MS = 60_000;
41
+ // The window length is a unit and lives with the limiter. Both names are kept: `DEFAULT_WINDOW_MS` is the
42
+ // local binding this module uses, and `RATE_LIMIT_WINDOW_MS` is the name this module has always exported.
43
+ // `export ... from` would NOT have worked here -- it re-exports without creating a local binding, so the
44
+ // constructor below saw `RATE_LIMIT_WINDOW_MS is not defined` while the export itself looked fine.
45
+ import { DEFAULT_WINDOW_MS, SlidingWindowRateLimiter } from '../util/rate-limit.mjs';
46
+ export { DEFAULT_WINDOW_MS as RATE_LIMIT_WINDOW_MS };
42
47
  export const DEFAULT_STATE_DIR = path.join(os.homedir(), '.w2m');
43
48
 
44
49
  /**
@@ -278,62 +283,18 @@ function readSecretValue(value, label) {
278
283
  * Per-IP sliding window. Successful pairs count too, otherwise a working pairing
279
284
  * code could be used to flush the failure budget.
280
285
  */
281
- export class SlidingWindowRateLimiter {
282
- constructor({ limit = DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE, windowMs = RATE_LIMIT_WINDOW_MS, now = Date.now } = {}) {
283
- this.limit = limit;
284
- this.windowMs = windowMs;
285
- this._now = now;
286
- /** @type {Map<string, number[]>} */
287
- this.buckets = new Map();
288
- }
289
-
290
- get windowSeconds() { return Math.round(this.windowMs / 1000); }
291
-
292
- /** Records one attempt and reports whether it is allowed. */
293
- hit(key) {
294
- if (this.limit <= 0) {
295
- return { allowed: true, retry_after_seconds: 0, limit: 0, window_seconds: this.windowSeconds, remaining: Infinity };
296
- }
297
- const now = this._now();
298
- const cutoff = now - this.windowMs;
299
- let hits = this.buckets.get(key);
300
- if (!hits) {
301
- hits = [];
302
- this.buckets.set(key, hits);
303
- }
304
- while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
305
-
306
- if (hits.length >= this.limit) {
307
- const retryMs = hits[0] + this.windowMs - now;
308
- return {
309
- allowed: false,
310
- retry_after_seconds: Math.max(1, Math.ceil(retryMs / 1000)),
311
- limit: this.limit,
312
- window_seconds: this.windowSeconds,
313
- remaining: 0,
314
- };
315
- }
316
- hits.push(now);
317
- return {
318
- allowed: true,
319
- retry_after_seconds: 0,
320
- limit: this.limit,
321
- window_seconds: this.windowSeconds,
322
- remaining: this.limit - hits.length,
323
- };
324
- }
325
-
326
- /** Drop idle buckets so a long-running relay cannot leak memory. */
327
- prune() {
328
- const cutoff = this._now() - this.windowMs;
329
- for (const [key, hits] of this.buckets) {
330
- while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
331
- if (hits.length === 0) this.buckets.delete(key);
332
- }
333
- }
334
-
335
- reset() { this.buckets.clear(); }
336
- }
286
+ /*
287
+ * The limiter MOVED to `src/util/rate-limit.mjs` in v0.30.0.
288
+ *
289
+ * It never used anything from this file, and when the approval gate needed the same window, importing it
290
+ * from here made an `agent` module depend on the `relay` layer -- which `scripts/layer-model.mjs` refused,
291
+ * correctly. `agent` is rank 2 and `relay` is rank 3, so the edge points up; moving the class to `util`
292
+ * (rank 0, a leaf) lets both depend on it in the only direction the model allows.
293
+ *
294
+ * Re-exported rather than merely imported, because `SlidingWindowRateLimiter` and `RATE_LIMIT_WINDOW_MS` are
295
+ * part of this module's published surface and `test/relay.test.mjs` imports the class from here.
296
+ */
297
+ export { SlidingWindowRateLimiter };
337
298
 
338
299
  /* ------------------------------------------------------------------ */
339
300
  /* server */
@@ -420,7 +381,7 @@ export class RelayServer {
420
381
  /* ---- /v1/pair rate limit (§6) ---- */
421
382
  this.pairLimiter = new SlidingWindowRateLimiter({
422
383
  limit: this.pairRateLimitPerMinute,
423
- windowMs: RATE_LIMIT_WINDOW_MS,
384
+ windowMs: DEFAULT_WINDOW_MS,
424
385
  now: () => this.state.nowMs(),
425
386
  });
426
387
 
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A per-key sliding-window rate limiter, shared by the relay and the agent.
3
+ *
4
+ * ## Why this moved here
5
+ *
6
+ * It was written inside `src/relay/server.mjs` for pairing, and it does not use anything from the relay — it
7
+ * holds a `Map` and reads a clock. When the approval gate needed the same window, importing it from `relay`
8
+ * made an **`agent` module depend on the `relay` layer**, which `scripts/layer-model.mjs` refused:
9
+ *
10
+ * ALLOWED_EDGES: plugin→agent, plugin→util, plugin→relay, relay→chatops, agent→shared, relay→shared
11
+ *
12
+ * The checker was right, and the interesting part is what the right fix turned out to be. Adding
13
+ * `['agent', 'relay']` would have legalised an inversion — `agent` is rank 2 and `relay` is rank 3, so that
14
+ * edge points *up*. Moving the class down to `util` (rank 0, a leaf) lets both layers depend on it in the
15
+ * only direction the model allows, and **cost nothing at runtime**: measured, the relay's own construction
16
+ * site already passed `limit` explicitly, so the defaults here had never been used.
17
+ *
18
+ * ## Why the limit has no default
19
+ *
20
+ * It used to default to `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE`. Keeping that would mean this leaf importing a
21
+ * relay constant, which is the same inversion one level down. It is now required, and the two callers state
22
+ * their own numbers — which is the honest arrangement, because pairing and raising approvals are limited for
23
+ * **different reasons** and should not share a figure by inheritance.
24
+ */
25
+
26
+ /** One minute, the window both current callers use. Named here because it is a unit, not a policy. */
27
+ export const DEFAULT_WINDOW_MS = 60_000;
28
+
29
+ /**
30
+ * Per-key sliding window.
31
+ *
32
+ * A key is whatever the caller decides identifies a budget — an address, an account, a machine. This class
33
+ * does not care which, deliberately: an address-keyed limit and an identity-keyed limit are different
34
+ * policies, and a limiter that assumed one would make the other awkward.
35
+ */
36
+ export class SlidingWindowRateLimiter {
37
+ /**
38
+ * @param {{limit: number, windowMs?: number, now?: () => number}} options
39
+ * `limit` is required. `limit <= 0` disables the limiter, which is a supported configuration and is why
40
+ * the check is `<= 0` rather than a truthiness test.
41
+ */
42
+ constructor({ limit, windowMs = DEFAULT_WINDOW_MS, now = Date.now } = {}) {
43
+ if (!Number.isFinite(limit)) {
44
+ // Refused rather than defaulted. A limiter that silently picks its own figure is a policy nobody wrote,
45
+ // and the two callers want opposite orders of magnitude (5 for pairing, 30 for approvals).
46
+ throw new TypeError('SlidingWindowRateLimiter needs a numeric `limit`; the two callers want different ones and neither should be inherited');
47
+ }
48
+ this.limit = limit;
49
+ this.windowMs = windowMs;
50
+ this._now = now;
51
+ /** @type {Map<string, number[]>} */
52
+ this.buckets = new Map();
53
+ }
54
+
55
+ get windowSeconds() {
56
+ return Math.round(this.windowMs / 1000);
57
+ }
58
+
59
+ /** Records one attempt and reports whether it is allowed. */
60
+ hit(key) {
61
+ if (this.limit <= 0) {
62
+ return { allowed: true, retry_after_seconds: 0, limit: 0, window_seconds: this.windowSeconds, remaining: Infinity };
63
+ }
64
+ const now = this._now();
65
+ const cutoff = now - this.windowMs;
66
+ let hits = this.buckets.get(key);
67
+ if (!hits) {
68
+ hits = [];
69
+ this.buckets.set(key, hits);
70
+ }
71
+ while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
72
+
73
+ if (hits.length >= this.limit) {
74
+ const retryMs = hits[0] + this.windowMs - now;
75
+ return {
76
+ allowed: false,
77
+ retry_after_seconds: Math.max(1, Math.ceil(retryMs / 1000)),
78
+ limit: this.limit,
79
+ window_seconds: this.windowSeconds,
80
+ remaining: 0,
81
+ };
82
+ }
83
+ hits.push(now);
84
+ return {
85
+ allowed: true,
86
+ retry_after_seconds: 0,
87
+ limit: this.limit,
88
+ window_seconds: this.windowSeconds,
89
+ remaining: this.limit - hits.length,
90
+ };
91
+ }
92
+
93
+ /** Drop idle buckets so a long-running process cannot leak memory. */
94
+ prune() {
95
+ const cutoff = this._now() - this.windowMs;
96
+ for (const [key, hits] of this.buckets) {
97
+ while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
98
+ if (hits.length === 0) this.buckets.delete(key);
99
+ }
100
+ }
101
+
102
+ reset() {
103
+ this.buckets.clear();
104
+ }
105
+ }