knowless 0.1.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 ADDED
@@ -0,0 +1,142 @@
1
+ # Changelog
2
+
3
+ All notable changes to `knowless` are recorded here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Versioning is [SemVer](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ - Standalone server (`bin/knowless-server`): env-var-driven CLI with
11
+ `--print-config` and `--config-check`, forward-auth deployment shape
12
+ for self-hosters gating no-auth services. (Tracked in TASKS.md
13
+ Phase 6.)
14
+ - `OPS.md`: full operator setup walkthrough (Postfix, null-route for
15
+ sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
16
+ Traefik). (Tracked in TASKS.md Phase 7.)
17
+
18
+ ## [0.1.0] — 2026-04-28
19
+
20
+ First public release. Library-mode auth flow is complete and
21
+ production-grounded; standalone-server deployment shape ships in 0.2.0.
22
+
23
+ ### Added — library
24
+
25
+ - `knowless({ secret, baseUrl, from, ... })` factory wires store,
26
+ mailer, handlers, and a periodic sweeper.
27
+ - Five framework-agnostic HTTP handlers: `login`, `callback`, `verify`,
28
+ `logout`, `loginForm`. Each is `(req, res) => Promise<void>`,
29
+ mountable on Express / Fastify / Hono / `node:http`.
30
+ - `deleteHandle(handle)` for GDPR right-to-erasure (FR-37a). Removes
31
+ the handle, all active tokens, all active sessions, and the
32
+ `last_login_at` row in one transaction.
33
+ - `close()` stops the sweeper and the SQLite handle for graceful
34
+ shutdown.
35
+
36
+ ### Added — privacy / security
37
+
38
+ - **Silent-on-miss with practical timing equivalence (FR-6).** The
39
+ registered-hit and silent-miss paths are practically
40
+ indistinguishable: the in-tree timing test (SPEC §14, 1ms
41
+ delta-mean bar) measures `Δ_mean = 0.002ms` on commodity hardware
42
+ — 500× under the bar. Achieved via the four-step sham-work
43
+ pattern in SPEC §7.3.
44
+ - **Sham mail goes to a configurable null-route address** (default
45
+ `null@knowless.invalid`), not to the unregistered email. Real
46
+ users never receive unsolicited mail. Operator's MTA discards via
47
+ `transport_maps`. Documented in OPS.md (forthcoming) and SPEC §7.4.
48
+ - **Plaintext email never persisted.** Handle is `HMAC-SHA256(secret,
49
+ normalized_email)` per SPEC §3. DB-only leak yields opaque hashes,
50
+ not a mailing list.
51
+ - **Plain-text 7bit ASCII mail** with the magic-link URL on its own
52
+ line (FR-17). Sidesteps the v0.11 POC finding that nodemailer's
53
+ default quoted-printable encoding wraps the URL with `=\n` soft
54
+ breaks. Implemented by composing the RFC822 message ourselves and
55
+ using nodemailer only as the SMTP submission transport.
56
+ - **Tokens stored as SHA-256 hashes** at rest (FR-13). 256-bit
57
+ entropy from `node:crypto.randomBytes`, base64url-encoded raw
58
+ (43 chars). Single-use; used / expired tokens are swept on a
59
+ schedule.
60
+ - **Session cookies are signed** with HMAC-SHA256 (`sess\0`
61
+ domain-separated), `Secure; HttpOnly; SameSite=Lax`. Server-side
62
+ expiry enforced via stored row; cookie expiry is advisory.
63
+ - **Replay protection** via atomic `markTokenUsed`. Replays return
64
+ the same response as expired or never-existed.
65
+ - **Forward-auth return URL** via DB-bound `next_url` on the token
66
+ row (SPEC §11). Same security as URL-signing without bloating the
67
+ magic link. Cross-domain `next` is silently dropped per the
68
+ cookie-domain whitelist; `javascript:` and other schemes
69
+ rejected.
70
+ - **Per-IP and per-handle rate limiting** with safe defaults
71
+ (FR-38, FR-39, FR-40). Per-IP login cap: 30/hour. Per-handle
72
+ active token cap: 5 (newest replaces oldest). Per-IP
73
+ account-creation cap (open-registration only): 3/hour.
74
+ - **Honeypot field** in the login form (FR-41), `aria-hidden="true"`
75
+ and `tabindex="-1"` so screen-reader users aren't trapped.
76
+ - **No JS, no external resources** in any HTML page (FR-22). Inline
77
+ CSS only. Login form works in text-mode browsers.
78
+
79
+ ### Added — storage
80
+
81
+ - `better-sqlite3`-backed store implementing the SPEC §13 interface.
82
+ WAL journal mode, prepared-statement caching, transactional
83
+ token issuance with cap-eviction, transactional `deleteHandle`.
84
+ - Periodic sweeper (default 5 min) deletes expired tokens (with
85
+ 24h grace for redeemed ones), expired sessions, and rate-limit
86
+ rows older than 24h.
87
+
88
+ ### Added — docs
89
+
90
+ - `docs/01-product/PRD.md` (v0.12) — product requirements.
91
+ - `docs/02-design/SPEC.md` (v0.1) — wire formats, byte layouts,
92
+ algorithms.
93
+ - `docs/03-tasks/TASKS.md` (v0.1) — 8-phase implementation plan.
94
+ - `README.md`, `GUIDE.md`, `knowless.context.md`, `CHANGELOG.md`.
95
+
96
+ ### Tests
97
+
98
+ - 102 tests passing on Node 20+ and Node 22+.
99
+ - Testing Trophy: ~50 unit tests (handle, token, session, form,
100
+ abuse), ~50 integration tests (store, mailer, full-flow,
101
+ sham-work, forward-auth-next, library-mode), 1 timing test
102
+ (FR-6 acceptance gate).
103
+
104
+ ### Production deps
105
+
106
+ - `nodemailer` ^8.0.7 — SMTP submission to localhost MTA.
107
+ - `better-sqlite3` ^11.0.0 — synchronous SQLite via N-API.
108
+
109
+ Two deps total. Both stable, MIT-licensed, well-maintained.
110
+
111
+ ### Audience
112
+
113
+ Two primary audiences (PRD §4):
114
+
115
+ 1. **In-app services where auth is the only legitimate email need.**
116
+ Indie tools, side projects, internal dashboards, member areas,
117
+ self-hosted apps. Library mode.
118
+ 2. **Self-hosters gating services without good auth.** Uptime Kuma,
119
+ AdGuard Home, Pi-hole, Sonarr, Jellyfin admin, etc. Standalone
120
+ server mode (ships in 0.2.0).
121
+
122
+ ### Known limitations (deliberate)
123
+
124
+ - **ASCII-only email addresses** in v0.1. IDN support deferred to
125
+ 0.2 (SPEC §15 Q-5).
126
+ - **Standalone server not yet shipped.** v0.1.0 is library-mode
127
+ only. Use as `import { knowless } from 'knowless'` and mount
128
+ the handlers on your existing HTTP framework.
129
+ - **No standalone server `bin/knowless-server`** — that's 0.2.0.
130
+ Forward-auth deployments wait for 0.2.0 unless you write a small
131
+ `node:http` wrapper yourself; see GUIDE.md for the ~30-line
132
+ pattern.
133
+ - **Postfix on localhost is the only outbound mail transport.**
134
+ No remote SMTP, no Mailgun / Postmark / SES (intentional, see
135
+ PRD §16.2).
136
+
137
+ ### License
138
+
139
+ Apache 2.0 with NOTICE preservation. See `LICENSE` and `NOTICE`.
140
+
141
+ [Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.0...HEAD
142
+ [0.1.0]: https://github.com/hamr0/knowless/releases/tag/v0.1.0
package/GUIDE.md ADDED
@@ -0,0 +1,441 @@
1
+ # knowless — Adopter Guide
2
+
3
+ > The "is this for me, and how do I wire it up" doc. For the dense
4
+ > AI-agent reference, see [`knowless.context.md`](knowless.context.md).
5
+ > For the product philosophy, see
6
+ > [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
7
+
8
+ ## Who this is for
9
+
10
+ Three audiences, in order of fit:
11
+
12
+ ### 1. In-app services where auth is the only legitimate email need
13
+
14
+ You're building something where users log in, do their work in the
15
+ app, leave. Email is purely the door opener — once they're in, the
16
+ app delivers value through its UI.
17
+
18
+ Good fits:
19
+ - Web apps and SaaS dashboards (occasional login, work in-app)
20
+ - Indie tools and side projects with infrequent users
21
+ - Small-business B2B internal tools (HR portals, ops dashboards)
22
+ - Member areas, paywalled forums, community sites
23
+ - Self-hosted apps your team uses
24
+
25
+ The disqualifier isn't service type — it's **email needs**. If you
26
+ genuinely need to send order confirmations, subscription renewals,
27
+ billing notifications, calendar invites, or any digest /
28
+ newsletter, knowless is the wrong choice. Use a vendor with
29
+ deliverability as their core business (Postmark, SES, Mailgun).
30
+
31
+ ### 2. Self-hosters gating services without good native auth
32
+
33
+ You're running Uptime Kuma, AdGuard Home, Pi-hole, Sonarr, Jellyfin,
34
+ n8n, Homepage, Heimdall, Portainer, Paperless-ngx — and their
35
+ built-in auth is either missing or weak. The existing alternatives
36
+ (Authelia, Authentik, Keycloak, oauth2-proxy) are heavyweight for
37
+ the job: "redirect to login if no cookie, otherwise let through."
38
+
39
+ knowless's standalone server (v0.2.0, in development) sits behind
40
+ Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
41
+ session cookie scoped to the parent eTLD+1, SSO across all your
42
+ services for free.
43
+
44
+ ### 3. Privacy-skeptical developers building for clients
45
+
46
+ Small businesses, non-profits, EU operators, healthcare-adjacent,
47
+ legal, education. Where the privacy story is part of the sale.
48
+ knowless gives you a clean, defensible answer to "what data do you
49
+ store about your users?": *an opaque salted hash of their email,
50
+ nothing else*.
51
+
52
+ ## Who this isn't for
53
+
54
+ - Apps that need to send any email beyond the sign-in link (order
55
+ confirmations, billing, reminders, digests, newsletters,
56
+ calendar invites)
57
+ - Apps that need OAuth / OIDC / SAML / federated identity
58
+ - Apps that need integrated 2FA / WebAuthn / TOTP (compose
59
+ separately if needed)
60
+ - Teams without VPS ops capability — running your own Postfix is
61
+ real work
62
+ - Anything where email deliverability problems would be
63
+ catastrophic
64
+
65
+ ## What knowless commits to (so you know what you're getting)
66
+
67
+ - **Plaintext email is never persisted.** It's salted-hashed
68
+ (`HMAC-SHA256(secret, normalized_email)`) on the way in and
69
+ discarded.
70
+ - **Only the magic link is ever sent.** No welcome email. No
71
+ digest. No notification. The library has no API to send anything
72
+ else.
73
+ - **All outbound mail goes via your localhost MTA.** No Postmark.
74
+ No SES. No vendor credentials.
75
+ - **The login flow is timing-equivalent** between registered and
76
+ unregistered emails — practical-effect-size delta under 1ms,
77
+ measured by a CI test.
78
+ - **The library is self-contained.** Two npm deps. No build step.
79
+ Plain ES modules with JSDoc.
80
+ - **Walks away at v1.0.0.** Maintenance mode (security patches +
81
+ bug fixes) after that, by design.
82
+
83
+ ## Walkthrough: library mode (v0.1.0)
84
+
85
+ The shape: import `knowless`, configure it, mount the handlers on
86
+ your HTTP framework.
87
+
88
+ ### Step 1: Install
89
+
90
+ ```
91
+ npm install knowless
92
+ ```
93
+
94
+ Requires Node.js 20+ (LTS until April 2026).
95
+
96
+ ### Step 2: Generate the secret
97
+
98
+ The HMAC secret is the keystone of the privacy model. It must be
99
+ ≥32 random bytes (≥64 hex chars).
100
+
101
+ ```
102
+ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
103
+ ```
104
+
105
+ Store it in your env vars / secret manager. **Never** commit it.
106
+ Rotating the secret invalidates every existing handle and session
107
+ — it's a one-way switch.
108
+
109
+ ### Step 3: Set up Postfix on localhost
110
+
111
+ knowless submits SMTP to `localhost:25`. You need a localhost MTA.
112
+
113
+ On Ubuntu / Debian:
114
+ ```
115
+ sudo apt install postfix
116
+ # Pick "Internet Site" when prompted
117
+ # System mail name: your sending domain (e.g. app.example.com)
118
+ ```
119
+
120
+ Add the **null-route** for sham mail (this is the destination
121
+ knowless uses on silent-miss to keep timing equivalence without
122
+ delivering unsolicited mail to unregistered addresses):
123
+
124
+ ```
125
+ # /etc/postfix/transport
126
+ knowless.invalid discard:silently dropped by knowless null-route
127
+ ```
128
+
129
+ ```
130
+ # /etc/postfix/main.cf — add this line
131
+ transport_maps = hash:/etc/postfix/transport
132
+ ```
133
+
134
+ ```
135
+ sudo postmap /etc/postfix/transport
136
+ sudo systemctl reload postfix
137
+ ```
138
+
139
+ Then the DNS records — set on your sending domain, **not** your
140
+ app's primary domain (typical setup: `auth.example.com` is the
141
+ sending domain):
142
+
143
+ - **SPF**: `v=spf1 ip4:<your-server-ip> -all`
144
+ - **DKIM**: generate via `opendkim-genkey` and publish the public
145
+ key as a TXT record
146
+ - **PTR (reverse DNS)**: ask your VPS provider to set the PTR for
147
+ your IP to your sending hostname
148
+
149
+ Without all three, Gmail / Outlook will silently drop your auth
150
+ mail. This is the operator commitment knowless asks of you.
151
+
152
+ > Full Postfix walkthrough lives in `OPS.md` (shipping with v0.2.0).
153
+
154
+ ### Step 4: Mount the handlers
155
+
156
+ Express:
157
+ ```js
158
+ import express from 'express';
159
+ import { knowless } from 'knowless';
160
+
161
+ const app = express();
162
+ const auth = knowless({
163
+ secret: process.env.KNOWLESS_SECRET,
164
+ baseUrl: 'https://app.example.com',
165
+ from: 'auth@app.example.com',
166
+ });
167
+
168
+ app.use(express.urlencoded({ extended: false }));
169
+ app.get('/login', auth.loginForm);
170
+ app.post('/login', auth.login);
171
+ app.get('/auth/callback', auth.callback);
172
+ app.get('/verify', auth.verify);
173
+ app.post('/logout', auth.logout);
174
+
175
+ app.listen(8080);
176
+ ```
177
+
178
+ Fastify, Hono, `node:http` — all work. Each handler is a plain
179
+ `(req, res) => Promise<void>` function. No framework hooks, no
180
+ middleware injection.
181
+
182
+ ### Step 5: Pre-seed users (closed-registration mode, default)
183
+
184
+ By default, knowless is closed: a handle must already exist before
185
+ that email can request a magic link. To seed users:
186
+
187
+ ```js
188
+ import { deriveHandle } from 'knowless';
189
+
190
+ // At admin setup time:
191
+ auth._handlers; // not the public path — use a custom admin script
192
+ // that calls into the underlying store.
193
+ ```
194
+
195
+ Actually the cleanest pattern: write a tiny admin script using the
196
+ re-exported primitives:
197
+
198
+ ```js
199
+ import { knowless, deriveHandle, createStore } from 'knowless';
200
+
201
+ const SECRET = process.env.KNOWLESS_SECRET;
202
+ const store = createStore('./knowless.db');
203
+
204
+ const teamEmails = ['alice@example.com', 'bob@example.com'];
205
+ for (const email of teamEmails) {
206
+ store.upsertHandle(deriveHandle(email, SECRET));
207
+ }
208
+ store.close();
209
+ ```
210
+
211
+ Or run with `openRegistration: true` if you want first-email-wins:
212
+
213
+ ```js
214
+ const auth = knowless({ ..., openRegistration: true });
215
+ ```
216
+
217
+ Note that open registration adds a per-IP cap on new handles
218
+ (default 3/hour) to mitigate signup spam.
219
+
220
+ ### Step 6: Use sessions in your app
221
+
222
+ After `/auth/callback` succeeds, the user has a session cookie.
223
+ Read it on every protected request:
224
+
225
+ ```js
226
+ function requireAuth(req, res, next) {
227
+ // Use auth.verify() in a sub-request shape, or read the cookie
228
+ // and call into the store. Simplest pattern:
229
+ // Mount a middleware that calls the verify handler against the
230
+ // request and checks the result.
231
+ // (Cleaner pattern coming in v0.2.0 with a middleware factory.)
232
+ next();
233
+ }
234
+ ```
235
+
236
+ For now, the friendliest pattern: route a dedicated `/me` endpoint
237
+ through `auth.verify` and have the rest of your app fetch it on
238
+ mount.
239
+
240
+ ### Step 7: GDPR right-to-erasure
241
+
242
+ The store interface exposes `deleteHandle(handle)` — atomic delete
243
+ of the handle row, all active tokens, and all active sessions.
244
+ Wire it to your "delete my account" UX:
245
+
246
+ ```js
247
+ app.post('/account/delete', requireAuth, (req, res) => {
248
+ const handle = /* read from session via auth.verify or cookie */;
249
+ auth.deleteHandle(handle);
250
+ res.redirect('/goodbye');
251
+ });
252
+ ```
253
+
254
+ Library doesn't ship a built-in HTTP endpoint for this — operator
255
+ chooses the UX (admin CLI, in-app self-service, ticket-driven
256
+ support).
257
+
258
+ ## Walkthrough: standalone server mode (v0.2.0, coming)
259
+
260
+ The shape: run `npx knowless-server`, point Caddy / nginx /
261
+ Traefik at it for forward-auth, protect any HTTP service behind
262
+ magic-link login.
263
+
264
+ The deployment-shape pattern:
265
+ ```
266
+ [browser] → [Caddy] → [knowless-server /verify]
267
+ ↓ 200 OK + X-User-Handle
268
+ [Caddy proxies to Uptime Kuma]
269
+ -OR-
270
+ ↓ 401 Unauthorized
271
+ [Caddy redirects to auth.example.com/login?next=...]
272
+ ```
273
+
274
+ Sample Caddyfile (forthcoming OPS.md will have the full setup):
275
+ ```caddy
276
+ auth.example.com {
277
+ reverse_proxy localhost:8080
278
+ }
279
+
280
+ kuma.example.com {
281
+ forward_auth localhost:8080 {
282
+ uri /verify
283
+ copy_headers X-User-Handle
284
+ }
285
+ reverse_proxy localhost:3001 # Uptime Kuma
286
+ }
287
+
288
+ adguard.example.com {
289
+ forward_auth localhost:8080 {
290
+ uri /verify
291
+ copy_headers X-User-Handle
292
+ }
293
+ reverse_proxy localhost:3000 # AdGuard Home
294
+ }
295
+ ```
296
+
297
+ One auth subdomain, one cookie, SSO across all gated services
298
+ because the cookie is scoped to the parent eTLD+1.
299
+
300
+ Until v0.2.0, you can replicate this yourself with ~30 lines of
301
+ `node:http` wrapping the library-mode handlers — see
302
+ `knowless.context.md` for the pattern.
303
+
304
+ ## Configuration reference
305
+
306
+ Full options table:
307
+
308
+ | Option | Required | Default | Purpose |
309
+ |---|---|---|---|
310
+ | `secret` | yes | — | HMAC key, ≥64 hex chars (32 bytes). FR-47, FR-48. |
311
+ | `baseUrl` | yes | — | Base URL for magic-link construction. |
312
+ | `from` | yes | — | Sender email address. |
313
+ | `dbPath` | no | `./knowless.db` | SQLite file path. |
314
+ | `cookieDomain` | no | (eTLD+1 of `baseUrl`) | Session cookie scope. |
315
+ | `cookieName` | no | `knowless_session` | Session cookie name. |
316
+ | `tokenTtlSeconds` | no | `900` | Magic-link expiry (15 min). |
317
+ | `sessionTtlSeconds` | no | `2592000` | Session lifetime (30 days). |
318
+ | `linkPath` | no | `/auth/callback` | Magic-link URL path. |
319
+ | `loginPath` | no | `/login` | Login form / submission path. |
320
+ | `verifyPath` | no | `/verify` | Forward-auth check. |
321
+ | `logoutPath` | no | `/logout` | Logout endpoint. |
322
+ | `smtpHost` | no | `localhost` | MTA host. |
323
+ | `smtpPort` | no | `25` | MTA port. |
324
+ | `openRegistration` | no | `false` | Allow new-handle creation on first email. |
325
+ | `subject` | no | `'Sign in'` | Mail subject. ASCII, ≤60 chars. |
326
+ | `confirmationMessage` | no | (default with `{email}` placeholder) | Shown after submission. |
327
+ | `includeLastLoginInEmail` | no | `true` | Append "Last sign-in" line for compromise hint. |
328
+ | `maxActiveTokensPerHandle` | no | `5` | Per-handle cap; 0 disables. |
329
+ | `maxLoginRequestsPerIpPerHour` | no | `30` | Per-IP login cap; 0 disables. |
330
+ | `maxNewHandlesPerIpPerHour` | no | `3` | Per-IP creation cap (open-reg only); 0 disables. |
331
+ | `honeypotFieldName` | no | `website` | Hidden form field name. |
332
+ | `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
333
+ | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
334
+ | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
335
+ | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. |
336
+ | `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
337
+ | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
338
+
339
+ ## FAQ
340
+
341
+ ### My users say magic links land in spam.
342
+
343
+ This is operator infrastructure, not the library. The library
344
+ sends RFC-clean plain-text mail with whitelisted headers; what
345
+ mail providers do with it depends entirely on your sending
346
+ domain's reputation. Verify SPF, DKIM, and PTR records are all
347
+ set correctly. Test with [mail-tester.com](https://www.mail-tester.com/).
348
+
349
+ ### Can I use Mailgun / Postmark / SES instead of localhost Postfix?
350
+
351
+ No, by design. The library refuses remote SMTP and vendor APIs.
352
+ The reasoning is in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.2: a
353
+ vendor relationship invites the operator to use the same mailer
354
+ for non-auth mail (welcome emails, digests), which contradicts
355
+ the philosophy. If you need a vendor mailer, you're not the
356
+ audience for knowless.
357
+
358
+ ### How do I rotate the secret?
359
+
360
+ You can't, in practice. Rotating invalidates every existing handle
361
+ (they're salted by the secret) and every session (they're signed
362
+ by it). Treat the secret like a database master key: generate it
363
+ once, back it up safely, never expose it.
364
+
365
+ ### Can I customise the login HTML?
366
+
367
+ No. The form is hardcoded. Operators wanting branding fork the
368
+ project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
369
+ is a slope ("let me put my logo" → "let me theme the page" →
370
+ "let me embed a JS framework"). The hardcoded form refuses to
371
+ drift.
372
+
373
+ ### How do I add 2FA / WebAuthn / TOTP?
374
+
375
+ Compose with a separate library. knowless does magic-link, full
376
+ stop. WebAuthn after login is a different layer.
377
+
378
+ ### What about CSRF on POST /login?
379
+
380
+ The login form is unauthenticated, so traditional CSRF
381
+ mitigations (anti-CSRF tokens) don't apply directly. SameSite=Lax
382
+ on the session cookie covers the post-login risk. CSRF on the
383
+ unauthenticated /login endpoint is on the v0.2 open-questions
384
+ list (SPEC §15 Q-4) — Origin-header validation is the likely
385
+ answer.
386
+
387
+ ### Can I run multiple instances behind a load balancer?
388
+
389
+ Yes — the SQLite store is shared across processes via the file
390
+ system. Concurrent writes use SQLite's `BEGIN IMMEDIATE` for the
391
+ token-issuance transaction (SPEC §4.7). For very high concurrency
392
+ (>1000 logins/sec), implement the store interface against
393
+ Postgres or Redis.
394
+
395
+ ### How do I see what's in the store?
396
+
397
+ ```
398
+ sqlite3 knowless.db
399
+ sqlite> .schema
400
+ sqlite> SELECT count(*) FROM handles;
401
+ sqlite> SELECT count(*) FROM sessions WHERE expires_at > unixepoch() * 1000;
402
+ ```
403
+
404
+ The schema is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §6.
405
+
406
+ ## Troubleshooting
407
+
408
+ ### "config.secret must be ≥64 hex chars (32 bytes)"
409
+
410
+ You passed a secret shorter than 64 characters or not a string.
411
+ Run `node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"`
412
+ and use the output.
413
+
414
+ ### Magic link works but `/verify` returns 401
415
+
416
+ Common causes:
417
+ - Cookie domain mismatch. The cookie is set to the eTLD+1 of
418
+ `baseUrl` by default; if your protected service is on a
419
+ different parent domain, the browser won't send the cookie.
420
+ Set `cookieDomain` explicitly.
421
+ - Cookie not surviving the redirect. The `Set-Cookie` from
422
+ `/auth/callback` must be `Secure`, so HTTP-only origins won't
423
+ receive it. Use HTTPS in production.
424
+
425
+ ### "ERR_UNKNOWN_ENCODING: 7bit" or "Content-Transfer-Encoding: base64" in mail
426
+
427
+ Library bug — the mailer is supposed to compose 7bit raw RFC822.
428
+ Open an issue with the captured wire output.
429
+
430
+ ### Tests fail intermittently in CI
431
+
432
+ The FR-6 timing test has a 1ms `delta_mean` bar. On extremely
433
+ noisy CI runners this can spuriously fail. Re-run; if persistent,
434
+ your runner is anomalous. Document the policy locally rather
435
+ than weakening the bar — see SPEC §14.5.
436
+
437
+ ### "The link expires in 15 minutes" — can I make it longer?
438
+
439
+ Yes: `tokenTtlSeconds`. Don't set it absurdly high. Magic links
440
+ that linger in inboxes are a phishing-amplification risk if the
441
+ mail account is later compromised.