knowless 0.1.1 → 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 +31 -0
- package/package.json +1 -1
- package/src/index.js +18 -2
- package/src/store.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,37 @@ 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
|
+
|
|
18
49
|
## [0.1.1] — 2026-04-29
|
|
19
50
|
|
|
20
51
|
First-customer scope (the webrevival forum) review identified one
|
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/index.js
CHANGED
|
@@ -100,7 +100,10 @@ export function knowless(options = {}) {
|
|
|
100
100
|
const handlers = createHandlers({ store, mailer, config: options });
|
|
101
101
|
|
|
102
102
|
const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
103
|
-
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() {
|
|
104
107
|
try {
|
|
105
108
|
const now = Date.now();
|
|
106
109
|
store.sweepTokens(now);
|
|
@@ -108,8 +111,19 @@ export function knowless(options = {}) {
|
|
|
108
111
|
store.sweepRateLimits(now - DEFAULT_RATE_LIMIT_RETENTION_MS);
|
|
109
112
|
} catch (err) {
|
|
110
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
|
+
}
|
|
111
124
|
}
|
|
112
|
-
}
|
|
125
|
+
}
|
|
126
|
+
const sweepTimer = setInterval(runSweep, sweepIntervalMs);
|
|
113
127
|
// Don't keep the event loop alive just for the sweeper.
|
|
114
128
|
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
115
129
|
|
|
@@ -125,6 +139,8 @@ export function knowless(options = {}) {
|
|
|
125
139
|
deleteHandle: (handle) => store.deleteHandle(handle),
|
|
126
140
|
/** Effective config (with defaults applied), useful for routing. */
|
|
127
141
|
config: handlers._config,
|
|
142
|
+
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
|
143
|
+
_sweep: runSweep,
|
|
128
144
|
close() {
|
|
129
145
|
clearInterval(sweepTimer);
|
|
130
146
|
try {
|
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()) {
|