fix(access): renew site access during active use (#315)

This commit is contained in:
AAA authored and GitHub committed 2026-08-28 20:11:03 +08:00
1 parent fe1e31c8bb
commit ce19085265
10 files changed
+118 -20

No files matched your search

+6 -3
View File
@@ -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
+6 -5
View File
@@ -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
+3 -1
View File
@@ -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;
+2 -3
View File
@@ -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
+33 -2
View File
@@ -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<void> {
setAccess({ kind: "checking" });
try {
+1 -1
View File
@@ -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 {
+5 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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"/);
});
+60 -2
View File
@@ -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);