knowless 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,80 @@ Versioning is [SemVer](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ - **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
11
+ bundling Postfix + null-route + the binary so a self-hoster
12
+ runs `docker compose up` and has a working auth gateway in
13
+ one step. Material UX win for the PRD §4.2 self-hoster
14
+ audience. Targeted for v0.2.0.
10
15
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
16
+ - `knowless-server --check-null-route`: CLI probe that submits a
17
+ test message to `shamRecipient` and confirms the local MTA
18
+ discarded it. Targeted for v0.2.0.
19
+
20
+ ## [0.1.9] — 2026-04-28
21
+
22
+ addypin manual smoke turned up one real bug, one defaults footgun,
23
+ and one DX gap.
24
+
25
+ ### Fixed
26
+
27
+ - **`auth.deriveHandle(email)` now normalizes the email before
28
+ HMAC (AF-13).** Prior versions skipped `normalize()` while
29
+ `auth.startLogin` and `POST /login` ran it — adopters using
30
+ `deriveHandle` to precompute owner-keyed lookups got silent
31
+ handle mismatches whenever email casing varied between
32
+ create-time and click-time. Symptom was "user's records
33
+ disappear after login," which is awful to debug. The bare
34
+ `deriveHandle(emailNormalized, secret)` re-export still
35
+ expects pre-normalized input — that contract is unchanged.
36
+
37
+ ### Documentation
38
+
39
+ - **GUIDE flags the `failureRedirect` Mode-A footgun (AF-14).**
40
+ Adopters running programmatic-only (`startLogin` without
41
+ mounting `loginForm`) hit a default `failureRedirect = /login`
42
+ pointing at a route they don't serve — expired/replayed
43
+ magic-link clicks 302 to a 404. The GUIDE now leads with this
44
+ in the Mode-A walkthrough and adds a callout in the config
45
+ table. Default unchanged to avoid breaking Mode-B users with
46
+ custom paths.
47
+ - **OPS.md §11b — MailHog dev workflow (AF-15).** `docker run
48
+ mailhog/mailhog`, point knowless at port 1025, inspect every
49
+ outgoing mail (including sham submissions) in a UI at port
50
+ 8025. Verifies `bodyFooter`, `subjectOverride`, and the
51
+ URL-line-isn't-QP-soft-broken invariant without spinning up
52
+ real Postfix.
53
+
54
+ ## [0.1.8] — 2026-04-28
55
+
56
+ addypin round 4 — one small API addition + documentation polish.
57
+
58
+ ### Added
59
+
60
+ - **`bypassRateLimit: true` arg on `auth.startLogin` (AF-10).**
61
+ Trusted server-side callers (CLI workers, cron jobs, internal
62
+ services on the same host as the web process) opt out of IP-
63
+ based rate-limit accounting entirely — neither check nor
64
+ increment for the `login_ip` and `create_ip` buckets. The per-
65
+ handle token cap (`maxActiveTokensPerHandle`) is still enforced.
66
+ Solves the "web + CLI sharing 127.0.0.1" starvation problem
67
+ without requiring config divergence between processes. Throws
68
+ on non-boolean. Do NOT plumb this from unauthenticated user
69
+ input.
70
+
71
+ ### Documentation
72
+
73
+ - **GUIDE Step 6 rewrite (AF-11).** `auth.handleFromRequest(req)`
74
+ is now front-and-centre as the load-bearing primitive for
75
+ adopter authorization. Worked Express-style examples for
76
+ `requireAuth` middleware and per-handle CRUD gating. Replaces
77
+ the previous "(coming in v0.2.0)" placeholder with the v0.1.1
78
+ reality.
79
+ - **OPS.md §11a "Multi-process deployments" (AF-12).** Half-page
80
+ guide covering when sharing one DB across processes is safe
81
+ (WAL mode, default), sweeper redundancy semantics, rate-limit
82
+ enforcement-vs-accounting under sharing (and why AF-10
83
+ matters), `auth.close()` behavior, and the cross-machine no-go.
11
84
 
12
85
  ## [0.1.7] — 2026-04-28
13
86
 
package/GUIDE.md CHANGED
@@ -62,8 +62,8 @@ built-in auth is either missing or weak. The existing alternatives
62
62
  (Authelia, Authentik, Keycloak, oauth2-proxy) are heavyweight for
63
63
  the job: "redirect to login if no cookie, otherwise let through."
64
64
 
65
- knowless's standalone server (v0.2.0, in development) sits behind
66
- Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
65
+ knowless's standalone server (`bin/knowless-server`, shipped in
66
+ v0.1.3) sits behind Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
67
67
  session cookie scoped to the parent eTLD+1, SSO across all your
68
68
  services for free.
69
69
 
@@ -106,7 +106,7 @@ nothing else*.
106
106
  - **Walks away at v1.0.0.** Maintenance mode (security patches +
107
107
  bug fixes) after that, by design.
108
108
 
109
- ## Walkthrough: library mode (v0.1.0)
109
+ ## Walkthrough: library mode
110
110
 
111
111
  The shape: import `knowless`, configure it, mount the handlers on
112
112
  your HTTP framework.
@@ -175,7 +175,9 @@ sending domain):
175
175
  Without all three, Gmail / Outlook will silently drop your auth
176
176
  mail. This is the operator commitment knowless asks of you.
177
177
 
178
- > Full Postfix walkthrough lives in `OPS.md` (shipping with v0.2.0).
178
+ > Full Postfix walkthrough lives in [`OPS.md`](OPS.md) Postfix
179
+ > install, null-route, SPF/DKIM/PTR, systemd, reverse-proxy
180
+ > forward-auth examples, multi-process deployments.
179
181
 
180
182
  ### Step 4: Mount the handlers
181
183
 
@@ -295,7 +297,19 @@ callers. See SPEC §7.3a for the full contract.
295
297
 
296
298
  `auth.deriveHandle(email)` returns the same opaque HMAC handle
297
299
  that the form path uses, without you having to import the helper
298
- or pass the secret around.
300
+ or pass the secret around. The instance method **normalizes the
301
+ email** (lowercase, trim) before HMAC (AF-13), so `Alice@X.com`
302
+ and `alice@x.com` produce the same handle — match what the form
303
+ and `startLogin` would compute. The bare `deriveHandle` re-export
304
+ takes pre-normalized input; use the instance method unless you
305
+ have a specific reason to call the lower-level primitive.
306
+
307
+ > **Mode-A heads-up: set `failureRedirect`.** If you only mount
308
+ > `auth.callback` (not `auth.loginForm`), the default
309
+ > `failureRedirect` cascade points at `/login` — a route you
310
+ > don't serve. An expired or replayed magic-link click will 302
311
+ > to a 404. Set `failureRedirect: '/'` (or any route you do
312
+ > serve) when wiring Mode A.
299
313
 
300
314
  ### Step 5: Pre-seed users (closed-registration mode, default)
301
315
 
@@ -335,25 +349,54 @@ const auth = knowless({ ..., openRegistration: true });
335
349
  Note that open registration adds a per-IP cap on new handles
336
350
  (default 3/hour) to mitigate signup spam.
337
351
 
338
- ### Step 6: Use sessions in your app
352
+ ### Step 6: Use sessions in your app — `auth.handleFromRequest`
339
353
 
340
- After `/auth/callback` succeeds, the user has a session cookie.
341
- Read it on every protected request:
354
+ After `/auth/callback` succeeds, the user has a session cookie. To
355
+ gate your own protected endpoints, call `auth.handleFromRequest(req)`:
356
+ it returns the requesting session's opaque handle (64-char hex), or
357
+ `null` when the cookie is missing, malformed, or expired. **This is
358
+ the load-bearing primitive for adopter authorization.** The five
359
+ mounted handlers (`login`, `callback`, `verify`, `logout`, `loginForm`)
360
+ own the auth round-trip; everything *else* in your app uses
361
+ `handleFromRequest`.
342
362
 
343
363
  ```js
364
+ // Express-shaped middleware. Same pattern works in Hono / Fastify /
365
+ // node:http — handleFromRequest takes a node-shaped req and returns
366
+ // a string or null synchronously. No async, no DB hop beyond the
367
+ // session lookup.
344
368
  function requireAuth(req, res, next) {
345
- // Use auth.verify() in a sub-request shape, or read the cookie
346
- // and call into the store. Simplest pattern:
347
- // Mount a middleware that calls the verify handler against the
348
- // request and checks the result.
349
- // (Cleaner pattern coming in v0.2.0 with a middleware factory.)
369
+ const handle = auth.handleFromRequest(req);
370
+ if (!handle) {
371
+ res.statusCode = 401;
372
+ return res.end('unauthorized');
373
+ }
374
+ req.handle = handle;
350
375
  next();
351
376
  }
377
+
378
+ // Then on every protected endpoint:
379
+ app.get('/api/pins', requireAuth, (req, res) => {
380
+ const pins = db.findPinsByOwner(req.handle); // owner_handle = req.handle
381
+ res.json(pins);
382
+ });
383
+
384
+ app.delete('/api/pins/:id', requireAuth, (req, res) => {
385
+ const pin = db.getPin(req.params.id);
386
+ if (pin.owner_handle !== req.handle) {
387
+ res.statusCode = 403;
388
+ return res.end('forbidden');
389
+ }
390
+ db.deletePin(req.params.id);
391
+ res.end();
392
+ });
352
393
  ```
353
394
 
354
- For now, the friendliest pattern: route a dedicated `/me` endpoint
355
- through `auth.verify` and have the rest of your app fetch it on
356
- mount.
395
+ The `verify` handler is for **forward-auth deployments** (your
396
+ reverse proxy gates upstreams via `/verify` returning 200/401 +
397
+ `X-User-Handle`). For in-process middleware, prefer
398
+ `handleFromRequest` — same answer, no sub-request round-trip, no
399
+ header parsing.
357
400
 
358
401
  ### Step 7: GDPR right-to-erasure
359
402
 
@@ -373,11 +416,10 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
373
416
  chooses the UX (admin CLI, in-app self-service, ticket-driven
374
417
  support).
375
418
 
376
- ## Walkthrough: standalone server mode (v0.2.0, coming)
419
+ ## Walkthrough: standalone server mode
377
420
 
378
- The shape: run `npx knowless-server`, point Caddy / nginx /
379
- Traefik at it for forward-auth, protect any HTTP service behind
380
- magic-link login.
421
+ Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
422
+ forward-auth, protect any HTTP service behind magic-link login.
381
423
 
382
424
  The deployment-shape pattern:
383
425
  ```
@@ -389,7 +431,9 @@ The deployment-shape pattern:
389
431
  [Caddy redirects to auth.example.com/login?next=...]
390
432
  ```
391
433
 
392
- Sample Caddyfile (forthcoming OPS.md will have the full setup):
434
+ Sample Caddyfile (full setup including TLS/ACME + multiple gated
435
+ services lives in [`OPS.md`](OPS.md) §7):
436
+
393
437
  ```caddy
394
438
  auth.example.com {
395
439
  reverse_proxy localhost:8080
@@ -398,7 +442,7 @@ auth.example.com {
398
442
  kuma.example.com {
399
443
  forward_auth localhost:8080 {
400
444
  uri /verify
401
- copy_headers X-User-Handle
445
+ copy_headers X-Knowless-Handle
402
446
  }
403
447
  reverse_proxy localhost:3001 # Uptime Kuma
404
448
  }
@@ -406,7 +450,7 @@ kuma.example.com {
406
450
  adguard.example.com {
407
451
  forward_auth localhost:8080 {
408
452
  uri /verify
409
- copy_headers X-User-Handle
453
+ copy_headers X-Knowless-Handle
410
454
  }
411
455
  reverse_proxy localhost:3000 # AdGuard Home
412
456
  }
@@ -415,9 +459,11 @@ adguard.example.com {
415
459
  One auth subdomain, one cookie, SSO across all gated services
416
460
  because the cookie is scoped to the parent eTLD+1.
417
461
 
418
- Until v0.2.0, you can replicate this yourself with ~30 lines of
419
- `node:http` wrapping the library-mode handlers — see
420
- `knowless.context.md` for the pattern.
462
+ Configuration is via `KNOWLESS_*` env vars see
463
+ [`config.example.env`](config.example.env) and run
464
+ `knowless-server --help` for the full list. `knowless-server
465
+ --config-check` validates your env, SMTP reachability, and DB
466
+ write access; suitable for systemd `ExecStartPre`.
421
467
 
422
468
  ## Configuration reference
423
469
 
@@ -450,7 +496,7 @@ Full options table:
450
496
  | `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
451
497
  | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
452
498
  | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
453
- | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. |
499
+ | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. **Mode-A adopters:** if you don't mount `loginForm`, set this to a route you actually serve (e.g. `/`) — otherwise expired/replayed magic-link clicks 302 to a 404. |
454
500
  | `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
455
501
  | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
456
502
 
package/OPS.md CHANGED
@@ -540,6 +540,103 @@ in that order — most spam-folder verdicts trace to one of those four.
540
540
 
541
541
  ---
542
542
 
543
+ ## 11a. Multi-process deployments (web + worker / CLI)
544
+
545
+ Multiple processes can share one knowless SQLite file. This is a
546
+ common adopter pattern: a long-running web server plus a per-message
547
+ CLI invoked by Postfix's `pipe` transport, or a web server plus a
548
+ cron worker handling 48h reminders. Each process instantiates
549
+ `knowless({...})` against the same `dbPath`.
550
+
551
+ **Why this works.** `better-sqlite3` opens the database in WAL mode
552
+ by default (knowless explicitly sets `journal_mode=WAL` at startup).
553
+ WAL allows multiple readers and one writer concurrently, with the
554
+ OS-level locking semantics needed for cross-process safety. Every
555
+ write goes through a prepared statement under a SQLite transaction,
556
+ so two processes inserting tokens or sessions at the same time can't
557
+ corrupt the table.
558
+
559
+ **What to know about each subsystem under multi-process:**
560
+
561
+ - **Sweeper redundancy is harmless.** Each process runs its own 5-
562
+ minute sweep tick. The DELETE statements are idempotent — once
563
+ one process has deleted a row, the others' DELETEs simply affect
564
+ zero rows. No coordination needed.
565
+ - **Rate-limit rows are shared but enforcement is per-process.**
566
+ All processes read and write the same `rate_limits` table, so
567
+ counter values are consistent. But each process makes its own
568
+ *enforcement* decision against its own configured cap. This
569
+ matters: a CLI worker calling `auth.startLogin` from `127.0.0.1`
570
+ uses the same `login_ip` bucket as web traffic from `127.0.0.1`.
571
+ Trusted CLI callers should pass `bypassRateLimit: true` to
572
+ `startLogin` (AF-10) so they don't starve the shared bucket and
573
+ don't participate in accounting.
574
+ - **`auth.close()` from one process doesn't affect the others.**
575
+ Each process holds its own connection. Closing one is the right
576
+ thing to do at that process's shutdown; the database remains
577
+ open for the others.
578
+ - **Magic-link tokens and session IDs are globally unique.** The
579
+ random-byte primitives have enough entropy that two processes
580
+ minting tokens concurrently won't collide (43-char base64url =
581
+ 256 bits).
582
+
583
+ **Don't share the DB across machines.** SQLite WAL only protects
584
+ processes on the same host (it relies on POSIX advisory locks).
585
+ For a multi-host knowless deployment, run a single instance behind
586
+ a load balancer or — better — run one knowless per host, each
587
+ with its own DB. Sessions are per-host but that's usually what you
588
+ want for forward-auth-style deployments anyway.
589
+
590
+ ---
591
+
592
+ ## 11b. Local development without a real Postfix
593
+
594
+ Spinning up Postfix on a developer laptop just to inspect what
595
+ knowless sends is heavy. Two leaner options:
596
+
597
+ ### Option A — `devLogMagicLinks: true` (no MTA needed)
598
+
599
+ knowless already supports this for the URL. When SMTP submission
600
+ fails AND `devLogMagicLinks: true` is set, the magic link is
601
+ printed to stderr tagged `[knowless dev:<from>]`. Sufficient for
602
+ smoke-testing the click flow, not the email content. AF-6.2.
603
+
604
+ ### Option B — MailHog (visual UI for the rendered mail)
605
+
606
+ For verifying subject/body/footer rendering or the timing of
607
+ sham-vs-real submissions, run [MailHog](https://github.com/mailhog/MailHog)
608
+ or any compatible test SMTP catcher (e.g. mailpit, smtp4dev) on
609
+ the dev machine and point knowless at it.
610
+
611
+ ```sh
612
+ # Docker one-liner for MailHog
613
+ docker run --rm -p 1025:1025 -p 8025:8025 mailhog/mailhog
614
+ ```
615
+
616
+ Then in your dev knowless config:
617
+
618
+ ```js
619
+ const auth = knowless({
620
+ secret: process.env.KNOWLESS_SECRET,
621
+ baseUrl: 'http://localhost:3000',
622
+ from: 'auth@dev.local',
623
+ smtpHost: 'localhost',
624
+ smtpPort: 1025, // MailHog's SMTP port
625
+ cookieSecure: false, // localhost-only dev
626
+ });
627
+ ```
628
+
629
+ Open `http://localhost:8025` in your browser; every magic-link mail
630
+ (including sham submissions to `null@knowless.invalid`) shows up in
631
+ the inbox UI with full subject/body/headers visible. Perfect for
632
+ verifying `bodyFooter`, `subjectOverride`, the URL line is intact
633
+ without QP soft-breaks, etc.
634
+
635
+ > Don't ship MailHog into production. It accepts mail from anywhere
636
+ > and stores it forever — defeats the entire knowless threat model.
637
+
638
+ ---
639
+
543
640
  ## 12. Backup and recovery
544
641
 
545
642
  The only stateful file is the SQLite database (`KNOWLESS_DB_PATH`,
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.1.7 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
10
+ > v0.1.9 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
11
11
 
12
12
  ## What this is
13
13
 
@@ -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.1.0 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
4
+ > v0.1.9 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -18,11 +18,13 @@ npm install knowless
18
18
 
19
19
  Two integration paths:
20
20
 
21
- 1. **Library mode (v0.1.0):** `import { knowless } from 'knowless'` --
22
- mount five handlers on Express / Fastify / Hono / `node:http`
23
- 2. **Standalone server (v0.2.0, in development):** `npx knowless-server` --
24
- forward-auth gateway for Caddy / nginx / Traefik in front of
25
- no-auth services like Uptime Kuma, AdGuard, Pi-hole
21
+ 1. **Library mode:** `import { knowless } from 'knowless'` --
22
+ mount five handlers on Express / Fastify / Hono / `node:http`,
23
+ gate your endpoints with `auth.handleFromRequest(req)`.
24
+ 2. **Standalone server:** `npx knowless-server` -- forward-auth
25
+ gateway for Caddy / nginx / Traefik in front of no-auth services
26
+ like Uptime Kuma, AdGuard, Pi-hole. Configured via `KNOWLESS_*`
27
+ env vars; see [`OPS.md`](OPS.md) for the full deployment guide.
26
28
 
27
29
  This document is the dense reference. For the why, see
28
30
  `docs/01-product/PRD.md`. For the wire formats, see
@@ -147,8 +149,8 @@ const auth = knowless({
147
149
  | `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
148
150
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
149
151
  | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
150
- | `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?}) | 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` for this call. SPEC §7.3a. AF-7.3. |
151
- | `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
152
+ | `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
+ | `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. |
152
154
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
153
155
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
154
156
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
@@ -243,7 +243,7 @@ export function createHandlers({ store, mailer, config }) {
243
243
  * handle is null only when the email failed to normalize (programmer
244
244
  * bug for startLogin; same-shape silent for /login).
245
245
  */
246
- async function runSendLink({ emailRaw, nextRaw, sourceIp, subject }) {
246
+ async function runSendLink({ emailRaw, nextRaw, sourceIp, subject, bypassRateLimit = false }) {
247
247
  // Step 1: parse + normalize
248
248
  let emailNorm;
249
249
  try {
@@ -252,8 +252,13 @@ export function createHandlers({ store, mailer, config }) {
252
252
  return { handle: null, isSham: false, emailNorm: emailRaw, nextValidated: null };
253
253
  }
254
254
 
255
- // Step 3: per-IP rate limit on /login — exempt short-circuit
255
+ // Step 3: per-IP rate limit on /login — exempt short-circuit.
256
+ // AF-10: trusted server-side callers (CLI, cron, worker) opt out
257
+ // of IP-based rate-limit accounting entirely — neither check nor
258
+ // increment. Per-handle token cap (insertToken's maxActive) still
259
+ // applies; only the IP buckets are bypassed.
256
260
  if (
261
+ !bypassRateLimit &&
257
262
  rateLimitExceeded(
258
263
  store,
259
264
  'login_ip',
@@ -271,7 +276,7 @@ export function createHandlers({ store, mailer, config }) {
271
276
  const exists = store.handleExists(handle);
272
277
  let isCreating = !exists && cfg.openRegistration;
273
278
 
274
- if (isCreating) {
279
+ if (isCreating && !bypassRateLimit) {
275
280
  if (
276
281
  rateLimitExceeded(
277
282
  store,
@@ -353,8 +358,10 @@ export function createHandlers({ store, mailer, config }) {
353
358
  }
354
359
  }
355
360
 
356
- rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
357
- if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
361
+ if (!bypassRateLimit) {
362
+ rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
363
+ if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
364
+ }
358
365
 
359
366
  return { handle, isSham, emailNorm, nextValidated };
360
367
  }
@@ -404,6 +411,7 @@ export function createHandlers({ store, mailer, config }) {
404
411
  nextUrl,
405
412
  sourceIp = '',
406
413
  subjectOverride,
414
+ bypassRateLimit = false,
407
415
  } = {}) {
408
416
  // Programmer-error guards (AF-7.3). These DO throw; they're not
409
417
  // silent-miss conditions, they're "you called the API wrong."
@@ -416,6 +424,9 @@ export function createHandlers({ store, mailer, config }) {
416
424
  if (typeof sourceIp !== 'string') {
417
425
  throw new Error('startLogin: sourceIp must be a string when provided');
418
426
  }
427
+ if (typeof bypassRateLimit !== 'boolean') {
428
+ throw new Error('startLogin: bypassRateLimit must be a boolean when provided');
429
+ }
419
430
  // AF-9: per-call subject override. Validated with the same rules as
420
431
  // the factory subject (ASCII, ≤60 chars, no CR/LF). Throws on
421
432
  // invalid — same "programmer-error" treatment as other startLogin
@@ -431,6 +442,7 @@ export function createHandlers({ store, mailer, config }) {
431
442
  nextRaw: nextUrl ?? null,
432
443
  sourceIp,
433
444
  subject,
445
+ bypassRateLimit,
434
446
  });
435
447
  // Same-shape return: rate-limit / sham / real all collapse here.
436
448
  // `handle` is the HMAC of the normalized email (or null if email
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createStore } from './store.js';
2
2
  import { createMailer, validateBodyFooter } from './mailer.js';
3
3
  import { createHandlers } from './handlers.js';
4
- import { deriveHandle as deriveHandleRaw } from './handle.js';
4
+ import { deriveHandle as deriveHandleRaw, normalize } from './handle.js';
5
5
 
6
6
  /** Default sweeper tick: 5 minutes. Per FR-13. */
7
7
  const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
@@ -153,9 +153,12 @@ export function knowless(options = {}) {
153
153
  * See SPEC §7.3a. AF-7.3. */
154
154
  startLogin: handlers.startLogin,
155
155
  /** Derive the opaque handle for an email using the configured
156
- * secret. Lets adopters compute owner-handles outside HTTP context
157
- * without spreading the secret across modules. AF-7.4. */
158
- deriveHandle: (email) => deriveHandleRaw(email, options.secret),
156
+ * secret. Normalizes the email first (AF-13) so handles match
157
+ * what `auth.startLogin` and `POST /login` would compute for the
158
+ * same address typed with different casing or surrounding
159
+ * whitespace. Adopters should treat this as the canonical handle
160
+ * derivation. AF-7.4 / AF-13. */
161
+ deriveHandle: (email) => deriveHandleRaw(normalize(email), options.secret),
159
162
  /** Effective config (with defaults applied), useful for routing. */
160
163
  config: handlers._config,
161
164
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */