knowless 0.1.6 → 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 +56 -0
- package/GUIDE.md +44 -12
- package/OPS.md +49 -0
- package/README.md +1 -1
- package/knowless.context.md +1 -1
- package/package.json +1 -1
- package/src/handlers.js +42 -8
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,62 @@ 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
|
+
|
|
43
|
+
## [0.1.7] — 2026-04-28
|
|
44
|
+
|
|
45
|
+
addypin integration round 3 — one small API addition.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`subjectOverride` arg on `auth.startLogin`.** Per-call subject
|
|
50
|
+
replaces the factory `subject` for that one mail. Validated by
|
|
51
|
+
the same rules (ASCII, ≤60 chars, no CR/LF) and throws on invalid
|
|
52
|
+
— programmer error, not silent miss. The subject is decided
|
|
53
|
+
before the hit/miss branch, so sham and real submissions carry
|
|
54
|
+
the same subject and no observer can distinguish outcomes by
|
|
55
|
+
subject. Spam-trigger warnings (`!!`, `FREE`, etc.) do not throw;
|
|
56
|
+
the caller has more context. Closes AF-9.1.
|
|
57
|
+
|
|
58
|
+
Example: addypin sends three magic-link variants with distinct
|
|
59
|
+
subjects:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
await auth.startLogin({
|
|
63
|
+
email, nextUrl, sourceIp,
|
|
64
|
+
subjectOverride: `Confirm your pin: ${shortcode}`,
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
12
68
|
## [0.1.6] — 2026-04-28
|
|
13
69
|
|
|
14
70
|
addypin integration round 2 — one correctness fix (HMAC key handling)
|
package/GUIDE.md
CHANGED
|
@@ -267,11 +267,14 @@ share link," "submit a paste" — patterns where forcing a login
|
|
|
267
267
|
app.post('/api/pins', async (req, res) => {
|
|
268
268
|
const { email, lat, lng } = await readJsonBody(req);
|
|
269
269
|
const owner = auth.deriveHandle(email); // AF-7.4
|
|
270
|
-
await db.insertPendingPin({ owner, lat, lng });
|
|
270
|
+
const shortcode = await db.insertPendingPin({ owner, lat, lng });
|
|
271
271
|
await auth.startLogin({ // AF-7.3
|
|
272
272
|
email,
|
|
273
273
|
nextUrl: 'https://app.example.com/manage',
|
|
274
274
|
sourceIp: req.socket.remoteAddress,
|
|
275
|
+
// Per-call subject so the user can tell at a glance this is a
|
|
276
|
+
// pin-confirmation, not a routine login. AF-9.
|
|
277
|
+
subjectOverride: `Confirm your pin: ${shortcode}`,
|
|
275
278
|
});
|
|
276
279
|
res.status(202).end(); // "we'll email you the link"
|
|
277
280
|
});
|
|
@@ -332,25 +335,54 @@ const auth = knowless({ ..., openRegistration: true });
|
|
|
332
335
|
Note that open registration adds a per-IP cap on new handles
|
|
333
336
|
(default 3/hour) to mitigate signup spam.
|
|
334
337
|
|
|
335
|
-
### Step 6: Use sessions in your app
|
|
338
|
+
### Step 6: Use sessions in your app — `auth.handleFromRequest`
|
|
336
339
|
|
|
337
|
-
After `/auth/callback` succeeds, the user has a session cookie.
|
|
338
|
-
|
|
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`.
|
|
339
348
|
|
|
340
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.
|
|
341
354
|
function requireAuth(req, res, next) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
355
|
+
const handle = auth.handleFromRequest(req);
|
|
356
|
+
if (!handle) {
|
|
357
|
+
res.statusCode = 401;
|
|
358
|
+
return res.end('unauthorized');
|
|
359
|
+
}
|
|
360
|
+
req.handle = handle;
|
|
347
361
|
next();
|
|
348
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
|
+
});
|
|
349
379
|
```
|
|
350
380
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
354
386
|
|
|
355
387
|
### Step 7: GDPR right-to-erasure
|
|
356
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.
|
|
10
|
+
> v0.1.8 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## What this is
|
|
13
13
|
|
package/knowless.context.md
CHANGED
|
@@ -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?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. 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.
|
|
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
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { normalize, deriveHandle } from './handle.js';
|
|
3
3
|
import { issueToken, hashToken } from './token.js';
|
|
4
4
|
import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
5
|
-
import { composeBody } from './mailer.js';
|
|
5
|
+
import { composeBody, validateSubject } from './mailer.js';
|
|
6
6
|
import { renderLoginForm } from './form.js';
|
|
7
7
|
import {
|
|
8
8
|
buildTrustedPeers,
|
|
@@ -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 }) {
|
|
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,
|
|
@@ -322,8 +327,14 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
322
327
|
bodyFooter: cfg.bodyFooter,
|
|
323
328
|
});
|
|
324
329
|
|
|
330
|
+
// AF-9: programmatic callers may override the subject per call
|
|
331
|
+
// (addypin sends confirmation / login / expiry-warning all via
|
|
332
|
+
// magic links and needs distinct subjects). Decision happens
|
|
333
|
+
// BEFORE the hit/miss branch — same subject for sham and real,
|
|
334
|
+
// so timing equivalence is preserved.
|
|
335
|
+
const effectiveSubject = subject ?? cfg.subject;
|
|
325
336
|
try {
|
|
326
|
-
await mailer.submit({ to: toAddress, subject:
|
|
337
|
+
await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
|
|
327
338
|
} catch (err) {
|
|
328
339
|
// Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
|
|
329
340
|
console.error('[knowless] mail submit failed:', err.message);
|
|
@@ -347,8 +358,10 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
347
358
|
}
|
|
348
359
|
}
|
|
349
360
|
|
|
350
|
-
|
|
351
|
-
|
|
361
|
+
if (!bypassRateLimit) {
|
|
362
|
+
rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
|
|
363
|
+
if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
|
|
364
|
+
}
|
|
352
365
|
|
|
353
366
|
return { handle, isSham, emailNorm, nextValidated };
|
|
354
367
|
}
|
|
@@ -393,7 +406,13 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
393
406
|
sameResponse(res, result.emailNorm, result.nextValidated ?? '');
|
|
394
407
|
}
|
|
395
408
|
|
|
396
|
-
async function startLogin({
|
|
409
|
+
async function startLogin({
|
|
410
|
+
email,
|
|
411
|
+
nextUrl,
|
|
412
|
+
sourceIp = '',
|
|
413
|
+
subjectOverride,
|
|
414
|
+
bypassRateLimit = false,
|
|
415
|
+
} = {}) {
|
|
397
416
|
// Programmer-error guards (AF-7.3). These DO throw; they're not
|
|
398
417
|
// silent-miss conditions, they're "you called the API wrong."
|
|
399
418
|
if (typeof email !== 'string' || email.length === 0) {
|
|
@@ -405,10 +424,25 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
405
424
|
if (typeof sourceIp !== 'string') {
|
|
406
425
|
throw new Error('startLogin: sourceIp must be a string when provided');
|
|
407
426
|
}
|
|
427
|
+
if (typeof bypassRateLimit !== 'boolean') {
|
|
428
|
+
throw new Error('startLogin: bypassRateLimit must be a boolean when provided');
|
|
429
|
+
}
|
|
430
|
+
// AF-9: per-call subject override. Validated with the same rules as
|
|
431
|
+
// the factory subject (ASCII, ≤60 chars, no CR/LF). Throws on
|
|
432
|
+
// invalid — same "programmer-error" treatment as other startLogin
|
|
433
|
+
// arg validation. Spam-trigger warnings are NOT thrown for; the
|
|
434
|
+
// caller has more context than knowless about what's appropriate.
|
|
435
|
+
let subject;
|
|
436
|
+
if (subjectOverride !== undefined && subjectOverride !== null) {
|
|
437
|
+
validateSubject(subjectOverride); // throws on invalid
|
|
438
|
+
subject = subjectOverride;
|
|
439
|
+
}
|
|
408
440
|
const { handle } = await runSendLink({
|
|
409
441
|
emailRaw: email,
|
|
410
442
|
nextRaw: nextUrl ?? null,
|
|
411
443
|
sourceIp,
|
|
444
|
+
subject,
|
|
445
|
+
bypassRateLimit,
|
|
412
446
|
});
|
|
413
447
|
// Same-shape return: rate-limit / sham / real all collapse here.
|
|
414
448
|
// `handle` is the HMAC of the normalized email (or null if email
|