diff --git a/docs/product/rooms-access.md b/docs/product/rooms-access.md index 527c217..1fabe70 100644 --- a/docs/product/rooms-access.md +++ b/docs/product/rooms-access.md @@ -27,9 +27,12 @@ the storage decision; implementation detail belongs in code and tests. Three independent authorities exist: 1. **Site access.** Production requires an independent - `SITE_ACCESS_PASSWORD`. Successful entry creates a 12-hour stateless, - HttpOnly, `SameSite=Strict` cookie. It authorizes room creation, Host role, - and code-only Viewer attempts; it is not a room credential. + `SITE_ACCESS_PASSWORD`. Successful entry creates a stateless, HttpOnly, + `SameSite=Strict` cookie with a 24-hour rolling idle lifetime. An already + authorized page renews it through the site-access check while it remains + active. It authorizes room creation, Host role, and code-only Viewer + attempts; it is not a room credential, and Viewer-grant admission cannot + create or renew it. 2. **Host ownership.** The exact Host token authorizes that room's Host and access-management operations. It cannot authorize another room. 3. **Viewer invitation.** Every room creates a 128-bit, 22-character base64url diff --git a/docs/project-memory.md b/docs/project-memory.md index 9ebd2e6..f858cac 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,6 +1,6 @@ # Project Memory -Last updated: 2026-08-27 +Last updated: 2026-08-28 Screener is private, low-latency game screen sharing for one Host and up to 20 authenticated friends. The current product surface is Web Host, Web Viewer, and @@ -52,10 +52,11 @@ implementation and routine UI detail. ## Current Snapshot -Canonical source and production use the strict `screener-v16` Browser/server -contract with committed media readiness, bounded candidate-relative progress -and one-shot Host-root convergence. Both retain optional SQLite room authority -and the H.264/VP8 sender gate. [Status](./status.md) owns exact identity. +Canonical source uses the strict `screener-v17` Browser/server contract with +committed media readiness, bounded candidate-relative progress and one-shot +Host-root convergence. It retains optional SQLite room authority and the +H.264/VP8 sender gate. [Status](./status.md) owns exact source and production +identity. Current work is owned by the [TODO ledger](./todo.md). Open physical evidence is owned by [verification status](./verification-status.md). Environment and initial diff --git a/docs/status.md b/docs/status.md index 4befbc4..6e5e238 100644 --- a/docs/status.md +++ b/docs/status.md @@ -45,7 +45,7 @@ This is the current execution index. Git history owns completed timelines; ## Current Source -- Canonical source uses the strict `screener-v16` Browser/server contract. +- Canonical source uses the strict `screener-v17` Browser/server contract. Optional SQLite stable authority, non-expiring local preferred code, atomic room replacement and the Host codec selector remain implemented; production selects stable storage. Graceful restart, crash, timeout and network loss use @@ -76,6 +76,8 @@ This is the current execution index. Git history owns completed timelines; ## Source/Production Delta +- Current source uses 24-hour rolling site-access authorization and the v17 + single wire contract; production remains on the v16 contract until release. - Current source replaces aggregate route-fact retries with exact session, publication and transition opportunities; advances proved non-improving candidates; binds stall and recovery reports to physical media identities; diff --git a/docs/todo.md b/docs/todo.md index 4028f82..e8d2189 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,9 +7,8 @@ topics are not implementation authority. ## Now -No implementation task is currently authorized. The audit root-cause -corrections are integrated and verified in current source but remain undeployed; -deployment requires explicit authority. +Release the accepted current source with 24-hour rolling site access and the +strict v17 Browser/server contract, then record exact production identity. ## Parked Product Work diff --git a/src/client/App.tsx b/src/client/App.tsx index 43fd2f5..0a35130 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -13,16 +13,17 @@ import { ViewerPage } from "./pages/ViewerPage"; const appRoute = parseAppRoute(window.location.pathname); const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null; +const SITE_ACCESS_RENEWAL_INTERVAL_MS = 60 * 60 * 1_000; type AccessState = | { kind: "checking" } - | { kind: "ready" } + | { kind: "ready"; renewalRequired: boolean } | { kind: "required"; error: string | null } | { kind: "unavailable"; message: string }; function stateFromStatus(status: SiteAccessStatus): AccessState { return !status.required || status.authenticated - ? { kind: "ready" } + ? { kind: "ready", renewalRequired: status.required } : { kind: "required", error: null }; } @@ -75,6 +76,36 @@ function SiteAccessGate({ }; }, []); + useEffect(() => { + if (access.kind !== "ready" || !access.renewalRequired) { + return; + } + let active = true; + let checking = false; + const timer = window.setInterval(() => { + if (checking) { + return; + } + checking = true; + void getSiteAccess() + .then((status) => { + if (active && (!status.required || !status.authenticated)) { + setAccess(stateFromStatus(status)); + } + }) + .catch(() => undefined) + .finally(() => { + if (active) { + checking = false; + } + }); + }, SITE_ACCESS_RENEWAL_INTERVAL_MS); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [access]); + async function retry(): Promise { setAccess({ kind: "checking" }); try { diff --git a/src/server/access-session.ts b/src/server/access-session.ts index e030de0..a34ab4c 100644 --- a/src/server/access-session.ts +++ b/src/server/access-session.ts @@ -1,6 +1,6 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto"; -const DEFAULT_SESSION_TTL_SECONDS = 12 * 60 * 60; +const DEFAULT_SESSION_TTL_SECONDS = 24 * 60 * 60; const COOKIE_VERSION = "v1"; export interface SiteAccessOptions { diff --git a/src/server/app.ts b/src/server/app.ts index 93258a3..40e06ea 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -622,7 +622,11 @@ function handleSiteAccessRequest( response.setHeader("X-Content-Type-Options", "nosniff"); if (request.method === "GET") { - sendJson(response, 200, siteAccessStatus(request, siteAccess)); + const status = siteAccessStatus(request, siteAccess); + if (status.required && status.authenticated) { + response.setHeader("Set-Cookie", siteAccess.createCookie()!); + } + sendJson(response, 200, status); return; } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index d529671..ea3c513 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v16"; +export const SIGNALING_PROTOCOL = "screener-v17"; export const SIGNAL_CLOSE_CODES = { serviceRestart: 1012, sessionReplaced: 4001, diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 08f0e25..e016691 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -97,7 +97,7 @@ describe("client signaling protocol", () => { "utf8", ); - expect(SIGNALING_PROTOCOL).toBe("screener-v16"); + expect(SIGNALING_PROTOCOL).toBe("screener-v17"); expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/); }); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 5fc4e17..132d7cc 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -167,12 +167,13 @@ async function replaceRoom( } describe("site access", () => { - it("reports status and issues a stateless 12-hour cookie", async () => { + it("reports status and issues a stateless 24-hour cookie", async () => { const baseUrl = await start(); const initial = await fetch(`${baseUrl}/api/site-access`); expect(initial.status).toBe(200); expect(initial.headers.get("cache-control")).toBe("no-store"); + expect(initial.headers.get("set-cookie")).toBeNull(); expect(await initial.json()).toEqual({ required: true, authenticated: false, @@ -193,21 +194,78 @@ describe("site access", () => { expect(authenticated.status).toBe(200); expect(setCookie).toContain("screener-site-access=v1."); expect(setCookie).toContain("Path=/"); - expect(setCookie).toContain("Max-Age=43200"); + expect(setCookie).toContain("Max-Age=86400"); expect(setCookie).toContain("HttpOnly"); expect(setCookie).toContain("SameSite=Strict"); expect(setCookie).not.toContain("Secure"); expect(setCookie).not.toContain(siteAccessPassword); + const deniedRenewal = await fetch(`${baseUrl}/api/site-access`, { + method: "POST", + headers: { + Authorization: "Bearer wrong-password", + Cookie: cookiePair(authenticated), + Origin: allowedOrigin, + }, + }); + expect(deniedRenewal.status).toBe(401); + expect(deniedRenewal.headers.get("set-cookie")).toBeNull(); + const status = await fetch(`${baseUrl}/api/site-access`, { headers: { Cookie: cookiePair(authenticated) }, }); + expect(status.headers.get("set-cookie")).toContain( + "screener-site-access=v1.", + ); expect(await status.json()).toEqual({ required: true, authenticated: true, }); }); + it("renews an authenticated cookie across successive idle deadlines", async () => { + let now = 1_000; + const baseUrl = await start(testConfig(), { + now: () => now, + siteAccessTtlSeconds: 12, + }); + const authenticated = await login(baseUrl); + const originalCookie = cookiePair(authenticated); + + now = 7_000; + const firstRenewal = await fetch(`${baseUrl}/api/site-access`, { + headers: { Cookie: originalCookie }, + }); + const firstRenewedCookie = cookiePair(firstRenewal); + expect(firstRenewedCookie).not.toBe(originalCookie); + + now = 13_000; + const expiredOriginal = await fetch(`${baseUrl}/api/site-access`, { + headers: { Cookie: originalCookie }, + }); + expect(expiredOriginal.headers.get("set-cookie")).toBeNull(); + expect((await expiredOriginal.json()).authenticated).toBe(false); + + const secondRenewal = await fetch(`${baseUrl}/api/site-access`, { + headers: { Cookie: firstRenewedCookie }, + }); + const secondRenewedCookie = cookiePair(secondRenewal); + expect(secondRenewedCookie).not.toBe(firstRenewedCookie); + + now = 20_000; + const expiredFirstRenewal = await fetch(`${baseUrl}/api/site-access`, { + headers: { Cookie: firstRenewedCookie }, + }); + expect(expiredFirstRenewal.headers.get("set-cookie")).toBeNull(); + expect((await expiredFirstRenewal.json()).authenticated).toBe(false); + + const activeSecondRenewal = await fetch(`${baseUrl}/api/site-access`, { + headers: { Cookie: secondRenewedCookie }, + }); + expect(activeSecondRenewal.headers.get("set-cookie")).not.toBeNull(); + expect((await activeSecondRenewal.json()).authenticated).toBe(true); + }); + it("uses a secure __Host- cookie for production HTTPS", async () => { const baseUrl = await start(testConfig({ nodeEnv: "production" })); const response = await login(baseUrl);