knowless 0.1.0 → 0.1.2
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 +80 -1
- package/package.json +1 -1
- package/src/handlers.js +73 -15
- package/src/index.js +30 -2
- package/src/mailer.js +16 -0
- package/src/store.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,85 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
15
15
|
sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
|
|
16
16
|
Traefik). (Tracked in TASKS.md Phase 7.)
|
|
17
17
|
|
|
18
|
+
## [0.1.2] — 2026-04-28
|
|
19
|
+
|
|
20
|
+
P2 hardening sprint — completes the audit-finding backlog opened during
|
|
21
|
+
the v0.1.0 self-review. Defense-in-depth and test-strength improvements;
|
|
22
|
+
no behavior changes for correct callers.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `onSweepError(err)` config hook — invoked when the periodic sweeper
|
|
27
|
+
catches an exception (DB corruption, disk full, etc.). Best-effort:
|
|
28
|
+
hook errors are swallowed and the sweeper keeps running. `auth._sweep()`
|
|
29
|
+
is now exposed for tests and operator scripts to trigger a sweep on
|
|
30
|
+
demand. Closes AF-5.3.
|
|
31
|
+
|
|
32
|
+
### Security
|
|
33
|
+
|
|
34
|
+
- **Stored-hash integrity check.** All `handle` / `tokenHash` / `sidHash`
|
|
35
|
+
arguments are validated as 64-char lowercase hex at the store boundary
|
|
36
|
+
before any DB read or write. A bug elsewhere passing a wrong-format
|
|
37
|
+
value now fails fast with an actionable error instead of silently
|
|
38
|
+
corrupting the table. Closes AF-5.4.
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
|
|
42
|
+
- Rate-limit window-boundary precision: last ms of window N is still
|
|
43
|
+
limited; first ms of N+1 is fresh. Limit semantics: "exceeded" fires
|
|
44
|
+
AT the limit, not strictly above. Closes AF-5.1.
|
|
45
|
+
- Cookie parser hardening: 8 edge-case scenarios (whitespace,
|
|
46
|
+
duplicates, malformed pairs, RFC 6265 cases) verifying the existing
|
|
47
|
+
parser is robust. Closes AF-5.2.
|
|
48
|
+
|
|
49
|
+
## [0.1.1] — 2026-04-29
|
|
50
|
+
|
|
51
|
+
First-customer scope (the webrevival forum) review identified one
|
|
52
|
+
ergonomic gap and elevated three P1 hardening items to "must ship
|
|
53
|
+
before first real use." All five closed in this release.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `auth.handleFromRequest(req)` — programmatic session resolution
|
|
58
|
+
for in-process middleware. Returns `string | null` (handle on
|
|
59
|
+
valid session, null on any failure). Recommended integration
|
|
60
|
+
point for Express / Fastify / Hono `requireAuth` middleware. SPEC
|
|
61
|
+
§9.4. Closes AF-2.8.
|
|
62
|
+
- `cookieSecure` config option (default `true`). Operators MAY set
|
|
63
|
+
`false` for `http://localhost` development; the library emits a
|
|
64
|
+
stderr warning at startup. MUST NOT be `false` in production.
|
|
65
|
+
SPEC §5.4. PRD FR-30 revised. Closes AF-4.4.
|
|
66
|
+
|
|
67
|
+
### Security
|
|
68
|
+
|
|
69
|
+
- **CSRF defense on `POST /login`.** New Origin/Referer header
|
|
70
|
+
validation as Step 0 of the login flow. Both headers absent →
|
|
71
|
+
allow (curl, programmatic). Either present → host must equal
|
|
72
|
+
`cookieDomain` or be a subdomain. Cross-origin / unparseable →
|
|
73
|
+
silent short-circuit, no DB write, no mail. Same response shape
|
|
74
|
+
as a legitimate hit, so the attacker's measurement learns
|
|
75
|
+
nothing the request shape didn't already expose. SPEC §7.3 Step
|
|
76
|
+
0. Closes AF-4.3, resolves SPEC §15 Q-4.
|
|
77
|
+
|
|
78
|
+
### Tests
|
|
79
|
+
|
|
80
|
+
- AF-4.1: concurrent token issuance under cap contention.
|
|
81
|
+
10-parallel logins with `maxActiveTokensPerHandle=3` must end at
|
|
82
|
+
exactly 3 active rows. Pins the SPEC §4.7 BEGIN IMMEDIATE
|
|
83
|
+
contract.
|
|
84
|
+
- AF-4.2: SMTP-failure response-uniformity test. Stubs
|
|
85
|
+
`mailer.submit` to throw and asserts the response shape is
|
|
86
|
+
identical to a successful login. Pins NFR-10.
|
|
87
|
+
- 12 new tests total. 122 tests passing on Node 20+.
|
|
88
|
+
|
|
89
|
+
### Notes
|
|
90
|
+
|
|
91
|
+
The published `0.1.0` does not have these. Adopters who installed
|
|
92
|
+
`0.1.0` should `npm update knowless` to pick up the CSRF defense
|
|
93
|
+
and the localhost-dev-friendly cookieSecure option.
|
|
94
|
+
|
|
95
|
+
[0.1.1]: https://github.com/hamr0/knowless/releases/tag/v0.1.1
|
|
96
|
+
|
|
18
97
|
## [0.1.0] — 2026-04-28
|
|
19
98
|
|
|
20
99
|
First public release. Library-mode auth flow is complete and
|
|
@@ -138,5 +217,5 @@ Two primary audiences (PRD §4):
|
|
|
138
217
|
|
|
139
218
|
Apache 2.0 with NOTICE preservation. See `LICENSE` and `NOTICE`.
|
|
140
219
|
|
|
141
|
-
[Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.
|
|
220
|
+
[Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.1...HEAD
|
|
142
221
|
[0.1.0]: https://github.com/hamr0/knowless/releases/tag/v0.1.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
|
|
32
32
|
shamRecipient: 'null@knowless.invalid',
|
|
33
33
|
trustedProxies: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
|
34
34
|
failureRedirect: null,
|
|
35
|
+
cookieSecure: true,
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -84,6 +85,38 @@ function getCookie(req, name) {
|
|
|
84
85
|
return null;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Validate the request's Origin/Referer header against the cookie
|
|
90
|
+
* domain whitelist per SPEC §7.3 Step 0 (AF-4.3, CSRF defense).
|
|
91
|
+
*
|
|
92
|
+
* - Both headers absent → allow (curl, fetch without CORS, programmatic
|
|
93
|
+
* clients). Browsers always send Origin on cross-origin POST.
|
|
94
|
+
* - Either present → parse and require host == cookieDomain or
|
|
95
|
+
* .endsWith('.' + cookieDomain). Same whitelist as the next-URL
|
|
96
|
+
* check in §11.2.
|
|
97
|
+
* - Unparseable URL or non-matching host → reject.
|
|
98
|
+
*
|
|
99
|
+
* Origin is preferred when both are present (it's harder to spoof and
|
|
100
|
+
* more reliably set by browsers on POST).
|
|
101
|
+
*/
|
|
102
|
+
function validateOrigin(req, cookieDomain) {
|
|
103
|
+
const origin = req.headers?.origin;
|
|
104
|
+
const referer = req.headers?.referer ?? req.headers?.referrer;
|
|
105
|
+
const candidate = origin ?? referer;
|
|
106
|
+
if (!candidate) return true;
|
|
107
|
+
if (typeof candidate !== 'string') return false;
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(candidate);
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const host = parsed.hostname.toLowerCase();
|
|
115
|
+
if (!host) return false;
|
|
116
|
+
const dom = cookieDomain.toLowerCase();
|
|
117
|
+
return host === dom || host.endsWith('.' + dom);
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
/**
|
|
88
121
|
* Validate the `next` URL per SPEC §11.2.
|
|
89
122
|
* @param {string|null|undefined} rawNext
|
|
@@ -147,6 +180,12 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
147
180
|
|
|
148
181
|
const trustedProxies = new Set(cfg.trustedProxies);
|
|
149
182
|
|
|
183
|
+
// SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
|
|
184
|
+
// emitted by default and omitted only when cookieSecure: false (localhost
|
|
185
|
+
// dev). HttpOnly + SameSite=Lax are always set.
|
|
186
|
+
const secureAttr = cfg.cookieSecure ? '; Secure' : '';
|
|
187
|
+
const setCookieAttrs = `Domain=${cfg.cookieDomain}; Path=/; HttpOnly; SameSite=Lax`;
|
|
188
|
+
|
|
150
189
|
function sameResponse(res, echoedEmail, next) {
|
|
151
190
|
const html = renderLoginForm({
|
|
152
191
|
loginPath: cfg.loginPath,
|
|
@@ -168,6 +207,15 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
async function login(req, res) {
|
|
210
|
+
// Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
|
|
211
|
+
// CSRF defense: a malicious cross-origin page autosubmitting to /login
|
|
212
|
+
// would otherwise trigger magic-link sends to known emails. Exempt
|
|
213
|
+
// from FR-6 timing equivalence per SPEC §7.3.
|
|
214
|
+
if (!validateOrigin(req, cfg.cookieDomain)) {
|
|
215
|
+
sameResponse(res, '', '');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
171
219
|
let raw;
|
|
172
220
|
try {
|
|
173
221
|
raw = await readBody(req);
|
|
@@ -312,33 +360,42 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
312
360
|
res.statusCode = 302;
|
|
313
361
|
res.setHeader(
|
|
314
362
|
'Set-Cookie',
|
|
315
|
-
`${cfg.cookieName}=${cookie};
|
|
363
|
+
`${cfg.cookieName}=${cookie}; ${setCookieAttrs}; Max-Age=${cfg.sessionTtlSeconds}${secureAttr}`,
|
|
316
364
|
);
|
|
317
365
|
res.setHeader('Location', row.nextUrl ?? `${cfg.baseUrl}/`);
|
|
318
366
|
res.end();
|
|
319
367
|
}
|
|
320
368
|
|
|
321
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Programmatic session resolution per SPEC §9.4. Reads the
|
|
371
|
+
* configured cookie from the request, validates its signature,
|
|
372
|
+
* looks up the session row, and returns the handle. Returns
|
|
373
|
+
* null on any failure (missing/malformed cookie, signature
|
|
374
|
+
* mismatch, expired session, no row). Recommended integration
|
|
375
|
+
* point for in-process middleware. Closes AF-2.8.
|
|
376
|
+
*
|
|
377
|
+
* @param {{ headers?: { cookie?: string } }} req
|
|
378
|
+
* @returns {string | null}
|
|
379
|
+
*/
|
|
380
|
+
function handleFromRequest(req) {
|
|
322
381
|
const cookie = getCookie(req, cfg.cookieName);
|
|
323
|
-
if (!cookie)
|
|
324
|
-
res.statusCode = 401;
|
|
325
|
-
res.end();
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
382
|
+
if (!cookie) return null;
|
|
328
383
|
const sid = verifySessionSignature(cookie, cfg.secret);
|
|
329
|
-
if (!sid)
|
|
330
|
-
res.statusCode = 401;
|
|
331
|
-
res.end();
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
384
|
+
if (!sid) return null;
|
|
334
385
|
const row = store.getSession(sidHashOf(sid));
|
|
335
|
-
if (!row || row.expiresAt <= Date.now())
|
|
386
|
+
if (!row || row.expiresAt <= Date.now()) return null;
|
|
387
|
+
return row.handle;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function verify(req, res) {
|
|
391
|
+
const handle = handleFromRequest(req);
|
|
392
|
+
if (!handle) {
|
|
336
393
|
res.statusCode = 401;
|
|
337
394
|
res.end();
|
|
338
395
|
return;
|
|
339
396
|
}
|
|
340
397
|
res.statusCode = 200;
|
|
341
|
-
res.setHeader('X-User-Handle',
|
|
398
|
+
res.setHeader('X-User-Handle', handle);
|
|
342
399
|
res.end();
|
|
343
400
|
}
|
|
344
401
|
|
|
@@ -351,7 +408,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
351
408
|
res.statusCode = 200;
|
|
352
409
|
res.setHeader(
|
|
353
410
|
'Set-Cookie',
|
|
354
|
-
`${cfg.cookieName}=;
|
|
411
|
+
`${cfg.cookieName}=; ${setCookieAttrs}; Max-Age=0${secureAttr}`,
|
|
355
412
|
);
|
|
356
413
|
res.end();
|
|
357
414
|
}
|
|
@@ -376,6 +433,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
376
433
|
verify,
|
|
377
434
|
logout,
|
|
378
435
|
loginForm,
|
|
436
|
+
handleFromRequest,
|
|
379
437
|
validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
|
|
380
438
|
// exposed for tests
|
|
381
439
|
_config: cfg,
|
package/src/index.js
CHANGED
|
@@ -76,6 +76,16 @@ export function knowless(options = {}) {
|
|
|
76
76
|
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// SPEC §5.4: cookieSecure: false is allowed only for localhost dev.
|
|
80
|
+
// The library can't tell whether the operator is in production, but a
|
|
81
|
+
// visible warning makes it harder to ship by accident.
|
|
82
|
+
if (options.cookieSecure === false) {
|
|
83
|
+
console.warn(
|
|
84
|
+
'[knowless] WARNING: cookieSecure is false. Session cookies will be set without the Secure flag. ' +
|
|
85
|
+
'This is only safe for http://localhost development. Never deploy with cookieSecure: false.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
const store = options.store ?? createStore(options.dbPath ?? './knowless.db');
|
|
80
90
|
|
|
81
91
|
const mailer =
|
|
@@ -90,7 +100,10 @@ export function knowless(options = {}) {
|
|
|
90
100
|
const handlers = createHandlers({ store, mailer, config: options });
|
|
91
101
|
|
|
92
102
|
const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
93
|
-
const
|
|
103
|
+
const onSweepError = options.onSweepError;
|
|
104
|
+
// Extract the sweep body so tests / operators can trigger it without
|
|
105
|
+
// waiting for the interval. Closes AF-5.3.
|
|
106
|
+
function runSweep() {
|
|
94
107
|
try {
|
|
95
108
|
const now = Date.now();
|
|
96
109
|
store.sweepTokens(now);
|
|
@@ -98,8 +111,19 @@ export function knowless(options = {}) {
|
|
|
98
111
|
store.sweepRateLimits(now - DEFAULT_RATE_LIMIT_RETENTION_MS);
|
|
99
112
|
} catch (err) {
|
|
100
113
|
console.error('[knowless] sweep failed:', err.message);
|
|
114
|
+
if (typeof onSweepError === 'function') {
|
|
115
|
+
// Hook errors are swallowed — alerting is best-effort and MUST
|
|
116
|
+
// NOT crash the sweep loop. Operator's hook can fail; sweeper
|
|
117
|
+
// continues.
|
|
118
|
+
try {
|
|
119
|
+
onSweepError(err);
|
|
120
|
+
} catch {
|
|
121
|
+
/* intentional */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
101
124
|
}
|
|
102
|
-
}
|
|
125
|
+
}
|
|
126
|
+
const sweepTimer = setInterval(runSweep, sweepIntervalMs);
|
|
103
127
|
// Don't keep the event loop alive just for the sweeper.
|
|
104
128
|
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
105
129
|
|
|
@@ -109,10 +133,14 @@ export function knowless(options = {}) {
|
|
|
109
133
|
verify: handlers.verify,
|
|
110
134
|
logout: handlers.logout,
|
|
111
135
|
loginForm: handlers.loginForm,
|
|
136
|
+
/** Resolve handle from request cookie programmatically (SPEC §9.4). */
|
|
137
|
+
handleFromRequest: handlers.handleFromRequest,
|
|
112
138
|
/** Delete a handle + all tokens + all sessions atomically (FR-37a). */
|
|
113
139
|
deleteHandle: (handle) => store.deleteHandle(handle),
|
|
114
140
|
/** Effective config (with defaults applied), useful for routing. */
|
|
115
141
|
config: handlers._config,
|
|
142
|
+
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
|
143
|
+
_sweep: runSweep,
|
|
116
144
|
close() {
|
|
117
145
|
clearInterval(sweepTimer);
|
|
118
146
|
try {
|
package/src/mailer.js
CHANGED
|
@@ -19,6 +19,22 @@ const ASCII_RE = /^[\x00-\x7f]*$/;
|
|
|
19
19
|
* @returns {string} RFC822 message with CRLF line endings
|
|
20
20
|
*/
|
|
21
21
|
function composeRaw({ from, to, subject, body }) {
|
|
22
|
+
// AF-2.1: header-injection defense in depth. normalize() upstream
|
|
23
|
+
// already rejects \r and \n in email addresses, but the mailer
|
|
24
|
+
// shouldn't trust its callers — this is the layer that emits the
|
|
25
|
+
// wire-format bytes, so it owns the invariant.
|
|
26
|
+
for (const [name, value] of [
|
|
27
|
+
['from', from],
|
|
28
|
+
['to', to],
|
|
29
|
+
['subject', subject],
|
|
30
|
+
]) {
|
|
31
|
+
if (typeof value !== 'string') {
|
|
32
|
+
throw new Error(`mailer: ${name} must be a string`);
|
|
33
|
+
}
|
|
34
|
+
if (/[\r\n]/.test(value)) {
|
|
35
|
+
throw new Error(`mailer: ${name} contains CR/LF — header injection blocked`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
22
38
|
const fromDomain = from.includes('@') ? from.split('@').pop() : 'localhost';
|
|
23
39
|
const messageId = `<${crypto.randomUUID()}@${fromDomain}>`;
|
|
24
40
|
const date = new Date().toUTCString();
|
package/src/store.js
CHANGED
|
@@ -8,6 +8,28 @@ const DEFAULT_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
8
8
|
|
|
9
9
|
const SCHEMA_VERSION = '1';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Validate a 64-char lowercase hex string at the store boundary.
|
|
13
|
+
* Handles, token hashes, and session ID hashes are all this shape per
|
|
14
|
+
* SPEC §3.1, §4.1, §5.3. A bug elsewhere passing a wrong-format value
|
|
15
|
+
* would otherwise silently corrupt the table or fail at SELECT time
|
|
16
|
+
* with a less-actionable error. Closes AF-5.4.
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} value
|
|
19
|
+
* @param {string} name parameter name for the thrown error
|
|
20
|
+
*/
|
|
21
|
+
function assertHexHash(value, name) {
|
|
22
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
|
|
23
|
+
const got =
|
|
24
|
+
typeof value === 'string'
|
|
25
|
+
? `"${value.slice(0, 16)}${value.length > 16 ? '...' : ''}"`
|
|
26
|
+
: typeof value;
|
|
27
|
+
throw new Error(
|
|
28
|
+
`store: ${name} must be 64-char lowercase hex (got ${got})`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
const DDL = `
|
|
12
34
|
CREATE TABLE IF NOT EXISTS handles (
|
|
13
35
|
handle TEXT PRIMARY KEY,
|
|
@@ -168,12 +190,15 @@ export function createStore(dbPath = ':memory:') {
|
|
|
168
190
|
return {
|
|
169
191
|
// --- Handle ---
|
|
170
192
|
handleExists(handle) {
|
|
193
|
+
assertHexHash(handle, 'handle');
|
|
171
194
|
return !!stmt.handleExists.get(handle);
|
|
172
195
|
},
|
|
173
196
|
upsertHandle(handle) {
|
|
197
|
+
assertHexHash(handle, 'handle');
|
|
174
198
|
stmt.upsertHandleNoLogin.run(handle);
|
|
175
199
|
},
|
|
176
200
|
deleteHandle(handle) {
|
|
201
|
+
assertHexHash(handle, 'handle');
|
|
177
202
|
deleteHandleAtomic(handle);
|
|
178
203
|
},
|
|
179
204
|
|
|
@@ -188,6 +213,8 @@ export function createStore(dbPath = ':memory:') {
|
|
|
188
213
|
maxActive = 0,
|
|
189
214
|
now = Date.now(),
|
|
190
215
|
} = args;
|
|
216
|
+
assertHexHash(tokenHash, 'tokenHash');
|
|
217
|
+
assertHexHash(handle, 'handle');
|
|
191
218
|
insertTokenAtomic(
|
|
192
219
|
tokenHash,
|
|
193
220
|
handle,
|
|
@@ -199,6 +226,7 @@ export function createStore(dbPath = ':memory:') {
|
|
|
199
226
|
);
|
|
200
227
|
},
|
|
201
228
|
getToken(tokenHash) {
|
|
229
|
+
assertHexHash(tokenHash, 'tokenHash');
|
|
202
230
|
const row = stmt.getToken.get(tokenHash);
|
|
203
231
|
if (!row) return null;
|
|
204
232
|
return {
|
|
@@ -210,12 +238,15 @@ export function createStore(dbPath = ':memory:') {
|
|
|
210
238
|
};
|
|
211
239
|
},
|
|
212
240
|
markTokenUsed(tokenHash, usedAt) {
|
|
241
|
+
assertHexHash(tokenHash, 'tokenHash');
|
|
213
242
|
return stmt.markTokenUsed.run(usedAt, tokenHash).changes > 0;
|
|
214
243
|
},
|
|
215
244
|
countActiveTokens(handle, now = Date.now()) {
|
|
245
|
+
assertHexHash(handle, 'handle');
|
|
216
246
|
return stmt.countActiveTokens.get(handle, now).n;
|
|
217
247
|
},
|
|
218
248
|
evictOldestActiveToken(handle, now = Date.now()) {
|
|
249
|
+
assertHexHash(handle, 'handle');
|
|
219
250
|
return stmt.evictOldestActive.run(handle, now).changes;
|
|
220
251
|
},
|
|
221
252
|
sweepTokens(now = Date.now(), graceMs = DEFAULT_TOKEN_GRACE_MS) {
|
|
@@ -224,21 +255,27 @@ export function createStore(dbPath = ':memory:') {
|
|
|
224
255
|
|
|
225
256
|
// --- Last login ---
|
|
226
257
|
upsertLastLogin(handle, at) {
|
|
258
|
+
assertHexHash(handle, 'handle');
|
|
227
259
|
stmt.upsertLastLogin.run(handle, at);
|
|
228
260
|
},
|
|
229
261
|
getLastLogin(handle) {
|
|
262
|
+
assertHexHash(handle, 'handle');
|
|
230
263
|
const row = stmt.getLastLogin.get(handle);
|
|
231
264
|
return row ? row.lastLoginAt : null;
|
|
232
265
|
},
|
|
233
266
|
|
|
234
267
|
// --- Session ---
|
|
235
268
|
insertSession(sidHash, handle, expiresAt) {
|
|
269
|
+
assertHexHash(sidHash, 'sidHash');
|
|
270
|
+
assertHexHash(handle, 'handle');
|
|
236
271
|
stmt.insertSession.run(sidHash, handle, expiresAt);
|
|
237
272
|
},
|
|
238
273
|
getSession(sidHash) {
|
|
274
|
+
assertHexHash(sidHash, 'sidHash');
|
|
239
275
|
return stmt.getSession.get(sidHash) ?? null;
|
|
240
276
|
},
|
|
241
277
|
deleteSession(sidHash) {
|
|
278
|
+
assertHexHash(sidHash, 'sidHash');
|
|
242
279
|
return stmt.deleteSession.run(sidHash).changes > 0;
|
|
243
280
|
},
|
|
244
281
|
sweepSessions(now = Date.now()) {
|