feat(pam): capture and expose a player timezone from the browser - #135
feat(pam): capture and expose a player timezone from the browser#135mp-blurify wants to merge 3 commits into
Conversation
BF-532 The backoffice can render a stored UTC timestamp on the player's own clock only if the platform knows their zone. `country` is self-declared and large countries span several zones; `registrationIp` is VPN-defeated and less accurate than the device. So take the IANA zone the browser already resolves (`Intl.DateTimeFormat().resolvedOptions().timeZone`) and store it. - `player.timezone` + `player.timezone_updated_at`, both nullable with no backfill: an uncaptured player is honestly unknown, never guessed. - `resolveTimezone` canonicalises against the runtime's own tz database and drops anything it does not recognise, plus bare UTC offsets, so the column holds a zone name rather than arbitrary client text. - `ProfileService.recordTimezone` writes both columns in one statement whose WHERE skips an unchanged zone, so a remembered browser signing in for months does not churn `timezone_updated_at`. - Captured on all three session-establishing routes (login, verify2fa, phoneLoginVerify), on register, and on `profile.update` - the last because a remembered session skips login for weeks and no existing player re-authenticates just because a column appeared. Always after the auth succeeds, and never able to fail the request that carried it. - `PlayerSchema` gains `timezone` and `timezoneUpdatedAt`; one addition serves both the self-service and admin read paths. Display metadata only. The value is device-reported and spoofable, so it gates nothing and stays out of responsible-gambling windows and audit records, which remain timezone-free and UTC on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtKNx6FeNDS3B4ZPA6S4QQ
45df534 to
f99bae2
Compare
BF-532 Three review findings, all on the same feature. `verifyEmail` is a fourth session-establishing route, not a bystander: it is where better-auth's `autoSignInAfterVerification` mints the first session and it emits `identity.user.login` like the rest. It now carries `timezone` and captures after the account gate, before the 2FA branch - the emailed code is proof of address ownership, and a player verifying on a different device than they signed up on would otherwise never be captured there. The earlier claim that login, verify2fa and phoneLoginVerify were "all three" was wrong. `TimezoneSchema` loses `.min(1)`. A client falling back to `''` when `Intl` gives it nothing was failing contract validation, so a cosmetic column could 400 a login - exactly what the feature promises it can never do. Empty now reaches `resolveTimezone`'s silent no-op like any other unrecognised zone. The upper bound stays: past twice the longest real zone name it is not a zone the platform failed to recognise, it is arbitrary client text. `timezoneUpdatedAt` moves on every accepted capture, including one repeating the stored zone. "Last changed" made a player signing in daily from one zone for months read identically to one not seen since - and telling those apart is the only reason the read model ships the timestamp beside the zone. The write lands on a row an authenticated request touches anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyLqsaeoprPoPXehsU5BbR
| }); | ||
|
|
||
| describe('resolveTimezone', () => { | ||
| it('accepts an IANA zone the browser reports and returns it unchanged', () => { |
There was a problem hiding this comment.
What is the purpose of the test? xD
zaxovaiko
left a comment
There was a problem hiding this comment.
Could you please remove all unnecessary comments from the code?
|
apart from Volods comments lgtm |
zaxovaiko
left a comment
There was a problem hiding this comment.
verify is red on notifications.router.int.test.ts (the stream test timing out), which looks unrelated to this branch. Worth a re-run anyway - check:drift only runs after the tests in pnpm verify, so it never actually executed here.
|
|
||
| const [second] = await playersFor(account.id); | ||
| expect(second?.timezone).toBe('Europe/Warsaw'); | ||
| expect(second?.timezoneUpdatedAt?.getTime()).toBeGreaterThanOrEqual( |
There was a problem hiding this comment.
Hm, this one passes with the old behaviour too - if the write gets skipped then second just equals first and >= is still true, so the test can't fail on the thing it's here to catch. Let's use toBeGreaterThan. Same in __tests__/player-timezone.e2e.test.ts.
| // is logged and swallowed - it must never cost the player the session they just earned. | ||
| if (timezone && this.playerProvisioning) { | ||
| try { | ||
| await this.playerProvisioning.recordTimezone(account.id, timezone); |
There was a problem hiding this comment.
This is a copy of captureTimezone from service/identity.service.ts - same guard, same try/catch, same log string. Can we please extract it once and call it from both? Two copies will drift the moment one of them changes.
| // The IANA zone the player's browser last reported, for rendering a stored UTC timestamp | ||
| // on their own clock. Null until a session captures one - an uncaptured player is | ||
| // honestly unknown, never guessed from `country` or `registrationIp`. | ||
| timezone: z.string().nullable(), |
There was a problem hiding this comment.
Let's use TimezoneSchema.nullable() here - it's already imported in this file for UpdatePlayerProfileInputSchema, so the read model and the input can share the one source instead of a bare z.string().
| * evidence of where a player is and must never gate anything - responsible-gambling windows | ||
| * and audit records stay UTC on purpose. | ||
| */ | ||
| export const TimezoneSchema = z.string().max(64); |
There was a problem hiding this comment.
I was wondering about the asymmetry here - '' now goes through to the silent no-op, but 65 characters still 400s the login. resolveTimezone rejects anything the tz database doesn't know anyway, so do we need .max() at all? Dropping it would make the "a cosmetic column never costs a session" promise unconditional.
| } | ||
|
|
||
| /** | ||
| * Stores the IANA zone the browser reported, for rendering a stored UTC timestamp on the |
There was a problem hiding this comment.
This is the spot I meant about the comments - most of the block is the PR description restated. Let's keep "display metadata, never gates anything" and the reason for the offset guard in resolveTimezone, and drop the rest. TimezoneSchema in schemas/common.ts is the same.
Summary
Captures the IANA timezone the browser reports and persists it on the
playerrow, refreshingit on every session-establishing route, and exposes it on the shared player read model so
consumers can render a stored UTC timestamp on the player's own clock. BF-532
Why
Nothing in the platform stored a player timezone, so a consumer's backoffice could show a chat
message's UTC timestamp but not what time it was for the player who sent it. The two columns
that look like they could answer are not honest sources:
countryis self-declared atregistration and large countries span several zones, and
registrationIpis VPN-defeated andless accurate than the device itself. The browser already resolves the right answer via
Intl.DateTimeFormat().resolvedOptions().timeZone, so the platform takes that and stores it.Capture is deliberately spread across six routes rather than one. Four routes establish a
session, not one —
login,verify2fa,phoneLoginVerifyandverifyEmail— so extendingLoginInputSchemaalone would have covered a quarter of the cases.verifyEmailis easy tomiss because it looks like an email chore, but it is where better-auth's
autoSignInAfterVerificationmints a new player's first session, and it emitsidentity.user.loginlike the rest.registeris the fifth: it mints no session, but thebrowser is present at sign-up, so the zone is captured then rather than waiting for a first
login that may be days away — and sign-up and verification can happen on different devices, so
neither one covers the other.
profile.updateis the sixth and the one that makes the featureuseful on day one: a remembered session skips login for weeks, and no existing player
re-authenticates just because a column appeared, so a login-only capture would leave most of the
book reading
-for a long time.timezoneUpdatedAtis stamped on every capture the tz database accepts, including one thatrepeats the zone already stored, so it reads as last confirmed by a device. The alternative —
moving it only on a change — makes a player signing in daily from one zone for months
indistinguishable from one not seen since, which is precisely the distinction the read model
ships the timestamp to make.
The value is display metadata and is treated as such throughout. It is device-reported and
trivially spoofable, so it gates nothing, is never evidence of where a player is, and stays out
of responsible-gambling windows and audit records, which remain timezone-free and UTC on purpose.
An unrecognised zone is a silent no-op, never a request failure — a cosmetic column must not cost
a player their session. That includes the empty string a client falls back to when
Intlgivesit nothing: the contract accepts it and the write drops it, rather than 400ing the login that
carried it.
Alternatives considered
country. Rejected: self-declared, and a single country routinelyspans several zones, so the answer is wrong for a large share of players by construction.
accurate than the device that already knows.
a request-scoped value to drive a stored decision.
PLAYER_ACTIVITY_TRACKER.touchLastSeen, which already fires on every authenticatedrequest and is a ready-made seam. Rejected because using it would require the zone on every
request — exactly the thing ruled out above.
resolveTimezonecanonicalises against theruntime's own tz database instead, so the column holds a zone name rather than arbitrary client
text, and the aliases and casings different browsers report (
US/Pacific,europe/warsaw)land on one stored spelling instead of flip-flopping between them.
timezoneUpdatedAtonly when the zone changes. Rejected: it saves one write on arow an authenticated request touches anyway, and costs the column its only useful reading —
see above.
timezoneUpdatedAtfrom the read model. Rejected: the zone is a point-in-time,device-reported guess, so a player who moved — or whose last session was months ago — renders a
plausible but wrong local time with nothing to signal it. Shipping the value without its
staleness marker hands the reader confident-looking wrong data.
Risks
PlayerProvisioninggains a requiredrecordTimezone. An overlay thatrebinds
PLAYER_PROVISIONINGwith its own implementation will fail to compile until it addsthe method. Deliberate over an optional member: a silently-missing implementation would look
like a working capture that never writes.
ADD COLUMNs with no backfill and no index — additive, no rewrite,safe to roll back by ignoring the columns. An uncaptured player reads
nullrather than aguess, so the read model gains a value consumers must handle as absent.
the caller, and a capture failure is logged and swallowed. That is the intended trade — it
guarantees the feature can never fail an auth flow — but it means a client sending a
consistently bad value sees success and stores nothing. The warn-level log is the only signal.
The contract keeps one hard bound, a 64-character ceiling: past twice the longest real zone
name the value is not a zone the platform failed to recognise, it is arbitrary client text.
repo populates it, so the columns stay null on an upgrade alone. The consumer-side change
(sending the browser zone on login and on the post-auth profile write, and rendering the
column) is out of scope here and lands separately after a version bump.
session or limit window; making RG timezone-aware is a licence problem, not a feature. Nothing
under
compliance/was touched.Review follow-ups
Three findings from review, all fixed in
fix(pam): close the timezone capture gaps found in review:verifyEmailcarried notimezoneand never captured, despite minting the first session.It now does, after the account gate and before the 2FA branch.
TimezoneSchemalost its.min(1): an empty string was failing contract validation, so aclient-side
Intlfallback could 400 a login. It now reaches the same silent no-op as anyother unrecognised zone.
timezoneUpdatedAtrefreshes on every accepted capture rather than only on a change, and thecolumn is documented as last confirmed in the schema, the read model and the service.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SyLqsaeoprPoPXehsU5BbR