From 6b94b68690be19b97d9ff38502013a4074f8df36 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 23 Jun 2026 15:31:38 -0500 Subject: [PATCH] Store device signals as region-keyed map, resolve to current region Persist on-device age signals client-side as a map keyed by country[-region] string, so multiple regions can each retain their own grant. Reads resolve to the user's current region via mergedGeolocation (react-query `select` for the hook, a shared helper for the out-of-band path), so a grant captured in one region never unlocks another. - AgeAssuranceDeviceSignals is now a region-keyed map - setDeviceSignalsForRegion merges a region's signals into the map - useDeviceSignalsQuery select-resolves to the current region; cache keeps the full map for the writer + persistence - getAssuredAgeFromDeviceSignals takes the already-resolved signals Co-Authored-By: Claude Opus 4.8 --- .../components/NoAccessScreen.tsx | 19 ++-- src/ageAssurance/data.tsx | 94 +++++++++++++++---- src/ageAssurance/state.ts | 8 +- src/ageAssurance/types.ts | 17 ++-- src/ageAssurance/util.ts | 48 +++++----- 5 files changed, 113 insertions(+), 73 deletions(-) diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 9bed93872b..de06986cfe 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -34,11 +34,10 @@ import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {useAgeAssurance} from '#/ageAssurance' import { getDeviceSignals, - setDeviceSignalsForDid, + setDeviceSignalsForRegion, useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' -import {type AgeAssuranceDeviceSignals} from '#/ageAssurance/types' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import { getAssuredAgeFromDeviceSignals, @@ -350,18 +349,12 @@ function AccessSection() { const did = currentAccount?.did const signals = await getDeviceSignals() if (signals && did) { - const deviceSignals: AgeAssuranceDeviceSignals = { - signals, - originRegion: { - countryCode: region.countryCode, - regionCode: region.regionCode, - }, - } - const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals) + const assuredAge = getAssuredAgeFromDeviceSignals(region, signals) if (assuredAge !== undefined) { - // Sufficient device signals: persist and let the AA state recompute - // from the cache write unlock access. Nothing else to do here. - setDeviceSignalsForDid({did, deviceSignals}) + // Sufficient device signals: persist (keyed by this region) and let + // the AA state recompute from the cache write unlock access. Nothing + // else to do here. + setDeviceSignalsForRegion({did, region, signals}) return } } diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 40d44ccece..4211642aca 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -30,6 +30,7 @@ import { type AgeAssuranceMetadata, } from '#/ageAssurance/types' import { + createRegionKey, getBirthdateStringFromAge, isLegacyBirthdateBug, } from '#/ageAssurance/util' @@ -518,7 +519,13 @@ export async function getDeviceSignals(): Promise< return undefined } } -export function getDeviceSignalsFromCache({ +/** + * The raw region-keyed map of device signals (all regions). Used internally by + * the query + writer, which operate on the full map. Most consumers want + * {@link getDeviceSignalsFromCacheForCurrentRegion}, which resolves to the + * current region. + */ +export function getDeviceSignalsMapFromCache({ did, }: { did: string @@ -528,23 +535,62 @@ export function getDeviceSignalsFromCache({ ) } /** - * Writes freshly granted device signals into the (persisted) cache, tagged with - * the region they were captured in. Notifies the disabled - * `useDeviceSignalsQuery` observer so the AA state recomputes. + * Resolves a region-keyed signals map down to the signals for the region the + * user is currently in (per `mergedGeolocation`). Returns undefined when we + * have no map, no geolocation, or no stored signals for that region. * - * Device assurance is client-side only (it can't be verified server-side) and - * region-bound — see {@link AgeAssuranceDeviceSignals}. + * Device assurance is region-bound, so we only ever surface the signals + * captured in the user's current region. */ -export function setDeviceSignalsForDid({ +function selectDeviceSignalsForCurrentRegion( + map: AgeAssuranceDeviceSignals | undefined, +): AgeRange.AgeRangeResponse | undefined { + if (!map) return undefined + const geolocation = device.get(['mergedGeolocation']) + if (!geolocation?.countryCode) return undefined + return map[ + createRegionKey({ + countryCode: geolocation.countryCode, + regionCode: geolocation.regionCode, + }) + ] +} +/** + * Returns the device signals for the region the user is currently in, or + * undefined. See {@link selectDeviceSignalsForCurrentRegion}. + */ +export function getDeviceSignalsFromCacheForCurrentRegion({ did, - deviceSignals, }: { did: string - deviceSignals: AgeAssuranceDeviceSignals | undefined +}): AgeRange.AgeRangeResponse | undefined { + return selectDeviceSignalsForCurrentRegion( + getDeviceSignalsMapFromCache({did}), + ) +} +/** + * Stores freshly granted device signals into the (persisted) cache under the + * region they were captured in, merging with any signals already stored for + * other regions. Notifies the disabled `useDeviceSignalsQuery` observer so the + * AA state recomputes. + * + * Device assurance is client-side only (it can't be verified server-side) and + * region-bound — keying by region is what binds it. See + * {@link AgeAssuranceDeviceSignals}. + */ +export function setDeviceSignalsForRegion({ + did, + region, + signals, +}: { + did: string + region: {countryCode: string; regionCode?: string} + signals: AgeRange.AgeRangeResponse }) { + const regionKey = createRegionKey(region) qc.setQueryData( createDeviceSignalsQueryKey({did}), - deviceSignals, + prev => ({...prev, [regionKey]: signals}), ) } export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { @@ -559,9 +605,10 @@ export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { * user can (re)grant access later via the NoAccessScreen verify flow. */ await cacheHydrationPromise - const cached = getDeviceSignalsFromCache({did}) + const cached = getDeviceSignalsMapFromCache({did}) logger.debug( `prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`, + cached, ) } export function useDeviceSignalsQuery() { @@ -573,20 +620,24 @@ export function useDeviceSignalsQuery() { * Disabled so we never auto-call the native age API on load — that would * prompt the OS for every logged-in user. We restore from the persisted * cache (via `initialData`) and otherwise only update reactively when the - * user explicitly verifies (see `getDeviceSignals` + `setDeviceSignalsForDid` - * in the NoAccessScreen verify flow). + * user explicitly verifies (see `getDeviceSignals` + + * `setDeviceSignalsForRegion` in the NoAccessScreen verify flow). * * A future enhancement could silently refresh here when already cached, * since the OS returns the granted result without re-prompting. */ enabled: false, - initialData: getDeviceSignalsFromCache({did: did!}), + initialData: getDeviceSignalsMapFromCache({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}), queryFn() { // Never auto-fetches (see `enabled: false`); the verify flow writes the - // region-tagged record directly via `setDeviceSignalsForDid`. - return getDeviceSignalsFromCache({did: did!}) + // region-keyed signals directly via `setDeviceSignalsForRegion`. + return getDeviceSignalsMapFromCache({did: did!}) }, + // The cache holds the full region-keyed map (the writer merges into it); + // `select` resolves it to the current region for consumers without + // mutating the cached value. + select: selectDeviceSignalsForCurrentRegion, }, qc, ) @@ -634,11 +685,13 @@ export type AgeAssuranceServerData = { state: AppBskyAgeassuranceDefs.State | undefined metadata: AgeAssuranceMetadata | undefined /** - * The native on-device age signals, if the user has granted access, tagged - * with the region they were captured in. Only consumed for regions that - * permit device verification and that match the capture region. + * The native on-device age signals for the region the user is currently in, + * if they've granted access there. Already resolved from the region-keyed + * cache (see `getDeviceSignalsFromCacheForCurrentRegion`), so a grant from + * another region won't appear here. Only consumed for regions that permit + * device verification. */ - deviceSignals: AgeAssuranceDeviceSignals | undefined + deviceSignals: AgeRange.AgeRangeResponse | undefined } const AgeAssuranceServerDataContext = createContext({ config: undefined, @@ -662,6 +715,7 @@ export function AgeAssuranceServerDataProvider({ const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} const {data} = useOtherRequiredDataQuery() + // `select` resolves the cached region-keyed map to the current region. const {data: deviceSignals} = useDeviceSignalsQuery() const ctx = useMemo( () => ({ diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index bb3678f0ca..7d31da2706 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,4 +1,5 @@ import {useEffect, useMemo, useState} from 'react' +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, computeAgeAssuranceRegionAccess, @@ -8,7 +9,7 @@ import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' import { getConfigFromCache, - getDeviceSignalsFromCache, + getDeviceSignalsFromCacheForCurrentRegion, getOtherRequiredDataFromCache, getServerStateFromCache, useAgeAssuranceServerDataContext, @@ -16,7 +17,6 @@ import { import {logger} from '#/ageAssurance/logger' import { AgeAssuranceAccess, - type AgeAssuranceDeviceSignals, type AgeAssuranceMetadata, type AgeAssuranceState, AgeAssuranceStatus, @@ -49,7 +49,7 @@ function computeAgeAssuranceState({ config?: AppBskyAgeassuranceDefs.Config state?: AppBskyAgeassuranceDefs.State metadata?: AgeAssuranceMetadata - deviceSignals?: AgeAssuranceDeviceSignals + deviceSignals?: AgeRange.AgeRangeResponse }) { /** * This is where we control logged-out moderation prefs. It's all @@ -137,7 +137,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { const config = getConfigFromCache() const state = getServerStateFromCache({did}) const requiredData = getOtherRequiredDataFromCache({did}) - const deviceSignals = getDeviceSignalsFromCache({did}) + const deviceSignals = getDeviceSignalsFromCacheForCurrentRegion({did}) const geolocation = device.get(['mergedGeolocation']) if (!geolocation || !config || !state || !requiredData) { diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index 92ad5d2d77..50ef68eaa2 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -29,20 +29,19 @@ export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & { } /** - * The on-device age signals plus the region they were captured in. + * Native on-device age signals, keyed by the region (a `country[-region]` + * string, see `createRegionKey`) they were captured in. We keep one entry per + * region so multiple regions with differing criteria can each retain their own + * grant. * * Device assurance can't be verified server-side (the OS gives us no signed * attestation, only age bounds), so we persist it client-side only and bind it - * to its origin region. The signals are only honored when the user's current - * region matches `originRegion` — a grant captured in TX must not silently - * unlock another region. See `getAssuredAgeFromDeviceSignals`. + * to its capture region via the key. A grant captured in TX is only ever read + * back for TX — it can't silently unlock another region. See + * `getAssuredAgeFromDeviceSignals`. */ export type AgeAssuranceDeviceSignals = { - signals: AgeRange.AgeRangeResponse - originRegion: { - countryCode: string - regionCode?: string - } + [regionKey: string]: AgeRange.AgeRangeResponse } export enum AgeAssuranceAccess { diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index 85ab0f8706..2292c630cf 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -1,4 +1,5 @@ import {useMemo} from 'react' +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, getAgeAssuranceRegionConfig, @@ -12,7 +13,6 @@ import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import { AgeAssuranceAccess, type AgeAssuranceConfigRegion, - type AgeAssuranceDeviceSignals, type AgeAssuranceFlags, type AgeAssuranceMetadata, type AgeAssuranceState, @@ -64,26 +64,29 @@ export function regionAllowsDeviceVerification( } /** - * Whether two regions refer to the same country + region. Used to ensure device - * signals are only applied within the region they were captured in. + * Builds the cache key for a region's device signals — a `country[-region]` + * string (e.g. `US-TX` or `GB`). This is the key under which on-device + * assurance is stored and read back, which is what binds a grant to its capture + * region. */ -function isSameRegion( - a: {countryCode: string; regionCode?: string}, - b: {countryCode: string; regionCode?: string}, -): boolean { - return a.countryCode === b.countryCode && a.regionCode === b.regionCode +export function createRegionKey(region: { + countryCode: string + regionCode?: string +}): string { + return region.regionCode + ? `${region.countryCode}-${region.regionCode}` + : region.countryCode } /** - * Derives an assured age from native device signals, but only when: + * Derives an assured age from native device signals for the given region, but + * only when the region permits device verification. The signals are expected to + * already be resolved to the user's current region (see + * `getDeviceSignalsFromCacheForCurrentRegion`), so a grant captured in another + * region won't reach here. * - * 1. the current region permits device verification, and - * 2. the signals were captured in this same region. - * - * Device assurance is region-bound (see {@link AgeAssuranceDeviceSignals}): a - * grant captured in TX must not unlock another region. The OS-provided - * `lowerBound` is the minimum age the platform will attest to, which maps - * directly onto the `assuredAge` input of the rule engine (i.e. + * The OS-provided `lowerBound` is the minimum age the platform will attest to, + * which maps directly onto the `assuredAge` input of the rule engine (i.e. * `IfAssuredOverAge`/`IfAssuredUnderAge` rules). * * Returns undefined when device verification doesn't apply or the OS didn't @@ -91,19 +94,10 @@ function isSameRegion( */ export function getAssuredAgeFromDeviceSignals( region: AppBskyAgeassuranceDefs.ConfigRegion, - deviceSignals: AgeAssuranceDeviceSignals | undefined, + deviceSignals: AgeRange.AgeRangeResponse | undefined, ): number | undefined { if (!regionAllowsDeviceVerification(region)) return undefined - if (!deviceSignals) return undefined - if ( - !isSameRegion(deviceSignals.originRegion, { - countryCode: region.countryCode, - regionCode: region.regionCode, - }) - ) { - return undefined - } - const lowerBound = deviceSignals.signals.lowerBound + const lowerBound = deviceSignals?.lowerBound return typeof lowerBound === 'number' ? lowerBound : undefined }