@osqd/bothandlerjs 0.3.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 +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- package/package.json +145 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
Nothing yet.
|
|
10
|
+
|
|
11
|
+
## [0.2.0]
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- **The dashboard accepted state-changing requests from other people's pages.**
|
|
16
|
+
`POST /api/policy/apply` and `POST /api/reset` checked credentials and nothing
|
|
17
|
+
else, and a browser attaches credentials to a cross-site request as willingly
|
|
18
|
+
as to a real one. Because `text/plain` is a CORS "simple request", a plain HTML
|
|
19
|
+
form on any page an operator visited could swap the running bot policy — to
|
|
20
|
+
`monitor-only`, disabling blocking site-wide — or wipe the actor registry,
|
|
21
|
+
with no preflight and no warning. Three locks now, checked independently:
|
|
22
|
+
|
|
23
|
+
- `Sec-Fetch-Site` must be `same-origin` or `none` when the browser sends it,
|
|
24
|
+
and `Origin`, when present without it, must name this same server. Requests
|
|
25
|
+
with no browser provenance at all — curl, a deploy script — still pass.
|
|
26
|
+
- Request bodies must be `application/json`. An HTML form cannot send that
|
|
27
|
+
media type, and anything that can must ask permission first through a
|
|
28
|
+
preflight this server answers for nobody.
|
|
29
|
+
- Reads are deliberately untouched: no `Access-Control-Allow-Origin` is sent,
|
|
30
|
+
so a cross-origin reader never sees the response it fetched.
|
|
31
|
+
|
|
32
|
+
- **The dashboard answered to any `Host`, which left DNS rebinding open.** A
|
|
33
|
+
name the attacker controls, pointed at 127.0.0.1, is *same-origin* by the
|
|
34
|
+
browser's reckoning, so every check above agrees with it. On a loopback bind
|
|
35
|
+
the `Host` header is now checked against the names a dashboard is actually
|
|
36
|
+
opened under (`localhost`, `127.0.0.1`, `[::1]`) plus anything listed in the
|
|
37
|
+
new `allowedHosts` option; unrecognised names get a 421. A public bind is
|
|
38
|
+
unaffected — rebinding wins nothing against an address the attacker can reach
|
|
39
|
+
directly, and enforcing a list there would break every reverse proxy.
|
|
40
|
+
|
|
41
|
+
- **`X-Forwarded-For` was believed even from a peer that is not one of your
|
|
42
|
+
proxies.** With `trustedProxies` configured, the header chain was walked but
|
|
43
|
+
the connecting peer never was, so a request that reached the origin *without*
|
|
44
|
+
passing through the load balancer — a leaked origin address, a directly
|
|
45
|
+
reachable pod — could name its own client IP, and with it its own actor key,
|
|
46
|
+
rate-limit bucket and IP reputation. The peer is now the first hop and is
|
|
47
|
+
checked like any other: a request from outside the trusted ranges falls back
|
|
48
|
+
to the socket address. This is what `proxy-addr` has always done and what
|
|
49
|
+
`trustedProxies` was there to promise.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **The fetch adapter did not fail open.** An error inside the engine escaped
|
|
54
|
+
`createFetchAdapter` to the runtime, and Workers, Deno and Bun all answer 500
|
|
55
|
+
— a detection bug charged to the visitor, which is the one thing every
|
|
56
|
+
adapter's doc comment promises not to do. It now reports through `onError` and
|
|
57
|
+
serves the request, as the node, Koa and Fastify adapters already did.
|
|
58
|
+
|
|
59
|
+
- **123 build-tool packages were declared as runtime dependencies.** `vite`,
|
|
60
|
+
`rollup`, `esbuild`, `chai`, `express` and the rest of the flattened dev tree
|
|
61
|
+
were listed under `dependencies`, so `npm install @osqd/bothandlerjs` pulled roughly
|
|
62
|
+
134 MB into somebody else's project and handed them an esbuild advisory
|
|
63
|
+
(GHSA-g7r4-m6w7-qqqr) as a *production* vulnerability in a library that does
|
|
64
|
+
not use esbuild. The library imports nothing but `node:` builtins; the
|
|
65
|
+
dependency list now says so, and CI fails if that stops being true.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`minUnsolvedChallenges` — a rule that reads challenges nobody answered.**
|
|
70
|
+
`ActorState.unsolvedChallenges` was declared and never written or read; it counts now,
|
|
71
|
+
rising with each challenge issued and clearing the moment one is solved. Outstanding
|
|
72
|
+
rather than cumulative, so it never accumulates against somebody who came back and
|
|
73
|
+
proved it.
|
|
74
|
+
|
|
75
|
+
A rule rather than evidence, on purpose. One abandoned challenge is a person — a slow
|
|
76
|
+
phone, a lost tab, a change of mind — and only repetition means anything, which is a
|
|
77
|
+
judgement about traffic the library cannot see. The engine counts; your policy decides.
|
|
78
|
+
Shown on the Actors screen and offered in the dashboard's rule editor.
|
|
79
|
+
|
|
80
|
+
- **The challenge interstitial can be written in the visitor's language.**
|
|
81
|
+
`challenge.translations`, keyed by language tag, chosen from `Accept-Language` with
|
|
82
|
+
`q` honoured and a region falling back to its primary tag — and the document's `lang`
|
|
83
|
+
attribute set to match, which is what decides the voice a screen reader reads it in.
|
|
84
|
+
|
|
85
|
+
This is the only page the library shows to a member of the public, and they see it
|
|
86
|
+
because a *probabilistic* verdict went against them: somebody who cannot read it
|
|
87
|
+
cannot find the contact link on it either. The library ships no translations and will
|
|
88
|
+
not — a machine-translated apology on a page that just turned somebody away is worse
|
|
89
|
+
than an honest English one.
|
|
90
|
+
|
|
91
|
+
- **A performance ratchet, in CI.** There was a coverage ratchet and nothing guarding the
|
|
92
|
+
hot path — the code every user of this library runs on every request — so a detector
|
|
93
|
+
that got ten times slower would have shipped in silence. `npm run bench:guard` budgets
|
|
94
|
+
`assess`, `handle` and `createFacts` as **ratios against a reference loop measured in
|
|
95
|
+
the same process**, because a baseline in microseconds is a statement about the machine
|
|
96
|
+
that produced it and fails on a CI runner for reasons unrelated to the code.
|
|
97
|
+
|
|
98
|
+
- **Published crawler ranges — `startCrawlerRangeRefresh()`.** Twelve shipped signatures
|
|
99
|
+
verify by address rather than by reverse DNS, every AI crawler among them, and nothing
|
|
100
|
+
in the library ever filled those ranges in: `updateCrawlerRanges()` was a method
|
|
101
|
+
waiting for a caller. The library still ships **no address data** — a range baked into
|
|
102
|
+
a release is wrong by the time somebody installs it — it ships the URL each operator
|
|
103
|
+
publishes, so the answer comes from the party entitled to give it.
|
|
104
|
+
|
|
105
|
+
Opt-in, because it makes outbound requests. Fails open per source: a publisher that is
|
|
106
|
+
down, has moved its file or serves something unrecognisable leaves every other
|
|
107
|
+
crawler's ranges as they were and that crawler's ranges as they were too. Two things
|
|
108
|
+
are refused outright, because these ranges do not describe a crawler but **verify**
|
|
109
|
+
one: a list containing a block bigger than any crawler owns, and an empty list.
|
|
110
|
+
|
|
111
|
+
- **`shareConfirmations` — proof crosses replicas; suspicion does not.** Behavioural
|
|
112
|
+
state is process-local by design, because a round trip per request would buy accuracy
|
|
113
|
+
for signals that may only raise suspicion. A confirmation is not one of those:
|
|
114
|
+
`confirmed-bot` is a *proven* verdict, and without sharing it a client proven to be a
|
|
115
|
+
bot on one replica was a stranger to the other seven — so `minPriorConfirmations: 1`
|
|
116
|
+
fired about an eighth as often as it read. The cost is one store read the first time
|
|
117
|
+
each instance sees an actor, never awaited, so nothing joins the request path.
|
|
118
|
+
|
|
119
|
+
- **An audit check for the challenge solve rate.** The audit watched bot share, traffic,
|
|
120
|
+
denials, guard stops, human share and detector failures — and not the one number that
|
|
121
|
+
says *you are challenging people*. A proof-of-work challenge is trivial for a browser
|
|
122
|
+
and trivial for a competent scraper; what it costs is a few seconds of somebody's
|
|
123
|
+
afternoon. So a solve rate near one does not mean the challenges are working, it means
|
|
124
|
+
they are mostly landing on people — and from every other angle a solved challenge
|
|
125
|
+
looks like a challenge that worked, which is why it needed saying. `AuditWindow` gains
|
|
126
|
+
`challengesSolved` and `challengeSolveRate`.
|
|
127
|
+
|
|
128
|
+
- **`bothandlerjs check` — your policy against 526 shapes of real traffic.** The question
|
|
129
|
+
the library is organised around, asked offline and before a deploy: *if I point this
|
|
130
|
+
configuration at the actual internet, who gets hurt?* It exits non-zero if any case
|
|
131
|
+
marked `human` is denied service, which is what makes it a CI step rather than a
|
|
132
|
+
report. DNS is controlled and the clock is manual, so it is reproducible and offline.
|
|
133
|
+
|
|
134
|
+
- **The corpus is a published entry point — `@osqd/bothandlerjs/corpus`.** Its own schema has
|
|
135
|
+
always said "point the runner at *your* `BotHandler`", and until now only this
|
|
136
|
+
repository could. It is a separate entry, so importing the library never loads a case
|
|
137
|
+
of it.
|
|
138
|
+
|
|
139
|
+
- **`bothandlerjs explain`** — the dashboard's request tester as a command. A User-Agent,
|
|
140
|
+
a `curl` command or a header block in; the verdict, the rule and every piece of
|
|
141
|
+
evidence out, as a dry run that records nothing.
|
|
142
|
+
|
|
143
|
+
- **Hono and Next.js, in an example and in tests.** Neither needs an adapter — both speak
|
|
144
|
+
`Request` and `Response`, which is what `createFetchAdapter` takes — but "it probably
|
|
145
|
+
works with X" is how a framework ends up unsupported by accident.
|
|
146
|
+
|
|
147
|
+
- `BotHandler.warn()` is public, so an adapter or the range refresher can raise a warning
|
|
148
|
+
through the handler's own channel.
|
|
149
|
+
|
|
150
|
+
- **`createDashboardHandler()` — the dashboard as a request handler, for a server you
|
|
151
|
+
already have.** `startDashboard` opens a plain HTTP listener of its own, which is
|
|
152
|
+
right on a laptop and wrong in most production networks: the certificate lives at an
|
|
153
|
+
ingress, everything has to be reachable under one hostname, or the platform exposes
|
|
154
|
+
exactly one port. This is the same dashboard without the socket, and `startDashboard`
|
|
155
|
+
is now a thin wrapper over it.
|
|
156
|
+
|
|
157
|
+
`auth` is required in this form, including the explicit `auth: false`: the listening
|
|
158
|
+
form may skip it on `127.0.0.1` because the operating system is then the access
|
|
159
|
+
control, and mounted there is no bind address to inspect, so nothing can be assumed.
|
|
160
|
+
`close()` unsubscribes and ends the streams without closing a server it does not own.
|
|
161
|
+
Routing accepts the path with or without `basePath` in front of it, so it works
|
|
162
|
+
whether or not the surrounding router strips the mount point. See
|
|
163
|
+
`examples/dashboard-mounted.ts`.
|
|
164
|
+
|
|
165
|
+
- **A wrong password is now slowed down.** Both halves of a basic credential were
|
|
166
|
+
compared in constant time, which defeats a timing attack and does nothing about the
|
|
167
|
+
obvious one — trying again. After five failures from an address the next attempt is
|
|
168
|
+
refused for a delay that doubles each time, up to five minutes; a success clears it.
|
|
169
|
+
Configured with `authThrottle`, and silent under a silent `refusal`, because a `429`
|
|
170
|
+
would tell a prober there is a credential here worth guessing.
|
|
171
|
+
|
|
172
|
+
- **`allowedClients` — which addresses may reach the dashboard at all.** The layer
|
|
173
|
+
`allowedHosts` is not: that checks the name in the `Host` header, this checks who is
|
|
174
|
+
connecting. It is what "bind `0.0.0.0`, but only the VPN can reach it" means, it is
|
|
175
|
+
checked before authentication, and it is a layer on top of `auth` rather than a
|
|
176
|
+
replacement — an address is not a person.
|
|
177
|
+
|
|
178
|
+
- **`feedTtlMs` — how long a request may stay in the feed. Default one hour.**
|
|
179
|
+
`feedLimit` is a capacity bound and this is a retention promise, which is a different
|
|
180
|
+
question: on a quiet service five hundred requests can be a fortnight, and every entry
|
|
181
|
+
holds somebody's address, User-Agent and header set. Eviction runs on the dashboard's
|
|
182
|
+
own timer as well as on arrival, so it holds on an idle process — the only kind where
|
|
183
|
+
it matters.
|
|
184
|
+
|
|
185
|
+
- **A Changes panel**, listing what was applied at runtime, when and by whom — the same
|
|
186
|
+
list the traffic timeline marks, written out, because a marker answers "was there a
|
|
187
|
+
change here?" and an audit wants "what were they, in order". `sections.changes`.
|
|
188
|
+
|
|
189
|
+
- **`peers` — sibling instances in the header.** The honest amount of help a page can
|
|
190
|
+
give with a fleet: a way to reach the other ones. It aggregates nothing, and the
|
|
191
|
+
counters that genuinely want aggregating already go to Prometheus.
|
|
192
|
+
|
|
193
|
+
- **Text alternatives for all three charts.** `role="img"` with a name says a picture is
|
|
194
|
+
here and what it is called, and nothing about what is in it — so every number on the
|
|
195
|
+
Statistics screen was unreachable to a reader who cannot see the bars. Each chart now
|
|
196
|
+
carries its totals, its bands and its markers in words.
|
|
197
|
+
|
|
198
|
+
- **An axe pass over all four screens, in both themes, at two widths**, failing on
|
|
199
|
+
anything it reports at any severity; a contrast matrix that measures every ink the page
|
|
200
|
+
can paint against every surface it can appear on; and a test that drives the dashboard
|
|
201
|
+
under sustained load and asserts every structure it owns stays bounded.
|
|
202
|
+
|
|
203
|
+
- **The challenge interstitial is in the browser suite too.** It is the only page in this
|
|
204
|
+
library a member of the public sees — somebody who was going about their day and
|
|
205
|
+
tripped a probabilistic verdict — so it is now audited in both themes, checked for
|
|
206
|
+
sideways scroll on a phone, and checked that its proof of work actually runs under the
|
|
207
|
+
strict CSP it is served with. A challenge that cannot start is a denial of service to
|
|
208
|
+
the exact population the guard exists to protect. It was clean, and now it stays that
|
|
209
|
+
way.
|
|
210
|
+
|
|
211
|
+
- **Acting on one client, not just reading about one — `controls.editRanges`.** Off by
|
|
212
|
+
default. It puts three operations on the actor drill-down and on every row of the new
|
|
213
|
+
Actors screen: allowlist the address, forget that actor's behavioural memory, or grant
|
|
214
|
+
it human clearance for an hour. All three existed in the engine and were reachable
|
|
215
|
+
from nothing.
|
|
216
|
+
|
|
217
|
+
**Forget** is the reason the flag exists. A person whose actor key collected a
|
|
218
|
+
`confirmed-bot` — a shared office address, a phone that reused an IP — carried
|
|
219
|
+
`priorConfirmations` for the rest of the window, and the only cure was Reset: throwing
|
|
220
|
+
away every actor's history to fix one person's. **Allowlist** is the consequential one
|
|
221
|
+
and asks twice, with the second button naming the address it is about to exempt,
|
|
222
|
+
because an allowlisted address is not judged leniently — it is not judged at all. The
|
|
223
|
+
whole control is unavailable under `redact.maskIp`, where the key on screen names a
|
|
224
|
+
`/24` and the registry is keyed by the address.
|
|
225
|
+
|
|
226
|
+
- **An Actors screen.** Everyone the *registry* is holding, busiest first — requests,
|
|
227
|
+
requests per minute, distinct paths, cadence regularity, prior confirmations,
|
|
228
|
+
clearance. The feed's ring holds a few hundred *requests*, which on a busy origin is a
|
|
229
|
+
few seconds; the registry holds up to `maxActors` *clients* with the history that
|
|
230
|
+
`cadence`, `crawl-breadth` and `rate-anomaly` are reading. "Who is hitting me hardest
|
|
231
|
+
right now" had nowhere to be asked. **In feed** sends one to the live feed as an
|
|
232
|
+
`actor:` filter, so it is a shareable URL like every other view.
|
|
233
|
+
|
|
234
|
+
- **A request tester.** Paste a User-Agent, a `curl` command out of devtools or a raw
|
|
235
|
+
header block, and see the verdict, the evidence and the rule that would fire — without
|
|
236
|
+
waiting for that client to come back, and without the investigation showing up in the
|
|
237
|
+
thing being investigated.
|
|
238
|
+
|
|
239
|
+
- **`assess(facts, { record: false })` — a dry run.** Every detector runs and the verdict
|
|
240
|
+
is real; no counter moves, no actor state changes, no `assessment` event fires, no
|
|
241
|
+
notification is sent. What backs the tester, and what to reach for anywhere else you
|
|
242
|
+
want the engine's opinion about a request that is not happening. It has no history by
|
|
243
|
+
construction, so what it answers is "what would this look like as a first request".
|
|
244
|
+
|
|
245
|
+
- **Changes are attributed.** `auth: { authorize }` may return a **string** — the
|
|
246
|
+
viewer's name — instead of `true`, and basic auth supplies one without being asked.
|
|
247
|
+
Every mutating method now takes a `{ by }`, and the name travels into the handler's
|
|
248
|
+
`warning`, into the change event, and onto the timeline marker. The library still has
|
|
249
|
+
no user model: it carries the name it was given.
|
|
250
|
+
|
|
251
|
+
- **Runtime changes are marked on the traffic timeline**, with what changed and who did
|
|
252
|
+
it. A preview says "44 of 151 requests would be treated differently"; the marker is
|
|
253
|
+
what lets you check whether it held.
|
|
254
|
+
|
|
255
|
+
- **`maxEventsPerSecond` on `DashboardOptions`** (default 100, `0` to disable). Every
|
|
256
|
+
assessment used to go to every open browser, so a thousand requests a second was a
|
|
257
|
+
couple of megabytes a second per viewer with Pause as the only lever. The cap is on the
|
|
258
|
+
*stream* — the ring keeps everything, so the preview, the export and anyone
|
|
259
|
+
reconnecting are unaffected — and the feed says how many were not streamed, because a
|
|
260
|
+
thinned feed must never look like a quiet one.
|
|
261
|
+
|
|
262
|
+
- **`instance` on `DashboardOptions`**, defaulting to the hostname and shown in the
|
|
263
|
+
header. A dashboard reports on one process; behind a load balancer with eight pods you
|
|
264
|
+
are looking at an eighth of your traffic. Naming the instance does not aggregate
|
|
265
|
+
anything, it stops a partial picture from looking like a whole one.
|
|
266
|
+
|
|
267
|
+
- `range-change` and `actor-change` events, with `onRangeChange` and `onActorChange`.
|
|
268
|
+
- `BotHandler.forgetActor()`, `clearActor()`, `rangeEntries()`, `ActorRegistry.top()` and
|
|
269
|
+
`IpRangeSet.entries()` — the engine surface behind all of the above. A range set could
|
|
270
|
+
report how many entries it held and not which.
|
|
271
|
+
- `sections.registry`, `sections.tester` and `sections.ranges`.
|
|
272
|
+
|
|
273
|
+
- **`sections` on `DashboardOptions` — what a listener shows, as opposed to what it
|
|
274
|
+
lets you do.** `feed`, `evidence`, `actors`, `statistics`, `audit`, `notices`,
|
|
275
|
+
`policy`, `guard` and `robots`, all on by default. Switching one off removes it from
|
|
276
|
+
the page *and* from the server: the tab is gone, the endpoint behind it answers 403,
|
|
277
|
+
and the fields it would have shown are dropped before they leave the process — so a
|
|
278
|
+
viewer with devtools open sees exactly what the page sees. `evidence` is the one to
|
|
279
|
+
think about before sharing a dashboard widely: it names which detector fired and why,
|
|
280
|
+
which is a tuning guide for whoever is scraping you.
|
|
281
|
+
|
|
282
|
+
There is still no role model in here, and that is still deliberate. `controls` says
|
|
283
|
+
what a viewer may do, `sections` says what a viewer may see, both are fixed when the
|
|
284
|
+
listener starts, and roles are yours: one listener per role, your own `authorize`
|
|
285
|
+
predicate in front of each. `npm run demo:roles` is now three of them.
|
|
286
|
+
|
|
287
|
+
- **`controls.editGuard` — the guard, changeable from the dashboard, behind its own
|
|
288
|
+
flag.** Off by default. With it on, the Guard panel becomes a form over
|
|
289
|
+
`falsePositivePolicy`, `fallbackAction`, `defaultAction`, `terminalScoreThreshold`
|
|
290
|
+
and `suspectThreshold`, applied to the running handler through a new
|
|
291
|
+
`POST /api/guard`.
|
|
292
|
+
|
|
293
|
+
It is a *separate* flag from `editPolicy` because they are different powers. A rule
|
|
294
|
+
editor can only write a rule that overreaches, and the guard stops it; this changes
|
|
295
|
+
whether anything stops it. Two settings stay impossible whatever the flag says: a
|
|
296
|
+
terminal `fallbackAction`, which would make every downgrade deny the request the
|
|
297
|
+
downgrade existed to protect while still recording it as a guard stop, and a
|
|
298
|
+
`terminalScoreThreshold` outside 1–100. Changes are validated before anything moves,
|
|
299
|
+
applied whole or not at all, previewable against the traffic in the window, and
|
|
300
|
+
announced through both `warning` and a new `guard-change` event carrying the before
|
|
301
|
+
and the after.
|
|
302
|
+
|
|
303
|
+
- `BotHandler.updateGuard()`, `Policy.replaceGuard()`, `Policy.describeGuard()` and the
|
|
304
|
+
`GuardSettings` type — the API behind the above, usable without a dashboard.
|
|
305
|
+
- `onGuardChange` on `BotHandlerOptions`, and `guard-change` on `BotHandlerEvents`.
|
|
306
|
+
- **A score histogram in the metrics.** `scores` on `MetricsSnapshot` and
|
|
307
|
+
`bothandler_score_bucket` in the Prometheus output: how suspicion is distributed
|
|
308
|
+
across everything that was scored, in ten buckets of ten points, since the process
|
|
309
|
+
started. Proven assessments are excluded — their score is 100 by definition and
|
|
310
|
+
decides nothing.
|
|
311
|
+
- **The dashboard's live feed can be searched by field.** `actor:203.0.113.4`,
|
|
312
|
+
`-path:/health`, `rule:no-scrapers`, `score:>70`, `"a quoted phrase"`; every term
|
|
313
|
+
must match. Anything that is not a field term still matches the whole request, so a
|
|
314
|
+
plain word behaves as it always did.
|
|
315
|
+
- **"Draft a rule" on any feed row.** Turns the request you are looking at into a rule
|
|
316
|
+
in the policy editor, matched on the strongest thing it actually proves — a verified
|
|
317
|
+
identity, else the detectors whose evidence was proven, else the verdict with a score
|
|
318
|
+
floor — appended last, previewed immediately, applied never. The action is always
|
|
319
|
+
`tag`: a drafted rule has been reviewed by nobody, and a dashboard choosing a terminal
|
|
320
|
+
action for a request that annoyed you is the reflex this library exists to interrupt.
|
|
321
|
+
- **Export the window.** The feed toolbar downloads every request matching the current
|
|
322
|
+
filter as replay JSONL — the bulk form of the per-row buttons.
|
|
323
|
+
- **The filter and the search are in the URL**, alongside the view, so a screen is a
|
|
324
|
+
link rather than a set of instructions and a refresh keeps your place.
|
|
325
|
+
- A time column on the feed, and a scope switch on the score distribution.
|
|
326
|
+
|
|
327
|
+
- `allowedHosts` on `DashboardOptions` — extra `Host` values a loopback
|
|
328
|
+
dashboard will answer to, for reaching it through a name of your own. `"*"`
|
|
329
|
+
disables the check.
|
|
330
|
+
- `clock` on `RedisStoreOptions`. The rate-limit window came from `Date.now()`
|
|
331
|
+
while every other stateful piece of the library takes an injectable clock,
|
|
332
|
+
which is why the window boundary could not be tested.
|
|
333
|
+
- `npm run demo:roles` — a second demo showing the dashboard behind roles: a signed
|
|
334
|
+
session cookie, an operator console to pick a role at, a read-only viewer dashboard
|
|
335
|
+
for analysts, and an admin dashboard with the editor and reset. It exists to make
|
|
336
|
+
three things visible before someone builds them wrong: a custom `authorize` sends no
|
|
337
|
+
`WWW-Authenticate`, cookies are scoped by host rather than by origin, and a session
|
|
338
|
+
cookie is not what stops a forged write.
|
|
339
|
+
|
|
340
|
+
- `refusal` on `DashboardOptions` — what a caller the dashboard will not serve is
|
|
341
|
+
told: `"unauthorized"` (401, the default), `"not-found"` (404, byte-identical to
|
|
342
|
+
an unknown path), `"close"` (drop the connection), or `{ redirect, status? }`.
|
|
343
|
+
Anything but the default also collapses the three pre-routing refusals — failed
|
|
344
|
+
auth, wrong `Host`, cross-site write — into one answer, so a probe cannot tell
|
|
345
|
+
which check turned it away. It is concealment rather than access control, and
|
|
346
|
+
combining a silent refusal with basic auth is refused at startup: a browser
|
|
347
|
+
prompts for a password only when a 401 asks it to.
|
|
348
|
+
- The dashboard page is navigable by keyboard. The tab strip now honours the
|
|
349
|
+
keyboard contract its `role="tablist"` was already promising (arrow keys,
|
|
350
|
+
Home/End, a roving tabindex), feed rows are focusable controls that open on Enter
|
|
351
|
+
or Space rather than click alone, `/` jumps to the filter, `1`–`3` switch views,
|
|
352
|
+
Escape backs out of whatever is open, and there is a skip link past the header.
|
|
353
|
+
- The current view is in the URL, so a screen can be linked to, the back button
|
|
354
|
+
moves between views instead of leaving the page, and a refresh keeps your place.
|
|
355
|
+
|
|
356
|
+
### Changed (the dashboard)
|
|
357
|
+
|
|
358
|
+
- **The live feed updates in place instead of being redrawn.** It used to empty the
|
|
359
|
+
table and rebuild up to three hundred rows on every frame that carried a request. The
|
|
360
|
+
expensive part was not the visible problem: a rebuilt row takes your text selection
|
|
361
|
+
with it, so copying a User-Agent out of a live feed was impossible without pressing
|
|
362
|
+
Pause first. Rows are now keyed by request id and rebuilt only when the entry behind
|
|
363
|
+
them changed.
|
|
364
|
+
- **Only the visible screen is drawn.** Every incoming request used to rebuild eight
|
|
365
|
+
statistics panels into a hidden tab, at the frame rate, on top of the feed.
|
|
366
|
+
- **The Statistics screen says which window each panel is counting.** Half of it counts
|
|
367
|
+
the retained ring and half counts since the process started — different populations
|
|
368
|
+
wearing the same grey subtitle, on the screen people read before moving a threshold.
|
|
369
|
+
Window-scoped panels now say how much window there is ("last 500 requests · 4 min"),
|
|
370
|
+
and the score distribution defaults to the whole run, drawn from the same counters the
|
|
371
|
+
Prometheus endpoint exposes, with the window one click away.
|
|
372
|
+
- **A reconnecting viewer is sent what it missed, not the whole ring.** Every frame
|
|
373
|
+
carries an id and the server honours `Last-Event-ID`, so a laptop lid or a proxy
|
|
374
|
+
timeout costs a handful of frames rather than five hundred entries with their headers,
|
|
375
|
+
evidence and actor history attached. A cursor the server cannot honour gets the
|
|
376
|
+
backlog and an instruction to replace what the page holds.
|
|
377
|
+
- **One stats timer for every viewer** rather than one per viewer: sixteen browsers on
|
|
378
|
+
the same dashboard meant sixteen identical snapshot walks every two seconds.
|
|
379
|
+
- **Reset tells every viewer**, not just the browser that pressed it.
|
|
380
|
+
- **The dashboard's browser code is a real TypeScript module** under
|
|
381
|
+
`src/dashboard/client/`, type-checked, linted, unit-tested and bundled into the page
|
|
382
|
+
at build time by `npm run client:build`. It used to be two thousand lines of
|
|
383
|
+
JavaScript inside a template literal, which is how a call to a function nobody had
|
|
384
|
+
written shipped and blanked the whole Statistics tab: the type-checker could not see
|
|
385
|
+
it, Biome globs `*.ts` and this was a string, and no test could import a function out
|
|
386
|
+
of it. The pure parts — the search, the outcome classification, the rule drafting, the
|
|
387
|
+
replay formats — now have unit tests that run without a browser.
|
|
388
|
+
- **The header no longer dresses status as controls.** Five bordered pills carrying
|
|
389
|
+
configuration facts sat beside five identical-looking pills that were real buttons,
|
|
390
|
+
which is not a toolbar but a guessing game. The facts moved to the tab strip's
|
|
391
|
+
empty right-hand side as plain text, label first and value second — "suspect at
|
|
392
|
+
60", not "60 suspect at" — the links and actions are grouped and divided, and
|
|
393
|
+
Reset, which discards the actor registry, is finally styled as the destructive
|
|
394
|
+
action it is.
|
|
395
|
+
|
|
396
|
+
### Fixed (dashboard page)
|
|
397
|
+
|
|
398
|
+
- **The Statistics tab threw on every render.** `draw()` called `drawAudit()`, which
|
|
399
|
+
was never written — so the Audit panel had never once rendered, and the
|
|
400
|
+
`ReferenceError` aborted `drawStats()` on the very next line, leaving the panels
|
|
401
|
+
below it empty too. Implemented: the audit's window and its baseline side by side,
|
|
402
|
+
because a bot share of 60% is a number and 60% against a baseline of 12% is an
|
|
403
|
+
incident. Found by the browser suite on its first run.
|
|
404
|
+
|
|
405
|
+
- **The sticky column headers never stuck.** Two faults on top of each other. They
|
|
406
|
+
were pinned to a hard-coded `54px` — the top row alone, with the tab strip
|
|
407
|
+
unaccounted for — and the panel around them was `overflow: hidden`, which makes it
|
|
408
|
+
a scroll container and therefore the containing block for anything sticky inside
|
|
409
|
+
it. The offset is measured at runtime now, and the panel clips instead of hiding.
|
|
410
|
+
|
|
411
|
+
- **The header overlapped itself on a narrow window.** Its top row was a fixed 54px
|
|
412
|
+
with children that refused to shrink, so below about 900px the title and the live
|
|
413
|
+
indicator drew on top of one another. It wraps now, and nothing in the header has a
|
|
414
|
+
fixed height.
|
|
415
|
+
|
|
416
|
+
- **The feed table was amputated below about 900px** — the panel simply clipped the
|
|
417
|
+
right-hand columns. The duration column now goes first, and below that the table
|
|
418
|
+
scrolls inside its own box rather than losing data. The scroll container is scoped
|
|
419
|
+
to those widths on purpose: it would otherwise become the containing block for the
|
|
420
|
+
sticky column headers and cost them on every screen.
|
|
421
|
+
|
|
422
|
+
- **The skip link was not reachable by Tab.** Parked at `top: -60px`, its whole
|
|
423
|
+
border box sat outside the viewport, and sequential focus navigation drops those —
|
|
424
|
+
so the one control that exists purely for keyboard users was the one control a
|
|
425
|
+
keyboard could not get to. Clipped rather than moved now.
|
|
426
|
+
|
|
427
|
+
- **A slow viewer could grow this process without limit.** `response.write()` returns
|
|
428
|
+
false when the socket's buffer is full and nothing looked at it, so the frames for a
|
|
429
|
+
viewer that had stopped reading — a laptop that slept with its tab open, a phone in a
|
|
430
|
+
tunnel, a proxy that stopped draining — accumulated in memory, one queue per viewer.
|
|
431
|
+
`maxEventsPerSecond` does not help: it bounds a rate and this is a backlog. A stream
|
|
432
|
+
that reports itself full is now sent no feed entries until it drains, is told how many
|
|
433
|
+
it missed when it catches up, and is ended after twenty seconds of never draining —
|
|
434
|
+
after which the browser reconnects and resumes from its cursor.
|
|
435
|
+
|
|
436
|
+
- **Accessibility defects nobody had noticed by looking**, found by the new axe pass and
|
|
437
|
+
then by widening it. Solid buttons put white text on the series blue at about 3.9:1,
|
|
438
|
+
under the 4.5 floor — they have a darker accent of their own now, and the charts keep
|
|
439
|
+
the validated series colour. Feed rows were `role="button"` with the actor link inside
|
|
440
|
+
them, which is a control nested in a control; each row's method-and-path is a real
|
|
441
|
+
disclosure button now, and clicking anywhere in the row still opens it.
|
|
442
|
+
|
|
443
|
+
The first version of that pass ran in light mode at one width and passed, while the
|
|
444
|
+
dark palette had a serious contrast failure on every piece of weak evidence. Widened
|
|
445
|
+
to both themes and both widths, it also found that the page had **no `h1` at all** —
|
|
446
|
+
"jump to the heading" had nothing to jump to — and an unlabelled column header. Four
|
|
447
|
+
more inks failed once measured in the contexts they actually appear in: a suspected
|
|
448
|
+
badge is an amber ink on a 16%-amber chip on a row that may itself be tinted, which is
|
|
449
|
+
three surfaces deep and passes at every stage but the last. The amber and the muted
|
|
450
|
+
inks are darker now, and a test measures **every** badge, action, tier and caption
|
|
451
|
+
against **every** surface it can land on, in both themes — because axe can only audit
|
|
452
|
+
the badges the traffic happened to produce that afternoon.
|
|
453
|
+
|
|
454
|
+
- **A half-pressed confirmation could be reset under the operator's cursor.** The Actors
|
|
455
|
+
screen refreshes every couple of seconds and a refresh rebuilds its buttons, so
|
|
456
|
+
somebody who clicked "Allowlist", read the address it offered back and reached for the
|
|
457
|
+
second click could find the second click had merely armed it again. Which teaches
|
|
458
|
+
people that the way through a confirmation is to click it twice, quickly — the exact
|
|
459
|
+
habit a confirmation exists to prevent. The list holds still while somebody is
|
|
460
|
+
deciding.
|
|
461
|
+
|
|
462
|
+
- **The guard form's labels were not attached to anything.** Four controls with visible
|
|
463
|
+
captions and no association, which reads as "edit, blank" to a screen reader. Both
|
|
464
|
+
form builders use one helper now that either points the label at its control or gives
|
|
465
|
+
the group an accessible name.
|
|
466
|
+
|
|
467
|
+
- **Warnings raised outside the engine never reached `warning` subscribers.** The fetch
|
|
468
|
+
adapter's "I cannot determine a client address" — the one that means every visitor
|
|
469
|
+
collapses into a single actor — called `config.onWarning` directly, which reaches the
|
|
470
|
+
callback and not the event. So it never appeared in the dashboard's notices, where an
|
|
471
|
+
operator would actually see it. Both paths go through `warn()` now.
|
|
472
|
+
|
|
473
|
+
- **A CLI flag's value could be read as the request.** `bothandlerjs explain --json
|
|
474
|
+
curl/8.4.0` treated the User-Agent as the value of `--json`, found no positional
|
|
475
|
+
argument, and sat waiting on a pipe nobody was writing to — a command that looks hung,
|
|
476
|
+
and in a script is. Flags that take a value are named explicitly now, and only an
|
|
477
|
+
*absent* argument reads stdin.
|
|
478
|
+
|
|
479
|
+
- **Every warning reached `onWarning` twice.** `warn()` emits the `warning` event *and*
|
|
480
|
+
calls `config.onWarning`, and `onWarning` was also registered as a listener for that
|
|
481
|
+
event — so the one channel most likely to be wired to a pager was the one that
|
|
482
|
+
double-fired. Found while adding attribution to the runtime-change warnings.
|
|
483
|
+
|
|
484
|
+
- **A User-Agent beginning with `curl` was read as a curl command.** In the new request
|
|
485
|
+
tester, `curl/8.4.0` — the single most likely thing anybody pastes, since it is what
|
|
486
|
+
the feed shows next to the request they came to ask about — parsed as a command with
|
|
487
|
+
no arguments, producing a request with no User-Agent and a verdict of "unknown". Which
|
|
488
|
+
is the most misleading answer available, because it looks like an answer.
|
|
489
|
+
|
|
490
|
+
- **The feed table was still being clipped, on most desktops.** The breakpoints that
|
|
491
|
+
drop a column when it will not fit were media queries, and the box the table has to
|
|
492
|
+
fit inside is the panel — one column of a two-column grid, about 880px wide at a
|
|
493
|
+
1400px viewport. So the 980px breakpoint never fired where it was needed and the
|
|
494
|
+
right-hand columns were simply not drawn. They are container queries now, measuring
|
|
495
|
+
the panel, and a test walks ten widths asserting the table either fits or scrolls and
|
|
496
|
+
is never clipped.
|
|
497
|
+
|
|
498
|
+
- **A title or link label containing `$&` would have rewritten the page around it.**
|
|
499
|
+
The page template used string replacements, and `String.prototype.replace` reads
|
|
500
|
+
`$&`, `` $` `` and `$'` out of a string replacement and substitutes match context for
|
|
501
|
+
them. Function replacements throughout now.
|
|
502
|
+
|
|
503
|
+
### Changed
|
|
504
|
+
|
|
505
|
+
- Test coverage of the adapters went from 47% to 94%. They are the only code
|
|
506
|
+
here that every user runs, and the Fastify adapter had never executed in a
|
|
507
|
+
test at all.
|
|
508
|
+
- Linting with Biome (`npm run lint`), matching the configuration used across
|
|
509
|
+
the sibling projects, and a coverage ratchet (`npm run test:coverage`).
|
|
510
|
+
- CI now lints, runs on Node 24 as well as 20 and 22, and checks the shape of
|
|
511
|
+
the published package: zero runtime dependencies, no non-`node:` runtime
|
|
512
|
+
imports, no advisories reaching a consumer, and a bounded tarball.
|
|
513
|
+
|
|
514
|
+
## [0.1.0]
|
|
515
|
+
|
|
516
|
+
- Initial release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
OSQD Non-Resale License, Version 1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michał Płatosz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person or organization
|
|
6
|
+
obtaining a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to use, copy, modify, merge, publish, and distribute the Software
|
|
8
|
+
and to create and distribute Derivative Works, subject to the conditions below.
|
|
9
|
+
|
|
10
|
+
"Derivative Work" means any work based on the Software, including a modified
|
|
11
|
+
copy, a translation, or a work that incorporates any substantial portion of it.
|
|
12
|
+
|
|
13
|
+
"You" means the person or organization exercising the permissions granted here.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
1. NOTICE
|
|
17
|
+
|
|
18
|
+
You must include the above copyright notice and this entire License in all
|
|
19
|
+
copies or substantial portions of the Software and in every Derivative Work
|
|
20
|
+
you distribute, whether or not you have modified it.
|
|
21
|
+
|
|
22
|
+
You must not remove, obscure, or alter any copyright, attribution, or license
|
|
23
|
+
notice contained in the Software.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
2. NO SALE
|
|
27
|
+
|
|
28
|
+
You must not Sell the Software.
|
|
29
|
+
|
|
30
|
+
"Sell" means charging any fee, of any kind, where the Software or a Derivative
|
|
31
|
+
Work is what is being paid for. This includes, without limitation:
|
|
32
|
+
|
|
33
|
+
(a) distributing copies of the Software or a Derivative Work for a fee;
|
|
34
|
+
|
|
35
|
+
(b) offering the Software or a Derivative Work to third parties as a hosted,
|
|
36
|
+
managed, or software-as-a-service product for a fee;
|
|
37
|
+
|
|
38
|
+
(c) including the Software or a Derivative Work in any product or service you
|
|
39
|
+
charge for, where the Software forms a substantial part of the value you
|
|
40
|
+
are offering; or
|
|
41
|
+
|
|
42
|
+
(d) sublicensing or relicensing the Software for a fee.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
3. WHAT IS EXPLICITLY PERMITTED
|
|
46
|
+
|
|
47
|
+
For the avoidance of doubt, none of the following is Selling, and all of it is
|
|
48
|
+
permitted, including by a commercial business and in production:
|
|
49
|
+
|
|
50
|
+
(a) using the Software, or a Derivative Work of it, to operate, monitor, or
|
|
51
|
+
protect your own systems — including the systems of the business you work
|
|
52
|
+
for, and including systems you charge your own customers to access, so
|
|
53
|
+
long as the Software itself is not part of what they are paying for;
|
|
54
|
+
|
|
55
|
+
(b) charging for your own professional services — consulting, integration,
|
|
56
|
+
deployment, operation, or support — provided the fee is for your services
|
|
57
|
+
and not for the Software, and you supply any copy of the Software to the
|
|
58
|
+
customer under this License at no charge;
|
|
59
|
+
|
|
60
|
+
(c) modifying the Software for any purpose, internal or otherwise, and keeping
|
|
61
|
+
those modifications private;
|
|
62
|
+
|
|
63
|
+
(d) distributing the Software or a Derivative Work at no charge; and
|
|
64
|
+
|
|
65
|
+
(e) using the Software for personal, educational, academic, research, or
|
|
66
|
+
non-profit purposes of any kind.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
4. SEPARATE COMMERCIAL TERMS
|
|
70
|
+
|
|
71
|
+
If you want to do something this License does not permit, the copyright holder
|
|
72
|
+
can grant you a separate license on different terms. Ask.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
5. TERMINATION
|
|
76
|
+
|
|
77
|
+
If you breach this License, the permissions granted to you terminate
|
|
78
|
+
automatically. If the breach is curable and you cure it within 30 days of
|
|
79
|
+
becoming aware of it, your permissions are reinstated retroactively. Any
|
|
80
|
+
permissions granted to those who received the Software from you in compliance
|
|
81
|
+
with this License are unaffected by your termination.
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
6. THIRD-PARTY COMPONENTS
|
|
85
|
+
|
|
86
|
+
This License covers only the Software itself. Dependencies distributed
|
|
87
|
+
alongside or installed with it remain under their own licenses.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
7. NO WARRANTY
|
|
91
|
+
|
|
92
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
93
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
94
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
8. LIMITATION OF LIABILITY
|
|
98
|
+
|
|
99
|
+
IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
|
100
|
+
DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
|
|
101
|
+
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
102
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|