('realtime');
-```
-
-### Broadcasting
-
-```typescript
-// Broadcast to all connected clients
-await realtime.broadcast({
- event: 'notification',
- data: { message: 'System update in 5 minutes' },
-});
-
-// Broadcast to specific room
-await realtime.broadcastToRoom('opportunity:123', {
- event: 'record_updated',
- data: { recordId: '123', field: 'stage', value: 'closed_won' },
-});
-
-// Broadcast to specific user
-await realtime.broadcastToUser('user:456', {
- event: 'mention',
- data: { commentId: 'comment:789', mentionedBy: 'user:123' },
-});
-```
-
-### Channel Management
-
-```typescript
-// Join a channel (room)
-await realtime.join(connectionId, 'opportunity:123');
-
-// Leave a channel
-await realtime.leave(connectionId, 'opportunity:123');
-
-// Get all connections in a channel
-const connections = await realtime.getChannelConnections('opportunity:123');
-
-// Get all channels for a connection
-const channels = await realtime.getConnectionChannels(connectionId);
-```
-
-### Presence
-
-```typescript
-// Set user presence
-await realtime.setPresence('user:456', {
- status: 'online',
- currentPage: '/opportunity/123',
- lastActive: new Date(),
-});
-
-// Get user presence
-const presence = await realtime.getPresence('user:456');
-
-// Get all online users
-const onlineUsers = await realtime.getOnlineUsers();
-
-// Get users in a specific channel
-const channelUsers = await realtime.getChannelPresence('opportunity:123');
-```
-
-## Client-Side Usage
-
-### React Hook
-
-```typescript
-import { useRealtime } from '@objectstack/client-react';
-
-function OpportunityDetails({ id }: { id: string }) {
- const { subscribe, send, isConnected } = useRealtime();
-
- useEffect(() => {
- // Subscribe to record updates
- const unsubscribe = subscribe(`opportunity:${id}`, (event) => {
- if (event.type === 'record_updated') {
- console.log('Record updated:', event.data);
- // Update UI
- }
- });
-
- return unsubscribe;
- }, [id]);
-
- return (
-
- {isConnected ? '🟢 Connected' : '🔴 Disconnected'}
-
- );
-}
-```
-
-### JavaScript Client
-
-```typescript
-import { RealtimeClient } from '@objectstack/client';
-
-const client = new RealtimeClient({
- url: 'ws://localhost:3001/ws',
- auth: {
- token: 'your-auth-token',
- },
-});
+**v1 deployment contract (launch-readiness P0-5): single-instance only.** The adapter is
+process-local — events published on node A are not delivered to subscribers on node B.
+An HA adapter (Redis-backed, over `service-cluster-redis`) is a post-GA fast-follow.
-// Connect
-await client.connect();
-
-// Subscribe to a channel
-client.subscribe('opportunity:123', (event) => {
- console.log('Received event:', event);
-});
-
-// Send a message
-client.send('typing', {
- recordId: '123',
- userId: 'user:456',
- isTyping: true,
-});
-
-// Disconnect
-await client.disconnect();
-```
-
-## Advanced Features
-
-### Event Streaming
-
-Stream database changes in real-time:
+## Usage (server-side, trusted code only)
```typescript
-// Server-side: Stream record changes
-realtime.streamRecordChanges('opportunity', {
- onInsert: async (record) => {
- await realtime.broadcast({
- event: 'record_created',
- data: { object: 'opportunity', record },
- });
- },
- onUpdate: async (record, changes) => {
- await realtime.broadcastToRoom(`opportunity:${record.id}`, {
- event: 'record_updated',
- data: { recordId: record.id, changes },
- });
- },
- onDelete: async (recordId) => {
- await realtime.broadcast({
- event: 'record_deleted',
- data: { object: 'opportunity', recordId },
- });
- },
-});
-```
-
-### Private Channels
-
-```typescript
-// Server-side: Authorize private channel access
-realtime.authorizeChannel = async (userId, channel) => {
- if (channel.startsWith('user:')) {
- // Only allow users to join their own private channel
- return channel === `user:${userId}`;
- }
-
- if (channel.startsWith('opportunity:')) {
- // Check if user has access to the opportunity
- const opportunityId = channel.split(':')[1];
- return await hasAccess(userId, 'opportunity', opportunityId);
- }
+import { ObjectKernel } from '@objectstack/core';
+import { RealtimeServicePlugin } from '@objectstack/service-realtime';
- return false;
-};
-```
+const kernel = new ObjectKernel();
+kernel.use(new RealtimeServicePlugin());
+await kernel.bootstrap();
-### Typing Indicators
+const realtime = kernel.getService('realtime');
-```typescript
-// Client sends typing event
-client.send('typing', {
- recordId: '123',
- userId: 'user:456',
- isTyping: true,
-});
+const subId = await realtime.subscribe('records', (event) => {
+ console.log(event.type, event.payload);
+}, { object: 'account', eventTypes: ['record.created'] });
-// Server broadcasts to room
-realtime.on('typing', async (connectionId, data) => {
- await realtime.broadcastToRoom(`opportunity:${data.recordId}`, {
- event: 'user_typing',
- data: { userId: data.userId, isTyping: data.isTyping },
- }, { exclude: [connectionId] }); // Don't send back to sender
+await realtime.publish({
+ type: 'record.created',
+ object: 'account',
+ payload: { id: 'acc-1', name: 'Acme' },
+ timestamp: new Date().toISOString(),
});
-// Other clients receive typing notification
-client.subscribe('opportunity:123', (event) => {
- if (event.type === 'user_typing') {
- showTypingIndicator(event.data.userId, event.data.isTyping);
- }
-});
+await realtime.unsubscribe(subId);
```
-### Live Cursor Tracking
+Configuration:
```typescript
-// Client sends cursor position
-client.send('cursor', {
- recordId: '123',
- x: 450,
- y: 200,
-});
-
-// Server broadcasts to room
-realtime.on('cursor', async (connectionId, data) => {
- const user = await getConnectionUser(connectionId);
-
- await realtime.broadcastToRoom(`opportunity:${data.recordId}`, {
- event: 'cursor_moved',
- data: {
- userId: user.id,
- userName: user.name,
- x: data.x,
- y: data.y,
- },
- }, { exclude: [connectionId] });
+new RealtimeServicePlugin({
+ adapter: 'memory', // only supported adapter today
+ memory: { maxSubscriptions: 50_000 }, // 0 = unbounded (tests only)
});
```
-### Collaborative Editing
+## Security posture (#2992 / ADR-0096 D4)
-```typescript
-// Operational Transform (OT) for collaborative editing
-client.send('edit', {
- documentId: '123',
- operation: {
- type: 'insert',
- position: 42,
- text: 'Hello',
- },
-});
-
-realtime.on('edit', async (connectionId, data) => {
- // Apply operation transform
- const transformedOp = await applyOT(data.operation);
+Delivery is a **pure fan-out with no per-recipient authorization seam**:
- // Broadcast to all editors
- await realtime.broadcastToRoom(`document:${data.documentId}`, {
- event: 'operation',
- data: transformedOp,
- }, { exclude: [connectionId] });
-});
-```
+- subscriptions carry **no principal** — there is nothing to check a row against;
+- `matchesSubscription` filters only by object name + event type;
+- the ObjectQL engine publishes `record.created` / `record.updated` events with the
+ **full record body** (the `after` row) — rows and fields a subscriber's own `find`
+ would hide under RLS/FLS/tenant scoping.
-## Integration with ObjectStack Features
+That is safe **only while every subscriber is trusted server-internal code**. Before any
+end-user transport ships (WebSocket `handleUpgrade`, SSE, a REST subscribe route, or a
+real client transport), the delivery path MUST gain one of:
-### Feed Updates
+1. **a per-recipient re-check on delivery** — the subscription carries the subscriber's
+ `ExecutionContext`, and every event is re-authorized (RLS/FLS/tenant) against it
+ before the handler fires; **or**
+2. **id-only payloads** — the client re-fetches the record under its own authority.
-```typescript
-// When a comment is added
-feed.on('comment_added', async (comment) => {
- await realtime.broadcastToRoom(`${comment.object}:${comment.recordId}`, {
- event: 'feed_update',
- data: { type: 'comment', comment },
- });
-});
-```
-
-### Workflow Status
-
-```typescript
-// When a flow step completes
-automation.on('step_completed', async (execution) => {
- await realtime.broadcastToUser(execution.userId, {
- event: 'flow_progress',
- data: {
- flowId: execution.flowId,
- step: execution.currentStep,
- progress: execution.progress,
- },
- });
-});
-```
-
-### Analytics Dashboard
-
-```typescript
-// Stream real-time metrics
-setInterval(async () => {
- const metrics = await analytics.getCurrentMetrics();
-
- await realtime.broadcastToRoom('dashboard:sales', {
- event: 'metrics_update',
- data: metrics,
- });
-}, 5000); // Every 5 seconds
-```
-
-## Connection Events
-
-```typescript
-// Server-side event handlers
-realtime.on('connection', async (connectionId, userId) => {
- console.log(`User ${userId} connected (${connectionId})`);
-
- // Set initial presence
- await realtime.setPresence(userId, { status: 'online' });
-});
-
-realtime.on('disconnection', async (connectionId, userId) => {
- console.log(`User ${userId} disconnected`);
-
- // Update presence
- await realtime.setPresence(userId, {
- status: 'offline',
- lastSeen: new Date(),
- });
-});
-
-realtime.on('error', async (connectionId, error) => {
- console.error(`Connection error:`, error);
-});
-```
-
-## Best Practices
-
-1. **Channel Organization**: Use namespaced channels (e.g., `object:recordId`)
-2. **Authorization**: Always verify channel access before joining
-3. **Message Size**: Keep messages small (< 10KB)
-4. **Rate Limiting**: Limit message frequency per connection
-5. **Cleanup**: Remove disconnected users from channels
-6. **Heartbeat**: Implement ping/pong for connection health
-7. **Compression**: Enable WebSocket compression for large messages
-
-## Performance Considerations
-
-- **Scaling**: Use Redis adapter for multi-server deployments
-- **Connection Pooling**: Limit concurrent connections per user
-- **Channel Limits**: Limit channels per connection
-- **Message Batching**: Batch frequent updates to reduce traffic
-- **Binary Protocol**: Use binary for large data transfers
-
-## REST API Endpoints
-
-```
-POST /api/v1/realtime/broadcast # Broadcast to all
-POST /api/v1/realtime/broadcast/room/:room # Broadcast to room
-POST /api/v1/realtime/broadcast/user/:userId # Broadcast to user
-GET /api/v1/realtime/presence # Get online users
-GET /api/v1/realtime/presence/:userId # Get user presence
-GET /api/v1/realtime/channels/:channel # Get channel connections
-```
+This posture is registered in the authz conformance matrix
+(`packages/dogfood/test/authz-conformance.matrix.ts`, row `realtime-delivery-authz`),
+and transport **tripwire probes** in `authz-conformance.test.ts` fail CI if a transport
+is wired without upgrading that row with a real enforcement site.
-## Contract Implementation
+## Contract
Implements `IRealtimeService` from `@objectstack/spec/contracts`:
```typescript
interface IRealtimeService {
- broadcast(message: Message): Promise;
- broadcastToRoom(room: string, message: Message): Promise;
- broadcastToUser(userId: string, message: Message): Promise;
- join(connectionId: string, channel: string): Promise;
- leave(connectionId: string, channel: string): Promise;
- setPresence(userId: string, presence: PresenceData): Promise;
- getPresence(userId: string): Promise;
- getOnlineUsers(): Promise;
- on(event: string, handler: EventHandler): void;
+ publish(event: RealtimeEventPayload): Promise;
+ subscribe(channel: string, handler: RealtimeEventHandler, options?: RealtimeSubscriptionOptions): Promise;
+ unsubscribe(subscriptionId: string): Promise;
+ handleUpgrade?(request: Request): Promise; // deliberately unimplemented — see above
+ subscribeMetadata?(filter, handler): Promise; // optional convenience — not implemented here
+ subscribeData?(filter, handler): Promise; // optional convenience — not implemented here
}
```
@@ -432,7 +108,6 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
## See Also
-- [WebSocket API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
-- [@objectstack/client](../../client/)
-- [@objectstack/client-react](../../client-react/)
-- [Realtime Features Guide](/content/docs/protocol/kernel/realtime-protocol.mdx)
+- [@objectstack/spec — realtime contract](../../spec/src/contracts/realtime-service.ts)
+- framework#2992 — the identity-admission tracking issue for this surface
+- [ADR-0096 — Execution-Surface Identity Admission](../../../docs/adr/0096-execution-surface-identity-admission.md)
diff --git a/packages/services/service-realtime/src/in-memory-realtime-adapter.ts b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts
index 8ad6f00409..02c22cf22c 100644
--- a/packages/services/service-realtime/src/in-memory-realtime-adapter.ts
+++ b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts
@@ -76,7 +76,15 @@ export class InMemoryRealtimeAdapter implements IRealtimeService {
}
async publish(event: RealtimeEventPayload): Promise {
- // Deliver to all channel subscriptions that match filters
+ // Deliver to all channel subscriptions that match filters.
+ //
+ // ⚠️ TRUSTED fan-out (#2992 / ADR-0096 D4): there is NO per-recipient
+ // authorization here — `matchesSubscription` filters by object/event type
+ // only, `Subscription` carries no principal, and the engine publishes the
+ // full record body. Safe ONLY while every subscriber is server-internal.
+ // A client transport must not be wired to this path until delivery
+ // re-checks each recipient's authority (or payloads become id-only); the
+ // authz conformance matrix (`realtime-delivery-authz`) pins this.
for (const sub of this.subscriptions.values()) {
if (this.matchesSubscription(event, sub)) {
try {
diff --git a/packages/spec/src/contracts/graphql-service.ts b/packages/spec/src/contracts/graphql-service.ts
index 0306c30bde..1cb8e1dcd5 100644
--- a/packages/spec/src/contracts/graphql-service.ts
+++ b/packages/spec/src/contracts/graphql-service.ts
@@ -43,8 +43,18 @@ export interface GraphQLResponse {
export interface IGraphQLService {
/**
* Execute a GraphQL query or mutation
+ *
+ * ⚠️ Identity admission (ADR-0096 D1, #2992): `context` carries the
+ * caller's resolved `ExecutionContext`. An implementation that resolves
+ * objects through the data engine (ObjectQL) MUST forward it on every
+ * engine call as `options.context` — the security middleware falls OPEN
+ * on a missing principal, so executing resolvers context-less silently
+ * grants full authority (no RLS/FLS/CRUD/tenant scoping). The dispatcher's
+ * `/graphql` entry point threads the caller identity for exactly this
+ * purpose; dropping it here is a defect, never an authorization.
+ *
* @param request - The GraphQL request
- * @param context - Optional execution context (e.g. auth user)
+ * @param context - The caller's execution context (auth user / principal)
* @returns GraphQL response with data and/or errors
*/
execute(request: GraphQLRequest, context?: Record): Promise;
diff --git a/packages/spec/src/contracts/realtime-service.ts b/packages/spec/src/contracts/realtime-service.ts
index 3705eb8efd..ea8032ade4 100644
--- a/packages/spec/src/contracts/realtime-service.ts
+++ b/packages/spec/src/contracts/realtime-service.ts
@@ -40,7 +40,14 @@ export interface RealtimeSubscriptionOptions {
object?: string;
/** Event types to listen for */
eventTypes?: string[];
- /** Additional filter conditions */
+ /**
+ * Additional filter conditions.
+ *
+ * ⚠️ EXPERIMENTAL — declared but NOT evaluated by the in-memory adapter
+ * (`matchesSubscription` reads only `object` + `eventTypes`). Do not rely
+ * on it to narrow delivery, and NEVER as an authorization mechanism
+ * (see the identity-admission note on {@link IRealtimeService}).
+ */
filter?: Record;
}
@@ -60,6 +67,31 @@ export interface RealtimeSubscriptionFilter {
fields?: string[];
}
+/**
+ * ⚠️ Identity admission — READ BEFORE WIRING A CLIENT TRANSPORT (#2992,
+ * ADR-0096 D4).
+ *
+ * This contract currently serves TRUSTED, SERVER-INTERNAL subscribers only
+ * (webhook auto-enqueuer, knowledge sync). Delivery is a pure fan-out with
+ * **no per-recipient authorization seam**: subscriptions carry no principal,
+ * `matchesSubscription` filters only by object name + event type, and the
+ * engine publishes the FULL record body (`after` row) — rows and fields a
+ * subscriber's own `find` would hide under RLS/FLS/tenant scoping.
+ *
+ * Before ANY end-user transport ships (`handleUpgrade` WebSocket, SSE, a REST
+ * subscribe route, or a real client in `@objectstack/client`), the delivery
+ * path MUST gain one of:
+ * 1. a per-recipient re-check on delivery — the subscription carries the
+ * subscriber's `ExecutionContext` and every event is re-authorized
+ * (RLS/FLS/tenant) against it before the handler fires; or
+ * 2. id-only payloads — the client re-fetches the record under its own
+ * authority.
+ *
+ * Wiring a transport without this is a fall-open (full-authority broadcast,
+ * cross-tenant). The authz conformance matrix pins this posture
+ * (`realtime-delivery-authz` row + transport tripwire probes in
+ * `dogfood/test/authz-conformance.test.ts`) so CI blocks it, not review.
+ */
export interface IRealtimeService {
/**
* Publish an event to all subscribers
@@ -84,6 +116,11 @@ export interface IRealtimeService {
/**
* Handle an incoming HTTP upgrade request (WebSocket handshake)
+ *
+ * ⚠️ Deliberately UNIMPLEMENTED platform-wide: implementing this hands
+ * external clients the unauthorized fan-out described on the interface —
+ * satisfy the identity-admission requirement above first (#2992).
+ *
* @param request - Standard Request object
* @returns Standard Response object
*/