knowless 0.1.6 → 0.1.7

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,31 @@ 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.7] — 2026-04-28
13
+
14
+ addypin integration round 3 — one small API addition.
15
+
16
+ ### Added
17
+
18
+ - **`subjectOverride` arg on `auth.startLogin`.** Per-call subject
19
+ replaces the factory `subject` for that one mail. Validated by
20
+ the same rules (ASCII, ≤60 chars, no CR/LF) and throws on invalid
21
+ — programmer error, not silent miss. The subject is decided
22
+ before the hit/miss branch, so sham and real submissions carry
23
+ the same subject and no observer can distinguish outcomes by
24
+ subject. Spam-trigger warnings (`!!`, `FREE`, etc.) do not throw;
25
+ the caller has more context. Closes AF-9.1.
26
+
27
+ Example: addypin sends three magic-link variants with distinct
28
+ subjects:
29
+
30
+ ```js
31
+ await auth.startLogin({
32
+ email, nextUrl, sourceIp,
33
+ subjectOverride: `Confirm your pin: ${shortcode}`,
34
+ });
35
+ ```
36
+
12
37
  ## [0.1.6] — 2026-04-28
13
38
 
14
39
  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 }); // your code
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
  });
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.6 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
10
+ > v0.1.7 | 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?}) | 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?}) | 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
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.6",
3
+ "version": "0.1.7",
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 }) {
247
247
  // Step 1: parse + normalize
248
248
  let emailNorm;
249
249
  try {
@@ -322,8 +322,14 @@ export function createHandlers({ store, mailer, config }) {
322
322
  bodyFooter: cfg.bodyFooter,
323
323
  });
324
324
 
325
+ // AF-9: programmatic callers may override the subject per call
326
+ // (addypin sends confirmation / login / expiry-warning all via
327
+ // magic links and needs distinct subjects). Decision happens
328
+ // BEFORE the hit/miss branch — same subject for sham and real,
329
+ // so timing equivalence is preserved.
330
+ const effectiveSubject = subject ?? cfg.subject;
325
331
  try {
326
- await mailer.submit({ to: toAddress, subject: cfg.subject, body: mailBody });
332
+ await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
327
333
  } catch (err) {
328
334
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
329
335
  console.error('[knowless] mail submit failed:', err.message);
@@ -393,7 +399,12 @@ export function createHandlers({ store, mailer, config }) {
393
399
  sameResponse(res, result.emailNorm, result.nextValidated ?? '');
394
400
  }
395
401
 
396
- async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
402
+ async function startLogin({
403
+ email,
404
+ nextUrl,
405
+ sourceIp = '',
406
+ subjectOverride,
407
+ } = {}) {
397
408
  // Programmer-error guards (AF-7.3). These DO throw; they're not
398
409
  // silent-miss conditions, they're "you called the API wrong."
399
410
  if (typeof email !== 'string' || email.length === 0) {
@@ -405,10 +416,21 @@ export function createHandlers({ store, mailer, config }) {
405
416
  if (typeof sourceIp !== 'string') {
406
417
  throw new Error('startLogin: sourceIp must be a string when provided');
407
418
  }
419
+ // AF-9: per-call subject override. Validated with the same rules as
420
+ // the factory subject (ASCII, ≤60 chars, no CR/LF). Throws on
421
+ // invalid — same "programmer-error" treatment as other startLogin
422
+ // arg validation. Spam-trigger warnings are NOT thrown for; the
423
+ // caller has more context than knowless about what's appropriate.
424
+ let subject;
425
+ if (subjectOverride !== undefined && subjectOverride !== null) {
426
+ validateSubject(subjectOverride); // throws on invalid
427
+ subject = subjectOverride;
428
+ }
408
429
  const { handle } = await runSendLink({
409
430
  emailRaw: email,
410
431
  nextRaw: nextUrl ?? null,
411
432
  sourceIp,
433
+ subject,
412
434
  });
413
435
  // Same-shape return: rate-limit / sham / real all collapse here.
414
436
  // `handle` is the HMAC of the normalized email (or null if email