@reclaimprotocol/client 0.1.28 → 0.1.31
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.
- package/README.md +255 -234
- package/lib/api.d.ts +2 -0
- package/lib/api.js +2 -0
- package/lib/client.js +2 -0
- package/lib/enckeys.d.ts +1 -1
- package/lib/enckeys.js +2 -2
- package/lib/encoding.d.ts +4 -0
- package/lib/encoding.js +10 -0
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.js +19 -0
- package/lib/index.d.ts +4 -4
- package/lib/index.js +4 -4
- package/lib/jwks.d.ts +8 -8
- package/lib/jwks.js +38 -17
- package/lib/launch/destination.d.ts +14 -0
- package/lib/launch/destination.js +89 -0
- package/lib/launch/device.d.ts +30 -0
- package/lib/launch/device.js +168 -0
- package/lib/launch/environment.d.ts +224 -0
- package/lib/launch/environment.js +98 -0
- package/lib/launch/errors.d.ts +20 -0
- package/lib/launch/errors.js +25 -0
- package/lib/launch/extension.d.ts +42 -0
- package/lib/launch/extension.js +72 -0
- package/lib/launch/flow.d.ts +82 -0
- package/lib/launch/flow.js +222 -0
- package/lib/launch/index.d.ts +16 -0
- package/lib/launch/index.js +12 -0
- package/lib/launch/mobile.d.ts +77 -0
- package/lib/launch/mobile.js +124 -0
- package/lib/launch/modal.d.ts +50 -0
- package/lib/launch/modal.js +258 -0
- package/lib/launch/options.d.ts +74 -0
- package/lib/launch/options.js +20 -0
- package/lib/launch/portal.d.ts +28 -0
- package/lib/launch/portal.js +68 -0
- package/lib/launch/qr.d.ts +5 -0
- package/lib/launch/qr.js +28 -0
- package/lib/launch/target.d.ts +46 -0
- package/lib/launch/target.js +128 -0
- package/lib/launch/vendor/qrcodegen.d.ts +134 -0
- package/lib/launch/vendor/qrcodegen.js +822 -0
- package/lib/legacy-proof.d.ts +2 -0
- package/lib/legacy-proof.js +37 -7
- package/lib/proof-hash.d.ts +5 -5
- package/lib/proof-hash.js +1 -1
- package/lib/proof-recipe.d.ts +3 -3
- package/lib/proof-recipe.js +1 -1
- package/lib/proof-types.d.ts +35 -8
- package/lib/reclaim.d.ts +81 -70
- package/lib/reclaim.js +79 -130
- package/lib/request-proof.d.ts +60 -0
- package/lib/request-proof.js +241 -0
- package/lib/result.d.ts +46 -38
- package/lib/result.js +160 -100
- package/lib/routes.gen.d.ts +76 -28
- package/lib/routes.gen.js +24 -11
- package/lib/tee-binding.d.ts +1 -1
- package/lib/types/openapi.d.ts +1 -0
- package/lib/types/openapi.gen.d.ts +1594 -488
- package/lib/types/openapi.gen.js +5 -0
- package/lib/verification-clients.d.ts +28 -14
- package/lib/verification-clients.js +31 -11
- package/lib/verify-attestor-tee.d.ts +19 -2
- package/lib/verify-attestor-tee.js +40 -4
- package/lib/verify-tee.d.ts +10 -1
- package/lib/verify-tee.js +34 -10
- package/package.json +5 -1
- package/src/api.ts +9 -0
- package/src/client.ts +2 -0
- package/src/enckeys.ts +2 -2
- package/src/encoding.ts +12 -0
- package/src/feature-flags.ts +39 -0
- package/src/index.ts +13 -14
- package/src/jwks.ts +52 -30
- package/src/launch/destination.ts +111 -0
- package/src/launch/device.ts +242 -0
- package/src/launch/environment.ts +337 -0
- package/src/launch/errors.ts +49 -0
- package/src/launch/extension.ts +124 -0
- package/src/launch/flow.ts +369 -0
- package/src/launch/index.ts +42 -0
- package/src/launch/mobile.ts +184 -0
- package/src/launch/modal.ts +378 -0
- package/src/launch/options.ts +99 -0
- package/src/launch/portal.ts +104 -0
- package/src/launch/qr.ts +37 -0
- package/src/launch/target.ts +150 -0
- package/src/launch/vendor/qrcodegen.ts +1034 -0
- package/src/proof-hash.ts +6 -6
- package/src/proof-recipe.ts +3 -3
- package/src/proof-types.ts +36 -8
- package/src/reclaim.ts +185 -218
- package/src/{legacy-proof.ts → request-proof.ts} +81 -36
- package/src/result.ts +238 -172
- package/src/routes.gen.ts +24 -11
- package/src/tee-binding.ts +1 -1
- package/src/types/openapi.gen.ts +1599 -488
- package/src/types/openapi.ts +3 -1
- package/src/verification-clients.ts +33 -19
- package/src/verify-attestor-tee.ts +66 -8
- package/src/verify-tee.ts +44 -13
- package/lib/api-client.d.ts +0 -31
- package/lib/api-client.js +0 -112
- package/lib/api-consts.d.ts +0 -4
- package/lib/api-consts.js +0 -4
- package/lib/api-errors.d.ts +0 -13
- package/lib/api-errors.js +0 -30
- package/lib/eip712.d.ts +0 -20
- package/lib/eip712.js +0 -108
package/lib/api.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { ReclaimClient } from './client.ts';
|
|
|
2
2
|
export type { ReclaimClientOptions } from './client.ts';
|
|
3
3
|
export { isProblemDetails, isProblemError, readProblemDetails, throwProblemError, } from './errors.ts';
|
|
4
4
|
export type { ProblemError } from './errors.ts';
|
|
5
|
+
export { getBooleanFeatureFlag, getFeatureFlag, getNumberFeatureFlag, getStringFeatureFlag, } from './feature-flags.ts';
|
|
5
6
|
export { DEFAULT_BASE_URL, PROBLEM_CONTENT_TYPE, } from './consts.ts';
|
|
7
|
+
export { VerificationClient, } from './verification-clients.ts';
|
|
6
8
|
export type * from './types/openapi.ts';
|
|
7
9
|
export type * from './types/openapi.gen.ts';
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ReclaimClient } from "./client.js";
|
|
2
2
|
export { isProblemDetails, isProblemError, readProblemDetails, throwProblemError, } from "./errors.js";
|
|
3
|
+
export { getBooleanFeatureFlag, getFeatureFlag, getNumberFeatureFlag, getStringFeatureFlag, } from "./feature-flags.js";
|
|
3
4
|
export { DEFAULT_BASE_URL, PROBLEM_CONTENT_TYPE, } from "./consts.js";
|
|
5
|
+
export { VerificationClient, } from "./verification-clients.js";
|
package/lib/client.js
CHANGED
|
@@ -91,6 +91,8 @@ export class ReclaimClient {
|
|
|
91
91
|
}
|
|
92
92
|
const empty = res.status === 204
|
|
93
93
|
|| res.headers.get('content-length') === '0';
|
|
94
|
+
// Fetch exposes parsed JSON as `any`; OpenAPI owns the operation-specific
|
|
95
|
+
// response type, and callers can inspect the original Response if needed.
|
|
94
96
|
const data = (empty ? undefined : await res.json());
|
|
95
97
|
return { data, response: res };
|
|
96
98
|
}
|
package/lib/enckeys.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type DecryptionKey = Uint8Array;
|
|
|
11
11
|
export declare function loadDecryptionKey(input: DecryptionKeyInput): DecryptionKey;
|
|
12
12
|
/**
|
|
13
13
|
* Convert an uncompressed secp256k1 public key (`0x04` + 128 hex) to the public
|
|
14
|
-
* JWK the API stores and
|
|
14
|
+
* JWK the API stores and Builder encrypts to — so callers only
|
|
15
15
|
* ever handle eth-native hex, never a JWK. Mirrors the `x`/`y` coordinate split
|
|
16
16
|
* in `jwksFromPrivateKey` (./jwks.ts).
|
|
17
17
|
*/
|
package/lib/enckeys.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* secp256k1 (Ethereum) encryption-key helpers for ECIES result delivery.
|
|
3
3
|
*
|
|
4
4
|
* The org's encryption key is its **Ethereum public key** (secp256k1). The
|
|
5
|
-
*
|
|
5
|
+
* Builder ECIES-encrypts the signed JWS to it (see ./result.ts) and
|
|
6
6
|
* the Consumer decrypts with the matching eth **private key**. Eth-native `0x`
|
|
7
7
|
* hex is used everywhere at the edges (the format MetaMask/ethers/wallets
|
|
8
8
|
* emit); the wire/DB store the public key as a secp256k1 JWK
|
|
@@ -32,7 +32,7 @@ export function loadDecryptionKey(input) {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Convert an uncompressed secp256k1 public key (`0x04` + 128 hex) to the public
|
|
35
|
-
* JWK the API stores and
|
|
35
|
+
* JWK the API stores and Builder encrypts to — so callers only
|
|
36
36
|
* ever handle eth-native hex, never a JWK. Mirrors the `x`/`y` coordinate split
|
|
37
37
|
* in `jwksFromPrivateKey` (./jwks.ts).
|
|
38
38
|
*/
|
package/lib/encoding.d.ts
CHANGED
|
@@ -14,3 +14,7 @@ export declare function bytesToHex(bytes: Uint8Array): string;
|
|
|
14
14
|
export declare function base64urlToBytes(s: string): Uint8Array;
|
|
15
15
|
/** Encode bytes (or a UTF-8 string) as unpadded base64url. */
|
|
16
16
|
export declare function b64url(input: Uint8Array | string): string;
|
|
17
|
+
/** `value` without trailing `/`s. A loop, not `/\/+$/`: that regex backtracks
|
|
18
|
+
* quadratically on a long run of slashes followed by anything else, and
|
|
19
|
+
* some callers get attacker-supplied input (a JWS header's `iss`). */
|
|
20
|
+
export declare function stripTrailingSlashes(value: string): string;
|
package/lib/encoding.js
CHANGED
|
@@ -48,3 +48,13 @@ export function b64url(input) {
|
|
|
48
48
|
.replace(/\//g, '_')
|
|
49
49
|
.replace(/=+$/, '');
|
|
50
50
|
}
|
|
51
|
+
/** `value` without trailing `/`s. A loop, not `/\/+$/`: that regex backtracks
|
|
52
|
+
* quadratically on a long run of slashes followed by anything else, and
|
|
53
|
+
* some callers get attacker-supplied input (a JWS header's `iss`). */
|
|
54
|
+
export function stripTrailingSlashes(value) {
|
|
55
|
+
let end = value.length;
|
|
56
|
+
while (end > 0 && value.charCodeAt(end - 1) === 0x2f) {
|
|
57
|
+
end--;
|
|
58
|
+
}
|
|
59
|
+
return value.slice(0, end);
|
|
60
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { VerificationRuntimeConfig } from './types/openapi.ts';
|
|
2
|
+
/** Read a resolved session flag without assuming its runtime type. */
|
|
3
|
+
export declare function getFeatureFlag(config: VerificationRuntimeConfig | null | undefined, key: string): unknown;
|
|
4
|
+
/** Read a boolean flag, returning `fallback` for absent or mistyped values. */
|
|
5
|
+
export declare function getBooleanFeatureFlag(config: VerificationRuntimeConfig | null | undefined, key: string, fallback?: boolean): boolean;
|
|
6
|
+
/** Read a string flag, returning `fallback` for absent or mistyped values. */
|
|
7
|
+
export declare function getStringFeatureFlag(config: VerificationRuntimeConfig | null | undefined, key: string, fallback: string): string;
|
|
8
|
+
/** Read a numeric flag, returning `fallback` for absent or mistyped values. */
|
|
9
|
+
export declare function getNumberFeatureFlag(config: VerificationRuntimeConfig | null | undefined, key: string, fallback: number): number;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Read a resolved session flag without assuming its runtime type. */
|
|
2
|
+
export function getFeatureFlag(config, key) {
|
|
3
|
+
return config?.values[key];
|
|
4
|
+
}
|
|
5
|
+
/** Read a boolean flag, returning `fallback` for absent or mistyped values. */
|
|
6
|
+
export function getBooleanFeatureFlag(config, key, fallback = false) {
|
|
7
|
+
const value = getFeatureFlag(config, key);
|
|
8
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
9
|
+
}
|
|
10
|
+
/** Read a string flag, returning `fallback` for absent or mistyped values. */
|
|
11
|
+
export function getStringFeatureFlag(config, key, fallback) {
|
|
12
|
+
const value = getFeatureFlag(config, key);
|
|
13
|
+
return typeof value === 'string' ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
/** Read a numeric flag, returning `fallback` for absent or mistyped values. */
|
|
16
|
+
export function getNumberFeatureFlag(config, key, fallback) {
|
|
17
|
+
const value = getFeatureFlag(config, key);
|
|
18
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
19
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -7,11 +7,11 @@ export { addressFromUncompressedPubkey, secp, } from './secp.ts';
|
|
|
7
7
|
export { eip191Digest, signEip191 } from './eip191.ts';
|
|
8
8
|
export { type CallbackBody, decodeJwsPayload, decryptCallback, type DeliveryEnvelope, encryptForEthPublicKey, type Jws, ResultVerificationError, type ResultProofOutcome, signResultJws, verifyJwsSignature, verifyResult, verifyResultFull, type VerifyResultFullOptions, type VerifyResultFullOutcome, type VerifyResultOptions, type VerifyResultOutcome, } from './result.ts';
|
|
9
9
|
export { type DecryptionKey, type DecryptionKeyInput, generateEthKeypair, type GeneratedEthKeypair, loadDecryptionKey, pubkeyJwkToEthAddress, uncompressedPubkeyToJwk, } from './enckeys.ts';
|
|
10
|
-
export {
|
|
11
|
-
export { fetchJwks, fetchJwksUrl, fetchReclaimJwks, type Jwk, jwksFromPrivateKey, jwkToUncompressedPublicKey, type ReclaimJwks, selectKey, selectVerificationResultKey, trustedAttestorAddresses, validateAttestorAddresses, } from './jwks.ts';
|
|
10
|
+
export { type BindSessionTeeNonceInput, type CreateSessionInput, type ImportReclaimVerificationConfig, type ReclaimConfig, type ReclaimFlowFactory, ReclaimVerification, type ReclaimVerificationState, type ReceiveOptions, type ReceivedDelivery, type SessionFlow, } from './reclaim.ts';
|
|
11
|
+
export { fetchJwks, fetchJwksUrl, fetchReclaimJwks, type Jwk, jwksFromPrivateKey, jwkToUncompressedPublicKey, type ReclaimJwks, selectKey, selectVerificationResultKey, trustedAttestorAddresses, validateAttestorAddresses, validateAttestorImageDigests, } from './jwks.ts';
|
|
12
12
|
export { type NestedProofOutcome, verifyNestedProof, } from './attestor-proof.ts';
|
|
13
|
-
export { type
|
|
13
|
+
export { type ProofHashRequirement, type ReclaimRequestProof, type ReclaimRequestProofOutcome, type VerifyRequestProofOptions, proofHashes, verifyRequestProof, } from './request-proof.ts';
|
|
14
14
|
export { type BuilderProofRecipe, proofHashesFromBuilderRecipe, } from './proof-recipe.ts';
|
|
15
15
|
export { getHashFromProof, hashProofClaimParams, hashRequestSpec, type HttpProofParams, type ProofRequestSpec, } from './proof-hash.ts';
|
|
16
16
|
export { builderTeeBindingPayload, BUILDER_TEE_BINDING_DOMAIN, createBuilderTeeBinding, createLegacyTeeNonceContext, deriveLegacySignatureTeeNonce, deriveLegacyTeeNonce, LEGACY_TEE_ATTESTATION_VERSION, LEGACY_TEE_NONCE_DOMAIN, legacyApplicationIdFromPrivateKey, signBuilderTeeBinding, type BuilderTeeBinding, type BuilderTeeBindingPayload, type LegacyTeeNonceContext, type LegacyTeeNonceData, type LegacyTeeNonceMode, } from './tee-binding.ts';
|
|
17
|
-
export {
|
|
17
|
+
export { VerificationClient, } from './verification-clients.ts';
|
package/lib/index.js
CHANGED
|
@@ -7,11 +7,11 @@ export { addressFromUncompressedPubkey, secp, } from "./secp.js";
|
|
|
7
7
|
export { eip191Digest, signEip191 } from "./eip191.js";
|
|
8
8
|
export { decodeJwsPayload, decryptCallback, encryptForEthPublicKey, ResultVerificationError, signResultJws, verifyJwsSignature, verifyResult, verifyResultFull, } from "./result.js";
|
|
9
9
|
export { generateEthKeypair, loadDecryptionKey, pubkeyJwkToEthAddress, uncompressedPubkeyToJwk, } from "./enckeys.js";
|
|
10
|
-
export {
|
|
11
|
-
export { fetchJwks, fetchJwksUrl, fetchReclaimJwks, jwksFromPrivateKey, jwkToUncompressedPublicKey, selectKey, selectVerificationResultKey, trustedAttestorAddresses, validateAttestorAddresses, } from "./jwks.js";
|
|
10
|
+
export { ReclaimVerification, } from "./reclaim.js";
|
|
11
|
+
export { fetchJwks, fetchJwksUrl, fetchReclaimJwks, jwksFromPrivateKey, jwkToUncompressedPublicKey, selectKey, selectVerificationResultKey, trustedAttestorAddresses, validateAttestorAddresses, validateAttestorImageDigests, } from "./jwks.js";
|
|
12
12
|
export { verifyNestedProof, } from "./attestor-proof.js";
|
|
13
|
-
export { proofHashes,
|
|
13
|
+
export { proofHashes, verifyRequestProof, } from "./request-proof.js";
|
|
14
14
|
export { proofHashesFromBuilderRecipe, } from "./proof-recipe.js";
|
|
15
15
|
export { getHashFromProof, hashProofClaimParams, hashRequestSpec, } from "./proof-hash.js";
|
|
16
16
|
export { builderTeeBindingPayload, BUILDER_TEE_BINDING_DOMAIN, createBuilderTeeBinding, createLegacyTeeNonceContext, deriveLegacySignatureTeeNonce, deriveLegacyTeeNonce, LEGACY_TEE_ATTESTATION_VERSION, LEGACY_TEE_NONCE_DOMAIN, legacyApplicationIdFromPrivateKey, signBuilderTeeBinding, } from "./tee-binding.js";
|
|
17
|
-
export {
|
|
17
|
+
export { VerificationClient, } from "./verification-clients.js";
|
package/lib/jwks.d.ts
CHANGED
|
@@ -9,10 +9,6 @@ export interface Jwk {
|
|
|
9
9
|
key_ops?: string[];
|
|
10
10
|
/** Restricts how Builder consumers may use this key. */
|
|
11
11
|
'x-reclaim-purpose'?: 'verification-result' | 'attestor-proof';
|
|
12
|
-
/** Verification Client that owns a result-signing key. */
|
|
13
|
-
'x-reclaim-verification-client-id'?: string;
|
|
14
|
-
/** Verification Clients that share a built-in result-signing key. */
|
|
15
|
-
'x-reclaim-verification-client-ids'?: string[];
|
|
16
12
|
/** Attestor proofs use EIP-191 rather than JOSE ES256K. */
|
|
17
13
|
'x-reclaim-signature-scheme'?: 'EIP-191';
|
|
18
14
|
}
|
|
@@ -20,13 +16,15 @@ export interface ReclaimJwks {
|
|
|
20
16
|
keys: Jwk[];
|
|
21
17
|
/** Trusted EIP-191 attestor signer addresses (public-key hashes). */
|
|
22
18
|
'x-reclaim-attestors'?: string[];
|
|
19
|
+
/** Trusted sha256 digests for attestor Confidential Space images. */
|
|
20
|
+
'x-reclaim-attestor-image-digests'?: string[];
|
|
23
21
|
}
|
|
24
22
|
/**
|
|
25
|
-
* Build the JWK set for an ES256K signing key (
|
|
23
|
+
* Build the JWK set for an ES256K signing key (Builder's managed
|
|
26
24
|
* result-signing key). `kid` is the key's ETH address; `x`/`y` are the
|
|
27
25
|
* uncompressed public-key coordinates. Empty set when no key is configured.
|
|
28
26
|
*/
|
|
29
|
-
export declare function jwksFromPrivateKey(privateKeyHex?: string, metadata?: Pick<Jwk, 'x-reclaim-purpose'
|
|
27
|
+
export declare function jwksFromPrivateKey(privateKeyHex?: string, metadata?: Pick<Jwk, 'x-reclaim-purpose'>): {
|
|
30
28
|
keys: Jwk[];
|
|
31
29
|
};
|
|
32
30
|
/** Fetch the JWK set published at `<iss>/.well-known/jwks.json`. */
|
|
@@ -37,12 +35,14 @@ export declare function fetchJwksUrl(url: string, fetchImpl?: typeof fetch): Pro
|
|
|
37
35
|
export declare function fetchReclaimJwks(url: string, fetchImpl?: typeof fetch): Promise<ReclaimJwks>;
|
|
38
36
|
/** Find the key matching `kid` (case-insensitive — kids are ETH addresses). */
|
|
39
37
|
export declare function selectKey(keys: Jwk[], kid: string): Jwk | undefined;
|
|
40
|
-
/** Select
|
|
41
|
-
export declare function selectVerificationResultKey(keys: Jwk[], kid: string
|
|
38
|
+
/** Select Builder's result-signing key and validate its JOSE metadata. */
|
|
39
|
+
export declare function selectVerificationResultKey(keys: Jwk[], kid: string): Jwk | undefined;
|
|
42
40
|
/** Return ETH addresses from keys explicitly registered for attestor proofs. */
|
|
43
41
|
export declare function trustedAttestorAddresses(keys: Jwk[]): string[];
|
|
44
42
|
/** Validate and normalize the JWKS address-only trust extension. */
|
|
45
43
|
export declare function validateAttestorAddresses(addresses: string[]): string[];
|
|
44
|
+
/** Validate and normalize sha256 image digests from a JWKS trust extension. */
|
|
45
|
+
export declare function validateAttestorImageDigests(digests: string[]): string[];
|
|
46
46
|
/**
|
|
47
47
|
* Reconstruct the uncompressed secp256k1 public key (`0x04` + X + Y) from an
|
|
48
48
|
* EC JWK's base64url `x`/`y` coordinates, for signature verification.
|
package/lib/jwks.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JWKS (RFC 7517) discovery for verifying result/proof signatures. Signers
|
|
3
|
-
* (
|
|
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.js";
|
|
6
|
+
import { b64url, base64urlToBytes, bytesToHex, stripTrailingSlashes, } from "./encoding.js";
|
|
7
7
|
import { addressFromUncompressedPubkey, secp } from "./secp.js";
|
|
8
8
|
/**
|
|
9
|
-
* Build the JWK set for an ES256K signing key (
|
|
9
|
+
* Build the JWK set for an ES256K signing key (Builder's managed
|
|
10
10
|
* result-signing key). `kid` is the key's ETH address; `x`/`y` are the
|
|
11
11
|
* uncompressed public-key coordinates. Empty set when no key is configured.
|
|
12
12
|
*/
|
|
@@ -34,7 +34,7 @@ export function jwksFromPrivateKey(privateKeyHex, metadata = {}) {
|
|
|
34
34
|
}
|
|
35
35
|
/** Fetch the JWK set published at `<iss>/.well-known/jwks.json`. */
|
|
36
36
|
export async function fetchJwks(iss, fetchImpl = fetch) {
|
|
37
|
-
const base = iss
|
|
37
|
+
const base = stripTrailingSlashes(iss);
|
|
38
38
|
return fetchJwksUrl(`${base}/.well-known/jwks.json`, fetchImpl);
|
|
39
39
|
}
|
|
40
40
|
/** Fetch a JWK set from an explicitly trusted registry URL. */
|
|
@@ -44,6 +44,7 @@ export async function fetchJwksUrl(url, fetchImpl = fetch) {
|
|
|
44
44
|
}
|
|
45
45
|
/** Fetch Builder's JWKS plus its namespaced attestor trust registry. */
|
|
46
46
|
export async function fetchReclaimJwks(url, fetchImpl = fetch) {
|
|
47
|
+
assertSecureJwksUrl(url);
|
|
47
48
|
const res = await fetchImpl(url);
|
|
48
49
|
if (!res.ok) {
|
|
49
50
|
throw new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`);
|
|
@@ -52,6 +53,7 @@ export async function fetchReclaimJwks(url, fetchImpl = fetch) {
|
|
|
52
53
|
return {
|
|
53
54
|
keys: body.keys ?? [],
|
|
54
55
|
'x-reclaim-attestors': validateAttestorAddresses(body['x-reclaim-attestors'] ?? []),
|
|
56
|
+
'x-reclaim-attestor-image-digests': validateAttestorImageDigests(body['x-reclaim-attestor-image-digests'] ?? []),
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
/** Find the key matching `kid` (case-insensitive — kids are ETH addresses). */
|
|
@@ -59,11 +61,16 @@ export function selectKey(keys, kid) {
|
|
|
59
61
|
const want = kid.toLowerCase();
|
|
60
62
|
return keys.find((k) => (k.kid ?? '').toLowerCase() === want);
|
|
61
63
|
}
|
|
62
|
-
/** Select
|
|
63
|
-
export function selectVerificationResultKey(keys, kid
|
|
64
|
+
/** Select Builder's result-signing key and validate its JOSE metadata. */
|
|
65
|
+
export function selectVerificationResultKey(keys, kid) {
|
|
64
66
|
return keys.find((key) => ((key.kid ?? '').toLowerCase() === kid.toLowerCase()
|
|
67
|
+
&& key.kty === 'EC'
|
|
68
|
+
&& key.crv === 'secp256k1'
|
|
69
|
+
&& (!key.alg || key.alg === 'ES256K')
|
|
70
|
+
&& (!key.use || key.use === 'sig')
|
|
71
|
+
&& (!key.key_ops || key.key_ops.includes('verify'))
|
|
65
72
|
&& key['x-reclaim-purpose'] !== 'attestor-proof'
|
|
66
|
-
&&
|
|
73
|
+
&& jwkMatchesKid(key)));
|
|
67
74
|
}
|
|
68
75
|
/** Return ETH addresses from keys explicitly registered for attestor proofs. */
|
|
69
76
|
export function trustedAttestorAddresses(keys) {
|
|
@@ -94,6 +101,16 @@ export function validateAttestorAddresses(addresses) {
|
|
|
94
101
|
return normalized;
|
|
95
102
|
}))];
|
|
96
103
|
}
|
|
104
|
+
/** Validate and normalize sha256 image digests from a JWKS trust extension. */
|
|
105
|
+
export function validateAttestorImageDigests(digests) {
|
|
106
|
+
return [...new Set(digests.map((digest) => {
|
|
107
|
+
const normalized = digest.trim().toLowerCase();
|
|
108
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(normalized)) {
|
|
109
|
+
throw new Error('invalid attestor image digest in JWKS trust registry');
|
|
110
|
+
}
|
|
111
|
+
return normalized;
|
|
112
|
+
}))];
|
|
113
|
+
}
|
|
97
114
|
/**
|
|
98
115
|
* Reconstruct the uncompressed secp256k1 public key (`0x04` + X + Y) from an
|
|
99
116
|
* EC JWK's base64url `x`/`y` coordinates, for signature verification.
|
|
@@ -110,18 +127,22 @@ export function jwkToUncompressedPublicKey(jwk) {
|
|
|
110
127
|
out.set(y, 33);
|
|
111
128
|
return '0x' + bytesToHex(out);
|
|
112
129
|
}
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
function jwkMatchesKid(key) {
|
|
131
|
+
try {
|
|
132
|
+
const address = addressFromUncompressedPubkey(hexPublicKeyBytes(jwkToUncompressedPublicKey(key)));
|
|
133
|
+
return address.toLowerCase() === key.kid?.toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
118
137
|
}
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
}
|
|
139
|
+
function assertSecureJwksUrl(value) {
|
|
140
|
+
const url = new URL(value);
|
|
141
|
+
if (url.protocol === 'https:' || (url.protocol === 'http:'
|
|
142
|
+
&& ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
143
|
+
return;
|
|
121
144
|
}
|
|
122
|
-
|
|
123
|
-
// selected from the registered Verification Client record.
|
|
124
|
-
return key['x-reclaim-purpose'] !== 'verification-result';
|
|
145
|
+
throw new Error('JWKS URL must use HTTPS except on loopback');
|
|
125
146
|
}
|
|
126
147
|
function hexPublicKeyBytes(value) {
|
|
127
148
|
return Uint8Array.from(Buffer.from(value.slice(2), 'hex'));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ParsedFlowUrl, VerificationTarget } from './target.ts';
|
|
2
|
+
export interface DestinationOptions {
|
|
3
|
+
portalUrl?: string;
|
|
4
|
+
appLinkBaseUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_PORTAL_URL = "https://portal.reclaimprotocol.org";
|
|
7
|
+
/** The legacy share page. No longer a default — `verifier-app` sessions now
|
|
8
|
+
* carry a real Builder share URL — but kept as the value a deployment still
|
|
9
|
+
* pointing at `share.reclaimprotocol.org` would pass as `appLinkBaseUrl`. */
|
|
10
|
+
export declare const LEGACY_APP_LINK_BASE_URL = "https://share.reclaimprotocol.org/verify";
|
|
11
|
+
/** Copies `api`/`sessionId`/`diag` onto `base`, preserving any query
|
|
12
|
+
* parameters `base` already carries (e.g. an App Clip's `?p=`). */
|
|
13
|
+
export declare function appendSessionQuery(base: string, parsed: Pick<ParsedFlowUrl, 'sessionId' | 'diag'>): string;
|
|
14
|
+
export declare function resolveDestinationUrl(target: VerificationTarget | undefined, parsed: ParsedFlowUrl, options: DestinationOptions): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
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.js";
|
|
39
|
+
export const DEFAULT_PORTAL_URL = 'https://portal.reclaimprotocol.org';
|
|
40
|
+
/** The legacy share page. No longer a default — `verifier-app` sessions now
|
|
41
|
+
* carry a real Builder share URL — but kept as the value a deployment still
|
|
42
|
+
* pointing at `share.reclaimprotocol.org` would pass as `appLinkBaseUrl`. */
|
|
43
|
+
export const LEGACY_APP_LINK_BASE_URL = 'https://share.reclaimprotocol.org/verify';
|
|
44
|
+
/** Copies `api`/`sessionId`/`diag` onto `base`, preserving any query
|
|
45
|
+
* parameters `base` already carries (e.g. an App Clip's `?p=`). */
|
|
46
|
+
export function appendSessionQuery(base, parsed) {
|
|
47
|
+
const url = new URL(base);
|
|
48
|
+
url.searchParams.set('api', '2');
|
|
49
|
+
if (parsed.sessionId) {
|
|
50
|
+
url.searchParams.set('sessionId', parsed.sessionId);
|
|
51
|
+
}
|
|
52
|
+
if (parsed.diag) {
|
|
53
|
+
url.searchParams.set('diag', '1');
|
|
54
|
+
}
|
|
55
|
+
return url.toString();
|
|
56
|
+
}
|
|
57
|
+
function withoutTrailingSlash(value) {
|
|
58
|
+
return stripTrailingSlashes(value);
|
|
59
|
+
}
|
|
60
|
+
export function resolveDestinationUrl(target, parsed, options) {
|
|
61
|
+
switch (target) {
|
|
62
|
+
case 'builder':
|
|
63
|
+
return parsed.url.toString();
|
|
64
|
+
case 'portals': {
|
|
65
|
+
// Always rewritten here, even now that `/share/portals` also
|
|
66
|
+
// redirects server-side — see the file header for why this file,
|
|
67
|
+
// not Builder's route, has to own it.
|
|
68
|
+
const base = options.portalUrl ?? DEFAULT_PORTAL_URL;
|
|
69
|
+
return appendSessionQuery(`${withoutTrailingSlash(base)}/verify`, parsed);
|
|
70
|
+
}
|
|
71
|
+
case 'verifier-app': {
|
|
72
|
+
// The registered uri for `verifier-app` is Builder's own share page
|
|
73
|
+
// (`GET /s`), a real openable URL — so the verificationUrl already
|
|
74
|
+
// IS the share link, and rewriting it onto another origin would
|
|
75
|
+
// throw away the app-link association that makes a scan open the
|
|
76
|
+
// app (`build.reclaimprotocol.org` is what the app's entitlements
|
|
77
|
+
// and assetlinks now claim).
|
|
78
|
+
//
|
|
79
|
+
// `appLinkBaseUrl` stays as the override for a deployment that
|
|
80
|
+
// hosts its own share page, or one still pointing at the legacy
|
|
81
|
+
// `share.reclaimprotocol.org/verify`.
|
|
82
|
+
const base = options.appLinkBaseUrl;
|
|
83
|
+
return base ? appendSessionQuery(base, parsed) : parsed.url.toString();
|
|
84
|
+
}
|
|
85
|
+
case 'reclaim-browser-extension':
|
|
86
|
+
case undefined:
|
|
87
|
+
return parsed.url.toString();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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 } from './environment.ts';
|
|
22
|
+
export type DeviceKind = 'desktop' | 'ios' | 'android';
|
|
23
|
+
export type DetectionConfidence = 'high' | 'low';
|
|
24
|
+
export interface DeviceDetection {
|
|
25
|
+
device: DeviceKind;
|
|
26
|
+
confidence: DetectionConfidence;
|
|
27
|
+
}
|
|
28
|
+
/** Resolves the device this flow is running on, with a confidence a caller
|
|
29
|
+
* can act on instead of a silent guess. */
|
|
30
|
+
export declare function resolveDevice(env: LaunchEnvironment): DeviceDetection;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Matches the reference SDK's mobile/desktop scoring cutoff. */
|
|
2
|
+
const MOBILE_SCORE_THRESHOLD = 3;
|
|
3
|
+
/** A mobile/desktop score this close to the cutoff is a coin flip, not a
|
|
4
|
+
* confident read — surfaced as `confidence: 'low'`. */
|
|
5
|
+
const MOBILE_SCORE_MARGIN = 2;
|
|
6
|
+
function safeMatchMedia(win, query) {
|
|
7
|
+
try {
|
|
8
|
+
return win?.matchMedia?.(query)?.matches ?? false;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function safeCssSupports(win, property, value) {
|
|
15
|
+
try {
|
|
16
|
+
return win?.CSS?.supports?.(property, value) ?? false;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function safeQuerySelector(env, selector) {
|
|
23
|
+
try {
|
|
24
|
+
return !!env.document?.querySelector(selector);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Ported scoring pass. Returns the raw mobile score — positive leans
|
|
31
|
+
* mobile, negative leans desktop. */
|
|
32
|
+
function scoreMobile(env) {
|
|
33
|
+
const win = env.window;
|
|
34
|
+
const nav = env.navigator;
|
|
35
|
+
const userAgent = (nav?.userAgent ?? '').toLowerCase();
|
|
36
|
+
if (!win || !nav) {
|
|
37
|
+
// SSR / no environment: the reference SDK assumes desktop.
|
|
38
|
+
return -MOBILE_SCORE_THRESHOLD;
|
|
39
|
+
}
|
|
40
|
+
let score = 0;
|
|
41
|
+
const screenWidth = win.innerWidth || win.screen?.width || 0;
|
|
42
|
+
const screenHeight = win.innerHeight || win.screen?.height || 0;
|
|
43
|
+
const hasSmallScreen = screenWidth <= 480 || screenHeight <= 480;
|
|
44
|
+
const hasLargeScreen = screenWidth > 1024 && screenHeight > 768;
|
|
45
|
+
const hasTouch = 'ontouchstart' in win || (nav.maxTouchPoints ?? 0) > 0;
|
|
46
|
+
const hasPreciseMouse = safeMatchMedia(win, '(pointer: fine)');
|
|
47
|
+
const canHover = safeMatchMedia(win, '(hover: hover)');
|
|
48
|
+
const hasMouseAndTouch = hasTouch && hasPreciseMouse;
|
|
49
|
+
const isWindowsTouchLaptop = /windows/i.test(userAgent)
|
|
50
|
+
&& hasPreciseMouse && hasTouch;
|
|
51
|
+
if (hasTouch && !hasMouseAndTouch) {
|
|
52
|
+
score += 2;
|
|
53
|
+
}
|
|
54
|
+
else if (hasMouseAndTouch) {
|
|
55
|
+
score += 1;
|
|
56
|
+
}
|
|
57
|
+
if (hasSmallScreen && !isWindowsTouchLaptop) {
|
|
58
|
+
score += 2;
|
|
59
|
+
}
|
|
60
|
+
const mobileUaPattern = new RegExp('android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile', 'i');
|
|
61
|
+
const hasMobileUserAgent = mobileUaPattern.test(userAgent);
|
|
62
|
+
if (hasMobileUserAgent) {
|
|
63
|
+
score += 3;
|
|
64
|
+
}
|
|
65
|
+
const hasMobileApis = 'orientation' in win
|
|
66
|
+
|| 'DeviceMotionEvent' in win
|
|
67
|
+
|| 'DeviceOrientationEvent' in win;
|
|
68
|
+
const isDesktopSafari = /safari/i.test(userAgent)
|
|
69
|
+
&& !/mobile/i.test(userAgent)
|
|
70
|
+
&& /mac|intel/i.test(userAgent);
|
|
71
|
+
const mobileApisImplyMobile = hasMobileApis
|
|
72
|
+
&& (hasSmallScreen || hasMobileUserAgent)
|
|
73
|
+
&& !isDesktopSafari
|
|
74
|
+
&& !isWindowsTouchLaptop;
|
|
75
|
+
if (mobileApisImplyMobile) {
|
|
76
|
+
score += 2;
|
|
77
|
+
}
|
|
78
|
+
const hasHighDpi = (win.devicePixelRatio ?? 0) > 1.5;
|
|
79
|
+
if (hasHighDpi && hasSmallScreen) {
|
|
80
|
+
score += 1;
|
|
81
|
+
}
|
|
82
|
+
const hasViewportMeta = safeQuerySelector(env, 'meta[name="viewport"]');
|
|
83
|
+
if (hasViewportMeta && hasSmallScreen) {
|
|
84
|
+
score += 1;
|
|
85
|
+
}
|
|
86
|
+
const isPadProInDesktopMode = userAgent.includes('macintosh') && hasTouch;
|
|
87
|
+
if (isPadProInDesktopMode) {
|
|
88
|
+
score += 2;
|
|
89
|
+
}
|
|
90
|
+
if (hasLargeScreen && hasPreciseMouse) {
|
|
91
|
+
score -= 3;
|
|
92
|
+
}
|
|
93
|
+
else if (hasLargeScreen && !hasTouch) {
|
|
94
|
+
score -= 2;
|
|
95
|
+
}
|
|
96
|
+
if (hasPreciseMouse && canHover) {
|
|
97
|
+
score -= 2;
|
|
98
|
+
}
|
|
99
|
+
const isWindowsDesktop = /windows/i.test(userAgent) && !hasMobileUserAgent;
|
|
100
|
+
if (isWindowsDesktop) {
|
|
101
|
+
score -= 3;
|
|
102
|
+
}
|
|
103
|
+
// Chromium's low-entropy UA-CH `mobile` hint: an explicit, high-confidence
|
|
104
|
+
// signal where present, applied on top of the heuristic score above rather
|
|
105
|
+
// than replacing it (it's absent on WebKit/Firefox entirely).
|
|
106
|
+
if (nav.userAgentData?.mobile === true) {
|
|
107
|
+
score += 2;
|
|
108
|
+
}
|
|
109
|
+
else if (nav.userAgentData?.mobile === false) {
|
|
110
|
+
score -= 2;
|
|
111
|
+
}
|
|
112
|
+
return score;
|
|
113
|
+
}
|
|
114
|
+
/** Ported iOS/Android cascade. Only called once `scoreMobile` has decided the
|
|
115
|
+
* device is mobile. */
|
|
116
|
+
function guessMobileOs(env) {
|
|
117
|
+
const win = env.window;
|
|
118
|
+
const nav = env.navigator;
|
|
119
|
+
const userAgent = nav?.userAgent ?? '';
|
|
120
|
+
const hasTouch = !!win
|
|
121
|
+
&& ('ontouchstart' in win || (nav?.maxTouchPoints ?? 0) > 0);
|
|
122
|
+
if (/iPad|iPhone|iPod/i.test(userAgent)) {
|
|
123
|
+
return { os: 'ios', explicit: true };
|
|
124
|
+
}
|
|
125
|
+
// iPad Pro reports as a Mac (both in the UA string and in UA-CH's
|
|
126
|
+
// `platform` hint) once it gains a keyboard/trackpad, but keeps touch.
|
|
127
|
+
const isMacWithTouch = /Macintosh|MacIntel/i.test(userAgent) && hasTouch;
|
|
128
|
+
const isMacOsWithTouch = nav?.userAgentData?.platform === 'macOS' && hasTouch;
|
|
129
|
+
if (isMacWithTouch || isMacOsWithTouch) {
|
|
130
|
+
return { os: 'ios', explicit: true };
|
|
131
|
+
}
|
|
132
|
+
const hasIosPermissionApi = typeof win?.DeviceMotionEvent?.requestPermission
|
|
133
|
+
=== 'function';
|
|
134
|
+
const hasIosTouchCallout = safeCssSupports(win, '-webkit-touch-callout', 'none');
|
|
135
|
+
if (hasIosPermissionApi || hasIosTouchCallout) {
|
|
136
|
+
return { os: 'ios', explicit: true };
|
|
137
|
+
}
|
|
138
|
+
const isIosWebKit = /WebKit/i.test(userAgent)
|
|
139
|
+
&& !/Chrome|CriOS|Android/i.test(userAgent)
|
|
140
|
+
&& !/Macintosh|MacIntel/i.test(userAgent);
|
|
141
|
+
if (isIosWebKit) {
|
|
142
|
+
return { os: 'ios', explicit: true };
|
|
143
|
+
}
|
|
144
|
+
if (/Android/i.test(userAgent)) {
|
|
145
|
+
return { os: 'android', explicit: true };
|
|
146
|
+
}
|
|
147
|
+
if (nav?.userAgentData?.platform === 'Android') {
|
|
148
|
+
return { os: 'android', explicit: true };
|
|
149
|
+
}
|
|
150
|
+
// Final fallback: no rule matched. Android is the more common global
|
|
151
|
+
// install base, but this is a guess — callers see `confidence: 'low'`.
|
|
152
|
+
return { os: 'android', explicit: false };
|
|
153
|
+
}
|
|
154
|
+
/** Resolves the device this flow is running on, with a confidence a caller
|
|
155
|
+
* can act on instead of a silent guess. */
|
|
156
|
+
export function resolveDevice(env) {
|
|
157
|
+
const score = scoreMobile(env);
|
|
158
|
+
const isMobile = score >= MOBILE_SCORE_THRESHOLD;
|
|
159
|
+
const borderline = Math.abs(score - MOBILE_SCORE_THRESHOLD) < MOBILE_SCORE_MARGIN;
|
|
160
|
+
if (!isMobile) {
|
|
161
|
+
return { device: 'desktop', confidence: borderline ? 'low' : 'high' };
|
|
162
|
+
}
|
|
163
|
+
const guess = guessMobileOs(env);
|
|
164
|
+
return {
|
|
165
|
+
device: guess.os,
|
|
166
|
+
confidence: borderline || !guess.explicit ? 'low' : 'high',
|
|
167
|
+
};
|
|
168
|
+
}
|