@passlock/client 0.9.0

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 (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/dist/authentication/authenticate.d.ts +22 -0
  4. package/dist/authentication/authenticate.d.ts.map +1 -0
  5. package/dist/authentication/authenticate.fixture.d.ts +36 -0
  6. package/dist/authentication/authenticate.fixture.d.ts.map +1 -0
  7. package/dist/authentication/authenticate.fixture.js +52 -0
  8. package/dist/authentication/authenticate.fixture.js.map +1 -0
  9. package/dist/authentication/authenticate.js +69 -0
  10. package/dist/authentication/authenticate.js.map +1 -0
  11. package/dist/authentication/authenticate.test.d.ts +2 -0
  12. package/dist/authentication/authenticate.test.d.ts.map +1 -0
  13. package/dist/authentication/authenticate.test.js +111 -0
  14. package/dist/authentication/authenticate.test.js.map +1 -0
  15. package/dist/capabilities/capabilities.d.ts +18 -0
  16. package/dist/capabilities/capabilities.d.ts.map +1 -0
  17. package/dist/capabilities/capabilities.js +35 -0
  18. package/dist/capabilities/capabilities.js.map +1 -0
  19. package/dist/config.d.ts +22 -0
  20. package/dist/config.d.ts.map +1 -0
  21. package/dist/config.js +20 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/connection/connection.d.ts +9 -0
  24. package/dist/connection/connection.d.ts.map +1 -0
  25. package/dist/connection/connection.fixture.d.ts +12 -0
  26. package/dist/connection/connection.fixture.d.ts.map +1 -0
  27. package/dist/connection/connection.fixture.js +20 -0
  28. package/dist/connection/connection.fixture.js.map +1 -0
  29. package/dist/connection/connection.js +21 -0
  30. package/dist/connection/connection.js.map +1 -0
  31. package/dist/connection/connection.test.d.ts +2 -0
  32. package/dist/connection/connection.test.d.ts.map +1 -0
  33. package/dist/connection/connection.test.js +34 -0
  34. package/dist/connection/connection.test.js.map +1 -0
  35. package/dist/effect.d.ts +33 -0
  36. package/dist/effect.d.ts.map +1 -0
  37. package/dist/effect.js +62 -0
  38. package/dist/effect.js.map +1 -0
  39. package/dist/email/email.d.ts +37 -0
  40. package/dist/email/email.d.ts.map +1 -0
  41. package/dist/email/email.fixture.d.ts +33 -0
  42. package/dist/email/email.fixture.d.ts.map +1 -0
  43. package/dist/email/email.fixture.js +30 -0
  44. package/dist/email/email.fixture.js.map +1 -0
  45. package/dist/email/email.js +78 -0
  46. package/dist/email/email.js.map +1 -0
  47. package/dist/email/email.test.d.ts +2 -0
  48. package/dist/email/email.test.d.ts.map +1 -0
  49. package/dist/email/email.test.js +101 -0
  50. package/dist/email/email.test.js.map +1 -0
  51. package/dist/event/event.d.ts +9 -0
  52. package/dist/event/event.d.ts.map +1 -0
  53. package/dist/event/event.js +23 -0
  54. package/dist/event/event.js.map +1 -0
  55. package/dist/event/event.node.test.d.ts +2 -0
  56. package/dist/event/event.node.test.d.ts.map +1 -0
  57. package/dist/event/event.node.test.js +14 -0
  58. package/dist/event/event.node.test.js.map +1 -0
  59. package/dist/event/event.test.d.ts +2 -0
  60. package/dist/event/event.test.d.ts.map +1 -0
  61. package/dist/event/event.test.js +30 -0
  62. package/dist/event/event.test.js.map +1 -0
  63. package/dist/exit.d.ts +64 -0
  64. package/dist/exit.d.ts.map +1 -0
  65. package/dist/exit.js +106 -0
  66. package/dist/exit.js.map +1 -0
  67. package/dist/index.d.ts +110 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +108 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/logging/eventLogger.d.ts +18 -0
  72. package/dist/logging/eventLogger.d.ts.map +1 -0
  73. package/dist/logging/eventLogger.js +32 -0
  74. package/dist/logging/eventLogger.js.map +1 -0
  75. package/dist/logging/eventLogger.test.d.ts +2 -0
  76. package/dist/logging/eventLogger.test.d.ts.map +1 -0
  77. package/dist/logging/eventLogger.test.js +74 -0
  78. package/dist/logging/eventLogger.test.js.map +1 -0
  79. package/dist/registration/register.d.ts +29 -0
  80. package/dist/registration/register.d.ts.map +1 -0
  81. package/dist/registration/register.fixture.d.ts +40 -0
  82. package/dist/registration/register.fixture.d.ts.map +1 -0
  83. package/dist/registration/register.fixture.js +65 -0
  84. package/dist/registration/register.fixture.js.map +1 -0
  85. package/dist/registration/register.js +84 -0
  86. package/dist/registration/register.js.map +1 -0
  87. package/dist/registration/register.test.d.ts +2 -0
  88. package/dist/registration/register.test.d.ts.map +1 -0
  89. package/dist/registration/register.test.js +122 -0
  90. package/dist/registration/register.test.js.map +1 -0
  91. package/dist/storage/storage.d.ts +53 -0
  92. package/dist/storage/storage.d.ts.map +1 -0
  93. package/dist/storage/storage.fixture.d.ts +6 -0
  94. package/dist/storage/storage.fixture.d.ts.map +1 -0
  95. package/dist/storage/storage.fixture.js +26 -0
  96. package/dist/storage/storage.fixture.js.map +1 -0
  97. package/dist/storage/storage.js +102 -0
  98. package/dist/storage/storage.js.map +1 -0
  99. package/dist/storage/storage.test.d.ts +2 -0
  100. package/dist/storage/storage.test.d.ts.map +1 -0
  101. package/dist/storage/storage.test.js +122 -0
  102. package/dist/storage/storage.test.js.map +1 -0
  103. package/dist/test/fixtures.d.ts +14 -0
  104. package/dist/test/fixtures.d.ts.map +1 -0
  105. package/dist/test/fixtures.js +39 -0
  106. package/dist/test/fixtures.js.map +1 -0
  107. package/dist/user/user.d.ts +18 -0
  108. package/dist/user/user.d.ts.map +1 -0
  109. package/dist/user/user.fixture.d.ts +8 -0
  110. package/dist/user/user.fixture.d.ts.map +1 -0
  111. package/dist/user/user.fixture.js +17 -0
  112. package/dist/user/user.fixture.js.map +1 -0
  113. package/dist/user/user.js +23 -0
  114. package/dist/user/user.js.map +1 -0
  115. package/dist/user/user.test.d.ts +2 -0
  116. package/dist/user/user.test.d.ts.map +1 -0
  117. package/dist/user/user.test.js +37 -0
  118. package/dist/user/user.test.js.map +1 -0
  119. package/package.json +87 -0
  120. package/src/authentication/authenticate.fixture.ts +72 -0
  121. package/src/authentication/authenticate.test.ts +207 -0
  122. package/src/authentication/authenticate.ts +147 -0
  123. package/src/capabilities/capabilities.ts +81 -0
  124. package/src/config.ts +43 -0
  125. package/src/connection/connection.fixture.ts +27 -0
  126. package/src/connection/connection.test.ts +61 -0
  127. package/src/connection/connection.ts +51 -0
  128. package/src/effect.ts +278 -0
  129. package/src/email/email.fixture.ts +49 -0
  130. package/src/email/email.test.ts +186 -0
  131. package/src/email/email.ts +139 -0
  132. package/src/event/event.node.test.ts +20 -0
  133. package/src/event/event.test.ts +37 -0
  134. package/src/event/event.ts +25 -0
  135. package/src/index.ts +275 -0
  136. package/src/logging/eventLogger.test.ts +102 -0
  137. package/src/logging/eventLogger.ts +35 -0
  138. package/src/registration/register.fixture.ts +94 -0
  139. package/src/registration/register.test.ts +247 -0
  140. package/src/registration/register.ts +178 -0
  141. package/src/storage/storage.fixture.ts +33 -0
  142. package/src/storage/storage.test.ts +196 -0
  143. package/src/storage/storage.ts +165 -0
  144. package/src/test/fixtures.ts +51 -0
  145. package/src/user/user.fixture.ts +23 -0
  146. package/src/user/user.test.ts +53 -0
  147. package/src/user/user.ts +50 -0
package/src/effect.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { create, get as getCredential } from '@github/webauthn-json/browser-ponyfill'
2
+
3
+ export type {
4
+ BadRequest,
5
+ Disabled,
6
+ Duplicate,
7
+ Forbidden,
8
+ InternalBrowserError,
9
+ NotFound,
10
+ NotSupported,
11
+ Unauthorized,
12
+ } from '@passlock/shared/dist/error/error'
13
+
14
+ export type { Principal } from '@passlock/shared/dist/schema/schema'
15
+
16
+ import {
17
+ type BadRequest,
18
+ type Disabled,
19
+ Duplicate,
20
+ type Forbidden,
21
+ InternalBrowserError,
22
+ type NotFound,
23
+ type NotSupported,
24
+ type Unauthorized,
25
+ } from '@passlock/shared/dist/error/error'
26
+
27
+ import {
28
+ NetworkServiceLive,
29
+ RetrySchedule,
30
+ RpcClientLive,
31
+ RpcConfig,
32
+ } from '@passlock/shared/dist/rpc/rpc'
33
+
34
+ import type { Principal } from '@passlock/shared/dist/schema/schema'
35
+ import { Context, Effect as E, Layer as L, Layer, Schedule, pipe } from 'effect'
36
+ import type { NoSuchElementException } from 'effect/Cause'
37
+
38
+ import {
39
+ AuthenticateServiceLive,
40
+ type AuthenticationRequest,
41
+ AuthenticationService,
42
+ GetCredential,
43
+ } from './authentication/authenticate'
44
+
45
+ import { capabilitiesLive } from './capabilities/capabilities'
46
+ import { ConnectionService, ConnectionServiceLive } from './connection/connection'
47
+ import { EmailService, EmailServiceLive, LocationSearch, type VerifyRequest } from './email/email'
48
+
49
+ import {
50
+ CreateCredential,
51
+ type RegistrationRequest,
52
+ RegistrationService,
53
+ RegistrationServiceLive,
54
+ } from './registration/register'
55
+
56
+ import {
57
+ type AuthType,
58
+ Storage,
59
+ StorageService,
60
+ StorageServiceLive,
61
+ type StoredToken,
62
+ } from './storage/storage'
63
+
64
+ import { type Email, UserService, UserServiceLive } from './user/user'
65
+
66
+ /* Layers */
67
+
68
+ const createLive = L.succeed(
69
+ CreateCredential,
70
+ CreateCredential.of((options: CredentialCreationOptions) =>
71
+ pipe(
72
+ E.tryPromise({
73
+ try: () => create(options),
74
+ catch: e => {
75
+ if (e instanceof Error && e.message.includes('excludeCredentials')) {
76
+ return new Duplicate({
77
+ message: 'Passkey already registered on this device or cloud account',
78
+ })
79
+ } else {
80
+ return new InternalBrowserError({
81
+ message: 'Unable to create credential',
82
+ detail: String(e),
83
+ })
84
+ }
85
+ },
86
+ }),
87
+ E.map(credential => credential.toJSON()),
88
+ ),
89
+ ),
90
+ )
91
+
92
+ const getLive = L.succeed(
93
+ GetCredential,
94
+ GetCredential.of((options: CredentialRequestOptions) =>
95
+ pipe(
96
+ E.tryPromise({
97
+ try: () => getCredential(options),
98
+ catch: e =>
99
+ new InternalBrowserError({
100
+ message: 'Unable to get authentication credential',
101
+ detail: String(e),
102
+ }),
103
+ }),
104
+ E.map(credential => credential.toJSON()),
105
+ ),
106
+ ),
107
+ )
108
+
109
+ const schedule = Schedule.intersect(Schedule.recurs(3), Schedule.exponential('100 millis'))
110
+
111
+ const retryScheduleLive = L.succeed(RetrySchedule, RetrySchedule.of({ schedule }))
112
+
113
+ const networkServiceLive = pipe(NetworkServiceLive, L.provide(retryScheduleLive))
114
+
115
+ const rpcClientLive = pipe(RpcClientLive, L.provide(networkServiceLive))
116
+
117
+ const storageServiceLive = StorageServiceLive
118
+
119
+ const userServiceLive = pipe(UserServiceLive, L.provide(rpcClientLive))
120
+
121
+ const registrationServiceLive = pipe(
122
+ RegistrationServiceLive,
123
+ L.provide(rpcClientLive),
124
+ L.provide(userServiceLive),
125
+ L.provide(capabilitiesLive),
126
+ L.provide(createLive),
127
+ L.provide(storageServiceLive),
128
+ )
129
+
130
+ const authenticationServiceLive = pipe(
131
+ AuthenticateServiceLive,
132
+ L.provide(rpcClientLive),
133
+ L.provide(capabilitiesLive),
134
+ L.provide(getLive),
135
+ L.provide(storageServiceLive),
136
+ )
137
+
138
+ const connectionServiceLive = pipe(
139
+ ConnectionServiceLive,
140
+ L.provide(rpcClientLive),
141
+ L.provide(networkServiceLive)
142
+ )
143
+
144
+ const locationSearchLive = Layer.succeed(
145
+ LocationSearch,
146
+ LocationSearch.of(E.sync(() => globalThis.window.location.search)),
147
+ )
148
+
149
+ const emailServiceLive = pipe(
150
+ EmailServiceLive,
151
+ L.provide(locationSearchLive),
152
+ L.provide(rpcClientLive),
153
+ L.provide(capabilitiesLive),
154
+ L.provide(authenticationServiceLive),
155
+ L.provide(storageServiceLive),
156
+ )
157
+
158
+ export const allRequirements = Layer.mergeAll(
159
+ capabilitiesLive,
160
+ userServiceLive,
161
+ registrationServiceLive,
162
+ authenticationServiceLive,
163
+ connectionServiceLive,
164
+ emailServiceLive,
165
+ storageServiceLive,
166
+ )
167
+
168
+ export class Config extends Context.Tag('Config')<
169
+ Config,
170
+ {
171
+ tenancyId: string
172
+ clientId: string
173
+ endpoint?: string
174
+ }
175
+ >() {}
176
+
177
+ const storageLive = Layer.effect(
178
+ Storage,
179
+ E.sync(() => Storage.of(globalThis.localStorage)),
180
+ )
181
+
182
+ const exchangeConfig = <A, E>(effect: E.Effect<A, E, RpcConfig | Storage>) => {
183
+ return pipe(
184
+ Config,
185
+ E.flatMap(config => E.provideService(effect, RpcConfig, RpcConfig.of(config))),
186
+ effect => E.provide(effect, storageLive),
187
+ )
188
+ }
189
+
190
+ export const preConnect = (): E.Effect<void, never, Config> =>
191
+ pipe(
192
+ ConnectionService,
193
+ E.flatMap(service => service.preConnect()),
194
+ E.provide(connectionServiceLive),
195
+ exchangeConfig,
196
+ )
197
+
198
+ export const isRegistered = (email: Email): E.Effect<boolean, BadRequest, Config> =>
199
+ pipe(
200
+ UserService,
201
+ E.flatMap(service => service.isExistingUser(email)),
202
+ E.provide(userServiceLive),
203
+ exchangeConfig,
204
+ )
205
+
206
+ export type RegistrationErrors = NotSupported | BadRequest | Duplicate | Unauthorized | Forbidden
207
+
208
+ export const registerPasskey = (
209
+ request: RegistrationRequest,
210
+ ): E.Effect<Principal, RegistrationErrors, Config> =>
211
+ pipe(
212
+ RegistrationService,
213
+ E.flatMap(service => service.registerPasskey(request)),
214
+ E.provide(registrationServiceLive),
215
+ exchangeConfig,
216
+ )
217
+
218
+ export type AuthenticationErrors =
219
+ | NotSupported
220
+ | BadRequest
221
+ | NotFound
222
+ | Disabled
223
+ | Unauthorized
224
+ | Forbidden
225
+
226
+ export const authenticatePasskey = (
227
+ request: AuthenticationRequest,
228
+ ): E.Effect<Principal, AuthenticationErrors, Config> =>
229
+ pipe(
230
+ AuthenticationService,
231
+ E.flatMap(service => service.authenticatePasskey(request)),
232
+ E.provide(authenticationServiceLive),
233
+ exchangeConfig,
234
+ )
235
+
236
+ export type VerifyEmailErrors =
237
+ | NotSupported
238
+ | BadRequest
239
+ | NotFound
240
+ | Disabled
241
+ | Unauthorized
242
+ | Forbidden
243
+
244
+ export const verifyEmailCode = (
245
+ request: VerifyRequest,
246
+ ): E.Effect<boolean, VerifyEmailErrors, Config> =>
247
+ pipe(
248
+ EmailService,
249
+ E.flatMap(service => service.verifyEmailCode(request)),
250
+ E.provide(emailServiceLive),
251
+ exchangeConfig,
252
+ )
253
+
254
+ export const verifyEmailLink = (): E.Effect<boolean, VerifyEmailErrors, Config> =>
255
+ pipe(
256
+ EmailService,
257
+ E.flatMap(service => service.verifyEmailLink()),
258
+ E.provide(emailServiceLive),
259
+ exchangeConfig,
260
+ )
261
+
262
+ export const getSessionToken = (
263
+ authType: AuthType,
264
+ ): E.Effect<StoredToken, NoSuchElementException> =>
265
+ pipe(
266
+ StorageService,
267
+ E.flatMap(service => service.getToken(authType)),
268
+ E.provide(storageServiceLive),
269
+ E.provide(storageLive),
270
+ )
271
+
272
+ export const clearExpiredTokens = (): E.Effect<void> =>
273
+ pipe(
274
+ StorageService,
275
+ E.flatMap(service => service.clearExpiredTokens),
276
+ E.provide(storageServiceLive),
277
+ E.provide(storageLive),
278
+ )
@@ -0,0 +1,49 @@
1
+
2
+ import { BadRequest } from '@passlock/shared/dist/error/error'
3
+ import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
4
+ import { VerifyEmailReq, VerifyEmailRes } from '@passlock/shared/dist/rpc/user'
5
+ import { Effect as E, Layer as L } from 'effect'
6
+ import { LocationSearch } from './email'
7
+ import { AuthenticationService } from '../authentication/authenticate'
8
+ import * as Fixtures from '../test/fixtures'
9
+
10
+
11
+ export const token = 'token'
12
+ export const code = 'code'
13
+ export const authType = 'passkey'
14
+ export const expireAt = Date.now() + 10000
15
+
16
+ export const locationSearchTest = L.succeed(
17
+ LocationSearch,
18
+ LocationSearch.of(E.succeed(`?code=${code}`)),
19
+ )
20
+
21
+ export const authenticationServiceTest = L.succeed(
22
+ AuthenticationService,
23
+ AuthenticationService.of({
24
+ authenticatePasskey: () => E.succeed(Fixtures.principal),
25
+ }),
26
+ )
27
+
28
+ export const rpcClientTest = L.succeed(
29
+ RpcClient,
30
+ RpcClient.of({
31
+ preConnect: () => E.succeed({ warmed: true }),
32
+ isExistingUser: () => E.succeed({ existingUser: true }),
33
+ verifyEmail: () => E.succeed({ verified: true }),
34
+ getRegistrationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
35
+ verifyRegistrationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
36
+ getAuthenticationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
37
+ verifyAuthenticationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
38
+ }),
39
+ )
40
+
41
+ export const verifyEmailReq = new VerifyEmailReq({ token, code })
42
+
43
+ export const verifyEmailRes = new VerifyEmailRes({ verified: true })
44
+
45
+ export const principal = Fixtures.principal
46
+
47
+ export const storedToken = Fixtures.storedToken
48
+
49
+ export const storageServiceTest = Fixtures.storageServiceTest
@@ -0,0 +1,186 @@
1
+ import { type RouterOps, RpcClient } from '@passlock/shared/dist/rpc/rpc'
2
+ import { Effect as E, Layer as L, LogLevel, Logger, pipe } from 'effect'
3
+ import { NoSuchElementException } from 'effect/Cause'
4
+ import { describe, expect, test } from 'vitest'
5
+ import { mock } from 'vitest-mock-extended'
6
+ import { EmailService, EmailServiceLive } from './email'
7
+ import * as Fixture from './email.fixture'
8
+ import { AuthenticationService } from '../authentication/authenticate'
9
+ import { StorageService } from '../storage/storage'
10
+
11
+
12
+ describe('verifyEmailCode should', () => {
13
+ test('return true when the verification is successful', async () => {
14
+ const assertions = E.gen(function* (_) {
15
+ const service = yield* _(EmailService)
16
+ const result = yield* _(service.verifyEmailCode({ code: '123' }))
17
+
18
+ expect(result).toBe(true)
19
+ })
20
+
21
+ const service = pipe(
22
+ EmailServiceLive,
23
+ L.provide(Fixture.locationSearchTest),
24
+ L.provide(Fixture.authenticationServiceTest),
25
+ L.provide(Fixture.storageServiceTest),
26
+ L.provide(Fixture.rpcClientTest),
27
+ )
28
+
29
+ const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
30
+
31
+ return E.runPromise(effect)
32
+ })
33
+
34
+ test('check for a token in local storage', async () => {
35
+ const assertions = E.gen(function* (_) {
36
+ const service = yield* _(EmailService)
37
+ yield* _(service.verifyEmailCode({ code: '123' }))
38
+
39
+ const storageService = yield* _(StorageService)
40
+ expect(storageService.getToken).toHaveBeenCalledWith('passkey')
41
+ })
42
+
43
+ const storageServiceTest = L.effect(
44
+ StorageService,
45
+ E.sync(() => {
46
+ const storageServiceMock = mock<StorageService>()
47
+
48
+ storageServiceMock.getToken.mockReturnValue(E.succeed(Fixture.storedToken))
49
+ storageServiceMock.clearToken.mockReturnValue(E.unit)
50
+
51
+ return storageServiceMock
52
+ }),
53
+ )
54
+
55
+ const service = pipe(
56
+ EmailServiceLive,
57
+ L.provide(Fixture.locationSearchTest),
58
+ L.provide(Fixture.authenticationServiceTest),
59
+ L.provide(storageServiceTest),
60
+ L.provide(Fixture.rpcClientTest),
61
+ )
62
+
63
+ const layers = L.merge(service, storageServiceTest)
64
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
65
+
66
+ return E.runPromise(effect)
67
+ })
68
+
69
+ test('re-authenticate the user if no local token', async () => {
70
+ const assertions = E.gen(function* (_) {
71
+ const service = yield* _(EmailService)
72
+ yield* _(service.verifyEmailCode({ code: '123' }))
73
+
74
+ const authService = yield* _(AuthenticationService)
75
+ expect(authService.authenticatePasskey).toHaveBeenCalled()
76
+ })
77
+
78
+ const storageServiceTest = L.effect(
79
+ StorageService,
80
+ E.sync(() => {
81
+ const storageServiceMock = mock<StorageService>()
82
+
83
+ storageServiceMock.getToken.mockReturnValue(E.fail(new NoSuchElementException()))
84
+ storageServiceMock.clearToken.mockReturnValue(E.unit)
85
+
86
+ return storageServiceMock
87
+ }),
88
+ )
89
+
90
+ const authServiceTest = L.effect(
91
+ AuthenticationService,
92
+ E.sync(() => {
93
+ const authServiceMock = mock<AuthenticationService>()
94
+
95
+ authServiceMock.authenticatePasskey.mockReturnValue(E.succeed(Fixture.principal))
96
+
97
+ return authServiceMock
98
+ }),
99
+ )
100
+
101
+ const service = pipe(
102
+ EmailServiceLive,
103
+ L.provide(Fixture.locationSearchTest),
104
+ L.provide(authServiceTest),
105
+ L.provide(storageServiceTest),
106
+ L.provide(Fixture.rpcClientTest),
107
+ )
108
+
109
+ const layers = L.mergeAll(service, storageServiceTest, authServiceTest)
110
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
111
+
112
+ return E.runPromise(effect)
113
+ })
114
+
115
+ test('call the backend', async () => {
116
+ const assertions = E.gen(function* (_) {
117
+ const service = yield* _(EmailService)
118
+ yield* _(service.verifyEmailCode({ code: Fixture.code }))
119
+
120
+ const rpcClient = yield* _(RpcClient)
121
+ expect(rpcClient.verifyEmail).toHaveBeenCalledWith(Fixture.verifyEmailReq)
122
+ })
123
+
124
+ const rpcClientTest = L.effect(
125
+ RpcClient,
126
+ E.sync(() => {
127
+ const rpcMock = mock<RouterOps>()
128
+
129
+ rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.verifyEmailRes))
130
+
131
+ return rpcMock
132
+ }),
133
+ )
134
+
135
+ const service = pipe(
136
+ EmailServiceLive,
137
+ L.provide(Fixture.locationSearchTest),
138
+ L.provide(Fixture.authenticationServiceTest),
139
+ L.provide(Fixture.storageServiceTest),
140
+ L.provide(rpcClientTest),
141
+ )
142
+
143
+ const layers = L.merge(service, rpcClientTest)
144
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
145
+
146
+ return E.runPromise(effect)
147
+ })
148
+ })
149
+
150
+ describe('verifyEmailLink should', () => {
151
+ test('extract the code from the current url', async () => {
152
+ const assertions = E.gen(function* (_) {
153
+ const service = yield* _(EmailService)
154
+ yield* _(service.verifyEmailLink())
155
+
156
+ // LocationSearch return ?code=code
157
+ // and we expect rpcClient to be called with code
158
+ const rpcClient = yield* _(RpcClient)
159
+ expect(rpcClient.verifyEmail).toBeCalledWith(Fixture.verifyEmailReq)
160
+ })
161
+
162
+ const rpcClientTest = L.effect(
163
+ RpcClient,
164
+ E.sync(() => {
165
+ const rpcMock = mock<RouterOps>()
166
+
167
+ rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.verifyEmailRes))
168
+
169
+ return rpcMock
170
+ }),
171
+ )
172
+
173
+ const service = pipe(
174
+ EmailServiceLive,
175
+ L.provide(Fixture.locationSearchTest),
176
+ L.provide(Fixture.storageServiceTest),
177
+ L.provide(Fixture.authenticationServiceTest),
178
+ L.provide(rpcClientTest),
179
+ )
180
+
181
+ const layers = L.merge(service, rpcClientTest)
182
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
183
+
184
+ return E.runPromise(effect)
185
+ })
186
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Email verification effects
3
+ */
4
+ import { BadRequest } from '@passlock/shared/dist/error/error'
5
+ import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
6
+ import type { VerifyEmailErrors as RpcErrors} from '@passlock/shared/dist/rpc/user';
7
+ import { VerifyEmailReq } from '@passlock/shared/dist/rpc/user'
8
+ import { Context, Effect as E, Layer, Option as O, flow, identity, pipe } from 'effect'
9
+ import { type AuthenticationErrors, AuthenticationService } from '../authentication/authenticate'
10
+ import { StorageService } from '../storage/storage'
11
+
12
+
13
+ /* Requests */
14
+
15
+ export type VerifyRequest = {
16
+ code: string
17
+ }
18
+
19
+ /* Errors */
20
+
21
+ export type VerifyEmailErrors = RpcErrors | AuthenticationErrors
22
+
23
+ /* Dependencies */
24
+
25
+ export class LocationSearch extends Context.Tag('LocationSearch')<
26
+ LocationSearch,
27
+ E.Effect<string>
28
+ >() {}
29
+
30
+ /* Service */
31
+
32
+ export type EmailService = {
33
+ verifyEmailCode: (request: VerifyRequest) => E.Effect<boolean, VerifyEmailErrors>
34
+ verifyEmailLink: () => E.Effect<boolean, VerifyEmailErrors>
35
+ }
36
+
37
+ export const EmailService = Context.GenericTag<EmailService>('@services/EmailService')
38
+
39
+ /* Utils */
40
+
41
+ export type Dependencies = StorageService | AuthenticationService | RpcClient
42
+
43
+ /**
44
+ * Check for existing token in sessionStorage,
45
+ * otherwise force passkey re-authentication
46
+ * @returns
47
+ */
48
+ const getToken = () => {
49
+ return E.gen(function* (_) {
50
+ // Check for existing token
51
+ const storageService = yield* _(StorageService)
52
+ const existingTokenE = storageService.getToken('passkey')
53
+ const authenticationService = yield* _(AuthenticationService)
54
+
55
+ const tokenE = E.matchEffect(existingTokenE, {
56
+ onSuccess: token => E.succeed(token),
57
+ onFailure: () =>
58
+ // No token, need to authenticate the user
59
+ pipe(
60
+ authenticationService.authenticatePasskey({ userVerification: 'preferred' }),
61
+ E.map(principal => ({
62
+ token: principal.token,
63
+ authType: principal.authStatement.authType,
64
+ expiresAt: principal.expireAt.getTime(),
65
+ })),
66
+ ),
67
+ })
68
+
69
+ const token = yield* _(tokenE)
70
+ yield* _(storageService.clearToken('passkey'))
71
+
72
+ return token
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Look for ?code=<code> in the url
78
+ * @returns
79
+ */
80
+ export const extractCodeFromHref = () => {
81
+ return pipe(
82
+ LocationSearch,
83
+ E.flatMap(identity),
84
+ E.map(search => new URLSearchParams(search)),
85
+ E.flatMap(params => O.fromNullable(params.get('code'))),
86
+ )
87
+ }
88
+
89
+ /* Effects */
90
+
91
+ /**
92
+ * Verify the mailbox using the given code
93
+ * @param verificationRequest
94
+ * @returns
95
+ */
96
+ export const verifyEmail = (
97
+ verificationRequest: VerifyRequest,
98
+ ): E.Effect<boolean, VerifyEmailErrors, Dependencies> => {
99
+ return E.gen(function* (_) {
100
+ // Re-authenticate the user if required
101
+ const { token } = yield* _(getToken())
102
+
103
+ yield* _(E.logDebug('Making request'))
104
+ const client = yield* _(RpcClient)
105
+ const { verified } = yield* _(
106
+ client.verifyEmail(new VerifyEmailReq({ token, code: verificationRequest.code })),
107
+ )
108
+
109
+ return verified
110
+ })
111
+ }
112
+
113
+ /**
114
+ * Look for a code in the current url and verify it
115
+ * @returns
116
+ */
117
+ export const verifyEmailLink = () =>
118
+ pipe(
119
+ extractCodeFromHref(),
120
+ E.mapError(() => new BadRequest({ message: 'Expected ?code=xxx in window.location' })),
121
+ E.flatMap(code => verifyEmail({ code })),
122
+ )
123
+
124
+ /* Live */
125
+
126
+ /* v8 ignore start */
127
+ export const EmailServiceLive = Layer.effect(
128
+ EmailService,
129
+ E.gen(function* (_) {
130
+ const context = yield* _(
131
+ E.context<RpcClient | AuthenticationService | StorageService | LocationSearch>(),
132
+ )
133
+ return EmailService.of({
134
+ verifyEmailCode: flow(verifyEmail, E.provide(context)),
135
+ verifyEmailLink: flow(verifyEmailLink, E.provide(context)),
136
+ })
137
+ }),
138
+ )
139
+ /* v8 ignore stop */
@@ -0,0 +1,20 @@
1
+ import { Effect, pipe } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { fireEvent } from './event'
4
+
5
+ // @vitest-environment node
6
+
7
+ describe('isPasslockEvent', () => {
8
+ test("return a Passlock error if custom events aren't supported", async () => {
9
+ const program = pipe(
10
+ fireEvent('hello world'),
11
+ Effect.flip,
12
+ Effect.tap(e => {
13
+ expect(e._tag).toBe('InternalBrowserError')
14
+ expect(e.message).toBe('Unable to fire custom event')
15
+ }),
16
+ )
17
+
18
+ await Effect.runPromise(program)
19
+ })
20
+ })
@@ -0,0 +1,37 @@
1
+ import { Effect } from 'effect'
2
+ import { afterEach, describe, expect, test, vi } from 'vitest'
3
+ import { DebugMessage, fireEvent, isPasslockEvent } from './event'
4
+
5
+
6
+ describe('fireEvent', () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks()
9
+ })
10
+
11
+ test('fire a custom log event', () => {
12
+ const effect = fireEvent('hello world')
13
+ const eventSpy = vi.spyOn(globalThis, 'dispatchEvent')
14
+ Effect.runSync(effect)
15
+
16
+ const expectedEvent = new CustomEvent(DebugMessage, {
17
+ detail: 'hello world',
18
+ })
19
+
20
+ expect(eventSpy).toHaveBeenCalledWith(expectedEvent)
21
+ })
22
+ })
23
+
24
+ describe('isPasslockEvent', () => {
25
+ test('return true for our custom event', () => {
26
+ const passlockEvent = new CustomEvent(DebugMessage, {
27
+ detail: 'hello world',
28
+ })
29
+
30
+ expect(isPasslockEvent(passlockEvent)).toBe(true)
31
+ })
32
+
33
+ test('return false for other events', () => {
34
+ const otherEvent = new MouseEvent('click')
35
+ expect(isPasslockEvent(otherEvent)).toBe(false)
36
+ })
37
+ })