Compare commits

..

2 Commits

Author SHA1 Message Date
5d181f3b3b Enforce admin login: login, first-run setup, proxy and access checks
Completes securing the web front end (vrek gol-wqf95dq, iss-r5vrjx7).

- /login: Server Action with a generic error, same-site-only redirect
  back to ?next=, and throttling of failed logins (10 per address and
  100 overall per 15 min). The header shows "Signed in as" with
  Sign out (iss-nj9wmwp).
- First run with no admin: instrumentation prints a one-time setup
  code, shared with the app through globalThis. /setup requires it,
  and 5 wrong codes rotate it. "Skip for now" runs unsecured for the
  browser session behind a red warning banner on every page
  (iss-9nxdndr).
- src/proxy.ts: optimistic redirects to /login or /setup, 401 for
  the API, and the 12 h sliding session refresh. requirePageAccess()
  and apiAccessDenied() re-check in the page and all 6 route handlers
  (iss-76d5wrb).
- An expired session now shows "Your session has ended" instead of
  the camera-login form.
- README documents in-app setup, skipping and signing in.

Verified with unit tests (383, 99.9% line coverage), end to end
against `next start`, and manually in a browser by the user.
Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 11:58:20 -05:00
462141aa35 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) <noreply@anthropic.com>
2026-09-19 09:46:40 -05:00
52 changed files with 2837 additions and 9 deletions

View File

@ -262,3 +262,101 @@
{"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-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-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-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 `<username b64url>.<expiresAt>.<HMAC-SHA256>`; 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"}
{"id":"evt-40tzp9w71pwf","type":"node.created","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"/login (a Server Action through useActionState) signs in with a generic error on failure, redirects only to same-site paths, and throttles failures (10 per client, 100 overall, per 15 min, keyed on x-forwarded-for, with a client-count cap); the sign-out form in the header clears the session; /login redirects to /setup with no admin and onward when already signed in.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:01.115Z","parents":["evt-gxsrqxkcrrtn"],"hash":"b58d0c7f31d5318cf00e87ed22960fbeaba9bb756e3724ee60cbcce63a1d3af9"}
{"id":"evt-pghp99arbst3","type":"edge.added","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-pttw4js","to":"iss-nj9wmwp"},"at":"2026-09-19T14:54:01.121Z","parents":["evt-40tzp9w71pwf"],"hash":"3a3a5aa69c59bcb2ee8a2fa07b7e8e25e075f6dbd00badd169493dd7dc354fde"}
{"id":"evt-3xm5jkh26xd1","type":"verification.recorded","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/app/auth-actions.test.ts (login, logout), src/lib/login-throttle.test.ts, src/lib/auth-shared.test.ts (safeNextPath: //, /\\, absolute URL, control chars), src/app/auth-pages.test.tsx, src/app/auth-forms.test.tsx, src/app/security-bar.test.tsx. End to end against `next start` with a temp ADMIN_AUTH_FILE: /login → 307 /setup with no admin; the header shows 'Signed in as admin' with a valid session. 2026-09-19. Not exercised: submitting the login form in a real browser (a Server Action round trip); tracked as pending on the parent iss-r5vrjx7."},"at":"2026-09-19T14:54:01.122Z","parents":["evt-pghp99arbst3"],"hash":"6e0fc6b4722975520531a71446ff457770e3bbd2b1a8e77f3ebd289c27f8bc4f"}
{"id":"evt-8wfcr54fxde3","type":"node.created","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"With no admin: the setup code is printed once at startup (instrumentation) and /setup reuses it (globalThis); /setup requires the code (constant-time; rotated after 5 wrong), validates the username, password and confirmation before spending an attempt, creates the admin, signs in, and clears the code and skip cookie; skipping sets a browser-session cookie; a red banner shows on every page while no admin exists.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:05.126Z","parents":["evt-3xm5jkh26xd1"],"hash":"e56458fbd7adc9b1ee912a5dbde6eb274874cb9456b06cebfcdae2246abe6758"}
{"id":"evt-4z6vesr3k55h","type":"edge.added","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-q1ae1wj","to":"iss-9nxdndr"},"at":"2026-09-19T14:54:05.128Z","parents":["evt-8wfcr54fxde3"],"hash":"9301574565238450cc903551cce08946cb5727a0882d6ba8aafe473ede1da38a"}
{"id":"evt-2fxckhhhs7mf","type":"verification.recorded","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/lib/setup-code.test.ts, src/instrumentation.test.ts, src/app/auth-actions.test.ts (setupAdmin, skipSetup incl. an EEXIST race), src/app/auth-pages.test.tsx, src/app/security-bar.test.tsx. End to end with `next start`: exactly 1 'one-time code' block in the server log after start and still 1 after two GET /setup (so the code is shared between instrumentation and app bundles); GET / → 307 /setup; with the skip cookie → 200; the banner 'Not secured:' is rendered; with an admin, no code is printed and /setup → 307 /login. README documents the flow. 2026-09-19. Not exercised: submitting the setup and skip forms in a real browser; pending on iss-r5vrjx7."},"at":"2026-09-19T14:54:05.129Z","parents":["evt-4z6vesr3k55h"],"hash":"442901d20c4da3b276bab6966bb9f5d60ea14004e1fadb0766a7082dcb7a348a"}
{"id":"evt-arehf43fcbj4","type":"node.created","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"src/proxy.ts: with an admin, pages without a valid session → /login?next=… (query kept), /api/* → 401 JSON, and a valid session passes and slides when older than 5 min; with no admin, the API is open and pages go to /setup unless skipped. Authoritative checks: requirePageAccess() in page.tsx and apiAccessDenied() first in all 6 route handlers.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:09.877Z","parents":["evt-2fxckhhhs7mf"],"hash":"e04de035e56bb4780c54910eb8c21dabec05bbfb74162e8eab97174dea90e3ee"}
{"id":"evt-c5zqnqn59ad1","type":"edge.added","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-bj79asq","to":"iss-76d5wrb"},"at":"2026-09-19T14:54:09.879Z","parents":["evt-arehf43fcbj4"],"hash":"ff647282511532f16b85469596b5268d176e75b87be5d77a61e2b6b8b09bb28e"}
{"id":"evt-dyb79cnf4jpa","type":"verification.recorded","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/proxy.test.ts (14, real admin file, NextRequest; caught and fixed a bug where /?refresh=500 lost its query in ?next=), src/lib/access.test.ts, src/app/api/access.test.ts (each of the 6 handlers returns 401 and touches no camera or store code when signed out). End to end with `next start` + admin: GET /?refresh=500 → 307 /login?next=%2F%3Frefresh%3D500; the skip cookie doesn't bypass; API without a session → 401 {\"error\":\"Sign in required\"}; forged cookie → 401; valid session → 200 (API) and 200 (page); a 10-minute-old session gets Set-Cookie with a new token, Expires +12h, HttpOnly, SameSite=lax. Build lists 'ƒ Proxy (Middleware)'. Full suite 382/382, coverage 99.86%, tsc, eslint and build clean, 2026-09-19."},"at":"2026-09-19T14:54:09.880Z","parents":["evt-c5zqnqn59ad1"],"hash":"3a47fb0141472a72df76102f88cc73069f1d1806d5361ec681bbea98db503460"}
{"id":"evt-1frkwn4trvyt","type":"node.created","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: complete /setup with the console code, sign out, sign in (including a wrong password and a redirect back to ?next=), and skip setup to see the banner.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:13.002Z","parents":["evt-dyb79cnf4jpa"],"hash":"4b5037365460d9462c678d505c391ee270def03792dc041796d88dae113b6e6c"}
{"id":"evt-4pqk1wyfhf0s","type":"edge.added","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-qz5sev9","to":"iss-r5vrjx7"},"at":"2026-09-19T14:54:13.003Z","parents":["evt-1frkwn4trvyt"],"hash":"b387b8062288b31bc36fda4e095e8881dc90744f4ba5e5dc2e7d52263e955fa4"}
{"id":"evt-qa5z1v7chc02","type":"verification.recorded","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"Server Action round trips from real browser forms haven't been run: Claude doesn't start dev servers here, and curl can't easily drive Server Actions. Everything else is verified by unit tests and `next start` + curl (see iss-nj9wmwp, iss-9nxdndr, iss-76d5wrb). Needs a manual check by the user."},"at":"2026-09-19T14:54:13.004Z","parents":["evt-4pqk1wyfhf0s"],"hash":"f31c050167824784bbd13d5b0a35e015afa8194011d1693aafb9da418f30bf9a"}
{"id":"evt-c410xxjgthrh","type":"node.status_changed","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:14.116Z","parents":["evt-qa5z1v7chc02"],"hash":"370db1ceeadad76ad355d77dd2a0168ce3170a91f9dae928b19c2e2e7127a947"}
{"id":"evt-0zfw9s1039e8","type":"node.status_changed","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:15.515Z","parents":["evt-c410xxjgthrh"],"hash":"a26951573416be01590b0486119cde90a7c99b615690167c4f41a71e03f927f3"}
{"id":"evt-7jmwek1hzwy0","type":"node.status_changed","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:17.226Z","parents":["evt-0zfw9s1039e8"],"hash":"782451c37447ce0594da68efb324db8cf3925a1fbd9793fb8b366fe9bbfb0edd"}
{"id":"evt-5hdbhp5bn7t0","type":"node.created","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"When the app session ends, dashboard API calls (401 without a camera error code) show 'Your session has ended. Sign in again', not the camera-login form, and aren't retried.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:54.309Z","parents":["evt-7jmwek1hzwy0"],"hash":"7054ae0335f04c4300edcc3a059818725902e8fd93267f4bb7cc527e4ecc0aed"}
{"id":"evt-xqg7nwtyhdf3","type":"edge.added","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-xz17rpj","to":"iss-76d5wrb"},"at":"2026-09-19T14:54:54.311Z","parents":["evt-5hdbhp5bn7t0"],"hash":"2a1cec7150beff04c58cca6c76dd65701d390092e00f303e64f44a2e5a16d24b"}
{"id":"evt-d7j19r27at24","type":"verification.recorded","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Found after enforcement: camera-queries.ts mapped every 401 to the camera 'auth' problem, so an expired session would have shown the camera-login form. Fixed: code 'auth' means a camera login problem, and any other 401 means the new 'signed-out' kind, shown on camera-card.tsx with a /login link. Test: camera-card.test.tsx 'tells the user to sign in again…' (1 info request, no camera-login form). Suite 383/383, coverage 99.86%, tsc and eslint clean, 2026-09-19."},"at":"2026-09-19T14:54:54.312Z","parents":["evt-xqg7nwtyhdf3"],"hash":"cd46e3dfd1afd7710b28def1c9fa26177050936bad5e9e99956719907245c4fe"}
{"id":"evt-z4k3aqs5whz1","type":"node.created","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: complete /setup with the console code, sign out, sign in (including a wrong password and a redirect back to ?next=), and skip setup to see the banner.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T16:56:11.239Z","parents":["evt-d7j19r27at24"],"hash":"c2271075a643fce52efa076efef36cdf58601239e3b54c1f6ec588139c582927"}
{"id":"evt-3j6gy3qctp2f","type":"edge.added","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-86zabcp","to":"iss-r5vrjx7"},"at":"2026-09-19T16:56:11.242Z","parents":["evt-z4k3aqs5whz1"],"hash":"c292ee79097215281246b9eb8f6e111ffdfadb0ed2e30764244f9ba1da799923"}
{"id":"evt-qv7r0x32bhrq","type":"verification.recorded","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19, who went through the full flow and reported that everything works correctly. This resolves the earlier pending check ver-qz5sev9."},"at":"2026-09-19T16:56:11.243Z","parents":["evt-3j6gy3qctp2f"],"hash":"bcceb6a19d3adc70e59f72bb44ca0ee76d8f0d79f197018ce0f53ed841c5a122"}
{"id":"evt-h5c9jc9hak9q","type":"node.status_changed","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T16:56:12.460Z","parents":["evt-qv7r0x32bhrq"],"hash":"8c7c6c6e0a55282116ad4421d2f7491da248227185abea89a815e4c147cbfea9"}

102
README.md
View File

@ -1,3 +1,105 @@
# 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 (below).
### Setting it up from the browser instead
With no admin file, the server prints a **one-time setup code** in its console at startup:
```
====================================================================
No admin login is set up: the camera dashboard is NOT secured.
To secure it from a browser, open /setup and enter this one-time code:
XLZ7-Q4MB
...
```
Opening the app sends you to `/setup`, which asks for that code plus the new admin username
and password. Only someone who can see the server console can claim the login. After 5 wrong
codes a new one is printed.
You can also choose **Skip for now and run unsecured**. Every page then shows a red warning
banner, the API stays open to anyone on the network, and the setup prompt returns the next
time the browser is restarted.
### Signing in
Once an admin exists, every page and API call needs a session: pages redirect to `/login`,
and the API answers `401`. A session lasts **12 hours from your last activity**. After 10
wrong passwords from one address (or 100 from all addresses) within 15 minutes, logins are
paused for up to 15 minutes. **Sign out** (top right) ends the session in that browser; to
sign out everywhere, change the password.
### 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$<salt, base64>$<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). 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 ## Getting Started

View File

@ -9,7 +9,8 @@
"lint": "eslint", "lint": "eslint",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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": { "dependencies": {
"@tanstack/react-query": "^5.103.1", "@tanstack/react-query": "^5.103.1",

96
scripts/create-admin.mts Normal file
View File

@ -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<string> {
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();

View File

@ -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");
});
});

View File

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Every API handler must refuse a signed-out request before doing anything else
* (vrek iss-76d5wrb). The camera and store modules are replaced with spies that must never
* be reached.
*/
const denied = vi.fn<() => Promise<Response | null>>();
vi.mock("@/lib/access", () => ({ apiAccessDenied: denied }));
const touched = vi.fn();
const trap = () => touched();
vi.mock("@/lib/camera", () => ({
getCameraInfo: trap,
getSnapshot: trap,
testCredentials: trap,
resetConnection: trap,
}));
vi.mock("@/lib/camera-route", () => ({ cameraTarget: trap, cameraErrorResponse: trap }));
vi.mock("@/lib/credential-store", () => ({
credentialsSchema: { safeParse: trap },
setCredentials: trap,
deleteCredentials: trap,
describeCredentials: trap,
}));
vi.mock("@/lib/onvif", () => ({ discoverCameras: trap, discoverRequestSchema: { safeParse: trap } }));
vi.mock("@/lib/camera-registry", () => ({ recordDiscovered: trap }));
const info = await import("./cameras/[id]/info/route");
const snapshot = await import("./cameras/[id]/snapshot/route");
const credentials = await import("./cameras/[id]/credentials/route");
const discover = await import("./discover/route");
const ctx = { params: Promise.resolve({ id: "11111111-2222-3333-4444-555555555555" }) };
const req = (method = "GET") =>
new Request("http://localhost/api/x", { method, body: method === "GET" ? undefined : "{}" });
const handlers: [string, () => Promise<Response>][] = [
["GET /api/cameras/[id]/info", () => info.GET(req(), ctx)],
["GET /api/cameras/[id]/snapshot", () => snapshot.GET(req(), ctx)],
["GET /api/cameras/[id]/credentials", () => credentials.GET(req(), ctx)],
["PUT /api/cameras/[id]/credentials", () => credentials.PUT(req("PUT"), ctx)],
["DELETE /api/cameras/[id]/credentials", () => credentials.DELETE(req("DELETE"), ctx)],
["POST /api/discover", () => discover.POST(req("POST"))],
];
describe("API access control", () => {
beforeEach(() => {
touched.mockReset();
denied.mockReset().mockResolvedValue(Response.json({ error: "Sign in required" }, { status: 401 }));
});
it.each(handlers)("%s returns 401 and touches nothing when signed out", async (_name, call) => {
const res = await call();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Sign in required" });
expect(touched).not.toHaveBeenCalled();
});
});

View File

@ -10,6 +10,8 @@ const store = {
describeCredentials: vi.fn(), describeCredentials: vi.fn(),
}; };
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
vi.mock("@/lib/camera-registry", async (importOriginal) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,3 +1,4 @@
import { apiAccessDenied } from "@/lib/access";
import { resetConnection, testCredentials } from "@/lib/camera"; import { resetConnection, testCredentials } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
import { import {
@ -10,6 +11,8 @@ import {
// Passwords are write-only: GET reports whether one is set, never its value. // Passwords are write-only: GET reports whether one is set, never its value.
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) { export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
try { try {
@ -20,6 +23,8 @@ export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id
} }
export async function PUT(request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) { export async function PUT(request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
@ -44,6 +49,8 @@ export async function DELETE(
_request: Request, _request: Request,
ctx: RouteContext<"/api/cameras/[id]/credentials">, ctx: RouteContext<"/api/cameras/[id]/credentials">,
) { ) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
try { try {

View File

@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
const getCameraRecord = vi.fn(); const getCameraRecord = vi.fn();
const getCameraInfo = vi.fn(); const getCameraInfo = vi.fn();
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
vi.mock("@/lib/camera-registry", async (importOriginal) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { getCameraInfo } from "@/lib/camera"; import { getCameraInfo } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) { export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;

View File

@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
const getCameraRecord = vi.fn(); const getCameraRecord = vi.fn();
const getSnapshot = vi.fn(); const getSnapshot = vi.fn();
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
vi.mock("@/lib/camera-registry", async (importOriginal) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { getSnapshot } from "@/lib/camera"; import { getSnapshot } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) { export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
const profile = new URL(request.url).searchParams.get("profile") ?? undefined; const profile = new URL(request.url).searchParams.get("profile") ?? undefined;

View File

@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const discoverCameras = vi.fn(); const discoverCameras = vi.fn();
const recordDiscovered = vi.fn(); const recordDiscovered = vi.fn();
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
vi.mock("@/lib/onvif", async (importOriginal) => ({ vi.mock("@/lib/onvif", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/onvif")>()), ...(await importOriginal<typeof import("@/lib/onvif")>()),
discoverCameras, discoverCameras,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { recordDiscovered } from "@/lib/camera-registry"; import { recordDiscovered } from "@/lib/camera-registry";
import { discoverCameras, discoverRequestSchema } from "@/lib/onvif"; import { discoverCameras, discoverRequestSchema } from "@/lib/onvif";
export async function POST(request: Request) { export async function POST(request: Request) {
const denied = await apiAccessDenied();
if (denied) return denied;
// An empty body means "use the defaults". // An empty body means "use the defaults".
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const parsed = discoverRequestSchema.safeParse(body ?? {}); const parsed = discoverRequestSchema.safeParse(body ?? {});

View File

@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AdminFile } from "@/lib/admin-file";
const admin: AdminFile = { version: 1, username: "admin", passwordHash: "scrypt$hash" };
const mocks = vi.hoisted(() => ({
checkLogin: vi.fn(),
createAdmin: vi.fn(),
getAdmin: vi.fn(),
hasAdmin: vi.fn(),
createSession: vi.fn(),
deleteSession: vi.fn(),
checkSetupCode: vi.fn(),
clearSetupCode: vi.fn(),
cookieSet: vi.fn(),
cookieDelete: vi.fn(),
headers: new Map<string, string>(),
}));
vi.mock("@/lib/admin-auth", () => ({
checkLogin: mocks.checkLogin,
createAdmin: mocks.createAdmin,
getAdmin: mocks.getAdmin,
hasAdmin: mocks.hasAdmin,
}));
vi.mock("@/lib/session", () => ({
createSession: mocks.createSession,
deleteSession: mocks.deleteSession,
}));
vi.mock("@/lib/setup-code", () => ({
checkSetupCode: mocks.checkSetupCode,
clearSetupCode: mocks.clearSetupCode,
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ set: mocks.cookieSet, delete: mocks.cookieDelete }),
headers: async () => ({ get: (n: string) => mocks.headers.get(n) ?? null }),
}));
class Redirect extends Error {}
vi.mock("next/navigation", () => ({
redirect: (to: string) => {
throw new Redirect(to);
},
}));
let actions: typeof import("./auth-actions");
beforeEach(async () => {
for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset();
mocks.headers.clear();
mocks.headers.set("x-forwarded-for", "192.168.1.50");
mocks.getAdmin.mockResolvedValue(admin);
mocks.hasAdmin.mockResolvedValue(false);
mocks.createAdmin.mockResolvedValue(admin);
// A fresh process-wide throttle for each test.
delete (globalThis as Record<symbol, unknown>)[Symbol.for("cameras.loginThrottle")];
vi.resetModules();
actions = await import("./auth-actions");
});
const form = (fields: Record<string, string>) => {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.set(k, v);
return f;
};
/** Runs an action, returning its state or the path it redirected to. */
async function run<T>(p: Promise<T>): Promise<T | { redirect: string }> {
try {
return await p;
} catch (err) {
if (err instanceof Redirect) return { redirect: err.message };
throw err;
}
}
const PASSWORD = "correct horse battery";
describe("login", () => {
it("signs in and redirects to the requested page", async () => {
mocks.checkLogin.mockResolvedValue(true);
const result = await run(
actions.login({}, form({ username: "admin", password: PASSWORD, next: "/?refresh=500" })),
);
expect(result).toEqual({ redirect: "/?refresh=500" });
expect(mocks.checkLogin).toHaveBeenCalledWith("admin", PASSWORD);
expect(mocks.createSession).toHaveBeenCalledWith(admin);
});
it("never redirects off-site", async () => {
mocks.checkLogin.mockResolvedValue(true);
const result = await run(
actions.login({}, form({ username: "admin", password: PASSWORD, next: "//evil.example" })),
);
expect(result).toEqual({ redirect: "/" });
});
it("gives one generic error for a wrong username or password", async () => {
mocks.checkLogin.mockResolvedValue(false);
expect(await actions.login({}, form({ username: "admin", password: "nope" }))).toEqual({
error: "Wrong username or password.",
});
expect(mocks.createSession).not.toHaveBeenCalled();
});
it.each([
["missing fields", {}],
["an empty password", { username: "admin", password: "" }],
["an over-long username", { username: "a".repeat(65), password: "x" }],
])("asks for both fields on %s, without checking the password", async (_l, fields) => {
expect(await actions.login({}, form(fields))).toEqual({
error: "Enter your username and password.",
});
expect(mocks.checkLogin).not.toHaveBeenCalled();
});
it("locks a client out after 10 failures, without even checking the password", async () => {
mocks.checkLogin.mockResolvedValue(false);
for (let i = 0; i < 10; i++) await actions.login({}, form({ username: "admin", password: "x" }));
mocks.checkLogin.mockClear().mockResolvedValue(true);
const locked = await actions.login({}, form({ username: "admin", password: PASSWORD }));
expect(locked).toEqual({ error: "Too many failed attempts. Try again in 15 minutes." });
expect(mocks.checkLogin).not.toHaveBeenCalled();
// Another address on the LAN isn't affected.
mocks.headers.set("x-forwarded-for", "192.168.1.51");
expect(await run(actions.login({}, form({ username: "admin", password: PASSWORD })))).toEqual({
redirect: "/",
});
});
it("says 1 minute, not 1 minutes", async () => {
vi.useFakeTimers();
mocks.checkLogin.mockResolvedValue(false);
for (let i = 0; i < 10; i++) await actions.login({}, form({ username: "admin", password: "x" }));
vi.advanceTimersByTime(14.5 * 60_000);
expect(await actions.login({}, form({ username: "admin", password: "x" }))).toEqual({
error: "Too many failed attempts. Try again in 1 minute.",
});
vi.useRealTimers();
});
it("handles the admin file disappearing mid-login", async () => {
mocks.checkLogin.mockResolvedValue(true);
mocks.getAdmin.mockResolvedValue(null);
expect(await actions.login({}, form({ username: "admin", password: PASSWORD }))).toEqual({
error: "The admin login was removed. Reload the page.",
});
});
it("throttles an unknown client address too", async () => {
mocks.headers.clear();
mocks.checkLogin.mockResolvedValue(false);
expect(await actions.login({}, form({ username: "a", password: "b" }))).toEqual({
error: "Wrong username or password.",
});
});
});
describe("logout", () => {
it("clears the session and goes to /login", async () => {
expect(await run(actions.logout())).toEqual({ redirect: "/login" });
expect(mocks.deleteSession).toHaveBeenCalled();
});
});
describe("setupAdmin", () => {
const valid = { username: "admin", password: PASSWORD, confirm: PASSWORD, code: "ABCD-EFGH" };
it("creates the admin with a valid code, signs in, and clears the code and skip cookie", async () => {
mocks.checkSetupCode.mockReturnValue(true);
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/" });
expect(mocks.checkSetupCode).toHaveBeenCalledWith("ABCD-EFGH");
expect(mocks.createAdmin).toHaveBeenCalledWith("admin", PASSWORD);
expect(mocks.clearSetupCode).toHaveBeenCalled();
expect(mocks.cookieDelete).toHaveBeenCalledWith("cameras_setup_skipped");
expect(mocks.createSession).toHaveBeenCalledWith(admin);
});
it("refuses once an admin exists", async () => {
mocks.hasAdmin.mockResolvedValue(true);
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/login" });
expect(mocks.createAdmin).not.toHaveBeenCalled();
});
it("rejects a wrong setup code", async () => {
mocks.checkSetupCode.mockReturnValue(false);
expect(await actions.setupAdmin({}, form(valid))).toEqual({
error: "That setup code isn't right. Use the code printed in the server console.",
});
expect(mocks.createAdmin).not.toHaveBeenCalled();
});
it.each([
["a bad username", { username: "has space" }, /^Username: /],
["a short password", { password: "short", confirm: "short" }, /^Password: At least 12/],
["mismatched passwords", { confirm: "something else!!" }, /don't match/],
])("rejects %s before using up a setup-code attempt", async (_l, override, message) => {
const result = await actions.setupAdmin({}, form({ ...valid, ...override }));
expect((result as { error: string }).error).toMatch(message);
expect(mocks.checkSetupCode).not.toHaveBeenCalled();
});
it("goes to /login if an admin appears between the check and the write", async () => {
mocks.checkSetupCode.mockReturnValue(true);
mocks.createAdmin.mockRejectedValue(Object.assign(new Error("exists"), { code: "EEXIST" }));
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/login" });
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("surfaces unexpected write errors", async () => {
mocks.checkSetupCode.mockReturnValue(true);
mocks.createAdmin.mockRejectedValue(new Error("EACCES"));
await expect(actions.setupAdmin({}, form(valid))).rejects.toThrow("EACCES");
});
});
describe("skipSetup", () => {
it("sets a browser-session skip cookie and goes home", async () => {
expect(await run(actions.skipSetup())).toEqual({ redirect: "/" });
expect(mocks.cookieSet).toHaveBeenCalledWith("cameras_setup_skipped", "1", {
httpOnly: true,
sameSite: "lax",
path: "/",
});
});
it("does nothing but redirect once an admin exists", async () => {
mocks.hasAdmin.mockResolvedValue(true);
expect(await run(actions.skipSetup())).toEqual({ redirect: "/" });
expect(mocks.cookieSet).not.toHaveBeenCalled();
});
});

99
src/app/auth-actions.ts Normal file
View File

@ -0,0 +1,99 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { checkLogin, createAdmin, getAdmin, hasAdmin } from "@/lib/admin-auth";
import { passwordSchema, usernameSchema } from "@/lib/admin-file";
import { safeNextPath, SETUP_SKIP_COOKIE } from "@/lib/auth-shared";
import { clientKey, loginThrottle } from "@/lib/login-throttle";
import { createSession, deleteSession } from "@/lib/session";
import { checkSetupCode, clearSetupCode } from "@/lib/setup-code";
/**
* Login, sign-out and first-run setup. Server Actions are public POST endpoints, so each
* one validates its own input (vrek pri-m1csgrm) and expected failures come back as
* values for useActionState rather than thrown errors (pri-qqrp49f).
*/
export interface FormState {
error?: string;
}
const text = (form: FormData, key: string) => {
const value = form.get(key);
return typeof value === "string" ? value : "";
};
const loginSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(256),
});
export async function login(_prev: FormState, form: FormData): Promise<FormState> {
const next = safeNextPath(text(form, "next"));
const parsed = loginSchema.safeParse({
username: text(form, "username"),
password: text(form, "password"),
});
if (!parsed.success) return { error: "Enter your username and password." };
const client = clientKey((await headers()).get("x-forwarded-for"));
const wait = loginThrottle.retryAfter(client);
if (wait > 0) {
const minutes = Math.ceil(wait / 60_000);
return { error: `Too many failed attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.` };
}
if (!(await checkLogin(parsed.data.username, parsed.data.password))) {
loginThrottle.recordFailure(client);
return { error: "Wrong username or password." };
}
loginThrottle.recordSuccess(client);
const admin = await getAdmin();
if (!admin) return { error: "The admin login was removed. Reload the page." };
await createSession(admin);
redirect(next);
}
export async function logout(): Promise<void> {
await deleteSession();
redirect("/login");
}
export async function setupAdmin(_prev: FormState, form: FormData): Promise<FormState> {
if (await hasAdmin()) redirect("/login");
const username = usernameSchema.safeParse(text(form, "username"));
if (!username.success) return { error: `Username: ${username.error.issues[0].message}.` };
const password = passwordSchema.safeParse(text(form, "password"));
if (!password.success) return { error: `Password: ${password.error.issues[0].message}.` };
if (text(form, "confirm") !== password.data) return { error: "The passwords don't match." };
if (!checkSetupCode(text(form, "code"))) {
return { error: "That setup code isn't right. Use the code printed in the server console." };
}
let admin;
try {
admin = await createAdmin(username.data, password.data);
} catch (err) {
// Someone else (or the CLI) created an admin a moment ago.
if ((err as NodeJS.ErrnoException).code === "EEXIST") redirect("/login");
throw err;
}
clearSetupCode();
(await cookies()).delete(SETUP_SKIP_COOKIE);
await createSession(admin);
redirect("/");
}
/** Proceeds without an admin. The prompt returns when the browser restarts. */
export async function skipSetup(): Promise<void> {
if (!(await hasAdmin())) {
// No expiry: a browser-session cookie.
(await cookies()).set(SETUP_SKIP_COOKIE, "1", { httpOnly: true, sameSite: "lax", path: "/" });
}
redirect("/");
}

View File

@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const login = vi.fn();
const setupAdmin = vi.fn();
vi.mock("./auth-actions", () => ({ login, setupAdmin }));
const { default: LoginForm } = await import("./login/login-form");
const { default: SetupForm } = await import("./setup/setup-form");
afterEach(cleanup);
beforeEach(() => {
login.mockReset();
setupAdmin.mockReset();
});
const submitted = (fn: typeof login) => Object.fromEntries((fn.mock.calls[0][1] as FormData).entries());
describe("LoginForm", () => {
it("submits the username, password and destination, then shows the returned error", async () => {
login.mockResolvedValue({ error: "Wrong username or password." });
render(<LoginForm next="/?refresh=500" />);
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "admin" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter22hunter22" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect((await screen.findByRole("alert")).textContent).toBe("Wrong username or password.");
expect(submitted(login)).toEqual({
next: "/?refresh=500",
username: "admin",
password: "hunter22hunter22",
});
});
it("disables the button while signing in", async () => {
let finish!: (s: object) => void;
login.mockReturnValue(new Promise((r) => (finish = r)));
render(<LoginForm next="/" />);
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "a" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "b" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "Signing in…" });
expect(button.disabled).toBe(true);
finish({});
await screen.findByRole("button", { name: "Sign in" });
});
});
describe("SetupForm", () => {
it("submits the code, username and both passwords, then shows the returned error", async () => {
setupAdmin.mockResolvedValue({ error: "The passwords don't match." });
render(<SetupForm />);
fireEvent.change(screen.getByLabelText("Setup code (from the server console)"), { target: { value: "abcd-efgh" } });
fireEvent.change(screen.getByLabelText("Password (at least 12 characters)"), { target: { value: "first password!" } });
fireEvent.change(screen.getByLabelText("Repeat password"), { target: { value: "second password" } });
fireEvent.click(screen.getByRole("button", { name: "Create admin and sign in" }));
expect((await screen.findByRole("alert")).textContent).toBe("The passwords don't match.");
expect(submitted(setupAdmin)).toEqual({
code: "abcd-efgh",
username: "admin",
password: "first password!",
confirm: "second password",
});
});
it("shows progress while creating the admin", async () => {
let finish!: (s: object) => void;
setupAdmin.mockReturnValue(new Promise((r) => (finish = r)));
render(<SetupForm />);
for (const [label, value] of [
["Setup code (from the server console)", "ABCD-EFGH"],
["Password (at least 12 characters)", "correct horse battery"],
["Repeat password", "correct horse battery"],
]) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
fireEvent.click(screen.getByRole("button", { name: "Create admin and sign in" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Securing…" })).toBeTruthy());
finish({});
});
});

View File

@ -0,0 +1,89 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const authState = vi.fn();
const hasAdmin = vi.fn();
const ensureSetupCode = vi.fn();
vi.mock("@/lib/session", () => ({ authState }));
vi.mock("@/lib/admin-auth", () => ({ hasAdmin }));
vi.mock("@/lib/setup-code", () => ({ ensureSetupCode }));
vi.mock("./auth-actions", () => ({
login: async () => ({}),
setupAdmin: async () => ({}),
skipSetup: async () => {},
}));
class Redirect extends Error {}
vi.mock("next/navigation", () => ({
redirect: (to: string) => {
throw new Redirect(to);
},
}));
const { default: LoginPage } = await import("./login/page");
const { default: SetupPage } = await import("./setup/page");
const redirectOf = (p: Promise<unknown>) =>
p.then(
() => null,
(e) => (e instanceof Redirect ? e.message : Promise.reject(e)),
);
const loginPage = (next?: string) =>
LoginPage({ params: Promise.resolve({}), searchParams: Promise.resolve(next ? { next } : {}) });
beforeEach(() => {
authState.mockReset();
hasAdmin.mockReset();
ensureSetupCode.mockReset();
});
describe("/login", () => {
it("renders the form, carrying a safe destination", async () => {
authState.mockResolvedValue("signed-out");
const html = renderToStaticMarkup(await loginPage("/?refresh=500"));
expect(html).toContain("Sign in");
expect(html).toContain('name="next" value="/?refresh=500"');
expect(html).toContain("npm run admin:create -- --force");
});
it("drops an off-site destination", async () => {
authState.mockResolvedValue("signed-out");
const html = renderToStaticMarkup(await loginPage("https://evil.example"));
expect(html).toContain('name="next" value="/"');
});
it("sends an already signed-in admin on to their destination", async () => {
authState.mockResolvedValue("signed-in");
expect(await redirectOf(loginPage("/?refresh=250"))).toBe("/?refresh=250");
});
it("sends visitors to /setup when there's no admin yet", async () => {
authState.mockResolvedValue("no-admin");
expect(await redirectOf(loginPage())).toBe("/setup");
});
});
describe("/setup", () => {
it("makes sure a setup code has been printed, and explains where to find it", async () => {
hasAdmin.mockResolvedValue(false);
const html = renderToStaticMarkup(await SetupPage());
expect(ensureSetupCode).toHaveBeenCalled();
expect(html).toContain("Secure the camera dashboard");
expect(html).toContain("printed in the server&#x27;s console");
expect(html).toContain("npm run admin:create");
expect(html).toContain('name="code"');
});
it("shows the severe warning next to the skip option", async () => {
hasAdmin.mockResolvedValue(false);
const html = renderToStaticMarkup(await SetupPage());
expect(html).toContain("Skip at your own risk");
expect(html).toContain("anyone on your network");
expect(html).toContain("Skip for now and run unsecured");
});
it("sends visitors to /login once an admin exists", async () => {
hasAdmin.mockResolvedValue(true);
expect(await redirectOf(SetupPage())).toBe("/login");
expect(ensureSetupCode).not.toHaveBeenCalled();
});
});

View File

@ -128,6 +128,19 @@ describe("CameraCard", () => {
expect(screen.queryByText(/Setup required/)).toBeNull(); expect(screen.queryByText(/Setup required/)).toBeNull();
}); });
it("tells the user to sign in again when the app session ends, not to fix the camera login", async () => {
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json({ error: "Sign in required" }, 401),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(await screen.findByText("Your session has ended.")).toBeTruthy();
expect(screen.getByRole("link", { name: "Sign in again" }).getAttribute("href")).toBe("/login");
expect(screen.queryByText("Camera login")).toBeNull();
// Not retried: a session problem needs the user.
expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(1);
});
describe("login", () => { describe("login", () => {
it("asks for a login when the camera rejects it, then recovers after saving", async () => { it("asks for a login when the camera rejects it, then recovers after saving", async () => {
let loggedIn = false; let loggedIn = false;

View File

@ -246,6 +246,15 @@ export default function CameraCard({
/> />
)} )}
{problem?.kind === "signed-out" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Your session has ended.</span>
<a href="/login" className="underline">
Sign in again
</a>
</div>
)}
{problem?.kind === "error" && ( {problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600"> <div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Stopped: {problem.message}</span> <span>Stopped: {problem.message}</span>

View File

@ -7,8 +7,11 @@ import type { DiscoveredCamera } from "@/lib/onvif";
* handlers. No device logic lives here; the server decides what each response means. * handlers. No device logic lives here; the server decides what each response means.
*/ */
/** What the UI acts on: "inactive" shows setup steps, "auth" asks for a login. */ /**
export type ProblemKind = "inactive" | "auth" | "error"; * What the UI acts on: "inactive" shows setup steps, "auth" asks for the camera's login,
* "signed-out" means this app's own session ended (a 401 without a camera error code).
*/
export type ProblemKind = "inactive" | "auth" | "signed-out" | "error";
export class CameraProblem extends Error { export class CameraProblem extends Error {
name = "CameraProblem"; name = "CameraProblem";
@ -46,9 +49,11 @@ async function problemFrom(res: Response): Promise<CameraProblem> {
const kind: ProblemKind = const kind: ProblemKind =
data.code === "inactive" data.code === "inactive"
? "inactive" ? "inactive"
: res.status === 401 || data.code === "auth" : data.code === "auth"
? "auth" ? "auth"
: "error"; : res.status === 401
? "signed-out"
: "error";
return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`); return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`);
} }
@ -64,7 +69,7 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return (await request(url, init)).json() as Promise<T>; return (await request(url, init)).json() as Promise<T>;
} }
/** Only plain failures are worth one retry; a login or setup problem needs the user. */ /** Only plain failures are worth one retry; a login, session or setup problem needs the user. */
const retryPlainFailureOnce = (failures: number, err: Error) => const retryPlainFailureOnce = (failures: number, err: Error) =>
failures < 1 && !(err instanceof CameraProblem && err.kind !== "error"); failures < 1 && !(err instanceof CameraProblem && err.kind !== "error");

View File

@ -6,6 +6,7 @@ vi.mock("next/font/google", () => ({
Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }), Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }),
})); }));
vi.mock("./globals.css", () => ({})); vi.mock("./globals.css", () => ({}));
vi.mock("./security-bar", () => ({ default: () => <div data-testid="security-bar" /> }));
vi.mock("./providers", () => ({ vi.mock("./providers", () => ({
default: ({ children }: { children: React.ReactNode }) => ( default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="providers">{children}</div> <div data-testid="providers">{children}</div>
@ -25,8 +26,10 @@ describe("RootLayout", () => {
expect(html).toMatch(/<html lang="en" class="v\(--font-geist-sans\) v\(--font-geist-mono\) /); expect(html).toMatch(/<html lang="en" class="v\(--font-geist-sans\) v\(--font-geist-mono\) /);
}); });
it("wraps the page in the client Providers", () => { it("puts the security bar above the page, which is wrapped in the client Providers", () => {
expect(html).toContain('<div data-testid="providers"><main>page</main></div>'); expect(html).toContain(
'<div data-testid="security-bar"></div><div data-testid="providers"><main>page</main></div>',
);
}); });
it("sets the page metadata", () => { it("sets the page metadata", () => {

View File

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Providers from "./providers"; import Providers from "./providers";
import SecurityBar from "./security-bar";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -25,6 +26,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<SecurityBar />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View File

@ -0,0 +1,43 @@
"use client";
import { useActionState } from "react";
import { login, type FormState } from "../auth-actions";
const inputClass =
"rounded border border-zinc-300 bg-transparent px-2 py-1.5 dark:border-zinc-700";
export default function LoginForm({ next }: { next: string }) {
const [state, action, pending] = useActionState<FormState, FormData>(login, {});
return (
<form action={action} className="mt-6 flex flex-col gap-3 text-sm">
<input type="hidden" name="next" value={next} />
<label className="flex flex-col gap-1">
Username
<input name="username" autoComplete="username" required className={inputClass} />
</label>
<label className="flex flex-col gap-1">
Password
<input
name="password"
type="password"
autoComplete="current-password"
required
className={inputClass}
/>
</label>
{state.error && (
<p role="alert" className="text-red-600">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}

24
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { safeNextPath } from "@/lib/auth-shared";
import { authState } from "@/lib/session";
import LoginForm from "./login-form";
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
const [state, { next }] = await Promise.all([authState(), searchParams]);
const destination = safeNextPath(next);
if (state === "no-admin") redirect("/setup");
if (state === "signed-in") redirect(destination);
return (
<main className="mx-auto w-full max-w-sm flex-1 px-4 py-16 font-sans">
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in with the camera dashboard&apos;s admin login.
</p>
<LoginForm next={destination} />
<p className="mt-6 text-xs text-zinc-500">
Forgot the password? On the server, run <code>npm run admin:create -- --force</code>.
</p>
</main>
);
}

View File

@ -1,3 +1,4 @@
import { requirePageAccess } from "@/lib/access";
import { listCameras } from "@/lib/camera-registry"; import { listCameras } from "@/lib/camera-registry";
import CameraCard from "./camera-card"; import CameraCard from "./camera-card";
import CameraScanner from "./camera-scanner"; import CameraScanner from "./camera-scanner";
@ -5,6 +6,7 @@ import RefreshRateSelect from "./refresh-rate-select";
import { refreshMsSchema } from "./refresh-rate"; import { refreshMsSchema } from "./refresh-rate";
export default async function Home({ searchParams }: PageProps<"/">) { export default async function Home({ searchParams }: PageProps<"/">) {
await requirePageAccess();
const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]); const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]);
const intervalMs = refreshMsSchema.parse(refresh); const intervalMs = refreshMsSchema.parse(refresh);

View File

@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const getAdmin = vi.fn();
const verifySession = vi.fn();
vi.mock("@/lib/admin-auth", () => ({ getAdmin }));
vi.mock("@/lib/session", () => ({ verifySession }));
vi.mock("./auth-actions", () => ({ logout: async () => {} }));
const { default: SecurityBar } = await import("./security-bar");
const render = async () => renderToStaticMarkup((await SecurityBar()) ?? <></>);
beforeEach(() => {
getAdmin.mockReset();
verifySession.mockReset().mockResolvedValue(null);
});
describe("SecurityBar", () => {
it("shows a severe warning with a setup link while no admin exists", async () => {
getAdmin.mockResolvedValue(null);
const html = await render();
expect(html).toContain('role="alert"');
expect(html).toContain("Not secured:");
expect(html).toContain("anyone on your network can view your cameras");
expect(html).toContain('href="/setup"');
});
it("shows who is signed in, with a sign-out button", async () => {
getAdmin.mockResolvedValue({ username: "admin" });
verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 });
const html = await render();
expect(html).toContain("Signed in as <strong>admin</strong>");
expect(html).toContain("Sign out");
expect(html).not.toContain("Not secured");
});
it("shows nothing on the login page before signing in", async () => {
getAdmin.mockResolvedValue({ username: "admin" });
expect(await render()).toBe("");
});
});

40
src/app/security-bar.tsx Normal file
View File

@ -0,0 +1,40 @@
import Link from "next/link";
import { getAdmin } from "@/lib/admin-auth";
import { verifySession } from "@/lib/session";
import { logout } from "./auth-actions";
/**
* Top of every page: a severe warning while no admin exists (vrek dec-nw2hvff), or who is
* signed in with a sign-out button.
*/
export default async function SecurityBar() {
const [admin, session] = await Promise.all([getAdmin(), verifySession()]);
if (!admin) {
return (
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 bg-red-700 px-4 py-3 text-sm text-white">
<p>
<strong>Not secured:</strong> anyone on your network can view your cameras and change
their logins.
</p>
<Link href="/setup" className="rounded bg-white px-3 py-1 font-medium text-red-700">
Set up admin login
</Link>
</div>
);
}
if (!session) return null;
return (
<div className="flex items-center justify-end gap-3 border-b border-zinc-200 px-4 py-2 text-xs text-zinc-600 dark:border-zinc-800 dark:text-zinc-400">
<span>
Signed in as <strong>{session.username}</strong>
</span>
<form action={logout}>
<button type="submit" className="underline">
Sign out
</button>
</form>
</div>
);
}

40
src/app/setup/page.tsx Normal file
View File

@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { hasAdmin } from "@/lib/admin-auth";
import { ensureSetupCode } from "@/lib/setup-code";
import { skipSetup } from "../auth-actions";
import SetupForm from "./setup-form";
export default async function SetupPage() {
if (await hasAdmin()) redirect("/login");
// Make sure a code exists and has been printed (e.g. after a restart or a rotation).
ensureSetupCode();
return (
<main className="mx-auto w-full max-w-md flex-1 px-4 py-12 font-sans">
<h1 className="text-2xl font-semibold tracking-tight">Secure the camera dashboard</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Create the admin login. Everyone will need it to view cameras or change their settings.
</p>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
The <strong>setup code</strong> is printed in the server&apos;s console, so only someone
with access to the server can claim this login. Prefer the command line? Run{" "}
<code>npm run admin:create</code> on the server instead.
</p>
<SetupForm />
<div className="mt-10 rounded-md border-2 border-red-600 p-4 text-sm">
<p className="font-semibold text-red-700 dark:text-red-400">Skip at your own risk</p>
<p className="mt-1">
Without an admin login, <strong>anyone on your network</strong> can view your cameras,
change their logins, and use the API.
</p>
<form action={skipSetup} className="mt-3">
<button type="submit" className="rounded border border-red-600 px-3 py-1.5 text-red-700 dark:text-red-400">
Skip for now and run unsecured
</button>
</form>
</div>
</main>
);
}

View File

@ -0,0 +1,64 @@
"use client";
import { useActionState } from "react";
import { setupAdmin, type FormState } from "../auth-actions";
const inputClass =
"rounded border border-zinc-300 bg-transparent px-2 py-1.5 dark:border-zinc-700";
export default function SetupForm() {
const [state, action, pending] = useActionState<FormState, FormData>(setupAdmin, {});
return (
<form action={action} className="mt-6 flex flex-col gap-3 text-sm">
<label className="flex flex-col gap-1">
Setup code (from the server console)
<input
name="code"
autoComplete="off"
placeholder="XXXX-XXXX"
required
className={`${inputClass} font-mono uppercase`}
/>
</label>
<label className="flex flex-col gap-1">
Admin username
<input name="username" defaultValue="admin" autoComplete="username" required className={inputClass} />
</label>
<label className="flex flex-col gap-1">
Password (at least 12 characters)
<input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
required
className={inputClass}
/>
</label>
<label className="flex flex-col gap-1">
Repeat password
<input
name="confirm"
type="password"
autoComplete="new-password"
minLength={12}
required
className={inputClass}
/>
</label>
{state.error && (
<p role="alert" className="text-red-600">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{pending ? "Securing…" : "Create admin and sign in"}
</button>
</form>
);
}

View File

@ -0,0 +1,46 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ensureSetupCode = vi.fn();
vi.mock("./lib/setup-code", () => ({ ensureSetupCode }));
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "instr-"));
vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json"));
vi.stubEnv("NEXT_RUNTIME", "nodejs");
ensureSetupCode.mockReset();
});
afterEach(() => rm(dir, { recursive: true, force: true }));
const { register } = await import("./instrumentation");
describe("instrumentation register", () => {
it("prints the setup code at startup when no admin exists", async () => {
await register();
expect(ensureSetupCode).toHaveBeenCalledTimes(1);
});
it("stays quiet when an admin exists", async () => {
const { createAdminRecord, writeAdminFile } = await import("./lib/admin-file");
await writeAdminFile(process.env.ADMIN_AUTH_FILE!, await createAdminRecord("admin", "correct horse battery"));
await register();
expect(ensureSetupCode).not.toHaveBeenCalled();
});
it("reports a malformed admin file loudly instead of printing a code", async () => {
await writeFile(process.env.ADMIN_AUTH_FILE!, "{broken");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await register();
expect(ensureSetupCode).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringMatching(/^\[auth\] Admin file .* not valid JSON/));
});
it("does nothing outside the Node.js runtime", async () => {
vi.stubEnv("NEXT_RUNTIME", "edge");
await register();
expect(ensureSetupCode).not.toHaveBeenCalled();
});
});

15
src/instrumentation.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Runs once when the server starts. If no admin login exists yet, prints the one-time
* setup code (vrek dec-nw2hvff) so it's in the console before anyone opens /setup.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { adminFilePath, readAdminFile } = await import("./lib/admin-file");
const { ensureSetupCode } = await import("./lib/setup-code");
try {
if (!(await readAdminFile(adminFilePath()))) ensureSetupCode();
} catch (err) {
// A malformed admin file: say so loudly; every request will fail until it's fixed.
console.error(`[auth] ${(err as Error).message}`);
}
}

76
src/lib/access.test.ts Normal file
View File

@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthState } from "./session";
const authState = vi.fn<() => Promise<AuthState>>();
vi.mock("./session", () => ({ authState }));
const cookieNames = new Set<string>();
vi.mock("next/headers", () => ({ cookies: async () => ({ has: (n: string) => cookieNames.has(n) }) }));
class Redirect extends Error {}
vi.mock("next/navigation", () => ({
redirect: (to: string) => {
throw new Redirect(to);
},
}));
const { access, requirePageAccess, apiAccessDenied } = await import("./access");
const redirectOf = (p: Promise<unknown>) =>
p.then(
() => null,
(e) => (e instanceof Redirect ? e.message : Promise.reject(e)),
);
beforeEach(() => {
cookieNames.clear();
authState.mockReset();
});
describe("access", () => {
it.each([
["no-admin", "unsecured"],
["signed-in", "signed-in"],
["signed-out", "signed-out"],
] as const)("maps %s to %s", async (state, expected) => {
authState.mockResolvedValue(state);
expect(await access()).toBe(expected);
});
});
describe("requirePageAccess", () => {
it("lets a signed-in admin through", async () => {
authState.mockResolvedValue("signed-in");
expect(await redirectOf(requirePageAccess())).toBeNull();
});
it("sends a signed-out visitor to /login", async () => {
authState.mockResolvedValue("signed-out");
expect(await redirectOf(requirePageAccess())).toBe("/login");
});
it("sends visitors to /setup while no admin exists", async () => {
authState.mockResolvedValue("no-admin");
expect(await redirectOf(requirePageAccess())).toBe("/setup");
});
it("lets a visitor through unsecured once they chose to skip setup", async () => {
authState.mockResolvedValue("no-admin");
cookieNames.add("cameras_setup_skipped");
expect(await redirectOf(requirePageAccess())).toBeNull();
});
});
describe("apiAccessDenied", () => {
it("returns 401 when an admin exists and the caller isn't signed in", async () => {
authState.mockResolvedValue("signed-out");
const res = (await apiAccessDenied())!;
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Sign in required" });
});
it.each(["signed-in", "no-admin"] as const)("allows the call when %s", async (state) => {
authState.mockResolvedValue(state);
expect(await apiAccessDenied()).toBeNull();
});
});

36
src/lib/access.ts Normal file
View File

@ -0,0 +1,36 @@
import "server-only";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SETUP_SKIP_COOKIE } from "./auth-shared";
import { authState } from "./session";
/**
* The authoritative access check for pages and route handlers (Next 16 authentication
* guide, "Creating a Data Access Layer"). proxy.ts applies the same rules optimistically;
* these run next to the data, so a request that slips past the proxy still gets nothing.
*
* Rules (vrek dec-nw2hvff): with an admin, a valid session is required everywhere. With no
* admin the app is deliberately unsecured: the API is open, and pages are open once the user
* has chosen to skip setup.
*/
export type Access = "signed-in" | "unsecured" | "signed-out";
export async function access(): Promise<Access> {
const state = await authState();
if (state === "no-admin") return "unsecured";
return state;
}
/** For pages: sends the visitor to /login or /setup unless they may see the page. */
export async function requirePageAccess(): Promise<void> {
const state = await access();
if (state === "signed-out") redirect("/login");
if (state === "unsecured" && !(await cookies()).has(SETUP_SKIP_COOKIE)) redirect("/setup");
}
/** For route handlers: a 401 response to return, or null if the request may proceed. */
export async function apiAccessDenied(): Promise<Response | null> {
if ((await access()) !== "signed-out") return null;
return Response.json({ error: "Sign in required" }, { status: 401 });
}

View File

@ -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);
});
});

45
src/lib/admin-auth.ts Normal file
View File

@ -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<AdminFile | null> {
return readAdminFile(adminFilePath());
}
export async function hasAdmin(): Promise<boolean> {
return (await getAdmin()) !== null;
}
// Hashed once, lazily: lets a failed login cost the same whether or not the username exists.
let dummyHash: Promise<string> | 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<boolean> {
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<AdminFile> {
const admin = await createAdminRecord(username, password);
await writeAdminFile(adminFilePath(), admin);
return admin;
}

172
src/lib/admin-file.test.ts Normal file
View File

@ -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);
});
});
});

157
src/lib/admin-file.ts Normal file
View File

@ -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<string, string | undefined> = 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<Buffer>((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<string> {
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<boolean> {
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<typeof adminFileSchema>;
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<AdminFile | null> {
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<void> {
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<AdminFile> {
return {
version: 1,
username: usernameSchema.parse(username),
passwordHash: await hashPassword(passwordSchema.parse(password)),
};
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { safeNextPath, sessionCookieOptions } from "./auth-shared";
describe("safeNextPath", () => {
it.each([
["/", "/"],
["/?refresh=500", "/?refresh=500"],
["/cameras/abc", "/cameras/abc"],
])("keeps same-site path %s", (input, output) => {
expect(safeNextPath(input)).toBe(output);
});
it.each([
["missing", undefined],
["an array", ["/a", "/b"]],
["a relative path", "cameras"],
["an absolute URL", "https://evil.example/"],
["a protocol-relative URL", "//evil.example/"],
["a backslash trick", "/\\evil.example"],
["a control character", "/\u0000evil"],
["a javascript: URL", "javascript:alert(1)"],
])("falls back to / for %s", (_label, input) => {
expect(safeNextPath(input)).toBe("/");
});
});
describe("sessionCookieOptions", () => {
it("is HttpOnly, SameSite=Lax, site-wide, expiring with the session", () => {
expect(sessionCookieOptions(1_000, false)).toEqual({
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
expires: new Date(1_000),
});
expect(sessionCookieOptions(1_000, true).secure).toBe(true);
});
});

33
src/lib/auth-shared.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* Auth constants and helpers shared by proxy.ts, Server Actions and pages. Plain module (no
* "server-only", no next/headers) so the proxy can import it.
*/
export const SESSION_COOKIE = "cameras_session";
/** Set when the user chooses to run without an admin; lasts until the browser closes. */
export const SETUP_SKIP_COOKIE = "cameras_setup_skipped";
/** Pages reachable without a session. */
export const PUBLIC_PATHS = ["/login", "/setup"];
/** Session cookie attributes. Secure only over HTTPS: the app usually runs on plain LAN HTTP. */
export function sessionCookieOptions(expiresAt: number, secure: boolean) {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure,
expires: new Date(expiresAt),
};
}
/**
* Where to send the user after login: a same-site path only, so `?next=` can't be used to
* redirect to another site (`//evil.example`, `/\evil.example`, `https://…`).
*/
export function safeNextPath(value: unknown): string {
if (typeof value !== "string" || !value.startsWith("/")) return "/";
if (value.startsWith("//") || value.startsWith("/\\")) return "/";
if (/[\u0000-\u001f]/.test(value)) return "/";
return value;
}

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { clientKey, DEFAULT_LIMITS, LoginThrottle, loginThrottle } from "./login-throttle";
const MIN = 60_000;
const T0 = 1_000_000_000;
describe("LoginThrottle", () => {
it("defaults to 10 failures per client and 100 overall per 15 minutes", () => {
expect(DEFAULT_LIMITS).toMatchObject({ windowMs: 15 * MIN, perClient: 10, global: 100 });
});
it("allows 9 failures, then locks the client out until the oldest one ages out", () => {
const t = new LoginThrottle();
for (let i = 0; i < 9; i++) t.recordFailure("a", T0 + i * MIN);
expect(t.retryAfter("a", T0 + 9 * MIN)).toBe(0);
t.recordFailure("a", T0 + 9 * MIN);
expect(t.retryAfter("a", T0 + 9 * MIN)).toBe(6 * MIN);
expect(t.retryAfter("a", T0 + 15 * MIN)).toBe(0);
});
it("locks out only the failing client", () => {
const t = new LoginThrottle();
for (let i = 0; i < 10; i++) t.recordFailure("a", T0);
expect(t.retryAfter("a", T0)).toBeGreaterThan(0);
expect(t.retryAfter("b", T0)).toBe(0);
});
it("clears a client's failures after a successful login", () => {
const t = new LoginThrottle();
for (let i = 0; i < 10; i++) t.recordFailure("a", T0);
t.recordSuccess("a");
expect(t.retryAfter("a", T0)).toBe(0);
});
it("locks everyone out once the global ceiling is hit, even across spoofed addresses", () => {
const t = new LoginThrottle({ ...DEFAULT_LIMITS, global: 5 });
for (let i = 0; i < 5; i++) t.recordFailure(`spoofed-${i}`, T0 + i);
expect(t.retryAfter("fresh-client", T0 + 5)).toBe(15 * MIN - 5);
expect(t.retryAfter("fresh-client", T0 + 15 * MIN)).toBe(0);
});
it("caps the number of tracked clients, dropping the least recent", () => {
const t = new LoginThrottle({ ...DEFAULT_LIMITS, perClient: 1, global: 1_000, maxClients: 2 });
t.recordFailure("a", T0);
t.recordFailure("b", T0);
t.recordFailure("a", T0); // a is now most recent
t.recordFailure("c", T0); // evicts b
expect(t.retryAfter("a", T0)).toBeGreaterThan(0);
expect(t.retryAfter("b", T0)).toBe(0);
expect(t.retryAfter("c", T0)).toBeGreaterThan(0);
});
it("uses the current time by default", () => {
const t = new LoginThrottle({ ...DEFAULT_LIMITS, perClient: 1 });
t.recordFailure("a");
expect(t.retryAfter("a")).toBeGreaterThan(0);
});
it("is one instance per process", async () => {
const again = await import("./login-throttle");
expect(again.loginThrottle).toBe(loginThrottle);
});
});
describe("clientKey", () => {
it.each([
["192.168.1.20", "192.168.1.20"],
[" 10.0.0.5 , 172.16.0.1", "10.0.0.5"],
["", "unknown"],
[null, "unknown"],
])("%j → %s", (header, key) => {
expect(clientKey(header)).toBe(key);
});
});

77
src/lib/login-throttle.ts Normal file
View File

@ -0,0 +1,77 @@
/**
* Limits password guessing at the login form.
*
* Per client: 10 failures within 15 minutes locks that client out until the oldest failure
* ages out. The client key comes from x-forwarded-for, which Next fills from the socket
* address but a client can also send itself, so a global ceiling (100 failures per 15
* minutes across all clients) stops an attacker who rotates fake addresses. Each attempt
* also costs one scrypt hash (~130 ms) regardless.
*/
export interface ThrottleLimits {
windowMs: number;
perClient: number;
global: number;
/** Cap on tracked clients, so spoofed addresses can't grow memory without bound. */
maxClients: number;
}
export const DEFAULT_LIMITS: ThrottleLimits = {
windowMs: 15 * 60 * 1000,
perClient: 10,
global: 100,
maxClients: 10_000,
};
export class LoginThrottle {
private readonly limits: ThrottleLimits;
private readonly clients = new Map<string, number[]>();
private globalFailures: number[] = [];
constructor(limits: ThrottleLimits = DEFAULT_LIMITS) {
this.limits = limits;
}
private recent(times: number[], now: number) {
return times.filter((t) => now - t < this.limits.windowMs);
}
/** Milliseconds until this client may try again; 0 if it may try now. */
retryAfter(client: string, now: number = Date.now()): number {
const { windowMs, perClient, global } = this.limits;
this.globalFailures = this.recent(this.globalFailures, now);
const mine = this.recent(this.clients.get(client) ?? [], now);
const waits = [0];
if (mine.length >= perClient) waits.push(mine[mine.length - perClient] + windowMs - now);
if (this.globalFailures.length >= global) {
waits.push(this.globalFailures[this.globalFailures.length - global] + windowMs - now);
}
return Math.max(...waits);
}
recordFailure(client: string, now: number = Date.now()): void {
const mine = this.recent(this.clients.get(client) ?? [], now);
mine.push(now);
this.clients.delete(client); // re-insert so Map order tracks recency
this.clients.set(client, mine);
if (this.clients.size > this.limits.maxClients) {
this.clients.delete(this.clients.keys().next().value!);
}
this.globalFailures.push(now);
}
recordSuccess(client: string): void {
this.clients.delete(client);
}
}
/** The key a request is throttled under: the first x-forwarded-for address. */
export function clientKey(forwardedFor: string | null): string {
return forwardedFor?.split(",")[0].trim() || "unknown";
}
// One throttle per server process, shared by every module instance that imports it.
const KEY = Symbol.for("cameras.loginThrottle");
export const loginThrottle: LoginThrottle = ((globalThis as { [KEY]?: LoginThrottle })[KEY] ??=
new LoginThrottle());

View File

@ -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();
});
});
});

75
src/lib/session-token.ts Normal file
View File

@ -0,0 +1,75 @@
import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
import type { AdminFile } from "./admin-file";
/**
* Stateless admin session tokens: `<username, base64url>.<expiresAt ms>.<HMAC-SHA256>`.
*
* 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;
}

128
src/lib/session.test.ts Normal file
View File

@ -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<string, string>(),
options: new Map<string, Record<string, unknown>>(),
headers: new Map<string, string>(),
}));
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<string, unknown>) => {
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<AdminFile | null>>();
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);
});
});
});

66
src/lib/session.ts Normal file
View File

@ -0,0 +1,66 @@
import "server-only";
import { cookies, headers } from "next/headers";
import { getAdmin } from "./admin-auth";
import type { AdminFile } from "./admin-file";
import { SESSION_COOKIE, sessionCookieOptions } from "./auth-shared";
import { issueToken, readToken, refreshToken, type Session } from "./session-token";
export { SESSION_COOKIE };
/**
* 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 type AuthState = "no-admin" | "signed-out" | "signed-in";
async function requestIsHttps(): Promise<boolean> {
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<Session | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
return readToken(token, await getAdmin());
}
export async function authState(): Promise<AuthState> {
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<void> {
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<Session | null> {
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<void> {
(await cookies()).delete(SESSION_COOKIE);
}

View File

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let setup: typeof import("./setup-code");
const log = vi.fn<(message: string) => void>();
beforeEach(async () => {
delete (globalThis as Record<symbol, unknown>)[Symbol.for("cameras.setupCode")];
log.mockReset();
vi.resetModules();
setup = await import("./setup-code");
});
const printedCode = (call = 0) => /\s{6}([A-Z0-9]{4}-[A-Z0-9]{4})\n/.exec(log.mock.calls[call][0])![1];
describe("setup code", () => {
it("creates an 8-character code once and prints it with instructions", () => {
const code = setup.ensureSetupCode(log);
expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/);
expect(setup.ensureSetupCode(log)).toBe(code);
expect(log).toHaveBeenCalledTimes(1);
const message = log.mock.calls[0][0];
expect(message).toContain("NOT secured");
expect(message).toContain("open /setup");
expect(message).toContain("npm run admin:create");
expect(printedCode()).toBe(`${code.slice(0, 4)}-${code.slice(4)}`);
});
it("is shared through globalThis, as instrumentation and the app are bundled separately", async () => {
const code = setup.ensureSetupCode(log);
vi.resetModules();
const other = await import("./setup-code");
expect(other.ensureSetupCode(log)).toBe(code);
});
it("accepts the code regardless of case, spaces or the dash", () => {
setup.ensureSetupCode(log);
const shown = printedCode();
expect(setup.checkSetupCode(shown, log)).toBe(true);
expect(setup.checkSetupCode(shown.toLowerCase(), log)).toBe(true);
expect(setup.checkSetupCode(` ${shown.replace("-", " ")} `, log)).toBe(true);
});
it("rejects wrong or differently sized codes", () => {
setup.ensureSetupCode(log);
expect(setup.checkSetupCode("", log)).toBe(false);
expect(setup.checkSetupCode("AAAA-AAA", log)).toBe(false);
});
it("replaces the code after 5 wrong guesses and prints the new one", () => {
const first = setup.ensureSetupCode(log);
for (let i = 0; i < 4; i++) setup.checkSetupCode("WRONGWRONG", log);
expect(setup.ensureSetupCode(log)).toBe(first);
setup.checkSetupCode("WRONGWRONG", log);
const second = setup.ensureSetupCode(log);
expect(second).not.toBe(first);
expect(log).toHaveBeenCalledWith("[setup] Too many wrong setup codes; issuing a new one.");
expect(setup.checkSetupCode(first, log)).toBe(false);
expect(setup.checkSetupCode(second, log)).toBe(true);
});
it("creates a code on first check if none exists", () => {
expect(setup.checkSetupCode("ANYTHING", log)).toBe(false);
expect(log).toHaveBeenCalledTimes(1);
});
it("forgets the code once cleared", () => {
const code = setup.ensureSetupCode(log);
setup.clearSetupCode();
expect(setup.ensureSetupCode(log)).not.toBe(code);
});
it("logs to the console by default", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
setup.ensureSetupCode();
setup.checkSetupCode("X");
expect(spy).toHaveBeenCalled();
});
});

93
src/lib/setup-code.ts Normal file
View File

@ -0,0 +1,93 @@
import { randomInt, timingSafeEqual } from "node:crypto";
/**
* The one-time code that authorizes creating the first admin from the browser (vrek
* dec-nw2hvff). It exists only in server memory and is printed to the server console, so
* only someone who can see the console can claim the admin account.
*
* Kept on globalThis because instrumentation.ts and the app are bundled separately; both
* must see the same code. Plain module (no "server-only") so instrumentation can import it.
*/
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I
const MAX_FAILURES = 5;
interface SetupCodeState {
code: string | null;
failures: number;
}
const KEY = Symbol.for("cameras.setupCode");
function state(): SetupCodeState {
const g = globalThis as { [KEY]?: SetupCodeState };
return (g[KEY] ??= { code: null, failures: 0 });
}
function generate(): string {
let code = "";
for (let i = 0; i < 8; i++) code += ALPHABET[randomInt(ALPHABET.length)];
return code;
}
const normalize = (input: string) => input.toUpperCase().replace(/[\s-]/g, "");
const pretty = (code: string) => `${code.slice(0, 4)}-${code.slice(4)}`;
function announce(code: string, log: (message: string) => void) {
const rule = "=".repeat(68);
log(
[
"",
rule,
" No admin login is set up: the camera dashboard is NOT secured.",
"",
" To secure it from a browser, open /setup and enter this one-time code:",
"",
` ${pretty(code)}`,
"",
" Or create the admin without the browser: npm run admin:create",
rule,
"",
].join("\n"),
);
}
/** The current code, creating and printing one if needed. */
export function ensureSetupCode(log: (message: string) => void = console.log): string {
const s = state();
if (!s.code) {
s.code = generate();
s.failures = 0;
announce(s.code, log);
}
return s.code;
}
/**
* Checks a submitted code in constant time. After MAX_FAILURES wrong guesses the code is
* replaced (and the new one printed), so it can't be brute-forced.
*/
export function checkSetupCode(
input: string,
log: (message: string) => void = console.log,
): boolean {
const code = ensureSetupCode(log);
const given = Buffer.from(normalize(input));
const expected = Buffer.from(code);
const ok = given.length === expected.length && timingSafeEqual(given, expected);
if (!ok) {
const s = state();
if (++s.failures >= MAX_FAILURES) {
s.code = null;
log("[setup] Too many wrong setup codes; issuing a new one.");
ensureSetupCode(log);
}
}
return ok;
}
/** Forgets the code once an admin exists. */
export function clearSetupCode(): void {
const s = state();
s.code = null;
s.failures = 0;
}

127
src/proxy.test.ts Normal file
View File

@ -0,0 +1,127 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdminFile } from "./lib/admin-file";
import { issueToken, REFRESH_AFTER_MS } from "./lib/session-token";
const admin: AdminFile = {
version: 1,
username: "admin",
passwordHash: `scrypt$65536$8$1$${"A".repeat(24)}$${"K".repeat(88)}`,
};
let dir: string;
let proxy: typeof import("./proxy").proxy;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "proxy-"));
vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json"));
({ proxy } = await import("./proxy"));
});
afterEach(() => rm(dir, { recursive: true, force: true }));
const withAdmin = () => writeFile(process.env.ADMIN_AUTH_FILE!, JSON.stringify(admin));
function request(pathname: string, cookies: Record<string, string> = {}, headers: Record<string, string> = {}) {
const cookie = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://cams.local${pathname}`, {
headers: { ...(cookie ? { cookie } : {}), ...headers },
});
}
const redirectTo = (res: Response) => res.headers.get("location");
const passes = (res: Response) => res.headers.get("x-middleware-next") === "1";
describe("proxy without an admin (unsecured)", () => {
it("sends pages to /setup", async () => {
expect(redirectTo(await proxy(request("/")))).toBe("http://cams.local/setup");
});
it("lets /setup and /login through", async () => {
expect(passes(await proxy(request("/setup")))).toBe(true);
expect(passes(await proxy(request("/login")))).toBe(true);
});
it("leaves the API open", async () => {
expect(passes(await proxy(request("/api/discover")))).toBe(true);
});
it("lets pages through once setup was skipped", async () => {
expect(passes(await proxy(request("/", { cameras_setup_skipped: "1" })))).toBe(true);
});
});
describe("proxy with an admin", () => {
beforeEach(withAdmin);
it("sends a signed-out visitor to /login", async () => {
expect(redirectTo(await proxy(request("/")))).toBe("http://cams.local/login");
});
it("remembers where a signed-out visitor was going", async () => {
const res = await proxy(request("/?refresh=500"));
expect(redirectTo(res)).toBe("http://cams.local/login?next=%2F%3Frefresh%3D500");
});
it("returns 401 JSON for the API without a session", async () => {
for (const p of ["/api", "/api/discover", "/api/cameras/x/snapshot"]) {
const res = await proxy(request(p));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Sign in required" });
}
});
it("ignores the skip cookie and forged sessions", async () => {
expect(redirectTo(await proxy(request("/", { cameras_setup_skipped: "1" })))).toContain("/login");
expect(redirectTo(await proxy(request("/", { cameras_session: "forged" })))).toContain("/login");
});
it("lets /login and /setup through so they can redirect appropriately", async () => {
expect(passes(await proxy(request("/login")))).toBe(true);
expect(passes(await proxy(request("/setup")))).toBe(true);
});
it("lets a signed-in admin through without rewriting a fresh cookie", async () => {
const res = await proxy(request("/api/discover", { cameras_session: issueToken(admin) }));
expect(passes(res)).toBe(true);
expect(res.cookies.get("cameras_session")).toBeUndefined();
});
it("slides an older session to a fresh 12-hour cookie", async () => {
const old = issueToken(admin, Date.now() - REFRESH_AFTER_MS - 1_000);
const res = await proxy(request("/", { cameras_session: old }));
const cookie = res.cookies.get("cameras_session")!;
expect(passes(res)).toBe(true);
expect(cookie.value).not.toBe(old);
expect(cookie).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/", secure: false });
expect(cookie.expires!.valueOf()).toBeGreaterThan(Date.now() + 11.9 * 3600_000);
});
it("marks the refreshed cookie Secure behind HTTPS", async () => {
const old = issueToken(admin, Date.now() - REFRESH_AFTER_MS - 1_000);
const res = await proxy(request("/", { cameras_session: old }, { "x-forwarded-proto": "https" }));
expect(res.cookies.get("cameras_session")!.secure).toBe(true);
});
});
describe("proxy with a malformed admin file", () => {
it("fails rather than letting anyone in", async () => {
await writeFile(process.env.ADMIN_AUTH_FILE!, "{broken");
await expect(proxy(request("/"))).rejects.toThrow(/not valid JSON/);
});
});
describe("matcher", () => {
it("skips build assets but covers pages and the API", async () => {
const { config } = await import("./proxy");
const re = new RegExp(`^${config.matcher[0]}$`);
expect(re.test("/")).toBe(true);
expect(re.test("/api/discover")).toBe(true);
expect(re.test("/_next/static/chunk.js")).toBe(false);
expect(re.test("/favicon.ico")).toBe(false);
});
});

55
src/proxy.ts Normal file
View File

@ -0,0 +1,55 @@
import { NextResponse, type NextRequest } from "next/server";
import { adminFilePath, readAdminFile } from "./lib/admin-file";
import {
PUBLIC_PATHS,
SESSION_COOKIE,
SETUP_SKIP_COOKIE,
sessionCookieOptions,
} from "./lib/auth-shared";
import { readToken, refreshToken } from "./lib/session-token";
/**
* Optimistic auth for every request (Next 16 proxy, Node runtime): redirects to /login or
* /setup, returns 401 for the API, and slides the 12-hour session window (vrek
* dec-f0xar8r). Pages and routes re-check with lib/access.ts, so this is not the only
* line of defense.
*/
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isApi = pathname === "/api" || pathname.startsWith("/api/");
const isPublic = PUBLIC_PATHS.includes(pathname);
const admin = await readAdminFile(adminFilePath());
if (!admin) {
// Unsecured by choice: the API stays open, pages once setup has been skipped.
if (isApi || isPublic || request.cookies.has(SETUP_SKIP_COOKIE)) return NextResponse.next();
return NextResponse.redirect(new URL("/setup", request.url));
}
const session = readToken(request.cookies.get(SESSION_COOKIE)?.value, admin);
if (!session) {
if (isPublic) return NextResponse.next();
if (isApi) return NextResponse.json({ error: "Sign in required" }, { status: 401 });
const login = new URL("/login", request.url);
const destination = pathname + search;
if (destination !== "/") login.searchParams.set("next", destination);
return NextResponse.redirect(login);
}
const response = NextResponse.next();
const fresh = refreshToken(session, admin);
if (fresh) {
const secure = request.headers.get("x-forwarded-proto") === "https";
response.cookies.set(
SESSION_COOKIE,
fresh,
sessionCookieOptions(readToken(fresh, admin)!.expiresAt, secure),
);
}
return response;
}
export const config = {
// Everything except build assets.
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

View File

@ -6,6 +6,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",

View File

@ -15,7 +15,7 @@ export default defineConfig({
test: { test: {
// Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`. // Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`.
environment: "node", environment: "node",
include: ["src/**/*.test.{ts,tsx}"], include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"],
restoreMocks: true, restoreMocks: true,
unstubEnvs: true, unstubEnvs: true,
coverage: { coverage: {