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 <noreply@anthropic.com>
This commit is contained in:
@@ -34,11 +34,10 @@ import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
|||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {
|
import {
|
||||||
getDeviceSignals,
|
getDeviceSignals,
|
||||||
setDeviceSignalsForDid,
|
setDeviceSignalsForRegion,
|
||||||
useAgeAssuranceServerDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {type AgeAssuranceDeviceSignals} from '#/ageAssurance/types'
|
|
||||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
import {
|
import {
|
||||||
getAssuredAgeFromDeviceSignals,
|
getAssuredAgeFromDeviceSignals,
|
||||||
@@ -350,18 +349,12 @@ function AccessSection() {
|
|||||||
const did = currentAccount?.did
|
const did = currentAccount?.did
|
||||||
const signals = await getDeviceSignals()
|
const signals = await getDeviceSignals()
|
||||||
if (signals && did) {
|
if (signals && did) {
|
||||||
const deviceSignals: AgeAssuranceDeviceSignals = {
|
const assuredAge = getAssuredAgeFromDeviceSignals(region, signals)
|
||||||
signals,
|
|
||||||
originRegion: {
|
|
||||||
countryCode: region.countryCode,
|
|
||||||
regionCode: region.regionCode,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals)
|
|
||||||
if (assuredAge !== undefined) {
|
if (assuredAge !== undefined) {
|
||||||
// Sufficient device signals: persist and let the AA state recompute
|
// Sufficient device signals: persist (keyed by this region) and let
|
||||||
// from the cache write unlock access. Nothing else to do here.
|
// the AA state recompute from the cache write unlock access. Nothing
|
||||||
setDeviceSignalsForDid({did, deviceSignals})
|
// else to do here.
|
||||||
|
setDeviceSignalsForRegion({did, region, signals})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-20
@@ -30,6 +30,7 @@ import {
|
|||||||
type AgeAssuranceMetadata,
|
type AgeAssuranceMetadata,
|
||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {
|
import {
|
||||||
|
createRegionKey,
|
||||||
getBirthdateStringFromAge,
|
getBirthdateStringFromAge,
|
||||||
isLegacyBirthdateBug,
|
isLegacyBirthdateBug,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
@@ -518,7 +519,13 @@ export async function getDeviceSignals(): Promise<
|
|||||||
return undefined
|
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,
|
||||||
}: {
|
}: {
|
||||||
did: string
|
did: string
|
||||||
@@ -528,23 +535,62 @@ export function getDeviceSignalsFromCache({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Writes freshly granted device signals into the (persisted) cache, tagged with
|
* Resolves a region-keyed signals map down to the signals for the region the
|
||||||
* the region they were captured in. Notifies the disabled
|
* user is currently in (per `mergedGeolocation`). Returns undefined when we
|
||||||
* `useDeviceSignalsQuery` observer so the AA state recomputes.
|
* 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
|
* Device assurance is region-bound, so we only ever surface the signals
|
||||||
* region-bound — see {@link AgeAssuranceDeviceSignals}.
|
* 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,
|
did,
|
||||||
deviceSignals,
|
|
||||||
}: {
|
}: {
|
||||||
did: string
|
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<AgeAssuranceDeviceSignals | undefined>(
|
qc.setQueryData<AgeAssuranceDeviceSignals | undefined>(
|
||||||
createDeviceSignalsQueryKey({did}),
|
createDeviceSignalsQueryKey({did}),
|
||||||
deviceSignals,
|
prev => ({...prev, [regionKey]: signals}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
|
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.
|
* user can (re)grant access later via the NoAccessScreen verify flow.
|
||||||
*/
|
*/
|
||||||
await cacheHydrationPromise
|
await cacheHydrationPromise
|
||||||
const cached = getDeviceSignalsFromCache({did})
|
const cached = getDeviceSignalsMapFromCache({did})
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
|
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
|
||||||
|
cached,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export function useDeviceSignalsQuery() {
|
export function useDeviceSignalsQuery() {
|
||||||
@@ -573,20 +620,24 @@ export function useDeviceSignalsQuery() {
|
|||||||
* Disabled so we never auto-call the native age API on load — that would
|
* 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
|
* prompt the OS for every logged-in user. We restore from the persisted
|
||||||
* cache (via `initialData`) and otherwise only update reactively when the
|
* cache (via `initialData`) and otherwise only update reactively when the
|
||||||
* user explicitly verifies (see `getDeviceSignals` + `setDeviceSignalsForDid`
|
* user explicitly verifies (see `getDeviceSignals` +
|
||||||
* in the NoAccessScreen verify flow).
|
* `setDeviceSignalsForRegion` in the NoAccessScreen verify flow).
|
||||||
*
|
*
|
||||||
* A future enhancement could silently refresh here when already cached,
|
* A future enhancement could silently refresh here when already cached,
|
||||||
* since the OS returns the granted result without re-prompting.
|
* since the OS returns the granted result without re-prompting.
|
||||||
*/
|
*/
|
||||||
enabled: false,
|
enabled: false,
|
||||||
initialData: getDeviceSignalsFromCache({did: did!}),
|
initialData: getDeviceSignalsMapFromCache({did: did!}),
|
||||||
queryKey: createDeviceSignalsQueryKey({did: did!}),
|
queryKey: createDeviceSignalsQueryKey({did: did!}),
|
||||||
queryFn() {
|
queryFn() {
|
||||||
// Never auto-fetches (see `enabled: false`); the verify flow writes the
|
// Never auto-fetches (see `enabled: false`); the verify flow writes the
|
||||||
// region-tagged record directly via `setDeviceSignalsForDid`.
|
// region-keyed signals directly via `setDeviceSignalsForRegion`.
|
||||||
return getDeviceSignalsFromCache({did: did!})
|
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,
|
qc,
|
||||||
)
|
)
|
||||||
@@ -634,11 +685,13 @@ export type AgeAssuranceServerData = {
|
|||||||
state: AppBskyAgeassuranceDefs.State | undefined
|
state: AppBskyAgeassuranceDefs.State | undefined
|
||||||
metadata: AgeAssuranceMetadata | undefined
|
metadata: AgeAssuranceMetadata | undefined
|
||||||
/**
|
/**
|
||||||
* The native on-device age signals, if the user has granted access, tagged
|
* The native on-device age signals for the region the user is currently in,
|
||||||
* with the region they were captured in. Only consumed for regions that
|
* if they've granted access there. Already resolved from the region-keyed
|
||||||
* permit device verification and that match the capture region.
|
* 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<AgeAssuranceServerData>({
|
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||||
config: undefined,
|
config: undefined,
|
||||||
@@ -662,6 +715,7 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
const serverState = useServerStateQuery()
|
const serverState = useServerStateQuery()
|
||||||
const {state, metadata} = serverState.data || {}
|
const {state, metadata} = serverState.data || {}
|
||||||
const {data} = useOtherRequiredDataQuery()
|
const {data} = useOtherRequiredDataQuery()
|
||||||
|
// `select` resolves the cached region-keyed map to the current region.
|
||||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react'
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
|
import type * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
computeAgeAssuranceRegionAccess,
|
computeAgeAssuranceRegionAccess,
|
||||||
@@ -8,7 +9,7 @@ import {getAge} from '#/lib/strings/time'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
getConfigFromCache,
|
getConfigFromCache,
|
||||||
getDeviceSignalsFromCache,
|
getDeviceSignalsFromCacheForCurrentRegion,
|
||||||
getOtherRequiredDataFromCache,
|
getOtherRequiredDataFromCache,
|
||||||
getServerStateFromCache,
|
getServerStateFromCache,
|
||||||
useAgeAssuranceServerDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
type AgeAssuranceDeviceSignals,
|
|
||||||
type AgeAssuranceMetadata,
|
type AgeAssuranceMetadata,
|
||||||
type AgeAssuranceState,
|
type AgeAssuranceState,
|
||||||
AgeAssuranceStatus,
|
AgeAssuranceStatus,
|
||||||
@@ -49,7 +49,7 @@ function computeAgeAssuranceState({
|
|||||||
config?: AppBskyAgeassuranceDefs.Config
|
config?: AppBskyAgeassuranceDefs.Config
|
||||||
state?: AppBskyAgeassuranceDefs.State
|
state?: AppBskyAgeassuranceDefs.State
|
||||||
metadata?: AgeAssuranceMetadata
|
metadata?: AgeAssuranceMetadata
|
||||||
deviceSignals?: AgeAssuranceDeviceSignals
|
deviceSignals?: AgeRange.AgeRangeResponse
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* This is where we control logged-out moderation prefs. It's all
|
* 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 config = getConfigFromCache()
|
||||||
const state = getServerStateFromCache({did})
|
const state = getServerStateFromCache({did})
|
||||||
const requiredData = getOtherRequiredDataFromCache({did})
|
const requiredData = getOtherRequiredDataFromCache({did})
|
||||||
const deviceSignals = getDeviceSignalsFromCache({did})
|
const deviceSignals = getDeviceSignalsFromCacheForCurrentRegion({did})
|
||||||
const geolocation = device.get(['mergedGeolocation'])
|
const geolocation = device.get(['mergedGeolocation'])
|
||||||
|
|
||||||
if (!geolocation || !config || !state || !requiredData) {
|
if (!geolocation || !config || !state || !requiredData) {
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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
|
* to its capture region via the key. A grant captured in TX is only ever read
|
||||||
* region matches `originRegion` — a grant captured in TX must not silently
|
* back for TX — it can't silently unlock another region. See
|
||||||
* unlock another region. See `getAssuredAgeFromDeviceSignals`.
|
* `getAssuredAgeFromDeviceSignals`.
|
||||||
*/
|
*/
|
||||||
export type AgeAssuranceDeviceSignals = {
|
export type AgeAssuranceDeviceSignals = {
|
||||||
signals: AgeRange.AgeRangeResponse
|
[regionKey: string]: AgeRange.AgeRangeResponse
|
||||||
originRegion: {
|
|
||||||
countryCode: string
|
|
||||||
regionCode?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AgeAssuranceAccess {
|
export enum AgeAssuranceAccess {
|
||||||
|
|||||||
+21
-27
@@ -1,4 +1,5 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
|
import type * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
getAgeAssuranceRegionConfig,
|
getAgeAssuranceRegionConfig,
|
||||||
@@ -12,7 +13,6 @@ import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
|||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
type AgeAssuranceConfigRegion,
|
type AgeAssuranceConfigRegion,
|
||||||
type AgeAssuranceDeviceSignals,
|
|
||||||
type AgeAssuranceFlags,
|
type AgeAssuranceFlags,
|
||||||
type AgeAssuranceMetadata,
|
type AgeAssuranceMetadata,
|
||||||
type AgeAssuranceState,
|
type AgeAssuranceState,
|
||||||
@@ -64,26 +64,29 @@ export function regionAllowsDeviceVerification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether two regions refer to the same country + region. Used to ensure device
|
* Builds the cache key for a region's device signals — a `country[-region]`
|
||||||
* signals are only applied within the region they were captured in.
|
* 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(
|
export function createRegionKey(region: {
|
||||||
a: {countryCode: string; regionCode?: string},
|
countryCode: string
|
||||||
b: {countryCode: string; regionCode?: string},
|
regionCode?: string
|
||||||
): boolean {
|
}): string {
|
||||||
return a.countryCode === b.countryCode && a.regionCode === b.regionCode
|
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
|
* The OS-provided `lowerBound` is the minimum age the platform will attest to,
|
||||||
* 2. the signals were captured in this same region.
|
* which maps directly onto the `assuredAge` input of the rule engine (i.e.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
* `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
|
* `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
|
||||||
*
|
*
|
||||||
* Returns undefined when device verification doesn't apply or the OS didn't
|
* Returns undefined when device verification doesn't apply or the OS didn't
|
||||||
@@ -91,19 +94,10 @@ function isSameRegion(
|
|||||||
*/
|
*/
|
||||||
export function getAssuredAgeFromDeviceSignals(
|
export function getAssuredAgeFromDeviceSignals(
|
||||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||||
deviceSignals: AgeAssuranceDeviceSignals | undefined,
|
deviceSignals: AgeRange.AgeRangeResponse | undefined,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (!regionAllowsDeviceVerification(region)) return undefined
|
if (!regionAllowsDeviceVerification(region)) return undefined
|
||||||
if (!deviceSignals) return undefined
|
const lowerBound = deviceSignals?.lowerBound
|
||||||
if (
|
|
||||||
!isSameRegion(deviceSignals.originRegion, {
|
|
||||||
countryCode: region.countryCode,
|
|
||||||
regionCode: region.regionCode,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const lowerBound = deviceSignals.signals.lowerBound
|
|
||||||
return typeof lowerBound === 'number' ? lowerBound : undefined
|
return typeof lowerBound === 'number' ? lowerBound : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user