1import { assert, describe, it } from "@effect/vitest"
2import { Context, Effect, Fiber, Queue, type Scope } from "effect"
3import * as EventJournal from "effect/eventlog/EventJournal"
4import * as EventLog from "effect/eventlog/EventLog"
5import * as EventLogEncryption from "effect/eventlog/EventLogEncryption"
6import * as EventLogMessage from "effect/eventlog/EventLogMessage"
7import * as EventLogRemote from "effect/eventlog/EventLogRemote"
8import * as EventLogSessionAuth from "effect/eventlog/EventLogSessionAuth"
9import { makeGetIdentityRootSecretMaterial } from "effect/eventlog/internal/identityRootSecretDerivation"
10import type * as Rpc from "effect/rpc/Rpc"
11import type * as RpcGroup from "effect/rpc/RpcGroup"
12import * as RpcTest from "effect/rpc/RpcTest"
13
14describe("EventLogRemote", () => {
15 it.effect("retries wrapped Forbidden authentication errors", () =>
16 Effect.gen(function*() {
17 const harness = yield* makeHarness(EventLogRemote.makeUnencrypted)
18 const identity = yield* EventLog.makeIdentity
19 let authenticationAttempts = 0
20
21 yield* Effect.forever(Effect.gen(function*() {
22 const request = yield* harness.take
23 if (request._tag === "EventLog.Hello") {
24 request.resume(Effect.succeed(new EventLogMessage.HelloResponse({ remoteId, challenge })))
25 } else if (request._tag === "EventLog.Authenticate") {
26 authenticationAttempts++
27 request.resume(
28 authenticationAttempts === 1
29 ? Effect.fail(
30 new EventLogMessage.EventLogProtocolError({
31 requestTag: "Authenticate",
32 publicKey: undefined,
33 code: "Forbidden",
34 message: "Session auth challenge has expired"
35 })
36 )
37 : Effect.void
38 )
39 } else if (request._tag === "EventLog.WriteSingle") {
40 request.resume(Effect.void)
41 }
42 })).pipe(Effect.forkChild)
43
44 yield* harness.remote.write({
45 identity,
46 storeId: defaultStoreId,
47 entries: [makeEntry()]
48 })
49
50 assert.strictEqual(authenticationAttempts, 2)
51 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))
52
53 it.effect("makeUnencrypted authenticates before writing plaintext entries", () =>
54 Effect.gen(function*() {
55 const harness = yield* makeHarness(EventLogRemote.makeUnencrypted)
56 const identity = yield* EventLog.makeIdentity
57 const entry = makeEntry()
58
59 const writeFiber = yield* harness.remote.write({
60 identity,
61 storeId: defaultStoreId,
62 entries: [entry]
63 }).pipe(Effect.forkChild)
64 yield* authenticate({ harness, identity })
65
66 const request = yield* harness.take
67 assert(request._tag === "EventLog.WriteSingle")
68 const payload = request.request as Rpc.Payload<typeof EventLogMessage.WriteSingleRpc>
69 const decoded = yield* EventLogMessage.WriteEntriesUnencrypted.decode(payload.data)
70
71 assert.strictEqual(decoded.publicKey, identity.publicKey)
72 assert.strictEqual(decoded.storeId, defaultStoreId)
73 assert.strictEqual(decoded.entries.length, 1)
74 assert.strictEqual(decoded.entries[0].idString, entry.idString)
75 request.resume(Effect.void)
76
77 yield* Fiber.join(writeFiber)
78 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))
79
80 it.effect("makeEncrypted authenticates before writing encrypted entries", () =>
81 Effect.gen(function*() {
82 const harness = yield* makeHarness(EventLogRemote.makeEncrypted)
83 const identity = yield* EventLog.makeIdentity
84
85 const writeFiber = yield* harness.remote.write({
86 identity,
87 storeId: defaultStoreId,
88 entries: [makeEntry()]
89 }).pipe(Effect.forkScoped)
90 yield* authenticate({ harness, identity })
91
92 const request = yield* harness.take
93 assert(request._tag === "EventLog.WriteSingle")
94 const payload = request.request as Rpc.Payload<typeof EventLogMessage.WriteSingleRpc>
95 const decoded = yield* EventLogMessage.WriteEntries.decode(payload.data)
96
97 assert.strictEqual(decoded.publicKey, identity.publicKey)
98 assert.strictEqual(decoded.storeId, defaultStoreId)
99 assert.strictEqual(decoded.encryptedEntries.length, 1)
100
101 request.resume(Effect.void)
102 yield* Fiber.join(writeFiber)
103 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))
104})
105
106class RemoteHarness extends Context.Service<RemoteHarness, {
107 readonly remote: EventLogRemote.EventLogRemote["Service"]
108 readonly take: Effect.Effect<{
109 readonly _tag: Rpc.Tag<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>>
110 readonly request: Rpc.Payload<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>>
111 readonly resume: (response: Effect.Effect<unknown, unknown>) => void
112 }>
113}>()("RemoteHarness") {}
114
115const makeHarness = Effect.fn(function*(
116 makeClient: Effect.Effect<
117 EventLogRemote.EventLogRemote["Service"],
118 EventLogRemote.EventLogRemoteError,
119 EventLogEncryption.EventLogEncryption | EventLogRemote.EventLogRemoteClient | EventLog.Registry | Scope.Scope
120 >
121): Effect.fn.Return<
122 RemoteHarness["Service"],
123 EventLogRemote.EventLogRemoteError,
124 Scope.Scope
125> {
126 const requests = yield* Queue.make<{
127 readonly _tag: Rpc.Tag<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>>
128 readonly request: Rpc.Payload<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>>
129 readonly resume: (response: Effect.Effect<unknown, unknown>) => void
130 }>()
131
132 const handleRequest = (tag: string) => <A, E>(request: any) =>
133 Effect.callback<A, E>((resume) => {
134 Queue.offerUnsafe(requests, {
135 _tag: tag as any,
136 request,
137 resume: resume as any
138 })
139 })
140
141 const client = yield* RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe(
142 Effect.provide(EventLogMessage.EventLogRemoteRpcs.toLayer({
143 "EventLog.Hello": handleRequest("EventLog.Hello"),
144 "EventLog.Authenticate": handleRequest("EventLog.Authenticate"),
145 "EventLog.WriteChunked": handleRequest("EventLog.WriteChunked"),
146 "EventLog.WriteSingle": handleRequest("EventLog.WriteSingle"),
147 "EventLog.Changes": handleRequest("EventLog.Changes")
148 })),
149 Effect.provideService(
150 EventLogMessage.EventLogAuthentication,
151 (effect) => Effect.provideService(effect, EventLog.Identity, {} as any)
152 )
153 )
154 const remote = yield* makeClient.pipe(
155 Effect.provideService(EventLogRemote.EventLogRemoteClient, client),
156 Effect.provide([EventLogEncryption.layerSubtle, EventLog.layerRegistry]),
157 Effect.forkChild
158 )
159
160 const hello = yield* Queue.take(requests)
161 assert(hello._tag === "EventLog.Hello")
162 hello.resume(Effect.succeed(
163 new EventLogMessage.HelloResponse({
164 remoteId,
165 challenge
166 })
167 ))
168
169 return RemoteHarness.of({
170 remote: yield* Fiber.join(remote),
171 take: Queue.take(requests)
172 })
173})
174
175const makeEntry = (options?: {
176 readonly primaryKey?: string | undefined
177 readonly payloadSize?: number | undefined
178}): EventJournal.Entry =>
179 new EventJournal.Entry({
180 id: EventJournal.makeEntryIdUnsafe(),
181 event: "UserCreated",
182 primaryKey: options?.primaryKey ?? "user-1",
183 payload: new Uint8Array(options?.payloadSize ?? 4).fill(1)
184 })
185
186const defaultStoreId = EventLogMessage.StoreId.make("default")
187const remoteId = EventJournal.makeRemoteIdUnsafe()
188const challenge = new Uint8Array(16).fill(1)
189const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto)
190
191const authenticate = Effect.fnUntraced(function*(options: {
192 readonly harness: RemoteHarness["Service"]
193 readonly identity: EventLog.Identity["Service"]
194}) {
195 const auth = yield* options.harness.take
196 assert(auth._tag === "EventLog.Authenticate")
197 const payload = auth.request as Rpc.Payload<typeof EventLogMessage.AuthenticateRpc>
198 const rootSecretMaterial = yield* getIdentityRootSecretMaterial(options.identity)
199
200 assert.strictEqual(payload.publicKey, options.identity.publicKey)
201 assert.strictEqual(payload.algorithm, "Ed25519")
202 assert.deepStrictEqual(payload.signingPublicKey, rootSecretMaterial.signingPublicKey)
203
204 const verified = yield* EventLogSessionAuth.verifySessionAuthenticateRequest({
205 remoteId,
206 challenge,
207 publicKey: payload.publicKey,
208 signingPublicKey: payload.signingPublicKey,
209 signature: payload.signature,
210 algorithm: payload.algorithm
211 })
212 assert.isTrue(verified)
213 auth.resume(Effect.void)
214})