Skip to content

feat(pam): capture and expose a player timezone from the browser - #135

Open
mp-blurify wants to merge 3 commits into
devfrom
feat/player-timezone
Open

feat(pam): capture and expose a player timezone from the browser#135
mp-blurify wants to merge 3 commits into
devfrom
feat/player-timezone

Conversation

@mp-blurify

@mp-blurify mp-blurify commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Captures the IANA timezone the browser reports and persists it on the player row, refreshing
it 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: country is self-declared at
registration and large countries span several zones, and registrationIp is VPN-defeated and
less 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, phoneLoginVerify and verifyEmail — so extending
LoginInputSchema alone would have covered a quarter of the cases. verifyEmail is easy to
miss because it looks like an email chore, but it is where better-auth's
autoSignInAfterVerification mints a new player's first session, and it emits
identity.user.login like the rest. register is the fifth: it mints no session, but the
browser 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.update is the sixth and the one that makes the feature
useful 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.

timezoneUpdatedAt is stamped on every capture the tz database accepts, including one that
repeats 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 Intl gives
it nothing: the contract accepts it and the write drops it, rather than 400ing the login that
carried it.

Alternatives considered

  • Deriving the zone from country. Rejected: self-declared, and a single country routinely
    spans several zones, so the answer is wrong for a large share of players by construction.
  • IP geolocation. Rejected: costs a provider, is defeated by any VPN, and is still less
    accurate than the device that already knows.
  • Attaching the zone to every request. Rejected: spoofable per request, and it invites using
    a request-scoped value to drive a stored decision.
  • Riding PLAYER_ACTIVITY_TRACKER.touchLastSeen, which already fires on every authenticated
    request and is a ready-made seam. Rejected because using it would require the zone on every
    request — exactly the thing ruled out above.
  • Storing the raw client string. Rejected: resolveTimezone canonicalises against the
    runtime'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.
  • Moving timezoneUpdatedAt only when the zone changes. Rejected: it saves one write on a
    row an authenticated request touches anyway, and costs the column its only useful reading —
    see above.
  • Omitting timezoneUpdatedAt from 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

  • Port change. PlayerProvisioning gains a required recordTimezone. An overlay that
    rebinds PLAYER_PROVISIONING with its own implementation will fail to compile until it adds
    the method. Deliberate over an optional member: a silently-missing implementation would look
    like a working capture that never writes.
  • Migration. Two nullable ADD COLUMNs with no backfill and no index — additive, no rewrite,
    safe to roll back by ignoring the columns. An uncaptured player reads null rather than a
    guess, so the read model gains a value consumers must handle as absent.
  • Silent by design. A zone the tz database does not recognise is dropped with no feedback to
    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.
  • Deferred work. The platform half is inert until consumers send the zone: no client in this
    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.
  • Scope discipline. This must not become an input to responsible-gambling evaluation or any
    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:

  • verifyEmail carried no timezone and never captured, despite minting the first session.
    It now does, after the account gate and before the 2FA branch.
  • TimezoneSchema lost its .min(1): an empty string was failing contract validation, so a
    client-side Intl fallback could 400 a login. It now reaches the same silent no-op as any
    other unrecognised zone.
  • timezoneUpdatedAt refreshes on every accepted capture rather than only on a change, and the
    column is documented as last confirmed in the schema, the read model and the service.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SyLqsaeoprPoPXehsU5BbR

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
Comment thread packages/core/src/contracts/schemas/identity.ts
Comment thread packages/core/src/contracts/schemas/common.ts Outdated
Comment thread packages/core/src/pam/profile/service/profile.service.ts Outdated
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', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the test? xD

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please remove all unnecessary comments from the code?

@klaudia-blazyczek-blurify

Copy link
Copy Markdown
Collaborator

apart from Volods comments lgtm

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants