@ossy/event-store 3.11.0 → 3.12.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.
package/README.md CHANGED
@@ -15,12 +15,13 @@ Event sourcing primitives for the Ossy platform. Provides `Aggregate` (ADR 0008
15
15
  | `getProjection` | `projection-queries.js` | Read a projection snapshot by scope |
16
16
  | `PushInvalidation` | `push-invalidation.js` | Workspace-scoped SSE fan-out |
17
17
  | `buildInvalidationKeys`, `buildPushMessage` | `push-invalidation.js` | Derive client cache keys from events |
18
+ | `runWithEventContext`, `getEventContextIp`, `resolveClientIp`, `stampAppendedEvent` | `event-context.js` / `resolve-client-ip.js` / `stamp-resource-event.js` | Stamp trusted connection `ip` on appended events (ADR 0016) |
18
19
 
19
20
  ## Core concepts
20
21
 
21
22
  The event store keeps two MongoDB collections:
22
23
 
23
- - **`eventstore`** — every resource/entity event ever appended. Immutable. ADR 0008 documents use `type` (schema id), `resourceId`, `event` (lifecycle name), `version`, `payload`, `created`, and `createdBy`.
24
+ - **`eventstore`** — every resource/entity event ever appended. Immutable. ADR 0008 documents use `type` (schema id), `resourceId`, `event` (lifecycle name), `version`, `payload`, `created`, `createdBy`, and optional `ip` (ADR 0016; omitted from folded snapshots).
24
25
  - **`aggregates`** — denormalized state snapshots: entity/resource folds keyed by `id`, and projection snapshots keyed by `{ id: scopeId, type: projectionId, kind: 'projection' }`. Rebuilt at startup and updated on the changestream.
25
26
 
26
27
  ---
@@ -159,7 +160,8 @@ Recommended indexes (created at startup by `ensureEventStoreIndexes`):
159
160
 
160
161
  ```js
161
162
  // eventstore
162
- { resourceId: 1, version: 1 } // stream queries
163
+ { resourceId: 1, version: 1 } // stream queries
164
+ { type: 1, createdBy: 1, event: 1 } // per-user membership list (select-workspace)
163
165
 
164
166
  // aggregates
165
167
  { id: 1 } // snapshot get-by-id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/event-store",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
26
26
  "license": "MIT",
27
27
  "dependencies": {
28
- "@ossy/fold": "^3.4.0",
28
+ "@ossy/fold": "^3.12.0",
29
29
  "@ossy/observability": "^3.0.9",
30
30
  "mongodb": "^7.2.0",
31
31
  "nanoid": "^5.1.11"
@@ -37,5 +37,5 @@
37
37
  "files": [
38
38
  "src"
39
39
  ],
40
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
40
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
41
41
  }
@@ -9,6 +9,8 @@ let ensured = false
9
9
  * Ensure MongoDB indexes required for aggregate/event-stream reads.
10
10
  * Without `{ resourceId, version }` on `eventstore`, every `getResourceStream`
11
11
  * call scans the full collection — sign-up and changestream rebuilds deadlock under load.
12
+ * Without `{ type, createdBy, event }` on `eventstore`, select-workspace list
13
+ * (`getAllByUserIncluded`) cannot use an index for per-user membership events.
12
14
  * Without `{ state.belongsTo, state.location }` on `aggregates`, folder list/search
13
15
  * examines the whole collection, including audit snapshots that are not in any folder.
14
16
  */
@@ -21,6 +23,10 @@ export async function ensureEventStoreIndexes () {
21
23
  { resourceId: 1, version: 1 },
22
24
  { name: 'resourceId_version', background: true },
23
25
  )
26
+ await eventstore.createIndex(
27
+ { type: 1, createdBy: 1, event: 1 },
28
+ { name: 'type_createdBy_event', background: true },
29
+ )
24
30
 
25
31
  const aggregates = Mongo.db.collection('aggregates')
26
32
  await aggregates.createIndex(
@@ -0,0 +1,27 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ /** @type {AsyncLocalStorage<{ ip?: string | null }>} */
4
+ const storage = new AsyncLocalStorage()
5
+
6
+ /**
7
+ * Run `fn` with an append-time event context (ADR 0016).
8
+ * `ip` is the trusted connection address for this request, or null to omit.
9
+ *
10
+ * @template T
11
+ * @param {{ ip?: string | null }} ctx
12
+ * @param {() => T} fn
13
+ * @returns {T}
14
+ */
15
+ export function runWithEventContext (ctx, fn) {
16
+ const ip = typeof ctx?.ip === 'string' && ctx.ip.trim() ? ctx.ip.trim() : null
17
+ return storage.run({ ip }, fn)
18
+ }
19
+
20
+ /**
21
+ * Trusted IP for the current append, or null when no HTTP request context.
22
+ * @returns {string | null}
23
+ */
24
+ export function getEventContextIp () {
25
+ const ip = storage.getStore()?.ip
26
+ return typeof ip === 'string' && ip.trim() ? ip.trim() : null
27
+ }
package/src/index.js CHANGED
@@ -7,3 +7,6 @@ export * from './projection-rebuild.js'
7
7
  export { getProjection } from './projection-queries.js'
8
8
  export { PushInvalidation, buildInvalidationKeys, buildPushMessage } from './push-invalidation.js'
9
9
  export { ensureEventStoreIndexes, resetEnsureIndexesForTests } from './ensure-indexes.js'
10
+ export { runWithEventContext, getEventContextIp } from './event-context.js'
11
+ export { resolveClientIp } from './resolve-client-ip.js'
12
+ export { stampAppendedEvent } from './stamp-resource-event.js'
@@ -124,7 +124,8 @@ export class ProjectionRebuild {
124
124
  for (const [scopeId, scopeEvents] of byScope) {
125
125
  let state = ProjectionClass.initialState?.(scopeId) ?? {}
126
126
  for (const event of scopeEvents) {
127
- state = ProjectionClass.Apply(event, state) ?? state
127
+ // Apply may be sync or async (e.g. MaxMind enrichment on page-view location).
128
+ state = (await Promise.resolve(ProjectionClass.Apply(event, state))) ?? state
128
129
  }
129
130
  const version = scopeEvents.length
130
131
  const last = scopeEvents[scopeEvents.length - 1]
@@ -161,7 +162,8 @@ export class ProjectionRebuild {
161
162
  )
162
163
 
163
164
  const state = snapshot?.state ?? ProjectionClass.initialState?.(scopeId) ?? {}
164
- const next = ProjectionClass.Apply(event, state)
165
+ // Apply may be sync or async (e.g. MaxMind enrichment on page-view location).
166
+ const next = await Promise.resolve(ProjectionClass.Apply(event, state))
165
167
  if (!next) return
166
168
 
167
169
  const version = (snapshot?.version ?? 0) + 1
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Best-effort client IP from an Express-like request.
3
+ *
4
+ * Prefer `req.ip` when Express `trust proxy` is enabled (platform defaults on —
5
+ * CloudFront → ALB). Then `socket.remoteAddress`. Leftmost `X-Forwarded-For`
6
+ * is **last resort only** (spoofable if the edge did not set `req.ip`).
7
+ * Never read `payload.ip` (ADR 0016).
8
+ *
9
+ * @param {unknown} req
10
+ * @returns {string | null}
11
+ */
12
+ export function resolveClientIp (req) {
13
+ if (!req || typeof req !== 'object') return null
14
+ const request = /** @type {{ headers?: Record<string, unknown>, ip?: unknown, socket?: { remoteAddress?: string } }} */ (req)
15
+ if (typeof request.ip === 'string' && request.ip.trim()) {
16
+ return normalizeIp(request.ip.trim())
17
+ }
18
+ const remote = request.socket?.remoteAddress
19
+ if (typeof remote === 'string' && remote.trim()) {
20
+ return normalizeIp(remote.trim())
21
+ }
22
+ const xff = request.headers?.['x-forwarded-for']
23
+ if (typeof xff === 'string' && xff.trim()) {
24
+ return normalizeIp(xff.split(',')[0].trim()) || null
25
+ }
26
+ if (Array.isArray(xff) && typeof xff[0] === 'string') {
27
+ return normalizeIp(xff[0].split(',')[0].trim()) || null
28
+ }
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * Strip IPv6-mapped IPv4 prefix (`::ffff:1.2.3.4` → `1.2.3.4`).
34
+ * @param {string} value
35
+ * @returns {string}
36
+ */
37
+ function normalizeIp (value) {
38
+ if (value.startsWith('::ffff:')) return value.slice('::ffff:'.length)
39
+ return value
40
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveClientIp } from './resolve-client-ip.js'
3
+
4
+ describe('resolveClientIp', () => {
5
+ it('prefers req.ip over socket and spoofable X-Forwarded-For', () => {
6
+ expect(resolveClientIp({
7
+ headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' },
8
+ socket: { remoteAddress: '10.0.0.1' },
9
+ ip: '9.9.9.9',
10
+ })).toBe('9.9.9.9')
11
+ })
12
+
13
+ it('prefers socket.remoteAddress over X-Forwarded-For', () => {
14
+ expect(resolveClientIp({
15
+ headers: { 'x-forwarded-for': '1.2.3.4' },
16
+ socket: { remoteAddress: '10.0.0.1' },
17
+ })).toBe('10.0.0.1')
18
+ })
19
+
20
+ it('uses leftmost XFF only as last resort', () => {
21
+ expect(resolveClientIp({
22
+ headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' },
23
+ })).toBe('1.2.3.4')
24
+ expect(resolveClientIp({
25
+ socket: { remoteAddress: '10.0.0.1' },
26
+ })).toBe('10.0.0.1')
27
+ })
28
+
29
+ it('normalizes IPv6-mapped IPv4 and returns null when empty', () => {
30
+ expect(resolveClientIp({ ip: '::ffff:8.8.8.8' })).toBe('8.8.8.8')
31
+ expect(resolveClientIp({ headers: {} })).toBeNull()
32
+ expect(resolveClientIp(null)).toBeNull()
33
+ })
34
+ })
@@ -1,6 +1,7 @@
1
1
  import { nanoid } from 'nanoid'
2
2
  import { EventStore, getResourceStream } from './event-store.js'
3
3
  import { Mongo, withMongoReconnect } from './mongodb.js'
4
+ import { stampAppendedEvent } from './stamp-resource-event.js'
4
5
  import { createLogger } from '@ossy/observability'
5
6
 
6
7
  const log = createLogger('event-store')
@@ -39,14 +40,11 @@ export class SchemaStream {
39
40
  return stream => {
40
41
  const version = stream.version + 1
41
42
  const schemaId = event.type ?? stream.events[0]?.type ?? stream.AggregateRoot?.SchemaId
42
- const saved = {
43
- ...event,
44
- type: schemaId,
45
- id: nanoid(),
46
- created: Date.now(),
43
+ const saved = stampAppendedEvent(event, {
47
44
  resourceId: stream.id,
48
45
  version,
49
- }
46
+ type: schemaId,
47
+ })
50
48
  return EventStore.AppendResourceEvent(saved)
51
49
  .then(() => {
52
50
  stream.version = version
@@ -0,0 +1,35 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { getEventContextIp } from './event-context.js'
3
+
4
+ /**
5
+ * Platform fields on an appended eventstore row (ADR 0008 + 0016).
6
+ * Caller-supplied `ip` / `id` / `created` / `version` / `resourceId` are ignored.
7
+ *
8
+ * @param {object} event
9
+ * @param {{ resourceId: string, version: number, type?: string }} meta
10
+ * @returns {object}
11
+ */
12
+ export function stampAppendedEvent (event, { resourceId, version, type }) {
13
+ const {
14
+ ip: _ignoredIp,
15
+ id: _ignoredId,
16
+ created: _ignoredCreated,
17
+ resourceId: _ignoredResourceId,
18
+ version: _ignoredVersion,
19
+ ...rest
20
+ } = event ?? {}
21
+
22
+ const saved = {
23
+ ...rest,
24
+ type: type ?? rest.type,
25
+ id: nanoid(),
26
+ created: Date.now(),
27
+ resourceId,
28
+ version,
29
+ }
30
+
31
+ const ip = getEventContextIp()
32
+ if (ip) saved.ip = ip
33
+
34
+ return saved
35
+ }
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it, jest } from '@jest/globals'
2
+ import { runWithEventContext } from './event-context.js'
3
+ import { stampAppendedEvent } from './stamp-resource-event.js'
4
+
5
+ describe('stampAppendedEvent', () => {
6
+ afterEach(() => {
7
+ jest.useRealTimers()
8
+ })
9
+
10
+ it('stamps platform fields and omits ip without request context', () => {
11
+ jest.useFakeTimers({ now: 1_700_000_000_000 })
12
+ const saved = stampAppendedEvent({
13
+ event: 'Created',
14
+ createdBy: 'user-1',
15
+ ip: '1.2.3.4',
16
+ payload: { name: 'Doc' },
17
+ }, { resourceId: 'res-1', version: 1, type: '@ossy/example/schema/doc' })
18
+
19
+ expect(saved).toMatchObject({
20
+ event: 'Created',
21
+ createdBy: 'user-1',
22
+ type: '@ossy/example/schema/doc',
23
+ resourceId: 'res-1',
24
+ version: 1,
25
+ created: 1_700_000_000_000,
26
+ payload: { name: 'Doc' },
27
+ })
28
+ expect(saved.id).toEqual(expect.any(String))
29
+ expect(saved).not.toHaveProperty('ip')
30
+ })
31
+
32
+ it('stamps trusted context ip and ignores caller ip', () => {
33
+ const saved = runWithEventContext({ ip: '203.0.113.10' }, () =>
34
+ stampAppendedEvent({
35
+ event: 'Patched',
36
+ createdBy: 'user-1',
37
+ ip: '1.2.3.4',
38
+ payload: { name: 'Doc' },
39
+ }, { resourceId: 'res-1', version: 2, type: '@ossy/example/schema/doc' }),
40
+ )
41
+
42
+ expect(saved.ip).toBe('203.0.113.10')
43
+ expect(saved.event).toBe('Patched')
44
+ })
45
+
46
+ it('stamps ip on Deleted', () => {
47
+ const saved = runWithEventContext({ ip: '198.51.100.9' }, () =>
48
+ stampAppendedEvent({
49
+ event: 'Deleted',
50
+ createdBy: 'user-1',
51
+ payload: {},
52
+ }, { resourceId: 'res-1', version: 3, type: '@ossy/example/schema/doc' }),
53
+ )
54
+
55
+ expect(saved.ip).toBe('198.51.100.9')
56
+ expect(saved.event).toBe('Deleted')
57
+ })
58
+ })