@thru/passkey 0.2.21 → 0.2.23

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 (80) hide show
  1. package/dist/auth/add-device.cjs +139 -0
  2. package/dist/auth/add-device.cjs.map +1 -0
  3. package/dist/auth/add-device.d.cts +69 -0
  4. package/dist/auth/add-device.d.ts +69 -0
  5. package/dist/auth/add-device.js +7 -0
  6. package/dist/auth/add-device.js.map +1 -0
  7. package/dist/auth.cjs +121 -6
  8. package/dist/auth.cjs.map +1 -1
  9. package/dist/auth.d.cts +2 -0
  10. package/dist/auth.d.ts +2 -0
  11. package/dist/auth.js +10 -4
  12. package/dist/auth.js.map +1 -1
  13. package/dist/chunk-KASTJBBY.js +128 -0
  14. package/dist/chunk-KASTJBBY.js.map +1 -0
  15. package/dist/{chunk-75G2FPYW.js → chunk-OULTQZT7.js} +4 -3
  16. package/dist/chunk-OULTQZT7.js.map +1 -0
  17. package/dist/{chunk-B5SN7AS7.js → chunk-TW7HANJM.js} +99 -49
  18. package/dist/chunk-TW7HANJM.js.map +1 -0
  19. package/dist/{chunk-2JHC7OOH.js → chunk-ZNBMADOM.js} +2 -2
  20. package/dist/chunk-ZNBMADOM.js.map +1 -0
  21. package/dist/index.cjs +102 -50
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +3 -3
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +4 -2
  26. package/dist/mobile.cjs +2 -2
  27. package/dist/mobile.cjs.map +1 -1
  28. package/dist/mobile.d.cts +2 -2
  29. package/dist/mobile.d.ts +2 -2
  30. package/dist/mobile.js +2 -2
  31. package/dist/mobile.js.map +1 -1
  32. package/dist/popup.cjs +3 -2
  33. package/dist/popup.cjs.map +1 -1
  34. package/dist/popup.d.cts +3 -3
  35. package/dist/popup.d.ts +3 -3
  36. package/dist/popup.js +1 -1
  37. package/dist/server.cjs +54 -66
  38. package/dist/server.cjs.map +1 -1
  39. package/dist/server.d.cts +14 -6
  40. package/dist/server.d.ts +14 -6
  41. package/dist/server.js +58 -69
  42. package/dist/server.js.map +1 -1
  43. package/dist/{types-_HRzmn-j.d.cts → types-BTTlCVrw.d.cts} +25 -2
  44. package/dist/{types-_HRzmn-j.d.ts → types-BTTlCVrw.d.ts} +25 -2
  45. package/dist/web.cjs +99 -48
  46. package/dist/web.cjs.map +1 -1
  47. package/dist/web.d.cts +14 -7
  48. package/dist/web.d.ts +14 -7
  49. package/dist/web.js +3 -1
  50. package/package.json +11 -6
  51. package/src/auth/add-device.ts +236 -0
  52. package/src/auth/execute-tx.ts +1 -1
  53. package/src/auth/index.ts +11 -0
  54. package/src/auth/use-passkey-auth.ts +4 -2
  55. package/src/capabilities.ts +2 -1
  56. package/src/index.ts +4 -0
  57. package/src/label.test.ts +21 -0
  58. package/src/label.ts +14 -0
  59. package/src/mobile/index.ts +1 -1
  60. package/src/mobile/passkey.ts +1 -1
  61. package/src/mobile/storage.ts +1 -1
  62. package/src/mobile/types.ts +2 -2
  63. package/src/popup-service.ts +2 -1
  64. package/src/register.ts +23 -8
  65. package/src/server/challenge.ts +15 -4
  66. package/src/server/create-wallet.test.ts +2 -2
  67. package/src/server/create-wallet.ts +24 -16
  68. package/src/server/handlers.ts +6 -2
  69. package/src/server/submit.test.ts +24 -6
  70. package/src/server/submit.ts +41 -10
  71. package/src/server/types.ts +5 -3
  72. package/src/server/utils.ts +1 -1
  73. package/src/sign.ts +127 -37
  74. package/src/types.ts +27 -2
  75. package/src/web.ts +6 -2
  76. package/tsconfig.json +3 -2
  77. package/tsup.config.ts +1 -0
  78. package/dist/chunk-2JHC7OOH.js.map +0 -1
  79. package/dist/chunk-75G2FPYW.js.map +0 -1
  80. package/dist/chunk-B5SN7AS7.js.map +0 -1
@@ -8,14 +8,14 @@ import {
8
8
  deriveWalletAddress,
9
9
  encodeCreateInstruction,
10
10
  encodeRegisterCredentialInstruction,
11
- } from '@thru/passkey-manager';
11
+ } from '@thru/programs/passkey-manager';
12
12
  import {
13
13
  toThruAddress,
14
14
  getStateProof,
15
15
  trackTransaction,
16
16
  withSerializedFeePayer,
17
- } from './utils';
18
- import type { ThruClient } from './types';
17
+ } from "./utils";
18
+ import type { ThruClient } from "./types";
19
19
 
20
20
  export async function createPasskeyWallet(opts: {
21
21
  client: ThruClient;
@@ -27,9 +27,12 @@ export async function createPasskeyWallet(opts: {
27
27
  credentialId?: string;
28
28
  walletName?: string;
29
29
  }): Promise<{ walletAddress: string; credentialLookupAddress?: string }> {
30
- const walletName = opts.walletName ?? 'default';
30
+ const walletName = opts.walletName ?? "default";
31
31
  const seed = await createWalletSeed(walletName, opts.pubkeyX, opts.pubkeyY);
32
- const walletBytes = await deriveWalletAddress(seed, PASSKEY_MANAGER_PROGRAM_ADDRESS);
32
+ const walletBytes = await deriveWalletAddress(
33
+ seed,
34
+ PASSKEY_MANAGER_PROGRAM_ADDRESS,
35
+ );
33
36
  const walletAddress = toThruAddress(walletBytes);
34
37
 
35
38
  await withSerializedFeePayer(opts.adminPublicKey, async () => {
@@ -77,11 +80,13 @@ export async function createPasskeyWallet(opts: {
77
80
  await transaction.sign(opts.adminPrivateKey);
78
81
  const signature = await opts.client.transactions.send(transaction.toWire());
79
82
  const result = await trackTransaction(opts.client, signature, 60000);
80
- if (result.status !== 'finalized') {
83
+ if (result.status !== "finalized") {
81
84
  throw new Error(
82
85
  `Wallet creation failed with status: ${result.status}${
83
- result.errorCode !== undefined ? ` (error code: ${result.errorCode})` : ''
84
- }`
86
+ result.errorCode !== undefined
87
+ ? ` (error code: ${result.errorCode})`
88
+ : ""
89
+ }`,
85
90
  );
86
91
  }
87
92
  });
@@ -91,8 +96,7 @@ export async function createPasskeyWallet(opts: {
91
96
  const credentialIdBytes = base64UrlToBytes(opts.credentialId);
92
97
  const lookupAddressBytes = await deriveCredentialLookupAddress(
93
98
  credentialIdBytes,
94
- walletName,
95
- PASSKEY_MANAGER_PROGRAM_ADDRESS
99
+ PASSKEY_MANAGER_PROGRAM_ADDRESS,
96
100
  );
97
101
  const lookupAddress = toThruAddress(lookupAddressBytes);
98
102
 
@@ -110,7 +114,7 @@ export async function createPasskeyWallet(opts: {
110
114
 
111
115
  if (lookupExists) return;
112
116
 
113
- const credSeed = await createCredentialLookupSeed(credentialIdBytes, walletName);
117
+ const credSeed = await createCredentialLookupSeed(credentialIdBytes);
114
118
  const stateProof = await getStateProof(opts.client, lookupAddress);
115
119
  const accountCtx = buildAccountContext({
116
120
  walletAddress,
@@ -139,18 +143,22 @@ export async function createPasskeyWallet(opts: {
139
143
  });
140
144
 
141
145
  await transaction.sign(opts.adminPrivateKey);
142
- const signature = await opts.client.transactions.send(transaction.toWire());
146
+ const signature = await opts.client.transactions.send(
147
+ transaction.toWire(),
148
+ );
143
149
  const result = await trackTransaction(opts.client, signature, 60000);
144
- if (result.status !== 'finalized') {
150
+ if (result.status !== "finalized") {
145
151
  throw new Error(
146
152
  `Credential registration failed with status: ${result.status}${
147
- result.errorCode !== undefined ? ` (error code: ${result.errorCode})` : ''
148
- }`
153
+ result.errorCode !== undefined
154
+ ? ` (error code: ${result.errorCode})`
155
+ : ""
156
+ }`,
149
157
  );
150
158
  }
151
159
  });
152
160
  } catch (error) {
153
- console.warn('Credential registration failed (non-fatal):', error);
161
+ console.warn("Credential registration failed (non-fatal):", error);
154
162
  }
155
163
  }
156
164
 
@@ -45,7 +45,9 @@ export function createPasskeyHandlers<P>(opts: {
45
45
  client: opts.client,
46
46
  walletAddress,
47
47
  accountCtx: context.accountCtx,
48
- invokeIx: context.invokeIx,
48
+ targetProgramAddress: context.targetProgramAddress,
49
+ instructionData: context.instructionData,
50
+ authIdx: context.authIdx,
49
51
  });
50
52
 
51
53
  pendingContexts.set(
@@ -85,7 +87,9 @@ export function createPasskeyHandlers<P>(opts: {
85
87
  adminPrivateKey: opts.adminPrivateKey,
86
88
  walletAddress,
87
89
  accountCtx: pending.context.accountCtx,
88
- invokeIx: pending.context.invokeIx,
90
+ targetProgramAddress: pending.context.targetProgramAddress,
91
+ instructionData: pending.context.instructionData,
92
+ authIdx: pending.context.authIdx,
89
93
  ...signaturePayload,
90
94
  });
91
95
  },
@@ -1,20 +1,24 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import type { AccountContext } from '@thru/passkey-manager';
2
+ import type { AccountContext } from '@thru/programs/passkey-manager';
3
3
  import type { ThruClient } from './types';
4
4
 
5
- vi.mock('@thru/passkey-manager', () => ({
5
+ const passkeyManagerMocks = vi.hoisted(() => ({
6
6
  PASSKEY_MANAGER_PROGRAM_ADDRESS: 'passkey-program',
7
- concatenateInstructions: (instructions: Uint8Array[]) => new Uint8Array(instructions.flatMap((ix) => Array.from(ix))),
8
- encodeValidateInstruction: () => new Uint8Array([1, 2]),
7
+ decodeAddress: vi.fn(() => new Uint8Array(32).fill(7)),
8
+ encodeValidateInstruction: vi.fn(() => new Uint8Array([1, 2])),
9
9
  hexToBytes: (value: string) => new Uint8Array(Buffer.from(value, 'hex')),
10
10
  }));
11
11
 
12
+ vi.mock('@thru/programs/passkey-manager', () => passkeyManagerMocks);
13
+
12
14
  import { buildPasskeyTransaction, submitPasskeyTransaction } from './submit';
13
15
 
14
16
  const accountCtx = {
15
17
  walletAccountIdx: 3,
18
+ accountAddresses: ['fee-payer', 'passkey-program', 'wallet-address', 'readonly-address'],
16
19
  readWriteAddresses: ['lookup-address', 'wallet-address'],
17
20
  readOnlyAddresses: ['readonly-address'],
21
+ getAccountIndex: vi.fn(() => 4),
18
22
  } as AccountContext;
19
23
 
20
24
  const signaturePayload = {
@@ -58,7 +62,8 @@ describe('passkey submit', () => {
58
62
  adminPrivateKey: new Uint8Array(32).fill(2),
59
63
  walletAddress: 'wallet-address',
60
64
  accountCtx,
61
- invokeIx: new Uint8Array([3, 4]),
65
+ targetProgramAddress: 'target-program',
66
+ instructionData: new Uint8Array([3, 4]),
62
67
  header: {
63
68
  fee: 0n,
64
69
  nonce: 42n,
@@ -76,7 +81,19 @@ describe('passkey submit', () => {
76
81
  fee: 0n,
77
82
  nonce: 42n,
78
83
  },
84
+ instructionData: new Uint8Array([1, 2]),
79
85
  }));
86
+ expect(passkeyManagerMocks.decodeAddress).toHaveBeenCalledWith('target-program');
87
+ expect(passkeyManagerMocks.encodeValidateInstruction).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ walletAccountIdx: 3,
90
+ authIdx: 0,
91
+ targetInstruction: {
92
+ programIdx: 4,
93
+ instructionData: new Uint8Array([3, 4]),
94
+ },
95
+ })
96
+ );
80
97
  expect(transaction.sign).toHaveBeenCalledWith(new Uint8Array(32).fill(2));
81
98
  expect(result.rawTransaction).toEqual(new Uint8Array([9, 9, 9]));
82
99
  });
@@ -90,7 +107,8 @@ describe('passkey submit', () => {
90
107
  adminPrivateKey: new Uint8Array(32).fill(2),
91
108
  walletAddress: 'wallet-address',
92
109
  accountCtx,
93
- invokeIx: new Uint8Array([3, 4]),
110
+ targetProgramAddress: 'target-program',
111
+ instructionData: new Uint8Array([3, 4]),
94
112
  ...signaturePayload,
95
113
  })).resolves.toEqual({
96
114
  signature: 'tx-signature',
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  PASSKEY_MANAGER_PROGRAM_ADDRESS,
3
- concatenateInstructions,
3
+ decodeAddress,
4
4
  encodeValidateInstruction,
5
5
  hexToBytes,
6
- } from '@thru/passkey-manager';
7
- import type { AccountContext } from '@thru/passkey-manager';
6
+ } from '@thru/programs/passkey-manager';
7
+ import type { AccountContext } from '@thru/programs/passkey-manager';
8
8
  import { trackTransaction, withSerializedFeePayer } from './utils';
9
9
  import type {
10
10
  BuiltPasskeyTransaction,
@@ -14,6 +14,27 @@ import type {
14
14
  TransactionResult,
15
15
  } from './types';
16
16
 
17
+ function base64ToBytes(base64: string): Uint8Array {
18
+ type BufferLike = {
19
+ from(value: string, encoding: 'base64'): Uint8Array;
20
+ };
21
+ const globalBuffer = (globalThis as { Buffer?: BufferLike }).Buffer;
22
+ if (globalBuffer) {
23
+ return globalBuffer.from(base64, 'base64');
24
+ }
25
+
26
+ if (typeof atob === 'function') {
27
+ const binary = atob(base64);
28
+ const bytes = new Uint8Array(binary.length);
29
+ for (let i = 0; i < binary.length; i++) {
30
+ bytes[i] = binary.charCodeAt(i);
31
+ }
32
+ return bytes;
33
+ }
34
+
35
+ throw new Error('Base64 decoding is not supported in this environment');
36
+ }
37
+
17
38
  /**
18
39
  * Builds and signs a passkey-manager transaction without submitting it.
19
40
  *
@@ -27,23 +48,31 @@ export async function buildPasskeyTransaction(opts: {
27
48
  adminPrivateKey: Uint8Array;
28
49
  walletAddress: string;
29
50
  accountCtx: AccountContext;
30
- invokeIx: Uint8Array;
51
+ targetProgramAddress: string;
52
+ instructionData: Uint8Array;
53
+ authIdx?: number;
31
54
  header?: PasskeyTransactionHeaderOverrides;
32
55
  } & PasskeySignaturePayload): Promise<BuiltPasskeyTransaction> {
56
+ const targetProgramIdx = opts.accountCtx.getAccountIndex(
57
+ decodeAddress(opts.targetProgramAddress)
58
+ );
33
59
  const validateIx = encodeValidateInstruction({
34
60
  walletAccountIdx: opts.accountCtx.walletAccountIdx,
35
- authIdx: 0,
61
+ authIdx: opts.authIdx ?? 0,
62
+ targetInstruction: {
63
+ programIdx: targetProgramIdx,
64
+ instructionData: opts.instructionData,
65
+ },
36
66
  signatureR: hexToBytes(opts.signatureR),
37
67
  signatureS: hexToBytes(opts.signatureS),
38
- authenticatorData: Buffer.from(opts.authenticatorData, 'base64'),
39
- clientDataJSON: Buffer.from(opts.clientDataJSON, 'base64'),
68
+ authenticatorData: base64ToBytes(opts.authenticatorData),
69
+ clientDataJSON: base64ToBytes(opts.clientDataJSON),
40
70
  });
41
71
 
42
- const instructionData = concatenateInstructions([validateIx, opts.invokeIx]);
43
72
  const transaction = await opts.client.transactions.build({
44
73
  feePayer: { publicKey: opts.adminPublicKey },
45
74
  program: PASSKEY_MANAGER_PROGRAM_ADDRESS,
46
- instructionData,
75
+ instructionData: validateIx,
47
76
  accounts: {
48
77
  readWrite: opts.accountCtx.readWriteAddresses,
49
78
  readOnly: opts.accountCtx.readOnlyAddresses,
@@ -67,7 +96,9 @@ export async function submitPasskeyTransaction(opts: {
67
96
  adminPrivateKey: Uint8Array;
68
97
  walletAddress: string;
69
98
  accountCtx: AccountContext;
70
- invokeIx: Uint8Array;
99
+ targetProgramAddress: string;
100
+ instructionData: Uint8Array;
101
+ authIdx?: number;
71
102
  header?: PasskeyTransactionHeaderOverrides;
72
103
  } & PasskeySignaturePayload): Promise<TransactionResult> {
73
104
  return withSerializedFeePayer(opts.adminPublicKey, async () => {
@@ -1,5 +1,5 @@
1
- import type { AccountContext } from '@thru/passkey-manager';
2
- import type { TransactionHeaderConfig } from '@thru/thru-sdk';
1
+ import type { AccountContext } from '@thru/programs/passkey-manager';
2
+ import type { TransactionHeaderConfig } from '@thru/sdk';
3
3
 
4
4
  export type PasskeyTransactionHeaderOverrides = TransactionHeaderConfig;
5
5
 
@@ -79,5 +79,7 @@ export interface PasskeyChallengeResult {
79
79
 
80
80
  export interface PasskeyContextResult {
81
81
  accountCtx: AccountContext;
82
- invokeIx: Uint8Array;
82
+ targetProgramAddress: string;
83
+ instructionData: Uint8Array;
84
+ authIdx?: number;
83
85
  }
@@ -1,4 +1,4 @@
1
- import { encodeAddress } from '@thru/helpers';
1
+ import { encodeAddress } from '@thru/sdk/helpers';
2
2
  import type { ThruClient, TransactionResult } from './types';
3
3
 
4
4
  const feePayerQueueSymbol = Symbol.for('thru.sharedFeePayerQueues');
package/src/sign.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  PasskeyPopupContext,
7
7
  PasskeyPopupSigningResult,
8
8
  PasskeyPopupStoredSigningResult,
9
+ PasskeyStoredSigningOptions,
9
10
  } from './types';
10
11
  import {
11
12
  arrayBufferToBase64Url,
@@ -14,7 +15,7 @@ import {
14
15
  base64UrlToBytes,
15
16
  parseDerSignature,
16
17
  normalizeLowS,
17
- } from '@thru/passkey-manager';
18
+ } from '@thru/programs/passkey-manager';
18
19
  import {
19
20
  isWebAuthnSupported,
20
21
  getPasskeyPromptMode,
@@ -23,7 +24,11 @@ import {
23
24
  shouldFallbackToPopup,
24
25
  type PasskeyPromptAction,
25
26
  } from './capabilities';
26
- import { requestPasskeyPopup, openPasskeyPopupWindow, closePopup } from './popup';
27
+ import {
28
+ requestPasskeyPopup,
29
+ openPasskeyPopupWindow,
30
+ closePopup,
31
+ } from './popup';
27
32
 
28
33
  /**
29
34
  * Sign a challenge with an existing passkey (by credential ID).
@@ -40,7 +45,8 @@ export async function signWithPasskey(
40
45
  return runWithPromptMode(
41
46
  'get',
42
47
  () => signWithPasskeyInline(credentialId, challenge, rpId),
43
- (preopenedPopup) => signWithPasskeyViaPopup(credentialId, challenge, rpId, preopenedPopup)
48
+ (preopenedPopup) =>
49
+ signWithPasskeyViaPopup(credentialId, challenge, rpId, preopenedPopup)
44
50
  );
45
51
  }
46
52
 
@@ -52,16 +58,31 @@ export async function signWithStoredPasskey(
52
58
  rpId: string,
53
59
  preferredPasskey: PasskeyMetadata | null,
54
60
  allPasskeys: PasskeyMetadata[],
55
- context?: PasskeyPopupContext
61
+ context?: PasskeyPopupContext,
62
+ options: PasskeyStoredSigningOptions = {}
56
63
  ): Promise<PasskeyStoredSigningResult> {
57
64
  if (!isWebAuthnSupported()) {
58
65
  throw new Error('WebAuthn is not supported in this browser');
59
66
  }
60
67
 
61
- const preopenedPopup = maybePreopenPopup('get', openPasskeyPopupWindow);
62
- const promptMode = await getPasskeyPromptMode('get');
68
+ const allowPopupFallback = options.allowPopupFallback ?? true;
69
+ const preopenedPopup = allowPopupFallback
70
+ ? maybePreopenPopup('get', openPasskeyPopupWindow)
71
+ : null;
72
+ const promptMode = allowPopupFallback
73
+ ? await getPasskeyPromptMode('get')
74
+ : 'inline';
63
75
  const storedPasskey = preferredPasskey;
64
- const canUsePopup = isInIframe();
76
+ const canUsePopup = allowPopupFallback && isInIframe();
77
+
78
+ if (options.preferDiscoverable) {
79
+ closePopup(preopenedPopup);
80
+ return signWithDiscoverableStoredPasskey(
81
+ challenge,
82
+ storedPasskey?.rpId ?? rpId,
83
+ allPasskeys
84
+ );
85
+ }
65
86
 
66
87
  if (promptMode === 'popup' || (canUsePopup && !storedPasskey)) {
67
88
  return requestStoredPasskeyPopup(challenge, preopenedPopup, context);
@@ -71,37 +92,29 @@ export async function signWithStoredPasskey(
71
92
 
72
93
  try {
73
94
  if (storedPasskey) {
74
- const result = await signWithPasskeyInline(
75
- storedPasskey.credentialId,
76
- challenge,
77
- storedPasskey.rpId
78
- );
79
- return {
80
- ...result,
81
- passkey: storedPasskey,
82
- };
95
+ try {
96
+ const result = await signWithPasskeyInline(
97
+ storedPasskey.credentialId,
98
+ challenge,
99
+ storedPasskey.rpId
100
+ );
101
+ return {
102
+ ...result,
103
+ passkey: storedPasskey,
104
+ };
105
+ } catch (error) {
106
+ if (!shouldFallbackToDiscoverable(error)) {
107
+ throw error;
108
+ }
109
+ return signWithDiscoverableStoredPasskey(
110
+ challenge,
111
+ storedPasskey.rpId,
112
+ allPasskeys
113
+ );
114
+ }
83
115
  }
84
116
 
85
- const discoverable = await signWithDiscoverablePasskey(challenge, rpId);
86
- const matchingPasskey = allPasskeys.find(p => p.credentialId === discoverable.credentialId) ?? null;
87
- const now = new Date().toISOString();
88
- const passkey = matchingPasskey ?? {
89
- credentialId: discoverable.credentialId,
90
- publicKeyX: '',
91
- publicKeyY: '',
92
- rpId: discoverable.rpId,
93
- createdAt: now,
94
- lastUsedAt: now,
95
- };
96
-
97
- return {
98
- signature: discoverable.signature,
99
- authenticatorData: discoverable.authenticatorData,
100
- clientDataJSON: discoverable.clientDataJSON,
101
- signatureR: discoverable.signatureR,
102
- signatureS: discoverable.signatureS,
103
- passkey,
104
- };
117
+ return signWithDiscoverableStoredPasskey(challenge, rpId, allPasskeys);
105
118
  } catch (error) {
106
119
  if (canUsePopup && shouldFallbackToPopup(error)) {
107
120
  return requestStoredPasskeyPopup(challenge, undefined, context);
@@ -111,6 +124,66 @@ export async function signWithStoredPasskey(
111
124
  }
112
125
  }
113
126
 
127
+ async function signWithDiscoverableStoredPasskey(
128
+ challenge: Uint8Array,
129
+ rpId: string,
130
+ allPasskeys: PasskeyMetadata[]
131
+ ): Promise<PasskeyStoredSigningResult> {
132
+ const discoverable = await signWithDiscoverablePasskey(challenge, rpId);
133
+ const matchingPasskey =
134
+ allPasskeys.find((p) => p.credentialId === discoverable.credentialId) ??
135
+ null;
136
+ const now = new Date().toISOString();
137
+ const passkey = matchingPasskey ?? {
138
+ credentialId: discoverable.credentialId,
139
+ publicKeyX: '',
140
+ publicKeyY: '',
141
+ rpId: discoverable.rpId,
142
+ createdAt: now,
143
+ lastUsedAt: now,
144
+ };
145
+
146
+ return {
147
+ signature: discoverable.signature,
148
+ authenticatorData: discoverable.authenticatorData,
149
+ clientDataJSON: discoverable.clientDataJSON,
150
+ signatureR: discoverable.signatureR,
151
+ signatureS: discoverable.signatureS,
152
+ authenticatorAttachment: discoverable.authenticatorAttachment,
153
+ passkey,
154
+ };
155
+ }
156
+
157
+ function shouldFallbackToDiscoverable(error: unknown): boolean {
158
+ const name =
159
+ error && typeof error === 'object' && 'name' in error
160
+ ? String((error as { name?: unknown }).name)
161
+ : '';
162
+ const message =
163
+ error && typeof error === 'object' && 'message' in error
164
+ ? String((error as { message?: unknown }).message)
165
+ : '';
166
+ const normalized = `${name} ${message}`.toLowerCase();
167
+
168
+ if (
169
+ normalized.includes('user rejected') ||
170
+ normalized.includes('user canceled') ||
171
+ normalized.includes('user cancelled')
172
+ ) {
173
+ return false;
174
+ }
175
+
176
+ return (
177
+ normalized.includes('notallowederror') ||
178
+ normalized.includes('invalidstateerror') ||
179
+ normalized.includes('notfounderror') ||
180
+ normalized.includes('not found') ||
181
+ normalized.includes('no passkey') ||
182
+ normalized.includes('no credential') ||
183
+ normalized.includes('saved for this app')
184
+ );
185
+ }
186
+
114
187
  /**
115
188
  * Sign with a discoverable passkey (no credential ID - browser prompts user to select).
116
189
  */
@@ -133,6 +206,7 @@ export async function signWithDiscoverablePasskey(
133
206
  signatureS: result.signatureS,
134
207
  credentialId: result.credentialId,
135
208
  rpId: resolvedRpId,
209
+ authenticatorAttachment: result.authenticatorAttachment,
136
210
  };
137
211
  }
138
212
 
@@ -173,6 +247,7 @@ async function signWithPasskeyInline(
173
247
  clientDataJSON: result.clientDataJSON,
174
248
  signatureR: result.signatureR,
175
249
  signatureS: result.signatureS,
250
+ authenticatorAttachment: result.authenticatorAttachment,
176
251
  };
177
252
  }
178
253
 
@@ -214,6 +289,17 @@ async function signWithPasskeyAssertion(
214
289
  let { r, s } = parseDerSignature(signature);
215
290
  s = normalizeLowS(s);
216
291
 
292
+ /* `authenticatorAttachment` distinguishes a same-device passkey
293
+ ('platform') from a cross-device one signed via QR / hybrid
294
+ transport ('cross-platform'). Drives the wallet's add-device
295
+ prompt. Browsers may report null. */
296
+ const rawAttachment =
297
+ (
298
+ assertion as PublicKeyCredential & {
299
+ authenticatorAttachment?: AuthenticatorAttachment | null;
300
+ }
301
+ ).authenticatorAttachment ?? null;
302
+
217
303
  return {
218
304
  signature: new Uint8Array([...r, ...s]),
219
305
  authenticatorData: new Uint8Array(response.authenticatorData),
@@ -221,6 +307,7 @@ async function signWithPasskeyAssertion(
221
307
  signatureR: r,
222
308
  signatureS: s,
223
309
  credentialId: arrayBufferToBase64Url(assertion.rawId),
310
+ authenticatorAttachment: rawAttachment,
224
311
  };
225
312
  }
226
313
 
@@ -259,13 +346,16 @@ async function requestStoredPasskeyPopup(
259
346
  return decodePopupStoredSigningResult(result);
260
347
  }
261
348
 
262
- function decodePopupSigningResult(result: PasskeyPopupSigningResult): PasskeySigningResult {
349
+ function decodePopupSigningResult(
350
+ result: PasskeyPopupSigningResult
351
+ ): PasskeySigningResult {
263
352
  return {
264
353
  signature: base64UrlToBytes(result.signatureBase64Url),
265
354
  authenticatorData: base64UrlToBytes(result.authenticatorDataBase64Url),
266
355
  clientDataJSON: base64UrlToBytes(result.clientDataJSONBase64Url),
267
356
  signatureR: base64UrlToBytes(result.signatureRBase64Url),
268
357
  signatureS: base64UrlToBytes(result.signatureSBase64Url),
358
+ authenticatorAttachment: result.authenticatorAttachment ?? null,
269
359
  };
270
360
  }
271
361
 
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PasskeySigningResult, PasskeyMetadata } from '@thru/passkey-manager';
1
+ import type { PasskeySigningResult, PasskeyMetadata } from '@thru/programs/passkey-manager';
2
2
 
3
3
  // Re-export platform-agnostic types for backward compatibility
4
4
  export type {
@@ -6,7 +6,7 @@ export type {
6
6
  PasskeySigningResult,
7
7
  PasskeyDiscoverableSigningResult,
8
8
  PasskeyMetadata,
9
- } from '@thru/passkey-manager';
9
+ } from '@thru/programs/passkey-manager';
10
10
 
11
11
  /**
12
12
  * Signing result with stored passkey metadata attached.
@@ -44,6 +44,25 @@ export interface PasskeyPopupContext {
44
44
  imageUrl?: string;
45
45
  }
46
46
 
47
+ /**
48
+ * Options for stored passkey signing in embedded contexts.
49
+ */
50
+ export interface PasskeyStoredSigningOptions {
51
+ allowPopupFallback?: boolean;
52
+ /** Prefer an RP-scoped discoverable credential prompt over a stored
53
+ * credential-id lookup. Native WebViews can show misleading
54
+ * app-level "no passkey" UI when allowCredentials is stale, while a
55
+ * discoverable prompt correctly lets the wallet/RP choose the passkey. */
56
+ preferDiscoverable?: boolean;
57
+ }
58
+
59
+ /**
60
+ * Options for passkey registration in embedded contexts.
61
+ */
62
+ export interface PasskeyRegistrationOptions {
63
+ allowPopupFallback?: boolean;
64
+ }
65
+
47
66
  /**
48
67
  * Account info passed through popup bridge.
49
68
  */
@@ -95,6 +114,7 @@ export interface PasskeyPopupSigningResult {
95
114
  clientDataJSONBase64Url: string;
96
115
  signatureRBase64Url: string;
97
116
  signatureSBase64Url: string;
117
+ authenticatorAttachment?: 'platform' | 'cross-platform' | null;
98
118
  }
99
119
 
100
120
  export interface PasskeyPopupStoredPasskey {
@@ -103,6 +123,10 @@ export interface PasskeyPopupStoredPasskey {
103
123
  publicKeyY: string;
104
124
  rpId: string;
105
125
  label?: string;
126
+ deviceName?: string;
127
+ devicePlatform?: string;
128
+ browserName?: string;
129
+ authenticatorAttachment?: 'platform' | 'cross-platform' | null;
106
130
  createdAt: string;
107
131
  lastUsedAt: string;
108
132
  }
@@ -117,6 +141,7 @@ export interface PasskeyPopupRegistrationResult {
117
141
  publicKeyX: string;
118
142
  publicKeyY: string;
119
143
  rpId: string;
144
+ authenticatorAttachment?: 'platform' | 'cross-platform' | null;
120
145
  }
121
146
 
122
147
  export type PasskeyPopupResponse =
package/src/web.ts CHANGED
@@ -7,9 +7,13 @@ export type {
7
7
  PasskeyClientCapabilities,
8
8
  PasskeyPopupContext,
9
9
  PasskeyPopupAccount,
10
+ PasskeyStoredSigningOptions,
11
+ PasskeyRegistrationOptions,
10
12
  } from './types';
11
13
 
12
14
  export { registerPasskey } from './register';
15
+ export { createDistinctPasskeyLabel } from './label';
16
+ export type { DistinctPasskeyLabelOptions } from './label';
13
17
 
14
18
  export {
15
19
  signWithPasskey,
@@ -25,7 +29,7 @@ export {
25
29
  P256_HALF_N,
26
30
  bytesToBigIntBE,
27
31
  bigIntToBytesBE,
28
- } from '@thru/passkey-manager';
32
+ } from '@thru/programs/passkey-manager';
29
33
 
30
34
  export {
31
35
  isWebAuthnSupported,
@@ -48,4 +52,4 @@ export {
48
52
  bytesEqual,
49
53
  compareBytes,
50
54
  uniqueAccounts,
51
- } from '@thru/passkey-manager';
55
+ } from '@thru/programs/passkey-manager';
package/tsconfig.json CHANGED
@@ -4,9 +4,10 @@
4
4
  "outDir": "./dist",
5
5
  "rootDir": "..",
6
6
  "baseUrl": ".",
7
+ "ignoreDeprecations": "6.0",
7
8
  "paths": {
8
- "@thru/helpers": ["../helpers/src/index.ts"],
9
- "@thru/passkey-manager": ["../passkey-manager/src/index.ts"]
9
+ "@thru/sdk/helpers": ["../sdk/src/helpers/index.ts"],
10
+ "@thru/programs/passkey-manager": ["../programs/src/passkey-manager/index.ts"]
10
11
  }
11
12
  },
12
13
  "include": ["src/**/*"],
package/tsup.config.ts CHANGED
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  popup: 'src/popup-entry.ts',
9
9
  mobile: 'src/mobile/index.ts',
10
10
  auth: 'src/auth/index.ts',
11
+ 'auth/add-device': 'src/auth/add-device.ts',
11
12
  server: 'src/server/index.ts',
12
13
  },
13
14
  external: ['expo-secure-store', 'react-native', 'react-native-passkeys', 'zustand'],