knowless 0.2.0 → 0.2.1
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 +88 -2
- package/GUIDE.md +131 -0
- package/README.md +1 -1
- package/knowless.context.md +167 -1
- package/package.json +1 -1
- package/src/handlers.js +46 -3
- package/src/index.js +98 -1
- package/src/mailer.js +21 -0
- package/src/store.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,11 +18,97 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
18
18
|
- **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
|
|
19
19
|
bundling Postfix + null-route + the binary. Now meaningfully
|
|
20
20
|
smaller and faster to build because v0.2.0 dropped the native
|
|
21
|
-
compile dep.
|
|
21
|
+
compile dep.
|
|
22
22
|
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
23
23
|
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
24
24
|
test message to `shamRecipient` and confirms the local MTA
|
|
25
|
-
discarded it.
|
|
25
|
+
discarded it.
|
|
26
|
+
|
|
27
|
+
## [0.2.1] — 2026-04-29
|
|
28
|
+
|
|
29
|
+
**Operator visibility, opt-in.** Three event hooks + one method,
|
|
30
|
+
the full surface forum + addypin asked for during the v0.2.0
|
|
31
|
+
integration spike. The shape was negotiated against
|
|
32
|
+
`walk-away-at-v1.0.0` (PRD §6.3): every "obvious" addition was
|
|
33
|
+
deliberately rejected if it could be done in adopter or perimeter
|
|
34
|
+
code. See `knowless.context.md` § "What's NOT in knowless, and why"
|
|
35
|
+
for the rejected-by-design list (disposable-domain check, account-age
|
|
36
|
+
accessor, hashcash, `lookupMessageId()`, `onShamHit`).
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`onMailerSubmit({messageId, handle, timestamp})` (AF-19).**
|
|
41
|
+
Per-event hook fired on successful SMTP submission for *real*
|
|
42
|
+
(non-sham) sends only. Adopters log it, build msg_id ↔ handle
|
|
43
|
+
correlation maps, or pipe to structured logging. Knowless never
|
|
44
|
+
stores the mapping. Sham branches deliberately do NOT fire this
|
|
45
|
+
hook — that's the load-bearing NFR-10 invariant (would let a
|
|
46
|
+
careless adopter log per-handle data and reopen the enumeration
|
|
47
|
+
oracle that sham-work was designed to prevent).
|
|
48
|
+
- **`onTransportFailure({error, timestamp}) (AF-19).** Per-event
|
|
49
|
+
hook fired on SMTP errors. No identity data — safe per-event,
|
|
50
|
+
safe to alert on.
|
|
51
|
+
- **`onSuppressionWindow({sham, rateLimited, windowMs})` (AF-19).**
|
|
52
|
+
Heartbeat hook fired every `suppressionWindowMs` (default 60s)
|
|
53
|
+
with aggregate counters across all silent-202 branches: sham
|
|
54
|
+
hits, `login_ip` cap, `create_ip` cap (counted both as sham and
|
|
55
|
+
rate-limited when fall-through happens), and per-handle token-cap
|
|
56
|
+
rotation. Heartbeats fire even when both counters are zero — a
|
|
57
|
+
missing emission is itself diagnostic. Replaces a per-event
|
|
58
|
+
`onShamHit` / `onRateLimitHit` design that would have leaked
|
|
59
|
+
per-handle data through log lines; the windowed aggregate
|
|
60
|
+
preserves the spike signal without per-call distinguishability.
|
|
61
|
+
- **`auth.verifyTransport()` method (AF-20).** Wraps
|
|
62
|
+
`transport.verify()`. Resolves `Promise<true>` on non-rejection,
|
|
63
|
+
rejects with the underlying error. Adopters call this explicitly
|
|
64
|
+
when they want fail-fast on misconfigured SMTP at boot. **No
|
|
65
|
+
auto-on-boot variant by design (AF-21).** Deployments where
|
|
66
|
+
knowless starts before Postfix (docker-compose ordering, k8s
|
|
67
|
+
readiness probes) would fail boot for the wrong reason.
|
|
68
|
+
- **`startLogin` silent-202 documented (AF-22).** New gotcha #19 in
|
|
69
|
+
`knowless.context.md` and a Mode-A pointer in GUIDE.md make
|
|
70
|
+
explicit that `startLogin` returns `{handle, submitted: true}`
|
|
71
|
+
for every branch (real, sham, rate-limited, missing handle) by
|
|
72
|
+
design. Operators who need branch visibility wire the v0.2.1
|
|
73
|
+
hooks; the per-call return shape never reveals which branch ran.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **`store.insertToken` returns the eviction count.** Internal
|
|
78
|
+
store-interface change: `insertToken` now returns the number of
|
|
79
|
+
tokens evicted to make room for the new one (always `0` when
|
|
80
|
+
`maxActive` is `0`). Used by `runSendLink` to count per-handle
|
|
81
|
+
cap rotation events into the `rateLimited` counter. Adopters
|
|
82
|
+
with custom stores implementing the SPEC §13 interface should
|
|
83
|
+
update accordingly; the change is forward-compatible (returning
|
|
84
|
+
`undefined` is treated as zero evictions).
|
|
85
|
+
|
|
86
|
+
### Documentation (forum + addypin negotiation outcome)
|
|
87
|
+
|
|
88
|
+
- **knowless.context.md § "What's NOT in knowless, and why"** —
|
|
89
|
+
permanent record of three rejected-by-design additions
|
|
90
|
+
(disposable-domain check, account-age accessor, per-IP hashcash)
|
|
91
|
+
with the seam argument and walk-away-at-v1.0.0 framing. Future
|
|
92
|
+
contributors evaluating "should X go in knowless?" run two tests
|
|
93
|
+
before answering yes: identity layer vs behavior layer; mechanism
|
|
94
|
+
living with policy.
|
|
95
|
+
- **GUIDE.md FAQ** — "Why doesn't knowless block disposable email
|
|
96
|
+
domains?" + "How do I check how old a user is?" with adopter-side
|
|
97
|
+
code patterns for both. Closes the most likely "but-can-it" requests.
|
|
98
|
+
|
|
99
|
+
### Internal
|
|
100
|
+
|
|
101
|
+
- Hook errors are caught and swallowed via a single `safeHook()`
|
|
102
|
+
wrapper, matching the existing `onSweepError` contract. Knowless
|
|
103
|
+
never crashes because an operator's observability sink threw.
|
|
104
|
+
- Suppression-window timer is `unref()`'d and only started when
|
|
105
|
+
`onSuppressionWindow` is wired — adopters not using the hook
|
|
106
|
+
spend zero `setInterval` slots on it.
|
|
107
|
+
- 16 new tests in `test/integration/v021-hooks.test.js` covering
|
|
108
|
+
payload shapes, the sham-no-fire invariant, aggregation
|
|
109
|
+
semantics, heartbeat behavior, counter reset, hook-error
|
|
110
|
+
containment, and `verifyTransport()` resolve/reject paths.
|
|
111
|
+
Test count: 192 → 207.
|
|
26
112
|
|
|
27
113
|
## [0.2.0] — 2026-04-28
|
|
28
114
|
|
package/GUIDE.md
CHANGED
|
@@ -295,6 +295,11 @@ return identical shapes — the caller can't observe which happened.
|
|
|
295
295
|
This preserves FR-6 timing equivalence even for programmatic
|
|
296
296
|
callers. See SPEC §7.3a for the full contract.
|
|
297
297
|
|
|
298
|
+
If you need *operator* visibility (not per-call: aggregate counts and
|
|
299
|
+
real-send confirmation), wire the v0.2.1 hooks documented in
|
|
300
|
+
[Step 8](#step-8-optional-operator-monitoring-via-event-hooks-v021)
|
|
301
|
+
below — they emit without breaking the per-call silent-202 contract.
|
|
302
|
+
|
|
298
303
|
`auth.deriveHandle(email)` returns the same opaque HMAC handle
|
|
299
304
|
that the form path uses, without you having to import the helper
|
|
300
305
|
or pass the secret around. The instance method **normalizes the
|
|
@@ -469,6 +474,76 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
|
|
|
469
474
|
chooses the UX (admin CLI, in-app self-service, ticket-driven
|
|
470
475
|
support).
|
|
471
476
|
|
|
477
|
+
### Step 8 (optional): Operator monitoring via event hooks (v0.2.1+)
|
|
478
|
+
|
|
479
|
+
Three event hooks + one opt-in method. All optional, all opt-in. None
|
|
480
|
+
are required for correct operation; the library is fully functional
|
|
481
|
+
with zero hooks wired. They exist so an operator can wire knowless to
|
|
482
|
+
their existing observability stack (Prometheus, statsd, structured
|
|
483
|
+
logs, on-call paging) without knowless curating its own metrics shape.
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
const auth = knowless({
|
|
487
|
+
// ...required + existing options...
|
|
488
|
+
|
|
489
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => {
|
|
490
|
+
log.info('knowless.dispatch', { messageId, handle, ts: timestamp });
|
|
491
|
+
},
|
|
492
|
+
onTransportFailure: ({error, timestamp}) => {
|
|
493
|
+
log.error('knowless.smtp_failed', { err: error.message, ts: timestamp });
|
|
494
|
+
pager.notify('SMTP transport failed');
|
|
495
|
+
},
|
|
496
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => {
|
|
497
|
+
metrics.gauge('knowless.sham_count', sham);
|
|
498
|
+
metrics.gauge('knowless.rate_limited', rateLimited);
|
|
499
|
+
if (sham > BASELINE * 10) pager.notify('possible enumeration attack');
|
|
500
|
+
},
|
|
501
|
+
// suppressionWindowMs: 60_000, // default; configurable
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Optional: explicit transport probe at boot. No auto-probe by design.
|
|
505
|
+
try {
|
|
506
|
+
await auth.verifyTransport();
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error('SMTP unreachable at boot:', err);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### Why three hooks, not four
|
|
514
|
+
|
|
515
|
+
The two silent-202 branches — sham hits (handle does not exist) and
|
|
516
|
+
rate-limit hits (any of the three caps) — are bundled into one *windowed
|
|
517
|
+
aggregate* (`onSuppressionWindow`) rather than per-event hooks. Per-event
|
|
518
|
+
hooks would let a careless adopter log per-handle data, which is the
|
|
519
|
+
enumeration oracle that sham-work exists to prevent. The HTTP response
|
|
520
|
+
is silent on these branches; the log file must be silent too.
|
|
521
|
+
|
|
522
|
+
Operators still get the spike signal — a 10× jump in `sham` count over
|
|
523
|
+
the window is the enumeration-attack alarm. They don't get per-call
|
|
524
|
+
correlation to a specific handle, and they shouldn't have it.
|
|
525
|
+
|
|
526
|
+
`onMailerSubmit` is per-event because it fires *only* on real
|
|
527
|
+
submissions, where the handle was already disclosed by the form
|
|
528
|
+
input. `onTransportFailure` is per-event because it carries no
|
|
529
|
+
identity data.
|
|
530
|
+
|
|
531
|
+
> **Don't log `onSuppressionWindow` payloads in a way that distinguishes
|
|
532
|
+
> them from `onMailerSubmit` at the log-line level.** The aggregate
|
|
533
|
+
> count is fine; the line itself should be cleanly labeled as a periodic
|
|
534
|
+
> counter emission, not "a sham just happened." If your log shipper or
|
|
535
|
+
> dashboard groups them differently, you've put back the per-event
|
|
536
|
+
> distinguishability the bundling was meant to remove.
|
|
537
|
+
|
|
538
|
+
#### Why `verifyTransport()` is opt-in
|
|
539
|
+
|
|
540
|
+
No auto-on-boot variant exists by design. Deployments where knowless
|
|
541
|
+
starts before Postfix (docker-compose ordering, k8s readiness probes
|
|
542
|
+
that run knowless before the SMTP container is healthy) would fail
|
|
543
|
+
boot for the wrong reason. Adopters who want fail-fast call
|
|
544
|
+
`verifyTransport()` explicitly; everyone else gets eventually-consistent
|
|
545
|
+
SMTP startup.
|
|
546
|
+
|
|
472
547
|
## Walkthrough: standalone server mode
|
|
473
548
|
|
|
474
549
|
Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
|
|
@@ -555,6 +630,62 @@ Full options table:
|
|
|
555
630
|
|
|
556
631
|
## FAQ
|
|
557
632
|
|
|
633
|
+
### Why doesn't knowless block disposable email domains?
|
|
634
|
+
|
|
635
|
+
Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
|
|
636
|
+
adopter policy, not identity layer. The blocklist is a public GitHub
|
|
637
|
+
repo, the override list is operator-specific, and the cron to refresh
|
|
638
|
+
it lives in your ops layer. Putting the *mechanism* in knowless while
|
|
639
|
+
the *list curation* and *overrides* live in the adopter is the wrong
|
|
640
|
+
seam — both stay together in your form handler.
|
|
641
|
+
|
|
642
|
+
```js
|
|
643
|
+
// In your /login form handler, before calling auth.login:
|
|
644
|
+
import { DISPOSABLE_DOMAINS, ADOPTER_OVERRIDES } from './disposable-domains.js';
|
|
645
|
+
|
|
646
|
+
app.post('/login', async (req, res) => {
|
|
647
|
+
const email = /* parse from body */;
|
|
648
|
+
const domain = email.split('@')[1]?.toLowerCase();
|
|
649
|
+
if (domain && DISPOSABLE_DOMAINS.has(domain) && !ADOPTER_OVERRIDES.has(domain)) {
|
|
650
|
+
// Reply with the same shape as auth.login's success/sham response
|
|
651
|
+
// to preserve FR-6-equivalent timing at your layer too. Match
|
|
652
|
+
// status, headers, and body that auth.login would emit.
|
|
653
|
+
return res.status(200).type('html').send(/* same confirmation HTML */);
|
|
654
|
+
}
|
|
655
|
+
return auth.login(req, res);
|
|
656
|
+
});
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The same argument applies to per-IP hashcash / proof-of-work: if
|
|
660
|
+
`maxNewHandlesPerIpPerHour: 3` isn't enough for your threat model,
|
|
661
|
+
run hashcash at Caddy or your edge layer — knowless's login form
|
|
662
|
+
deliberately stays zero-JS.
|
|
663
|
+
|
|
664
|
+
### How do I check how old a handle / user is?
|
|
665
|
+
|
|
666
|
+
knowless deliberately doesn't expose handle creation dates. The reason:
|
|
667
|
+
"first time this email hit knowless" is rarely the trust signal you
|
|
668
|
+
actually want — you want "first time this user did something meaningful
|
|
669
|
+
in *my app*." A six-month-old knowless handle that has never posted
|
|
670
|
+
has zero application tenure.
|
|
671
|
+
|
|
672
|
+
Pattern: track `(handle, first_seen_at)` in your own table the first
|
|
673
|
+
time a handle performs the action you care about (first post, first
|
|
674
|
+
purchase, first non-trivial API call). Bucket by your tenure, not
|
|
675
|
+
knowless's.
|
|
676
|
+
|
|
677
|
+
```js
|
|
678
|
+
// On the action you care about:
|
|
679
|
+
const handle = auth.handleFromRequest(req);
|
|
680
|
+
db.recordFirstSeen(handle, Date.now()); // INSERT OR IGNORE
|
|
681
|
+
const age = db.ageBucketFor(handle); // 'new' | '1mo' | '1y' | '5y+'
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
This is also safer: returning a `Date | null` keyed by handle is itself
|
|
685
|
+
an enumeration oracle (null leaks "this handle doesn't exist"). Bucket
|
|
686
|
+
on your side from a table that only knows about handles that have
|
|
687
|
+
already acted.
|
|
688
|
+
|
|
558
689
|
### My users say magic links land in spam.
|
|
559
690
|
|
|
560
691
|
This is operator infrastructure, not the library. The library
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ that don't need to email their users for anything but the sign-in link.
|
|
|
7
7
|
npm install knowless
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
> v0.2.
|
|
10
|
+
> v0.2.1 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## Why this exists
|
|
13
13
|
|
package/knowless.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# knowless -- Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring knowless into a project.
|
|
4
|
-
> v0.2.
|
|
4
|
+
> v0.2.1 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
|
|
5
5
|
|
|
6
6
|
## What this is
|
|
7
7
|
|
|
@@ -122,6 +122,17 @@ const auth = knowless({
|
|
|
122
122
|
sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
|
|
123
123
|
onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
|
|
124
124
|
|
|
125
|
+
// --- Operator visibility (v0.2.1, all opt-in) ---
|
|
126
|
+
// Per-event hooks. Errors swallowed (matches onSweepError contract).
|
|
127
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
|
|
128
|
+
onTransportFailure: ({error, timestamp}) => { /* */ },
|
|
129
|
+
// Heartbeat aggregate. Default 60s; emits even when both counters
|
|
130
|
+
// are zero. See "Operator visibility" section for the threat-model
|
|
131
|
+
// reasoning behind aggregating sham + rate-limit branches here
|
|
132
|
+
// rather than emitting per-event.
|
|
133
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
|
|
134
|
+
suppressionWindowMs: 60_000,
|
|
135
|
+
|
|
125
136
|
// --- Dev mode (AF-6.2) ---
|
|
126
137
|
// When SMTP submission fails AND this flag is true, the magic link
|
|
127
138
|
// is printed to stderr so a developer can click through. Off by
|
|
@@ -152,6 +163,7 @@ const auth = knowless({
|
|
|
152
163
|
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
|
|
153
164
|
| `deriveHandle` | (email: string) | string | `HMAC-SHA256(secret, normalize(email))` using the configured secret. Normalizes input (lowercase + trim) so `Alice@X.com` and `alice@x.com` produce the same handle. Match what `startLogin` and `POST /login` compute. AF-7.4 / AF-13. |
|
|
154
165
|
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
166
|
+
| `verifyTransport` | -- | Promise\<true\> | Probe the configured SMTP transport (v0.2.1). Resolves true on success, rejects with the underlying error. Adopters call this explicitly when they want fail-fast on misconfigured SMTP at boot — no auto-on-boot variant by design (k8s readiness probes / docker-compose ordering would fail boot for the wrong reason). AF-20. |
|
|
155
167
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
156
168
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
157
169
|
|
|
@@ -173,6 +185,91 @@ import {
|
|
|
173
185
|
} from 'knowless';
|
|
174
186
|
```
|
|
175
187
|
|
|
188
|
+
## Operator visibility (v0.2.1)
|
|
189
|
+
|
|
190
|
+
Three event hooks + one opt-in method, shipped in v0.2.1. Future
|
|
191
|
+
contributors reading this section before extending the surface: do not
|
|
192
|
+
add a per-event `onShamHit`, do not add a per-handle `onRateLimitHit`,
|
|
193
|
+
do not add an auto-on-boot probe, do not add a `lookupMessageId()`
|
|
194
|
+
endpoint. Each was considered and deliberately rejected during the
|
|
195
|
+
forum + addypin negotiation that produced this surface (PRD §17.3,
|
|
196
|
+
v0.2.1) — see "What's NOT in knowless" below for the reasoning.
|
|
197
|
+
|
|
198
|
+
### Three hooks (factory options)
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
const auth = knowless({
|
|
202
|
+
// ...required + existing options...
|
|
203
|
+
|
|
204
|
+
// Per-event, safe to log per-call.
|
|
205
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
|
|
206
|
+
onTransportFailure: ({error, timestamp}) => { /* */ },
|
|
207
|
+
|
|
208
|
+
// Batched aggregate. Fires every windowMs regardless of count
|
|
209
|
+
// (heartbeat). Default cadence 60s.
|
|
210
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
|
|
211
|
+
suppressionWindowMs: 60_000,
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Field types:
|
|
216
|
+
- `messageId`: string — SMTP `Message-ID` returned by nodemailer
|
|
217
|
+
- `handle`: string — 64-char hex; only emitted on real (non-sham) submits
|
|
218
|
+
- `timestamp`: number — epoch ms
|
|
219
|
+
- `error`: Error
|
|
220
|
+
- `sham`, `rateLimited`: integer counters, count within the window
|
|
221
|
+
- `windowMs`: integer — the configured window length, echoed in the payload
|
|
222
|
+
|
|
223
|
+
Errors thrown from hooks are caught and swallowed (matches the existing
|
|
224
|
+
`onSweepError` contract); knowless does not depend on hook delivery for
|
|
225
|
+
correctness.
|
|
226
|
+
|
|
227
|
+
### Method
|
|
228
|
+
|
|
229
|
+
`auth.verifyTransport()` — wraps `transport.verify()` on the configured
|
|
230
|
+
SMTP transport. Returns `Promise<true>` on success, rejects with the
|
|
231
|
+
underlying error. Adopters call this explicitly when they want fail-fast
|
|
232
|
+
on misconfigured SMTP at boot. **No auto-on-boot variant** by design:
|
|
233
|
+
deployments where knowless starts before Postfix (docker-compose
|
|
234
|
+
ordering, k8s readiness probes) would fail boot for the wrong reason.
|
|
235
|
+
|
|
236
|
+
### Threat-model justification (the durable part)
|
|
237
|
+
|
|
238
|
+
The two silent-202 branches — sham (handle does not exist) and rate-limit
|
|
239
|
+
(any of the three caps) — are aggregated rather than per-event because
|
|
240
|
+
**NFR-10 timing equivalence applies at the log layer too**, not just the
|
|
241
|
+
HTTP response. A per-event `onShamHit({handle})` lets a careless adopter
|
|
242
|
+
log "sham detected for X" and the log file becomes an enumeration oracle
|
|
243
|
+
— the exact thing sham-work was designed to prevent. The response is
|
|
244
|
+
silent; the log must be silent too.
|
|
245
|
+
|
|
246
|
+
Knowless has three rate limits, and one of them is identity-tied:
|
|
247
|
+
- `maxLoginRequestsPerIpPerHour` — IP-keyed
|
|
248
|
+
- `maxNewHandlesPerIpPerHour` — IP-keyed
|
|
249
|
+
- `maxActiveTokensPerHandle` — **handle-keyed; per-event hits leak
|
|
250
|
+
"this handle exists and has hit a token cap"**
|
|
251
|
+
|
|
252
|
+
Splitting per-event-IP from per-event-handle works in theory and fails
|
|
253
|
+
in practice — future contributor sees the asymmetry and adds the missing
|
|
254
|
+
handle variant for symmetry. Bundling all three into the windowed
|
|
255
|
+
aggregate forecloses that drift.
|
|
256
|
+
|
|
257
|
+
`onMailerSubmit` carries `handle` per-event because it fires *only on
|
|
258
|
+
real submissions*, where the handle was already disclosed to knowless
|
|
259
|
+
by the form input. Emitting it back to the adopter is not a new leak.
|
|
260
|
+
`onTransportFailure` carries no identity data, per-event safe.
|
|
261
|
+
|
|
262
|
+
### Why no `lookupMessageId()` endpoint
|
|
263
|
+
|
|
264
|
+
An earlier proposal added an authenticated `auth.lookupMessageId(id)`
|
|
265
|
+
behind an operator secret so operators could correlate maillog entries
|
|
266
|
+
to handles. Rejected: the same capability is achievable by the adopter
|
|
267
|
+
maintaining their own `(messageId → handle)` map, populated from
|
|
268
|
+
`onMailerSubmit`. Knowless never stores the mapping, never exposes a
|
|
269
|
+
new authenticated surface, never carries operator-secret rotation
|
|
270
|
+
burden. The hook is the mechanism; the correlation map is adopter
|
|
271
|
+
choice.
|
|
272
|
+
|
|
176
273
|
## Handle / token / session lifecycles
|
|
177
274
|
|
|
178
275
|
```
|
|
@@ -352,6 +449,66 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
|
352
449
|
| `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
|
|
353
450
|
| `src/form.js` | ~110 | Hardcoded login HTML |
|
|
354
451
|
|
|
452
|
+
## What's NOT in knowless, and why
|
|
453
|
+
|
|
454
|
+
Three capabilities that look like they belong here but don't, listed
|
|
455
|
+
because the "why not" needs to outlast walk-away-at-v1.0.0. When future
|
|
456
|
+
contributors propose adding any of these back, point them here.
|
|
457
|
+
|
|
458
|
+
### Disposable-domain blocking — adopter / form handler
|
|
459
|
+
|
|
460
|
+
Reject `mailinator.com` etc. before knowless sees the submission.
|
|
461
|
+
Mechanism + list + override + weekly cron all live in the adopter's
|
|
462
|
+
form handler.
|
|
463
|
+
|
|
464
|
+
The argument for putting this in knowless was timing equivalence: if
|
|
465
|
+
the adopter rejects fast, an attacker times the response and learns
|
|
466
|
+
"this domain is on a public blocklist." Counter: the blocklist is a
|
|
467
|
+
public GitHub repo (`disposable-email-domains/disposable-email-domains`).
|
|
468
|
+
Anyone can fetch it directly. Timing-equivalence here protects information
|
|
469
|
+
that isn't secret. Knowless's sham-work protects against email
|
|
470
|
+
*enumeration* (is `alice@x.com` registered?), not domain *classification*
|
|
471
|
+
(is `x.com` on a public list?). Different threat, different defense.
|
|
472
|
+
|
|
473
|
+
Splitting mechanism (knowless) from policy + list curation (adopter) is
|
|
474
|
+
the wrong seam. Both stay in the adopter's form handler.
|
|
475
|
+
|
|
476
|
+
### App-tenure / account-age — adopter / first-seen tracking
|
|
477
|
+
|
|
478
|
+
Knowless's "handle creation date" is when this email first hit knowless.
|
|
479
|
+
The adopter's interesting question is "how long has this user been
|
|
480
|
+
participating in *my app*" — a different number, and the adopter's
|
|
481
|
+
number is the one that should drive trust decisions.
|
|
482
|
+
|
|
483
|
+
Concrete failure mode: a handle registered with knowless six months ago
|
|
484
|
+
but never posted has zero app-tenure. If the adopter reads knowless's
|
|
485
|
+
age, a brand-new spammer with an old handle gets unearned credibility.
|
|
486
|
+
|
|
487
|
+
Pattern: adopter stores `(handle, first_seen_at)` the first time it sees
|
|
488
|
+
a handle perform a meaningful action. App-tenure is app-derived. Knowless
|
|
489
|
+
doesn't expose age data — and wouldn't even if it could, because
|
|
490
|
+
returning `Date | null` keyed by handle is itself an enumeration leak.
|
|
491
|
+
|
|
492
|
+
### Per-IP hashcash / proof-of-work — Caddy / perimeter layer
|
|
493
|
+
|
|
494
|
+
`maxNewHandlesPerIpPerHour: 3` already covers the ground hashcash would
|
|
495
|
+
cover. A botnet that can't get past three signups per IP per hour needs
|
|
496
|
+
IP rotation regardless; once rotated, a 2s hashcash is rounding error
|
|
497
|
+
at botnet economics. Costs are real: breaks Lynx/w3m (gotcha #10),
|
|
498
|
+
requires JS in the login form (the only zero-JS exception we'd carry),
|
|
499
|
+
~2s UX delay for legit users on weak devices. If a deployment observes
|
|
500
|
+
per-IP signup actually saturating the cap, Caddy (or another perimeter
|
|
501
|
+
layer) can run hashcash off-the-shelf without making knowless carry it.
|
|
502
|
+
|
|
503
|
+
### The deciding lens
|
|
504
|
+
|
|
505
|
+
knowless walks away at v1.0.0 (PRD §6.3). Every config option carried
|
|
506
|
+
into v1.0.0 is something v1.x has to keep stable through the
|
|
507
|
+
maintenance window. The test for any proposed addition: does this
|
|
508
|
+
belong in the **identity layer** (who they are) or the **behavior
|
|
509
|
+
layer** (what they did)? Identity layer is in scope. Behavior layer is
|
|
510
|
+
out. When unsure, default out — less surface, less carrying cost.
|
|
511
|
+
|
|
355
512
|
## Threat model summary
|
|
356
513
|
|
|
357
514
|
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
@@ -482,6 +639,15 @@ rate-limits) belongs above the library.
|
|
|
482
639
|
factory startup; fails fast. Goes after RFC 3676 `"-- "`
|
|
483
640
|
delimiter so mail clients strip it from quoted replies.
|
|
484
641
|
|
|
642
|
+
19. **`startLogin` is silent at every layer (FR-6).** Returns
|
|
643
|
+
`{handle, submitted: true}` for *every* branch — real send, sham,
|
|
644
|
+
rate-limited, missing-handle-with-`openRegistration:false`. Adopters
|
|
645
|
+
cannot derive the branch from the return value, by design.
|
|
646
|
+
Operator visibility comes from the v0.2.1 hooks (`onMailerSubmit`
|
|
647
|
+
per-event, `onSuppressionWindow` aggregated) — *not* from the
|
|
648
|
+
return shape. Don't wrap `startLogin` in something that surfaces
|
|
649
|
+
the branch to the caller; that re-opens the enumeration oracle.
|
|
650
|
+
|
|
485
651
|
## Constraints
|
|
486
652
|
|
|
487
653
|
- **Node 20+** -- targeting LTS; tested on Node 22
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/handlers.js
CHANGED
|
@@ -163,7 +163,20 @@ function sidHashOf(sid) {
|
|
|
163
163
|
* validateNextUrl: (raw:string)=>string|null
|
|
164
164
|
* }}
|
|
165
165
|
*/
|
|
166
|
-
export function createHandlers({ store, mailer, config }) {
|
|
166
|
+
export function createHandlers({ store, mailer, config, events }) {
|
|
167
|
+
// v0.2.1 operator-visibility hooks. All optional; treat missing as
|
|
168
|
+
// no-ops so the handler hot path is identical for adopters who don't
|
|
169
|
+
// wire them. The factory passes a fully-populated `events` object;
|
|
170
|
+
// direct callers of createHandlers (tests / advanced wiring) may omit
|
|
171
|
+
// it entirely.
|
|
172
|
+
const noop = () => {};
|
|
173
|
+
const ev = {
|
|
174
|
+
shamHit: events?.shamHit ?? noop,
|
|
175
|
+
rateLimitHit: events?.rateLimitHit ?? noop,
|
|
176
|
+
onMailerSubmit: events?.onMailerSubmit ?? noop,
|
|
177
|
+
onTransportFailure: events?.onTransportFailure ?? noop,
|
|
178
|
+
};
|
|
179
|
+
|
|
167
180
|
const cfg = { ...DEFAULTS, ...config };
|
|
168
181
|
if (!cfg.secret) throw new Error('config.secret required');
|
|
169
182
|
if (typeof cfg.secret !== 'string' || cfg.secret.length < 64) {
|
|
@@ -267,6 +280,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
267
280
|
HOUR_MS,
|
|
268
281
|
)
|
|
269
282
|
) {
|
|
283
|
+
ev.rateLimitHit();
|
|
270
284
|
return { handle: null, isSham: false, emailNorm, nextValidated: null };
|
|
271
285
|
}
|
|
272
286
|
|
|
@@ -287,6 +301,11 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
287
301
|
)
|
|
288
302
|
) {
|
|
289
303
|
// Cap exceeded — fall through to sham, do NOT short-circuit.
|
|
304
|
+
// The fall-through becomes a sham-hit too; both counters
|
|
305
|
+
// increment because they're independent dimensions (operator
|
|
306
|
+
// can correlate from `rateLimited` jumping in lockstep with
|
|
307
|
+
// `sham`).
|
|
308
|
+
ev.rateLimitHit();
|
|
290
309
|
isCreating = false;
|
|
291
310
|
}
|
|
292
311
|
}
|
|
@@ -308,9 +327,10 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
308
327
|
} else {
|
|
309
328
|
isSham = true;
|
|
310
329
|
toAddress = cfg.shamRecipient;
|
|
330
|
+
ev.shamHit();
|
|
311
331
|
}
|
|
312
332
|
|
|
313
|
-
store.insertToken({
|
|
333
|
+
const evicted = store.insertToken({
|
|
314
334
|
tokenHash: token.hash,
|
|
315
335
|
handle,
|
|
316
336
|
expiresAt,
|
|
@@ -318,6 +338,10 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
318
338
|
isSham,
|
|
319
339
|
maxActive: cfg.maxActiveTokensPerHandle,
|
|
320
340
|
});
|
|
341
|
+
// Per-handle token cap rotation is the third rate limit. Counted
|
|
342
|
+
// here in the aggregate `rateLimited` window so operators see
|
|
343
|
+
// hammering of a single handle without per-event identity leakage.
|
|
344
|
+
if (evicted > 0) ev.rateLimitHit();
|
|
321
345
|
|
|
322
346
|
const mailBody = composeBody({
|
|
323
347
|
tokenRaw: token.raw,
|
|
@@ -334,10 +358,29 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
334
358
|
// so timing equivalence is preserved.
|
|
335
359
|
const effectiveSubject = subject ?? cfg.subject;
|
|
336
360
|
try {
|
|
337
|
-
await mailer.submit({
|
|
361
|
+
const info = await mailer.submit({
|
|
362
|
+
to: toAddress,
|
|
363
|
+
subject: effectiveSubject,
|
|
364
|
+
body: mailBody,
|
|
365
|
+
});
|
|
366
|
+
// v0.2.1: per-event hook on real (non-sham) submissions only.
|
|
367
|
+
// Sham branches go through the windowed aggregate; emitting them
|
|
368
|
+
// per-event here would let a careless adopter log per-handle
|
|
369
|
+
// data and reopen the enumeration oracle that sham-work exists
|
|
370
|
+
// to prevent.
|
|
371
|
+
if (!isSham) {
|
|
372
|
+
ev.onMailerSubmit({
|
|
373
|
+
messageId: info?.messageId ?? null,
|
|
374
|
+
handle,
|
|
375
|
+
timestamp: Date.now(),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
338
378
|
} catch (err) {
|
|
339
379
|
// Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
|
|
340
380
|
console.error('[knowless] mail submit failed:', err.message);
|
|
381
|
+
// v0.2.1: per-event hook for SMTP failures. Carries no identity
|
|
382
|
+
// data, safe per-event. Operator wires this to alerting.
|
|
383
|
+
ev.onTransportFailure({ error: err, timestamp: Date.now() });
|
|
341
384
|
// AF-6.2: dev-mode fallback. When SMTP is unreachable in local
|
|
342
385
|
// development the operator otherwise has no way to obtain the magic
|
|
343
386
|
// link. Print it to stderr only when explicitly opted in.
|
package/src/index.js
CHANGED
|
@@ -9,8 +9,27 @@ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
9
9
|
/** Default rate-limit retention: 24 hours past window-start. */
|
|
10
10
|
const DEFAULT_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
11
11
|
|
|
12
|
+
/** Default suppression-window cadence: 60 seconds. v0.2.1. */
|
|
13
|
+
const DEFAULT_SUPPRESSION_WINDOW_MS = 60 * 1000;
|
|
14
|
+
|
|
12
15
|
const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Wrap a user-supplied hook so its errors are caught and swallowed.
|
|
19
|
+
* Matches the `onSweepError` contract: knowless never crashes because
|
|
20
|
+
* an operator's observability sink threw.
|
|
21
|
+
*/
|
|
22
|
+
function safeHook(fn, label) {
|
|
23
|
+
if (typeof fn !== 'function') return () => {};
|
|
24
|
+
return (arg) => {
|
|
25
|
+
try {
|
|
26
|
+
fn(arg);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(`[knowless] ${label} hook threw:`, err?.message ?? err);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
/**
|
|
15
34
|
* @typedef {Object} KnowlessOptions
|
|
16
35
|
* @property {string} secret HMAC secret, ≥64 hex chars (32 bytes). FR-47, FR-48.
|
|
@@ -37,6 +56,21 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
|
|
|
37
56
|
* @property {string[]} [trustedProxies]
|
|
38
57
|
* @property {string} [shamRecipient='null@knowless.invalid'] See SPEC §7.4.
|
|
39
58
|
* @property {number} [sweepIntervalMs] Sweeper tick; defaults to 5 minutes.
|
|
59
|
+
* @property {function} [onSweepError] Optional alerting hook for sweep failures.
|
|
60
|
+
* @property {function} [onMailerSubmit] v0.2.1. Per-event hook fired on
|
|
61
|
+
* successful mail submission for *real* (non-sham) sends only. Payload:
|
|
62
|
+
* `{messageId, handle, timestamp}`. Errors are caught and swallowed.
|
|
63
|
+
* @property {function} [onTransportFailure] v0.2.1. Per-event hook fired
|
|
64
|
+
* on SMTP errors. Payload: `{error, timestamp}`. Errors swallowed.
|
|
65
|
+
* @property {function} [onSuppressionWindow] v0.2.1. Heartbeat hook fired
|
|
66
|
+
* every `suppressionWindowMs` with aggregate counters. Payload:
|
|
67
|
+
* `{sham, rateLimited, windowMs}`. Aggregates the silent-202 branches
|
|
68
|
+
* (sham + rate-limit hits) without per-event identity disclosure;
|
|
69
|
+
* see knowless.context.md § "v0.2.1 design" for the threat-model
|
|
70
|
+
* reasoning. Fires even when both counters are zero (heartbeat).
|
|
71
|
+
* Errors swallowed.
|
|
72
|
+
* @property {number} [suppressionWindowMs=60000] v0.2.1. Cadence of
|
|
73
|
+
* `onSuppressionWindow` emissions. Default 60 seconds.
|
|
40
74
|
* @property {object} [store] Inject your own store implementation.
|
|
41
75
|
* @property {object} [mailer] Inject your own mailer.
|
|
42
76
|
* @property {object} [transportOverride] Pass to nodemailer.createTransport (tests).
|
|
@@ -64,7 +98,12 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
|
|
|
64
98
|
* verify: Function,
|
|
65
99
|
* logout: Function,
|
|
66
100
|
* loginForm: Function,
|
|
101
|
+
* handleFromRequest: (req: any) => string | null,
|
|
67
102
|
* deleteHandle: (handle: string) => void,
|
|
103
|
+
* revokeSessions: (handle: string) => number,
|
|
104
|
+
* startLogin: (args: object) => Promise<{handle: string|null, submitted: true}>,
|
|
105
|
+
* deriveHandle: (email: string) => string,
|
|
106
|
+
* verifyTransport: () => Promise<true>,
|
|
68
107
|
* config: object,
|
|
69
108
|
* close: () => void,
|
|
70
109
|
* }}
|
|
@@ -102,7 +141,58 @@ export function knowless(options = {}) {
|
|
|
102
141
|
transportOverride: options.transportOverride,
|
|
103
142
|
});
|
|
104
143
|
|
|
105
|
-
|
|
144
|
+
// v0.2.1 operator-visibility hooks. All optional. Validate types up
|
|
145
|
+
// front so a typo is caught at startup, not on the first hit.
|
|
146
|
+
for (const k of ['onMailerSubmit', 'onTransportFailure', 'onSuppressionWindow']) {
|
|
147
|
+
if (options[k] !== undefined && typeof options[k] !== 'function') {
|
|
148
|
+
throw new Error(`knowless: ${k} must be a function when provided`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const suppressionWindowMs =
|
|
152
|
+
options.suppressionWindowMs ?? DEFAULT_SUPPRESSION_WINDOW_MS;
|
|
153
|
+
if (
|
|
154
|
+
typeof suppressionWindowMs !== 'number' ||
|
|
155
|
+
!Number.isFinite(suppressionWindowMs) ||
|
|
156
|
+
suppressionWindowMs <= 0
|
|
157
|
+
) {
|
|
158
|
+
throw new Error('knowless: suppressionWindowMs must be a positive number');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Counters reset every windowMs. Aggregating sham + rate-limit
|
|
162
|
+
// branches behind a windowed counter (rather than per-event hooks)
|
|
163
|
+
// is the deliberate design — see knowless.context.md § "Why three
|
|
164
|
+
// hooks, not four" for the threat-model justification.
|
|
165
|
+
let shamCount = 0;
|
|
166
|
+
let rateLimitedCount = 0;
|
|
167
|
+
const onMailerSubmit = safeHook(options.onMailerSubmit, 'onMailerSubmit');
|
|
168
|
+
const onTransportFailure = safeHook(options.onTransportFailure, 'onTransportFailure');
|
|
169
|
+
const onSuppressionWindow = safeHook(options.onSuppressionWindow, 'onSuppressionWindow');
|
|
170
|
+
|
|
171
|
+
const events = {
|
|
172
|
+
shamHit: () => { shamCount++; },
|
|
173
|
+
rateLimitHit: () => { rateLimitedCount++; },
|
|
174
|
+
onMailerSubmit,
|
|
175
|
+
onTransportFailure,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const handlers = createHandlers({ store, mailer, config: options, events });
|
|
179
|
+
|
|
180
|
+
// The window timer fires every windowMs as a heartbeat — emits even
|
|
181
|
+
// when both counters are zero. Operators rely on the heartbeat as a
|
|
182
|
+
// liveness signal ("knowless is processing"); a missing emission is
|
|
183
|
+
// itself diagnostic. Only run the timer when the hook is wired so we
|
|
184
|
+
// don't spend a setInterval slot on adopters who don't use it.
|
|
185
|
+
let suppressionTimer = null;
|
|
186
|
+
if (typeof options.onSuppressionWindow === 'function') {
|
|
187
|
+
suppressionTimer = setInterval(() => {
|
|
188
|
+
const sham = shamCount;
|
|
189
|
+
const rateLimited = rateLimitedCount;
|
|
190
|
+
shamCount = 0;
|
|
191
|
+
rateLimitedCount = 0;
|
|
192
|
+
onSuppressionWindow({ sham, rateLimited, windowMs: suppressionWindowMs });
|
|
193
|
+
}, suppressionWindowMs);
|
|
194
|
+
if (typeof suppressionTimer.unref === 'function') suppressionTimer.unref();
|
|
195
|
+
}
|
|
106
196
|
|
|
107
197
|
const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
108
198
|
const onSweepError = options.onSweepError;
|
|
@@ -163,8 +253,15 @@ export function knowless(options = {}) {
|
|
|
163
253
|
config: handlers._config,
|
|
164
254
|
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
|
165
255
|
_sweep: runSweep,
|
|
256
|
+
/** Probe the configured SMTP transport (v0.2.1). Resolves true on
|
|
257
|
+
* success, rejects with the underlying error. Opt-in fail-fast for
|
|
258
|
+
* adopters who want to validate SMTP at boot; no auto-on-boot
|
|
259
|
+
* variant by design — k8s readiness probes / docker-compose
|
|
260
|
+
* ordering would fail boot for the wrong reason. */
|
|
261
|
+
verifyTransport: () => mailer.verify(),
|
|
166
262
|
close() {
|
|
167
263
|
clearInterval(sweepTimer);
|
|
264
|
+
if (suppressionTimer !== null) clearInterval(suppressionTimer);
|
|
168
265
|
try {
|
|
169
266
|
mailer.close?.();
|
|
170
267
|
} catch {
|
package/src/mailer.js
CHANGED
|
@@ -221,6 +221,27 @@ export function createMailer(cfg) {
|
|
|
221
221
|
raw,
|
|
222
222
|
});
|
|
223
223
|
},
|
|
224
|
+
/**
|
|
225
|
+
* Probe the underlying SMTP transport. Resolves to true on success,
|
|
226
|
+
* rejects with the underlying error otherwise. Adopters call this
|
|
227
|
+
* explicitly when they want fail-fast on misconfigured SMTP at boot.
|
|
228
|
+
* No auto-on-boot variant: deployments where knowless starts before
|
|
229
|
+
* Postfix (docker-compose ordering, k8s readiness probes) would
|
|
230
|
+
* fail boot for the wrong reason. v0.2.1.
|
|
231
|
+
*
|
|
232
|
+
* Contract: non-rejection means success. The underlying nodemailer
|
|
233
|
+
* transport may return a truthy value, falsy value, or throw —
|
|
234
|
+
* non-throwing is treated as success and normalized to `true`.
|
|
235
|
+
* Tests using `streamTransport` exercise this normalization
|
|
236
|
+
* (streamTransport's verify() returns false even on healthy probes).
|
|
237
|
+
*/
|
|
238
|
+
async verify() {
|
|
239
|
+
if (typeof transport.verify !== 'function') {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
await transport.verify();
|
|
243
|
+
return true;
|
|
244
|
+
},
|
|
224
245
|
close() {
|
|
225
246
|
if (typeof transport.close === 'function') transport.close();
|
|
226
247
|
},
|
package/src/store.js
CHANGED
|
@@ -194,17 +194,23 @@ export function createStore(dbPath = ':memory:') {
|
|
|
194
194
|
};
|
|
195
195
|
|
|
196
196
|
// Transactional cap-check + insert per SPEC §4.7.
|
|
197
|
+
// Returns the number of tokens evicted to make room for the new one
|
|
198
|
+
// (always 0 when maxActive is 0). Callers use this to count
|
|
199
|
+
// per-handle-cap rotation events for operator monitoring (v0.2.1).
|
|
197
200
|
const insertTokenAtomic = makeTransaction(db,
|
|
198
201
|
(tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
|
|
202
|
+
let evicted = 0;
|
|
199
203
|
if (maxActive > 0) {
|
|
200
204
|
const { n: count } = stmt.countActiveTokens.get(handle, now);
|
|
201
205
|
let toEvict = count - maxActive + 1;
|
|
202
206
|
while (toEvict > 0) {
|
|
203
207
|
stmt.evictOldestActive.run(handle, now);
|
|
204
208
|
toEvict--;
|
|
209
|
+
evicted++;
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
stmt.insertToken.run(tokenHash, handle, expiresAt, nextUrl, isSham);
|
|
213
|
+
return evicted;
|
|
208
214
|
},
|
|
209
215
|
);
|
|
210
216
|
|
|
@@ -231,6 +237,13 @@ export function createStore(dbPath = ':memory:') {
|
|
|
231
237
|
},
|
|
232
238
|
|
|
233
239
|
// --- Token ---
|
|
240
|
+
/**
|
|
241
|
+
* Insert a token, evicting oldest active tokens for the handle when
|
|
242
|
+
* the per-handle cap (maxActive) would be exceeded.
|
|
243
|
+
* @returns {number} count of tokens evicted to make room (0 when no
|
|
244
|
+
* rotation occurred). Used for operator monitoring (v0.2.1
|
|
245
|
+
* `onSuppressionWindow.rateLimited` counter).
|
|
246
|
+
*/
|
|
234
247
|
insertToken(args) {
|
|
235
248
|
const {
|
|
236
249
|
tokenHash,
|
|
@@ -243,7 +256,7 @@ export function createStore(dbPath = ':memory:') {
|
|
|
243
256
|
} = args;
|
|
244
257
|
assertHexHash(tokenHash, 'tokenHash');
|
|
245
258
|
assertHexHash(handle, 'handle');
|
|
246
|
-
insertTokenAtomic(
|
|
259
|
+
return insertTokenAtomic(
|
|
247
260
|
tokenHash,
|
|
248
261
|
handle,
|
|
249
262
|
expiresAt,
|