| 1 | import { assert, it } from "@effect/vitest" |
| 2 | import { Context, Effect, Layer, Redacted, Ref, Schema } from "effect" |
| 3 | import * as EventGroup from "effect/eventlog/EventGroup" |
| 4 | import * as EventJournal from "effect/eventlog/EventJournal" |
| 5 | import * as EventLog from "effect/eventlog/EventLog" |
| 6 | import * as EventLogEncryption from "effect/eventlog/EventLogEncryption" |
| 7 | import * as EventLogMessage from "effect/eventlog/EventLogMessage" |
| 8 | import * as EventLogServerUnencrypted from "effect/eventlog/EventLogServerUnencrypted" |
| 9 | import * as EventLogSessionAuth from "effect/eventlog/EventLogSessionAuth" |
| 10 | import { makeGetIdentityRootSecretMaterial } from "effect/eventlog/internal/identityRootSecretDerivation" |
| 11 | import * as RpcTest from "effect/rpc/RpcTest" |
| 12 | |
| 13 | const ReproGroup = EventGroup.empty.add({ |
| 14 | tag: "ReproEvent", |
| 15 | primaryKey: (payload) => payload.key, |
| 16 | payload: Schema.Struct({ key: Schema.String, value: Schema.Number }) |
| 17 | }) |
| 18 | const event = ReproGroup.events.ReproEvent |
| 19 | const storeId = EventLogMessage.StoreId.make("repro-store") |
| 20 | const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto) |
| 21 | |
| 22 | const 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 | |
| 43 | it.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))) |
| 118 | const 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 | |
| 134 | const 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 | |
| 147 | it.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 | |
| 185 | it.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))) |