diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 08705aea..dc4e41a5 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased - Patch] +### Added + +- `observerAllowsEvent` and the `ObserverToken` type are exported from the package entry, so a realtime adapter hosted outside the engine can apply the same per-socket observer filter the Node adapter uses. + ### Fixed - The realtime `message.created` event includes the message `metadata` that `GET /v1/channels/:name/messages` already returns. diff --git a/packages/engine/src/__tests__/observerEventExport.test.ts b/packages/engine/src/__tests__/observerEventExport.test.ts new file mode 100644 index 00000000..2e4f729d --- /dev/null +++ b/packages/engine/src/__tests__/observerEventExport.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { observerAllowsEvent, type ObserverToken } from '../index.js'; + +// The Cloudflare workspace stream fans events out outside this package and +// imports this filter from the public entry; pin both the export and the +// behaviour it relies on for a channel-scoped token. +const scoped = { + scopes: ['stream:read', 'messages:read', 'dms:read', 'channels:read'], + filters: { channel_names: ['wf-run'], include_dms: false }, +} as Pick; + +describe('observerAllowsEvent (public export)', () => { + it('passes the scoped channel and withholds DMs and other channels', () => { + expect(observerAllowsEvent(scoped, { type: 'message.created', channel: 'wf-run', message: { agent_id: 'a' } })).toBe(true); + expect(observerAllowsEvent(scoped, { type: 'message.created', channel: 'general', message: { agent_id: 'a' } })).toBe(false); + expect(observerAllowsEvent(scoped, { type: 'dm.received', conversation_id: 'dm_1' })).toBe(false); + expect(observerAllowsEvent(scoped, { type: 'group_dm.received' })).toBe(false); + }); + + it('lets an unscoped principal (workspace key) see everything', () => { + expect(observerAllowsEvent(undefined, { type: 'dm.received', conversation_id: 'dm_1' })).toBe(true); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5e0febdd..c2d40f4a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -34,6 +34,11 @@ export type { TelemetryEvent, } from './ports/index.js'; +// The one filter that decides what a scoped observer token may see on the +// workspace stream. A realtime adapter that fans events out to observer sockets +// must apply it per socket — the Cloudflare stream lives outside this package. +export { observerAllowsEvent, type ObserverToken } from './engine/observerToken.js'; + // OSS default providers (self-host). Cloud injects its own. export { SqliteApiKeyAuthProvider, hashToken } from './auth/index.js'; export { StaticEntitlementsProvider, PLAN_LIMITS } from './providers/static-entitlements.js';