accounts 0.3.0 → 0.4.1

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 (168) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/dist/core/AccessKey.d.ts +55 -0
  5. package/dist/core/AccessKey.d.ts.map +1 -0
  6. package/dist/core/AccessKey.js +69 -0
  7. package/dist/core/AccessKey.js.map +1 -0
  8. package/dist/core/Account.d.ts +91 -0
  9. package/dist/core/Account.d.ts.map +1 -0
  10. package/dist/core/Account.js +64 -0
  11. package/dist/core/Account.js.map +1 -0
  12. package/dist/core/Adapter.d.ts +187 -0
  13. package/dist/core/Adapter.d.ts.map +1 -0
  14. package/dist/core/Adapter.js +7 -0
  15. package/dist/core/Adapter.js.map +1 -0
  16. package/dist/core/Ceremony.d.ts +109 -0
  17. package/dist/core/Ceremony.d.ts.map +1 -0
  18. package/dist/core/Ceremony.js +104 -0
  19. package/dist/core/Ceremony.js.map +1 -0
  20. package/dist/core/Client.d.ts +16 -0
  21. package/dist/core/Client.d.ts.map +1 -0
  22. package/dist/core/Client.js +18 -0
  23. package/dist/core/Client.js.map +1 -0
  24. package/dist/core/Dialog.d.ts +52 -0
  25. package/dist/core/Dialog.d.ts.map +1 -0
  26. package/dist/core/Dialog.js +342 -0
  27. package/dist/core/Dialog.js.map +1 -0
  28. package/dist/core/Expiry.d.ts +15 -0
  29. package/dist/core/Expiry.d.ts.map +1 -0
  30. package/dist/core/Expiry.js +29 -0
  31. package/dist/core/Expiry.js.map +1 -0
  32. package/dist/core/Messenger.d.ts +86 -0
  33. package/dist/core/Messenger.d.ts.map +1 -0
  34. package/dist/core/Messenger.js +127 -0
  35. package/dist/core/Messenger.js.map +1 -0
  36. package/dist/core/Provider.d.ts +69 -0
  37. package/dist/core/Provider.d.ts.map +1 -0
  38. package/dist/core/Provider.js +401 -0
  39. package/dist/core/Provider.js.map +1 -0
  40. package/dist/core/Remote.d.ts +114 -0
  41. package/dist/core/Remote.d.ts.map +1 -0
  42. package/dist/core/Remote.js +116 -0
  43. package/dist/core/Remote.js.map +1 -0
  44. package/dist/core/Schema.d.ts +805 -0
  45. package/dist/core/Schema.d.ts.map +1 -0
  46. package/dist/core/Schema.js +43 -0
  47. package/dist/core/Schema.js.map +1 -0
  48. package/dist/core/Storage.d.ts +42 -0
  49. package/dist/core/Storage.d.ts.map +1 -0
  50. package/dist/core/Storage.js +173 -0
  51. package/dist/core/Storage.js.map +1 -0
  52. package/dist/core/Store.d.ts +58 -0
  53. package/dist/core/Store.d.ts.map +1 -0
  54. package/dist/core/Store.js +58 -0
  55. package/dist/core/Store.js.map +1 -0
  56. package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
  57. package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
  58. package/dist/core/adapters/dangerous_secp256k1.js +39 -0
  59. package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
  60. package/dist/core/adapters/dialog.d.ts +31 -0
  61. package/dist/core/adapters/dialog.d.ts.map +1 -0
  62. package/dist/core/adapters/dialog.js +306 -0
  63. package/dist/core/adapters/dialog.js.map +1 -0
  64. package/dist/core/adapters/local.d.ts +33 -0
  65. package/dist/core/adapters/local.d.ts.map +1 -0
  66. package/dist/core/adapters/local.js +227 -0
  67. package/dist/core/adapters/local.js.map +1 -0
  68. package/dist/core/adapters/webAuthn.d.ts +36 -0
  69. package/dist/core/adapters/webAuthn.d.ts.map +1 -0
  70. package/dist/core/adapters/webAuthn.js +93 -0
  71. package/dist/core/adapters/webAuthn.js.map +1 -0
  72. package/dist/core/internal/withDedupe.d.ts +12 -0
  73. package/dist/core/internal/withDedupe.d.ts.map +1 -0
  74. package/dist/core/internal/withDedupe.js +12 -0
  75. package/dist/core/internal/withDedupe.js.map +1 -0
  76. package/dist/core/zod/request.d.ts +31 -0
  77. package/dist/core/zod/request.d.ts.map +1 -0
  78. package/dist/core/zod/request.js +41 -0
  79. package/dist/core/zod/request.js.map +1 -0
  80. package/dist/core/zod/rpc.d.ts +603 -0
  81. package/dist/core/zod/rpc.d.ts.map +1 -0
  82. package/dist/core/zod/rpc.js +293 -0
  83. package/dist/core/zod/rpc.js.map +1 -0
  84. package/dist/core/zod/utils.d.ts +18 -0
  85. package/dist/core/zod/utils.d.ts.map +1 -0
  86. package/dist/core/zod/utils.js +21 -0
  87. package/dist/core/zod/utils.js.map +1 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +15 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/internal/types.d.ts +284 -0
  93. package/dist/internal/types.d.ts.map +1 -0
  94. package/dist/internal/types.js +2 -0
  95. package/dist/internal/types.js.map +1 -0
  96. package/dist/server/Handler.d.ts +257 -0
  97. package/dist/server/Handler.d.ts.map +1 -0
  98. package/dist/server/Handler.js +433 -0
  99. package/dist/server/Handler.js.map +1 -0
  100. package/dist/server/Kv.d.ts +16 -0
  101. package/dist/server/Kv.d.ts.map +1 -0
  102. package/dist/server/Kv.js +30 -0
  103. package/dist/server/Kv.js.map +1 -0
  104. package/dist/server/index.d.ts +3 -0
  105. package/dist/server/index.d.ts.map +1 -0
  106. package/dist/server/index.js +3 -0
  107. package/dist/server/index.js.map +1 -0
  108. package/dist/server/internal/requestListener.d.ts +124 -0
  109. package/dist/server/internal/requestListener.d.ts.map +1 -0
  110. package/dist/server/internal/requestListener.js +173 -0
  111. package/dist/server/internal/requestListener.js.map +1 -0
  112. package/dist/wagmi/Connector.d.ts +93 -0
  113. package/dist/wagmi/Connector.d.ts.map +1 -0
  114. package/dist/wagmi/Connector.js +238 -0
  115. package/dist/wagmi/Connector.js.map +1 -0
  116. package/dist/wagmi/index.d.ts +3 -0
  117. package/dist/wagmi/index.d.ts.map +1 -0
  118. package/dist/wagmi/index.js +3 -0
  119. package/dist/wagmi/index.js.map +1 -0
  120. package/package.json +56 -2
  121. package/src/core/AccessKey.test.ts +257 -0
  122. package/src/core/AccessKey.ts +123 -0
  123. package/src/core/Account.test.ts +309 -0
  124. package/src/core/Account.ts +152 -0
  125. package/src/core/Adapter.ts +238 -0
  126. package/src/core/Ceremony.browser.test.ts +239 -0
  127. package/src/core/Ceremony.test.ts +151 -0
  128. package/src/core/Ceremony.ts +203 -0
  129. package/src/core/Client.ts +36 -0
  130. package/src/core/Dialog.browser.test.ts +309 -0
  131. package/src/core/Dialog.test-d.ts +19 -0
  132. package/src/core/Dialog.ts +442 -0
  133. package/src/core/Expiry.ts +34 -0
  134. package/src/core/Messenger.ts +206 -0
  135. package/src/core/Provider.browser.test.ts +774 -0
  136. package/src/core/Provider.connect.browser.test.ts +415 -0
  137. package/src/core/Provider.test-d.ts +53 -0
  138. package/src/core/Provider.test.ts +1566 -0
  139. package/src/core/Provider.ts +559 -0
  140. package/src/core/Remote.ts +262 -0
  141. package/src/core/Schema.test-d.ts +211 -0
  142. package/src/core/Schema.ts +143 -0
  143. package/src/core/Storage.ts +213 -0
  144. package/src/core/Store.test.ts +287 -0
  145. package/src/core/Store.ts +129 -0
  146. package/src/core/adapters/dangerous_secp256k1.ts +53 -0
  147. package/src/core/adapters/dialog.ts +379 -0
  148. package/src/core/adapters/local.test.ts +97 -0
  149. package/src/core/adapters/local.ts +277 -0
  150. package/src/core/adapters/webAuthn.ts +129 -0
  151. package/src/core/internal/withDedupe.test.ts +116 -0
  152. package/src/core/internal/withDedupe.ts +20 -0
  153. package/src/core/mppx.test.ts +83 -0
  154. package/src/core/zod/request.test.ts +121 -0
  155. package/src/core/zod/request.ts +70 -0
  156. package/src/core/zod/rpc.ts +374 -0
  157. package/src/core/zod/utils.test.ts +69 -0
  158. package/src/core/zod/utils.ts +40 -0
  159. package/src/index.ts +14 -0
  160. package/src/internal/types.ts +378 -0
  161. package/src/server/Handler.test.ts +1014 -0
  162. package/src/server/Handler.ts +605 -0
  163. package/src/server/Kv.ts +46 -0
  164. package/src/server/index.ts +2 -0
  165. package/src/server/internal/requestListener.ts +273 -0
  166. package/src/tsconfig.json +9 -0
  167. package/src/wagmi/Connector.ts +287 -0
  168. package/src/wagmi/index.ts +2 -0
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as Ceremony from './Ceremony.js'
4
+
5
+ describe('from', () => {
6
+ test('default: returns the ceremony', () => {
7
+ const ceremony = Ceremony.from({
8
+ getRegistrationOptions: async () => ({ options: {} as any }),
9
+ verifyRegistration: async () => ({ credentialId: 'cred-1', publicKey: '0x1234' }),
10
+ getAuthenticationOptions: async () => ({ options: {} as any }),
11
+ verifyAuthentication: async () => ({ credentialId: 'cred-1', publicKey: '0x1234' }),
12
+ })
13
+ expect(ceremony).toBeDefined()
14
+ expect(typeof ceremony.getRegistrationOptions).toMatchInlineSnapshot(`"function"`)
15
+ expect(typeof ceremony.verifyRegistration).toMatchInlineSnapshot(`"function"`)
16
+ expect(typeof ceremony.getAuthenticationOptions).toMatchInlineSnapshot(`"function"`)
17
+ expect(typeof ceremony.verifyAuthentication).toMatchInlineSnapshot(`"function"`)
18
+ })
19
+ })
20
+
21
+ describe('local', () => {
22
+ test('default: creates a ceremony', () => {
23
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
24
+ expect(ceremony).toBeDefined()
25
+ })
26
+
27
+ test('behavior: getRegistrationOptions returns serialized options', async () => {
28
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
29
+
30
+ const { options } = await ceremony.getRegistrationOptions({ name: 'Test' })
31
+ expect(options.publicKey).toBeDefined()
32
+ expect(options.publicKey!.rp.id).toMatchInlineSnapshot(`"example.com"`)
33
+ expect(options.publicKey!.rp.name).toMatchInlineSnapshot(`"example.com"`)
34
+ expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
35
+ expect(options.publicKey!.authenticatorSelection).toBeDefined()
36
+ })
37
+
38
+ test('behavior: getAuthenticationOptions returns serialized options', async () => {
39
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
40
+
41
+ const { options } = await ceremony.getAuthenticationOptions()
42
+ expect(options.publicKey).toBeDefined()
43
+ expect(options.publicKey!.rpId).toMatchInlineSnapshot(`"example.com"`)
44
+ expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
45
+ })
46
+
47
+ test('behavior: verifyRegistration stores credential and returns publicKey', async () => {
48
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
49
+
50
+ const result = await ceremony.verifyRegistration({
51
+ attestationObject: 'mock',
52
+ clientDataJSON: 'mock',
53
+ id: 'cred-1',
54
+ publicKey: '0xabcd',
55
+ raw: {
56
+ id: 'cred-1',
57
+ type: 'public-key',
58
+ authenticatorAttachment: null,
59
+ rawId: 'cred-1',
60
+ response: { clientDataJSON: 'mock' },
61
+ },
62
+ })
63
+
64
+ expect(result).toMatchInlineSnapshot(`
65
+ {
66
+ "credentialId": "cred-1",
67
+ "publicKey": "0xabcd",
68
+ }
69
+ `)
70
+ })
71
+
72
+ test('behavior: verifyAuthentication returns stored publicKey', async () => {
73
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
74
+
75
+ // Register first to store the credential
76
+ await ceremony.verifyRegistration({
77
+ attestationObject: 'mock',
78
+ clientDataJSON: 'mock',
79
+ id: 'cred-1',
80
+ publicKey: '0xabcd',
81
+ raw: {
82
+ id: 'cred-1',
83
+ type: 'public-key',
84
+ authenticatorAttachment: null,
85
+ rawId: 'cred-1',
86
+ response: { clientDataJSON: 'mock' },
87
+ },
88
+ })
89
+
90
+ const result = await ceremony.verifyAuthentication({
91
+ id: 'cred-1',
92
+ metadata: {
93
+ authenticatorData: '0x00',
94
+ clientDataJSON: '{}',
95
+ },
96
+ raw: {
97
+ id: 'cred-1',
98
+ type: 'public-key',
99
+ authenticatorAttachment: null,
100
+ rawId: 'cred-1',
101
+ response: { clientDataJSON: 'mock' },
102
+ },
103
+ signature: '0x00',
104
+ })
105
+
106
+ expect(result).toMatchInlineSnapshot(`
107
+ {
108
+ "credentialId": "cred-1",
109
+ "publicKey": "0xabcd",
110
+ }
111
+ `)
112
+ })
113
+
114
+ test('error: verifyAuthentication throws for unknown credential', async () => {
115
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
116
+
117
+ await expect(
118
+ ceremony.verifyAuthentication({
119
+ id: 'unknown',
120
+ metadata: {
121
+ authenticatorData: '0x00',
122
+ clientDataJSON: '{}',
123
+ },
124
+ raw: {
125
+ id: 'unknown',
126
+ type: 'public-key',
127
+ authenticatorAttachment: null,
128
+ rawId: 'unknown',
129
+ response: { clientDataJSON: 'mock' },
130
+ },
131
+ signature: '0x00',
132
+ }),
133
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Unknown credential: unknown]`)
134
+ })
135
+
136
+ test('behavior: each call to getRegistrationOptions generates a unique challenge', async () => {
137
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
138
+
139
+ const { options: a } = await ceremony.getRegistrationOptions({ name: 'Test' })
140
+ const { options: b } = await ceremony.getRegistrationOptions({ name: 'Test' })
141
+ expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
142
+ })
143
+
144
+ test('behavior: each call to getAuthenticationOptions generates a unique challenge', async () => {
145
+ const ceremony = Ceremony.local({ rpId: 'example.com' })
146
+
147
+ const { options: a } = await ceremony.getAuthenticationOptions()
148
+ const { options: b } = await ceremony.getAuthenticationOptions()
149
+ expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
150
+ })
151
+ })
@@ -0,0 +1,203 @@
1
+ import { Bytes } from 'ox'
2
+ import type { Hex } from 'viem'
3
+ import { Authentication, Registration } from 'webauthx/server'
4
+
5
+ import * as Storage from './Storage.js'
6
+
7
+ /** Pluggable strategy for WebAuthn registration and authentication ceremonies. */
8
+ export type Ceremony = {
9
+ /** Get credential creation options for `navigator.credentials.create()`. */
10
+ getRegistrationOptions: (
11
+ params: getRegistrationOptions.Parameters,
12
+ ) => Promise<getRegistrationOptions.ReturnType>
13
+ /** Verify a registration response and extract the public key. */
14
+ verifyRegistration: (
15
+ credential: Registration.Credential,
16
+ options?: verifyRegistration.Options | undefined,
17
+ ) => Promise<verifyRegistration.ReturnType>
18
+ /** Get credential request options for `navigator.credentials.get()`. */
19
+ getAuthenticationOptions: (
20
+ params?: getAuthenticationOptions.Parameters | undefined,
21
+ ) => Promise<getAuthenticationOptions.ReturnType>
22
+ /** Verify an authentication response and extract the public key. */
23
+ verifyAuthentication: (
24
+ response: Authentication.Response,
25
+ ) => Promise<verifyAuthentication.ReturnType>
26
+ }
27
+
28
+ export declare namespace getRegistrationOptions {
29
+ type Parameters = {
30
+ /** Credential IDs to exclude (prevents re-registering existing credentials). */
31
+ excludeCredentialIds?: readonly string[] | undefined
32
+ /** Credential display name (e.g. `"alice"`). */
33
+ name: string
34
+ /** Opaque user identifier. Encoded as `user.id` in the WebAuthn creation options. */
35
+ userId?: string | undefined
36
+ }
37
+ type ReturnType = { options: Registration.Options }
38
+ }
39
+
40
+ export declare namespace verifyRegistration {
41
+ type Options = {
42
+ /** Display name for the credential (e.g. user's email). */
43
+ name?: string | undefined
44
+ }
45
+ type ReturnType = {
46
+ /** The registered credential's ID. */
47
+ credentialId: string
48
+ /** The credential's public key (uncompressed P256, hex-encoded). */
49
+ publicKey: Hex
50
+ }
51
+ }
52
+
53
+ export declare namespace getAuthenticationOptions {
54
+ type Parameters = {
55
+ /** Credential IDs to allow (restricts which credentials can be used). */
56
+ allowCredentialIds?: readonly string[] | undefined
57
+ /** Challenge to use. */
58
+ challenge?: `0x${string}` | undefined
59
+ /** Credential ID to restrict authentication to a specific credential. */
60
+ credentialId?: string | undefined
61
+ /** Mediation hint for passkey autofill / conditional UI. */
62
+ mediation?: 'conditional' | 'optional' | 'required' | 'silent' | undefined
63
+ }
64
+ type ReturnType = { options: Authentication.Options }
65
+ }
66
+
67
+ export declare namespace verifyAuthentication {
68
+ type ReturnType = {
69
+ /** The authenticated credential's ID. */
70
+ credentialId: string
71
+ /** The credential's public key (uncompressed P256, hex-encoded). */
72
+ publicKey: Hex
73
+ /** User identifier from the authenticator's `userHandle` (discoverable/conditional flows). */
74
+ userId?: string | undefined
75
+ }
76
+ }
77
+
78
+ /** Creates a {@link Ceremony} from a custom implementation. */
79
+ export function from(ceremony: Ceremony): Ceremony {
80
+ return ceremony
81
+ }
82
+
83
+ /**
84
+ * Creates a pure client-side ceremony for development and prototyping.
85
+ *
86
+ * Generates challenges and verifies responses locally using `webauthx/server`.
87
+ * Stores credentials in memory. No external server needed.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { Ceremony } from 'accounts'
92
+ *
93
+ * const ceremony = Ceremony.local()
94
+ * ```
95
+ */
96
+ export function local(options: local.Options = {}): Ceremony {
97
+ const rpId = options.rpId ?? (typeof location !== 'undefined' ? location.hostname : 'localhost')
98
+ const storage =
99
+ options.storage ?? (typeof window !== 'undefined' ? Storage.idb() : Storage.memory())
100
+ const storageKey = 'credentials'
101
+
102
+ return from({
103
+ async getRegistrationOptions(parameters) {
104
+ const { excludeCredentialIds, name, userId } = parameters
105
+ const { options } = Registration.getOptions({
106
+ excludeCredentialIds: excludeCredentialIds as string[] | undefined,
107
+ name,
108
+ rp: { id: rpId, name: rpId },
109
+ user: userId ? { id: Bytes.fromString(userId), name } : undefined,
110
+ })
111
+ return { options }
112
+ },
113
+
114
+ async verifyRegistration(credential) {
115
+ const publicKey = credential.publicKey
116
+ const credentials = (await storage.getItem<Record<string, Hex>>(storageKey)) ?? {}
117
+ credentials[credential.id] = publicKey
118
+ await storage.setItem(storageKey, credentials)
119
+ return { credentialId: credential.id, publicKey }
120
+ },
121
+
122
+ async getAuthenticationOptions(parameters = {}) {
123
+ const { allowCredentialIds, challenge, credentialId } = parameters
124
+ const { options } = Authentication.getOptions({
125
+ challenge,
126
+ credentialId: (allowCredentialIds as string[] | undefined) ?? credentialId,
127
+ rpId,
128
+ })
129
+ return { options }
130
+ },
131
+
132
+ async verifyAuthentication(response) {
133
+ const credentials = (await storage.getItem<Record<string, Hex>>(storageKey)) ?? {}
134
+ const publicKey = credentials[response.id]
135
+ if (!publicKey) throw new Error(`Unknown credential: ${response.id}`)
136
+ return { credentialId: response.id, publicKey }
137
+ },
138
+ })
139
+ }
140
+
141
+ export declare namespace local {
142
+ type Options = {
143
+ /** Relying Party ID (e.g. `"example.com"`). @default location.hostname */
144
+ rpId?: string | undefined
145
+ /** Storage adapter for credential persistence. @default Storage.idb() in browser, Storage.memory() otherwise. */
146
+ storage?: Storage.Storage | undefined
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Creates a server-backed ceremony that delegates to a remote {@link Handler.webauthn} endpoint.
152
+ *
153
+ * All challenge generation, verification, and credential storage happen server-side.
154
+ * The client uses `fetch()` to communicate with 4 POST endpoints derived from the base URL.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * import { Ceremony } from 'accounts'
159
+ *
160
+ * const ceremony = Ceremony.server({ url: 'https://example.com/webauthn' })
161
+ * ```
162
+ */
163
+ export function server(options: server.Options): Ceremony {
164
+ const { url } = options
165
+
166
+ async function request<returnType>(path: string, body: unknown): Promise<returnType> {
167
+ const response = await fetch(`${url}${path}`, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify(body),
171
+ })
172
+ const json = await response.json()
173
+ if (!response.ok) throw new Error((json as { error?: string }).error ?? 'Request failed')
174
+ return json as returnType
175
+ }
176
+
177
+ return from({
178
+ async getRegistrationOptions(parameters) {
179
+ const { excludeCredentialIds, name, userId } = parameters
180
+ return request('/register/options', { excludeCredentialIds, name, userId })
181
+ },
182
+
183
+ async verifyRegistration(credential) {
184
+ return request('/register', credential)
185
+ },
186
+
187
+ async getAuthenticationOptions(parameters = {}) {
188
+ const { allowCredentialIds, challenge, credentialId, mediation } = parameters
189
+ return request('/login/options', { allowCredentialIds, challenge, credentialId, mediation })
190
+ },
191
+
192
+ async verifyAuthentication(response) {
193
+ return request('/login', response)
194
+ },
195
+ })
196
+ }
197
+
198
+ export declare namespace server {
199
+ type Options = {
200
+ /** Base URL of the WebAuthn handler (e.g. `"https://example.com/webauthn"`). */
201
+ url: string
202
+ }
203
+ }
@@ -0,0 +1,36 @@
1
+ import { type Chain, createClient, type Client, http, type Transport } from 'viem'
2
+ import type { tempo } from 'viem/chains'
3
+ import { withFeePayer } from 'viem/tempo'
4
+
5
+ import type * as Store from './Store.js'
6
+
7
+ const clients = new Map<string, Client>()
8
+
9
+ /** Resolves a viem Client for a given chain ID (cached). */
10
+ export function fromChainId(
11
+ chainId: number | undefined,
12
+ options: fromChainId.Options,
13
+ ): Client<Transport, typeof tempo> {
14
+ const { chains, feePayer, store } = options
15
+ const id = chainId ?? store.getState().chainId
16
+ const key = `${id}:${feePayer ?? ''}`
17
+ let client = clients.get(key)
18
+ if (!client) {
19
+ const chain = chains.find((c) => c.id === id) ?? chains[0]!
20
+ const transport = feePayer ? withFeePayer(http(), http(feePayer)) : http()
21
+ client = createClient({ chain, transport, pollingInterval: 1000 })
22
+ clients.set(key, client)
23
+ }
24
+ return client as never
25
+ }
26
+
27
+ export declare namespace fromChainId {
28
+ type Options = {
29
+ /** Supported chains. */
30
+ chains: readonly [Chain, ...Chain[]]
31
+ /** Fee payer service URL. When set, the transport routes fee-payer RPC calls to this URL. */
32
+ feePayer?: string | undefined
33
+ /** Reactive state store. */
34
+ store: Store.Store
35
+ }
36
+ }
@@ -0,0 +1,309 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
2
+
3
+ import * as Dialog from './Dialog.js'
4
+ import * as Storage from './Storage.js'
5
+ import * as Store from './Store.js'
6
+
7
+ const host = 'https://wallet.tempo.xyz/embed'
8
+
9
+ function setup() {
10
+ const store = Store.create({
11
+ chainId: 1,
12
+ storage: Storage.memory({ key: 'dialog-test' }),
13
+ })
14
+ const dialog = Dialog.iframe()
15
+ const handle = dialog({ host, store })
16
+ return { handle, store }
17
+ }
18
+
19
+ afterEach(() => {
20
+ document.querySelectorAll('dialog[data-tempo-wallet]').forEach((el) => el.remove())
21
+ document.body.style.overflow = ''
22
+ })
23
+
24
+ describe('Dialog.iframe', () => {
25
+ test('default: appends dialog and iframe to document.body', () => {
26
+ setup()
27
+ const dialog = document.querySelector('dialog[data-tempo-wallet]')
28
+ expect(dialog).not.toBeNull()
29
+ const iframe = dialog!.querySelector('iframe')
30
+ expect(iframe).not.toBeNull()
31
+ })
32
+
33
+ test('behavior: iframe has correct sandbox attributes', () => {
34
+ setup()
35
+ const iframe = document.querySelector('dialog[data-tempo-wallet] iframe')!
36
+ expect(iframe.getAttribute('sandbox')).toMatchInlineSnapshot(
37
+ `"allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`,
38
+ )
39
+ })
40
+
41
+ test('behavior: iframe has correct allow attributes', () => {
42
+ setup()
43
+ const iframe = document.querySelector('dialog[data-tempo-wallet] iframe')!
44
+ const allow = iframe.getAttribute('allow')!
45
+ expect(allow).toContain('publickey-credentials-get')
46
+ expect(allow).toContain('publickey-credentials-create')
47
+ })
48
+
49
+ test('behavior: iframe src points to host', () => {
50
+ setup()
51
+ const iframe = document.querySelector('dialog[data-tempo-wallet] iframe') as HTMLIFrameElement
52
+ expect(iframe.src).toMatchInlineSnapshot(`"https://wallet.tempo.xyz/embed/"`)
53
+ expect(iframe.src).toContain(host)
54
+ })
55
+
56
+ test('behavior: open shows dialog', () => {
57
+ const { handle } = setup()
58
+ handle.open()
59
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
60
+ expect(dialog.open).toBe(true)
61
+ })
62
+
63
+ test('behavior: close hides dialog', () => {
64
+ const { handle } = setup()
65
+ handle.open()
66
+ handle.close()
67
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
68
+ expect(dialog.open).toBe(false)
69
+ })
70
+
71
+ test('behavior: destroy removes dialog from DOM', () => {
72
+ const { handle } = setup()
73
+ handle.destroy()
74
+ expect(document.querySelector('dialog[data-tempo-wallet]')).toBeNull()
75
+ })
76
+
77
+ test('behavior: body scroll locked on open', () => {
78
+ const { handle } = setup()
79
+ handle.open()
80
+ expect(document.body.style.overflow).toBe('hidden')
81
+ })
82
+
83
+ test('behavior: body scroll restored on close', () => {
84
+ const { handle } = setup()
85
+ document.body.style.overflow = 'auto'
86
+ handle.open()
87
+ handle.close()
88
+ expect(document.body.style.overflow).toBe('auto')
89
+ })
90
+
91
+ test('behavior: open is idempotent', () => {
92
+ const { handle } = setup()
93
+ handle.open()
94
+ expect(() => handle.open()).not.toThrow()
95
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
96
+ expect(dialog.open).toBe(true)
97
+ handle.close()
98
+ })
99
+
100
+ test('behavior: close without open does not throw', () => {
101
+ const { handle } = setup()
102
+ expect(() => handle.close()).not.toThrow()
103
+ })
104
+
105
+ test('behavior: destroy restores body scroll', () => {
106
+ const { handle } = setup()
107
+ document.body.style.overflow = 'auto'
108
+ handle.open()
109
+ handle.destroy()
110
+ expect(document.body.style.overflow).toBe('auto')
111
+ })
112
+
113
+ test('behavior: destroy closes open dialog', () => {
114
+ const { handle } = setup()
115
+ handle.open()
116
+ handle.destroy()
117
+ expect(document.querySelector('dialog[data-tempo-wallet]')).toBeNull()
118
+ })
119
+
120
+ test('behavior: cancel event rejects pending requests', () => {
121
+ const { handle, store } = setup()
122
+ handle.open()
123
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
124
+ dialog.dispatchEvent(new Event('cancel'))
125
+ const queue = store.getState().requestQueue
126
+ for (const q of queue) expect(q.status).toBe('error')
127
+ })
128
+
129
+ test('behavior: focus restored to previous element on close', () => {
130
+ const button = document.createElement('button')
131
+ document.body.appendChild(button)
132
+ button.focus()
133
+ expect(document.activeElement).toBe(button)
134
+
135
+ const { handle } = setup()
136
+ handle.open()
137
+ handle.close()
138
+
139
+ expect(document.activeElement).toBe(button)
140
+ button.remove()
141
+ })
142
+
143
+ test('behavior: backdrop click rejects pending requests', () => {
144
+ const { handle, store } = setup()
145
+ handle.open()
146
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
147
+
148
+ dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }))
149
+
150
+ const queue = store.getState().requestQueue
151
+ for (const q of queue) expect(q.status).toBe('error')
152
+ })
153
+
154
+ test('behavior: click inside iframe does not close dialog', () => {
155
+ const { handle } = setup()
156
+ handle.open()
157
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
158
+ const iframe = dialog.querySelector('iframe')!
159
+
160
+ iframe.dispatchEvent(new MouseEvent('click', { bubbles: true }))
161
+
162
+ expect(dialog.open).toBe(true)
163
+ })
164
+
165
+ test('behavior: 1Password inert attribute stripped from dialog', async () => {
166
+ const { handle } = setup()
167
+ handle.open()
168
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
169
+
170
+ dialog.setAttribute('inert', '')
171
+
172
+ await new Promise((resolve) => setTimeout(resolve, 10))
173
+
174
+ expect(dialog.hasAttribute('inert')).toBe(false)
175
+ handle.close()
176
+ })
177
+
178
+ test('behavior: iframe has accessibility attributes', () => {
179
+ setup()
180
+ const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
181
+ expect(dialog.getAttribute('role')).toBe('dialog')
182
+ expect(dialog.getAttribute('aria-label')).toBe('Tempo Auth')
183
+ })
184
+ })
185
+
186
+ describe('Dialog.popup', () => {
187
+ test('default: window.open called with correct URL', () => {
188
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue({
189
+ closed: false,
190
+ close: vi.fn(),
191
+ } as unknown as Window)
192
+
193
+ const store = Store.create({
194
+ chainId: 1,
195
+ storage: Storage.memory({ key: 'popup-test' }),
196
+ })
197
+ const dialog = Dialog.popup()
198
+ const handle = dialog({ host, store })
199
+ handle.open()
200
+
201
+ expect(openSpy).toHaveBeenCalledOnce()
202
+ expect(openSpy.mock.calls[0]![0]).toBe(host)
203
+
204
+ handle.destroy()
205
+ openSpy.mockRestore()
206
+ })
207
+
208
+ test('behavior: window.open called with centered position', () => {
209
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue({
210
+ closed: false,
211
+ close: vi.fn(),
212
+ } as unknown as Window)
213
+
214
+ const store = Store.create({
215
+ chainId: 1,
216
+ storage: Storage.memory({ key: 'popup-test' }),
217
+ })
218
+ const dialog = Dialog.popup()
219
+ const handle = dialog({ host, store })
220
+ handle.open()
221
+
222
+ const features = openSpy.mock.calls[0]![2] as string
223
+ expect(features).toContain('width=')
224
+ expect(features).toContain('height=')
225
+ expect(features).toContain('left=')
226
+ expect(features).toContain('top=')
227
+
228
+ handle.destroy()
229
+ openSpy.mockRestore()
230
+ })
231
+
232
+ test('behavior: close calls popup.close()', () => {
233
+ const popupClose = vi.fn()
234
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue({
235
+ closed: false,
236
+ close: popupClose,
237
+ } as unknown as Window)
238
+
239
+ const store = Store.create({
240
+ chainId: 1,
241
+ storage: Storage.memory({ key: 'popup-test' }),
242
+ })
243
+ const dialog = Dialog.popup()
244
+ const handle = dialog({ host, store })
245
+ handle.open()
246
+ handle.close()
247
+
248
+ expect(popupClose).toHaveBeenCalledOnce()
249
+
250
+ handle.destroy()
251
+ openSpy.mockRestore()
252
+ })
253
+
254
+ test('behavior: open throws if popup blocked', () => {
255
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
256
+
257
+ const store = Store.create({
258
+ chainId: 1,
259
+ storage: Storage.memory({ key: 'popup-test' }),
260
+ })
261
+ const dialog = Dialog.popup()
262
+ const handle = dialog({ host, store })
263
+
264
+ expect(() => handle.open()).toThrow('Failed to open popup')
265
+
266
+ openSpy.mockRestore()
267
+ })
268
+
269
+ test('behavior: destroy cleans up', () => {
270
+ const popupClose = vi.fn()
271
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue({
272
+ closed: false,
273
+ close: popupClose,
274
+ } as unknown as Window)
275
+
276
+ const store = Store.create({
277
+ chainId: 1,
278
+ storage: Storage.memory({ key: 'popup-test' }),
279
+ })
280
+ const dialog = Dialog.popup()
281
+ const handle = dialog({ host, store })
282
+ handle.open()
283
+ handle.destroy()
284
+
285
+ expect(popupClose).toHaveBeenCalled()
286
+
287
+ openSpy.mockRestore()
288
+ })
289
+ })
290
+
291
+ describe('Dialog.noop', () => {
292
+ test('default: open, close, destroy are callable without error', () => {
293
+ const store = Store.create({
294
+ chainId: 1,
295
+ storage: Storage.memory({ key: 'noop-test' }),
296
+ })
297
+ const dialog = Dialog.noop()
298
+ const handle = dialog({ host, store })
299
+ expect(() => handle.open()).not.toThrow()
300
+ expect(() => handle.close()).not.toThrow()
301
+ expect(() => handle.destroy()).not.toThrow()
302
+ })
303
+ })
304
+
305
+ describe('isSafari', () => {
306
+ test('default: returns false in non-Safari environment', () => {
307
+ expect(Dialog.isSafari()).toBe(false)
308
+ })
309
+ })