1import { assert, it } from "@effect/vitest"
2import { Context, Effect, Layer, Redacted, Ref, Schema } from "effect"
3import * as EventGroup from "effect/eventlog/EventGroup"
4import * as EventJournal from "effect/eventlog/EventJournal"
5import * as EventLog from "effect/eventlog/EventLog"
6import * as EventLogEncryption from "effect/eventlog/EventLogEncryption"
7import * as EventLogMessage from "effect/eventlog/EventLogMessage"
8import * as EventLogServerUnencrypted from "effect/eventlog/EventLogServerUnencrypted"
9import * as EventLogSessionAuth from "effect/eventlog/EventLogSessionAuth"
10import { makeGetIdentityRootSecretMaterial } from "effect/eventlog/internal/identityRootSecretDerivation"
11import * as RpcTest from "effect/rpc/RpcTest"
12
13const ReproGroup = EventGroup.empty.add({
14 tag: "ReproEvent",
15 primaryKey: (payload) => payload.key,
16 payload: Schema.Struct({ key: Schema.String, value: Schema.Number })
17})
18const event = ReproGroup.events.ReproEvent
19const storeId = EventLogMessage.StoreId.make("repro-store")
20const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto)
21
22const authenticate = Effect.fnUntraced(function*(options: {
23 readonly identity: EventLog.Identity["Service"]
24 readonly challenge: Uint8Array
25 readonly remoteId: EventJournal.RemoteId
26}) {
27 const material = yield* getIdentityRootSecretMaterial(options.identity)
28 const signature = yield* EventLogSessionAuth.signSessionAuthPayload({
29 remoteId: options.remoteId,
30 challenge: options.challenge,
31 publicKey: options.identity.publicKey,
32 signingPublicKey: material.signingPublicKey,
33 signingPrivateKey: Redacted.value(material.signingPrivateKey)
34 })
35 return new EventLogMessage.Authenticate({
36 publicKey: options.identity.publicKey,
37 signingPublicKey: material.signingPublicKey,
38 signature,
39 algorithm: "Ed25519"
40 })
41})
42
43it.effect("indexes conflicts from the sliced history", () =>
44 Effect.gen(function*() {
45 const encode = Schema.encodeUnknownEffect(event.payloadSchemaBinary)
46 const makeEntry = Effect.fnUntraced(function*(msecs: number, key: string, value: number) {
47 return new EventJournal.Entry({
48 id: EventJournal.makeEntryIdUnsafe({ msecs }),
49 event: "ReproEvent",
50 primaryKey: key,
51 payload: yield* encode({ key, value })
52 }, { disableChecks: true })
53 })
54 const originA = yield* makeEntry(1_000, "other-origin", 10)
55 const oldSameKey = yield* makeEntry(2_000, "key", 20)
56 const originB = yield* makeEntry(3_000, "key", 30)
57 const newerOtherKey = yield* makeEntry(4_000, "other", 40)
58 const newerSameKey = yield* makeEntry(5_000, "key", 50)
59
60 const storage = yield* EventLogServerUnencrypted.makeStorageMemory
61 yield* storage.write(storeId, [oldSameKey, newerOtherKey, newerSameKey])
62 const registry = yield* EventLog.Registry.pipe(Effect.provide(EventLog.layerRegistry))
63 const seenOriginA = yield* Ref.make<ReadonlyArray<EventJournal.Entry> | undefined>(undefined)
64 const seenOriginB = yield* Ref.make<ReadonlyArray<EventJournal.Entry> | undefined>(undefined)
65 registry.registerHandlerUnsafe({
66 event: event.tag,
67 handler: {
68 event,
69 context: Context.empty() as Context.Context<any>,
70 handler: ({ payload, conflicts }) => {
71 const value = (payload as { value: number }).value
72 return value === 10
73 ? Ref.set(seenOriginA, conflicts.map((conflict) => conflict.entry))
74 : value === 30
75 ? Ref.set(seenOriginB, conflicts.map((conflict) => conflict.entry))
76 : Effect.void
77 }
78 }
79 })
80
81 const client = yield* RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe(
82 Effect.provide(EventLogServerUnencrypted.layerRpcHandlers.pipe(
83 Layer.provide(Layer.succeed(EventLogServerUnencrypted.Storage, storage)),
84 Layer.provide(Layer.succeed(EventLog.Registry, registry)),
85 Layer.provide(Layer.succeed(EventLogServerUnencrypted.StoreMapping, {
86 resolve: ({ storeId }) => Effect.succeed(storeId),
87 hasStore: () => Effect.succeed(true)
88 })),
89 Layer.provide(Layer.succeed(EventLogServerUnencrypted.EventLogServerAuthorization, {
90 authorizeWrite: () => Effect.void,
91 authorizeRead: () => Effect.void,
92 authorizeIdentity: () => Effect.void
93 }))
94 ))
95 )
96 const identity = yield* EventLog.makeIdentity
97 const hello = yield* client["EventLog.Hello"]()
98 yield* client["EventLog.Authenticate"](
99 yield* authenticate({
100 identity,
101 challenge: hello.challenge,
102 remoteId: hello.remoteId
103 })
104 )
105 const data = yield* new EventLogMessage.WriteEntriesUnencrypted({
106 publicKey: identity.publicKey,
107 storeId,
108 entries: [originA, originB]
109 }).encoded
110 yield* client["EventLog.WriteSingle"]({ data })
111 const originAConflicts = yield* Ref.get(seenOriginA)
112 assert.isDefined(originAConflicts)
113 assert.deepStrictEqual(originAConflicts.map((entry) => entry.idString), [])
114 const originBConflicts = yield* Ref.get(seenOriginB)
115 assert.isDefined(originBConflicts)
116 assert.deepStrictEqual(originBConflicts.map((entry) => entry.idString), [newerSameKey.idString])
117 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))
118const makeAuthClient = (options: {
119 readonly storage: EventLogServerUnencrypted.Storage["Service"]
120 readonly authorization: EventLogServerUnencrypted.EventLogServerAuthorization["Service"]
121}) =>
122 RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe(
123 Effect.provide(EventLogServerUnencrypted.layerRpcHandlers.pipe(
124 Layer.provide(Layer.succeed(EventLogServerUnencrypted.Storage, options.storage)),
125 Layer.provide(EventLog.layerRegistry),
126 Layer.provide(Layer.succeed(EventLogServerUnencrypted.StoreMapping, {
127 resolve: ({ storeId }) => Effect.succeed(storeId),
128 hasStore: () => Effect.succeed(true)
129 })),
130 Layer.provide(Layer.succeed(EventLogServerUnencrypted.EventLogServerAuthorization, options.authorization))
131 ))
132 )
133
134const makeRecordingStorage = Effect.gen(function*() {
135 const bindingCalls = yield* Ref.make(0)
136 const inner = yield* EventLogServerUnencrypted.makeStorageMemory
137 const storage = EventLogServerUnencrypted.Storage.of({
138 ...inner,
139 getOrCreateSessionAuthBinding: (publicKey, signingPublicKey) =>
140 Ref.update(bindingCalls, (count) => count + 1).pipe(
141 Effect.andThen(inner.getOrCreateSessionAuthBinding(publicKey, signingPublicKey))
142 )
143 })
144 return { storage, bindingCalls }
145})
146
147it.effect("Authenticate denies a first-use identity without creating a binding", () =>
148 Effect.gen(function*() {
149 const { storage, bindingCalls } = yield* makeRecordingStorage
150 const policyCalls = yield* Ref.make<ReadonlyArray<string>>([])
151 const client = yield* makeAuthClient({
152 storage,
153 authorization: {
154 authorizeWrite: () => Effect.void,
155 authorizeRead: () => Effect.void,
156 authorizeIdentity: ({ publicKey }) =>
157 Ref.update(policyCalls, (calls) => [...calls, publicKey]).pipe(
158 Effect.andThen(Effect.fail(
159 new EventLogServerUnencrypted.EventLogServerAuthError({
160 reason: "Forbidden",
161 publicKey,
162 message: "Identity denied"
163 })
164 ))
165 )
166 }
167 })
168 const identity = yield* EventLog.makeIdentity
169 const hello = yield* client["EventLog.Hello"]()
170 const result = yield* client["EventLog.Authenticate"](
171 yield* authenticate({ identity, challenge: hello.challenge, remoteId: hello.remoteId })
172 ).pipe(Effect.match({
173 onFailure: (error) => ({ tag: "Failed" as const, error }),
174 onSuccess: () => ({ tag: "Succeeded" as const })
175 }))
176 assert.strictEqual(result.tag, "Failed")
177 if (result.tag === "Failed") {
178 assert.strictEqual(result.error._tag, "EventLogProtocolError")
179 assert.strictEqual(result.error.code, "Forbidden")
180 }
181 assert.deepStrictEqual(yield* Ref.get(policyCalls), [identity.publicKey])
182 assert.strictEqual(yield* Ref.get(bindingCalls), 0)
183 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))
184
185it.effect("Authenticate re-checks identity authorization on a cached binding", () =>
186 Effect.gen(function*() {
187 const { storage, bindingCalls } = yield* makeRecordingStorage
188 const allowed = yield* Ref.make(true)
189 const policyCalls = yield* Ref.make<ReadonlyArray<string>>([])
190 const client = yield* makeAuthClient({
191 storage,
192 authorization: {
193 authorizeWrite: () => Effect.void,
194 authorizeRead: () => Effect.void,
195 authorizeIdentity: ({ publicKey }) =>
196 Ref.update(policyCalls, (calls) => [...calls, publicKey]).pipe(
197 Effect.andThen(Ref.get(allowed)),
198 Effect.andThen((allow) =>
199 allow ? Effect.void : Effect.fail(
200 new EventLogServerUnencrypted.EventLogServerAuthError({
201 reason: "Forbidden",
202 publicKey,
203 message: "Identity denied"
204 })
205 )
206 )
207 )
208 }
209 })
210 const identity = yield* EventLog.makeIdentity
211 const hello = yield* client["EventLog.Hello"]()
212 yield* client["EventLog.Authenticate"](
213 yield* authenticate({ identity, challenge: hello.challenge, remoteId: hello.remoteId })
214 )
215 assert.strictEqual(yield* Ref.get(bindingCalls), 1)
216
217 yield* Ref.set(allowed, false)
218 const retryHello = yield* client["EventLog.Hello"]()
219 const result = yield* client["EventLog.Authenticate"](
220 yield* authenticate({ identity, challenge: retryHello.challenge, remoteId: retryHello.remoteId })
221 ).pipe(Effect.match({
222 onFailure: (error) => ({ tag: "Failed" as const, error }),
223 onSuccess: () => ({ tag: "Succeeded" as const })
224 }))
225 assert.strictEqual(result.tag, "Failed")
226 if (result.tag === "Failed") {
227 assert.strictEqual(result.error._tag, "EventLogProtocolError")
228 assert.strictEqual(result.error.code, "Forbidden")
229 }
230 assert.deepStrictEqual(yield* Ref.get(policyCalls), [identity.publicKey, identity.publicKey])
231 assert.strictEqual(yield* Ref.get(bindingCalls), 1)
232 }).pipe(Effect.provide(EventLogEncryption.layerSubtle)))