@ossy/event-store 3.11.1 → 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 +2 -1
- package/package.json +3 -3
- package/src/event-context.js +27 -0
- package/src/index.js +3 -0
- package/src/projection-rebuild.js +4 -2
- package/src/resolve-client-ip.js +40 -0
- package/src/resolve-client-ip.spec.js +34 -0
- package/src/schema-stream.js +4 -6
- package/src/stamp-resource-event.js +35 -0
- package/src/stamp-resource-event.spec.js +58 -0
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 `
|
|
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
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/event-store",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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": "
|
|
40
|
+
"gitHead": "c2650803219ad1831559b512848358d9550ffad2"
|
|
41
41
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/schema-stream.js
CHANGED
|
@@ -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
|
+
})
|