knowless 0.1.7 → 0.1.8

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
@@ -9,6 +9,37 @@ Versioning is [SemVer](https://semver.org/).
9
9
 
10
10
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
11
11
 
12
+ ## [0.1.8] — 2026-04-28
13
+
14
+ addypin round 4 — one small API addition + documentation polish.
15
+
16
+ ### Added
17
+
18
+ - **`bypassRateLimit: true` arg on `auth.startLogin` (AF-10).**
19
+ Trusted server-side callers (CLI workers, cron jobs, internal
20
+ services on the same host as the web process) opt out of IP-
21
+ based rate-limit accounting entirely — neither check nor
22
+ increment for the `login_ip` and `create_ip` buckets. The per-
23
+ handle token cap (`maxActiveTokensPerHandle`) is still enforced.
24
+ Solves the "web + CLI sharing 127.0.0.1" starvation problem
25
+ without requiring config divergence between processes. Throws
26
+ on non-boolean. Do NOT plumb this from unauthenticated user
27
+ input.
28
+
29
+ ### Documentation
30
+
31
+ - **GUIDE Step 6 rewrite (AF-11).** `auth.handleFromRequest(req)`
32
+ is now front-and-centre as the load-bearing primitive for
33
+ adopter authorization. Worked Express-style examples for
34
+ `requireAuth` middleware and per-handle CRUD gating. Replaces
35
+ the previous "(coming in v0.2.0)" placeholder with the v0.1.1
36
+ reality.
37
+ - **OPS.md §11a "Multi-process deployments" (AF-12).** Half-page
38
+ guide covering when sharing one DB across processes is safe
39
+ (WAL mode, default), sweeper redundancy semantics, rate-limit
40
+ enforcement-vs-accounting under sharing (and why AF-10
41
+ matters), `auth.close()` behavior, and the cross-machine no-go.
42
+
12
43
  ## [0.1.7] — 2026-04-28
13
44
 
14
45
  addypin integration round 3 — one small API addition.
package/GUIDE.md CHANGED
@@ -335,25 +335,54 @@ const auth = knowless({ ..., openRegistration: true });
335
335
  Note that open registration adds a per-IP cap on new handles
336
336
  (default 3/hour) to mitigate signup spam.
337
337
 
338
- ### Step 6: Use sessions in your app
338
+ ### Step 6: Use sessions in your app — `auth.handleFromRequest`
339
339
 
340
- After `/auth/callback` succeeds, the user has a session cookie.
341
- Read it on every protected request:
340
+ After `/auth/callback` succeeds, the user has a session cookie. To
341
+ gate your own protected endpoints, call `auth.handleFromRequest(req)`:
342
+ it returns the requesting session's opaque handle (64-char hex), or
343
+ `null` when the cookie is missing, malformed, or expired. **This is
344
+ the load-bearing primitive for adopter authorization.** The five
345
+ mounted handlers (`login`, `callback`, `verify`, `logout`, `loginForm`)
346
+ own the auth round-trip; everything *else* in your app uses
347
+ `handleFromRequest`.
342
348
 
343
349
  ```js
350
+ // Express-shaped middleware. Same pattern works in Hono / Fastify /
351
+ // node:http — handleFromRequest takes a node-shaped req and returns
352
+ // a string or null synchronously. No async, no DB hop beyond the
353
+ // session lookup.
344
354
  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.)
355
+ const handle = auth.handleFromRequest(req);
356
+ if (!handle) {
357
+ res.statusCode = 401;
358
+ return res.end('unauthorized');
359
+ }
360
+ req.handle = handle;
350
361
  next();
351
362
  }
363
+
364
+ // Then on every protected endpoint:
365
+ app.get('/api/pins', requireAuth, (req, res) => {
366
+ const pins = db.findPinsByOwner(req.handle); // owner_handle = req.handle
367
+ res.json(pins);
368
+ });
369
+
370
+ app.delete('/api/pins/:id', requireAuth, (req, res) => {
371
+ const pin = db.getPin(req.params.id);
372
+ if (pin.owner_handle !== req.handle) {
373
+ res.statusCode = 403;
374
+ return res.end('forbidden');
375
+ }
376
+ db.deletePin(req.params.id);
377
+ res.end();
378
+ });
352
379
  ```
353
380
 
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.
381
+ The `verify` handler is for **forward-auth deployments** (your
382
+ reverse proxy gates upstreams via `/verify` returning 200/401 +
383
+ `X-User-Handle`). For in-process middleware, prefer
384
+ `handleFromRequest` — same answer, no sub-request round-trip, no
385
+ header parsing.
357
386
 
358
387
  ### Step 7: GDPR right-to-erasure
359
388
 
package/OPS.md CHANGED
@@ -540,6 +540,55 @@ 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
+
543
592
  ## 12. Backup and recovery
544
593
 
545
594
  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.8 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
11
11
 
12
12
  ## What this is
13
13
 
@@ -147,7 +147,7 @@ const auth = knowless({
147
147
  | `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
148
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
149
149
  | `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. |
150
+ | `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. |
151
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
152
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
153
153
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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