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.
@@ -0,0 +1,429 @@
1
+ # knowless -- Integration Guide
2
+
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
5
+
6
+ ## What this is
7
+
8
+ knowless is a passwordless auth library for Node.js services
9
+ (~1,500 lines src + ~1,800 lines tests). Email in, signed-cookie
10
+ session out. Magic-link round-trip + nothing else stored. The
11
+ library is opinionated about *not* sending non-auth email, *not*
12
+ storing plaintext identity, and *not* growing into a user-management
13
+ platform.
14
+
15
+ ```
16
+ npm install knowless
17
+ ```
18
+
19
+ Two integration paths:
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
26
+
27
+ This document is the dense reference. For the why, see
28
+ `docs/01-product/PRD.md`. For the wire formats, see
29
+ `docs/02-design/SPEC.md`. For an adopter walkthrough, see `GUIDE.md`.
30
+
31
+ ## Which mode do I need?
32
+
33
+ | Mode | What it does | When to use |
34
+ |---|---|---|
35
+ | Library | Import + mount handlers on your existing Node app | You already run a Node service and want auth in front of it |
36
+ | Standalone (0.2.0) | `npx knowless-server` exposes /verify for forward-auth | Self-hosting Kuma / AdGuard / etc.; one auth subdomain, SSO across services |
37
+
38
+ ## Minimal usage: library mode
39
+
40
+ ```js
41
+ import express from 'express';
42
+ import { knowless } from 'knowless';
43
+
44
+ const app = express();
45
+ const auth = knowless({
46
+ secret: process.env.KNOWLESS_SECRET, // 64-char hex (32 bytes), required
47
+ baseUrl: 'https://app.example.com', // required
48
+ from: 'auth@app.example.com', // required
49
+ });
50
+
51
+ app.use(express.urlencoded({ extended: false }));
52
+ app.get('/login', auth.loginForm); // GET form, optional
53
+ app.post('/login', auth.login); // POST: triggers magic link
54
+ app.get('/auth/callback', auth.callback); // GET: redeems token, sets cookie
55
+ app.get('/verify', auth.verify); // GET: forward-auth check
56
+ app.post('/logout', auth.logout); // POST: clears session
57
+ app.listen(8080);
58
+ ```
59
+
60
+ `auth.config` exposes the merged config (defaults + overrides) for
61
+ routing tables. `auth.deleteHandle(handle)` is GDPR right-to-erasure.
62
+ `auth.close()` shuts the sweeper and DB cleanly.
63
+
64
+ ## All options
65
+
66
+ ```js
67
+ const auth = knowless({
68
+ // --- Required ---
69
+ secret: '...', // 64-char hex; HMAC + cookie sig key
70
+ baseUrl: 'https://app.example.com', // base for magic-link URL construction
71
+ from: 'auth@app.example.com', // sender address
72
+
73
+ // --- Storage ---
74
+ dbPath: './knowless.db', // SQLite file; ':memory:' for tests
75
+
76
+ // --- Cookie / session ---
77
+ cookieDomain: 'app.example.com', // default: hostname of baseUrl
78
+ cookieName: 'knowless_session', // default 'knowless_session'
79
+ sessionTtlSeconds: 30 * 86400, // 30 days
80
+
81
+ // --- Token ---
82
+ tokenTtlSeconds: 900, // 15 min
83
+
84
+ // --- Routing ---
85
+ loginPath: '/login',
86
+ linkPath: '/auth/callback',
87
+ verifyPath: '/verify',
88
+ logoutPath: '/logout',
89
+ failureRedirect: null, // null → loginPath
90
+
91
+ // --- Mail / SMTP ---
92
+ smtpHost: 'localhost',
93
+ smtpPort: 25,
94
+ subject: 'Sign in', // ASCII, ≤ 60 chars
95
+ shamRecipient: 'null@knowless.invalid', // operator's MTA must discard this
96
+
97
+ // --- Behavior ---
98
+ openRegistration: false, // first-email-wins handle creation
99
+ includeLastLoginInEmail: true, // append "Last sign-in: <ISO ts>" line
100
+ confirmationMessage: 'Thanks. If <strong>{email}</strong>...',
101
+
102
+ // --- Abuse defenses (FR-38..41) ---
103
+ maxActiveTokensPerHandle: 5, // 0 to disable
104
+ maxLoginRequestsPerIpPerHour: 30, // 0 to disable
105
+ maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
106
+ honeypotFieldName: 'website',
107
+ trustedProxies: ['127.0.0.1', '::1'],
108
+
109
+ // --- Lifecycle ---
110
+ sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
111
+
112
+ // --- Injection (tests / advanced) ---
113
+ store: undefined, // bring your own store
114
+ mailer: undefined, // bring your own mailer
115
+ transportOverride: undefined, // pass to nodemailer.createTransport
116
+ });
117
+ ```
118
+
119
+ ## Public API
120
+
121
+ `knowless(options)` returns:
122
+
123
+ | Method | Args | Returns | Notes |
124
+ |---|---|---|---|
125
+ | `login` | (req, res) | Promise\<void\> | POST handler: parses form, applies sham-work, sends magic link |
126
+ | `callback` | (req, res) | Promise\<void\> | GET handler: redeems `?t=<token>`, sets cookie, redirects to `next_url` or default |
127
+ | `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
128
+ | `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
129
+ | `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
130
+ | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
131
+ | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
132
+ | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
133
+
134
+ Re-exports for advanced consumers:
135
+
136
+ ```js
137
+ import {
138
+ knowless, // factory
139
+ createStore, // direct store access (admin scripts)
140
+ createMailer, // direct mailer access
141
+ createHandlers, // bring your own factory wiring
142
+ composeBody, // pure: build the mail body
143
+ validateSubject, // pure: validate operator-supplied subject
144
+ renderLoginForm, // pure: HTML5 page rendering
145
+ normalize, // pure: email normalization
146
+ deriveHandle, // pure: HMAC-SHA256(secret, email)
147
+ } from 'knowless';
148
+ ```
149
+
150
+ ## Handle / token / session lifecycles
151
+
152
+ ```
153
+ email cookie
154
+ | |
155
+ v v
156
+ normalize() ----> deriveHandle() -+ verifySessionSignature()
157
+ | |
158
+ v v
159
+ handle sid_b64u
160
+ | |
161
+ v v
162
+ [handles table] [sessions table]
163
+ | |
164
+ v |
165
+ issueToken() |
166
+ | |
167
+ v |
168
+ {raw, hash} |
169
+ | |
170
+ +----------+ |
171
+ | |
172
+ v |
173
+ [tokens table] -- redeem ->+
174
+ |
175
+ sweep on
176
+ expiry/use
177
+ ```
178
+
179
+ | Property | Default | Configurable | Notes |
180
+ |---|---|---|---|
181
+ | Token entropy | 256 bits | No | Floor, not target |
182
+ | Token TTL | 15 min | Yes | Single-use; expired ≡ replayed ≡ never-existed |
183
+ | Token at rest | SHA-256 hash | No | Raw never persisted |
184
+ | Session lifetime | 30 days | Yes | Server-enforced via stored expiry |
185
+ | Cookie format | `<sid_b64u>.<sig_hex>` | Name only | 108 chars total |
186
+ | Sweep interval | 5 min | Yes | Drops expired tokens, sessions, old rate-limit rows |
187
+
188
+ ## The sham-work pattern (the thing that's special about knowless)
189
+
190
+ When an unregistered email submits to /login, the library does the
191
+ *same work* as a registered hit: derives the handle, looks it up,
192
+ inserts a token row (flagged `is_sham=1`), composes a mail, submits
193
+ it via SMTP. The difference: the mail's recipient is the configured
194
+ `shamRecipient` (default `null@knowless.invalid`) and your MTA's
195
+ `transport_maps` discards mail to that address.
196
+
197
+ Why: prevents email-enumeration via the login form. An attacker
198
+ submitting candidate emails cannot tell from response timing,
199
+ status, body, or "did the user receive a mail?" whether each
200
+ email is registered.
201
+
202
+ ```
203
+ POST /login (alice@registered.com) POST /login (nobody@example.com)
204
+ | |
205
+ v v
206
+ normalize → derive → exists normalize → derive → !exists
207
+ | |
208
+ v v
209
+ issueToken (is_sham=0) issueToken (is_sham=1)
210
+ | |
211
+ v v
212
+ compose mail to alice@registered.com compose mail to null@knowless.invalid
213
+ | |
214
+ v v
215
+ submit via SMTP submit via SMTP
216
+ | |
217
+ v v
218
+ Postfix → delivers Postfix → discards (transport_maps)
219
+ | |
220
+ v v
221
+ same 200 OK + same HTML body same 200 OK + same HTML body
222
+ (timing within ~2μs)
223
+ ```
224
+
225
+ The `is_sham=1` flag also makes sham tokens *un-redeemable*: even
226
+ if the discard misconfigured and a sham mail leaked, clicking
227
+ the link returns the same redirect as expired/replayed.
228
+
229
+ Operator setup for the null-route (Postfix):
230
+
231
+ ```
232
+ # /etc/postfix/transport
233
+ knowless.invalid discard:silently dropped by knowless null-route
234
+
235
+ # /etc/postfix/main.cf
236
+ transport_maps = hash:/etc/postfix/transport
237
+ ```
238
+
239
+ ```
240
+ postmap /etc/postfix/transport && systemctl reload postfix
241
+ ```
242
+
243
+ ## FR-6: timing equivalence (the testable property)
244
+
245
+ The library ships a CI test (`test/integration/timing.test.js`)
246
+ that asserts `|mean(hit_time) - mean(miss_time)| < 1ms` over 1000
247
+ interleaved iterations after a 200-iter warmup.
248
+
249
+ Local result on commodity hardware: `Δ_mean = 0.002ms` (500x under
250
+ the bar). The full sham-work pattern is achieving practically
251
+ perfect timing equivalence — better than the v0.11 POC measurement
252
+ of 260μs because production prepared statements and tighter
253
+ hot-path caching.
254
+
255
+ Why effect-size, not p-value: with N=10,000 and a Welch's t-test,
256
+ *any* constant offset above ~50μs registers as "statistically
257
+ significant" even though the offset is invisible across realistic
258
+ network jitter. The 1ms bar is what an attacker actually observes
259
+ through a connection. Detail in SPEC §14.
260
+
261
+ ## Forward-auth `?next=` handling
262
+
263
+ When `/login` receives a `?next=<url>` form field (typical from
264
+ forward-auth proxies redirecting unauthenticated requests):
265
+
266
+ 1. URL is validated against `cookieDomain` whitelist (host
267
+ equals or `.endsWith` of the cookie domain). `https`/`http`
268
+ only; `javascript:` etc. rejected.
269
+ 2. Validated URL is stored on the token row as `next_url`.
270
+ 3. On `/auth/callback` redemption, the redirect goes to
271
+ `next_url` (or `baseUrl + '/'` if absent).
272
+
273
+ The magic link URL stays short (`?t=<43 chars>` only) — the bound
274
+ URL doesn't bloat it. Tamper-resistance comes from token opacity:
275
+ substituting a different `next_url` requires forging a token,
276
+ which requires the operator secret.
277
+
278
+ ```
279
+ [unauth request to kuma.app.example.com]
280
+ |
281
+ v
282
+ [Caddy] -- 401 if no session cookie --> [redirect to auth.app.example.com/login?next=https://kuma.app.example.com/]
283
+ |
284
+ v
285
+ [user submits email; library binds next_url to token]
286
+ |
287
+ v
288
+ [email lands in inbox; user clicks magic link]
289
+ |
290
+ v
291
+ [GET /auth/callback?t=...]
292
+ |
293
+ v
294
+ [token redeemed, session created, cookie set on .app.example.com]
295
+ |
296
+ v
297
+ [302 Location: https://kuma.app.example.com/]
298
+ |
299
+ v
300
+ [Caddy verifies cookie via /verify, proxies to Uptime Kuma]
301
+ ```
302
+
303
+ ## Architecture
304
+
305
+ ```
306
+ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
307
+ -> handle.js (normalize ASCII-only, HMAC-SHA256)
308
+ -> abuse.js (per-IP rate limit, per-handle token cap, honeypot)
309
+ -> token.js (32 random bytes, base64url; SHA-256 at rest)
310
+ -> store.js (better-sqlite3, transactional, prepared statements)
311
+ -> mailer.js (raw RFC822 7bit; nodemailer for SMTP submission only)
312
+ -> session.js (HMAC-signed cookie with "sess\\0" domain tag)
313
+ -> form.js (hardcoded HTML5; no JS, no external resources)
314
+ -> index.js (factory + sweeper)
315
+ ```
316
+
317
+ | Module | Lines | Purpose |
318
+ |---|---|---|
319
+ | `src/index.js` | ~140 | Public factory, sweeper, re-exports |
320
+ | `src/handlers.js` | ~310 | login (sham), callback, verify, logout, loginForm, validateNextUrl |
321
+ | `src/store.js` | ~210 | better-sqlite3 store; SPEC §13 interface |
322
+ | `src/mailer.js` | ~120 | RFC822 raw composition + nodemailer SMTP submission |
323
+ | `src/abuse.js` | ~95 | Source-IP determination, rate limits |
324
+ | `src/handle.js` | ~50 | Email normalization, handle derivation |
325
+ | `src/token.js` | ~40 | issueToken, hashToken |
326
+ | `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
327
+ | `src/form.js` | ~110 | Hardcoded login HTML |
328
+
329
+ ## Threat model summary
330
+
331
+ **Defends well:** DB-only leaks (handles are HMAC-salted),
332
+ plaintext-email exfiltration (none persisted), password reuse
333
+ (no passwords), email enumeration via login form (timing
334
+ equivalent + same response shape), email-bombing (per-handle
335
+ cap), naive bot traffic (honeypot), high-volume floods (per-IP
336
+ cap), replay attacks (markTokenUsed atomic), open redirects
337
+ (`next_url` whitelist).
338
+
339
+ **Partial:** HMAC secret leak alone (allows targeted existence
340
+ checks but not session forgery), phishing (no password to
341
+ phish, but a phished mailbox still receives links).
342
+
343
+ **Does NOT defend against:** sophisticated bots that bypass the
344
+ honeypot, distributed floods, full server compromise,
345
+ compromised email accounts, social engineering, insider threat
346
+ at the operator. Layer-2 (Cloudflare / fail2ban / proxy
347
+ rate-limits) belongs above the library.
348
+
349
+ ## Gotchas
350
+
351
+ 1. **Closed-registration by default.** A handle must already
352
+ exist before its email can request a link. Operators pre-seed
353
+ via the re-exported `createStore` + `deriveHandle`, OR set
354
+ `openRegistration: true`.
355
+
356
+ 2. **Postfix on localhost is required.** No remote SMTP, no
357
+ Mailgun / Postmark / SES. The localhost requirement is
358
+ intentional (PRD §16.2): vendor mailers invite "while we're
359
+ at it, let's send a welcome email," which contradicts the
360
+ philosophy. If you can't run Postfix, knowless isn't your
361
+ library.
362
+
363
+ 3. **`shamRecipient` MUST be discarded by your MTA.** Default
364
+ is `null@knowless.invalid`. If your MTA tries to deliver it,
365
+ it'll bounce against an `.invalid` TLD that never resolves —
366
+ noise in your mail logs, wasted DNS lookups. Add the
367
+ `transport_maps` entry per Postfix snippet above.
368
+
369
+ 4. **Cookie domain defaults to baseUrl's hostname.** This is the
370
+ *narrow* default; for SSO across subdomains (forward-auth
371
+ pattern), set `cookieDomain` to the parent eTLD+1 explicitly.
372
+ The library does NOT compute eTLD+1 automatically (would
373
+ require a public-suffix-list dep).
374
+
375
+ 5. **`Secure` cookie attribute is non-negotiable.** All session
376
+ cookies set `Secure`. HTTP-only origins won't receive them.
377
+ Use HTTPS in production. Localhost development: use
378
+ `--insecure-localhost-cookies` (not implemented yet — TASKS
379
+ open question; works in Chrome with `--unsafely-treat-insecure-origin-as-secure`).
380
+
381
+ 6. **Forward-auth needs the parent-domain cookie.** If your auth
382
+ subdomain is `auth.example.com` and protected service is
383
+ `kuma.example.com`, set `cookieDomain: 'example.com'` so the
384
+ browser sends the cookie to both. Otherwise SSO doesn't work.
385
+
386
+ 7. **Session cookies don't slide.** Each session has a fixed
387
+ expiry (30 days default). User re-authenticates after expiry.
388
+ Sliding sessions are SPEC §15 Q-3 (deferred to v0.2).
389
+
390
+ 8. **Token replay is silent.** A second click on a magic link
391
+ redirects to /login (same as expired/never-existed). Users
392
+ sometimes double-click and wonder where they went. Mention
393
+ this in your UX copy.
394
+
395
+ 9. **Mail composition is a raw RFC822 string.** Nodemailer 8's
396
+ default encoding picks base64 or QP for plain ASCII bodies,
397
+ breaking the magic-link URL with QP soft-breaks (the v0.11
398
+ POC finding). We sidestep by composing the message ourselves
399
+ and using nodemailer only for SMTP. If you swap mailers, your
400
+ replacement must handle this OR keep emitting raw RFC822.
401
+
402
+ 10. **No JavaScript in any HTML page.** The login form, the
403
+ confirmation page, error pages — all static HTML5. Works in
404
+ text-mode browsers (Lynx, w3m). Operators wanting branding
405
+ fork the project.
406
+
407
+ 11. **Process cleanup matters.** `auth.close()` stops the
408
+ sweeper and closes the SQLite handle. Without it, your
409
+ process won't exit cleanly. The sweeper timer is `unref()`d
410
+ so it won't *prevent* exit, but the SQLite handle held by
411
+ `better-sqlite3` will leave a finalizer warning.
412
+
413
+ ## Constraints
414
+
415
+ - **Node 20+** -- targeting LTS; tested on Node 22
416
+ - **Plain ES modules** -- no TypeScript source, no build step;
417
+ ships JSDoc + (eventual) `.d.ts`
418
+ - **Two production deps** -- `nodemailer` (SMTP submission) and
419
+ `better-sqlite3` (storage). No third dep without revisiting
420
+ AGENT_RULES External Dependency Checklist.
421
+ - **Localhost MTA only** -- no remote SMTP, no vendor SDKs.
422
+ Operators run their own Postfix / OpenSMTPD / Exim.
423
+ - **ASCII-only email addresses** in v0.1. IDN deferred to v0.2.
424
+ - **SQLite is the default store** -- swap-in stores must
425
+ implement the SPEC §13 interface synchronously.
426
+ - **No telemetry of any kind.** No phone-home, no metrics
427
+ endpoint, no analytics.
428
+ - **Walks away at v1.0.0.** Maintenance mode (security + bug
429
+ fix) after that, by intent (PRD §6.3).
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "knowless",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "LICENSE",
13
+ "NOTICE",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "GUIDE.md",
17
+ "knowless.context.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20.0.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test 'test/**/*.test.js'",
24
+ "lint": "find src bin -type f \\( -name '*.js' -o -name 'knowless-server' \\) -exec node --check {} \\;"
25
+ },
26
+ "dependencies": {
27
+ "better-sqlite3": "^11.0.0",
28
+ "nodemailer": "^8.0.7"
29
+ },
30
+ "license": "Apache-2.0",
31
+ "homepage": "https://github.com/hamr0/knowless",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/hamr0/knowless.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/hamr0/knowless/issues"
38
+ },
39
+ "keywords": [
40
+ "auth",
41
+ "authentication",
42
+ "passwordless",
43
+ "magic-link",
44
+ "forward-auth",
45
+ "self-hosted",
46
+ "privacy"
47
+ ]
48
+ }
package/src/abuse.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Determine the source IP of a request per FR-42 and SPEC §7.6.
3
+ *
4
+ * If the request's connection peer is in `trustedProxies`, honour the
5
+ * `X-Forwarded-For` (first element) or `X-Real-IP` header. Otherwise
6
+ * fall back to the connection's remote address. This prevents IP
7
+ * spoofing from clients while supporting forward-auth deployments.
8
+ *
9
+ * @param {{
10
+ * socket?: { remoteAddress?: string },
11
+ * connection?: { remoteAddress?: string },
12
+ * headers?: Record<string, string|string[]|undefined>
13
+ * }} req a node:http request (or shape-compatible)
14
+ * @param {Set<string>|string[]} trustedProxies set or array of trusted peer IPs
15
+ * @returns {string} the determined IP, or '' if undeterminable
16
+ */
17
+ export function determineSourceIp(req, trustedProxies) {
18
+ const peer =
19
+ req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
20
+ const trusted =
21
+ trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
22
+ if (!trusted.has(peer)) {
23
+ return peer;
24
+ }
25
+ const xff = req.headers?.['x-forwarded-for'];
26
+ if (typeof xff === 'string' && xff.length > 0) {
27
+ // First element is the original client; subsequent are proxy chain.
28
+ const first = xff.split(',')[0].trim();
29
+ if (first) return first;
30
+ }
31
+ const xri = req.headers?.['x-real-ip'];
32
+ if (typeof xri === 'string' && xri.length > 0) return xri.trim();
33
+ return peer;
34
+ }
35
+
36
+ /**
37
+ * Compute the window-start timestamp (ms) for a given now and window size.
38
+ * Buckets are aligned to epoch — every `windowMs` slice has a stable start.
39
+ *
40
+ * @param {number} now Unix ms
41
+ * @param {number} windowMs window size in ms (e.g. 3_600_000 for 1h)
42
+ * @returns {number}
43
+ */
44
+ export function windowStart(now, windowMs) {
45
+ return Math.floor(now / windowMs) * windowMs;
46
+ }
47
+
48
+ /**
49
+ * Check whether the given (scope, key) has exceeded `limit` events in the
50
+ * current window.
51
+ *
52
+ * @param {object} store knowless store
53
+ * @param {string} scope e.g. 'login_ip'
54
+ * @param {string} key the value being limited (IP, handle hex)
55
+ * @param {number} limit threshold; if 0, the check is disabled (returns false)
56
+ * @param {number} windowMs
57
+ * @param {number} [now] override for testing
58
+ * @returns {boolean}
59
+ */
60
+ export function rateLimitExceeded(store, scope, key, limit, windowMs, now = Date.now()) {
61
+ if (limit <= 0) return false;
62
+ const ws = windowStart(now, windowMs);
63
+ return store.rateLimitGet(scope, key, ws) >= limit;
64
+ }
65
+
66
+ /**
67
+ * Increment the counter for (scope, key) in the current window. Returns the
68
+ * new count.
69
+ *
70
+ * @param {object} store
71
+ * @param {string} scope
72
+ * @param {string} key
73
+ * @param {number} windowMs
74
+ * @param {number} [now]
75
+ * @returns {number}
76
+ */
77
+ export function rateLimitIncrement(store, scope, key, windowMs, now = Date.now()) {
78
+ const ws = windowStart(now, windowMs);
79
+ return store.rateLimitIncrement(scope, key, ws);
80
+ }
package/src/form.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * HTML escape for use in attribute values and text content.
3
+ * @param {string} s
4
+ * @returns {string}
5
+ */
6
+ function htmlEscape(s) {
7
+ return String(s)
8
+ .replace(/&/g, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ .replace(/'/g, '&#x27;');
13
+ }
14
+
15
+ const STYLE = `
16
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 36rem;
17
+ margin: 4rem auto; padding: 0 1rem; line-height: 1.5; color: #111; }
18
+ form { display: flex; flex-direction: column; gap: 0.75rem; }
19
+ label { font-weight: 600; }
20
+ input[type="email"] { padding: 0.5rem; font-size: 1rem; border: 1px solid #999;
21
+ border-radius: 0.25rem; }
22
+ button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer;
23
+ background: #111; color: #fff; border: 0; border-radius: 0.25rem; }
24
+ button:hover { background: #333; }
25
+ .hp { position: absolute; left: -9999px; top: -9999px; width: 1px;
26
+ height: 1px; overflow: hidden; }
27
+ .msg { padding: 0.75rem 1rem; background: #f4f4f4; border-radius: 0.25rem; }
28
+ `.trim();
29
+
30
+ /**
31
+ * Render the login page (FR-22, FR-23).
32
+ *
33
+ * Two states share one page:
34
+ * - Initial GET /login: form is shown, no message.
35
+ * - After POST /login: form is shown again with the confirmation
36
+ * message above it (FR-7); user can resubmit if needed.
37
+ *
38
+ * No JavaScript, no external resources, inline minimal CSS only.
39
+ *
40
+ * @param {object} args
41
+ * @param {string} args.loginPath form action URL
42
+ * @param {string} args.honeypotName honeypot input field name (FR-41)
43
+ * @param {string} [args.confirmationMessage] message shown after submission;
44
+ * supports "{email}" placeholder, replaced with HTML-escaped echoedEmail.
45
+ * Omit/null to render the bare form (initial GET).
46
+ * @param {string} [args.echoedEmail] user-supplied email to echo back
47
+ * @param {string} [args.next] forward-auth next URL to preserve
48
+ * @returns {string} complete HTML5 document
49
+ */
50
+ export function renderLoginForm(args) {
51
+ const {
52
+ loginPath,
53
+ honeypotName,
54
+ confirmationMessage,
55
+ echoedEmail,
56
+ next,
57
+ } = args;
58
+
59
+ const messageBlock =
60
+ confirmationMessage != null
61
+ ? `<div class="msg" role="status">${
62
+ confirmationMessage.replace(
63
+ /\{email\}/g,
64
+ htmlEscape(echoedEmail ?? ''),
65
+ )
66
+ }</div>`
67
+ : '';
68
+
69
+ const nextField = next
70
+ ? `<input type="hidden" name="next" value="${htmlEscape(next)}">`
71
+ : '';
72
+
73
+ return `<!doctype html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="utf-8">
77
+ <title>Sign in</title>
78
+ <meta name="viewport" content="width=device-width, initial-scale=1">
79
+ <meta name="referrer" content="no-referrer">
80
+ <style>${STYLE}</style>
81
+ </head>
82
+ <body>
83
+ <h1>Sign in</h1>
84
+ ${messageBlock}
85
+ <form method="POST" action="${htmlEscape(loginPath)}" autocomplete="off">
86
+ <label for="email">Email address</label>
87
+ <input id="email" name="email" type="email" required autocomplete="email"
88
+ value="${htmlEscape(echoedEmail ?? '')}">
89
+ <div class="hp" aria-hidden="true">
90
+ <label for="${htmlEscape(honeypotName)}">Leave this empty</label>
91
+ <input id="${htmlEscape(honeypotName)}" name="${htmlEscape(honeypotName)}"
92
+ type="text" tabindex="-1" autocomplete="off">
93
+ </div>
94
+ ${nextField}
95
+ <button type="submit">Send sign-in link</button>
96
+ </form>
97
+ </body>
98
+ </html>
99
+ `;
100
+ }
101
+
102
+ export { htmlEscape };