@reclaimprotocol/client 0.1.29 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +255 -234
  2. package/lib/api.d.ts +2 -0
  3. package/lib/api.js +2 -0
  4. package/lib/client.js +2 -0
  5. package/lib/enckeys.d.ts +1 -1
  6. package/lib/enckeys.js +2 -2
  7. package/lib/encoding.d.ts +4 -0
  8. package/lib/encoding.js +10 -0
  9. package/lib/feature-flags.d.ts +9 -0
  10. package/lib/feature-flags.js +19 -0
  11. package/lib/index.d.ts +4 -4
  12. package/lib/index.js +4 -4
  13. package/lib/jwks.d.ts +8 -8
  14. package/lib/jwks.js +38 -17
  15. package/lib/launch/destination.d.ts +14 -0
  16. package/lib/launch/destination.js +89 -0
  17. package/lib/launch/device.d.ts +30 -0
  18. package/lib/launch/device.js +168 -0
  19. package/lib/launch/environment.d.ts +224 -0
  20. package/lib/launch/environment.js +98 -0
  21. package/lib/launch/errors.d.ts +20 -0
  22. package/lib/launch/errors.js +25 -0
  23. package/lib/launch/extension.d.ts +42 -0
  24. package/lib/launch/extension.js +72 -0
  25. package/lib/launch/flow.d.ts +82 -0
  26. package/lib/launch/flow.js +222 -0
  27. package/lib/launch/index.d.ts +16 -0
  28. package/lib/launch/index.js +12 -0
  29. package/lib/launch/mobile.d.ts +77 -0
  30. package/lib/launch/mobile.js +124 -0
  31. package/lib/launch/modal.d.ts +50 -0
  32. package/lib/launch/modal.js +258 -0
  33. package/lib/launch/options.d.ts +74 -0
  34. package/lib/launch/options.js +20 -0
  35. package/lib/launch/portal.d.ts +28 -0
  36. package/lib/launch/portal.js +68 -0
  37. package/lib/launch/qr.d.ts +5 -0
  38. package/lib/launch/qr.js +28 -0
  39. package/lib/launch/target.d.ts +46 -0
  40. package/lib/launch/target.js +128 -0
  41. package/lib/launch/vendor/qrcodegen.d.ts +134 -0
  42. package/lib/launch/vendor/qrcodegen.js +822 -0
  43. package/lib/legacy-proof.d.ts +2 -0
  44. package/lib/legacy-proof.js +37 -7
  45. package/lib/proof-hash.d.ts +5 -5
  46. package/lib/proof-hash.js +1 -1
  47. package/lib/proof-recipe.d.ts +3 -3
  48. package/lib/proof-recipe.js +1 -1
  49. package/lib/proof-types.d.ts +35 -8
  50. package/lib/reclaim.d.ts +81 -70
  51. package/lib/reclaim.js +79 -130
  52. package/lib/request-proof.d.ts +60 -0
  53. package/lib/request-proof.js +241 -0
  54. package/lib/result.d.ts +46 -38
  55. package/lib/result.js +160 -100
  56. package/lib/routes.gen.d.ts +76 -28
  57. package/lib/routes.gen.js +24 -11
  58. package/lib/tee-binding.d.ts +1 -1
  59. package/lib/types/openapi.d.ts +1 -0
  60. package/lib/types/openapi.gen.d.ts +1598 -485
  61. package/lib/types/openapi.gen.js +5 -0
  62. package/lib/verification-clients.d.ts +28 -14
  63. package/lib/verification-clients.js +31 -11
  64. package/lib/verify-attestor-tee.d.ts +19 -2
  65. package/lib/verify-attestor-tee.js +40 -4
  66. package/lib/verify-tee.d.ts +10 -1
  67. package/lib/verify-tee.js +34 -10
  68. package/package.json +5 -1
  69. package/src/api.ts +9 -0
  70. package/src/client.ts +2 -0
  71. package/src/enckeys.ts +2 -2
  72. package/src/encoding.ts +12 -0
  73. package/src/feature-flags.ts +39 -0
  74. package/src/index.ts +13 -14
  75. package/src/jwks.ts +52 -30
  76. package/src/launch/destination.ts +111 -0
  77. package/src/launch/device.ts +242 -0
  78. package/src/launch/environment.ts +337 -0
  79. package/src/launch/errors.ts +49 -0
  80. package/src/launch/extension.ts +124 -0
  81. package/src/launch/flow.ts +369 -0
  82. package/src/launch/index.ts +42 -0
  83. package/src/launch/mobile.ts +184 -0
  84. package/src/launch/modal.ts +378 -0
  85. package/src/launch/options.ts +99 -0
  86. package/src/launch/portal.ts +104 -0
  87. package/src/launch/qr.ts +37 -0
  88. package/src/launch/target.ts +150 -0
  89. package/src/launch/vendor/qrcodegen.ts +1034 -0
  90. package/src/proof-hash.ts +6 -6
  91. package/src/proof-recipe.ts +3 -3
  92. package/src/proof-types.ts +36 -8
  93. package/src/reclaim.ts +185 -218
  94. package/src/{legacy-proof.ts → request-proof.ts} +81 -36
  95. package/src/result.ts +238 -172
  96. package/src/routes.gen.ts +24 -11
  97. package/src/tee-binding.ts +1 -1
  98. package/src/types/openapi.gen.ts +1603 -485
  99. package/src/types/openapi.ts +3 -1
  100. package/src/verification-clients.ts +33 -19
  101. package/src/verify-attestor-tee.ts +66 -8
  102. package/src/verify-tee.ts +44 -13
  103. package/lib/api-client.d.ts +0 -31
  104. package/lib/api-client.js +0 -112
  105. package/lib/api-consts.d.ts +0 -4
  106. package/lib/api-consts.js +0 -4
  107. package/lib/api-errors.d.ts +0 -13
  108. package/lib/api-errors.js +0 -30
  109. package/lib/eip712.d.ts +0 -20
  110. package/lib/eip712.js +0 -108
package/src/jwks.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * JWKS (RFC 7517) discovery for verifying result/proof signatures. Signers
3
- * (the Verification Client, the Attestor) publish their secp256k1 public keys
3
+ * (Builder, the Attestor) publish their secp256k1 public keys
4
4
  * at `<iss>/.well-known/jwks.json` with `kid` = the signer's ETH address.
5
5
  */
6
- import { b64url, base64urlToBytes, bytesToHex } from './encoding.ts'
6
+ import {
7
+ b64url,
8
+ base64urlToBytes,
9
+ bytesToHex,
10
+ stripTrailingSlashes,
11
+ } from './encoding.ts'
7
12
  import { addressFromUncompressedPubkey, secp } from './secp.ts'
8
13
 
9
14
  export interface Jwk {
@@ -17,10 +22,6 @@ export interface Jwk {
17
22
  key_ops?: string[]
18
23
  /** Restricts how Builder consumers may use this key. */
19
24
  'x-reclaim-purpose'?: 'verification-result' | 'attestor-proof'
20
- /** Verification Client that owns a result-signing key. */
21
- 'x-reclaim-verification-client-id'?: string
22
- /** Verification Clients that share a built-in result-signing key. */
23
- 'x-reclaim-verification-client-ids'?: string[]
24
25
  /** Attestor proofs use EIP-191 rather than JOSE ES256K. */
25
26
  'x-reclaim-signature-scheme'?: 'EIP-191'
26
27
  }
@@ -29,21 +30,18 @@ export interface ReclaimJwks {
29
30
  keys: Jwk[]
30
31
  /** Trusted EIP-191 attestor signer addresses (public-key hashes). */
31
32
  'x-reclaim-attestors'?: string[]
33
+ /** Trusted sha256 digests for attestor Confidential Space images. */
34
+ 'x-reclaim-attestor-image-digests'?: string[]
32
35
  }
33
36
 
34
37
  /**
35
- * Build the JWK set for an ES256K signing key (the Verification Client's
38
+ * Build the JWK set for an ES256K signing key (Builder's managed
36
39
  * result-signing key). `kid` is the key's ETH address; `x`/`y` are the
37
40
  * uncompressed public-key coordinates. Empty set when no key is configured.
38
41
  */
39
42
  export function jwksFromPrivateKey(
40
43
  privateKeyHex?: string,
41
- metadata: Pick<
42
- Jwk,
43
- 'x-reclaim-purpose'
44
- | 'x-reclaim-verification-client-id'
45
- | 'x-reclaim-verification-client-ids'
46
- > = {},
44
+ metadata: Pick<Jwk, 'x-reclaim-purpose'> = {},
47
45
  ): { keys: Jwk[] } {
48
46
  if(!privateKeyHex) {
49
47
  return { keys: [] }
@@ -76,7 +74,7 @@ export async function fetchJwks(
76
74
  iss: string,
77
75
  fetchImpl: typeof fetch = fetch,
78
76
  ): Promise<Jwk[]> {
79
- const base = iss.replace(/\/+$/, '')
77
+ const base = stripTrailingSlashes(iss)
80
78
  return fetchJwksUrl(`${base}/.well-known/jwks.json`, fetchImpl)
81
79
  }
82
80
 
@@ -94,6 +92,7 @@ export async function fetchReclaimJwks(
94
92
  url: string,
95
93
  fetchImpl: typeof fetch = fetch,
96
94
  ): Promise<ReclaimJwks> {
95
+ assertSecureJwksUrl(url)
97
96
  const res = await fetchImpl(url)
98
97
  if(!res.ok) {
99
98
  throw new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`)
@@ -105,6 +104,9 @@ export async function fetchReclaimJwks(
105
104
  'x-reclaim-attestors': validateAttestorAddresses(
106
105
  body['x-reclaim-attestors'] ?? [],
107
106
  ),
107
+ 'x-reclaim-attestor-image-digests': validateAttestorImageDigests(
108
+ body['x-reclaim-attestor-image-digests'] ?? [],
109
+ ),
108
110
  }
109
111
  }
110
112
 
@@ -114,16 +116,20 @@ export function selectKey(keys: Jwk[], kid: string): Jwk | undefined {
114
116
  return keys.find((k) => (k.kid ?? '').toLowerCase() === want)
115
117
  }
116
118
 
117
- /** Select a result key and enforce its Verification Client ownership. */
119
+ /** Select Builder's result-signing key and validate its JOSE metadata. */
118
120
  export function selectVerificationResultKey(
119
121
  keys: Jwk[],
120
122
  kid: string,
121
- verificationClientId: string,
122
123
  ): Jwk | undefined {
123
124
  return keys.find((key) => (
124
125
  (key.kid ?? '').toLowerCase() === kid.toLowerCase()
126
+ && key.kty === 'EC'
127
+ && key.crv === 'secp256k1'
128
+ && (!key.alg || key.alg === 'ES256K')
129
+ && (!key.use || key.use === 'sig')
130
+ && (!key.key_ops || key.key_ops.includes('verify'))
125
131
  && key['x-reclaim-purpose'] !== 'attestor-proof'
126
- && belongsToVerificationClient(key, verificationClientId)
132
+ && jwkMatchesKid(key)
127
133
  ))
128
134
  }
129
135
 
@@ -167,6 +173,18 @@ export function validateAttestorAddresses(addresses: string[]): string[] {
167
173
  }))]
168
174
  }
169
175
 
176
+ /** Validate and normalize sha256 image digests from a JWKS trust extension. */
177
+ export function validateAttestorImageDigests(digests: string[]): string[] {
178
+ return [...new Set(digests.map((digest) => {
179
+ const normalized = digest.trim().toLowerCase()
180
+ if(!/^sha256:[0-9a-f]{64}$/.test(normalized)) {
181
+ throw new Error('invalid attestor image digest in JWKS trust registry')
182
+ }
183
+
184
+ return normalized
185
+ }))]
186
+ }
187
+
170
188
  /**
171
189
  * Reconstruct the uncompressed secp256k1 public key (`0x04` + X + Y) from an
172
190
  * EC JWK's base64url `x`/`y` coordinates, for signature verification.
@@ -185,23 +203,27 @@ export function jwkToUncompressedPublicKey(jwk: Jwk): string {
185
203
  return '0x' + bytesToHex(out)
186
204
  }
187
205
 
188
- function belongsToVerificationClient(
189
- key: Jwk,
190
- verificationClientId: string,
191
- ): boolean {
192
- const one = key['x-reclaim-verification-client-id']
193
- const many = key['x-reclaim-verification-client-ids']
194
- if(one) {
195
- return one === verificationClientId
206
+ function jwkMatchesKid(key: Jwk): boolean {
207
+ try {
208
+ const address = addressFromUncompressedPubkey(
209
+ hexPublicKeyBytes(jwkToUncompressedPublicKey(key)),
210
+ )
211
+ return address.toLowerCase() === key.kid?.toLowerCase()
212
+ } catch{
213
+ return false
196
214
  }
215
+ }
197
216
 
198
- if(many) {
199
- return many.includes(verificationClientId)
217
+ function assertSecureJwksUrl(value: string) {
218
+ const url = new URL(value)
219
+ if(url.protocol === 'https:' || (
220
+ url.protocol === 'http:'
221
+ && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
222
+ )) {
223
+ return
200
224
  }
201
225
 
202
- // Untagged self-hosted JWKS remain compatible because their URL is itself
203
- // selected from the registered Verification Client record.
204
- return key['x-reclaim-purpose'] !== 'verification-result'
226
+ throw new Error('JWKS URL must use HTTPS except on loopback')
205
227
  }
206
228
 
207
229
  function hexPublicKeyBytes(value: string) {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Rebuilds the real, openable destination URL for a parsed flow.
3
+ *
4
+ * Since migration `20260917130000000.share-routes-uris.sql`, every built-in
5
+ * client's registered uri is itself a real, openable Builder page (`/share/
6
+ * {name}` — see `builtin-clients.ts` and `SharePage.tsx`), including
7
+ * `portals`, which used to be an attribution-only label Builder served
8
+ * nothing at. That does NOT mean this file's job shrinks to "pass every
9
+ * target through unchanged": which side rewrites is decided per target by
10
+ * who is able to honour an override and who has to keep working against a
11
+ * pre-migration deployment, not by whether Builder happens to have a page
12
+ * there today.
13
+ *
14
+ * - `builder`: registered uri (`/v/{sessionId}` pre-migration, `/share/
15
+ * builder` post-migration) is always real and there is no override to
16
+ * express — Builder's own route owns any redirecting (`/share/builder`
17
+ * 302s to `/v/{sessionId}` server-side). This file never rewrites it.
18
+ * - `portals`: this file keeps rewriting to `options.portalUrl ??
19
+ * DEFAULT_PORTAL_URL`, unconditionally, even though `/share/portals` now
20
+ * redirects there too. Two reasons it can't defer to Builder's hop: (1) a
21
+ * pre-migration `/portals` uri is still a bare label Builder serves
22
+ * nothing at, so passing it through would 404; (2) `options.portalUrl` is
23
+ * an override Builder's fixed server-side redirect has no way to honour.
24
+ * `DEFAULT_PORTAL_URL` here and `SharePage.tsx`'s redirect default are
25
+ * deliberately kept identical, so the common (no-override) case reaches
26
+ * the same destination either hop — Builder's `/share/portals` hop is
27
+ * what makes a *pasted* link (opened without this SDK) still work.
28
+ * - `verifier-app`: registered uri (`/s` or `/share/verifier-app`, both
29
+ * real) is the share link itself, so this file passes it through unless
30
+ * `options.appLinkBaseUrl` overrides it — same shape of reasoning as
31
+ * `portals`, just already correct before this migration.
32
+ * - `reclaim-browser-extension`: has no destination URL at all — the
33
+ * mechanism is a `postMessage` handoff, not a navigation (see
34
+ * ./extension.ts) — so `getUrl()` on that target, like an unrecognised
35
+ * path, returns the original `verificationUrl` unchanged regardless of
36
+ * which shape registered it.
37
+ */
38
+ import { stripTrailingSlashes } from '../encoding.ts'
39
+ import type { ParsedFlowUrl, VerificationTarget } from './target.ts'
40
+
41
+ export interface DestinationOptions {
42
+ portalUrl?: string
43
+ appLinkBaseUrl?: string
44
+ }
45
+
46
+ export const DEFAULT_PORTAL_URL = 'https://portal.reclaimprotocol.org'
47
+ /** The legacy share page. No longer a default — `verifier-app` sessions now
48
+ * carry a real Builder share URL — but kept as the value a deployment still
49
+ * pointing at `share.reclaimprotocol.org` would pass as `appLinkBaseUrl`. */
50
+ export const LEGACY_APP_LINK_BASE_URL
51
+ = 'https://share.reclaimprotocol.org/verify'
52
+
53
+ /** Copies `api`/`sessionId`/`diag` onto `base`, preserving any query
54
+ * parameters `base` already carries (e.g. an App Clip's `?p=`). */
55
+ export function appendSessionQuery(
56
+ base: string,
57
+ parsed: Pick<ParsedFlowUrl, 'sessionId' | 'diag'>,
58
+ ): string {
59
+ const url = new URL(base)
60
+ url.searchParams.set('api', '2')
61
+ if(parsed.sessionId) {
62
+ url.searchParams.set('sessionId', parsed.sessionId)
63
+ }
64
+
65
+ if(parsed.diag) {
66
+ url.searchParams.set('diag', '1')
67
+ }
68
+
69
+ return url.toString()
70
+ }
71
+
72
+ function withoutTrailingSlash(value: string): string {
73
+ return stripTrailingSlashes(value)
74
+ }
75
+
76
+ export function resolveDestinationUrl(
77
+ target: VerificationTarget | undefined,
78
+ parsed: ParsedFlowUrl,
79
+ options: DestinationOptions,
80
+ ): string {
81
+ switch (target) {
82
+ case 'builder':
83
+ return parsed.url.toString()
84
+ case 'portals': {
85
+ // Always rewritten here, even now that `/share/portals` also
86
+ // redirects server-side — see the file header for why this file,
87
+ // not Builder's route, has to own it.
88
+ const base = options.portalUrl ?? DEFAULT_PORTAL_URL
89
+ return appendSessionQuery(`${withoutTrailingSlash(base)}/verify`, parsed)
90
+ }
91
+
92
+ case 'verifier-app': {
93
+ // The registered uri for `verifier-app` is Builder's own share page
94
+ // (`GET /s`), a real openable URL — so the verificationUrl already
95
+ // IS the share link, and rewriting it onto another origin would
96
+ // throw away the app-link association that makes a scan open the
97
+ // app (`build.reclaimprotocol.org` is what the app's entitlements
98
+ // and assetlinks now claim).
99
+ //
100
+ // `appLinkBaseUrl` stays as the override for a deployment that
101
+ // hosts its own share page, or one still pointing at the legacy
102
+ // `share.reclaimprotocol.org/verify`.
103
+ const base = options.appLinkBaseUrl
104
+ return base ? appendSessionQuery(base, parsed) : parsed.url.toString()
105
+ }
106
+
107
+ case 'reclaim-browser-extension':
108
+ case undefined:
109
+ return parsed.url.toString()
110
+ }
111
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Device detection, ported from the reference JS SDK's `src/utils/device.ts`
3
+ * (`getDeviceType`/`getMobileDeviceType`). Two differences from that source,
4
+ * both required by the launch-API plan:
5
+ *
6
+ * - Every signal is read through a `LaunchEnvironment` (see ./environment.ts)
7
+ * instead of module-scope `navigator`/`window`, so this is testable with
8
+ * plain fakes and safe to import under SSR.
9
+ * - The result carries a `confidence` ('high' | 'low') instead of silently
10
+ * committing to a guess, so a caller can render a chooser instead of
11
+ * assuming. `navigator.userAgentData` (Chromium's low-entropy User-Agent
12
+ * Client Hints) is read as an extra signal where present — it is
13
+ * Chromium-only and absent on every iOS browser, so it augments the
14
+ * heuristics below rather than replacing them, and `getHighEntropyValues`
15
+ * is never called (it's async and equally absent on WebKit).
16
+ *
17
+ * The mobile-vs-desktop scoring mirrors the reference SDK's weights exactly
18
+ * (`CONFIDENCE_THRESHOLD = 3`); the iOS-vs-Android cascade mirrors its rule
19
+ * order, including the iPad-reporting-as-macOS special case.
20
+ */
21
+ import type { LaunchEnvironment, LaunchWindow } from './environment.ts'
22
+
23
+ export type DeviceKind = 'desktop' | 'ios' | 'android'
24
+ export type DetectionConfidence = 'high' | 'low'
25
+
26
+ export interface DeviceDetection {
27
+ device: DeviceKind
28
+ confidence: DetectionConfidence
29
+ }
30
+
31
+ /** Matches the reference SDK's mobile/desktop scoring cutoff. */
32
+ const MOBILE_SCORE_THRESHOLD = 3
33
+ /** A mobile/desktop score this close to the cutoff is a coin flip, not a
34
+ * confident read — surfaced as `confidence: 'low'`. */
35
+ const MOBILE_SCORE_MARGIN = 2
36
+
37
+ function safeMatchMedia(win: LaunchWindow | undefined, query: string): boolean {
38
+ try {
39
+ return win?.matchMedia?.(query)?.matches ?? false
40
+ } catch{
41
+ return false
42
+ }
43
+ }
44
+
45
+ function safeCssSupports(
46
+ win: LaunchWindow | undefined,
47
+ property: string,
48
+ value: string,
49
+ ): boolean {
50
+ try {
51
+ return win?.CSS?.supports?.(property, value) ?? false
52
+ } catch{
53
+ return false
54
+ }
55
+ }
56
+
57
+ function safeQuerySelector(env: LaunchEnvironment, selector: string): boolean {
58
+ try {
59
+ return !!env.document?.querySelector(selector)
60
+ } catch{
61
+ return false
62
+ }
63
+ }
64
+
65
+ /** Ported scoring pass. Returns the raw mobile score — positive leans
66
+ * mobile, negative leans desktop. */
67
+ function scoreMobile(env: LaunchEnvironment): number {
68
+ const win = env.window
69
+ const nav = env.navigator
70
+ const userAgent = (nav?.userAgent ?? '').toLowerCase()
71
+
72
+ if(!win || !nav) {
73
+ // SSR / no environment: the reference SDK assumes desktop.
74
+ return -MOBILE_SCORE_THRESHOLD
75
+ }
76
+
77
+ let score = 0
78
+
79
+ const screenWidth = win.innerWidth || win.screen?.width || 0
80
+ const screenHeight = win.innerHeight || win.screen?.height || 0
81
+ const hasSmallScreen = screenWidth <= 480 || screenHeight <= 480
82
+ const hasLargeScreen = screenWidth > 1024 && screenHeight > 768
83
+
84
+ const hasTouch = 'ontouchstart' in win || (nav.maxTouchPoints ?? 0) > 0
85
+ const hasPreciseMouse = safeMatchMedia(win, '(pointer: fine)')
86
+ const canHover = safeMatchMedia(win, '(hover: hover)')
87
+ const hasMouseAndTouch = hasTouch && hasPreciseMouse
88
+
89
+ const isWindowsTouchLaptop = /windows/i.test(userAgent)
90
+ && hasPreciseMouse && hasTouch
91
+
92
+ if(hasTouch && !hasMouseAndTouch) {
93
+ score += 2
94
+ } else if(hasMouseAndTouch) {
95
+ score += 1
96
+ }
97
+
98
+ if(hasSmallScreen && !isWindowsTouchLaptop) {
99
+ score += 2
100
+ }
101
+
102
+ const mobileUaPattern = new RegExp(
103
+ 'android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile',
104
+ 'i',
105
+ )
106
+ const hasMobileUserAgent = mobileUaPattern.test(userAgent)
107
+ if(hasMobileUserAgent) {
108
+ score += 3
109
+ }
110
+
111
+ const hasMobileApis = 'orientation' in win
112
+ || 'DeviceMotionEvent' in win
113
+ || 'DeviceOrientationEvent' in win
114
+ const isDesktopSafari = /safari/i.test(userAgent)
115
+ && !/mobile/i.test(userAgent)
116
+ && /mac|intel/i.test(userAgent)
117
+ const mobileApisImplyMobile = hasMobileApis
118
+ && (hasSmallScreen || hasMobileUserAgent)
119
+ && !isDesktopSafari
120
+ && !isWindowsTouchLaptop
121
+ if(mobileApisImplyMobile) {
122
+ score += 2
123
+ }
124
+
125
+ const hasHighDpi = (win.devicePixelRatio ?? 0) > 1.5
126
+ if(hasHighDpi && hasSmallScreen) {
127
+ score += 1
128
+ }
129
+
130
+ const hasViewportMeta = safeQuerySelector(env, 'meta[name="viewport"]')
131
+ if(hasViewportMeta && hasSmallScreen) {
132
+ score += 1
133
+ }
134
+
135
+ const isPadProInDesktopMode = userAgent.includes('macintosh') && hasTouch
136
+ if(isPadProInDesktopMode) {
137
+ score += 2
138
+ }
139
+
140
+ if(hasLargeScreen && hasPreciseMouse) {
141
+ score -= 3
142
+ } else if(hasLargeScreen && !hasTouch) {
143
+ score -= 2
144
+ }
145
+
146
+ if(hasPreciseMouse && canHover) {
147
+ score -= 2
148
+ }
149
+
150
+ const isWindowsDesktop = /windows/i.test(userAgent) && !hasMobileUserAgent
151
+ if(isWindowsDesktop) {
152
+ score -= 3
153
+ }
154
+
155
+ // Chromium's low-entropy UA-CH `mobile` hint: an explicit, high-confidence
156
+ // signal where present, applied on top of the heuristic score above rather
157
+ // than replacing it (it's absent on WebKit/Firefox entirely).
158
+ if(nav.userAgentData?.mobile === true) {
159
+ score += 2
160
+ } else if(nav.userAgentData?.mobile === false) {
161
+ score -= 2
162
+ }
163
+
164
+ return score
165
+ }
166
+
167
+ interface MobileOsGuess {
168
+ os: 'ios' | 'android'
169
+ /** False only for the reference SDK's final fallback: no rule matched
170
+ * either OS, so it defaulted to Android because that's the more common
171
+ * global install base — a guess, not a read. */
172
+ explicit: boolean
173
+ }
174
+
175
+ /** Ported iOS/Android cascade. Only called once `scoreMobile` has decided the
176
+ * device is mobile. */
177
+ function guessMobileOs(env: LaunchEnvironment): MobileOsGuess {
178
+ const win = env.window
179
+ const nav = env.navigator
180
+ const userAgent = nav?.userAgent ?? ''
181
+ const hasTouch = !!win
182
+ && ('ontouchstart' in win || (nav?.maxTouchPoints ?? 0) > 0)
183
+
184
+ if(/iPad|iPhone|iPod/i.test(userAgent)) {
185
+ return { os: 'ios', explicit: true }
186
+ }
187
+
188
+ // iPad Pro reports as a Mac (both in the UA string and in UA-CH's
189
+ // `platform` hint) once it gains a keyboard/trackpad, but keeps touch.
190
+ const isMacWithTouch = /Macintosh|MacIntel/i.test(userAgent) && hasTouch
191
+ const isMacOsWithTouch = nav?.userAgentData?.platform === 'macOS' && hasTouch
192
+ if(isMacWithTouch || isMacOsWithTouch) {
193
+ return { os: 'ios', explicit: true }
194
+ }
195
+
196
+ const hasIosPermissionApi = typeof win?.DeviceMotionEvent?.requestPermission
197
+ === 'function'
198
+ const hasIosTouchCallout = safeCssSupports(
199
+ win, '-webkit-touch-callout', 'none',
200
+ )
201
+ if(hasIosPermissionApi || hasIosTouchCallout) {
202
+ return { os: 'ios', explicit: true }
203
+ }
204
+
205
+ const isIosWebKit = /WebKit/i.test(userAgent)
206
+ && !/Chrome|CriOS|Android/i.test(userAgent)
207
+ && !/Macintosh|MacIntel/i.test(userAgent)
208
+ if(isIosWebKit) {
209
+ return { os: 'ios', explicit: true }
210
+ }
211
+
212
+ if(/Android/i.test(userAgent)) {
213
+ return { os: 'android', explicit: true }
214
+ }
215
+
216
+ if(nav?.userAgentData?.platform === 'Android') {
217
+ return { os: 'android', explicit: true }
218
+ }
219
+
220
+ // Final fallback: no rule matched. Android is the more common global
221
+ // install base, but this is a guess — callers see `confidence: 'low'`.
222
+ return { os: 'android', explicit: false }
223
+ }
224
+
225
+ /** Resolves the device this flow is running on, with a confidence a caller
226
+ * can act on instead of a silent guess. */
227
+ export function resolveDevice(env: LaunchEnvironment): DeviceDetection {
228
+ const score = scoreMobile(env)
229
+ const isMobile = score >= MOBILE_SCORE_THRESHOLD
230
+ const borderline
231
+ = Math.abs(score - MOBILE_SCORE_THRESHOLD) < MOBILE_SCORE_MARGIN
232
+
233
+ if(!isMobile) {
234
+ return { device: 'desktop', confidence: borderline ? 'low' : 'high' }
235
+ }
236
+
237
+ const guess = guessMobileOs(env)
238
+ return {
239
+ device: guess.os,
240
+ confidence: borderline || !guess.explicit ? 'low' : 'high',
241
+ }
242
+ }