fix(access): renew site access during active use (#315)
This commit is contained in:
1 parent
fe1e31c8bb
commit
ce19085265
10 files changed
+118
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user