From 9eca2bca97e85390e8f081489cfb987b86c25cf5 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 7 Aug 2026 20:27:40 -0400 Subject: [PATCH] feat(web): count a sign-in, and join a player's visits up The far side of the OAuth redirect is a page load with a session cookie, which looks like every later page load of the same session. signin-form.ts leaves a mark on the way out so the return can be told apart and counted once. Players are identified by a salted SHA-256 of their DID, never the DID. The salt ships in the bundle, so it makes the id inert rather than secret; an opaque id minted by the API is the version that would resist someone trying. --- web/src/account.ts | 5 ++++ web/src/analytics.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ web/src/signin-form.ts | 10 ++++++++ 3 files changed, 69 insertions(+) diff --git a/web/src/account.ts b/web/src/account.ts index 6aac2f8..bddd814 100644 --- a/web/src/account.ts +++ b/web/src/account.ts @@ -15,6 +15,7 @@ * someone else chose. */ +import { noteSession } from "./analytics"; import { currentSession, logout, type Session } from "./api"; import { el } from "./dom"; import { attachMenu } from "./menu"; @@ -53,6 +54,10 @@ export async function refreshAccount(): Promise { } slot.replaceChildren(accountControl(session)); revealSignedInLinks(session !== null); + + // The masthead is on every page and reads the session once, so this is the + // one place that learns who is here without asking a second time. + void noteSession(session); } /** diff --git a/web/src/analytics.ts b/web/src/analytics.ts index 381a19c..323adb1 100644 --- a/web/src/analytics.ts +++ b/web/src/analytics.ts @@ -38,6 +38,22 @@ const HOST = "https://eu.i.posthog.com"; */ const SITE_HOST = "lance.blue"; +/** + * Mixed into a DID before it is hashed, so that what PostHog stores is not the + * plain SHA-256 of a public identifier. + * + * Not a secret, and not pretending to be one: it ships in this bundle, so + * anyone holding it and a list of DIDs can rebuild the mapping. What it buys is + * that the identifier in PostHog is inert on its own — it is not the player's + * DID, and it does not match the hash any other site would compute. The version + * that would actually resist someone determined is an opaque id minted by the + * API per account, which is a change to the API rather than to this file. + */ +const ID_SALT = "lance.blue/analytics/v1"; + +/** The tab is on its way back from a provider. See signin-form.ts. */ +const SIGNING_IN = "lance.blue:signing-in"; + /** * Whether this page should report at all. * @@ -129,3 +145,41 @@ export function capture( ): void { ph?.capture(event, properties); } + +/** Salted SHA-256, hex. The DID itself never leaves the browser. */ +async function pseudonym(did: string): Promise { + const bytes = new TextEncoder().encode(`${ID_SALT}:${did}`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * What the masthead learned about the session, which is the only place the + * whole site agrees on it. + * + * Two jobs, and they are deliberately not the same event. Identifying happens + * on every page a signed-in player loads, so that their visits join up instead + * of reading as a new stranger each time. Signing in is counted once, and only + * when this page is the far side of a redirect the player actually started — + * without that mark there is nothing here to tell a fresh sign-in apart from + * the ninth page load of an old session, and the funnel would report a login + * per pageview. + */ +export async function noteSession( + session: { did: string } | null, +): Promise { + if (!ph || !session) return; + + ph.identify(await pseudonym(session.did)); + + let started = false; + try { + started = sessionStorage.getItem(SIGNING_IN) !== null; + sessionStorage.removeItem(SIGNING_IN); + } catch { + /* blocked storage: the sign-in goes uncounted, the page still works */ + } + if (started) capture("signed in"); +} diff --git a/web/src/signin-form.ts b/web/src/signin-form.ts index 35dfe14..7836714 100644 --- a/web/src/signin-form.ts +++ b/web/src/signin-form.ts @@ -145,6 +145,16 @@ export function signInForm({ startLogin(input.value) .then((redirectUrl) => { + // Left for analytics.ts to find when the provider sends the player + // back. Set here rather than on the return, because this is the only + // moment the site knows a sign-in was actually asked for: the far side + // of the redirect is a page load with a session cookie, and that looks + // the same as every later page load of the same session. + try { + sessionStorage.setItem("lance.blue:signing-in", "1"); + } catch { + /* blocked storage: the sign-in goes uncounted, and still works */ + } window.location.assign(redirectUrl); }) .catch((err: unknown) => { -- 2.51.2