From 462141aa353f193576286276813a089e1c389586 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 09:46:40 -0500 Subject: [PATCH] Add admin login foundations: scrypt admin file, CLI, sessions Groundwork for securing the web front end (vrek gol-wqf95dq). The app does not enforce login yet. - src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default .data/admin.json). scrypt hashing (N=2^16, random salt, bounded parameters, constant-time compare), zod-validated reads where a malformed file is an error, and atomic 0600 writes that won't replace an existing admin without overwrite. Plain Node, so the CLI can share it (iss-mffqscg). - src/lib/admin-auth.ts: server-only app layer; failed logins always cost one hash. - scripts/create-admin.mts + `npm run admin:create`: create or reset the admin outside the app, interactive (hidden, confirmed) or piped (iss-7xmka20). The README documents it, a no-npm Node one-liner, the file format, and password reset. - src/lib/session-token.ts and session.ts: stateless HMAC-signed session cookie, keyed from the password hash so a password change ends every session, with a 12 h sliding window (iss-e27nb70, dec-f0xar8r). 281 tests, 99.8% line coverage. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) --- .vrek/log.ndjson | 76 +++++++++++++++ README.md | 72 ++++++++++++++ package.json | 3 +- scripts/create-admin.mts | 96 +++++++++++++++++++ scripts/create-admin.test.ts | 94 +++++++++++++++++++ src/lib/admin-auth.test.ts | 58 ++++++++++++ src/lib/admin-auth.ts | 45 +++++++++ src/lib/admin-file.test.ts | 172 ++++++++++++++++++++++++++++++++++ src/lib/admin-file.ts | 157 +++++++++++++++++++++++++++++++ src/lib/session-token.test.ts | 109 +++++++++++++++++++++ src/lib/session-token.ts | 75 +++++++++++++++ src/lib/session.test.ts | 128 +++++++++++++++++++++++++ src/lib/session.ts | 76 +++++++++++++++ tsconfig.json | 1 + vitest.config.mts | 2 +- 15 files changed, 1162 insertions(+), 2 deletions(-) create mode 100644 scripts/create-admin.mts create mode 100644 scripts/create-admin.test.ts create mode 100644 src/lib/admin-auth.test.ts create mode 100644 src/lib/admin-auth.ts create mode 100644 src/lib/admin-file.test.ts create mode 100644 src/lib/admin-file.ts create mode 100644 src/lib/session-token.test.ts create mode 100644 src/lib/session-token.ts create mode 100644 src/lib/session.test.ts create mode 100644 src/lib/session.ts diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index fb7f7f1..f9e2273 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -262,3 +262,79 @@ {"id":"evt-bez2zz3sb8xn","type":"edge.added","subject":"ver-bnbvcep","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-bnbvcep","to":"iss-jvxcd1n"},"at":"2026-09-19T14:23:23.405Z","parents":["evt-8x1b7dp5t0f0"],"hash":"cd4b45af93b765ba2bbfa6a665b3b63cea2bc067db740bd6609acd4678b1ca26"} {"id":"evt-8pf21y5tkn3y","type":"verification.recorded","subject":"ver-bnbvcep","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"2026-09-19: `npx next build` gives 0 'Dynamic filesystem access' warnings. The .next/server/app/**.nft.json for all five routes have 0 src/, 0 public/ and 0 .data/ entries. Before, the whole project was traced; with only readFile ignored, .data/cameras.json and .data/credentials.json were still traced, which would have copied the local camera registry and encrypted logins into a standalone bundle. Fix: a /* turbopackIgnore: true */ on the default path.join(process.cwd(), '.data', ...) in camera-registry.ts and credential-store.ts, which alone also clears the readFile warning. Full suite 200/200, coverage 99.77%, tsc and eslint clean."},"at":"2026-09-19T14:23:23.406Z","parents":["evt-bez2zz3sb8xn"],"hash":"c1c680a72c02c6076893f6d942a4a8a1a445fcf6d50fc2f264e16d2f2eb9625c"} {"id":"evt-mbng7jjqd16c","type":"node.status_changed","subject":"iss-jvxcd1n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:23:24.548Z","parents":["evt-8pf21y5tkn3y"],"hash":"2e53812074a3a2ebaf7c5447d79c34f9ef8eed4387177d3d0e6ca4bef9c82b5f"} +{"id":"evt-1pqxvsfa964w","type":"node.created","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Secure the web front end with an admin login","body":"The dashboard and every /api/* route require an admin session once an admin exists. The admin's username and scrypt password hash live in a file named by ADMIN_AUTH_FILE (default ./.data/admin.json). If the file is missing, users are prompted to create an admin; creating one needs a one-time setup code printed on the server console. Users may skip, but then every page shows a severe warning banner and the prompt returns each browser session. The file can also be created entirely outside the app with a documented CLI or one-liner, so the app never has to run unsecured. Passwords are never stored or logged in recoverable form. Third-party API tokens are out of scope here (gol-sjabnh3).","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T14:29:30.169Z","parents":["evt-mbng7jjqd16c"],"hash":"f75f435d71be601c3ed6894197a8286d6f3b5dd5f636711a5c3896dac5695b1b"} +{"id":"evt-m2r3gjx34a8v","type":"edge.added","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-wqf95dq","to":"gol-6q1q5mr"},"at":"2026-09-19T14:29:30.171Z","parents":["evt-1pqxvsfa964w"],"hash":"6e42f4bc8e7ee8417b900d6a23ddf6ecc972a793bc9625cce47ff1b533148021"} +{"id":"evt-kfx40y2wt20z","type":"edge.added","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-wqf95dq","to":"pri-tyrxdz9"},"at":"2026-09-19T14:29:30.172Z","parents":["evt-m2r3gjx34a8v"],"hash":"dcb62609776f7e7dcdde59ba1c2d17d5e361cc1341dcca9c29b9de72d4e3c088"} +{"id":"evt-tdtqt0v5fa72","type":"edge.added","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-wqf95dq","to":"pri-m1csgrm"},"at":"2026-09-19T14:29:30.173Z","parents":["evt-kfx40y2wt20z"],"hash":"ef284fae8ab79c68978718a62f5d23601658c85887019c15c81a1c1d3cbff402"} +{"id":"evt-5gq14vadrcym","type":"edge.added","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-wqf95dq","to":"pri-mz2jxpb"},"at":"2026-09-19T14:29:30.174Z","parents":["evt-tdtqt0v5fa72"],"hash":"57d9cb2fc6fca19e5197c1967f43c6e1a515c3ab9b3374c04a83d9d8deacb878"} +{"id":"evt-0zjk90y63ad5","type":"node.created","subject":"dec-nw2hvff","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Admin auth design: scrypt hashes, setup code, protect everything, banner + re-prompt","body":"User's choices (2026-09-19): (1) Hash with scrypt, Node built-in, random 16-byte salt, stored as `scrypt$N$r$p$saltB64$hashB64`. Rejected: plain salted SHA-512 (fast, brute-forceable if the file leaks) and PBKDF2-SHA512. (2) Creating the first admin in the browser requires a one-time setup code printed to the server console at startup. Rejected: anyone on the LAN, and localhost-only. (3) Once an admin exists, a session is required for all pages and all /api/*. Rejected: pages only. (4) Skipping setup shows a red banner on every page with a 'Set up admin' button, and the prompt reappears each browser session. Defaults chosen by Claude, open to change: ADMIN_AUTH_FILE defaults to ./.data/admin.json (gitignored); the file is JSON {version, username, passwordHash} written 0600; the CLI is `npm run admin:create` (scripts/, no dependencies) plus a documented node one-liner; sessions are a stateless HMAC-SHA256-signed HttpOnly SameSite=Lax cookie whose key is HKDF-derived from the stored hash, so a password change invalidates all sessions; proxy.ts does optimistic redirects, and a verifySession() in the lib layer does the authoritative check in each page and route (Next 16 authentication guide: 02-guides/authentication.md, 'Optimistic checks with Proxy' and 'Creating a Data Access Layer').","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:29:35.107Z","parents":["evt-5gq14vadrcym"],"hash":"836970b61f269e88861971d8af54f2ce5197ad77ac13be91eeb2e3f49bb85b98"} +{"id":"evt-0taaeh9gca0r","type":"edge.added","subject":"dec-nw2hvff","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-nw2hvff","to":"gol-6q1q5mr"},"at":"2026-09-19T14:29:35.109Z","parents":["evt-0zjk90y63ad5"],"hash":"64f2821c8808f6bc4f5d560ace813d400f6a6fe9cff6716a1639693204859110"} +{"id":"evt-6sgd2s2ry2h1","type":"node.created","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Add admin login to the web front end","body":"Parent for gol-wqf95dq, following the design in dec-nw2hvff. Done when every child is closed. With no admin file: the banner and setup prompt are shown, and setup requires the console code. With an admin file: every page and /api/* route rejects requests without a valid session. The file can be created with the CLI. Coverage stays at or above 95%.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:29:39.998Z","parents":["evt-0taaeh9gca0r"],"hash":"9fc73a6d145b670814f181616f86933ddbd349b9f228e3f5677d04276851c438"} +{"id":"evt-ax0hhtgvrgjz","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-r5vrjx7","to":"gol-wqf95dq"},"at":"2026-09-19T14:29:39.999Z","parents":["evt-6sgd2s2ry2h1"],"hash":"a86650a8661fbd06a14bac910d62876c93df9f79b1c83285fb683280de099968"} +{"id":"evt-f7yqykk7b5q1","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-r5vrjx7","to":"area:security"},"at":"2026-09-19T14:29:40.000Z","parents":["evt-ax0hhtgvrgjz"],"hash":"356641c0b22c84f63fdae42c311f2b28ccabd887768771d5a3bf92ed0f799002"} +{"id":"evt-179d7m068jwm","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-r5vrjx7","to":"area:auth"},"at":"2026-09-19T14:29:40.001Z","parents":["evt-f7yqykk7b5q1"],"hash":"5fe36d695ce2bdb0e1fbf89a1dedc4ae578e38d7e9c5db87f05c7058e255a0ed"} +{"id":"evt-j5pgdj02tv33","type":"node.created","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Admin credential file module: scrypt hash, verify, and read/write ADMIN_AUTH_FILE","body":"src/lib/admin-auth.ts (server-only). The path is ADMIN_AUTH_FILE, defaulting to .data/admin.json with the turbopackIgnore marker as in the other stores. hashPassword uses scrypt, a random salt, and the format `scrypt$N$r$p$salt$hash`. verifyPassword recomputes with the stored parameters and compares with timingSafeEqual; it also runs a dummy hash when there's no admin, so response timing doesn't reveal whether the username exists. Reading the file validates it with zod and treats a missing file as 'no admin'; a malformed file is an error, not 'no admin', so a typo can't silently disable auth. Writes are atomic with mode 0600. Password rules: at least 12 characters, at most 256. Must be plain Node so the CLI can share the hashing code.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:29:46.654Z","parents":["evt-179d7m068jwm"],"hash":"fc770c0e252c6c2b3385c01a8efc99cc4d7cf67d9df6e74f557a96abe50ddd5b"} +{"id":"evt-hcxmw6es699m","type":"edge.added","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-mffqscg","to":"gol-wqf95dq"},"at":"2026-09-19T14:29:46.656Z","parents":["evt-j5pgdj02tv33"],"hash":"2c5c7b8be61b8a97857b9a754a4347e91eaa0ea8a75406caf759df84231c9c82"} +{"id":"evt-0kjtasc8sz01","type":"edge.added","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-mffqscg","to":"src/lib/admin-auth.ts"},"at":"2026-09-19T14:29:46.657Z","parents":["evt-hcxmw6es699m"],"hash":"59646d59aa9fb83fed6e9f0fa1978668c2e022a4dfb2f7e65c7bde2caf646046"} +{"id":"evt-rsxe1s00mem7","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-mffqscg"},"at":"2026-09-19T14:29:46.658Z","parents":["evt-0kjtasc8sz01"],"hash":"be09b6c47860856bd21c7907fc561f7c6f44105b09de25f8fe444b212337ff07"} +{"id":"evt-0s253d2ejnvb","type":"edge.added","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-mffqscg","to":"area:auth"},"at":"2026-09-19T14:29:46.659Z","parents":["evt-rsxe1s00mem7"],"hash":"d2652f6e0abccf8f2100144c20d20d9667c579ffa3e413f91e10a184cd852de5"} +{"id":"evt-7bmke4j98p58","type":"node.created","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"CLI and docs for creating the admin file outside the app","body":"`npm run admin:create` runs a scripts/ file with no dependencies. It prompts for a username and a hidden password with confirmation, applies the same password rules, and writes ADMIN_AUTH_FILE (or the default) with mode 0600, refusing to overwrite without --force. It uses the same hashing code as the app. Document it in the README's security section, along with a fallback node one-liner that prints a hash, the file format, how to reset a forgotten password (delete the file or re-run with --force), and a note that changing the password signs out every session.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:29:55.552Z","parents":["evt-0s253d2ejnvb"],"hash":"b083d59fdb1a0a5ec22f2cdff77ced083505041b297d1a689ec418b8f30270e6"} +{"id":"evt-4nc7dw3y8y0y","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-7xmka20","to":"gol-wqf95dq"},"at":"2026-09-19T14:29:55.554Z","parents":["evt-7bmke4j98p58"],"hash":"cc62e3ac7284604c6bc726a892beafeb51bd4c9ec24459c3194ceb087c86ff33"} +{"id":"evt-wk3ettr00cah","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7xmka20","to":"scripts/"},"at":"2026-09-19T14:29:55.555Z","parents":["evt-4nc7dw3y8y0y"],"hash":"e37c899459f6649fef8a008f2dea5d176c06a9bddc3fa69e5c614541fad201cb"} +{"id":"evt-4sdy640mqbd5","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7xmka20","to":"package.json"},"at":"2026-09-19T14:29:55.556Z","parents":["evt-wk3ettr00cah"],"hash":"0d4471f316c0985c9b9329a7f06e026656bdb3dd77ed4d2fe7b9189ac6b04318"} +{"id":"evt-fe4a889tm84s","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7xmka20","to":"README.md"},"at":"2026-09-19T14:29:55.557Z","parents":["evt-4sdy640mqbd5"],"hash":"9c7bd39f3db11f042f6745b25c44e5e763feb503e855efb63cbd9832083623b5"} +{"id":"evt-eqh0qw4ctrww","type":"edge.added","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-mffqscg","to":"iss-7xmka20"},"at":"2026-09-19T14:29:55.558Z","parents":["evt-fe4a889tm84s"],"hash":"1a450ada5c3a83b015c155f63ba0d18ca2944cb1eda62ab1a0b837c765d99577"} +{"id":"evt-jpy7rfyv8tpy","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-7xmka20"},"at":"2026-09-19T14:29:55.559Z","parents":["evt-eqh0qw4ctrww"],"hash":"5f325cc0a869f1528d4542ac1b8eed299d40b72bc5c0e3e73a5810eeb66efcc7"} +{"id":"evt-dmapwtv13yhv","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7xmka20","to":"area:auth"},"at":"2026-09-19T14:29:55.560Z","parents":["evt-jpy7rfyv8tpy"],"hash":"32f6741bbe17cb0ddece1a0913365fb20a257d71753669bbaddcc7a81d0d7ff1"} +{"id":"evt-q6jz9zrt5018","type":"edge.added","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7xmka20","to":"area:docs"},"at":"2026-09-19T14:29:55.561Z","parents":["evt-dmapwtv13yhv"],"hash":"bb1e9c8a651ac5a53dbc2cdbcc7100a00cdafe3539858dc82adf50199c7462e7"} +{"id":"evt-e4j7ft9jx4r0","type":"node.created","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Signed session cookie: create, verify and clear an admin session","body":"src/lib/session.ts (server-only). The cookie holds {username, expiresAt}, signed with HMAC-SHA256; the key is HKDF-derived from the stored password hash. It is HttpOnly, SameSite=Lax, Path=/, Secure when the request is HTTPS, and lasts 7 days. verifySession() is the authoritative check used by pages and routes: it returns the session or null, and rejects on a bad signature, expiry, a username mismatch, or a missing admin file. authState() returns 'no-admin' | 'signed-out' | 'signed-in'. Comparisons are constant-time. Uses cookies() from next/headers, async in Next 16.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:29:57.645Z","parents":["evt-q6jz9zrt5018"],"hash":"9953ca57de2584589038186db241a3a1bbc091f31f3e11c3eb368ed52f3893b3"} +{"id":"evt-3gqyqs3rh8nw","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-e27nb70","to":"gol-wqf95dq"},"at":"2026-09-19T14:29:57.647Z","parents":["evt-e4j7ft9jx4r0"],"hash":"bbf45ea175e341c98ddbfd754d7724ffcd737b3305e2e50b58ac5d596b2c8d26"} +{"id":"evt-e8sc5kb7f8c4","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-e27nb70","to":"src/lib/session.ts"},"at":"2026-09-19T14:29:57.648Z","parents":["evt-3gqyqs3rh8nw"],"hash":"8d254260c8a56244cded78f736322a99dcecadfdfecb67954355ab18b47545d0"} +{"id":"evt-h02tdapx3sbe","type":"edge.added","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-mffqscg","to":"iss-e27nb70"},"at":"2026-09-19T14:29:57.649Z","parents":["evt-e8sc5kb7f8c4"],"hash":"cd23f26b03c5d87ad3b193b6417b5aabc49e85e5be7fca111085e6f4f1c1d6af"} +{"id":"evt-kywn3ewrqgc3","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-e27nb70"},"at":"2026-09-19T14:29:57.650Z","parents":["evt-h02tdapx3sbe"],"hash":"ce7c98887055e1c6b435d58a2574f596c562f306b27fa01bead564b859558065"} +{"id":"evt-g8x1rdk8f3ax","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-e27nb70","to":"area:auth"},"at":"2026-09-19T14:29:57.651Z","parents":["evt-kywn3ewrqgc3"],"hash":"2f95c8bccf7cab5797b3a7992678011724c10f0734710e9b17147e6eb122e3e1"} +{"id":"evt-eedhstp8sjjw","type":"node.created","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Login and sign-out: /login page, Server Action, and throttling of failed attempts","body":"A /login page with a form backed by a Server Action. The input is validated with zod, and the result comes back through useActionState with a generic 'Wrong username or password' error. On success it sets the session and redirects to the page the user came from; the destination is checked to be a same-origin path, so it can't be used as an open redirect. A sign-out control appears in the header. Failed attempts are throttled in memory per client IP, e.g. an increasing delay and a temporary lockout after 10 failures in 15 minutes, since this is a LAN brute-force surface.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:30:04.136Z","parents":["evt-g8x1rdk8f3ax"],"hash":"97d4dfd81f8faaee3a21291980904583e51567655300a8b6e357b00ebf837509"} +{"id":"evt-hzjr818geyz8","type":"edge.added","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-nj9wmwp","to":"gol-wqf95dq"},"at":"2026-09-19T14:30:04.139Z","parents":["evt-eedhstp8sjjw"],"hash":"7b72c7a8045c34eac51db2614e5c54382a8ecb1e08b7a5f8e278a6af0eb3b455"} +{"id":"evt-jz3da7vpwdsd","type":"edge.added","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-nj9wmwp","to":"src/app/login/"},"at":"2026-09-19T14:30:04.140Z","parents":["evt-hzjr818geyz8"],"hash":"3c90b3d1743a901352aea13888645399108c495d993528c6e9f2ba81aaa637ec"} +{"id":"evt-7cybsryn99pg","type":"edge.added","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-nj9wmwp","to":"src/app/layout.tsx"},"at":"2026-09-19T14:30:04.141Z","parents":["evt-jz3da7vpwdsd"],"hash":"a60f3c3deb8529889e93ce8769a5130233d852d974d829a8875f063cfa5eea69"} +{"id":"evt-8m0zts40x6fn","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-e27nb70","to":"iss-nj9wmwp"},"at":"2026-09-19T14:30:04.142Z","parents":["evt-7cybsryn99pg"],"hash":"4730ca23f65a27d125f26c5aa0461be5bede3bae4592e082c42f02ce9873facb"} +{"id":"evt-3ygvy2f243sr","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-nj9wmwp"},"at":"2026-09-19T14:30:04.143Z","parents":["evt-8m0zts40x6fn"],"hash":"ea3dd391959465718a07c1582a72302bf0c5ed5bc05775aa57841d91ab762413"} +{"id":"evt-wf5t9tkxc6xj","type":"edge.added","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-nj9wmwp","to":"area:auth"},"at":"2026-09-19T14:30:04.144Z","parents":["evt-3ygvy2f243sr"],"hash":"f28abc6de41d8b13e015f20c7991d693b960466c62d5dfc46c4b5e031c4b5ffc"} +{"id":"evt-cyk05sa8df4v","type":"edge.added","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-nj9wmwp","to":"area:ui"},"at":"2026-09-19T14:30:04.145Z","parents":["evt-wf5t9tkxc6xj"],"hash":"894478623f4288f450b13b9efeffac0bb730f264d54c344147a61e309750f6a6"} +{"id":"evt-7jzra01bg238","type":"node.created","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"First-run setup: console setup code, /setup page, skip option and severe warning banner","body":"When no admin file exists: instrumentation.ts (register) generates a random one-time setup code in memory and prints it with instructions (including the CLI alternative) to the server console. A /setup page takes the username, password, confirmation and setup code; its Server Action checks the code in constant time, refuses if an admin already exists, writes the file, signs the user in, and invalidates the code. 'Skip for now' sets a browser-session cookie so the prompt doesn't reappear until the browser restarts. While there's no admin, every page shows a red banner ('Not secured: anyone on your network can view your cameras and change their logins') with a 'Set up admin' link. Check whether instrumentation register() runs under `next start` in the bundled docs.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:30:08.171Z","parents":["evt-cyk05sa8df4v"],"hash":"826d855a40bfc444256c44ecfc27ef9847bfdbb1e754307ff56a8ed6891bae2e"} +{"id":"evt-d272az9jgq3b","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-9nxdndr","to":"gol-wqf95dq"},"at":"2026-09-19T14:30:08.172Z","parents":["evt-7jzra01bg238"],"hash":"9da836d0ab606c5123bdbf2cdf79242adf58113c77e00b2651e8568ece6e572e"} +{"id":"evt-w2xe6ytb00vv","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-9nxdndr","to":"src/instrumentation.ts"},"at":"2026-09-19T14:30:08.173Z","parents":["evt-d272az9jgq3b"],"hash":"154f604495dbdaad2d3ed23302028ecd38085e398934240591ea18d7b0b70057"} +{"id":"evt-z38mczsczkke","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-9nxdndr","to":"src/app/setup/"},"at":"2026-09-19T14:30:08.174Z","parents":["evt-w2xe6ytb00vv"],"hash":"541a9e935df8254f1166455a96e3e16f0e7717a89e3b28fe1b6a00533e2dde1b"} +{"id":"evt-5y2etwe9qnfp","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-9nxdndr","to":"src/app/layout.tsx"},"at":"2026-09-19T14:30:08.175Z","parents":["evt-z38mczsczkke"],"hash":"a5b400831e93e48dd2600c59ebebd5a850cb08c312cac14a0807cf1f071218aa"} +{"id":"evt-0s6g16vw92t0","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-e27nb70","to":"iss-9nxdndr"},"at":"2026-09-19T14:30:08.176Z","parents":["evt-5y2etwe9qnfp"],"hash":"1be8ce8bb513ff702bdd27d345a2dd53bbbe595f9d3cb7201ef29a7ebec7bea5"} +{"id":"evt-137wdzt3ytb2","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-9nxdndr"},"at":"2026-09-19T14:30:08.177Z","parents":["evt-0s6g16vw92t0"],"hash":"67eadabc015e5191e3c8f96c0f33e9bd77495b198855e1ef48a05125c2f6d8e0"} +{"id":"evt-q34ren2s73ey","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-9nxdndr","to":"area:auth"},"at":"2026-09-19T14:30:08.178Z","parents":["evt-137wdzt3ytb2"],"hash":"f158aafee35c1741245006c76692f3c8918710d96d14c4f09d105b74cb27e0fa"} +{"id":"evt-qhb2r7x5cgr8","type":"edge.added","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-9nxdndr","to":"area:ui"},"at":"2026-09-19T14:30:08.179Z","parents":["evt-q34ren2s73ey"],"hash":"8328c616919fbad349325770a3510734ccbb2e2ad107a918faef4f771f69c155"} +{"id":"evt-2zb4wga46rth","type":"node.created","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Enforce the admin session on every page and /api/* route","body":"src/proxy.ts (Next 16's replacement for middleware, Node runtime) does optimistic checks. With an admin: no valid cookie → redirect pages to /login?next=..., and return 401 JSON for /api/*. Without an admin: pages go to /setup unless the skip cookie is set, and the API stays open, matching 'skip = unsecured'. Excluded: /login, /setup, and _next/static assets. The authoritative check is verifySession() in page.tsx and in every route handler via camera-route.ts, so skipping the proxy still can't reach data. Tests cover each route unauthenticated → 401 when an admin exists.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T14:30:11.902Z","parents":["evt-qhb2r7x5cgr8"],"hash":"cc89ad454eeebd15067b8cd7a4d1c1bbe21aa877bfda1685eb1d811101c0e8a5"} +{"id":"evt-tyfyar8vdzjr","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-76d5wrb","to":"gol-wqf95dq"},"at":"2026-09-19T14:30:11.903Z","parents":["evt-2zb4wga46rth"],"hash":"e19b8669be2c5b5ddebf0df27d10007ac0727a1e7ffb711f33d99b32304c7a6b"} +{"id":"evt-3pggm88grjrm","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-76d5wrb","to":"src/proxy.ts"},"at":"2026-09-19T14:30:11.904Z","parents":["evt-tyfyar8vdzjr"],"hash":"a0061824a53c0baaa6737f56e9536c4f7d9d04d794596ebc5da28afdc7bc6b78"} +{"id":"evt-6ctn1h7tcjdf","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-76d5wrb","to":"src/lib/camera-route.ts"},"at":"2026-09-19T14:30:11.905Z","parents":["evt-3pggm88grjrm"],"hash":"8dde0239f8564efaa6dbc1131ec059304addafb37db42b4f482ab96953e7f46d"} +{"id":"evt-91zr543pgj4y","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-76d5wrb","to":"src/app/page.tsx"},"at":"2026-09-19T14:30:11.906Z","parents":["evt-6ctn1h7tcjdf"],"hash":"729b670454ec0846357c9c1d09972cd5ad3372dee137f894ca07515471aaa6f7"} +{"id":"evt-x90vnksepxyq","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-76d5wrb","to":"src/app/api/"},"at":"2026-09-19T14:30:11.907Z","parents":["evt-91zr543pgj4y"],"hash":"72846fc4fcb2971ea167710e3c0afb5163116a40096cc33dc7d9e06370360358"} +{"id":"evt-2m8rqzdwvk0w","type":"edge.added","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-e27nb70","to":"iss-76d5wrb"},"at":"2026-09-19T14:30:11.908Z","parents":["evt-x90vnksepxyq"],"hash":"755faaa1999460db3bf20decb4c6cc3b2ec4fa4d0e0c4707cee661f9e7d66531"} +{"id":"evt-p9byhy01ym59","type":"edge.added","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-r5vrjx7","to":"iss-76d5wrb"},"at":"2026-09-19T14:30:11.909Z","parents":["evt-2m8rqzdwvk0w"],"hash":"5abc3ecc9b1747c1a1d8e72f1891fb62a8c7f86f56ef76859a994701068ab87a"} +{"id":"evt-bdt2wdyym6g1","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-76d5wrb","to":"area:auth"},"at":"2026-09-19T14:30:11.910Z","parents":["evt-p9byhy01ym59"],"hash":"d374932c8fbd8b810c571965467d1cdf88b67594683da49cb68eac968a22dbaf"} +{"id":"evt-g1xr6vqnj3k6","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-76d5wrb","to":"area:security"},"at":"2026-09-19T14:30:11.911Z","parents":["evt-bdt2wdyym6g1"],"hash":"2cc6f29d6e4954065fccc5c3df7b4dc7c4e57900f4a93517ff3fc73facec75f9"} +{"id":"evt-n6kgan9e08pn","type":"edge.added","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-76d5wrb","to":"area:api"},"at":"2026-09-19T14:30:11.912Z","parents":["evt-g1xr6vqnj3k6"],"hash":"16c8d887f3d200c407c636efbc740954e0fa867337a864f85f5e040966d1c6c1"} +{"id":"evt-8e1730343hfr","type":"node.updated","subject":"gol-wqf95dq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":0.9}},"at":"2026-09-19T14:34:00.588Z","parents":["evt-n6kgan9e08pn"],"hash":"0482776c0995b425ad1b1fe22da7e132f0ad9ed67172dc1bf6bfd1c024f82d1e"} +{"id":"evt-1yckgsb8pvnt","type":"node.created","subject":"ver-5m61y5m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Admin file module: scrypt hash/verify (random salt, stored params, constant-time compare, bounded params), ADMIN_AUTH_FILE with a .data/admin.json default, zod-validated read (missing → null, malformed → AdminFileError), atomic 0600 write that can't replace an existing admin without overwrite, and checkLogin that always spends one hash.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:34:31.571Z","parents":["evt-8e1730343hfr"],"hash":"8e42720718ec8e89a6813d76aef3235c6aa0d491f1e80b2050866c7455fea1e7"} +{"id":"evt-5fr8erspmktk","type":"edge.added","subject":"ver-5m61y5m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-5m61y5m","to":"iss-mffqscg"},"at":"2026-09-19T14:34:31.573Z","parents":["evt-1yckgsb8pvnt"],"hash":"2eed13ae55a7bac1b53bd14309ff1d9396dbc90df8b87b7b477b4cbe5b21dc70"} +{"id":"evt-jg6sfntc43cp","type":"verification.recorded","subject":"ver-5m61y5m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/admin-file.ts (plain Node, shared with the future CLI) and src/lib/admin-auth.ts (server-only app layer); tests in src/lib/admin-file.test.ts and src/lib/admin-auth.test.ts, 43 tests, 100% lines for both. Full suite 243/243, coverage 99.8%, tsc and eslint clean, 2026-09-19. Split from the issue text: the hashing and file code is in admin-file.ts rather than admin-auth.ts, so the CLI can import it without Next."},"at":"2026-09-19T14:34:31.574Z","parents":["evt-5fr8erspmktk"],"hash":"518d9c4c8813d8bccd44ab1c34a92944cef8ba087e4de8f437c94fc4edf463ac"} +{"id":"evt-et9z26yd6dat","type":"node.status_changed","subject":"iss-mffqscg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:34:32.733Z","parents":["evt-jg6sfntc43cp"],"hash":"d4b9203a0d63789a16fde5731f32c7637095e62b47aefbeb2e66f48cb88a95bd"} +{"id":"evt-ktw4j5b38wm0","type":"node.created","subject":"ver-6a8cs5y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"`npm run admin:create` creates ADMIN_AUTH_FILE (or .data/admin.json) owner-only with a scrypt hash: interactive with a hidden, confirmed password, or piped; it refuses to overwrite without --force and rejects weak or invalid input. The README documents the CLI, a no-npm Node one-liner, the file format, and password reset.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:37:27.499Z","parents":["evt-et9z26yd6dat"],"hash":"becc70dde87809c16f8c9970957528f27d7bccfa97d01d5a88e2f27eed31070f"} +{"id":"evt-h2dw7m3h5wah","type":"edge.added","subject":"ver-6a8cs5y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-6a8cs5y","to":"iss-7xmka20"},"at":"2026-09-19T14:37:27.502Z","parents":["evt-ktw4j5b38wm0"],"hash":"65fe15671bf53cf588c46b11d4cfebf26f5c8e6b6ead8d51542e56960a85cb62"} +{"id":"evt-t416xnmprmnp","type":"verification.recorded","subject":"ver-6a8cs5y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"scripts/create-admin.mts shares src/lib/admin-file.ts and runs on plain `node` (Node 24 type stripping; tsconfig allowImportingTsExtensions). scripts/create-admin.test.ts: 9 process-level tests (create, stdin username, refuse without --force, --force replace, short password, bad username, empty input, corrupt file, --help). Interactive mode checked by hand via a pty (`script`): password not echoed; mismatched confirmation refused. The README one-liner was extracted verbatim and run in bash and zsh through a pty, producing a -rw------- file that verifyPassword accepts. Full suite 252/252, tsc, eslint and next build clean, 2026-09-19."},"at":"2026-09-19T14:37:27.503Z","parents":["evt-h2dw7m3h5wah"],"hash":"6764ee63d4f00f8af0aa7a5029ada2fd4a6aef2832f2e1da07bc83134642f950"} +{"id":"evt-3jj78jc2jtra","type":"node.status_changed","subject":"iss-7xmka20","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:37:28.623Z","parents":["evt-t416xnmprmnp"],"hash":"48dd11b3f5b33812b650821013a633fa57fac932bf87b3b23990a6640d75016d"} +{"id":"evt-me637e20qx4a","type":"node.created","subject":"dec-f0xar8r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Admin sessions: 12-hour sliding window; locking out other sessions means changing the password","body":"User's choice (2026-09-19), replacing the 7-day default in dec-nw2hvff. A session expires 12 hours after the last activity. The signed token carries its own expiry, which is renewed to now+12h when it's older than 5 minutes, so the dashboard's 1 s polling doesn't rewrite the cookie on every request. Next 16 can't set cookies during Server Component rendering (03-api-reference/04-functions/cookies.md), so the refresh happens in proxy.ts (iss-76d5wrb), Server Actions and route handlers; verifySession() during render only reads. Sessions stay stateless: signing out clears only that browser, and the accepted way to cut off other sessions is changing the password, which rotates the HKDF-derived key. Rejected: a fixed 7-day session, and a server-side session store.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:42:27.204Z","parents":["evt-3jj78jc2jtra"],"hash":"172672fb31586e94a5ab483b00394d35be5b4754a3e8388b69bda463e1a60a65"} +{"id":"evt-d18rdkvv0z2f","type":"edge.added","subject":"dec-f0xar8r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-f0xar8r","to":"gol-wqf95dq"},"at":"2026-09-19T14:42:27.205Z","parents":["evt-me637e20qx4a"],"hash":"227b45629b5c4ec0837ee3317ceb5bac4b8797ddf110e2c793cb0817f7edab9e"} +{"id":"evt-k075xnkjjazc","type":"node.status_changed","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T14:42:28.474Z","parents":["evt-d18rdkvv0z2f"],"hash":"867a9ae8503654e3b632ff01afc669efa0afd904d2257373b96db35bdf033554"} +{"id":"evt-qzwpjb3mdebm","type":"node.updated","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"src/lib/session-token.ts (pure: issue, read and refresh tokens) and src/lib/session.ts (server-only: cookies via next/headers). The token is `..`; the key is HKDF-SHA256 from the stored password hash, so a password change ends all sessions. The cookie `cameras_session` is HttpOnly, SameSite=Lax, Path=/, Secure behind HTTPS (x-forwarded-proto), expiring with the token. Lifetime is a 12-hour sliding window (dec-f0xar8r): re-issued at most every 5 minutes. verifySession() only reads, so it's safe during render; touchSession() slides the window in Server Actions and route handlers; proxy.ts will slide it on page loads (iss-76d5wrb). authState() returns 'no-admin' | 'signed-out' | 'signed-in'. createSession() and deleteSession() are used by login and sign-out. Signature comparison is constant-time."}},"at":"2026-09-19T14:43:44.948Z","parents":["evt-k075xnkjjazc"],"hash":"e17c5608ce4126fc5e7a4d2558ec36b0e5d0fb9ae029ca8c2bb2061dab29fa68"} +{"id":"evt-vmhgde1h8656","type":"node.created","subject":"ver-9h5hthr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Tokens round-trip; expire at 12 h idle; slide to now+12h when older than 5 min (an active user stays signed in past 30 h, an idle one lapses); reject a forged, tampered, other-username, or password-changed token, garbage, and a missing admin. The cookie is HttpOnly, SameSite=Lax, Path=/, Secure only behind HTTPS; verifySession, authState, createSession, touchSession and deleteSession behave as specified.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:43:48.423Z","parents":["evt-qzwpjb3mdebm"],"hash":"aceb8345fa1ae05fc0c8ea9137a2d641a88e6e597392d032515394e2fdb829a5"} +{"id":"evt-3zd0afwt73jz","type":"edge.added","subject":"ver-9h5hthr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-9h5hthr","to":"iss-e27nb70"},"at":"2026-09-19T14:43:48.424Z","parents":["evt-vmhgde1h8656"],"hash":"54f3637661f1a3dbefd062c1c7a7cdae0485735d6e848e425265fed528906ee9"} +{"id":"evt-ys0ns2v7xa4g","type":"verification.recorded","subject":"ver-9h5hthr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/session-token.test.ts and src/lib/session.test.ts, 29 tests; 100% statements, branches, functions and lines for both modules (next/headers replaced by an in-memory cookie jar). Full suite 281/281, coverage 99.81%, tsc, eslint and next build clean, 2026-09-19. Nothing calls these yet: login, setup and enforcement (iss-nj9wmwp, iss-9nxdndr, iss-76d5wrb) wire them in."},"at":"2026-09-19T14:43:48.425Z","parents":["evt-3zd0afwt73jz"],"hash":"68a859ece84f4bd548a2e8590a4526b302e661785bd8f2428adcb9caf59cb5a0"} +{"id":"evt-gxsrqxkcrrtn","type":"node.status_changed","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T14:43:49.506Z","parents":["evt-ys0ns2v7xa4g"],"hash":"2a55c37b48d5a13eb0bacb0e76956f129d58b045bfb92b06092f572b607d1c19"} diff --git a/README.md b/README.md index e215bc4..d17f7f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,75 @@ +# Cameras + +A web interface for discovering and managing Hikvision and Annke IP cameras on your local network. + +## Securing the web interface + +The dashboard and its API are protected by a single admin login. The login is stored in a +file, **never** in plain text: the password is hashed with [scrypt](https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback) +(random salt, N=65536, r=8, p=1), so it can't be recovered from the file. + +| Setting | Default | +| --- | --- | +| `ADMIN_AUTH_FILE` (environment variable, e.g. in `.env.local`) | `./.data/admin.json` | + +If the file doesn't exist, the app starts **unsecured**: anyone who can reach it can view your +cameras and change their logins. To avoid ever running it that way, create the admin file +before the first start. + +### Create the admin login (recommended) + +```bash +npm run admin:create +``` + +It asks for a username and a password (at least 12 characters; typing is hidden, and you +confirm it), then writes the file with owner-only permissions (`0600`). Options: + +```bash +npm run admin:create -- --username admin # only ask for the password +npm run admin:create -- --force # replace the existing admin (reset the password) +printf '%s\n' "$PW" | npm run admin:create -- --username admin # non-interactive, e.g. provisioning +``` + +### Without npm: a Node one-liner + +Any Node 24+ can produce the same file. The password is read from the terminal without +echoing, so it never appears in your shell history or the process list: + +```bash +(read -rs PW && export PW && umask 077 && mkdir -p .data && node -e ' +const c = require("node:crypto"), N = 65536, r = 8, p = 1, salt = c.randomBytes(16); +const key = c.scryptSync(process.env.PW, salt, 64, { N, r, p, maxmem: 256 * N * r }); +const passwordHash = ["scrypt", N, r, p, salt.toString("base64"), key.toString("base64")].join("$"); +console.log(JSON.stringify({ version: 1, username: "admin", passwordHash }, null, 2)); +' > .data/admin.json) +``` + +The parentheses run it in a subshell, so the password variable and the stricter `umask` end +with it. + +Change `username: "admin"` to taste. Usernames are 1–64 letters, digits, or `. _ @ -`. + +### File format + +```json +{ + "version": 1, + "username": "admin", + "passwordHash": "scrypt$65536$8$1$$<64-byte key, base64>" +} +``` + +A file that exists but is malformed stops the app from authenticating anyone rather than +quietly turning security off; fix it or delete it. + +### Forgot the password? + +Run `npm run admin:create -- --force` (or delete the file and create it again). Changing the +password signs out every existing session. + +--- + This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started diff --git a/package.json b/package.json index 7b94b26..891c0dd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "admin:create": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/create-admin.mts" }, "dependencies": { "@tanstack/react-query": "^5.103.1", diff --git a/scripts/create-admin.mts b/scripts/create-admin.mts new file mode 100644 index 0000000..159109d --- /dev/null +++ b/scripts/create-admin.mts @@ -0,0 +1,96 @@ +// Creates (or with --force, replaces) the admin login file, outside the app, so the web +// interface never has to run unsecured. No dependencies beyond the app's own; runs on +// Node 24+ directly: +// +// npm run admin:create # prompts for username and password +// npm run admin:create -- --username admin # prompts for password only +// npm run admin:create -- --force # replace an existing admin (resets password) +// printf '%s\n' "$PW" | npm run admin:create -- --username admin # non-interactive +// +// Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600). +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { parseArgs } from "node:util"; +import { + adminFilePath, + createAdminRecord, + passwordSchema, + readAdminFile, + usernameSchema, + writeAdminFile, +} from "../src/lib/admin-file.ts"; + +const { values } = parseArgs({ + options: { + username: { type: "string" }, + force: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, +}); + +if (values.help) { + console.log("Usage: npm run admin:create -- [--username NAME] [--force]"); + process.exit(0); +} + +const file = adminFilePath(); +const interactive = process.stdin.isTTY === true; + +// Echo is switched off while a password is typed. +let muted = false; +const output = new Writable({ + write(chunk, _encoding, done) { + if (!muted) process.stdout.write(chunk); + done(); + }, +}); +const rl = createInterface({ input: process.stdin, output, terminal: interactive }); +// Piped input can arrive before the questions are asked, so read it line by line. +const lines = interactive ? null : rl[Symbol.asyncIterator](); + +async function ask(question: string, { secret = false } = {}): Promise { + if (lines) { + const next = await lines.next(); + return next.done ? "" : String(next.value); + } + if (!secret) return rl.question(question); + process.stdout.write(question); + muted = true; + const answer = await rl.question(""); + muted = false; + process.stdout.write("\n"); + return answer; +} + +function fail(message: string): never { + console.error(`Error: ${message}`); + rl.close(); + process.exit(1); +} + +async function main() { + const existing = await readAdminFile(file).catch((err: Error) => fail(err.message)); + if (existing && !values.force) { + fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`); + } + + const username = values.username ?? (await ask("Admin username: ")); + const name = usernameSchema.safeParse(username); + if (!name.success) fail(`username ${name.error.issues[0].message}`); + + const password = await ask("Password (12+ characters): ", { secret: true }); + const strong = passwordSchema.safeParse(password); + if (!strong.success) fail(`password: ${strong.error.issues[0].message}`); + if (interactive && (await ask("Repeat password: ", { secret: true })) !== password) { + fail("passwords don't match."); + } + rl.close(); + + await writeAdminFile(file, await createAdminRecord(username, password), { + overwrite: values.force, + }); + console.log(`Admin "${username}" saved to ${file} (owner-only).`); + if (existing) console.log("Existing sessions are signed out; log in again with the new password."); +} + +await main(); diff --git a/scripts/create-admin.test.ts b/scripts/create-admin.test.ts new file mode 100644 index 0000000..c069e2d --- /dev/null +++ b/scripts/create-admin.test.ts @@ -0,0 +1,94 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { verifyPassword } from "../src/lib/admin-file"; + +/** Runs the CLI as a real process with piped (non-interactive) stdin. */ +const run = promisify(execFile); +const SCRIPT = path.join(import.meta.dirname, "create-admin.mts"); +const PASSWORD = "correct horse battery"; + +let dir: string; +let file: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "create-admin-")); + file = path.join(dir, "admin.json"); +}); +afterEach(() => rm(dir, { recursive: true, force: true })); + +async function cli(args: string[], stdin: string) { + const child = run( + process.execPath, + ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", SCRIPT, ...args], + { env: { ...process.env, ADMIN_AUTH_FILE: file } }, + ); + child.child.stdin!.end(stdin); + return child.then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (err) => ({ code: err.code as number, stdout: err.stdout as string, stderr: err.stderr as string }), + ); +} + +const readAdmin = async () => JSON.parse(await readFile(file, "utf8")); + +describe("npm run admin:create", () => { + it("creates an owner-only admin file with a hash of the piped password", async () => { + const res = await cli(["--username", "admin"], `${PASSWORD}\n`); + expect(res).toMatchObject({ code: 0, stderr: "" }); + expect(res.stdout).toContain(`Admin "admin" saved to ${file} (owner-only).`); + + const admin = await readAdmin(); + expect(admin.username).toBe("admin"); + expect(await verifyPassword(PASSWORD, admin.passwordHash)).toBe(true); + expect(JSON.stringify(admin)).not.toContain(PASSWORD); + expect((await stat(file)).mode & 0o777).toBe(0o600); + }); + + it("reads the username from stdin too", async () => { + expect((await cli([], `ops\n${PASSWORD}\n`)).code).toBe(0); + expect((await readAdmin()).username).toBe("ops"); + }); + + it("won't replace an existing admin without --force", async () => { + await cli(["--username", "admin"], `${PASSWORD}\n`); + const res = await cli(["--username", "intruder"], `${PASSWORD}\n`); + expect(res.code).toBe(1); + expect(res.stderr).toContain('an admin ("admin") already exists'); + expect((await readAdmin()).username).toBe("admin"); + }); + + it("replaces the admin with --force and says sessions are signed out", async () => { + await cli(["--username", "admin"], `${PASSWORD}\n`); + const res = await cli(["--username", "admin", "--force"], "a brand new password\n"); + expect(res.code).toBe(0); + expect(res.stdout).toContain("Existing sessions are signed out"); + expect(await verifyPassword("a brand new password", (await readAdmin()).passwordHash)).toBe(true); + }); + + it.each([ + ["a short password", ["--username", "admin"], "short\n", "password: At least 12 characters"], + ["an invalid username", ["--username", "bad name"], `${PASSWORD}\n`, "username 1–64"], + ["no input", [], "", "username 1–64"], + ])("rejects %s and writes nothing", async (_label, args, stdin, message) => { + const res = await cli(args, stdin); + expect(res.code).toBe(1); + expect(res.stderr).toContain(message); + await expect(stat(file)).rejects.toThrow(/ENOENT/); + }); + + it("refuses to run over a corrupt admin file", async () => { + await writeFile(file, "{oops"); + const res = await cli(["--username", "admin", "--force"], `${PASSWORD}\n`); + expect(res.code).toBe(1); + expect(res.stderr).toContain("is not valid JSON"); + }); + + it("prints usage with --help", async () => { + const res = await cli(["--help"], ""); + expect(res).toMatchObject({ code: 0 }); + expect(res.stdout).toContain("Usage: npm run admin:create"); + }); +}); diff --git a/src/lib/admin-auth.test.ts b/src/lib/admin-auth.test.ts new file mode 100644 index 0000000..ab9defc --- /dev/null +++ b/src/lib/admin-auth.test.ts @@ -0,0 +1,58 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const PASSWORD = "correct horse battery"; +let dir: string; +let auth: typeof import("./admin-auth"); + +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "admin-auth-")); + vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json")); + vi.resetModules(); + auth = await import("./admin-auth"); +}); +afterEach(() => rm(dir, { recursive: true, force: true })); + +describe("admin auth", () => { + it("has no admin until one is created", async () => { + expect(await auth.hasAdmin()).toBe(false); + expect(await auth.getAdmin()).toBeNull(); + }); + + it("creates the first admin and accepts only its exact login", async () => { + await auth.createAdmin("admin", PASSWORD); + expect(await auth.hasAdmin()).toBe(true); + + expect(await auth.checkLogin("admin", PASSWORD)).toBe(true); + expect(await auth.checkLogin("admin", "wrong password!")).toBe(false); + expect(await auth.checkLogin("Admin", PASSWORD)).toBe(false); + expect(await auth.checkLogin("someone", PASSWORD)).toBe(false); + }); + + it("refuses to create a second admin", async () => { + await auth.createAdmin("admin", PASSWORD); + await expect(auth.createAdmin("intruder", PASSWORD)).rejects.toMatchObject({ code: "EEXIST" }); + expect((await auth.getAdmin())!.username).toBe("admin"); + }); + + it("rejects every login when no admin exists, still spending a full hash", async () => { + const admin = await import("./admin-file"); + const verify = vi.spyOn(admin, "verifyPassword"); + vi.resetModules(); + vi.doMock("./admin-file", () => admin); + const fresh = await import("./admin-auth"); + + expect(await fresh.checkLogin("admin", PASSWORD)).toBe(false); + expect(verify).toHaveBeenCalledTimes(1); + vi.doUnmock("./admin-file"); + }); + + it("picks up an admin created outside the app without a restart", async () => { + expect(await auth.hasAdmin()).toBe(false); + const { createAdminRecord, writeAdminFile } = await import("./admin-file"); + await writeAdminFile(process.env.ADMIN_AUTH_FILE!, await createAdminRecord("cli", PASSWORD)); + expect(await auth.checkLogin("cli", PASSWORD)).toBe(true); + }); +}); diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts new file mode 100644 index 0000000..eea30dc --- /dev/null +++ b/src/lib/admin-auth.ts @@ -0,0 +1,45 @@ +import "server-only"; +import { + adminFilePath, + createAdminRecord, + hashPassword, + readAdminFile, + verifyPassword, + writeAdminFile, + type AdminFile, +} from "./admin-file"; + +/** + * The app's view of the admin login. The file is re-read on each call (it's tiny), so an + * admin created or reset with the CLI takes effect without restarting the server. + */ + +export function getAdmin(): Promise { + return readAdminFile(adminFilePath()); +} + +export async function hasAdmin(): Promise { + return (await getAdmin()) !== null; +} + +// Hashed once, lazily: lets a failed login cost the same whether or not the username exists. +let dummyHash: Promise | null = null; + +/** + * True only for the admin's exact username and password. Always spends one full scrypt + * hash, so timing doesn't reveal whether an admin exists or the username was right. + */ +export async function checkLogin(username: string, password: string): Promise { + const admin = await getAdmin(); + const userMatches = admin !== null && admin.username === username; + const hash = userMatches ? admin.passwordHash : await (dummyHash ??= hashPassword("not the password")); + const passwordMatches = await verifyPassword(password, hash); + return userMatches && passwordMatches; +} + +/** Creates the first admin. Fails with EEXIST if one already exists. */ +export async function createAdmin(username: string, password: string): Promise { + const admin = await createAdminRecord(username, password); + await writeAdminFile(adminFilePath(), admin); + return admin; +} diff --git a/src/lib/admin-file.test.ts b/src/lib/admin-file.test.ts new file mode 100644 index 0000000..e2679c0 --- /dev/null +++ b/src/lib/admin-file.test.ts @@ -0,0 +1,172 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + AdminFileError, + adminFilePath, + createAdminRecord, + DEFAULT_ADMIN_FILE, + hashPassword, + isValidPasswordHash, + passwordSchema, + readAdminFile, + usernameSchema, + verifyPassword, + writeAdminFile, + type AdminFile, +} from "./admin-file"; + +const PASSWORD = "correct horse battery"; +// A well-formed hash string (valid shape, meaningless key) for the malformed-hash table, +// which is built before beforeAll runs. +const SHAPE = `scrypt$65536$8$1$${"A".repeat(24)}$${"A".repeat(88)}`; +let hash: string; +let admin: AdminFile; + +beforeAll(async () => { + hash = await hashPassword(PASSWORD); + admin = { version: 1, username: "admin", passwordHash: hash }; +}); + +let dir: string; +let file: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "admin-file-")); + file = path.join(dir, "nested", "admin.json"); +}); +afterEach(() => rm(dir, { recursive: true, force: true })); + +describe("adminFilePath", () => { + it("uses ADMIN_AUTH_FILE when set", () => { + expect(adminFilePath({ ADMIN_AUTH_FILE: "/etc/cams/admin.json" })).toBe("/etc/cams/admin.json"); + }); + + it.each([[{}], [{ ADMIN_AUTH_FILE: "" }]])("defaults to ./.data/admin.json for %j", (env) => { + expect(adminFilePath(env)).toBe(DEFAULT_ADMIN_FILE); + expect(DEFAULT_ADMIN_FILE).toBe(path.join(process.cwd(), ".data", "admin.json")); + }); +}); + +describe("password hashing", () => { + it("produces a salted scrypt string that never contains the password", () => { + expect(hash).toMatch(/^scrypt\$65536\$8\$1\$[A-Za-z0-9+/=]{24}\$[A-Za-z0-9+/=]{88}$/); + expect(hash).not.toContain(PASSWORD); + expect(isValidPasswordHash(hash)).toBe(true); + }); + + it("salts every hash differently", async () => { + expect(await hashPassword(PASSWORD)).not.toBe(hash); + }); + + it("verifies the right password and rejects others", async () => { + expect(await verifyPassword(PASSWORD, hash)).toBe(true); + expect(await verifyPassword("correct horse battery!", hash)).toBe(false); + expect(await verifyPassword("", hash)).toBe(false); + }); + + it("verifies hashes made with other scrypt parameters", async () => { + // e.g. one produced by a future, costlier default, or by hand with the documented one-liner. + const { scryptSync, randomBytes } = await import("node:crypto"); + const salt = randomBytes(16); + const key = scryptSync(PASSWORD, salt, 32, { N: 1024, r: 4, p: 2 }); + const encoded = `scrypt$1024$4$2$${salt.toString("base64")}$${key.toString("base64")}`; + expect(await verifyPassword(PASSWORD, encoded)).toBe(true); + }); + + it("accepts the table's base shape", () => expect(isValidPasswordHash(SHAPE)).toBe(true)); + + it.each([ + ["not a hash", "hunter2"], + ["wrong algorithm", SHAPE.replace(/^scrypt/, "sha512")], + ["N not a power of two", SHAPE.replace("$65536$", "$65535$")], + ["N too large", SHAPE.replace("$65536$", "$2097152$")], + ["r out of range", SHAPE.replace("$8$1$", "$0$1$")], + ["p out of range", SHAPE.replace("$8$1$", "$8$99$")], + ["salt too short", "scrypt$1024$8$1$AAAA$" + "A".repeat(88)], + ["key too short", `scrypt$1024$8$1$${"A".repeat(24)}$AAAA`], + ])("rejects %s without hashing", async (_label, encoded) => { + expect(isValidPasswordHash(encoded)).toBe(false); + expect(await verifyPassword(PASSWORD, encoded)).toBe(false); + }); +}); + +describe("validation", () => { + it.each(["admin", "mike.m", "ops_team-1", "me@example.com", "a".repeat(64)])( + "accepts username %s", + (u) => expect(usernameSchema.safeParse(u).success).toBe(true), + ); + + it.each(["", "has space", "semi;colon", "a".repeat(65), "ünïcode"])("rejects username %j", (u) => + expect(usernameSchema.safeParse(u).success).toBe(false), + ); + + it("requires 12–256 character passwords", () => { + expect(passwordSchema.safeParse("x".repeat(11)).success).toBe(false); + expect(passwordSchema.safeParse("x".repeat(12)).success).toBe(true); + expect(passwordSchema.safeParse("x".repeat(256)).success).toBe(true); + expect(passwordSchema.safeParse("x".repeat(257)).success).toBe(false); + }); + + it("createAdminRecord validates, then hashes", async () => { + await expect(createAdminRecord("bad name", PASSWORD)).rejects.toThrow(); + await expect(createAdminRecord("admin", "short")).rejects.toThrow(); + const record = await createAdminRecord("admin", PASSWORD); + expect(record).toMatchObject({ version: 1, username: "admin" }); + expect(await verifyPassword(PASSWORD, record.passwordHash)).toBe(true); + }); +}); + +describe("reading and writing the file", () => { + it("returns null when the file doesn't exist", async () => { + expect(await readAdminFile(file)).toBeNull(); + }); + + it("writes owner-only in an owner-only folder, and reads it back", async () => { + await writeAdminFile(file, admin); + expect(await readAdminFile(file)).toEqual(admin); + expect((await stat(file)).mode & 0o777).toBe(0o600); + expect((await stat(path.dirname(file))).mode & 0o777).toBe(0o700); + expect(await readFile(file, "utf8")).not.toContain(PASSWORD); + }); + + it("refuses to replace an existing admin unless asked, leaving no temp files", async () => { + await writeAdminFile(file, admin); + const other = { ...admin, username: "intruder" }; + + await expect(writeAdminFile(file, other)).rejects.toMatchObject({ code: "EEXIST" }); + expect((await readAdminFile(file))!.username).toBe("admin"); + + await writeAdminFile(file, other, { overwrite: true }); + expect((await readAdminFile(file))!.username).toBe("intruder"); + + const { readdir } = await import("node:fs/promises"); + expect(await readdir(path.dirname(file))).toEqual(["admin.json"]); + }); + + it("refuses to write an invalid record", async () => { + await expect(writeAdminFile(file, { ...admin, passwordHash: "plaintext" })).rejects.toThrow(); + expect(await readAdminFile(file)).toBeNull(); + }); + + describe("fails loudly instead of disabling auth", () => { + it.each([ + ["invalid JSON", "{not json", /not valid JSON/], + ["a missing field", JSON.stringify({ version: 1, username: "admin" }), /passwordHash/], + ["a plaintext password", JSON.stringify({ version: 1, username: "admin", passwordHash: "hunter2" }), /passwordHash/], + ["an unknown version", JSON.stringify({ version: 2, username: "admin", passwordHash: "x" }), /version/], + ["a non-object", "null", /file/], + ])("on %s", async (_label, contents, message) => { + await writeFile(file.replace("nested/", ""), contents); + const err = await readAdminFile(file.replace("nested/", "")).catch((e) => e); + expect(err).toBeInstanceOf(AdminFileError); + expect(err.message).toMatch(message); + }); + + it("on an unreadable path", async () => { + // A directory where the file should be: exists, but can't be read as a file. + await writeAdminFile(path.join(file, "inner.json"), admin); + await expect(readAdminFile(file)).rejects.toBeInstanceOf(AdminFileError); + }); + }); +}); diff --git a/src/lib/admin-file.ts b/src/lib/admin-file.ts new file mode 100644 index 0000000..639247c --- /dev/null +++ b/src/lib/admin-file.ts @@ -0,0 +1,157 @@ +/** + * The admin login file: format, password hashing, and atomic read/write. + * + * Plain Node on purpose (no "server-only", no path aliases, no TS-only syntax) so that + * scripts/create-admin.mts can run it directly with `node` to create the file outside the + * app. The app uses it through admin-auth.ts. + */ +import { randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from "node:crypto"; +import { link, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +// Runtime data, not a build input: the ignore comment keeps it out of Next's output file +// tracing (see camera-registry.ts). +export const DEFAULT_ADMIN_FILE = path.join( + /* turbopackIgnore: true */ process.cwd(), + ".data", + "admin.json", +); + +/** ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json. */ +export function adminFilePath(env: Record = process.env): string { + return env.ADMIN_AUTH_FILE || DEFAULT_ADMIN_FILE; +} + +export const usernameSchema = z + .string() + .regex(/^[A-Za-z0-9._@-]{1,64}$/, "1–64 letters, digits, or . _ @ -"); + +export const passwordSchema = z + .string() + .min(12, "At least 12 characters") + .max(256, "At most 256 characters"); + +// scrypt cost: N=2^16, r=8, p=1 needs 64 MiB and ~100–200 ms per hash, which makes guessing +// against a stolen file expensive while keeping a login quick. +const COST = { N: 2 ** 16, r: 8, p: 1 }; +const KEY_LENGTH = 64; +const SALT_LENGTH = 16; +// Refuse absurd parameters from a tampered file rather than exhausting memory. +const MAX_N = 2 ** 20; + +function scrypt( + password: string, + salt: Buffer, + keylen: number, + { N, r, p }: { N: number; r: number; p: number }, +) { + // scrypt needs 128·N·r bytes; allow twice that so Node's default 32 MiB cap doesn't refuse. + const options: ScryptOptions = { N, r, p, maxmem: 256 * N * r }; + return new Promise((resolve, reject) => + scryptCb(password, salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key))), + ); +} + +/** `scrypt$N$r$p$salt$hash`, with salt and hash in base64. */ +export async function hashPassword(password: string): Promise { + const salt = randomBytes(SALT_LENGTH); + const key = await scrypt(password, salt, KEY_LENGTH, COST); + const { N, r, p } = COST; + return ["scrypt", N, r, p, salt.toString("base64"), key.toString("base64")].join("$"); +} + +const HASH_PATTERN = /^scrypt\$(\d+)\$(\d+)\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/; + +function parseHash(encoded: string) { + const m = HASH_PATTERN.exec(encoded); + if (!m) return null; + const [N, r, p] = [m[1], m[2], m[3]].map(Number); + const salt = Buffer.from(m[4], "base64"); + const key = Buffer.from(m[5], "base64"); + const powerOfTwo = N > 1 && (N & (N - 1)) === 0; + if (!powerOfTwo || N > MAX_N || r < 1 || r > 32 || p < 1 || p > 16) return null; + if (salt.length < 8 || key.length < 32) return null; + return { N, r, p, salt, key }; +} + +export function isValidPasswordHash(encoded: string): boolean { + return parseHash(encoded) !== null; +} + +/** Recomputes the hash with its stored parameters and compares in constant time. */ +export async function verifyPassword(password: string, encoded: string): Promise { + const parsed = parseHash(encoded); + if (!parsed) return false; + const { N, r, p, salt, key } = parsed; + const candidate = await scrypt(password, salt, key.length, { N, r, p }); + return timingSafeEqual(candidate, key); +} + +export const adminFileSchema = z.object({ + version: z.literal(1), + username: usernameSchema, + passwordHash: z.string().refine(isValidPasswordHash, "Not a scrypt$N$r$p$salt$hash string"), +}); + +export type AdminFile = z.infer; + +export class AdminFileError extends Error { + name = "AdminFileError"; +} + +/** + * The admin file, or null if it doesn't exist. A file that exists but is unreadable or + * malformed throws: a typo must never silently turn authentication off. + */ +export async function readAdminFile(file: string): Promise { + let text: string; + try { + text = await readFile(file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new AdminFileError(`Can't read admin file ${file}: ${(err as Error).message}`); + } + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new AdminFileError(`Admin file ${file} is not valid JSON`); + } + const parsed = adminFileSchema.safeParse(json); + if (!parsed.success) { + const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "file"}: ${i.message}`); + throw new AdminFileError(`Admin file ${file} is malformed: ${problems.join("; ")}`); + } + return parsed.data; +} + +/** + * Writes the file owner-only (0600) via a temp file, so a crash never leaves it half + * written. Without `overwrite`, fails with EEXIST if an admin already exists, atomically. + */ +export async function writeAdminFile( + file: string, + admin: AdminFile, + { overwrite = false }: { overwrite?: boolean } = {}, +): Promise { + const contents = `${JSON.stringify(adminFileSchema.parse(admin), null, 2)}\n`; + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + await writeFile(tmp, contents, { mode: 0o600 }); + try { + // link() refuses to replace an existing file; rename() replaces it. + await (overwrite ? rename(tmp, file) : link(tmp, file)); + } finally { + if (!overwrite) await unlink(tmp).catch(() => {}); + } +} + +/** Builds a validated admin record, hashing the password. */ +export async function createAdminRecord(username: string, password: string): Promise { + return { + version: 1, + username: usernameSchema.parse(username), + passwordHash: await hashPassword(passwordSchema.parse(password)), + }; +} diff --git a/src/lib/session-token.test.ts b/src/lib/session-token.test.ts new file mode 100644 index 0000000..6b813b6 --- /dev/null +++ b/src/lib/session-token.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { AdminFile } from "./admin-file"; +import { + issueToken, + readToken, + REFRESH_AFTER_MS, + refreshToken, + SESSION_TTL_MS, +} from "./session-token"; + +const hash = (salt: string) => `scrypt$65536$8$1$${salt.repeat(24)}$${"K".repeat(88)}`; +const admin: AdminFile = { version: 1, username: "admin", passwordHash: hash("A") }; +const NOW = Date.UTC(2026, 8, 19, 12, 0, 0); +const HOUR = 60 * 60 * 1000; + +describe("session tokens", () => { + it("round-trips a session that expires 12 hours after issue", () => { + const token = issueToken(admin, NOW); + expect(token).toMatch(/^[A-Za-z0-9_-]+\.\d+\.[A-Za-z0-9_-]{43}$/); + expect(readToken(token, admin, NOW)).toEqual({ username: "admin", expiresAt: NOW + 12 * HOUR }); + expect(SESSION_TTL_MS).toBe(12 * HOUR); + }); + + it("carries unusual usernames safely", () => { + const odd = { ...admin, username: "me@example.com" }; + expect(readToken(issueToken(odd, NOW), odd, NOW)?.username).toBe("me@example.com"); + }); + + it("expires at the 12-hour mark", () => { + const token = issueToken(admin, NOW); + expect(readToken(token, admin, NOW + 12 * HOUR - 1)).not.toBeNull(); + expect(readToken(token, admin, NOW + 12 * HOUR)).toBeNull(); + }); + + it("is invalidated by a password change, even for the same username", () => { + const token = issueToken(admin, NOW); + expect(readToken(token, { ...admin, passwordHash: hash("B") }, NOW)).toBeNull(); + }); + + it("rejects a token for a different username", () => { + const other = { ...admin, username: "someone" }; + expect(readToken(issueToken(admin, NOW), other, NOW)).toBeNull(); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["garbage", "not-a-token"], + ["too few parts", "YWRtaW4.123"], + ["a non-numeric expiry", `YWRtaW4.soon.${"a".repeat(43)}`], + ["a short signature", "YWRtaW4.123.abc"], + ])("rejects a %s token", (_label, token) => { + expect(readToken(token, admin, NOW)).toBeNull(); + }); + + it("rejects any token when there is no admin", () => { + expect(readToken(issueToken(admin, NOW), null, NOW)).toBeNull(); + }); + + describe("tampering", () => { + const token = issueToken(admin, NOW); + const [user, expires, signature] = token.split("."); + + it("rejects an extended expiry", () => { + expect(readToken(`${user}.${Number(expires) + HOUR}.${signature}`, admin, NOW)).toBeNull(); + }); + + it("rejects a swapped username", () => { + const intruder = Buffer.from("intruder").toString("base64url"); + expect(readToken(`${intruder}.${expires}.${signature}`, admin, NOW)).toBeNull(); + }); + + it("rejects a modified signature", () => { + const flipped = (signature[0] === "A" ? "B" : "A") + signature.slice(1); + expect(readToken(`${user}.${expires}.${flipped}`, admin, NOW)).toBeNull(); + }); + }); + + describe("sliding window", () => { + it("doesn't re-issue within 5 minutes of the last issue", () => { + const session = readToken(issueToken(admin, NOW), admin, NOW)!; + expect(refreshToken(session, admin, NOW + REFRESH_AFTER_MS - 1)).toBeNull(); + expect(REFRESH_AFTER_MS).toBe(5 * 60 * 1000); + }); + + it("re-issues after 5 minutes with a fresh 12-hour expiry", () => { + const session = readToken(issueToken(admin, NOW), admin, NOW)!; + const later = NOW + 3 * HOUR; + const fresh = refreshToken(session, admin, later)!; + expect(readToken(fresh, admin, later)?.expiresAt).toBe(later + 12 * HOUR); + }); + + it("keeps an active user signed in well past 12 hours from login", () => { + let token = issueToken(admin, NOW); + for (let t = NOW + HOUR; t <= NOW + 30 * HOUR; t += HOUR) { + const session = readToken(token, admin, t); + expect(session).not.toBeNull(); + token = refreshToken(session!, admin, t) ?? token; + } + }); + + it("lets an idle session lapse after 12 hours", () => { + let token = issueToken(admin, NOW); + token = refreshToken(readToken(token, admin, NOW + HOUR)!, admin, NOW + HOUR)!; + expect(readToken(token, admin, NOW + 13 * HOUR - 1)).not.toBeNull(); + expect(readToken(token, admin, NOW + 13 * HOUR)).toBeNull(); + }); + }); +}); diff --git a/src/lib/session-token.ts b/src/lib/session-token.ts new file mode 100644 index 0000000..e68a370 --- /dev/null +++ b/src/lib/session-token.ts @@ -0,0 +1,75 @@ +import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto"; +import type { AdminFile } from "./admin-file"; + +/** + * Stateless admin session tokens: `..`. + * + * The HMAC key is derived from the admin's stored password hash, so changing the password + * invalidates every token at once (vrek dec-f0xar8r). Pure functions of (token, admin, now) + * so they can be used from proxy.ts, Server Actions and route handlers alike. + */ + +/** Sessions end 12 hours after the last activity. */ +export const SESSION_TTL_MS = 12 * 60 * 60 * 1000; +/** A token is re-issued at most this often, so polling doesn't rewrite the cookie per request. */ +export const REFRESH_AFTER_MS = 5 * 60 * 1000; + +export interface Session { + username: string; + expiresAt: number; +} + +function sessionKey(admin: AdminFile): Buffer { + return Buffer.from( + hkdfSync("sha256", admin.passwordHash, "cameras-admin-session", "session-hmac-v1", 32), + ); +} + +function sign(payload: string, key: Buffer): string { + return createHmac("sha256", key).update(payload).digest("base64url"); +} + +/** A token for the admin, valid for SESSION_TTL_MS from `now`. */ +export function issueToken(admin: AdminFile, now: number = Date.now()): string { + const payload = `${Buffer.from(admin.username).toString("base64url")}.${now + SESSION_TTL_MS}`; + return `${payload}.${sign(payload, sessionKey(admin))}`; +} + +const TOKEN_PATTERN = /^([A-Za-z0-9_-]+)\.(\d{1,15})\.([A-Za-z0-9_-]{43})$/; + +/** + * The session a token proves, or null if it is malformed, forged, signed with an old + * password, for a different username, or expired. + */ +export function readToken( + token: string | undefined, + admin: AdminFile | null, + now: number = Date.now(), +): Session | null { + if (!token || !admin) return null; + const m = TOKEN_PATTERN.exec(token); + if (!m) return null; + const [, user64, expires, signature] = m; + + const expected = Buffer.from(sign(`${user64}.${expires}`, sessionKey(admin))); + const given = Buffer.from(signature); + if (!timingSafeEqual(expected, given)) return null; + + const username = Buffer.from(user64, "base64url").toString(); + const expiresAt = Number(expires); + if (username !== admin.username || expiresAt <= now) return null; + return { username, expiresAt }; +} + +/** + * A fresh token if this session has been in use for REFRESH_AFTER_MS since it was last + * issued (sliding the 12-hour window), otherwise null. + */ +export function refreshToken( + session: Session, + admin: AdminFile, + now: number = Date.now(), +): string | null { + const issuedAt = session.expiresAt - SESSION_TTL_MS; + return now - issuedAt >= REFRESH_AFTER_MS ? issueToken(admin, now) : null; +} diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..ad9c804 --- /dev/null +++ b/src/lib/session.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdminFile } from "./admin-file"; +import { issueToken, REFRESH_AFTER_MS } from "./session-token"; + +/** An in-memory stand-in for Next's cookies() / headers() request store. */ +const jar = vi.hoisted(() => ({ + values: new Map(), + options: new Map>(), + headers: new Map(), +})); +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: (name: string) => (jar.values.has(name) ? { name, value: jar.values.get(name) } : undefined), + set: (name: string, value: string, options: Record) => { + jar.values.set(name, value); + jar.options.set(name, options); + }, + delete: (name: string) => { + jar.values.delete(name); + }, + }), + headers: async () => ({ get: (name: string) => jar.headers.get(name) ?? null }), +})); + +const getAdmin = vi.fn<() => Promise>(); +vi.mock("./admin-auth", () => ({ getAdmin })); + +const session = await import("./session"); +const { SESSION_COOKIE } = session; + +const admin: AdminFile = { + version: 1, + username: "admin", + passwordHash: `scrypt$65536$8$1$${"A".repeat(24)}$${"K".repeat(88)}`, +}; + +beforeEach(() => { + jar.values.clear(); + jar.options.clear(); + jar.headers.clear(); + getAdmin.mockReset().mockResolvedValue(admin); + vi.useRealTimers(); +}); + +describe("session", () => { + it("createSession sets an HttpOnly, SameSite=Lax cookie that verifySession accepts", async () => { + await session.createSession(admin); + + expect(jar.values.get(SESSION_COOKIE)).toBeTruthy(); + const options = jar.options.get(SESSION_COOKIE)!; + expect(options).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/", secure: false }); + const expires = (options.expires as Date).getTime(); + expect(expires).toBeGreaterThan(Date.now() + 11.9 * 3600_000); + expect(expires).toBeLessThanOrEqual(Date.now() + 12 * 3600_000); + + expect(await session.verifySession()).toMatchObject({ username: "admin" }); + expect(await session.authState()).toBe("signed-in"); + }); + + it("marks the cookie Secure behind HTTPS", async () => { + jar.headers.set("x-forwarded-proto", "https"); + await session.createSession(admin); + expect(jar.options.get(SESSION_COOKIE)).toMatchObject({ secure: true }); + }); + + it("is signed out without a cookie, or with a forged one", async () => { + expect(await session.verifySession()).toBeNull(); + expect(await session.authState()).toBe("signed-out"); + + jar.values.set(SESSION_COOKIE, "YWRtaW4.99999999999999.forgedforgedforgedforgedforgedforgedforgedf"); + expect(await session.verifySession()).toBeNull(); + expect(await session.authState()).toBe("signed-out"); + }); + + it("reports no-admin, and accepts no session, when the admin file is gone", async () => { + await session.createSession(admin); + getAdmin.mockResolvedValue(null); + expect(await session.authState()).toBe("no-admin"); + expect(await session.verifySession()).toBeNull(); + }); + + it("ends every session when the password changes", async () => { + await session.createSession(admin); + getAdmin.mockResolvedValue({ ...admin, passwordHash: admin.passwordHash.replace("KKKK", "LLLL") }); + expect(await session.verifySession()).toBeNull(); + }); + + it("deleteSession signs this browser out", async () => { + await session.createSession(admin); + await session.deleteSession(); + expect(jar.values.has(SESSION_COOKIE)).toBe(false); + expect(await session.verifySession()).toBeNull(); + }); + + describe("touchSession", () => { + it("returns null and sets nothing when signed out", async () => { + expect(await session.touchSession()).toBeNull(); + expect(jar.values.size).toBe(0); + }); + + it("returns null when there's no admin", async () => { + jar.values.set(SESSION_COOKIE, issueToken(admin)); + getAdmin.mockResolvedValue(null); + expect(await session.touchSession()).toBeNull(); + }); + + it("leaves a recently issued cookie alone", async () => { + const token = issueToken(admin); + jar.values.set(SESSION_COOKIE, token); + expect(await session.touchSession()).toMatchObject({ username: "admin" }); + expect(jar.values.get(SESSION_COOKIE)).toBe(token); + expect(jar.options.size).toBe(0); + }); + + it("slides an older cookie to a fresh 12-hour expiry", async () => { + const issuedAt = Date.now() - REFRESH_AFTER_MS - 1000; + const token = issueToken(admin, issuedAt); + jar.values.set(SESSION_COOKIE, token); + + await session.touchSession(); + const fresh = jar.values.get(SESSION_COOKIE)!; + expect(fresh).not.toBe(token); + expect(await session.verifySession()).toMatchObject({ username: "admin" }); + const expires = (jar.options.get(SESSION_COOKIE)!.expires as Date).getTime(); + expect(expires).toBeGreaterThan(issuedAt + 12 * 3600_000); + }); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..2da82a9 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,76 @@ +import "server-only"; +import { cookies, headers } from "next/headers"; +import { getAdmin } from "./admin-auth"; +import type { AdminFile } from "./admin-file"; +import { issueToken, readToken, refreshToken, type Session } from "./session-token"; + +/** + * The admin session as seen by the app, stored in an HttpOnly cookie. verifySession() is + * the authoritative check for pages and routes (Next 16 authentication guide, "Creating a + * Data Access Layer"); proxy.ts only does optimistic redirects and slides the window. + */ + +export const SESSION_COOKIE = "cameras_session"; + +export type AuthState = "no-admin" | "signed-out" | "signed-in"; + +/** Secure only over HTTPS: the app usually runs on plain HTTP on the LAN. */ +export function sessionCookieOptions(expiresAt: number, secure: boolean) { + return { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure, + expires: new Date(expiresAt), + }; +} + +async function requestIsHttps(): Promise { + return (await headers()).get("x-forwarded-proto") === "https"; +} + +async function setToken(token: string, admin: AdminFile) { + const session = readToken(token, admin)!; + (await cookies()).set( + SESSION_COOKIE, + token, + sessionCookieOptions(session.expiresAt, await requestIsHttps()), + ); +} + +/** The signed-in admin, or null. Reads only, so it is safe during Server Component render. */ +export async function verifySession(): Promise { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + return readToken(token, await getAdmin()); +} + +export async function authState(): Promise { + const admin = await getAdmin(); + if (!admin) return "no-admin"; + const token = (await cookies()).get(SESSION_COOKIE)?.value; + return readToken(token, admin) ? "signed-in" : "signed-out"; +} + +/** Starts a session for the admin. Call only after checking the login (Server Actions). */ +export async function createSession(admin: AdminFile): Promise { + await setToken(issueToken(admin), admin); +} + +/** + * Slides the 12-hour window when due. For Server Actions and route handlers; returns the + * session, or null if not signed in. + */ +export async function touchSession(): Promise { + const admin = await getAdmin(); + const token = (await cookies()).get(SESSION_COOKIE)?.value; + const session = readToken(token, admin); + if (!session || !admin) return null; + const fresh = refreshToken(session, admin); + if (fresh) await setToken(fresh, admin); + return session; +} + +/** Signs this browser out. Other sessions end only when the password changes. */ +export async function deleteSession(): Promise { + (await cookies()).delete(SESSION_COOKIE); +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..f88b45b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", diff --git a/vitest.config.mts b/vitest.config.mts index 23f2eee..da19e31 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -15,7 +15,7 @@ export default defineConfig({ test: { // Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`. environment: "node", - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"], restoreMocks: true, unstubEnvs: true, coverage: {