@twinsearth/w2m-dsh-plugin 0.28.0 → 0.30.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 +146 -0
- package/ROADMAP.md +35 -3
- package/package.json +6 -4
- package/src/agent/approval-gate.mjs +112 -0
- package/src/agent/field-authority.mjs +202 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +19 -58
- package/src/util/rate-limit.mjs +105 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,152 @@ 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.30.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The same ruler on the other two entries: v4.9.0 had the same defect, v4.10.0 did not.**
|
|
10
|
+
|
|
11
|
+
After 0.29.0 found that v4.8.0's *"driven by the authority model"* was a phrase rather than a mechanism, the
|
|
12
|
+
next two ROADMAP sentences were checked the same way rather than assumed sound.
|
|
13
|
+
|
|
14
|
+
| Entry | Claim | Verdict |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| v4.9.0 | cross-session pending-approval index | **real**, substantial |
|
|
17
|
+
| v4.9.0 | **"with the already-shipped rate limiter as its front door"** | **never built** |
|
|
18
|
+
| v4.10.0 | ordered phases, a phase cannot be skipped | **real**, asserted by name |
|
|
19
|
+
| v4.10.0 | weights reproducible from stored inputs | **real**, plus tamper detection |
|
|
20
|
+
| v4.10.0 | dissent retrievable *with* the decision | **real**, and the dissent is inside the seal |
|
|
21
|
+
|
|
22
|
+
Measured for the missing half: **`approval-index.mjs` imports `authority.mjs` and nothing else** — no
|
|
23
|
+
limiter, no window, no cooldown anywhere in the file — while the shipped `SlidingWindowRateLimiter` sat in
|
|
24
|
+
`relay/server.mjs` with **no approval caller**. Two entries, one defect, and the third entry was genuine:
|
|
25
|
+
**the pattern was not assumed, it was tested.**
|
|
26
|
+
|
|
27
|
+
### `src/agent/approval-gate.mjs`
|
|
28
|
+
|
|
29
|
+
**It limits raising and nothing else.** That asymmetry is the design:
|
|
30
|
+
|
|
31
|
+
* limiting the **view** would limit *knowing what is waiting*, which is the one thing the queue exists for;
|
|
32
|
+
* limiting **settling** would punish the supervisor clearing the backlog — a limit there is a limit on fixing
|
|
33
|
+
the problem;
|
|
34
|
+
* **raising** is the surface, because the threat is not brute force but **queue flooding**: an agent in a
|
|
35
|
+
retry loop can raise hundreds of requests and drown out the work that needs attention.
|
|
36
|
+
|
|
37
|
+
Two more decisions worth naming:
|
|
38
|
+
|
|
39
|
+
* **The key is an identity, not an address.** A fleet's agents frequently share a host, so an address-keyed
|
|
40
|
+
budget would apply one allowance to everybody on it — refusing the well-behaved because a neighbour
|
|
41
|
+
misbehaved. An anonymous caller gets a **shared named budget rather than none**, because *"no id, no key, no
|
|
42
|
+
limit"* would make forgetting to identify yourself the way to escape the limit.
|
|
43
|
+
* **The default is not the pairing limit.** `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE` is 5 because pairing is rare
|
|
44
|
+
and a wrong guess is an attack; raising approvals is ordinary work done in bursts. Inheriting a number built
|
|
45
|
+
for a different threat would have been the easy mistake.
|
|
46
|
+
|
|
47
|
+
It composes the shipped `SlidingWindowRateLimiter` rather than writing a second sliding window — the roadmap's
|
|
48
|
+
word was *"already-shipped"*, and two windows in one codebase are two chances to disagree about what a minute
|
|
49
|
+
is. A test asserts the composition.
|
|
50
|
+
|
|
51
|
+
The absence of `mayList` / `maySettle` / `mayDecide` is asserted **as an absence**, because their presence is
|
|
52
|
+
the defect and a test about flooding would never notice them.
|
|
53
|
+
|
|
54
|
+
### One error of mine
|
|
55
|
+
|
|
56
|
+
I looked for the "shared anonymous budget" wording on the **refusal**; it is on the **allow** branch, which is
|
|
57
|
+
where a caller learns which budget they are spending from. The implementation was right and the assertion was
|
|
58
|
+
in the wrong half — seventh time in this project, and the same remedy worked: measuring instead of predicting.
|
|
59
|
+
|
|
60
|
+
### The layer checker refused my first version, and it was right
|
|
61
|
+
|
|
62
|
+
Importing the limiter from `relay/server.mjs` made an **`agent` module depend on the `relay` layer**:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
relay -> util is not in ALLOWED_EDGES (undeclared) ← after the first fix attempt
|
|
66
|
+
agent -> relay ← the original refusal
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`agent` is rank 2 and `relay` is rank 3, so that edge **points up**. The tempting fix was to add
|
|
70
|
+
`['agent', 'relay']` to the allow-list, which would have **legalised an inversion**. The right fix was to
|
|
71
|
+
notice that `SlidingWindowRateLimiter` **never used anything from the relay** — it holds a `Map` and reads a
|
|
72
|
+
clock — so it belonged in `util` (rank 0, a leaf) all along.
|
|
73
|
+
|
|
74
|
+
The move cost nothing at runtime, and that is measured rather than assumed: **the relay's own construction
|
|
75
|
+
site already passed `limit` explicitly**, so the class's defaults had never been used. Its `limit` is now
|
|
76
|
+
**required** instead of defaulting to `DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE` — keeping that default would mean
|
|
77
|
+
this leaf importing a relay constant, **the same inversion one level down**.
|
|
78
|
+
|
|
79
|
+
Two edges are declared, both downward: `['agent', 'util']` and `['relay', 'util']`. The layer model is now
|
|
80
|
+
`clean: every cross-layer edge is declared`.
|
|
81
|
+
|
|
82
|
+
### Two more of my own errors
|
|
83
|
+
|
|
84
|
+
* `export { DEFAULT_WINDOW_MS as RATE_LIMIT_WINDOW_MS } from '...'` **re-exports without creating a local
|
|
85
|
+
binding**, so the relay's constructor threw `RATE_LIMIT_WINDOW_MS is not defined` while the module's public
|
|
86
|
+
surface looked perfectly correct. **106 tests failed at once**, which is how it was found.
|
|
87
|
+
* I looked for the "shared anonymous budget" wording on the refusal; it is on the allow branch.
|
|
88
|
+
|
|
89
|
+
**12 tests, 0 failures**, and 197 across the five affected suites.
|
|
90
|
+
|
|
91
|
+
## [0.29.0] — 2026-10-15
|
|
92
|
+
|
|
93
|
+
**v4.8.0 was half shipped, and the ROADMAP said otherwise. The missing half is now built.**
|
|
94
|
+
|
|
95
|
+
### What the check found
|
|
96
|
+
|
|
97
|
+
`ROADMAP.md` described v4.8.0 as *"a CAT7 seven-field cognitive block, **per-field acceptance driven by §2's
|
|
98
|
+
authority model**, and lineage"* and marked it **shipped in 0.9.9**. Reading the tree instead of the sentence:
|
|
99
|
+
|
|
100
|
+
| Claim | Verdict |
|
|
101
|
+
|---|---|
|
|
102
|
+
| CAT7 seven-field block | **real**, asserted |
|
|
103
|
+
| lineage, with a missing parent refused rather than re-rooted | **real**, asserted |
|
|
104
|
+
| a refused field *absent* from the stored block rather than null | **real**, asserted |
|
|
105
|
+
| **"driven by §2's authority model"** | **never built** |
|
|
106
|
+
|
|
107
|
+
Measured: **`src/agent/blocks.mjs` did not import `authority.mjs` at all**, and `defaultChoose` — the only
|
|
108
|
+
chooser that shipped — refused `confidence` by name and accepted everything else. **A roadmap sentence is a
|
|
109
|
+
claim, and this one was checkable in a single grep.** It stood for several releases because nothing tested the
|
|
110
|
+
sentence itself.
|
|
111
|
+
|
|
112
|
+
### `src/agent/field-authority.mjs`
|
|
113
|
+
|
|
114
|
+
Per-field acceptance now goes through `authority.evaluate`, so the two models cannot drift into different
|
|
115
|
+
notions of what a rule is. Three refusals apply in a fixed order:
|
|
116
|
+
|
|
117
|
+
1. **not a CAT7 field** — before any rule, because a rule cannot make a field exist;
|
|
118
|
+
2. **no declared rule** — refused, never granted and never escalated;
|
|
119
|
+
3. **the authority verdict** — `allow` accepts, anything else refuses with the model's own reason.
|
|
120
|
+
|
|
121
|
+
**A field with no declared rule is refused**, which is the same decision `authority.mjs` already states for
|
|
122
|
+
exemptions and `permission.mjs` states for capabilities. Three modules, one rule, because the failure mode is
|
|
123
|
+
identical: **a table that grants what it has not heard of grants whatever is added next.**
|
|
124
|
+
|
|
125
|
+
### The three inherited fields are deliberately undeclared, and that is not dead policy
|
|
126
|
+
|
|
127
|
+
Measured: the chooser is asked about `subject`, `body` and `evidence` — **never** `origin`, `observed_at` or
|
|
128
|
+
`lineage`, because `acceptBlock` copies the inherited three directly. A rule for them would read as policy
|
|
129
|
+
while doing nothing, so they are left out and `acceptField` reports them `UNDECLARED`. A test asserts a rule
|
|
130
|
+
can never be added for them by accident.
|
|
131
|
+
|
|
132
|
+
`confidence` is refused **at every level including `AUTHORITY_MAX`**, because if it were merely expensive a
|
|
133
|
+
system administrator would be *permitted by this model* to adopt the sender's own certainty about the
|
|
134
|
+
sender's own assertion.
|
|
135
|
+
|
|
136
|
+
### Four of my own errors, and how each surfaced
|
|
137
|
+
|
|
138
|
+
* I invented `scope_claim` and `meaning` as field names. **Neither exists** — the real seven are `origin`,
|
|
139
|
+
`subject`, `body`, `evidence`, `confidence`, `observed_at`, `lineage`. My "undeclared field" test was
|
|
140
|
+
therefore testing `NOT_A_FIELD` and passing for the wrong reason; the fields that are genuinely
|
|
141
|
+
governed-but-undeclared are the three inherited ones.
|
|
142
|
+
* I asserted `accepted_fields` held only the receiver's two, when it also carries the three inherited ones.
|
|
143
|
+
* I asserted one chooser ruling per choosable field; there are **three, not four**, because `acceptBlock`
|
|
144
|
+
hard-codes the `confidence` refusal and `continue`s **before** calling the chooser.
|
|
145
|
+
* `observed_at: 'T'` is not an ISO timestamp and `makeBlock` refused it — **the block layer was right and my
|
|
146
|
+
probe was wrong**, which is the sixth time in this project that a failing test was the test's fault.
|
|
147
|
+
|
|
148
|
+
Every one of the four was found by **measuring the real behaviour** rather than by reading the source and
|
|
149
|
+
predicting it.
|
|
150
|
+
|
|
151
|
+
**17 tests, 0 failures.**
|
|
152
|
+
|
|
7
153
|
## [0.28.0] — 2026-10-15
|
|
8
154
|
|
|
9
155
|
**First real-machine verification of the recent work, and a tool that packs four answers into one exit code.**
|
package/ROADMAP.md
CHANGED
|
@@ -60,7 +60,7 @@ that produced it.
|
|
|
60
60
|
the document does not grant it and inventing it would be the worst possible default;
|
|
61
61
|
- a rule with no `matching_exempt_level` exempts nobody, rather than exempting everybody.
|
|
62
62
|
|
|
63
|
-
### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9
|
|
63
|
+
### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9 (block + lineage); the authority half completed in 0.29.0**
|
|
64
64
|
|
|
65
65
|
**Deliverable.** Extend the shipped memory layer: a CAT7 seven-field cognitive block, per-field
|
|
66
66
|
acceptance driven by §2's authority model, and lineage — each accepted claim records the content hash of
|
|
@@ -70,14 +70,36 @@ its parent, so a returned claim is identifiable as the receiver's own earlier th
|
|
|
70
70
|
claim's ancestor chain survives a round trip; a claim whose parent is missing is refused rather than
|
|
71
71
|
silently re-rooted.
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
**Correction, 0.29.0.** This entry previously read *"shipped in 0.9.9, suite verified on MacMini"*, and that
|
|
74
|
+
was **half true**. The block, the lineage, the absent-not-null refusal and the missing-parent refusal all
|
|
75
|
+
shipped and are all asserted. But **"per-field acceptance driven by §2's authority model" was never built**:
|
|
76
|
+
measured, `src/agent/blocks.mjs` did not import `authority.mjs` at all, and the only chooser that shipped
|
|
77
|
+
refused `confidence` by name and accepted everything else. The connection now exists in
|
|
78
|
+
`src/agent/field-authority.mjs`, with the three inherited fields deliberately left undeclared because no
|
|
79
|
+
chooser is ever asked about them.
|
|
80
|
+
|
|
81
|
+
The lesson is recorded rather than tidied away: **a roadmap sentence is a claim, and this one was checkable
|
|
82
|
+
in one grep.** It stood for several releases because nothing tested the sentence itself.
|
|
83
|
+
|
|
84
|
+
### v4.9.0 — approval queue and scheduling **shipped in 0.10.0 (index); the rate-limiter front door added in 0.30.0**
|
|
74
85
|
|
|
75
86
|
**Deliverable.** A cross-session pending-approval index (§4.2's "supervisor reviews every agent's pending
|
|
76
87
|
approvals from one queue"), with the already-shipped rate limiter as its front door. Scheduling stays
|
|
77
88
|
out of scope here — MARL is not buildable at five machines, and pretending otherwise would be the most
|
|
78
89
|
expensive kind of dishonesty in this roadmap.
|
|
79
90
|
|
|
80
|
-
|
|
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**
|
|
81
103
|
|
|
82
104
|
**Deliverable.** The AURA six-role council as a *single* decision engine with the seven phases, weighted
|
|
83
105
|
voting whose weights come from v4.7.0's authority plus reputation, and **dissent retention** — the losing
|
|
@@ -87,6 +109,16 @@ a decision auditable later.
|
|
|
87
109
|
**Falsifying tests.** a phase cannot be skipped; a vote's weights are reproducible from stored inputs; a
|
|
88
110
|
decision with recorded dissent is retrievable *with* the dissent.
|
|
89
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
|
+
|
|
90
122
|
### v4.11.0–v4.13.0 — scale, defence, integration
|
|
91
123
|
|
|
92
124
|
Named for completeness, not planned in detail, because planning them now would produce fiction. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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/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/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 .",
|
|
@@ -146,7 +146,9 @@
|
|
|
146
146
|
"test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
|
|
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
|
-
"test:packet": "node --test --test-force-exit test/machine-test-packet.test.mjs"
|
|
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",
|
|
151
|
+
"test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs"
|
|
150
152
|
},
|
|
151
153
|
"keywords": [
|
|
152
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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-level acceptance driven by the authority model — MMP P1/P2's missing half.
|
|
3
|
+
*
|
|
4
|
+
* ## What was missing, and how it was found
|
|
5
|
+
*
|
|
6
|
+
* `ROADMAP.md` describes v4.8.0 as *"a CAT7 seven-field cognitive block, **per-field acceptance driven by §2's
|
|
7
|
+
* authority model**, and lineage"*, and marks it **shipped in 0.9.9**. Reading the tree to check that claim:
|
|
8
|
+
* the block, the lineage, the absent-not-null refusal and the missing-parent refusal are all real and all
|
|
9
|
+
* asserted. But **`blocks.mjs` does not import `authority.mjs` at all**, and `defaultChoose` — the only
|
|
10
|
+
* chooser that ships — refuses `confidence` by name and accepts everything else.
|
|
11
|
+
*
|
|
12
|
+
* So "driven by the authority model" was a description of an intention, and the one field rule that existed
|
|
13
|
+
* was hard-coded. This module is the connection that was claimed and not built.
|
|
14
|
+
*
|
|
15
|
+
* ## The rule that makes it safe
|
|
16
|
+
*
|
|
17
|
+
* **A field with no declared rule is REFUSED.** Not accepted, and not escalated. This is the same decision
|
|
18
|
+
* `authority.mjs` already states for exemptions — *"the tempting default is 'absent means unconstrained', and
|
|
19
|
+
* it is wrong in the one direction that matters"* — and `permission.mjs` states for capabilities: an omission
|
|
20
|
+
* must never become a grant. Three modules, one rule, because the failure mode is identical in all three: a
|
|
21
|
+
* table that grants what it has not heard of grants whatever is added next.
|
|
22
|
+
*
|
|
23
|
+
* ## Ordering, which is the part that is easy to get silently wrong
|
|
24
|
+
*
|
|
25
|
+
* Three refusals can apply to one field, and they are checked in this order:
|
|
26
|
+
*
|
|
27
|
+
* 1. **not a CAT7 field** — refused before any rule is consulted, because a rule cannot make a field exist;
|
|
28
|
+
* 2. **no declared rule** — refused, because an undeclared field is one nobody decided about;
|
|
29
|
+
* 3. **the authority verdict** — `allow` accepts, anything else refuses with the model's own reason.
|
|
30
|
+
*
|
|
31
|
+
* Step 3 never *grants* on a non-allow verdict. A `needs_approval` on a field is a refusal here rather than a
|
|
32
|
+
* queue: an accepted block is a stored fact, and storing a field as though a supervisor had approved it when
|
|
33
|
+
* nobody has is worse than omitting it.
|
|
34
|
+
*/
|
|
35
|
+
import { CAT7_FIELDS } from './blocks.mjs';
|
|
36
|
+
import { AUTHORITY_VERDICT, evaluate } from './authority.mjs';
|
|
37
|
+
|
|
38
|
+
/** Why a field was not accepted, as codes rather than prose. */
|
|
39
|
+
export const FIELD_REFUSAL = Object.freeze({
|
|
40
|
+
NOT_A_FIELD: 'FIELD_NOT_A_FIELD',
|
|
41
|
+
UNDECLARED: 'FIELD_UNDECLARED',
|
|
42
|
+
BELOW_AUTHORITY: 'FIELD_BELOW_AUTHORITY',
|
|
43
|
+
MALFORMED_RULE: 'FIELD_MALFORMED_RULE',
|
|
44
|
+
ALWAYS_REFUSED: 'FIELD_ALWAYS_REFUSED',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What each field costs to accept.
|
|
49
|
+
*
|
|
50
|
+
* Only the four **receiver-choosable** fields can be governed this way, because `acceptBlock` copies the
|
|
51
|
+
* three inherited ones (`origin`, `observed_at`, `lineage`) without consulting a chooser at all — a receiver
|
|
52
|
+
* that could set them could assert a provenance it does not have. The two choosable fields that do not appear
|
|
53
|
+
* here are governed on purpose and the code says which reason applies to each.
|
|
54
|
+
*
|
|
55
|
+
* The levels follow `AUTHORITY_BANDS`: `ordinary` may say what was observed and what it means; `captain` may
|
|
56
|
+
* assert what supports it. A field is a claim about how far the assertion reaches, so its cost is its reach.
|
|
57
|
+
*/
|
|
58
|
+
export const FIELD_LEVELS = Object.freeze({
|
|
59
|
+
subject: 0,
|
|
60
|
+
body: 0,
|
|
61
|
+
evidence: 50,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fields a receiver is refused whatever its authority, and why.
|
|
66
|
+
*
|
|
67
|
+
* `confidence` is **not** in {@link FIELD_LEVELS} at any level. A level can be raised, and if this field were
|
|
68
|
+
* merely expensive then a system administrator would be *permitted by this model* to adopt the sender's own
|
|
69
|
+
* certainty about the sender's own assertion — the one transfer of belief `blocks.mjs` calls a hard invariant
|
|
70
|
+
* rather than a default. This table is what makes that invariant reachable from here, so the refusal is
|
|
71
|
+
* reported with the same words `acceptBlock` uses instead of a second, drifting explanation.
|
|
72
|
+
*/
|
|
73
|
+
export const NEVER_ACCEPTED = Object.freeze({
|
|
74
|
+
confidence: 'a receiver does not adopt the sender\'s certainty (invariant, not a default)',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The authority rule for one field, or `null` when the field has no declared rule.
|
|
79
|
+
*
|
|
80
|
+
* Returned in the shape `authority.evaluate` accepts, so the two models cannot drift into different notions
|
|
81
|
+
* of what a rule is.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} field
|
|
84
|
+
* @returns {{action: string, minLevel: number, matchingExemptLevel: null}|null}
|
|
85
|
+
*/
|
|
86
|
+
export function ruleForField(field) {
|
|
87
|
+
if (!Object.prototype.hasOwnProperty.call(FIELD_LEVELS, field)) return null;
|
|
88
|
+
return { action: `accept_field:${field}`, minLevel: FIELD_LEVELS[field], matchingExemptLevel: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether an actor may accept one field into a stored block.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} field
|
|
95
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
96
|
+
* @returns {{accepted: boolean, field: string|null, code: string, verdict: string|null,
|
|
97
|
+
* required: number|null, reason: string}}
|
|
98
|
+
*/
|
|
99
|
+
export function acceptField(field, actor) {
|
|
100
|
+
if (typeof field !== 'string' || !CAT7_FIELDS.includes(field)) {
|
|
101
|
+
// Before any rule: a rule cannot make a field exist, so consulting one here would let a typo in a rule
|
|
102
|
+
// table conjure a field that `verifyBlock` would then reject at the other end of the wire.
|
|
103
|
+
return {
|
|
104
|
+
accepted: false,
|
|
105
|
+
field: typeof field === 'string' ? field : null,
|
|
106
|
+
code: FIELD_REFUSAL.NOT_A_FIELD,
|
|
107
|
+
verdict: null,
|
|
108
|
+
required: null,
|
|
109
|
+
reason: `${JSON.stringify(field)} is not one of the seven CAT7 fields`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(NEVER_ACCEPTED, field)) {
|
|
114
|
+
// Checked before the table, so raising a level cannot reach it. The message says it is an invariant, not a
|
|
115
|
+
// threshold, because a reader who thinks it is a threshold will try to raise it.
|
|
116
|
+
return {
|
|
117
|
+
accepted: false,
|
|
118
|
+
field,
|
|
119
|
+
code: FIELD_REFUSAL.ALWAYS_REFUSED,
|
|
120
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
121
|
+
required: null,
|
|
122
|
+
reason: NEVER_ACCEPTED[field],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rule = ruleForField(field);
|
|
127
|
+
if (rule === null) {
|
|
128
|
+
// The load-bearing refusal. Accepting an undeclared field would make this table grant whatever is added
|
|
129
|
+
// next; escalating it would make it requestable. Both turn an omission into a permission.
|
|
130
|
+
return {
|
|
131
|
+
accepted: false,
|
|
132
|
+
field,
|
|
133
|
+
code: FIELD_REFUSAL.UNDECLARED,
|
|
134
|
+
verdict: null,
|
|
135
|
+
required: null,
|
|
136
|
+
reason: `no authority rule declares what ${field} costs, so nothing may accept it; a field the table has not heard of is refused rather than granted`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const decision = evaluate(rule, actor);
|
|
141
|
+
if (decision.verdict === AUTHORITY_VERDICT.ALLOW) {
|
|
142
|
+
return {
|
|
143
|
+
accepted: true,
|
|
144
|
+
field,
|
|
145
|
+
code: AUTHORITY_VERDICT.ALLOW,
|
|
146
|
+
verdict: decision.verdict,
|
|
147
|
+
required: rule.minLevel,
|
|
148
|
+
reason: decision.reason,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
accepted: false,
|
|
153
|
+
field,
|
|
154
|
+
code: decision.reason.startsWith('malformed rule:') ? FIELD_REFUSAL.MALFORMED_RULE : FIELD_REFUSAL.BELOW_AUTHORITY,
|
|
155
|
+
verdict: decision.verdict,
|
|
156
|
+
required: rule.minLevel,
|
|
157
|
+
reason: decision.reason,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A `choose` function for `acceptBlock`, built from the authority model.
|
|
163
|
+
*
|
|
164
|
+
* Shaped to drop straight into `acceptBlock(block, { choose })`, which is the only reason it exists as a
|
|
165
|
+
* separate function rather than being inlined: the block layer must not need to know about authority, and the
|
|
166
|
+
* authority layer must not need to know about blocks.
|
|
167
|
+
*
|
|
168
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
169
|
+
* @param {{onDecision?: (result: object) => void}} [options] `onDecision` sees every field's ruling, so a
|
|
170
|
+
* caller can record refusals without re-deriving them.
|
|
171
|
+
* @returns {(field: string, value: unknown, block: object) => {accept: boolean, why: string}}
|
|
172
|
+
*/
|
|
173
|
+
export function authorityChooser(actor, options = {}) {
|
|
174
|
+
const onDecision = typeof options.onDecision === 'function' ? options.onDecision : null;
|
|
175
|
+
return (field) => {
|
|
176
|
+
const result = acceptField(field, actor);
|
|
177
|
+
if (onDecision !== null) onDecision(result);
|
|
178
|
+
return { accept: result.accepted, why: result.reason };
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Accept a whole block with the authority chooser, and report both halves.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} block
|
|
186
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
187
|
+
* @param {{lineage?: string[]|null, accept?: Function}} [options] `accept` is `acceptBlock`, injected so this
|
|
188
|
+
* module does not import it in a cycle.
|
|
189
|
+
* @returns {{accepted: object|null, refused: Array<object>, decisions: Array<object>}}
|
|
190
|
+
*/
|
|
191
|
+
export function acceptWithAuthority(block, actor, options = {}) {
|
|
192
|
+
const accept = options.accept;
|
|
193
|
+
if (typeof accept !== 'function') {
|
|
194
|
+
return { accepted: null, refused: [{ field: null, why: 'no acceptBlock implementation was supplied' }], decisions: [] };
|
|
195
|
+
}
|
|
196
|
+
const decisions = [];
|
|
197
|
+
const result = accept(block, {
|
|
198
|
+
choose: authorityChooser(actor, { onDecision: (d) => decisions.push(d) }),
|
|
199
|
+
...(Object.prototype.hasOwnProperty.call(options, 'lineage') ? { lineage: options.lineage } : {}),
|
|
200
|
+
});
|
|
201
|
+
return { accepted: result.accepted, refused: result.refused, decisions };
|
|
202
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -193,7 +193,7 @@ const MAX_STAGES = 16;
|
|
|
193
193
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
194
194
|
* overrides it at runtime for tests and packagers.
|
|
195
195
|
*/
|
|
196
|
-
const PLUGIN_VERSION = '0.
|
|
196
|
+
const PLUGIN_VERSION = '0.30.0';
|
|
197
197
|
|
|
198
198
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
199
199
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
package/src/relay/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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:
|
|
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
|
+
}
|