From b32d86adc2bb77bc94ecb7327769c00254e08917 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 08:24:29 -0400 Subject: [PATCH 01/15] Add scoped observer tokens --- README.md | 17 +- openapi.yaml | 282 +++++++++++- .../src/__tests__/conformance/node.test.ts | 74 ++++ .../conformance/nodeDeliveryContracts.test.ts | 81 ++++ .../conformance/observerToken.test.ts | 229 ++++++++++ .../src/__tests__/nodeUpgradeAuth.test.ts | 13 +- packages/engine/src/adapters/node/realtime.ts | 21 +- packages/engine/src/auth/index.ts | 21 + .../db/migrations/0026_observer_tokens.sql | 32 ++ packages/engine/src/db/schema.ts | 42 ++ packages/engine/src/engine.ts | 3 + packages/engine/src/engine/deliveryWrites.ts | 4 +- packages/engine/src/engine/observerToken.ts | 401 ++++++++++++++++++ packages/engine/src/engine/search.ts | 6 +- packages/engine/src/engine/thread.ts | 5 + packages/engine/src/engine/wsAuth.ts | 24 +- packages/engine/src/engine/wsTransform.ts | 9 + packages/engine/src/entrypoints/node.ts | 6 +- packages/engine/src/env.ts | 3 +- packages/engine/src/middleware/auth.ts | 75 ++++ packages/engine/src/ports/auth.ts | 7 +- packages/engine/src/ports/realtime.ts | 3 + packages/engine/src/routes/agent.ts | 22 +- packages/engine/src/routes/channel.ts | 31 +- packages/engine/src/routes/console.ts | 26 +- packages/engine/src/routes/deliveryRouting.ts | 2 + packages/engine/src/routes/file.ts | 6 +- packages/engine/src/routes/message.ts | 33 +- packages/engine/src/routes/node.ts | 8 +- packages/engine/src/routes/observerToken.ts | 122 ++++++ packages/engine/src/routes/presence.ts | 12 +- packages/engine/src/routes/reaction.ts | 23 +- packages/engine/src/routes/search.ts | 18 +- packages/engine/src/routes/thread.ts | 23 +- packages/engine/src/routes/workspace.ts | 19 +- packages/sdk-python/src/relay_sdk/models.py | 59 +++ packages/sdk-python/src/relay_sdk/relay.py | 65 +++ packages/sdk-python/src/relay_sdk/ws.py | 2 +- packages/sdk-python/tests/test_relay.py | 62 +++ packages/sdk-python/tests/test_ws.py | 10 +- packages/sdk-rust/src/lib.rs | 2 +- packages/sdk-rust/src/relay.rs | 65 +++ packages/sdk-rust/src/types.rs | 65 +++ .../sdk-swift/Sources/Relaycast/Models.swift | 87 ++++ .../Sources/Relaycast/RelayCast.swift | 33 ++ .../src/__tests__/relay.test.ts | 82 +++- packages/sdk-typescript/src/relay.ts | 22 + packages/sdk-typescript/src/types.ts | 56 +++ 48 files changed, 2221 insertions(+), 92 deletions(-) create mode 100644 packages/engine/src/__tests__/conformance/observerToken.test.ts create mode 100644 packages/engine/src/db/migrations/0026_observer_tokens.sql create mode 100644 packages/engine/src/engine/observerToken.ts create mode 100644 packages/engine/src/routes/observerToken.ts diff --git a/README.md b/README.md index e757219d..a5341e8b 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ omitted. - Workspace key (`rk_live_*`): admin token for managing workspace resources - Agent token (`at_live_*`): REST identity token an individual agent uses to participate - Node token (`nt_live_*`): realtime transport token for direct or broker nodes on `/v1/node/ws` +- Observer token (`ot_live_*`): scoped read-only token for workspace realtime and read-only REST - Identity types: `agent` (AI worker), `human` (person), `system` (automation/service actor) - Message payloads and realtime message events include optional `agent_type` so clients can distinguish agent, human, and system senders without extra identity lookups. - Channel: shared room for team/agent communication @@ -156,15 +157,16 @@ me.subscribe(['general', '@self'], (event) => { await me.send('#general', 'Hello from Relaycast'); -// Workspace-key clients observe the workspace stream directly on /v1/ws. -relay.connect(); -relay.on.messageCreated((event) => { +// Workspace observers use an ot_live_* token with stream:read. +const observer = new RelayCast({ apiKey: 'ot_live_...' }); +observer.connect(); +observer.on.messageCreated((event) => { console.log(`[workspace] ${event.channel}: ${event.message.text}`); }); -relay.on.actionCompleted((event) => { +observer.on.actionCompleted((event) => { console.log(`[workspace] ${event.actionName} ${event.status}`); }); -relay.on.any((event) => { +observer.on.any((event) => { console.log(`[workspace] ${event.type}`); }); @@ -336,13 +338,14 @@ origin (e.g. `http://localhost:8787/v1`). Authentication header: -- `Authorization: Bearer ` +- `Authorization: Bearer ` Realtime transport: -- `/v1/ws` is the workspace observer stream and requires a workspace key. +- `/v1/ws` is the workspace observer stream and requires an observer token with `stream:read`. - `/v1/node/ws` is the node control/delivery stream and requires a node token. - Agent SDKs use `at_live_*` for REST, mint a direct `nt_live_*` token, and receive realtime events as node `deliver` frames. +- Workspace keys create, rotate, list, and revoke observer tokens at `/v1/observer-tokens`; observer tokens are read-only and cannot mutate workspace state. Core endpoints: diff --git a/openapi.yaml b/openapi.yaml index a4080968..cd551159 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,7 +4,7 @@ info: description: | REST API for Relaycast - headless Slack for AI agents. A hosted messaging store + router with fast retrieval. - All routes are prefixed with `/v1/`. Authentication is via workspace key (`rk_live_*`), agent token (`at_live_*`), or node token (`nt_live_*`) + All routes are prefixed with `/v1/`. Authentication is via workspace key (`rk_live_*`), agent token (`at_live_*`), node token (`nt_live_*`), or observer token (`ot_live_*`) passed in the `Authorization: Bearer` header. Response format: @@ -41,6 +41,10 @@ components: type: http scheme: bearer description: Node token (nt_live_*) + observerToken: + type: http + scheme: bearer + description: Scoped observer token (ot_live_*) webhookToken: type: http scheme: bearer @@ -746,6 +750,87 @@ components: format: date-time nullable: true + ObserverToken: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + scopes: + type: array + items: + type: string + enum: + - stream:read + - messages:read + - threads:read + - dms:read + - channels:read + - search:read + - agents:read + - nodes:read + - deliveries:read + - activity:read + - files:read + - reactions:read + filters: + type: object + properties: + channel_ids: + type: array + items: + type: string + channel_names: + type: array + items: + type: string + include_dms: + type: boolean + dm_conversation_ids: + type: array + items: + type: string + agent_ids: + type: array + items: + type: string + event_types: + type: array + items: + type: string + created_after: + type: string + format: date-time + status: + type: string + enum: [active, revoked] + expires_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + revoked_at: + type: string + format: date-time + nullable: true + last_used_at: + type: string + format: date-time + nullable: true + token: + type: string + description: Raw ot_live_* token, returned only on create and rotate. + Error: type: object properties: @@ -948,6 +1033,195 @@ paths: '204': description: Workspace deleted + /observer-tokens: + post: + summary: Create observer token + description: Create a scoped read-only observer token. The raw `ot_live_*` token is returned only in this response. + tags: + - Workspaces + security: + - workspaceKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - scopes + properties: + name: + type: string + description: + type: string + nullable: true + scopes: + type: array + minItems: 1 + items: + type: string + filters: + type: object + expires_at: + type: string + format: date-time + nullable: true + responses: + '201': + description: Observer token created + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ObserverToken' + get: + summary: List observer tokens + description: List observer token metadata. Raw token material is not returned. + tags: + - Workspaces + security: + - workspaceKey: [] + responses: + '200': + description: Observer token list + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/ObserverToken' + + /observer-tokens/{id}: + get: + summary: Get observer token + tags: + - Workspaces + security: + - workspaceKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Observer token metadata + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ObserverToken' + '404': + description: Observer token not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + summary: Update observer token + tags: + - Workspaces + security: + - workspaceKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + nullable: true + scopes: + type: array + minItems: 1 + items: + type: string + filters: + type: object + expires_at: + type: string + format: date-time + nullable: true + responses: + '200': + description: Observer token updated + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ObserverToken' + delete: + summary: Revoke observer token + tags: + - Workspaces + security: + - workspaceKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '204': + description: Observer token revoked + + /observer-tokens/{id}/rotate: + post: + summary: Rotate observer token + description: Rotate token material for an existing observer token. The new raw `ot_live_*` token is returned only in this response. + tags: + - Workspaces + security: + - workspaceKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Observer token rotated + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ObserverToken' + /agent: get: summary: Resolve authenticated agent @@ -3727,16 +4001,18 @@ paths: /ws: get: summary: Workspace observer WebSocket stream - description: Upgrade to the workspace observer realtime stream via token query parameter. Agent tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. + description: Upgrade to the workspace observer realtime stream with an `ot_live_*` token that has `stream:read`. Workspace, agent, and node tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. tags: - System + security: + - observerToken: [] parameters: - name: token in: query required: true schema: type: string - description: Workspace key (`rk_live_*`) + description: Observer token (`ot_live_*`) with `stream:read` responses: '101': description: Switching Protocols (WebSocket) diff --git a/packages/engine/src/__tests__/conformance/node.test.ts b/packages/engine/src/__tests__/conformance/node.test.ts index 1b2c2b90..7f08a8a3 100644 --- a/packages/engine/src/__tests__/conformance/node.test.ts +++ b/packages/engine/src/__tests__/conformance/node.test.ts @@ -372,6 +372,80 @@ describe('node adapter conformance', () => { ]); }); + it('creates a mention delivery for muted members mentioned in thread replies', async () => { + const ws = await createWorkspace(stack.app, 'muted-thread-mention-delivery-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); + + const createRes = await stack.app.request('/v1/channels', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ name: 'thread-mentions-chat' }), + }); + expect(createRes.status).toBeLessThan(300); + for (const token of [alice.token, bob.token]) { + const joinRes = await stack.app.request('/v1/channels/thread-mentions-chat/join', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + expect(joinRes.status).toBeLessThan(300); + } + + const muteRes = await stack.app.request('/v1/channels/thread-mentions-chat/mute', { + method: 'POST', + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect(muteRes.status).toBeLessThan(300); + + const parentRes = await stack.app.request('/v1/channels/thread-mentions-chat/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'parent message' }), + }); + expect(parentRes.status).toBeLessThan(300); + const parent = await parentRes.json() as { data: { id: string } }; + + const { sock: bobSock } = await attachDirectNodeSocket(stack, ws.workspaceId, bob); + bobSock.received.length = 0; + + const replyRes = await stack.app.request(`/v1/messages/${parent.data.id}/replies`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: '@bob please see this reply' }), + }); + expect(replyRes.status).toBeLessThan(300); + const reply = await replyRes.json() as { data: { id: string } }; + + const rows = await stack.runtime.deps.db + .select({ id: deliveries.id, reason: deliveries.reason }) + .from(deliveries) + .where(and( + eq(deliveries.workspaceId, ws.workspaceId), + eq(deliveries.messageId, reply.data.id), + eq(deliveries.agentId, bob.agentId), + )); + + expect(rows).toEqual([ + expect.objectContaining({ reason: 'mention' }), + ]); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(deliverFramesOfType(bobSock, 'thread.reply')).toEqual([ + expect.objectContaining({ + type: 'deliver', + msg_id: reply.data.id, + payload: expect.objectContaining({ + type: 'thread.reply', + data: expect.objectContaining({ + id: reply.data.id, + channel_name: 'thread-mentions-chat', + from_name: 'alice', + text: '@bob please see this reply', + }), + }), + }), + ]); + }); + it('replays queued direct-node deliveries when the agent socket reconnects', async () => { const ws = await createWorkspace(stack.app, 'direct-node-reconnect-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); diff --git a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts index d00948e6..662a833b 100644 --- a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts +++ b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts @@ -262,6 +262,87 @@ describe('node delivery contracts', () => { }); }); + it('keeps response-mode http_push deliveries queued when 2xx omits an ack signal', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ack: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const ws = await createWorkspace(stack.app, 'http-node-response-no-ack'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); + const node = await createHttpNode(ws.workspaceKey, { + name: 'http-node-response-no-ack', + ackMode: 'response', + }); + expect( + (await bindAgent(ws.workspaceKey, node.data.name, 'bob')).status, + ).toBe(201); + + const post = await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${alice.token}`, + }, + body: JSON.stringify({ text: 'missing response ack' }), + }); + expect(post.status).toBe(201); + + await waitForAssertion(async () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + const queued = await stack.app.request('/v1/deliveries', { + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect( + ((await queued.json()) as { + data: Array<{ + status: string; + dispatch_attempts: number; + last_dispatch_error: string | null; + next_attempt_at: string | null; + }>; + }).data, + ).toEqual([ + expect.objectContaining({ + status: 'queued', + dispatch_attempts: 1, + last_dispatch_error: 'response ack not signaled', + next_attempt_at: expect.any(String), + }), + ]); + }); + + const swept = await sweepDueHttpPushDeliveries(stack.runtime.deps, { + now: new Date(Date.now() + 31_000), + }); + expect(swept).toBe(1); + + await waitForAssertion(async () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + const acked = await stack.app.request('/v1/deliveries?status=acked', { + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect( + ((await acked.json()) as { data: Array<{ status: string; dispatch_attempts: number }> }).data, + ).toEqual([ + expect.objectContaining({ + status: 'acked', + dispatch_attempts: 2, + }), + ]); + }); + }); + it('defaults http_push nodes to one active agent binding', async () => { const ws = await createWorkspace(stack.app, 'http-node-capacity'); await registerAgent(stack.app, ws.workspaceKey, 'bob'); diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts new file mode 100644 index 00000000..d10b3650 --- /dev/null +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { + createWorkspace, + FakeSocket, + makeNodeStack, + registerAgent, + type TestStack, +} from './harness.js'; +import { observerTokens } from '../../db/schema.js'; +import { authenticateRealtimeWs } from '../../engine/wsAuth.js'; + +async function createObserverToken( + stack: TestStack, + workspaceKey: string, + body: Record, +) { + const res = await stack.app.request('/v1/observer-tokens', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${workspaceKey}` }, + body: JSON.stringify(body), + }); + const json = await res.json() as { data: { id: string; token?: string; scopes: string[]; filters: Record } }; + return { res, data: json.data }; +} + +describe('observer tokens', () => { + let stack: TestStack; + + beforeEach(() => { stack = makeNodeStack(); }); + afterEach(() => stack.close()); + + it('mints, lists, rotates, and revokes observer tokens without exposing token material after create/rotate', async () => { + const ws = await createWorkspace(stack.app, 'observer-lifecycle-ws'); + const created = await createObserverToken(stack, ws.workspaceKey, { + name: 'dashboard', + description: 'Read-only dashboard', + scopes: ['stream:read', 'channels:read'], + filters: { channel_names: ['general'] }, + }); + + expect(created.res.status).toBe(201); + expect(created.data.token).toMatch(/^ot_live_/); + expect(created.data.filters).toMatchObject({ include_dms: false, channel_names: ['general'] }); + + const list = await stack.app.request('/v1/observer-tokens', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const listed = await list.json() as { data: Array<{ id: string; token?: string }> }; + expect(list.status).toBe(200); + expect(listed.data).toHaveLength(1); + expect(listed.data[0].id).toBe(created.data.id); + expect(listed.data[0].token).toBeUndefined(); + + const rotate = await stack.app.request(`/v1/observer-tokens/${created.data.id}/rotate`, { + method: 'POST', + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const rotated = await rotate.json() as { data: { token?: string } }; + expect(rotate.status).toBe(200); + expect(rotated.data.token).toMatch(/^ot_live_/); + expect(rotated.data.token).not.toBe(created.data.token); + + const revoke = await stack.app.request(`/v1/observer-tokens/${created.data.id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(revoke.status).toBe(204); + }); + + it('gates REST reads by scope and channel filters and rejects observer writes', async () => { + const ws = await createWorkspace(stack.app, 'observer-rest-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + + const createTeam = await stack.app.request('/v1/channels', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ name: 'team-chat' }), + }); + expect(createTeam.status).toBe(201); + + const general = await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'visible general message' }), + }); + expect(general.status).toBe(201); + + const team = await stack.app.request('/v1/channels/team-chat/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'hidden team message' }), + }); + expect(team.status).toBe(201); + + const observer = await createObserverToken(stack, ws.workspaceKey, { + name: 'general-reader', + scopes: ['messages:read', 'channels:read', 'search:read'], + filters: { channel_names: ['general'] }, + }); + const token = observer.data.token!; + + const allowedMessages = await stack.app.request('/v1/channels/general/messages', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(allowedMessages.status).toBe(200); + const allowedBody = await allowedMessages.json() as { data: Array<{ text: string }> }; + expect(allowedBody.data.map((message) => message.text)).toContain('visible general message'); + + const deniedMessages = await stack.app.request('/v1/channels/team-chat/messages', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(deniedMessages.status).toBe(404); + + const search = await stack.app.request('/v1/search?q=message', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(search.status).toBe(200); + const searchBody = await search.json() as { data: Array<{ text: string; channel_name: string }> }; + expect(searchBody.data.map((result) => result.text)).toContain('visible general message'); + expect(searchBody.data.map((result) => result.text)).not.toContain('hidden team message'); + expect(searchBody.data.every((result) => result.channel_name === 'general')).toBe(true); + + const write = await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ text: 'must not write' }), + }); + expect(write.status).toBe(401); + }); + + it('requires the right read scopes and enforces DM opt-in filters', async () => { + const ws = await createWorkspace(stack.app, 'observer-dm-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + await registerAgent(stack.app, ws.workspaceKey, 'bob'); + + const dm = await stack.app.request('/v1/dm', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ to: 'bob', text: 'private note' }), + }); + expect(dm.status).toBe(201); + + const noDm = await createObserverToken(stack, ws.workspaceKey, { + name: 'no-dms', + scopes: ['dms:read'], + filters: { include_dms: false }, + }); + const hidden = await stack.app.request('/v1/dm/conversations/all', { + headers: { authorization: `Bearer ${noDm.data.token}` }, + }); + const hiddenBody = await hidden.json() as { data: unknown[] }; + expect(hidden.status).toBe(200); + expect(hiddenBody.data).toHaveLength(0); + + const withDm = await createObserverToken(stack, ws.workspaceKey, { + name: 'with-dms', + scopes: ['dms:read'], + filters: { include_dms: true }, + }); + const visible = await stack.app.request('/v1/dm/conversations/all', { + headers: { authorization: `Bearer ${withDm.data.token}` }, + }); + const visibleBody = await visible.json() as { data: Array<{ id: string }> }; + expect(visible.status).toBe(200); + expect(visibleBody.data).toHaveLength(1); + + const underScoped = await createObserverToken(stack, ws.workspaceKey, { + name: 'channels-only', + scopes: ['channels:read'], + }); + const denied = await stack.app.request('/v1/channels/general/messages', { + headers: { authorization: `Bearer ${underScoped.data.token}` }, + }); + expect(denied.status).toBe(403); + await expect(denied.json()).resolves.toMatchObject({ + ok: false, + error: { code: 'insufficient_scope' }, + }); + }); + + it('requires stream-scoped observer tokens for workspace WebSockets and filters events per socket', async () => { + const ws = await createWorkspace(stack.app, 'observer-stream-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + await stack.app.request('/v1/channels', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ name: 'team-chat' }), + }); + + const observer = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-general', + scopes: ['stream:read'], + filters: { channel_names: ['general'] }, + }); + const auth = { auth: stack.runtime.deps.auth, db: stack.runtime.deps.db }; + await expect(authenticateRealtimeWs(auth, ws.workspaceKey)).resolves.toMatchObject({ + ok: false, + message: 'Observer token required for workspace stream', + }); + const accepted = await authenticateRealtimeWs(auth, observer.data.token!); + expect(accepted.ok).toBe(true); + + const [row] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, observer.data.id)); + const sock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, sock, row); + + await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'stream visible' }), + }); + await stack.app.request('/v1/channels/team-chat/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'stream hidden' }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sock.ofType('message.created')).toHaveLength(1); + expect(sock.ofType('message.created')[0]).toMatchObject({ + channel: 'general', + message: { text: 'stream visible' }, + }); + }); +}); diff --git a/packages/engine/src/__tests__/nodeUpgradeAuth.test.ts b/packages/engine/src/__tests__/nodeUpgradeAuth.test.ts index fcba87e8..bf310fd3 100644 --- a/packages/engine/src/__tests__/nodeUpgradeAuth.test.ts +++ b/packages/engine/src/__tests__/nodeUpgradeAuth.test.ts @@ -98,7 +98,7 @@ describe('node control WS upgrade auth (self-host)', () => { expect(await tryUpgrade({}, `?token=${token}`, '/v1/ws')).toBe(401); }); - it('accepts workspace observer realtime upgrades with workspace keys', async () => { + it('requires observer tokens for workspace realtime upgrades', async () => { const ws = await api('/v1/workspaces', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -106,7 +106,16 @@ describe('node control WS upgrade auth (self-host)', () => { }); const workspaceKey = ws.body.data.api_key as string; - expect(await tryUpgrade({}, `?token=${workspaceKey}`, '/v1/ws')).toBe(101); + const observer = await api('/v1/observer-tokens', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${workspaceKey}` }, + body: JSON.stringify({ name: 'stream', scopes: ['stream:read'] }), + }); + expect(observer.status).toBe(201); + const observerToken = observer.body.data.token as string; + + expect(await tryUpgrade({}, `?token=${workspaceKey}`, '/v1/ws')).toBe(401); + expect(await tryUpgrade({}, `?token=${observerToken}`, '/v1/ws')).toBe(101); }); it('rejects a missing or malformed token (401)', async () => { diff --git a/packages/engine/src/adapters/node/realtime.ts b/packages/engine/src/adapters/node/realtime.ts index 34c4d964..a40e246c 100644 --- a/packages/engine/src/adapters/node/realtime.ts +++ b/packages/engine/src/adapters/node/realtime.ts @@ -7,6 +7,7 @@ import type { UpgradeArgs, NodeUpgradeArgs, } from '../../ports/realtime.js'; +import type { ObserverToken } from '../../ports/auth.js'; import { and, eq, sql } from 'drizzle-orm'; import type { EngineDb } from '../../ports/database.js'; import type { PresenceTracker } from '../../ports/presence.js'; @@ -16,6 +17,7 @@ import { directNodeIdForAgent, handleNodeControlMessage, markDirectNodeOfflineFo import { reserveNodeCapacity } from '../../engine/placement.js'; import { markDrainedInvocationDispatched } from '../../engine/action.js'; import { deliverPendingToNode } from '../../engine/delivery.js'; +import { observerAllowsEvent } from '../../engine/observerToken.js'; import type { InvocationCompletionDeps } from '../../engine/invocationCompletion.js'; import type { FleetRelaycastToBrokerMessage } from '@relaycast/types'; @@ -50,6 +52,11 @@ interface NodeConn { socket: EngineSocket; } +interface WorkspaceConn { + socket: EngineSocket; + observerToken?: ObserverToken; +} + interface QueuedNodeMessage { key: string; message: Extract; @@ -79,7 +86,7 @@ interface ChannelState { export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeConnectionRegistry { private readonly agents = new Map(); private readonly channels = new Map(); - private readonly workspaceSockets = new Map>(); + private readonly workspaceSockets = new Map>(); private readonly nodeSockets = new Map(); private readonly nodeQueues = new Map(); /** Serializes drains per node so concurrent triggers never overlap. */ @@ -193,9 +200,10 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC const set = this.workspaceSockets.get(args.workspaceId); if (!set || set.size === 0) return; const data = JSON.stringify(args.event); - for (const socket of set) { + for (const conn of set) { + if (!observerAllowsEvent(conn.observerToken, args.event)) continue; try { - socket.send(data); + conn.socket.send(data); } catch { // Socket may have closed between enumeration and send. } @@ -330,17 +338,18 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC } /** Register a live workspace-stream socket; returns handlers the transport drives. */ - attachWorkspaceSocket(workspaceId: string, socket: EngineSocket): SocketHandle { + attachWorkspaceSocket(workspaceId: string, socket: EngineSocket, observerToken?: ObserverToken): SocketHandle { let set = this.workspaceSockets.get(workspaceId); if (!set) { set = new Set(); this.workspaceSockets.set(workspaceId, set); } - set.add(socket); + const conn: WorkspaceConn = { socket, observerToken }; + set.add(conn); return { handleMessage: async (raw) => this.onWorkspaceMessage(socket, raw), handleClose: async () => { - set!.delete(socket); + set!.delete(conn); }, }; } diff --git a/packages/engine/src/auth/index.ts b/packages/engine/src/auth/index.ts index 068ff22d..70b567ad 100644 --- a/packages/engine/src/auth/index.ts +++ b/packages/engine/src/auth/index.ts @@ -1,6 +1,7 @@ import { eq } from 'drizzle-orm'; import { workspaces, agents, nodes } from '../db/schema.js'; import { sha256Hex } from '../lib/crypto.js'; +import { getActiveObserverTokenByHash } from '../engine/observerToken.js'; import type { AuthProvider, AuthResult, AuthRequire } from '../ports/auth.js'; import type { EngineDb } from '../ports/database.js'; @@ -37,6 +38,9 @@ export class SqliteApiKeyAuthProvider implements AuthProvider { if (require === 'node') { return unauthorized('Node token required (nt_live_...)'); } + if (require === 'observer') { + return unauthorized('Observer token required (ot_live_...)'); + } const [workspace] = await db.select().from(workspaces).where(eq(workspaces.apiKeyHash, hash)); if (!workspace) return unauthorized('Invalid API key'); return { ok: true, workspace }; @@ -49,6 +53,9 @@ export class SqliteApiKeyAuthProvider implements AuthProvider { if (require === 'node') { return unauthorized('Node token required (nt_live_...)'); } + if (require === 'observer') { + return unauthorized('Observer token required (ot_live_...)'); + } const [agent] = await db.select().from(agents).where(eq(agents.tokenHash, hash)); if (!agent) return unauthorized('Invalid agent token', 'agent_token_invalid'); const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, agent.workspaceId)); @@ -63,6 +70,9 @@ export class SqliteApiKeyAuthProvider implements AuthProvider { if (require === 'agent') { return unauthorized('Agent token required (at_live_...)'); } + if (require === 'observer') { + return unauthorized('Observer token required (ot_live_...)'); + } const [node] = await db.select().from(nodes).where(eq(nodes.tokenHash, hash)); if (!node) return unauthorized('Invalid node token', 'node_token_invalid'); const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, node.workspaceId)); @@ -70,6 +80,17 @@ export class SqliteApiKeyAuthProvider implements AuthProvider { return { ok: true, workspace, node }; } + if (token.startsWith('ot_live_')) { + if (require !== 'observer') { + return unauthorized('Observer token cannot perform this operation', 'observer_token_forbidden'); + } + const observerToken = await getActiveObserverTokenByHash(db, hash); + if (!observerToken) return unauthorized('Invalid observer token', 'observer_token_invalid'); + const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, observerToken.workspaceId)); + if (!workspace) return unauthorized('Workspace not found'); + return { ok: true, workspace, observerToken }; + } + return unauthorized('Invalid token format'); } } diff --git a/packages/engine/src/db/migrations/0026_observer_tokens.sql b/packages/engine/src/db/migrations/0026_observer_tokens.sql new file mode 100644 index 00000000..1bb1fd5b --- /dev/null +++ b/packages/engine/src/db/migrations/0026_observer_tokens.sql @@ -0,0 +1,32 @@ +-- Add scoped observer tokens for workspace realtime and read-only REST. + +CREATE TABLE observer_tokens ( + id TEXT PRIMARY KEY NOT NULL, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + token_hash TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '[]', + filters TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + expires_at INTEGER, + created_by TEXT, + created_by_type TEXT NOT NULL DEFAULT 'workspace', + last_used_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER, + revoked_at INTEGER, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX observer_tokens_workspace_name_unique + ON observer_tokens (workspace_id, name); + +CREATE UNIQUE INDEX observer_tokens_token_hash_unique + ON observer_tokens (token_hash); + +CREATE INDEX idx_observer_tokens_workspace_status + ON observer_tokens (workspace_id, status); + +CREATE INDEX idx_observer_tokens_expires_at + ON observer_tokens (expires_at); diff --git a/packages/engine/src/db/schema.ts b/packages/engine/src/db/schema.ts index 35e06e1f..595f6677 100644 --- a/packages/engine/src/db/schema.ts +++ b/packages/engine/src/db/schema.ts @@ -26,6 +26,16 @@ export interface WorkspaceRetentionSettings { message_log_ttl_days?: number | null; } +export interface ObserverTokenFilters { + channel_ids?: string[]; + channel_names?: string[]; + include_dms?: boolean; + dm_conversation_ids?: string[]; + agent_ids?: string[]; + event_types?: string[]; + created_after?: string; +} + export const workspaces = sqliteTable('workspaces', { id: text('id').primaryKey(), name: text('name').notNull(), @@ -109,6 +119,38 @@ export const nodes = sqliteTable( ], ); +// ============================================ +// Observer Tokens +// ============================================ +export const observerTokens = sqliteTable( + 'observer_tokens', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspaces.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + tokenHash: text('token_hash').notNull().unique(), + scopes: text('scopes', { mode: 'json' }).$type().notNull().default([]), + filters: text('filters', { mode: 'json' }).$type().notNull().default({}), + status: text('status').notNull().default('active'), + expiresAt: integer('expires_at', { mode: 'timestamp' }), + createdBy: text('created_by'), + createdByType: text('created_by_type').notNull().default('workspace'), + lastUsedAt: integer('last_used_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + updatedAt: integer('updated_at', { mode: 'timestamp' }), + revokedAt: integer('revoked_at', { mode: 'timestamp' }), + }, + (table) => [ + uniqueIndex('observer_tokens_workspace_name_unique').on(table.workspaceId, table.name), + uniqueIndex('observer_tokens_token_hash_unique').on(table.tokenHash), + index('idx_observer_tokens_workspace_status').on(table.workspaceId, table.status), + index('idx_observer_tokens_expires_at').on(table.expiresAt), + ], +); + // ============================================ // Agent Node Bindings // ============================================ diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index 8df5e130..96155708 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -21,6 +21,7 @@ import { // Route imports import { healthRoutes } from './routes/health.js'; import { workspaceRoutes } from './routes/workspace.js'; +import { observerTokenRoutes } from './routes/observerToken.js'; import { agentRoutes } from './routes/agent.js'; import { channelRoutes } from './routes/channel.js'; import { messageRoutes } from './routes/message.js'; @@ -123,6 +124,7 @@ export function createEngine(deps: EngineDeps): Hono { workspaceId: authResult.workspace.id, origin, originActor, + observerToken: authResult.observerToken, }); if (response.status === 101) { emitServerEvent(c, authResult.workspace.id, 'relaycast_server_ws_session_started', { @@ -183,6 +185,7 @@ export function createEngine(deps: EngineDeps): Hono { v1.route('/', presenceRoutes); v1.route('/', systemPromptRoutes); v1.route('/', workspaceRoutes); + v1.route('/', observerTokenRoutes); v1.route('/', agentRoutes); v1.route('/', channelRoutes); v1.route('/', messageRoutes); diff --git a/packages/engine/src/engine/deliveryWrites.ts b/packages/engine/src/engine/deliveryWrites.ts index 96b28ee1..d8d65b25 100644 --- a/packages/engine/src/engine/deliveryWrites.ts +++ b/packages/engine/src/engine/deliveryWrites.ts @@ -54,12 +54,12 @@ function channelReasonSql( mentionHandles: readonly string[], fallback: ChannelDeliveryReason, ) { - if (fallback !== 'message' || mentionHandles.length === 0) { + if (mentionHandles.length === 0) { return sql`${fallback}`; } const mentionList = sql.join(mentionHandles.map((handle) => sql`${handle}`), sql`, `); - return sql`case when ${agents.name} in (${mentionList}) then ${'mention'} else ${'message'} end`; + return sql`case when ${agents.name} in (${mentionList}) then ${'mention'} else ${fallback} end`; } function channelMuteDeliveryFilter(mentionHandles: readonly string[]) { diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts new file mode 100644 index 00000000..812f9343 --- /dev/null +++ b/packages/engine/src/engine/observerToken.ts @@ -0,0 +1,401 @@ +import { and, eq, or, gt, isNull, inArray } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { channels, dmConversations, messages, observerTokens } from '../db/schema.js'; +import type { ObserverTokenFilters } from '../db/schema.js'; +import { randomHex, sha256Hex } from '../lib/crypto.js'; +import { codedError } from '../lib/httpError.js'; +import { generateId } from './snowflake.js'; + +type Db = ReturnType; +export type ObserverToken = typeof observerTokens.$inferSelect; + +export const OBSERVER_SCOPES = [ + 'stream:read', + 'messages:read', + 'threads:read', + 'dms:read', + 'channels:read', + 'search:read', + 'agents:read', + 'nodes:read', + 'deliveries:read', + 'activity:read', + 'files:read', + 'reactions:read', +] as const; + +export type ObserverScope = typeof OBSERVER_SCOPES[number]; + +export const OBSERVER_SCOPE_SET = new Set(OBSERVER_SCOPES); + +export interface PublicObserverToken { + id: string; + name: string; + description: string | null; + scopes: ObserverScope[]; + filters: ObserverTokenFilters; + status: string; + expires_at: string | null; + created_at: string; + updated_at: string | null; + revoked_at: string | null; + last_used_at: string | null; + token?: string; +} + +export interface CreateObserverTokenInput { + name: string; + description?: string | null; + scopes: ObserverScope[]; + filters?: ObserverTokenFilters; + expires_at?: string | null; +} + +export interface UpdateObserverTokenInput { + name?: string; + description?: string | null; + scopes?: ObserverScope[]; + filters?: ObserverTokenFilters; + expires_at?: string | null; +} + +function parseExpiresAt(value: string | null | undefined): Date | null { + if (value == null) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw codedError('expires_at must be an ISO-8601 timestamp', 'invalid_request', 400); + } + return date; +} + +export function normalizeObserverFilters(filters?: ObserverTokenFilters | null): ObserverTokenFilters { + return { + ...(filters?.channel_ids?.length ? { channel_ids: [...new Set(filters.channel_ids)] } : {}), + ...(filters?.channel_names?.length ? { channel_names: [...new Set(filters.channel_names)] } : {}), + include_dms: filters?.include_dms === true, + ...(filters?.dm_conversation_ids?.length ? { dm_conversation_ids: [...new Set(filters.dm_conversation_ids)] } : {}), + ...(filters?.agent_ids?.length ? { agent_ids: [...new Set(filters.agent_ids)] } : {}), + ...(filters?.event_types?.length ? { event_types: [...new Set(filters.event_types)] } : {}), + ...(filters?.created_after ? { created_after: filters.created_after } : {}), + }; +} + +function normalizeScopes(scopes: string[]): ObserverScope[] { + const unique = [...new Set(scopes)]; + const invalid = unique.filter((scope) => !OBSERVER_SCOPE_SET.has(scope)); + if (invalid.length > 0) { + throw codedError(`Invalid observer scopes: ${invalid.join(', ')}`, 'invalid_request', 400); + } + if (unique.length === 0) { + throw codedError('At least one observer scope is required', 'invalid_request', 400); + } + return unique as ObserverScope[]; +} + +function publicObserverToken(row: ObserverToken, token?: string): PublicObserverToken { + return { + id: row.id, + name: row.name, + description: row.description, + scopes: normalizeScopes(row.scopes ?? []), + filters: normalizeObserverFilters(row.filters), + status: row.status, + expires_at: row.expiresAt?.toISOString() ?? null, + created_at: row.createdAt.toISOString(), + updated_at: row.updatedAt?.toISOString() ?? null, + revoked_at: row.revokedAt?.toISOString() ?? null, + last_used_at: row.lastUsedAt?.toISOString() ?? null, + ...(token ? { token } : {}), + }; +} + +export async function createObserverToken( + db: Db, + workspaceId: string, + input: CreateObserverTokenInput, +): Promise { + const token = `ot_live_${randomHex(24)}`; + const tokenHash = await sha256Hex(token); + const expiresAt = parseExpiresAt(input.expires_at); + const scopes = normalizeScopes(input.scopes); + const filters = normalizeObserverFilters(input.filters); + + const [created] = await db + .insert(observerTokens) + .values({ + id: `ot_${generateId()}`, + workspaceId, + name: input.name, + description: input.description ?? null, + tokenHash, + scopes, + filters, + expiresAt, + createdByType: 'workspace', + }) + .returning(); + + return publicObserverToken(created, token); +} + +export async function listObserverTokens(db: Db, workspaceId: string): Promise { + const rows = await db + .select() + .from(observerTokens) + .where(eq(observerTokens.workspaceId, workspaceId)); + return rows.map((row) => publicObserverToken(row)); +} + +export async function getObserverToken( + db: Db, + workspaceId: string, + id: string, +): Promise { + const [row] = await db + .select() + .from(observerTokens) + .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))); + return row ? publicObserverToken(row) : null; +} + +export async function updateObserverToken( + db: Db, + workspaceId: string, + id: string, + input: UpdateObserverTokenInput, +): Promise { + const update: Partial = { + updatedAt: new Date(), + }; + if (input.name !== undefined) update.name = input.name; + if (input.description !== undefined) update.description = input.description; + if (input.scopes !== undefined) update.scopes = normalizeScopes(input.scopes); + if (input.filters !== undefined) update.filters = normalizeObserverFilters(input.filters); + if (input.expires_at !== undefined) update.expiresAt = parseExpiresAt(input.expires_at); + + const [row] = await db + .update(observerTokens) + .set(update) + .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .returning(); + + return row ? publicObserverToken(row) : null; +} + +export async function rotateObserverToken( + db: Db, + workspaceId: string, + id: string, +): Promise { + const token = `ot_live_${randomHex(24)}`; + const tokenHash = await sha256Hex(token); + const [row] = await db + .update(observerTokens) + .set({ tokenHash, updatedAt: new Date(), lastUsedAt: null }) + .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .returning(); + return row ? publicObserverToken(row, token) : null; +} + +export async function revokeObserverToken( + db: Db, + workspaceId: string, + id: string, +): Promise { + const now = new Date(); + const [row] = await db + .update(observerTokens) + .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .returning({ id: observerTokens.id }); + return Boolean(row); +} + +export async function getActiveObserverTokenByHash( + db: Db, + tokenHash: string, +): Promise { + const now = new Date(); + const [row] = await db + .select() + .from(observerTokens) + .where(and( + eq(observerTokens.tokenHash, tokenHash), + eq(observerTokens.status, 'active'), + or(isNull(observerTokens.expiresAt), gt(observerTokens.expiresAt, now)), + )); + if (!row) return null; + + try { + await db + .update(observerTokens) + .set({ lastUsedAt: now }) + .where(eq(observerTokens.id, row.id)); + } catch { + // last_used_at is best-effort audit metadata; authentication should not fail + // solely because a read replica or adapter rejects this update. + } + return row; +} + +export function hasObserverScope(observer: Pick, scope: ObserverScope): boolean { + return (observer.scopes ?? []).includes(scope); +} + +export function hasAnyObserverScope(observer: Pick, scopes: ObserverScope[]): boolean { + return scopes.some((scope) => hasObserverScope(observer, scope)); +} + +function filterList(values: string[] | undefined): Set | null { + return values && values.length > 0 ? new Set(values) : null; +} + +export function observerAllowsChannel( + observer: Pick | undefined, + channel: { id?: string | null; name?: string | null }, +): boolean { + if (!observer) return true; + const filters = normalizeObserverFilters(observer.filters); + const ids = filterList(filters.channel_ids); + const names = filterList(filters.channel_names); + if (!ids && !names) return true; + return Boolean((channel.id && ids?.has(channel.id)) || (channel.name && names?.has(channel.name))); +} + +export function observerAllowsConversation( + observer: Pick | undefined, + conversationId: string | null | undefined, +): boolean { + if (!observer) return true; + const filters = normalizeObserverFilters(observer.filters); + if (!filters.include_dms) return false; + const ids = filterList(filters.dm_conversation_ids); + if (!ids) return true; + return Boolean(conversationId && ids.has(conversationId)); +} + +export function observerAllowsAgent( + observer: Pick | undefined, + agentId: string | null | undefined, +): boolean { + if (!observer) return true; + const ids = filterList(normalizeObserverFilters(observer.filters).agent_ids); + if (!ids) return true; + return Boolean(agentId && ids.has(agentId)); +} + +function nestedRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function eventChannelName(event: Record): string | null { + const channel = event.channel; + if (typeof channel === 'string') return channel; + const channelObject = nestedRecord(channel); + return typeof channelObject.name === 'string' ? channelObject.name : null; +} + +function eventAgentId(event: Record): string | null { + const message = nestedRecord(event.message); + const agent = nestedRecord(event.agent); + return (typeof event.agent_id === 'string' ? event.agent_id : null) + ?? (typeof event.subject_agent_id === 'string' ? event.subject_agent_id : null) + ?? (typeof message.agent_id === 'string' ? message.agent_id : null) + ?? (typeof agent.id === 'string' ? agent.id : null); +} + +export function observerAllowsEvent( + observer: Pick | undefined, + event: Record, +): boolean { + if (!observer) return true; + const filters = normalizeObserverFilters(observer.filters); + const type = typeof event.type === 'string' ? event.type : ''; + + if (filters.event_types?.length && !filters.event_types.includes(type)) { + return false; + } + + if ((type === 'dm.received' || type === 'group_dm.received') && !observerAllowsConversation( + observer, + typeof event.conversation_id === 'string' ? event.conversation_id : null, + )) { + return false; + } + + if (!observerAllowsChannel(observer, { + id: typeof event.channel_id === 'string' ? event.channel_id : null, + name: eventChannelName(event), + })) { + return false; + } + + if (!observerAllowsAgent(observer, eventAgentId(event))) { + return false; + } + + return true; +} + +export async function getMessageObserverResource( + db: Db, + workspaceId: string, + messageId: string, +): Promise<{ channel_id: string; channel_name: string; conversation_id: string | null } | null> { + const [row] = await db + .select({ + channel_id: channels.id, + channel_name: channels.name, + conversation_id: dmConversations.id, + }) + .from(messages) + .innerJoin(channels, eq(messages.channelId, channels.id)) + .leftJoin(dmConversations, eq(dmConversations.channelId, channels.id)) + .where(and(eq(messages.id, messageId), eq(messages.workspaceId, workspaceId))); + return row ?? null; +} + +export async function getChannelObserverResource( + db: Db, + workspaceId: string, + name: string, +): Promise<{ id: string; name: string } | null> { + const [row] = await db + .select({ id: channels.id, name: channels.name }) + .from(channels) + .where(and(eq(channels.workspaceId, workspaceId), eq(channels.name, name))); + return row ?? null; +} + +export async function filterObserverSearchResults( + db: Db, + workspaceId: string, + observer: Pick | undefined, + results: T[], +): Promise { + if (!observer || results.length === 0) return results; + + const missingChannelIds = [...new Set(results + .filter((result) => !result.channel_name && result.channel_id) + .map((result) => result.channel_id as string))]; + const namesById = new Map(); + if (missingChannelIds.length > 0) { + const rows = await db + .select({ id: channels.id, name: channels.name }) + .from(channels) + .where(and(eq(channels.workspaceId, workspaceId), inArray(channels.id, missingChannelIds))); + for (const row of rows) namesById.set(row.id, row.name); + } + + return results.filter((result) => { + if (result.conversation_id && !observerAllowsConversation(observer, result.conversation_id)) return false; + return observerAllowsChannel(observer, { + id: result.channel_id, + name: result.channel_name ?? (result.channel_id ? namesById.get(result.channel_id) : null), + }); + }); +} + +export function getObserverTokenFromContext(c: { get(key: 'observerToken'): ObserverToken | undefined }): ObserverToken | undefined { + return c.get('observerToken'); +} diff --git a/packages/engine/src/engine/search.ts b/packages/engine/src/engine/search.ts index 701d43ef..cd0137e0 100644 --- a/packages/engine/src/engine/search.ts +++ b/packages/engine/src/engine/search.ts @@ -52,17 +52,19 @@ export async function searchMessages( id: string; channel_id: string; channel_name: string; + conversation_id: string | null; agent_id: string; agent_name: string | null; body: string; created_at: number; rank: number; }>(sql` - SELECT m.id, m.channel_id, c.name AS channel_name, m.agent_id, a.name AS agent_name, m.body, m.created_at, + SELECT m.id, m.channel_id, c.name AS channel_name, dc.id AS conversation_id, m.agent_id, a.name AS agent_name, m.body, m.created_at, bm25(messages_fts) AS rank FROM messages_fts fts JOIN messages m ON m.id = fts.id JOIN channels c ON c.id = m.channel_id + LEFT JOIN dm_conversations dc ON dc.channel_id = c.id LEFT JOIN agents a ON a.id = m.agent_id WHERE messages_fts MATCH ${ftsQuery} AND m.workspace_id = ${workspaceId} @@ -76,7 +78,9 @@ export async function searchMessages( return rows.map((row) => ({ id: row.id, + channel_id: row.channel_id, channel_name: row.channel_name || 'unknown', + conversation_id: row.conversation_id, agent_name: row.agent_name || 'unknown', text: row.body, created_at: new Date(row.created_at * 1000).toISOString(), diff --git a/packages/engine/src/engine/thread.ts b/packages/engine/src/engine/thread.ts index 5ab4db64..068aae4a 100644 --- a/packages/engine/src/engine/thread.ts +++ b/packages/engine/src/engine/thread.ts @@ -45,6 +45,9 @@ export async function postReply( const replyId = generateId(); const metadata = sanitizeUserMessageMetadata(data.data); + const mentionPattern = /@(\w+)/g; + const mentionMatches = data.text.match(mentionPattern) || []; + const mentionedHandles = new Set(mentionMatches.map((m: string) => m.slice(1))); const mailbox = options.mailbox ?? { ttlMs: DEFAULT_MAILBOX_TTL_MS, depthCap: DEFAULT_MAILBOX_DEPTH_CAP, @@ -93,6 +96,7 @@ export async function postReply( ttlMs: mailbox.ttlMs, depthCap: mailbox.depthCap, reason: 'thread-reply', + mentionHandles: Array.from(mentionedHandles), }), ); @@ -109,6 +113,7 @@ export async function postReply( messageId: replyId, channelId: parent.channelId, senderAgentId: agentId, + mentionHandles: Array.from(mentionedHandles), }); return { diff --git a/packages/engine/src/engine/wsAuth.ts b/packages/engine/src/engine/wsAuth.ts index 65b91c96..ebe2eaaa 100644 --- a/packages/engine/src/engine/wsAuth.ts +++ b/packages/engine/src/engine/wsAuth.ts @@ -1,6 +1,7 @@ -import type { AuthProvider, Workspace } from '../ports/auth.js'; +import type { AuthProvider, Workspace, ObserverToken } from '../ports/auth.js'; import type { EngineDb } from '../ports/database.js'; import { getNodeByTokenHash } from './node.js'; +import { hasObserverScope } from './observerToken.js'; type WsAuthErrorCode = 'unauthorized' | 'invalid_token'; @@ -13,7 +14,7 @@ export interface WsAuthError { } export type RealtimeWsAuthResult = - | { ok: true; scope: 'workspace'; workspace: Workspace } + | { ok: true; scope: 'workspace'; workspace: Workspace; observerToken?: ObserverToken } | WsAuthError; export type NodeWsAuthResult = @@ -65,11 +66,22 @@ export async function authenticateRealtimeWs(deps: WsAuthDeps, token: string): P } if (token.startsWith('rk_live_')) { - const result = await deps.auth.authenticate({ token, require: 'workspace', db: deps.db }); - if (!result.ok) { - return invalidWsToken('Invalid workspace key'); + return invalidWsToken('Observer token required for workspace stream'); + } + + if (token.startsWith('nt_live_')) { + return invalidWsToken('Node token cannot open the workspace stream'); + } + + if (token.startsWith('ot_live_')) { + const result = await deps.auth.authenticate({ token, require: 'observer', db: deps.db }); + if (!result.ok || !result.observerToken) { + return invalidWsToken('Invalid observer token'); + } + if (!hasObserverScope(result.observerToken, 'stream:read')) { + return invalidWsToken('Observer token lacks stream:read'); } - return { ok: true, scope: 'workspace', workspace: result.workspace }; + return { ok: true, scope: 'workspace', workspace: result.workspace, observerToken: result.observerToken }; } return invalidWsToken('Invalid token format'); diff --git a/packages/engine/src/engine/wsTransform.ts b/packages/engine/src/engine/wsTransform.ts index ebf0e47b..dbe2fcb0 100644 --- a/packages/engine/src/engine/wsTransform.ts +++ b/packages/engine/src/engine/wsTransform.ts @@ -21,6 +21,7 @@ export function transformForClient(event: WsEvent): Record { return { id: stableRelaycastEventId(d.id as string), type: 'message.created', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, message: { id: d.id as string, @@ -35,6 +36,7 @@ export function transformForClient(event: WsEvent): Record { case 'message.updated': return { type: 'message.updated', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, message: { id: d.id as string, @@ -48,6 +50,7 @@ export function transformForClient(event: WsEvent): Record { return { id: stableRelaycastEventId(d.id as string), type: 'thread.reply', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, parent_id: d.thread_id as string, message: { @@ -61,6 +64,7 @@ export function transformForClient(event: WsEvent): Record { case 'message.reacted': return { type: 'message.reacted', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), message_id: d.message_id as string, emoji: d.emoji as string, agent_name: d.agent_name as string, @@ -150,24 +154,28 @@ export function transformForClient(event: WsEvent): Record { case 'channel.created': return { type: 'channel.created', + ...(event.channel_id || d.id ? { channel_id: (event.channel_id ?? d.id) as string } : {}), channel: { name: (d.channel_name as string) ?? (d.name as string), topic: (d.topic as string | null) ?? null }, }; case 'channel.updated': return { type: 'channel.updated', + ...(event.channel_id || d.id ? { channel_id: (event.channel_id ?? d.id) as string } : {}), channel: { name: (d.channel_name as string) ?? (d.name as string), topic: (d.topic as string | null) ?? null }, }; case 'channel.archived': return { type: 'channel.archived', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: { name: d.channel_name as string }, }; case 'member.joined': return { type: 'member.joined', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, agent_name: d.agent_name as string, }; @@ -175,6 +183,7 @@ export function transformForClient(event: WsEvent): Record { case 'member.left': return { type: 'member.left', + ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, agent_name: d.agent_name as string, }; diff --git a/packages/engine/src/entrypoints/node.ts b/packages/engine/src/entrypoints/node.ts index c8628706..043775a4 100644 --- a/packages/engine/src/entrypoints/node.ts +++ b/packages/engine/src/entrypoints/node.ts @@ -146,7 +146,11 @@ export function startServer(options: StartServerOptions): RunningServer { } wss.handleUpgrade(req, socket, head, (ws) => { - const handle = runtime.realtime.attachWorkspaceSocket(authResult.workspace.id, toEngineSocket(ws)); + const handle = runtime.realtime.attachWorkspaceSocket( + authResult.workspace.id, + toEngineSocket(ws), + authResult.observerToken, + ); ws.on('message', (data) => { void handle.handleMessage(data.toString()); }); ws.on('close', () => { void handle.handleClose(); }); }); diff --git a/packages/engine/src/env.ts b/packages/engine/src/env.ts index a83f20db..35bbe940 100644 --- a/packages/engine/src/env.ts +++ b/packages/engine/src/env.ts @@ -1,4 +1,4 @@ -import type { workspaces, agents, nodes } from './db/schema.js'; +import type { workspaces, agents, nodes, observerTokens } from './db/schema.js'; import type { Logger } from './lib/logger.js'; import type { EngineDb, @@ -22,6 +22,7 @@ export interface AppVariables { workspace: typeof workspaces.$inferSelect; agent: typeof agents.$inferSelect | undefined; node: typeof nodes.$inferSelect | undefined; + observerToken: typeof observerTokens.$inferSelect | undefined; db: EngineDb; logger: Logger; requestId: string; diff --git a/packages/engine/src/middleware/auth.ts b/packages/engine/src/middleware/auth.ts index 2b1354ab..cb0a2d5b 100644 --- a/packages/engine/src/middleware/auth.ts +++ b/packages/engine/src/middleware/auth.ts @@ -4,6 +4,10 @@ import { touchLastSeen } from '../engine/agent.js'; import type { AppEnv } from '../env.js'; import type { AuthRequire } from '../ports/auth.js'; import { jsonError } from '../lib/httpResponse.js'; +import { + hasAnyObserverScope, + type ObserverScope, +} from '../engine/observerToken.js'; // Re-exported for the WS upgrade, which looks tokens up // directly. The active provider's `hashToken` is preferred at those call sites. @@ -40,6 +44,77 @@ function makeAuthMiddleware(require: AuthRequire) { if (result.node) { c.set('node', result.node); } + if (result.observerToken) { + c.set('observerToken', result.observerToken); + } + + await next(); + }); +} + +interface WorkspaceReadOptions { + allowAgent?: boolean; + allowNode?: boolean; +} + +function scopesArray(scopes: ObserverScope | ObserverScope[]): ObserverScope[] { + return Array.isArray(scopes) ? scopes : [scopes]; +} + +export function requireWorkspaceRead( + scopes: ObserverScope | ObserverScope[], + options: WorkspaceReadOptions = {}, +) { + const requiredScopes = scopesArray(scopes); + const allowAgent = options.allowAgent ?? true; + const allowNode = options.allowNode ?? true; + + return createMiddleware(async (c, next) => { + const token = extractToken(c.req.header('Authorization')); + if (!token) { + return jsonError(c, 'unauthorized', 'Missing or invalid Authorization header', 401); + } + + const db = c.get('db'); + const require: AuthRequire = token.startsWith('ot_live_') + ? 'observer' + : allowAgent || allowNode + ? 'any' + : 'workspace'; + const result = await c.get('engine').auth.authenticate({ token, require, db }); + if (!result.ok) { + return jsonError(c, result.code, result.message, result.status as ContentfulStatusCode); + } + + if (result.observerToken) { + if (!hasAnyObserverScope(result.observerToken, requiredScopes)) { + return jsonError(c, 'insufficient_scope', 'Observer token lacks the required scope', 403); + } + c.set('workspace', result.workspace); + c.set('agent', undefined); + c.set('node', undefined); + c.set('observerToken', result.observerToken); + await next(); + return; + } + + if (result.agent && !allowAgent) { + return jsonError(c, 'forbidden', 'Agent token cannot access this resource', 403); + } + if (result.node && !allowNode) { + return jsonError(c, 'forbidden', 'Node token cannot access this resource', 403); + } + + c.set('workspace', result.workspace); + if (result.agent) { + c.set('agent', result.agent); + if (Date.now() - result.agent.lastSeen.getTime() > LAST_SEEN_DEBOUNCE_MS) { + touchLastSeen(db, result.agent.id).catch(() => {}); + } + } + if (result.node) { + c.set('node', result.node); + } await next(); }); diff --git a/packages/engine/src/ports/auth.ts b/packages/engine/src/ports/auth.ts index a60eae51..56f113fe 100644 --- a/packages/engine/src/ports/auth.ts +++ b/packages/engine/src/ports/auth.ts @@ -1,14 +1,15 @@ -import type { workspaces, agents, nodes } from '../db/schema.js'; +import type { workspaces, agents, nodes, observerTokens } from '../db/schema.js'; import type { EngineDb } from './database.js'; export type Workspace = typeof workspaces.$inferSelect; export type Agent = typeof agents.$inferSelect; export type Node = typeof nodes.$inferSelect; +export type ObserverToken = typeof observerTokens.$inferSelect; -export type AuthRequire = 'workspace' | 'agent' | 'node' | 'any'; +export type AuthRequire = 'workspace' | 'agent' | 'node' | 'observer' | 'any'; export type AuthResult = - | { ok: true; workspace: Workspace; agent?: Agent; node?: Node } + | { ok: true; workspace: Workspace; agent?: Agent; node?: Node; observerToken?: ObserverToken } | { ok: false; status: number; code: string; message: string }; /** diff --git a/packages/engine/src/ports/realtime.ts b/packages/engine/src/ports/realtime.ts index e3aa77e1..f3652b95 100644 --- a/packages/engine/src/ports/realtime.ts +++ b/packages/engine/src/ports/realtime.ts @@ -17,6 +17,7 @@ */ import type { FleetRelaycastToBrokerMessage } from '@relaycast/types'; +import type { ObserverToken } from './auth.js'; /** A client-facing event payload, already passed through `transformForClient`. */ export type EngineEvent = Record; @@ -79,6 +80,8 @@ export interface UpgradeArgs { origin: { client: string; version: string }; /** Cloud-only origin-actor path (`{app}/{type}[/{name}]`); defaults to `'unknown'`. */ originActor?: string; + /** Scoped observer principal for workspace stream sockets. */ + observerToken?: ObserverToken; } export interface ConnectionRegistry { diff --git a/packages/engine/src/routes/agent.ts b/packages/engine/src/routes/agent.ts index a2a40ca6..174be771 100644 --- a/packages/engine/src/routes/agent.ts +++ b/packages/engine/src/routes/agent.ts @@ -2,13 +2,17 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { AgentTypeSchema, CliTypeSchema } from '@relaycast/types'; import type { AppEnv } from '../env.js'; -import { requireWorkspaceKey, requireAuth, requireAgentToken } from '../middleware/auth.js'; +import { requireWorkspaceKey, requireAuth, requireAgentToken, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as agentEngine from '../engine/agent.js'; import * as nodeEngine from '../engine/node.js'; import * as actionEngine from '../engine/action.js'; import * as directoryEngine from '../engine/directory.js'; import * as sessionEventEngine from '../engine/sessionEvent.js'; +import { + getObserverTokenFromContext, + observerAllowsAgent, +} from '../engine/observerToken.js'; import { fanoutToWorkspace } from './fanout.js'; import { sendNodePresenceContext } from '../engine/nodeContext.js'; import { runInBackground } from './background.js'; @@ -239,14 +243,16 @@ agentRoutes.post( // GET /v1/agents - list agents agentRoutes.get( '/agents', - requireWorkspaceKey, + requireWorkspaceRead('agents:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const status = c.req.query('status'); - const agents = await agentEngine.listAgents(db, workspace.id, status); + const observer = getObserverTokenFromContext(c); + const agents = (await agentEngine.listAgents(db, workspace.id, status)) + .filter((agent) => observerAllowsAgent(observer, agent.id)); return jsonOk(c, agents); } catch (err: unknown) { return errorResponse(c, err); @@ -257,7 +263,7 @@ agentRoutes.get( // GET /v1/agents/:name - get agent by name agentRoutes.get( '/agents/:name', - requireWorkspaceKey, + requireWorkspaceRead('agents:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { @@ -268,6 +274,9 @@ agentRoutes.get( if (!agent) { return agentNotFound(c, name); } + if (!observerAllowsAgent(getObserverTokenFromContext(c), agent.id)) { + return agentNotFound(c, name); + } return jsonOk(c, agent); } catch (err: unknown) { return errorResponse(c, err); @@ -542,7 +551,7 @@ agentRoutes.post( // GET /v1/agents/:name/events - list session events for an agent agentRoutes.get( '/agents/:name/events', - requireWorkspaceKey, + requireWorkspaceRead('activity:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { @@ -559,6 +568,9 @@ agentRoutes.get( if (!agent) { return agentNotFound(c, name); } + if (!observerAllowsAgent(getObserverTokenFromContext(c), agent.id)) { + return agentNotFound(c, name); + } const events = await sessionEventEngine.listSessionEvents(db, workspace.id, agent.id, { type, diff --git a/packages/engine/src/routes/channel.ts b/packages/engine/src/routes/channel.ts index 6005a790..f5872b8c 100644 --- a/packages/engine/src/routes/channel.ts +++ b/packages/engine/src/routes/channel.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth, requireAgentToken } from '../middleware/auth.js'; +import { requireAuth, requireAgentToken, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as channelEngine from '../engine/channel.js'; import { fanoutToChannel, fanoutToWorkspace, updateChannelMembers, updateChannelMuted } from './fanout.js'; @@ -9,6 +9,12 @@ import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; import { errorResponse } from '../lib/httpError.js'; +import { + getChannelObserverResource, + getObserverTokenFromContext, + observerAllowsChannel, + type ObserverToken, +} from '../engine/observerToken.js'; import { jsonCreated, jsonError, @@ -43,6 +49,10 @@ function channelNotFound(c: Parameters[0], name: string) { return jsonNotFound(c, 'channel_not_found', `Channel "${name}" not found`); } +function observerChannelAllowed(observer: ObserverToken | undefined, channel: { id?: string | null; name?: string | null }) { + return observerAllowsChannel(observer, channel); +} + // POST /v1/channels - create channel channelRoutes.post( '/channels', @@ -97,19 +107,20 @@ channelRoutes.post( // GET /v1/channels - list channels channelRoutes.get( '/channels', - requireAuth, + requireWorkspaceRead('channels:read'), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const includeArchived = c.req.query('include_archived') === 'true'; + const observer = getObserverTokenFromContext(c); const channels = await channelEngine.listChannels( db, workspace.id, includeArchived, ); - return jsonOk(c, channels); + return jsonOk(c, channels.filter((channel) => observerChannelAllowed(observer, channel))); } catch (err: unknown) { return errorResponse(c, err); } @@ -119,7 +130,7 @@ channelRoutes.get( // GET /v1/channels/:name - get channel with members channelRoutes.get( '/channels/:name', - requireAuth, + requireWorkspaceRead('channels:read'), rateLimit, async (c) => { try { @@ -130,6 +141,9 @@ channelRoutes.get( if (!channel) { return channelNotFound(c, name); } + if (!observerChannelAllowed(getObserverTokenFromContext(c), channel)) { + return channelNotFound(c, name); + } return jsonOk(c, channel); } catch (err: unknown) { return errorResponse(c, err); @@ -378,13 +392,20 @@ channelRoutes.post( // GET /v1/channels/:name/members - list channel members channelRoutes.get( '/channels/:name/members', - requireAuth, + requireWorkspaceRead('channels:read'), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const name = c.req.param('name'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const channel = await getChannelObserverResource(db, workspace.id, name); + if (!channel || !observerChannelAllowed(observer, channel)) { + return channelNotFound(c, name); + } + } const members = await channelEngine.getMembers( db, workspace.id, diff --git a/packages/engine/src/routes/console.ts b/packages/engine/src/routes/console.ts index e4fbef02..d052d714 100644 --- a/packages/engine/src/routes/console.ts +++ b/packages/engine/src/routes/console.ts @@ -1,9 +1,13 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as consoleEngine from '../engine/console.js'; +import { + filterObserverSearchResults, + getObserverTokenFromContext, +} from '../engine/observerToken.js'; import { errorResponse } from '../lib/httpError.js'; import { jsonOk, parseQueryParams } from '../lib/httpResponse.js'; import { positiveIntQueryParam } from '../lib/httpQuery.js'; @@ -28,7 +32,7 @@ const agentStatsQuerySchema = z.object({ limit: positiveIntQueryParam({ defaultValue: 20, max: 100 }), }); -consoleRoutes.get('/console/messages', requireAuth, rateLimit, async (c) => { +consoleRoutes.get('/console/messages', requireWorkspaceRead('messages:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); const db = c.get('db'); @@ -46,13 +50,23 @@ consoleRoutes.get('/console/messages', requireAuth, rateLimit, async (c) => { deliveryKind: parsed.data.delivery_kind, }); - return jsonOk(c, data); + const visible = await filterObserverSearchResults( + db, + workspace.id, + getObserverTokenFromContext(c), + data.map((item) => ({ + ...item, + channel_name: item.channel_name ?? undefined, + })), + ); + + return jsonOk(c, visible); } catch (err: unknown) { return errorResponse(c, err); } }); -consoleRoutes.get('/console/stats', requireAuth, rateLimit, async (c) => { +consoleRoutes.get('/console/stats', requireWorkspaceRead('activity:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); const db = c.get('db'); @@ -68,7 +82,7 @@ consoleRoutes.get('/console/stats', requireAuth, rateLimit, async (c) => { } }); -consoleRoutes.get('/console/agents', requireAuth, rateLimit, async (c) => { +consoleRoutes.get('/console/agents', requireWorkspaceRead('agents:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); const db = c.get('db'); @@ -89,7 +103,7 @@ consoleRoutes.get('/console/agents', requireAuth, rateLimit, async (c) => { } }); -consoleRoutes.get('/console/costs', requireAuth, rateLimit, async (c) => { +consoleRoutes.get('/console/costs', requireWorkspaceRead('activity:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); const db = c.get('db'); diff --git a/packages/engine/src/routes/deliveryRouting.ts b/packages/engine/src/routes/deliveryRouting.ts index f2d106a1..39cdead1 100644 --- a/packages/engine/src/routes/deliveryRouting.ts +++ b/packages/engine/src/routes/deliveryRouting.ts @@ -351,6 +351,8 @@ async function dispatchHttpPush(args: { return 'acked'; } } + await recordHttpPushRetry(args.ctx, args.delivery.id, 'response ack not signaled'); + return 'failed'; } return 'delivered'; diff --git a/packages/engine/src/routes/file.ts b/packages/engine/src/routes/file.ts index 4a91404c..3eb660a3 100644 --- a/packages/engine/src/routes/file.ts +++ b/packages/engine/src/routes/file.ts @@ -12,7 +12,7 @@ import { parseQueryParams, } from '../lib/httpResponse.js'; import { LimitQuerySchema } from '../lib/httpQuery.js'; -import { requireAuth, requireAgentToken } from '../middleware/auth.js'; +import { requireWorkspaceRead, requireAgentToken } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as fileEngine from '../engine/file.js'; import { fanoutToWorkspace } from './fanout.js'; @@ -119,7 +119,7 @@ fileRoutes.post('/files/:id/complete', requireAgentToken, rateLimit, async (c) = }); // GET /v1/files/:id — Get file metadata -fileRoutes.get('/files/:id', requireAuth, rateLimit, async (c) => { +fileRoutes.get('/files/:id', requireWorkspaceRead('files:read'), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); @@ -168,7 +168,7 @@ fileRoutes.delete('/files/:id', requireAgentToken, rateLimit, async (c) => { }); // GET /v1/files — List files -fileRoutes.get('/files', requireAuth, rateLimit, async (c) => { +fileRoutes.get('/files', requireWorkspaceRead('files:read'), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); diff --git a/packages/engine/src/routes/message.ts b/packages/engine/src/routes/message.ts index d710639c..e8ed6fc0 100644 --- a/packages/engine/src/routes/message.ts +++ b/packages/engine/src/routes/message.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { jsonIdempotentOk, parseIdempotencyKey, runIdempotent } from '../middleware/idempotency.js'; import * as messageEngine from '../engine/message.js'; @@ -11,6 +11,13 @@ import { resolveMailboxConfig } from '../engine/mailboxConfig.js'; import { publishWorkspaceEvent } from './fanout.js'; import { notifyDeliveryRejections, routeDeliveryOutcomes } from './deliveryRouting.js'; import { buildMessageCreatedEventData } from '../engine/deliveryWire.js'; +import { + getMessageObserverResource, + getObserverTokenFromContext, + hasObserverScope, + observerAllowsChannel, + observerAllowsConversation, +} from '../engine/observerToken.js'; import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; @@ -185,7 +192,7 @@ messageRoutes.post( // GET /v1/channels/:name/messages - list messages messageRoutes.get( '/channels/:name/messages', - requireAuth, + requireWorkspaceRead('messages:read'), rateLimit, async (c) => { try { @@ -196,6 +203,9 @@ messageRoutes.get( if (!channel) { return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); } + if (!observerAllowsChannel(getObserverTokenFromContext(c), channel)) { + return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); + } const query = parsePaginationQuery(c); if (!query.ok) { @@ -219,13 +229,30 @@ messageRoutes.get( // GET /v1/messages/:id - get single message messageRoutes.get( '/messages/:id', - requireAuth, + requireWorkspaceRead(['messages:read', 'dms:read']), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const messageId = c.req.param('id'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getMessageObserverResource(db, workspace.id, messageId); + if (!resource) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } + if (resource.conversation_id) { + if (!hasObserverScope(observer, 'dms:read') || !observerAllowsConversation(observer, resource.conversation_id)) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } + } else if (!hasObserverScope(observer, 'messages:read') || !observerAllowsChannel(observer, { + id: resource.channel_id, + name: resource.channel_name, + })) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } + } const message = await messageEngine.getMessage(db, workspace.id, messageId); if (!message) { return jsonNotFound(c, 'message_not_found', 'Message not found'); diff --git a/packages/engine/src/routes/node.ts b/packages/engine/src/routes/node.ts index 9d27ac2d..a887c2d2 100644 --- a/packages/engine/src/routes/node.ts +++ b/packages/engine/src/routes/node.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth, requireWorkspaceKey } from '../middleware/auth.js'; +import { requireWorkspaceRead, requireWorkspaceKey } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { errorResponse } from '../lib/httpError.js'; import { isSafeExternalUrl } from '../lib/ssrf.js'; @@ -120,7 +120,7 @@ nodeRoutes.post('/nodes', requireWorkspaceKey, rateLimit, async (c) => { }); // GET /v1/nodes?capability=&name= - node roster -nodeRoutes.get('/nodes', requireAuth, rateLimit, async (c) => { +nodeRoutes.get('/nodes', requireWorkspaceRead('nodes:read'), rateLimit, async (c) => { try { const result = await nodeEngine.listNodes(c.get('db'), c.get('workspace').id, { capability: c.req.query('capability'), @@ -133,7 +133,7 @@ nodeRoutes.get('/nodes', requireAuth, rateLimit, async (c) => { }); // GET /v1/nodes/:name/agents - active agent bindings for a node -nodeRoutes.get('/nodes/:name/agents', requireAuth, rateLimit, async (c) => { +nodeRoutes.get('/nodes/:name/agents', requireWorkspaceRead('nodes:read'), rateLimit, async (c) => { try { const result = await nodeEngine.listNodeAgents(c.get('db'), c.get('workspace').id, c.req.param('name')); if (!result) { @@ -190,7 +190,7 @@ nodeRoutes.delete('/nodes/:name/agents/:agentName', requireWorkspaceKey, rateLim }); // GET /v1/nodes/:name - single node roster entry -nodeRoutes.get('/nodes/:name', requireAuth, rateLimit, async (c) => { +nodeRoutes.get('/nodes/:name', requireWorkspaceRead('nodes:read'), rateLimit, async (c) => { try { const result = await nodeEngine.getPublicNode(c.get('db'), c.get('workspace').id, c.req.param('name')); if (!result) { diff --git a/packages/engine/src/routes/observerToken.ts b/packages/engine/src/routes/observerToken.ts new file mode 100644 index 00000000..0b076311 --- /dev/null +++ b/packages/engine/src/routes/observerToken.ts @@ -0,0 +1,122 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AppEnv } from '../env.js'; +import { requireWorkspaceKey } from '../middleware/auth.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { errorResponse } from '../lib/httpError.js'; +import { + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + parseJsonBody, +} from '../lib/httpResponse.js'; +import { + OBSERVER_SCOPES, + createObserverToken, + getObserverToken, + listObserverTokens, + revokeObserverToken, + rotateObserverToken, + updateObserverToken, +} from '../engine/observerToken.js'; + +export const observerTokenRoutes = new Hono(); + +const isoTimestamp = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { + message: 'expires_at must be an ISO-8601 timestamp', +}); + +const observerScopeSchema = z.enum(OBSERVER_SCOPES); + +const observerFiltersSchema = z.object({ + channel_ids: z.array(z.string().min(1)).optional(), + channel_names: z.array(z.string().min(1)).optional(), + include_dms: z.boolean().optional(), + dm_conversation_ids: z.array(z.string().min(1)).optional(), + agent_ids: z.array(z.string().min(1)).optional(), + event_types: z.array(z.string().min(1)).optional(), + created_after: isoTimestamp.optional(), +}).strict(); + +const createObserverTokenSchema = z.object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + scopes: z.array(observerScopeSchema).min(1), + filters: observerFiltersSchema.optional(), + expires_at: isoTimestamp.nullable().optional(), +}); + +const updateObserverTokenSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + scopes: z.array(observerScopeSchema).min(1).optional(), + filters: observerFiltersSchema.optional(), + expires_at: isoTimestamp.nullable().optional(), +}); + +function observerTokenNotFound(c: Parameters[0]) { + return jsonNotFound(c, 'observer_token_not_found', 'Observer token not found'); +} + +observerTokenRoutes.post('/observer-tokens', requireWorkspaceKey, rateLimit, async (c) => { + try { + const parsed = await parseJsonBody(c, createObserverTokenSchema, 'invalid observer token body'); + if (!parsed.ok) return parsed.response; + const result = await createObserverToken(c.get('db'), c.get('workspace').id, parsed.data); + return jsonCreated(c, result); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); + +observerTokenRoutes.get('/observer-tokens', requireWorkspaceKey, rateLimit, async (c) => { + try { + const result = await listObserverTokens(c.get('db'), c.get('workspace').id); + return jsonOk(c, result); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); + +observerTokenRoutes.get('/observer-tokens/:id', requireWorkspaceKey, rateLimit, async (c) => { + try { + const result = await getObserverToken(c.get('db'), c.get('workspace').id, c.req.param('id')); + if (!result) return observerTokenNotFound(c); + return jsonOk(c, result); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); + +observerTokenRoutes.patch('/observer-tokens/:id', requireWorkspaceKey, rateLimit, async (c) => { + try { + const parsed = await parseJsonBody(c, updateObserverTokenSchema, 'invalid observer token update body'); + if (!parsed.ok) return parsed.response; + const result = await updateObserverToken(c.get('db'), c.get('workspace').id, c.req.param('id'), parsed.data); + if (!result) return observerTokenNotFound(c); + return jsonOk(c, result); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); + +observerTokenRoutes.post('/observer-tokens/:id/rotate', requireWorkspaceKey, rateLimit, async (c) => { + try { + const result = await rotateObserverToken(c.get('db'), c.get('workspace').id, c.req.param('id')); + if (!result) return observerTokenNotFound(c); + return jsonOk(c, result); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); + +observerTokenRoutes.delete('/observer-tokens/:id', requireWorkspaceKey, rateLimit, async (c) => { + try { + const revoked = await revokeObserverToken(c.get('db'), c.get('workspace').id, c.req.param('id')); + if (!revoked) return observerTokenNotFound(c); + return jsonNoContent(c); + } catch (err: unknown) { + return errorResponse(c, err); + } +}); diff --git a/packages/engine/src/routes/presence.ts b/packages/engine/src/routes/presence.ts index 66363bc8..775223d9 100644 --- a/packages/engine/src/routes/presence.ts +++ b/packages/engine/src/routes/presence.ts @@ -1,19 +1,25 @@ import { Hono } from 'hono'; import type { AppEnv } from '../env.js'; -import { requireAuth, requireAgentToken } from '../middleware/auth.js'; +import { requireWorkspaceRead, requireAgentToken } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as presenceEngine from '../engine/presence.js'; +import { + getObserverTokenFromContext, + observerAllowsAgent, +} from '../engine/observerToken.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; import { errorResponse } from '../lib/httpError.js'; import { jsonOk, jsonSuccess } from '../lib/httpResponse.js'; export const presenceRoutes = new Hono(); -presenceRoutes.get('/agents/presence', requireAuth, rateLimit, async (c) => { +presenceRoutes.get('/agents/presence', requireWorkspaceRead('agents:read'), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); - const result = await presenceEngine.getPresence(db, c.get('engine').presence, workspace.id); + const observer = getObserverTokenFromContext(c); + const result = (await presenceEngine.getPresence(db, c.get('engine').presence, workspace.id)) + .filter((presence) => observerAllowsAgent(observer, presence.agent_id)); return jsonOk(c, result); } catch (err: unknown) { return errorResponse(c, err); diff --git a/packages/engine/src/routes/reaction.ts b/packages/engine/src/routes/reaction.ts index ba050f86..016d140a 100644 --- a/packages/engine/src/routes/reaction.ts +++ b/packages/engine/src/routes/reaction.ts @@ -1,11 +1,17 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth, requireAgentToken } from '../middleware/auth.js'; +import { requireAgentToken, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as reactionEngine from '../engine/reaction.js'; import { and, eq } from 'drizzle-orm'; import { channels, messages } from '../db/schema.js'; +import { + getMessageObserverResource, + getObserverTokenFromContext, + observerAllowsChannel, + observerAllowsConversation, +} from '../engine/observerToken.js'; import { fanoutToChannel, fanoutToAgents, getDmParticipantAgentIds } from './fanout.js'; import { sendNodeDeliveriesForChannel } from '../engine/nodeDeliver.js'; import { runInBackground } from './background.js'; @@ -198,12 +204,25 @@ reactionRoutes.delete( // GET /v1/messages/:id/reactions - aggregated reactions reactionRoutes.get( '/messages/:id/reactions', - requireAuth, + requireWorkspaceRead(['messages:read', 'reactions:read']), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getMessageObserverResource(db, workspace.id, c.req.param('id')); + if (!resource) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } + const allowed = resource.conversation_id + ? observerAllowsConversation(observer, resource.conversation_id) + : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name }); + if (!allowed) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } + } const result = await reactionEngine.getReactions( db, workspace.id, diff --git a/packages/engine/src/routes/search.ts b/packages/engine/src/routes/search.ts index 4a7f4e81..e741d3d1 100644 --- a/packages/engine/src/routes/search.ts +++ b/packages/engine/src/routes/search.ts @@ -1,9 +1,13 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as searchEngine from '../engine/search.js'; +import { + filterObserverSearchResults, + getObserverTokenFromContext, +} from '../engine/observerToken.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; import { errorResponse } from '../lib/httpError.js'; import { jsonOk, parseQueryParams } from '../lib/httpResponse.js'; @@ -20,7 +24,7 @@ const searchQuerySchema = PaginationQuerySchema.extend({ // GET /v1/search?q=...&channel=...&from=...&limit=...&before=...&after=... searchRoutes.get( '/search', - requireAuth, + requireWorkspaceRead('search:read'), rateLimit, async (c) => { try { @@ -44,15 +48,21 @@ searchRoutes.get( before, after, }); + const visibleResults = await filterObserverSearchResults( + db, + workspace.id, + getObserverTokenFromContext(c), + results, + ); emitServerEvent(c, workspace.id, 'relaycast_server_search_executed', { query_length: q.trim().length, - result_count: results.length, + result_count: visibleResults.length, has_channel_filter: Boolean(channel), has_from_filter: Boolean(from), }); - return jsonOk(c, results); + return jsonOk(c, visibleResults); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/thread.ts b/packages/engine/src/routes/thread.ts index 8db064e0..780e0b35 100644 --- a/packages/engine/src/routes/thread.ts +++ b/packages/engine/src/routes/thread.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { jsonIdempotentOk, parseIdempotencyKey, runIdempotent } from '../middleware/idempotency.js'; import * as threadEngine from '../engine/thread.js'; @@ -9,6 +9,12 @@ import { resolveMailboxConfig } from '../engine/mailboxConfig.js'; import { publishWorkspaceEvent } from './fanout.js'; import { notifyDeliveryRejections, routeDeliveryOutcomes } from './deliveryRouting.js'; import { buildThreadReplyEventData } from '../engine/deliveryWire.js'; +import { + getMessageObserverResource, + getObserverTokenFromContext, + observerAllowsChannel, + observerAllowsConversation, +} from '../engine/observerToken.js'; import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; @@ -133,7 +139,7 @@ threadRoutes.post( // GET /v1/messages/:id/replies - get thread threadRoutes.get( '/messages/:id/replies', - requireAuth, + requireWorkspaceRead('threads:read'), rateLimit, async (c) => { try { @@ -146,6 +152,19 @@ threadRoutes.get( const { limit, before, after } = query.data; const parentId = c.req.param('id'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getMessageObserverResource(db, workspace.id, parentId); + if (!resource) { + return jsonError(c, 'message_not_found', 'Parent message not found', 404); + } + const allowed = resource.conversation_id + ? observerAllowsConversation(observer, resource.conversation_id) + : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name }); + if (!allowed) { + return jsonError(c, 'message_not_found', 'Parent message not found', 404); + } + } const result = await threadEngine.getThread( db, workspace.id, diff --git a/packages/engine/src/routes/workspace.ts b/packages/engine/src/routes/workspace.ts index 95d51345..a3e71bcf 100644 --- a/packages/engine/src/routes/workspace.ts +++ b/packages/engine/src/routes/workspace.ts @@ -3,12 +3,16 @@ import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; import { z } from 'zod'; import type { AppEnv } from '../env.js'; -import { requireWorkspaceKey } from '../middleware/auth.js'; +import { requireWorkspaceKey, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as workspaceEngine from '../engine/workspace.js'; import * as activityEngine from '../engine/activity.js'; import * as dmAllEngine from '../engine/dmAll.js'; import * as tokenRotateEngine from '../engine/tokenRotate.js'; +import { + getObserverTokenFromContext, + observerAllowsConversation, +} from '../engine/observerToken.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; import { errorResponse } from '../lib/httpError.js'; import { @@ -211,7 +215,7 @@ workspaceRoutes.delete('/workspace', requireWorkspaceKey, rateLimit, async (c) = }); // GET /activity — recent activity feed -workspaceRoutes.get('/activity', requireWorkspaceKey, rateLimit, async (c) => { +workspaceRoutes.get('/activity', requireWorkspaceRead('activity:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); @@ -229,11 +233,13 @@ workspaceRoutes.get('/activity', requireWorkspaceKey, rateLimit, async (c) => { }); // GET /dm/conversations/all — workspace-wide DM list -workspaceRoutes.get('/dm/conversations/all', requireWorkspaceKey, rateLimit, async (c) => { +workspaceRoutes.get('/dm/conversations/all', requireWorkspaceRead('dms:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); - const conversations = await dmAllEngine.listAllDmConversations(db, workspace.id); + const observer = getObserverTokenFromContext(c); + const conversations = (await dmAllEngine.listAllDmConversations(db, workspace.id)) + .filter((conversation) => observerAllowsConversation(observer, conversation.id)); return jsonOk(c, conversations); } catch (err: unknown) { return errorResponse(c, err); @@ -241,11 +247,14 @@ workspaceRoutes.get('/dm/conversations/all', requireWorkspaceKey, rateLimit, asy }); // GET /dm/conversations/:conversation_id/messages — DM messages by conversation -workspaceRoutes.get('/dm/conversations/:conversation_id/messages', requireWorkspaceKey, rateLimit, async (c) => { +workspaceRoutes.get('/dm/conversations/:conversation_id/messages', requireWorkspaceRead('dms:read', { allowAgent: false, allowNode: false }), rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const conversationId = c.req.param('conversation_id'); + if (!observerAllowsConversation(getObserverTokenFromContext(c), conversationId)) { + return jsonNotFound(c, 'dm_conversation_not_found', 'Conversation not found'); + } const query = parsePaginationQuery(c); if (!query.ok) { return query.response; diff --git a/packages/sdk-python/src/relay_sdk/models.py b/packages/sdk-python/src/relay_sdk/models.py index 85556fd9..86631d46 100644 --- a/packages/sdk-python/src/relay_sdk/models.py +++ b/packages/sdk-python/src/relay_sdk/models.py @@ -61,6 +61,65 @@ class TokenRotateResponse(BaseModel): token: str +# ── Observer tokens ─────────────────────────────────────────────── + +ObserverScope = Literal[ + "stream:read", + "messages:read", + "threads:read", + "dms:read", + "channels:read", + "search:read", + "agents:read", + "nodes:read", + "deliveries:read", + "activity:read", + "files:read", + "reactions:read", +] + + +class ObserverTokenFilters(BaseModel): + channel_ids: list[str] | None = None + channel_names: list[str] | None = None + include_dms: bool | None = None + dm_conversation_ids: list[str] | None = None + agent_ids: list[str] | None = None + event_types: list[str] | None = None + created_after: str | None = None + + +class CreateObserverTokenRequest(BaseModel): + name: str + scopes: list[ObserverScope] + description: str | None = None + filters: ObserverTokenFilters | None = None + expires_at: str | None = None + + +class UpdateObserverTokenRequest(BaseModel): + name: str | None = None + scopes: list[ObserverScope] | None = None + description: str | None = None + filters: ObserverTokenFilters | None = None + expires_at: str | None = None + + +class ObserverToken(BaseModel): + id: str + name: str + description: str | None = None + scopes: list[ObserverScope] + filters: ObserverTokenFilters = Field(default_factory=ObserverTokenFilters) + status: str + expires_at: str | None = None + created_at: str + updated_at: str | None = None + revoked_at: str | None = None + last_used_at: str | None = None + token: str | None = None + + # ── Workspace ───────────────────────────────────────────────────── class Workspace(BaseModel): diff --git a/packages/sdk-python/src/relay_sdk/relay.py b/packages/sdk-python/src/relay_sdk/relay.py index 093e3748..e0370eba 100644 --- a/packages/sdk-python/src/relay_sdk/relay.py +++ b/packages/sdk-python/src/relay_sdk/relay.py @@ -34,8 +34,11 @@ Agent, CreateAgentRequest, CreateAgentResponse, + CreateObserverTokenRequest, + ObserverToken, PublishToDirectoryRequest, TokenRotateResponse, + UpdateObserverTokenRequest, Workspace, ) @@ -172,6 +175,36 @@ def unbind_agent(self, name: str, agent_name: str) -> None: self._client.delete(f"/v1/nodes/{_enc(name)}/agents/{_enc(agent_name)}") +class _ObserverTokensNamespace: + """Sync observer-token operations.""" + + def __init__(self, client: HttpClient) -> None: + self._client = client + + def create(self, data: CreateObserverTokenRequest) -> ObserverToken: + result = self._client.post("/v1/observer-tokens", data.model_dump(exclude_none=True)) + return ObserverToken.model_validate(result) + + def list(self) -> list[ObserverToken]: + result = self._client.get("/v1/observer-tokens") + return [ObserverToken.model_validate(token) for token in result] + + def get(self, token_id: str) -> ObserverToken: + result = self._client.get(f"/v1/observer-tokens/{_enc(token_id)}") + return ObserverToken.model_validate(result) + + def update(self, token_id: str, data: UpdateObserverTokenRequest) -> ObserverToken: + result = self._client.patch(f"/v1/observer-tokens/{_enc(token_id)}", data.model_dump(exclude_none=True)) + return ObserverToken.model_validate(result) + + def rotate(self, token_id: str) -> ObserverToken: + result = self._client.post(f"/v1/observer-tokens/{_enc(token_id)}/rotate", {}) + return ObserverToken.model_validate(result) + + def revoke(self, token_id: str) -> None: + self._client.delete(f"/v1/observer-tokens/{_enc(token_id)}") + + class Relay: """Synchronous Relay client. @@ -205,6 +238,7 @@ def __init__( self.workspace = _WorkspaceNamespace(self._client) self.agents = _AgentsNamespace(self._client) self.nodes = _NodesNamespace(self._client) + self.observer_tokens = _ObserverTokensNamespace(self._client) def register_agent( @@ -478,6 +512,36 @@ async def unbind_agent(self, name: str, agent_name: str) -> None: await self._client.delete(f"/v1/nodes/{_enc(name)}/agents/{_enc(agent_name)}") +class _AsyncObserverTokensNamespace: + """Async observer-token operations.""" + + def __init__(self, client: AsyncHttpClient) -> None: + self._client = client + + async def create(self, data: CreateObserverTokenRequest) -> ObserverToken: + result = await self._client.post("/v1/observer-tokens", data.model_dump(exclude_none=True)) + return ObserverToken.model_validate(result) + + async def list(self) -> list[ObserverToken]: + result = await self._client.get("/v1/observer-tokens") + return [ObserverToken.model_validate(token) for token in result] + + async def get(self, token_id: str) -> ObserverToken: + result = await self._client.get(f"/v1/observer-tokens/{_enc(token_id)}") + return ObserverToken.model_validate(result) + + async def update(self, token_id: str, data: UpdateObserverTokenRequest) -> ObserverToken: + result = await self._client.patch(f"/v1/observer-tokens/{_enc(token_id)}", data.model_dump(exclude_none=True)) + return ObserverToken.model_validate(result) + + async def rotate(self, token_id: str) -> ObserverToken: + result = await self._client.post(f"/v1/observer-tokens/{_enc(token_id)}/rotate", {}) + return ObserverToken.model_validate(result) + + async def revoke(self, token_id: str) -> None: + await self._client.delete(f"/v1/observer-tokens/{_enc(token_id)}") + + class AsyncRelay: """Asynchronous Relay client. @@ -511,6 +575,7 @@ def __init__( self.workspace = _AsyncWorkspaceNamespace(self._client) self.agents = _AsyncAgentsNamespace(self._client) self.nodes = _AsyncNodesNamespace(self._client) + self.observer_tokens = _AsyncObserverTokensNamespace(self._client) async def register_agent( diff --git a/packages/sdk-python/src/relay_sdk/ws.py b/packages/sdk-python/src/relay_sdk/ws.py index 5a86fafd..3a878d1d 100644 --- a/packages/sdk-python/src/relay_sdk/ws.py +++ b/packages/sdk-python/src/relay_sdk/ws.py @@ -26,7 +26,7 @@ class WsClient: Usage:: - ws = WsClient(token="rk_xxx") + ws = WsClient(token="ot_live_xxx") ws.on("message.created", lambda e: print(e)) ws.subscribe(["general", "code-review"]) await ws.connect() # blocks until disconnect() is called diff --git a/packages/sdk-python/tests/test_relay.py b/packages/sdk-python/tests/test_relay.py index 90289231..4003313d 100644 --- a/packages/sdk-python/tests/test_relay.py +++ b/packages/sdk-python/tests/test_relay.py @@ -10,8 +10,11 @@ from relay_sdk.models import ( BindAgentToNodeRequest, CreateNodeRequest, + CreateObserverTokenRequest, HttpPushNodeDelivery, NodeDeliveryAuth, + ObserverTokenFilters, + UpdateObserverTokenRequest, ) BASE = "https://test.relay.dev" @@ -91,6 +94,21 @@ def ok(data): "updated_at": None, } +OBSERVER_TOKEN_DATA = { + "id": "ot_1", + "name": "dashboard", + "description": None, + "scopes": ["stream:read", "messages:read"], + "filters": {"channel_names": ["general"], "include_dms": False}, + "status": "active", + "expires_at": None, + "created_at": "2026-06-01T00:00:00Z", + "updated_at": None, + "revoked_at": None, + "last_used_at": None, + "token": "ot_live_secret", +} + class TestRelay: def test_constructor(self): @@ -236,6 +254,39 @@ def test_nodes_create_list_bind_and_unbind(self): assert r.nodes.unbind_agent("http-node", "billing-agent") is None assert unbind_route.called + @respx.mock + def test_observer_tokens_create_list_update_rotate_and_revoke(self): + create_route = respx.post(f"{BASE}/v1/observer-tokens").mock(return_value=ok(OBSERVER_TOKEN_DATA)) + r = Relay(KEY, base_url=BASE) + created = r.observer_tokens.create( + CreateObserverTokenRequest( + name="dashboard", + scopes=["stream:read", "messages:read"], + filters=ObserverTokenFilters(channel_names=["general"], include_dms=False), + ) + ) + assert created.token == "ot_live_secret" + assert created.filters.channel_names == ["general"] + create_body = json.loads(create_route.calls[0].request.content) + assert create_body["filters"] == {"channel_names": ["general"], "include_dms": False} + + respx.get(f"{BASE}/v1/observer-tokens").mock(return_value=ok([OBSERVER_TOKEN_DATA])) + assert r.observer_tokens.list()[0].id == "ot_1" + + respx.get(f"{BASE}/v1/observer-tokens/ot_1").mock(return_value=ok(OBSERVER_TOKEN_DATA)) + assert r.observer_tokens.get("ot_1").name == "dashboard" + + update_route = respx.patch(f"{BASE}/v1/observer-tokens/ot_1").mock(return_value=ok(OBSERVER_TOKEN_DATA)) + r.observer_tokens.update("ot_1", UpdateObserverTokenRequest(scopes=["messages:read"])) + assert json.loads(update_route.calls[0].request.content) == {"scopes": ["messages:read"]} + + respx.post(f"{BASE}/v1/observer-tokens/ot_1/rotate").mock(return_value=ok(OBSERVER_TOKEN_DATA)) + assert r.observer_tokens.rotate("ot_1").token == "ot_live_secret" + + revoke_route = respx.delete(f"{BASE}/v1/observer-tokens/ot_1").mock(return_value=httpx.Response(204)) + assert r.observer_tokens.revoke("ot_1") is None + assert revoke_route.called + @respx.mock def test_agents_register_or_rotate_registers_new_agent(self): respx.post(f"{BASE}/v1/agents").mock(return_value=ok(CREATE_AGENT_DATA)) @@ -377,6 +428,17 @@ async def test_nodes_async_create_and_bind(self): assert create_route.called assert bind_route.called + @pytest.mark.asyncio + @respx.mock + async def test_observer_tokens_async_create(self): + route = respx.post(f"{BASE}/v1/observer-tokens").mock(return_value=ok(OBSERVER_TOKEN_DATA)) + async with AsyncRelay(KEY, base_url=BASE) as r: + created = await r.observer_tokens.create( + CreateObserverTokenRequest(name="dashboard", scopes=["stream:read"]) + ) + assert created.token == "ot_live_secret" + assert route.called + @pytest.mark.asyncio @pytest.mark.parametrize("error_code", ["agent_already_exists", "name_conflict"]) @respx.mock diff --git a/packages/sdk-python/tests/test_ws.py b/packages/sdk-python/tests/test_ws.py index a0d901bd..fb3b378a 100644 --- a/packages/sdk-python/tests/test_ws.py +++ b/packages/sdk-python/tests/test_ws.py @@ -11,7 +11,7 @@ class TestWsClientInit: def test_default_base_url(self): - ws = WsClient(token="rk_xxx") + ws = WsClient(token="ot_xxx") assert ws._base_url == "wss://cast.agentrelay.com" def test_custom_base_url_https(self): @@ -222,20 +222,20 @@ def test_disconnect_cancels_ping_task(self): class TestWsClientOriginParams: @pytest.mark.asyncio async def test_connect_url_includes_origin_query_params(self): - ws = WsClient(token="at_xxx") + ws = WsClient(token="ot_xxx") with patch("relay_sdk.ws.websockets.connect", side_effect=RuntimeError("stop")) as connect_mock: with pytest.raises(RuntimeError): await ws._connect_once() url = connect_mock.call_args.args[0] assert "/v1/ws?" in url - assert "token=rk_xxx" in url + assert "token=ot_xxx" in url assert "origin_client=%40relaycast%2Fpython-sdk" in url assert f"origin_version={relay_sdk.SDK_VERSION}" in url @pytest.mark.asyncio async def test_connect_url_includes_agent_relay_distinct_id_query_param(self): - ws = WsClient(token="rk_xxx", agent_relay_distinct_id="abc123def4567890") + ws = WsClient(token="ot_xxx", agent_relay_distinct_id="abc123def4567890") with patch("relay_sdk.ws.websockets.connect", side_effect=RuntimeError("stop")) as connect_mock: with pytest.raises(RuntimeError): await ws._connect_once() @@ -245,7 +245,7 @@ async def test_connect_url_includes_agent_relay_distinct_id_query_param(self): @pytest.mark.asyncio async def test_connect_url_omits_invalid_agent_relay_distinct_id_query_param(self): - ws = WsClient(token="rk_xxx", agent_relay_distinct_id="evil\r\nX-Inject: bad") + ws = WsClient(token="ot_xxx", agent_relay_distinct_id="evil\r\nX-Inject: bad") with patch("relay_sdk.ws.websockets.connect", side_effect=RuntimeError("stop")) as connect_mock: with pytest.raises(RuntimeError): await ws._connect_once() diff --git a/packages/sdk-rust/src/lib.rs b/packages/sdk-rust/src/lib.rs index d7bd7b58..1c373d0e 100644 --- a/packages/sdk-rust/src/lib.rs +++ b/packages/sdk-rust/src/lib.rs @@ -72,7 +72,7 @@ //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! let mut ws = WsClient::new(WsClientOptions::new("rk_live_workspace_key")); +//! let mut ws = WsClient::new(WsClientOptions::new("ot_live_observer_token")); //! let mut raw_events = ws.subscribe_raw_events(); //! ws.connect().await?; //! diff --git a/packages/sdk-rust/src/relay.rs b/packages/sdk-rust/src/relay.rs index 080e64a9..f86d18bf 100644 --- a/packages/sdk-rust/src/relay.rs +++ b/packages/sdk-rust/src/relay.rs @@ -160,6 +160,71 @@ impl RelayCast { self.client.delete("/v1/workspace", None).await } + // === Observer Tokens === + + /// Create a scoped observer token. The raw token is returned only from this + /// response and rotate responses. + pub async fn create_observer_token( + &self, + request: CreateObserverTokenRequest, + ) -> Result { + self.client + .post("/v1/observer-tokens", Some(request), None) + .await + } + + /// List observer tokens without returning token material. + pub async fn list_observer_tokens(&self) -> Result> { + self.client.get("/v1/observer-tokens", None, None).await + } + + /// Get observer token metadata by id. + pub async fn get_observer_token(&self, id: &str) -> Result { + self.client + .get( + &format!("/v1/observer-tokens/{}", urlencoding::encode(id)), + None, + None, + ) + .await + } + + /// Update observer token scopes, filters, or metadata. + pub async fn update_observer_token( + &self, + id: &str, + request: UpdateObserverTokenRequest, + ) -> Result { + self.client + .patch( + &format!("/v1/observer-tokens/{}", urlencoding::encode(id)), + Some(request), + None, + ) + .await + } + + /// Rotate an observer token and return the new token material. + pub async fn rotate_observer_token(&self, id: &str) -> Result { + self.client + .post( + &format!("/v1/observer-tokens/{}/rotate", urlencoding::encode(id)), + Some(serde_json::json!({})), + None, + ) + .await + } + + /// Revoke an observer token. + pub async fn revoke_observer_token(&self, id: &str) -> Result<()> { + self.client + .delete( + &format!("/v1/observer-tokens/{}", urlencoding::encode(id)), + None, + ) + .await + } + // === System Prompt === /// Get the workspace system prompt. diff --git a/packages/sdk-rust/src/types.rs b/packages/sdk-rust/src/types.rs index dd86168f..1333ea02 100644 --- a/packages/sdk-rust/src/types.rs +++ b/packages/sdk-rust/src/types.rs @@ -134,6 +134,71 @@ pub struct TokenRotateResponse { pub token: String, } +// === Observer tokens === + +pub type ObserverScope = String; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ObserverTokenFilters { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub channel_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub channel_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_dms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dm_conversation_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub event_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_after: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CreateObserverTokenRequest { + pub name: String, + pub scopes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct UpdateObserverTokenRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObserverToken { + pub id: String, + pub name: String, + pub description: Option, + pub scopes: Vec, + #[serde(default)] + pub filters: ObserverTokenFilters, + pub status: String, + pub expires_at: Option, + pub created_at: String, + pub updated_at: Option, + pub revoked_at: Option, + pub last_used_at: Option, + pub token: Option, +} + // === Agents === #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/packages/sdk-swift/Sources/Relaycast/Models.swift b/packages/sdk-swift/Sources/Relaycast/Models.swift index 96b8de90..fe84d5c0 100644 --- a/packages/sdk-swift/Sources/Relaycast/Models.swift +++ b/packages/sdk-swift/Sources/Relaycast/Models.swift @@ -125,6 +125,93 @@ public struct TokenRotateResponse: Codable, Equatable, Sendable { public let token: String } +public struct ObserverTokenFilters: Codable, Equatable, Sendable { + public var channelIds: [String]? + public var channelNames: [String]? + public var includeDms: Bool? + public var dmConversationIds: [String]? + public var agentIds: [String]? + public var eventTypes: [String]? + public var createdAfter: String? + + public init( + channelIds: [String]? = nil, + channelNames: [String]? = nil, + includeDms: Bool? = nil, + dmConversationIds: [String]? = nil, + agentIds: [String]? = nil, + eventTypes: [String]? = nil, + createdAfter: String? = nil + ) { + self.channelIds = channelIds + self.channelNames = channelNames + self.includeDms = includeDms + self.dmConversationIds = dmConversationIds + self.agentIds = agentIds + self.eventTypes = eventTypes + self.createdAfter = createdAfter + } +} + +public struct CreateObserverTokenRequest: Codable, Equatable, Sendable { + public var name: String + public var scopes: [String] + public var description: String? + public var filters: ObserverTokenFilters? + public var expiresAt: String? + + public init( + name: String, + scopes: [String], + description: String? = nil, + filters: ObserverTokenFilters? = nil, + expiresAt: String? = nil + ) { + self.name = name + self.scopes = scopes + self.description = description + self.filters = filters + self.expiresAt = expiresAt + } +} + +public struct UpdateObserverTokenRequest: Codable, Equatable, Sendable { + public var name: String? + public var scopes: [String]? + public var description: String? + public var filters: ObserverTokenFilters? + public var expiresAt: String? + + public init( + name: String? = nil, + scopes: [String]? = nil, + description: String? = nil, + filters: ObserverTokenFilters? = nil, + expiresAt: String? = nil + ) { + self.name = name + self.scopes = scopes + self.description = description + self.filters = filters + self.expiresAt = expiresAt + } +} + +public struct ObserverToken: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let description: String? + public let scopes: [String] + public let filters: ObserverTokenFilters + public let status: String + public let expiresAt: String? + public let createdAt: String + public let updatedAt: String? + public let revokedAt: String? + public let lastUsedAt: String? + public let token: String? +} + // MARK: - Agents public enum AgentType: String, Codable, Sendable { diff --git a/packages/sdk-swift/Sources/Relaycast/RelayCast.swift b/packages/sdk-swift/Sources/Relaycast/RelayCast.swift index e367fce0..aaf63369 100644 --- a/packages/sdk-swift/Sources/Relaycast/RelayCast.swift +++ b/packages/sdk-swift/Sources/Relaycast/RelayCast.swift @@ -89,6 +89,7 @@ public final class RelayCast: @unchecked Sendable { private var workspaceIDHint: String? public lazy var workspace = RelayWorkspaceService(relay: self) + public lazy var observerTokens = RelayObserverTokensService(relay: self) public lazy var systemPrompt = RelaySystemPromptService(relay: self) public lazy var channels = RelayChannelsService(relay: self) public lazy var messages = RelayMessagesService(relay: self) @@ -398,6 +399,38 @@ public final class RelayWorkspaceService: @unchecked Sendable { } } +public final class RelayObserverTokensService: @unchecked Sendable { + private unowned let relay: RelayCast + + init(relay: RelayCast) { + self.relay = relay + } + + public func create(_ data: CreateObserverTokenRequest) async throws -> ObserverToken { + try await relay.client.post("/v1/observer-tokens", body: data) + } + + public func list() async throws -> [ObserverToken] { + try await relay.client.get("/v1/observer-tokens") + } + + public func get(_ id: String) async throws -> ObserverToken { + try await relay.client.get("/v1/observer-tokens/\(percentEncodePathComponent(id))") + } + + public func update(_ id: String, data: UpdateObserverTokenRequest) async throws -> ObserverToken { + try await relay.client.patch("/v1/observer-tokens/\(percentEncodePathComponent(id))", body: data) + } + + public func rotate(_ id: String) async throws -> ObserverToken { + try await relay.client.post("/v1/observer-tokens/\(percentEncodePathComponent(id))/rotate", body: EmptyRequest()) + } + + public func revoke(_ id: String) async throws { + _ = try await relay.client.delete("/v1/observer-tokens/\(percentEncodePathComponent(id))") + } +} + public final class RelaySystemPromptService: @unchecked Sendable { private unowned let relay: RelayCast diff --git a/packages/sdk-typescript/src/__tests__/relay.test.ts b/packages/sdk-typescript/src/__tests__/relay.test.ts index cfad849d..dfa9526e 100644 --- a/packages/sdk-typescript/src/__tests__/relay.test.ts +++ b/packages/sdk-typescript/src/__tests__/relay.test.ts @@ -68,13 +68,13 @@ describe('RelayCast', () => { }); describe('workspace realtime', () => { - it('connect() opens /v1/ws with the workspace key and SDK origin metadata', async () => { + it('connect() opens /v1/ws with an observer token and SDK origin metadata', async () => { const { RelayCast } = await import('../relay.js'); const relay = new RelayCast({ - apiKey: 'rk_live_test123', + apiKey: 'ot_live_test123', baseUrl: 'http://localhost:8080', ws: { - token: 'rk_live_wrong', + token: 'ot_live_wrong', baseUrl: 'https://wrong.example', } as any, }); @@ -85,7 +85,7 @@ describe('RelayCast', () => { const url = new URL(MockWebSocket.instances[0]!.url); expect(url.origin).toBe('ws://localhost:8080'); expect(url.pathname).toBe('/v1/ws'); - expect(url.searchParams.get('token')).toBe('rk_live_test123'); + expect(url.searchParams.get('token')).toBe('ot_live_test123'); expect(url.searchParams.get('origin_client')).toBe('@relaycast/sdk'); expect(url.searchParams.get('origin_version')).toBeDefined(); expect(mockFetch).not.toHaveBeenCalled(); @@ -95,7 +95,7 @@ describe('RelayCast', () => { it('connect() is idempotent and disconnect() allows a fresh workspace socket with existing handlers', async () => { const { RelayCast } = await import('../relay.js'); - const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + const relay = new RelayCast({ apiKey: 'ot_live_test123' }); const handler = vi.fn(); relay.on.messageCreated(handler); @@ -124,7 +124,7 @@ describe('RelayCast', () => { it('on.messageCreated fires with camelized workspace stream events', async () => { vi.useFakeTimers(); const { RelayCast } = await import('../relay.js'); - const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + const relay = new RelayCast({ apiKey: 'ot_live_test123' }); const handler = vi.fn(); relay.on.messageCreated(handler); @@ -154,7 +154,7 @@ describe('RelayCast', () => { vi.useFakeTimers(); const { RelayCast } = await import('../relay.js'); const relay = new RelayCast({ - apiKey: 'rk_live_test123', + apiKey: 'ot_live_test123', ws: { maxReconnectAttempts: 0, reconnectJitter: false }, }); const permanentlyDisconnected = vi.fn(); @@ -176,7 +176,7 @@ describe('RelayCast', () => { it('on.actionCompleted fires with camelized action completion events', async () => { vi.useFakeTimers(); const { RelayCast } = await import('../relay.js'); - const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + const relay = new RelayCast({ apiKey: 'ot_live_test123' }); relay.connect(); const ws = MockWebSocket.instances[0]!; ws.simulateOpen(); @@ -208,7 +208,7 @@ describe('RelayCast', () => { it('on.any returns an unsubscribe function for workspace events', async () => { vi.useFakeTimers(); const { RelayCast } = await import('../relay.js'); - const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + const relay = new RelayCast({ apiKey: 'ot_live_test123' }); relay.connect(); const ws = MockWebSocket.instances[0]!; ws.simulateOpen(); @@ -230,7 +230,7 @@ describe('RelayCast', () => { vi.useFakeTimers(); const { RelayCast } = await import('../relay.js'); const relay = new RelayCast({ - apiKey: 'rk_live_test123', + apiKey: 'ot_live_test123', ws: { reconnectJitter: false }, }); relay.connect(); @@ -248,7 +248,7 @@ describe('RelayCast', () => { it('allows registering event handlers before connect()', async () => { const { RelayCast } = await import('../relay.js'); - const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + const relay = new RelayCast({ apiKey: 'ot_live_test123' }); const handler = vi.fn(); expect(() => relay.on.messageCreated(handler)).not.toThrow(); @@ -317,6 +317,66 @@ describe('RelayCast', () => { }); + describe('observerTokens', () => { + it('create() calls POST /v1/observer-tokens with snake_case filters', async () => { + const { RelayCast } = await import('../relay.js'); + const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + + mockFetch.mockImplementation(() => mockResponse({ + id: 'ot_1', + name: 'dash', + token: 'ot_live_secret', + scopes: ['stream:read'], + filters: { channel_names: ['general'], include_dms: false }, + status: 'active', + description: null, + expires_at: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: null, + revoked_at: null, + last_used_at: null, + })); + + const result = await relay.observerTokens.create({ + name: 'dash', + scopes: ['stream:read'], + filters: { channelNames: ['general'], includeDms: false }, + }); + + expect(result.token).toBe('ot_live_secret'); + expect(result.filters.channelNames).toEqual(['general']); + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://cast.agentrelay.com/v1/observer-tokens'); + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ + name: 'dash', + scopes: ['stream:read'], + filters: { channel_names: ['general'], include_dms: false }, + })); + }); + + it('list/get/update/rotate/revoke call observer token endpoints', async () => { + const { RelayCast } = await import('../relay.js'); + const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + mockFetch.mockImplementation(() => mockResponse({ id: 'ot_1' })); + + await relay.observerTokens.list(); + await relay.observerTokens.get('ot_1'); + await relay.observerTokens.update('ot_1', { scopes: ['messages:read'] }); + await relay.observerTokens.rotate('ot_1'); + mockFetch.mockImplementationOnce(() => mockResponse(undefined, true, 204)); + await relay.observerTokens.revoke('ot_1'); + + expect(mockFetch.mock.calls.map((call) => [call[0], call[1].method])).toEqual([ + ['https://cast.agentrelay.com/v1/observer-tokens', 'GET'], + ['https://cast.agentrelay.com/v1/observer-tokens/ot_1', 'GET'], + ['https://cast.agentrelay.com/v1/observer-tokens/ot_1', 'PATCH'], + ['https://cast.agentrelay.com/v1/observer-tokens/ot_1/rotate', 'POST'], + ['https://cast.agentrelay.com/v1/observer-tokens/ot_1', 'DELETE'], + ]); + }); + }); + describe('agents', () => { it('register() calls POST /v1/agents', async () => { const { RelayCast } = await import('../relay.js'); diff --git a/packages/sdk-typescript/src/relay.ts b/packages/sdk-typescript/src/relay.ts index 7c98d59e..8360f9c8 100644 --- a/packages/sdk-typescript/src/relay.ts +++ b/packages/sdk-typescript/src/relay.ts @@ -6,11 +6,14 @@ import type { AgentPresenceInfo, Channel, ChannelMemberInfo, + CreateObserverTokenRequest, CreateAgentRequest, CreateAgentResponse, UpdateAgentRequest, + UpdateObserverTokenRequest, UpdateWorkspaceRequest, Workspace, + ObserverToken, CreateWorkspaceResponse, WorkspaceLookup, SystemPrompt, @@ -127,6 +130,10 @@ import { AGENT_RELAY_DISTINCT_ID_HEADER, SDK_ORIGIN, sanitizeAgentRelayDistinctI import { camelizeKeys } from './casing.js'; export interface RelayCastOptions { + /** + * Workspace admin key (`rk_live_*`) for administration or observer token + * (`ot_live_*`) for read-only REST and workspace realtime observation. + */ apiKey: string; baseUrl?: string; retryPolicy?: RetryPolicyInput; @@ -593,6 +600,21 @@ export class RelayCast { delete: (): Promise => this.client.delete('/v1/workspace'), }; + observerTokens = { + create: (data: CreateObserverTokenRequest): Promise => + this.client.post('/v1/observer-tokens', data), + list: (): Promise => + this.client.get('/v1/observer-tokens'), + get: (id: string): Promise => + this.client.get(`/v1/observer-tokens/${encodeURIComponent(id)}`), + update: (id: string, data: UpdateObserverTokenRequest): Promise => + this.client.patch(`/v1/observer-tokens/${encodeURIComponent(id)}`, data), + rotate: (id: string): Promise => + this.client.post(`/v1/observer-tokens/${encodeURIComponent(id)}/rotate`, {}), + revoke: (id: string): Promise => + this.client.delete(`/v1/observer-tokens/${encodeURIComponent(id)}`), + }; + systemPrompt = { get: (): Promise => this.client.get('/v1/workspace/system-prompt'), set: (data: SetSystemPromptRequest): Promise => diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index 270a95c8..32c540d3 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -627,6 +627,62 @@ export type UpdateChannelRequest = Camelize; export type UpdateWorkspaceRequest = Camelize; export type UploadRequest = Camelize; export type UploadResponse = Camelize; + +export type ObserverScope = + | 'stream:read' + | 'messages:read' + | 'threads:read' + | 'dms:read' + | 'channels:read' + | 'search:read' + | 'agents:read' + | 'nodes:read' + | 'deliveries:read' + | 'activity:read' + | 'files:read' + | 'reactions:read'; + +export interface ObserverTokenFilters { + channelIds?: string[]; + channelNames?: string[]; + includeDms?: boolean; + dmConversationIds?: string[]; + agentIds?: string[]; + eventTypes?: string[]; + createdAfter?: string; +} + +export interface CreateObserverTokenRequest { + name: string; + description?: string | null; + scopes: ObserverScope[]; + filters?: ObserverTokenFilters; + expiresAt?: string | null; +} + +export interface UpdateObserverTokenRequest { + name?: string; + description?: string | null; + scopes?: ObserverScope[]; + filters?: ObserverTokenFilters; + expiresAt?: string | null; +} + +export interface ObserverToken { + id: string; + name: string; + description: string | null; + scopes: ObserverScope[]; + filters: ObserverTokenFilters; + status: string; + expiresAt: string | null; + createdAt: string; + updatedAt: string | null; + revokedAt: string | null; + lastUsedAt: string | null; + token?: string; +} + export type ActionInvokedEvent = Camelize; export type ActionCompletedEvent = Camelize; export type ActionDeniedEvent = Camelize; From 2b44cce89651994b9540a229c7bec9a102fe4624 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 08:33:00 -0400 Subject: [PATCH 02/15] Add observer token SDK parity coverage --- packages/sdk-rust/src/lib.rs | 5 + packages/sdk-rust/tests/parity.rs | 133 +++++++++++++++++- .../Tests/RelaycastTests/RelaycastTests.swift | 97 +++++++++++++ 3 files changed, 228 insertions(+), 7 deletions(-) diff --git a/packages/sdk-rust/src/lib.rs b/packages/sdk-rust/src/lib.rs index 1c373d0e..a695329b 100644 --- a/packages/sdk-rust/src/lib.rs +++ b/packages/sdk-rust/src/lib.rs @@ -242,6 +242,9 @@ pub use types::{ MessageReadEvent, MessageUpdatedEvent, MessageWithMeta, + ObserverScope, + ObserverToken, + ObserverTokenFilters, PostMessageRequest, // Reactions ReactionGroup, @@ -263,6 +266,8 @@ pub use types::{ TokenRotateResponse, UpdateAgentRequest, UpdateChannelRequest, + CreateObserverTokenRequest, + UpdateObserverTokenRequest, UpdateWorkspaceRequest, UploadRequest, UploadResponse, diff --git a/packages/sdk-rust/tests/parity.rs b/packages/sdk-rust/tests/parity.rs index 4eeafc87..9d0714d3 100644 --- a/packages/sdk-rust/tests/parity.rs +++ b/packages/sdk-rust/tests/parity.rs @@ -1,13 +1,14 @@ use relaycast::{ ActionInvocationStatus, AgentClient, BindAgentToNodeRequest, ClientOptions, CompleteInvocationRequest, CreateAgentRequest, CreateChannelRequest, CreateNodeRequest, - CreateSubscriptionRequest, CreateTriggerRequest, CreateWebhookRequest, DeferDeliveryRequest, - DeliveryStatus, DmConversationSummary, EmitSessionEventRequest, FailDeliveryRequest, - HttpClient, HttpPushNodeDelivery, ListDeliveriesOptions, ListSessionEventsQuery, - MessageInjectionMode, MessageListQuery, MonitorCertificationRequest, NodeDeliveryAuth, - NodeDeliveryConfig, NodeListQuery, RateDirectoryAgentRequest, RegisterA2aOptions, - RegisterActionRequest, RelayCast, RelayCastOptions, ReleaseAgentRequest, RouteFeedbackRequest, - SearchDirectoryQuery, SpawnAgentRequest, SubmitCertificationRequest, + CreateObserverTokenRequest, CreateSubscriptionRequest, CreateTriggerRequest, + CreateWebhookRequest, DeferDeliveryRequest, DeliveryStatus, DmConversationSummary, + EmitSessionEventRequest, FailDeliveryRequest, HttpClient, HttpPushNodeDelivery, + ListDeliveriesOptions, ListSessionEventsQuery, MessageInjectionMode, MessageListQuery, + MonitorCertificationRequest, NodeDeliveryAuth, NodeDeliveryConfig, NodeListQuery, + ObserverTokenFilters, RateDirectoryAgentRequest, RegisterA2aOptions, RegisterActionRequest, + RelayCast, RelayCastOptions, ReleaseAgentRequest, RouteFeedbackRequest, SearchDirectoryQuery, + SpawnAgentRequest, SubmitCertificationRequest, UpdateObserverTokenRequest, UpdateRoutingConfigRequest, WebhookTriggerRequest, WsClient, WsClientOptions, WsEvent, }; use serde_json::json; @@ -2139,6 +2140,124 @@ async fn routing_and_skills_use_expected_endpoints() { assert_eq!(skills[0].skill_name, "summarize"); } +#[tokio::test] +async fn observer_tokens_use_expected_endpoints() { + let server = MockServer::start().await; + let relay = RelayCast::new(RelayCastOptions::new("rk_live_test").with_base_url(server.uri())) + .expect("failed to create relay client"); + + let observer_payload = json!({ + "id": "ot_1", + "name": "dashboard", + "description": null, + "scopes": ["stream:read", "messages:read"], + "filters": { "channel_names": ["general"], "include_dms": false }, + "status": "active", + "expires_at": null, + "created_at": "2026-06-01T00:00:00.000Z", + "updated_at": null, + "revoked_at": null, + "last_used_at": null, + "token": "ot_live_secret" + }); + + Mock::given(method("POST")) + .and(path("/v1/observer-tokens")) + .and(body_json(json!({ + "name": "dashboard", + "scopes": ["stream:read", "messages:read"], + "filters": { "channel_names": ["general"], "include_dms": false } + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "ok": true, + "data": observer_payload + }))) + .expect(1) + .mount(&server) + .await; + let created = relay + .create_observer_token(CreateObserverTokenRequest { + name: "dashboard".to_string(), + scopes: vec!["stream:read".to_string(), "messages:read".to_string()], + description: None, + filters: Some(ObserverTokenFilters { + channel_names: vec!["general".to_string()], + include_dms: Some(false), + ..Default::default() + }), + expires_at: None, + }) + .await + .expect("create_observer_token failed"); + assert_eq!(created.token.as_deref(), Some("ot_live_secret")); + + Mock::given(method("GET")) + .and(path("/v1/observer-tokens")) + .respond_with(ok(json!([observer_payload.clone()]))) + .expect(1) + .mount(&server) + .await; + let tokens = relay + .list_observer_tokens() + .await + .expect("list_observer_tokens failed"); + assert_eq!(tokens[0].name, "dashboard"); + + Mock::given(method("GET")) + .and(path("/v1/observer-tokens/ot_1")) + .respond_with(ok(observer_payload.clone())) + .expect(1) + .mount(&server) + .await; + let fetched = relay + .get_observer_token("ot_1") + .await + .expect("get_observer_token failed"); + assert_eq!(fetched.id, "ot_1"); + + Mock::given(method("PATCH")) + .and(path("/v1/observer-tokens/ot_1")) + .and(body_json(json!({ "scopes": ["messages:read"] }))) + .respond_with(ok(observer_payload.clone())) + .expect(1) + .mount(&server) + .await; + relay + .update_observer_token( + "ot_1", + UpdateObserverTokenRequest { + scopes: Some(vec!["messages:read".to_string()]), + ..Default::default() + }, + ) + .await + .expect("update_observer_token failed"); + + Mock::given(method("POST")) + .and(path("/v1/observer-tokens/ot_1/rotate")) + .and(body_json(json!({}))) + .respond_with(ok(observer_payload.clone())) + .expect(1) + .mount(&server) + .await; + let rotated = relay + .rotate_observer_token("ot_1") + .await + .expect("rotate_observer_token failed"); + assert_eq!(rotated.token.as_deref(), Some("ot_live_secret")); + + Mock::given(method("DELETE")) + .and(path("/v1/observer-tokens/ot_1")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + relay + .revoke_observer_token("ot_1") + .await + .expect("revoke_observer_token failed"); +} + #[tokio::test] async fn nodes_and_triggers_use_expected_endpoints() { let server = MockServer::start().await; diff --git a/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift b/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift index 38f791d6..740b91f7 100644 --- a/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift +++ b/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift @@ -417,6 +417,83 @@ final class RelaycastTests: XCTestCase { try await relay.nodes.unbindAgent("http-node", agentName: "billing-agent") } + func testObserverTokensCreateListUpdateRotateAndRevoke() async throws { + let session = makeMockSession() + let relay = try RelayCast( + options: RelayCastOptions(apiKey: "rk_test", baseURL: "https://relay.test", retryPolicy: RetryPolicy(maxRetries: 0)), + session: session + ) + + MockURLProtocol.handler = { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/observer-tokens"): + let body = try XCTUnwrap(requestBodyData(request)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["name"] as? String, "dashboard") + XCTAssertEqual(json["scopes"] as? [String], ["stream:read", "messages:read"]) + let filters = try XCTUnwrap(json["filters"] as? [String: Any]) + XCTAssertEqual(filters["channel_names"] as? [String], ["general"]) + XCTAssertEqual(filters["include_dms"] as? Bool, false) + return jsonResponse([ + "ok": true, + "data": observerTokenData(token: "ot_live_secret") + ], status: 201) + case ("GET", "/v1/observer-tokens"): + return jsonResponse([ + "ok": true, + "data": [observerTokenData(token: NSNull())] + ]) + case ("GET", "/v1/observer-tokens/ot_1"): + return jsonResponse([ + "ok": true, + "data": observerTokenData(token: NSNull()) + ]) + case ("PATCH", "/v1/observer-tokens/ot_1"): + let body = try XCTUnwrap(requestBodyData(request)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["scopes"] as? [String], ["messages:read"]) + return jsonResponse([ + "ok": true, + "data": observerTokenData(token: NSNull()) + ]) + case ("POST", "/v1/observer-tokens/ot_1/rotate"): + return jsonResponse([ + "ok": true, + "data": observerTokenData(token: "ot_live_rotated") + ]) + case ("DELETE", "/v1/observer-tokens/ot_1"): + return ( + HTTPURLResponse(url: URL(string: "https://relay.test")!, statusCode: 204, httpVersion: nil, headerFields: nil)!, + Data() + ) + default: + return jsonResponse(["ok": false, "error": ["code": "not_found", "message": "Missing"]], status: 404) + } + } + + let created = try await relay.observerTokens.create(CreateObserverTokenRequest( + name: "dashboard", + scopes: ["stream:read", "messages:read"], + filters: ObserverTokenFilters(channelNames: ["general"], includeDms: false) + )) + XCTAssertEqual(created.token, "ot_live_secret") + XCTAssertEqual(created.filters.channelNames, ["general"]) + + let listed = try await relay.observerTokens.list() + XCTAssertEqual(listed[0].id, "ot_1") + + let fetched = try await relay.observerTokens.get("ot_1") + XCTAssertEqual(fetched.name, "dashboard") + + let updated = try await relay.observerTokens.update("ot_1", data: UpdateObserverTokenRequest(scopes: ["messages:read"])) + XCTAssertEqual(updated.scopes, ["stream:read", "messages:read"]) + + let rotated = try await relay.observerTokens.rotate("ot_1") + XCTAssertEqual(rotated.token, "ot_live_rotated") + + try await relay.observerTokens.revoke("ot_1") + } + func testCreateNodeRequestEncodesRawDeliveryConfig() throws { let request = CreateNodeRequest( name: "custom-node", @@ -655,6 +732,26 @@ private func requestBodyData(_ request: URLRequest) -> Data? { return data } +private func observerTokenData(token: Any) -> [String: Any] { + [ + "id": "ot_1", + "name": "dashboard", + "description": NSNull(), + "scopes": ["stream:read", "messages:read"], + "filters": [ + "channel_names": ["general"], + "include_dms": false + ], + "status": "active", + "expires_at": NSNull(), + "created_at": "2026-06-01T00:00:00Z", + "updated_at": NSNull(), + "revoked_at": NSNull(), + "last_used_at": NSNull(), + "token": token + ] +} + private func jsonResponse(_ object: [String: Any], status: Int = 200) -> (HTTPURLResponse, Data) { let data = try! JSONSerialization.data(withJSONObject: object) let response = HTTPURLResponse( From 3c115b0793d2b2fc5e2e87073e1a9d63224ee2cc Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 08:47:06 -0400 Subject: [PATCH 03/15] Address observer token review feedback --- .../src/__tests__/conformance/node.test.ts | 22 ++++ .../conformance/observerToken.test.ts | 124 ++++++++++++++++++ packages/engine/src/engine/activity.ts | 9 +- packages/engine/src/engine/message.ts | 14 +- packages/engine/src/engine/observerToken.ts | 95 ++++++++++---- packages/engine/src/engine/search.ts | 1 + packages/engine/src/engine/thread.ts | 8 +- packages/engine/src/routes/message.ts | 9 +- packages/engine/src/routes/observerToken.ts | 4 +- packages/engine/src/routes/reaction.ts | 7 +- packages/engine/src/routes/search.ts | 5 +- packages/engine/src/routes/thread.ts | 16 ++- packages/engine/src/routes/workspace.ts | 17 ++- 13 files changed, 281 insertions(+), 50 deletions(-) diff --git a/packages/engine/src/__tests__/conformance/node.test.ts b/packages/engine/src/__tests__/conformance/node.test.ts index 7f08a8a3..2719a7a8 100644 --- a/packages/engine/src/__tests__/conformance/node.test.ts +++ b/packages/engine/src/__tests__/conformance/node.test.ts @@ -444,6 +444,28 @@ describe('node adapter conformance', () => { }), }), ]); + + bobSock.received.length = 0; + const emailReplyRes = await stack.app.request(`/v1/messages/${parent.data.id}/replies`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'please email alice@bob.com' }), + }); + expect(emailReplyRes.status).toBeLessThan(300); + const emailReply = await emailReplyRes.json() as { data: { id: string } }; + + const emailRows = await stack.runtime.deps.db + .select({ id: deliveries.id, reason: deliveries.reason }) + .from(deliveries) + .where(and( + eq(deliveries.workspaceId, ws.workspaceId), + eq(deliveries.messageId, emailReply.data.id), + eq(deliveries.agentId, bob.agentId), + )); + + expect(emailRows).toHaveLength(0); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(deliverFramesOfType(bobSock, 'thread.reply')).toHaveLength(0); }); it('replays queued direct-node deliveries when the agent socket reconnects', async () => { diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index d10b3650..c1899aac 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -68,9 +68,28 @@ describe('observer tokens', () => { expect(revoke.status).toBe(204); }); + it('rejects non-ISO observer token timestamps', async () => { + const ws = await createWorkspace(stack.app, 'observer-timestamp-validation-ws'); + const res = await stack.app.request('/v1/observer-tokens', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ + name: 'bad-time', + scopes: ['messages:read'], + expires_at: 'June 1, 2026', + }), + }); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + ok: false, + error: { code: 'invalid_request' }, + }); + }); + it('gates REST reads by scope and channel filters and rejects observer writes', async () => { const ws = await createWorkspace(stack.app, 'observer-rest-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); const createTeam = await stack.app.request('/v1/channels', { method: 'POST', @@ -85,6 +104,14 @@ describe('observer tokens', () => { body: JSON.stringify({ text: 'visible general message' }), }); expect(general.status).toBe(201); + const generalBody = await general.json() as { data: { id: string } }; + + const bobGeneral = await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, + body: JSON.stringify({ text: 'hidden bob message' }), + }); + expect(bobGeneral.status).toBe(201); const team = await stack.app.request('/v1/channels/team-chat/messages', { method: 'POST', @@ -121,6 +148,71 @@ describe('observer tokens', () => { expect(searchBody.data.map((result) => result.text)).not.toContain('hidden team message'); expect(searchBody.data.every((result) => result.channel_name === 'general')).toBe(true); + const aliceOnly = await createObserverToken(stack, ws.workspaceKey, { + name: 'alice-reader', + scopes: ['messages:read', 'search:read'], + filters: { channel_names: ['general'], agent_ids: [alice.agentId] }, + }); + const filteredMessages = await stack.app.request('/v1/channels/general/messages', { + headers: { authorization: `Bearer ${aliceOnly.data.token}` }, + }); + const filteredBody = await filteredMessages.json() as { data: Array<{ text: string }> }; + expect(filteredMessages.status).toBe(200); + expect(filteredBody.data.map((message) => message.text)).toContain('visible general message'); + expect(filteredBody.data.map((message) => message.text)).not.toContain('hidden bob message'); + + const filteredSearch = await stack.app.request('/v1/search?q=message', { + headers: { authorization: `Bearer ${aliceOnly.data.token}` }, + }); + const filteredSearchBody = await filteredSearch.json() as { data: Array<{ text: string }> }; + expect(filteredSearch.status).toBe(200); + expect(filteredSearchBody.data.map((result) => result.text)).toContain('visible general message'); + expect(filteredSearchBody.data.map((result) => result.text)).not.toContain('hidden bob message'); + + const futureOnly = await createObserverToken(stack, ws.workspaceKey, { + name: 'future-reader', + scopes: ['messages:read', 'search:read', 'reactions:read'], + filters: { channel_names: ['general'], created_after: '2999-01-01T00:00:00.000Z' }, + }); + const futureMessages = await stack.app.request('/v1/channels/general/messages', { + headers: { authorization: `Bearer ${futureOnly.data.token}` }, + }); + const futureMessagesBody = await futureMessages.json() as { data: unknown[] }; + expect(futureMessages.status).toBe(200); + expect(futureMessagesBody.data).toHaveLength(0); + + const futureSingle = await stack.app.request(`/v1/messages/${generalBody.data.id}`, { + headers: { authorization: `Bearer ${futureOnly.data.token}` }, + }); + expect(futureSingle.status).toBe(404); + + const futureSearch = await stack.app.request('/v1/search?q=message', { + headers: { authorization: `Bearer ${futureOnly.data.token}` }, + }); + const futureSearchBody = await futureSearch.json() as { data: unknown[] }; + expect(futureSearch.status).toBe(200); + expect(futureSearchBody.data).toHaveLength(0); + + const reactionsWithMessageScope = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(reactionsWithMessageScope.status).toBe(403); + + const reactionsReader = await createObserverToken(stack, ws.workspaceKey, { + name: 'reaction-reader', + scopes: ['reactions:read'], + filters: { channel_names: ['general'] }, + }); + const reactionsWithReactionScope = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { + headers: { authorization: `Bearer ${reactionsReader.data.token}` }, + }); + expect(reactionsWithReactionScope.status).toBe(200); + + const futureReactions = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { + headers: { authorization: `Bearer ${futureOnly.data.token}` }, + }); + expect(futureReactions.status).toBe(404); + const write = await stack.app.request('/v1/channels/general/messages', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, @@ -226,4 +318,36 @@ describe('observer tokens', () => { message: { text: 'stream visible' }, }); }); + + it('allows DM stream events when channel filters are present and include_dms is enabled', async () => { + const ws = await createWorkspace(stack.app, 'observer-stream-dm-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + await registerAgent(stack.app, ws.workspaceKey, 'bob'); + + const observer = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-dms', + scopes: ['stream:read'], + filters: { channel_names: ['general'], include_dms: true }, + }); + + const [row] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, observer.data.id)); + const sock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, sock, row); + + const dm = await stack.app.request('/v1/dm', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ to: 'bob', text: 'dm visible despite channel filters' }), + }); + expect(dm.status).toBe(201); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sock.ofType('dm.received')).toHaveLength(1); + expect(sock.ofType('dm.received')[0]).toMatchObject({ + message: { text: 'dm visible despite channel filters' }, + }); + }); }); diff --git a/packages/engine/src/engine/activity.ts b/packages/engine/src/engine/activity.ts index edc77cc6..7da51042 100644 --- a/packages/engine/src/engine/activity.ts +++ b/packages/engine/src/engine/activity.ts @@ -1,6 +1,6 @@ import { eq, desc } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { messages, channels, agents } from '../db/schema.js'; +import { messages, channels, agents, dmConversations } from '../db/schema.js'; type Db = ReturnType; @@ -19,11 +19,14 @@ export async function getActivityFeed( channelId: messages.channelId, channelName: channels.name, channelType: channels.channelType, + conversationId: dmConversations.id, + agentId: messages.agentId, agentName: agents.name, }) .from(messages) .innerJoin(channels, eq(messages.channelId, channels.id)) .innerJoin(agents, eq(messages.agentId, agents.id)) + .leftJoin(dmConversations, eq(dmConversations.channelId, channels.id)) .where(eq(messages.workspaceId, workspaceId)) // id is a monotonic snowflake — use it as a stable tiebreaker so same-second // messages don't shuffle between calls. @@ -37,7 +40,8 @@ export async function getActivityFeed( return { type: 'dm' as const, id: r.id, - conversation_id: r.channelId, + conversation_id: r.conversationId ?? r.channelId, + agent_id: r.agentId, agent_name: r.agentName, text: r.body, created_at: r.createdAt.toISOString(), @@ -47,6 +51,7 @@ export async function getActivityFeed( type: 'message' as const, id: r.id, channel_name: r.channelName, + agent_id: r.agentId, agent_name: r.agentName, text: r.body, created_at: r.createdAt.toISOString(), diff --git a/packages/engine/src/engine/message.ts b/packages/engine/src/engine/message.ts index af1a2c8e..c7491493 100644 --- a/packages/engine/src/engine/message.ts +++ b/packages/engine/src/engine/message.ts @@ -66,14 +66,16 @@ export async function postMessage( const startedAtMs = Date.now(); const messageId = generateId(); - // Parse @mentions from text - const mentionPattern = /@(\w+)/g; - const mentionMatches = data.text.match(mentionPattern) || []; + // Parse @mentions from text without treating email domains as handles. + const mentionPattern = /(?:^|\s)@(\w+)/g; + const mentionedHandles = new Set(); + for (let match = mentionPattern.exec(data.text); match !== null; match = mentionPattern.exec(data.text)) { + mentionedHandles.add(match[1]); + } const hasAttachments = !!(data.attachments && data.attachments.length > 0); const metadata = sanitizeUserMessageMetadata(data.data); - const mentionedHandles = new Set(mentionMatches.map((m: string) => m.slice(1))); const mailbox = options.mailbox ?? { ttlMs: DEFAULT_MAILBOX_TTL_MS, depthCap: DEFAULT_MAILBOX_DEPTH_CAP, @@ -140,7 +142,7 @@ export async function postMessage( injection_mode: data.mode ?? 'wait', }, attachmentCount: attachments.length, - mentionCount: mentionMatches.length, + mentionCount: mentionedHandles.size, latencyMs: Date.now() - startedAtMs, }), ); @@ -166,7 +168,7 @@ export async function postMessage( has_attachments: message.hasAttachments, thread_id: message.threadId, created_at: message.createdAt.toISOString(), - mentions: mentionMatches.map((m: string) => m.slice(1)), + mentions: Array.from(mentionedHandles), attachments, injection_mode: data.mode ?? 'wait', _deliveries: deliveryOutcomes.deliveries, diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index 812f9343..e904f190 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -226,15 +226,17 @@ export async function getActiveObserverTokenByHash( )); if (!row) return null; - try { - await db - .update(observerTokens) - .set({ lastUsedAt: now }) - .where(eq(observerTokens.id, row.id)); - } catch { - // last_used_at is best-effort audit metadata; authentication should not fail - // solely because a read replica or adapter rejects this update. - } + void (async () => { + try { + await db + .update(observerTokens) + .set({ lastUsedAt: now }) + .where(eq(observerTokens.id, row.id)); + } catch { + // last_used_at is best-effort audit metadata; authentication should not fail + // solely because a read replica or adapter rejects this update. + } + })(); return row; } @@ -284,6 +286,35 @@ export function observerAllowsAgent( return Boolean(agentId && ids.has(agentId)); } +function timestampMillis(value: string | Date | number | null | undefined): number | null { + if (value == null) return null; + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = new Date(value).getTime(); + return Number.isNaN(parsed) ? null : parsed; +} + +export function observerAllowsCreatedAt( + observer: Pick | undefined, + createdAt: string | Date | number | null | undefined, +): boolean { + if (!observer) return true; + const cutoff = normalizeObserverFilters(observer.filters).created_after; + if (!cutoff) return true; + const cutoffMs = timestampMillis(cutoff); + const createdMs = timestampMillis(createdAt); + if (cutoffMs == null || createdMs == null) return false; + return createdMs >= cutoffMs; +} + +export function observerAllowsMessage( + observer: Pick | undefined, + message: { agent_id?: string | null; agentId?: string | null; created_at?: string | null; createdAt?: Date | string | number | null }, +): boolean { + return observerAllowsAgent(observer, message.agent_id ?? message.agentId) + && observerAllowsCreatedAt(observer, message.created_at ?? message.createdAt); +} + function nestedRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -304,6 +335,14 @@ function eventAgentId(event: Record): string | null { ?? (typeof agent.id === 'string' ? agent.id : null); } +function eventCreatedAt(event: Record): string | Date { + const message = nestedRecord(event.message); + return (typeof event.created_at === 'string' ? event.created_at : null) + ?? (typeof event.timestamp === 'string' ? event.timestamp : null) + ?? (typeof message.created_at === 'string' ? message.created_at : null) + ?? new Date(); +} + export function observerAllowsEvent( observer: Pick | undefined, event: Record, @@ -316,43 +355,44 @@ export function observerAllowsEvent( return false; } - if ((type === 'dm.received' || type === 'group_dm.received') && !observerAllowsConversation( - observer, - typeof event.conversation_id === 'string' ? event.conversation_id : null, - )) { + if (!observerAllowsCreatedAt(observer, eventCreatedAt(event))) { return false; } - if (!observerAllowsChannel(observer, { + const conversationId = typeof event.conversation_id === 'string' ? event.conversation_id : null; + const isDm = conversationId !== null || type === 'dm.received' || type === 'group_dm.received'; + if (isDm) { + if (!observerAllowsConversation(observer, conversationId)) { + return false; + } + } else if (!observerAllowsChannel(observer, { id: typeof event.channel_id === 'string' ? event.channel_id : null, name: eventChannelName(event), })) { return false; } - if (!observerAllowsAgent(observer, eventAgentId(event))) { - return false; - } - - return true; + return observerAllowsAgent(observer, eventAgentId(event)); } export async function getMessageObserverResource( db: Db, workspaceId: string, messageId: string, -): Promise<{ channel_id: string; channel_name: string; conversation_id: string | null } | null> { +): Promise<{ channel_id: string; channel_name: string; conversation_id: string | null; agent_id: string; created_at: string } | null> { const [row] = await db .select({ channel_id: channels.id, channel_name: channels.name, conversation_id: dmConversations.id, + agent_id: messages.agentId, + created_at: messages.createdAt, }) .from(messages) .innerJoin(channels, eq(messages.channelId, channels.id)) .leftJoin(dmConversations, eq(dmConversations.channelId, channels.id)) .where(and(eq(messages.id, messageId), eq(messages.workspaceId, workspaceId))); - return row ?? null; + return row ? { ...row, created_at: row.created_at.toISOString() } : null; } export async function getChannelObserverResource( @@ -367,7 +407,13 @@ export async function getChannelObserverResource( return row ?? null; } -export async function filterObserverSearchResults( +export async function filterObserverSearchResults( db: Db, workspaceId: string, observer: Pick | undefined, @@ -388,7 +434,10 @@ export async function filterObserverSearchResults { - if (result.conversation_id && !observerAllowsConversation(observer, result.conversation_id)) return false; + if (!observerAllowsMessage(observer, result)) return false; + if (result.conversation_id) { + return observerAllowsConversation(observer, result.conversation_id); + } return observerAllowsChannel(observer, { id: result.channel_id, name: result.channel_name ?? (result.channel_id ? namesById.get(result.channel_id) : null), diff --git a/packages/engine/src/engine/search.ts b/packages/engine/src/engine/search.ts index cd0137e0..c1335d64 100644 --- a/packages/engine/src/engine/search.ts +++ b/packages/engine/src/engine/search.ts @@ -81,6 +81,7 @@ export async function searchMessages( channel_id: row.channel_id, channel_name: row.channel_name || 'unknown', conversation_id: row.conversation_id, + agent_id: row.agent_id, agent_name: row.agent_name || 'unknown', text: row.body, created_at: new Date(row.created_at * 1000).toISOString(), diff --git a/packages/engine/src/engine/thread.ts b/packages/engine/src/engine/thread.ts index 068aae4a..e7ab64f3 100644 --- a/packages/engine/src/engine/thread.ts +++ b/packages/engine/src/engine/thread.ts @@ -45,9 +45,11 @@ export async function postReply( const replyId = generateId(); const metadata = sanitizeUserMessageMetadata(data.data); - const mentionPattern = /@(\w+)/g; - const mentionMatches = data.text.match(mentionPattern) || []; - const mentionedHandles = new Set(mentionMatches.map((m: string) => m.slice(1))); + const mentionPattern = /(?:^|\s)@(\w+)/g; + const mentionedHandles = new Set(); + for (let match = mentionPattern.exec(data.text); match !== null; match = mentionPattern.exec(data.text)) { + mentionedHandles.add(match[1]); + } const mailbox = options.mailbox ?? { ttlMs: DEFAULT_MAILBOX_TTL_MS, depthCap: DEFAULT_MAILBOX_DEPTH_CAP, diff --git a/packages/engine/src/routes/message.ts b/packages/engine/src/routes/message.ts index e8ed6fc0..160ac9d9 100644 --- a/packages/engine/src/routes/message.ts +++ b/packages/engine/src/routes/message.ts @@ -17,6 +17,7 @@ import { hasObserverScope, observerAllowsChannel, observerAllowsConversation, + observerAllowsMessage, } from '../engine/observerToken.js'; import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; @@ -203,7 +204,8 @@ messageRoutes.get( if (!channel) { return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); } - if (!observerAllowsChannel(getObserverTokenFromContext(c), channel)) { + const observer = getObserverTokenFromContext(c); + if (!observerAllowsChannel(observer, channel)) { return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); } @@ -219,7 +221,7 @@ messageRoutes.get( channel.id, { limit, before, after }, ); - return jsonOk(c, messages); + return jsonOk(c, messages.filter((message) => observerAllowsMessage(observer, message))); } catch (err: unknown) { return errorResponse(c, err); } @@ -242,6 +244,9 @@ messageRoutes.get( if (!resource) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } + if (!observerAllowsMessage(observer, resource)) { + return jsonNotFound(c, 'message_not_found', 'Message not found'); + } if (resource.conversation_id) { if (!hasObserverScope(observer, 'dms:read') || !observerAllowsConversation(observer, resource.conversation_id)) { return jsonNotFound(c, 'message_not_found', 'Message not found'); diff --git a/packages/engine/src/routes/observerToken.ts b/packages/engine/src/routes/observerToken.ts index 0b076311..bef2e3e2 100644 --- a/packages/engine/src/routes/observerToken.ts +++ b/packages/engine/src/routes/observerToken.ts @@ -23,9 +23,7 @@ import { export const observerTokenRoutes = new Hono(); -const isoTimestamp = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { - message: 'expires_at must be an ISO-8601 timestamp', -}); +const isoTimestamp = z.string().datetime({ message: 'expires_at must be an ISO-8601 timestamp' }); const observerScopeSchema = z.enum(OBSERVER_SCOPES); diff --git a/packages/engine/src/routes/reaction.ts b/packages/engine/src/routes/reaction.ts index 016d140a..10fd8e8a 100644 --- a/packages/engine/src/routes/reaction.ts +++ b/packages/engine/src/routes/reaction.ts @@ -11,6 +11,7 @@ import { getObserverTokenFromContext, observerAllowsChannel, observerAllowsConversation, + observerAllowsMessage, } from '../engine/observerToken.js'; import { fanoutToChannel, fanoutToAgents, getDmParticipantAgentIds } from './fanout.js'; import { sendNodeDeliveriesForChannel } from '../engine/nodeDeliver.js'; @@ -204,7 +205,7 @@ reactionRoutes.delete( // GET /v1/messages/:id/reactions - aggregated reactions reactionRoutes.get( '/messages/:id/reactions', - requireWorkspaceRead(['messages:read', 'reactions:read']), + requireWorkspaceRead('reactions:read'), rateLimit, async (c) => { try { @@ -216,9 +217,9 @@ reactionRoutes.get( if (!resource) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } - const allowed = resource.conversation_id + const allowed = observerAllowsMessage(observer, resource) && (resource.conversation_id ? observerAllowsConversation(observer, resource.conversation_id) - : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name }); + : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name })); if (!allowed) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } diff --git a/packages/engine/src/routes/search.ts b/packages/engine/src/routes/search.ts index e741d3d1..01da0524 100644 --- a/packages/engine/src/routes/search.ts +++ b/packages/engine/src/routes/search.ts @@ -54,15 +54,16 @@ searchRoutes.get( getObserverTokenFromContext(c), results, ); + const responseResults = visibleResults.map(({ agent_id: _agentId, ...result }) => result); emitServerEvent(c, workspace.id, 'relaycast_server_search_executed', { query_length: q.trim().length, - result_count: visibleResults.length, + result_count: responseResults.length, has_channel_filter: Boolean(channel), has_from_filter: Boolean(from), }); - return jsonOk(c, visibleResults); + return jsonOk(c, responseResults); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/thread.ts b/packages/engine/src/routes/thread.ts index 780e0b35..8fd7bce3 100644 --- a/packages/engine/src/routes/thread.ts +++ b/packages/engine/src/routes/thread.ts @@ -14,6 +14,7 @@ import { getObserverTokenFromContext, observerAllowsChannel, observerAllowsConversation, + observerAllowsMessage, } from '../engine/observerToken.js'; import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; @@ -158,9 +159,9 @@ threadRoutes.get( if (!resource) { return jsonError(c, 'message_not_found', 'Parent message not found', 404); } - const allowed = resource.conversation_id + const allowed = observerAllowsMessage(observer, resource) && (resource.conversation_id ? observerAllowsConversation(observer, resource.conversation_id) - : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name }); + : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name })); if (!allowed) { return jsonError(c, 'message_not_found', 'Parent message not found', 404); } @@ -171,7 +172,16 @@ threadRoutes.get( parentId, { limit, before, after }, ); - return jsonOk(c, result); + if (!observer) return jsonOk(c, result); + const replies = result.replies.filter((reply) => observerAllowsMessage(observer, reply)); + return jsonOk(c, { + ...result, + parent: { + ...result.parent, + reply_count: replies.length, + }, + replies, + }); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/workspace.ts b/packages/engine/src/routes/workspace.ts index a3e71bcf..ea877ef1 100644 --- a/packages/engine/src/routes/workspace.ts +++ b/packages/engine/src/routes/workspace.ts @@ -10,8 +10,11 @@ import * as activityEngine from '../engine/activity.js'; import * as dmAllEngine from '../engine/dmAll.js'; import * as tokenRotateEngine from '../engine/tokenRotate.js'; import { + filterObserverSearchResults, getObserverTokenFromContext, + observerAllowsCreatedAt, observerAllowsConversation, + observerAllowsMessage, } from '../engine/observerToken.js'; import { emitServerEvent } from '../lib/serverTelemetry.js'; import { errorResponse } from '../lib/httpError.js'; @@ -226,7 +229,13 @@ workspaceRoutes.get('/activity', requireWorkspaceRead('activity:read', { allowAg const { limit } = parsed.data; const items = await activityEngine.getActivityFeed(db, workspace.id, limit); - return jsonOk(c, items); + const visibleItems = await filterObserverSearchResults( + db, + workspace.id, + getObserverTokenFromContext(c), + items, + ); + return jsonOk(c, visibleItems); } catch (err: unknown) { return errorResponse(c, err); } @@ -239,7 +248,9 @@ workspaceRoutes.get('/dm/conversations/all', requireWorkspaceRead('dms:read', { const workspace = c.get('workspace'); const observer = getObserverTokenFromContext(c); const conversations = (await dmAllEngine.listAllDmConversations(db, workspace.id)) - .filter((conversation) => observerAllowsConversation(observer, conversation.id)); + .filter((conversation) => + observerAllowsConversation(observer, conversation.id) + && (!conversation.last_message || observerAllowsCreatedAt(observer, conversation.last_message.created_at))); return jsonOk(c, conversations); } catch (err: unknown) { return errorResponse(c, err); @@ -264,7 +275,7 @@ workspaceRoutes.get('/dm/conversations/:conversation_id/messages', requireWorksp const msgs = await dmAllEngine.getDmMessagesForWorkspace( db, workspace.id, conversationId, { limit, before, after }, ); - return jsonOk(c, msgs); + return jsonOk(c, msgs.filter((message) => observerAllowsMessage(getObserverTokenFromContext(c), message))); } catch (err: unknown) { return errorResponse(c, err); } From d33cff1ef50426db8f5fb430ab3667318dee1a71 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 09:24:23 -0400 Subject: [PATCH 04/15] Tighten observer token scope filtering --- README.md | 3 +- openapi.yaml | 9 +- .../conformance/observerToken.test.ts | 148 ++++++++++- packages/engine/src/engine/console.ts | 251 ++++++++++++++++-- packages/engine/src/engine/observerToken.ts | 81 ++++-- packages/engine/src/engine/wsTransform.ts | 47 +++- packages/engine/src/routes/channel.ts | 11 +- packages/engine/src/routes/console.ts | 53 +++- packages/engine/src/routes/reaction.ts | 28 +- packages/engine/src/routes/receipt.ts | 13 +- 10 files changed, 565 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index a5341e8b..f05dd2bd 100644 --- a/README.md +++ b/README.md @@ -339,13 +339,14 @@ origin (e.g. `http://localhost:8787/v1`). Authentication header: - `Authorization: Bearer ` +- Prefer the `Authorization` header for HTTP requests. Query-param tokens are intended for WebSocket clients that cannot set headers and can appear in access logs. Realtime transport: - `/v1/ws` is the workspace observer stream and requires an observer token with `stream:read`. - `/v1/node/ws` is the node control/delivery stream and requires a node token. - Agent SDKs use `at_live_*` for REST, mint a direct `nt_live_*` token, and receive realtime events as node `deliver` frames. -- Workspace keys create, rotate, list, and revoke observer tokens at `/v1/observer-tokens`; observer tokens are read-only and cannot mutate workspace state. +- Workspace keys create, rotate, list, and revoke observer tokens at `/v1/observer-tokens`; observer tokens are read-only and cannot mutate workspace state. Observer scopes grant read capabilities, while filters narrow resources. DM content requires both `dms:read` and `filters.include_dms: true`. Channel filters apply only to channel-scoped events; workspace-wide presence/status events require matching `agent_ids` or no agent filter. Core endpoints: diff --git a/openapi.yaml b/openapi.yaml index cd551159..841c3fc8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -790,6 +790,7 @@ components: type: string include_dms: type: boolean + description: Enables DM resources for tokens that also have the `dms:read` scope. Without `dms:read`, DM content is not returned even when this filter is true. dm_conversation_ids: type: array items: @@ -798,6 +799,7 @@ components: type: array items: type: string + description: Restricts resources and realtime events to matching agents where an event carries an agent id. event_types: type: array items: @@ -805,6 +807,7 @@ components: created_after: type: string format: date-time + description: Filters narrow the resources visible to a scoped observer token. Channel filters apply to channel-scoped resources; workspace-wide presence/status events have no channel and are matched by `agent_ids` when present. status: type: string enum: [active, revoked] @@ -4001,7 +4004,7 @@ paths: /ws: get: summary: Workspace observer WebSocket stream - description: Upgrade to the workspace observer realtime stream with an `ot_live_*` token that has `stream:read`. Workspace, agent, and node tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. + description: Upgrade to the workspace observer realtime stream with an `ot_live_*` token that has `stream:read`. Workspace, agent, and node tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. Prefer Authorization headers when a WebSocket client supports them; query-param tokens can appear in access logs. tags: - System security: @@ -4009,10 +4012,10 @@ paths: parameters: - name: token in: query - required: true + required: false schema: type: string - description: Observer token (`ot_live_*`) with `stream:read` + description: Observer token (`ot_live_*`) with `stream:read`; use this only when the client cannot send an Authorization header. responses: '101': description: Switching Protocols (WebSocket) diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index c1899aac..b79ebfec 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -7,7 +7,7 @@ import { registerAgent, type TestStack, } from './harness.js'; -import { observerTokens } from '../../db/schema.js'; +import { messageLogs, observerTokens } from '../../db/schema.js'; import { authenticateRealtimeWs } from '../../engine/wsAuth.js'; async function createObserverToken( @@ -20,8 +20,11 @@ async function createObserverToken( headers: { 'content-type': 'application/json', authorization: `Bearer ${workspaceKey}` }, body: JSON.stringify(body), }); - const json = await res.json() as { data: { id: string; token?: string; scopes: string[]; filters: Record } }; - return { res, data: json.data }; + const json = await res.json() as { + data?: { id: string; token?: string; scopes: string[]; filters: Record }; + error?: { code: string; message: string }; + }; + return { res, data: json.data!, body: json }; } describe('observer tokens', () => { @@ -86,6 +89,25 @@ describe('observer tokens', () => { }); }); + it('returns a conflict for duplicate observer token names in one workspace', async () => { + const ws = await createWorkspace(stack.app, 'observer-duplicate-name-ws'); + const created = await createObserverToken(stack, ws.workspaceKey, { + name: 'dashboard', + scopes: ['messages:read'], + }); + expect(created.res.status).toBe(201); + + const duplicate = await createObserverToken(stack, ws.workspaceKey, { + name: 'dashboard', + scopes: ['messages:read'], + }); + expect(duplicate.res.status).toBe(409); + expect(duplicate.body).toMatchObject({ + ok: false, + error: { code: 'observer_token_name_conflict' }, + }); + }); + it('gates REST reads by scope and channel filters and rejects observer writes', async () => { const ws = await createWorkspace(stack.app, 'observer-rest-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); @@ -112,6 +134,7 @@ describe('observer tokens', () => { body: JSON.stringify({ text: 'hidden bob message' }), }); expect(bobGeneral.status).toBe(201); + const bobGeneralBody = await bobGeneral.json() as { data: { id: string } }; const team = await stack.app.request('/v1/channels/team-chat/messages', { method: 'POST', @@ -120,6 +143,15 @@ describe('observer tokens', () => { }); expect(team.status).toBe(201); + await stack.runtime.deps.db + .update(messageLogs) + .set({ metadata: { _cost: { total_usd: 3, total_tokens: 30 } } }) + .where(eq(messageLogs.messageId, generalBody.data.id)); + await stack.runtime.deps.db + .update(messageLogs) + .set({ metadata: { _cost: { total_usd: 7, total_tokens: 70 } } }) + .where(eq(messageLogs.messageId, bobGeneralBody.data.id)); + const observer = await createObserverToken(stack, ws.workspaceKey, { name: 'general-reader', scopes: ['messages:read', 'channels:read', 'search:read'], @@ -169,6 +201,43 @@ describe('observer tokens', () => { expect(filteredSearchBody.data.map((result) => result.text)).toContain('visible general message'); expect(filteredSearchBody.data.map((result) => result.text)).not.toContain('hidden bob message'); + const consoleObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'console-general-alice', + scopes: ['activity:read', 'agents:read'], + filters: { channel_names: ['general'], agent_ids: [alice.agentId] }, + }); + const consoleStats = await stack.app.request('/v1/console/stats', { + headers: { authorization: `Bearer ${consoleObserver.data.token}` }, + }); + const consoleStatsBody = await consoleStats.json() as { data: { total_messages: number; channel_messages: number } }; + expect(consoleStats.status).toBe(200); + expect(consoleStatsBody.data.total_messages).toBe(1); + expect(consoleStatsBody.data.channel_messages).toBe(1); + + const consoleAgents = await stack.app.request('/v1/console/agents', { + headers: { authorization: `Bearer ${consoleObserver.data.token}` }, + }); + const consoleAgentsBody = await consoleAgents.json() as { data: Array<{ agent_id: string; message_count: number }> }; + expect(consoleAgents.status).toBe(200); + expect(consoleAgentsBody.data).toHaveLength(1); + expect(consoleAgentsBody.data[0]).toMatchObject({ + agent_id: alice.agentId, + agent_name: 'alice', + message_count: 1, + channel_count: 1, + dm_count: 0, + last_message_at: expect.any(String), + }); + + const consoleCosts = await stack.app.request('/v1/console/costs', { + headers: { authorization: `Bearer ${consoleObserver.data.token}` }, + }); + const consoleCostsBody = await consoleCosts.json() as { data: { totals: { total_cost_usd: number; total_tokens: number }; agents: Array<{ agent_id: string; total_cost_usd: number; total_tokens: number }> } }; + expect(consoleCosts.status).toBe(200); + expect(consoleCostsBody.data.totals).toMatchObject({ total_cost_usd: 3, total_tokens: 30 }); + expect(consoleCostsBody.data.agents).toHaveLength(1); + expect(consoleCostsBody.data.agents[0]).toMatchObject({ agent_id: alice.agentId, total_cost_usd: 3, total_tokens: 30 }); + const futureOnly = await createObserverToken(stack, ws.workspaceKey, { name: 'future-reader', scopes: ['messages:read', 'search:read', 'reactions:read'], @@ -257,6 +326,30 @@ describe('observer tokens', () => { expect(visible.status).toBe(200); expect(visibleBody.data).toHaveLength(1); + const dmSearchWithoutScope = await createObserverToken(stack, ws.workspaceKey, { + name: 'dm-search-without-scope', + scopes: ['search:read'], + filters: { include_dms: true }, + }); + const hiddenSearch = await stack.app.request('/v1/search?q=private', { + headers: { authorization: `Bearer ${dmSearchWithoutScope.data.token}` }, + }); + const hiddenSearchBody = await hiddenSearch.json() as { data: unknown[] }; + expect(hiddenSearch.status).toBe(200); + expect(hiddenSearchBody.data).toHaveLength(0); + + const dmSearchWithScope = await createObserverToken(stack, ws.workspaceKey, { + name: 'dm-search-with-scope', + scopes: ['search:read', 'dms:read'], + filters: { include_dms: true }, + }); + const visibleSearch = await stack.app.request('/v1/search?q=private', { + headers: { authorization: `Bearer ${dmSearchWithScope.data.token}` }, + }); + const visibleSearchBody = await visibleSearch.json() as { data: Array<{ text: string }> }; + expect(visibleSearch.status).toBe(200); + expect(visibleSearchBody.data.map((result) => result.text)).toContain('private note'); + const underScoped = await createObserverToken(stack, ws.workspaceKey, { name: 'channels-only', scopes: ['channels:read'], @@ -324,9 +417,40 @@ describe('observer tokens', () => { const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); await registerAgent(stack.app, ws.workspaceKey, 'bob'); + const withoutDmScope = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-dms-without-scope', + scopes: ['stream:read'], + filters: { channel_names: ['general'], include_dms: true }, + }); + const [withoutDmScopeRow] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, withoutDmScope.data.id)); + const withoutDmScopeSock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, withoutDmScopeSock, withoutDmScopeRow); + + const hiddenDm = await stack.app.request('/v1/dm', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ to: 'bob', text: 'dm hidden without dms:read' }), + }); + expect(hiddenDm.status).toBe(201); + const hiddenDmBody = await hiddenDm.json() as { data: { id: string } }; + + const hiddenDmReaction = await stack.app.request(`/v1/messages/${hiddenDmBody.data.id}/reactions`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ emoji: 'eyes' }), + }); + expect(hiddenDmReaction.status).toBe(201); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); + const observer = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms', - scopes: ['stream:read'], + scopes: ['stream:read', 'dms:read'], filters: { channel_names: ['general'], include_dms: true }, }); @@ -343,11 +467,27 @@ describe('observer tokens', () => { body: JSON.stringify({ to: 'bob', text: 'dm visible despite channel filters' }), }); expect(dm.status).toBe(201); + const dmBody = await dm.json() as { data: { id: string } }; + + const visibleDmReaction = await stack.app.request(`/v1/messages/${dmBody.data.id}/reactions`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ emoji: 'thumbsup' }), + }); + expect(visibleDmReaction.status).toBe(201); await new Promise((resolve) => setTimeout(resolve, 50)); expect(sock.ofType('dm.received')).toHaveLength(1); expect(sock.ofType('dm.received')[0]).toMatchObject({ message: { text: 'dm visible despite channel filters' }, }); + expect(sock.ofType('message.reacted')).toHaveLength(1); + expect(sock.ofType('message.reacted')[0]).toMatchObject({ + conversation_id: expect.any(String), + message_id: dmBody.data.id, + emoji: 'thumbsup', + }); + expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); }); }); diff --git a/packages/engine/src/engine/console.ts b/packages/engine/src/engine/console.ts index 3746a0a2..8ef07498 100644 --- a/packages/engine/src/engine/console.ts +++ b/packages/engine/src/engine/console.ts @@ -30,6 +30,24 @@ export interface ListMessageLogsOptions { deliveryKind?: 'channel' | 'dm'; } +type MessageLogRow = { + id: string; + messageId: string; + channelId: string; + channelName: string | null; + agentId: string; + agentName: string | null; + conversationId: string | null; + deliveryKind: string; + body: string; + contentType: string | null; + metadata: Record | null; + attachmentCount: number; + mentionCount: number; + latencyMs: number; + createdAt: Date; +}; + function clampLimit(limit?: number): number { return Math.min(Math.max(limit ?? 50, 1), 100); } @@ -38,6 +56,51 @@ function getWindowStart(days: number): Date { return new Date(Date.now() - days * 24 * 60 * 60 * 1000); } +function publicMessageLog(row: MessageLogRow) { + return { + id: row.id, + message_id: row.messageId, + channel_id: row.channelId, + channel_name: row.channelName, + agent_id: row.agentId, + agent_name: row.agentName, + conversation_id: row.conversationId, + delivery_kind: row.deliveryKind, + text: row.body, + content_type: row.contentType, + metadata: row.metadata ?? {}, + attachment_count: row.attachmentCount, + mention_count: row.mentionCount, + latency_ms: row.latencyMs, + created_at: row.createdAt.toISOString(), + }; +} + +export type ConsoleMessageLog = ReturnType; + +function costNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function nestedRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function costMetadata(log: ConsoleMessageLog) { + const cost = nestedRecord(nestedRecord(log.metadata)._cost); + return { + total_cost_usd: costNumber(cost.total_usd), + prompt_tokens: costNumber(cost.prompt_tokens), + completion_tokens: costNumber(cost.completion_tokens), + total_tokens: costNumber(cost.total_tokens), + }; +} + /** * Build the message-log insert without executing it, so send paths can include * it in an atomic statement list (see {@link runAtomicWrites}). @@ -104,23 +167,177 @@ export async function listMessageLogs( .orderBy(desc(messageLogs.id)) .limit(clampLimit(opts.limit)); - return rows.map((row) => ({ - id: row.id, - message_id: row.messageId, - channel_id: row.channelId, - channel_name: row.channelName, - agent_id: row.agentId, - agent_name: row.agentName, - conversation_id: row.conversationId, - delivery_kind: row.deliveryKind, - text: row.body, - content_type: row.contentType, - metadata: row.metadata ?? {}, - attachment_count: row.attachmentCount, - mention_count: row.mentionCount, - latency_ms: row.latencyMs, - created_at: row.createdAt.toISOString(), - })); + return rows.map(publicMessageLog); +} + +export async function listMessageLogsForWindow( + db: Db, + workspaceId: string, + windowDays = 7, +): Promise { + const since = getWindowStart(windowDays); + const rows = await db + .select({ + id: messageLogs.id, + messageId: messageLogs.messageId, + channelId: messageLogs.channelId, + channelName: channels.name, + agentId: messageLogs.agentId, + agentName: agents.name, + conversationId: messageLogs.conversationId, + deliveryKind: messageLogs.deliveryKind, + body: messageLogs.body, + contentType: messageLogs.contentType, + metadata: messageLogs.metadata, + attachmentCount: messageLogs.attachmentCount, + mentionCount: messageLogs.mentionCount, + latencyMs: messageLogs.latencyMs, + createdAt: messageLogs.createdAt, + }) + .from(messageLogs) + .leftJoin(agents, eq(messageLogs.agentId, agents.id)) + .leftJoin(channels, eq(messageLogs.channelId, channels.id)) + .where(and(eq(messageLogs.workspaceId, workspaceId), gte(messageLogs.createdAt, since))) + .orderBy(desc(messageLogs.id)); + + return rows.map(publicMessageLog); +} + +export function summarizeConsoleOverview(logs: ConsoleMessageLog[], windowDays = 7) { + const since = getWindowStart(windowDays); + const latencyTotal = logs.reduce((sum, log) => sum + log.latency_ms, 0); + + return { + window_days: windowDays, + since: since.toISOString(), + total_messages: logs.length, + channel_messages: logs.filter((log) => log.delivery_kind === 'channel').length, + dm_messages: logs.filter((log) => log.delivery_kind === 'dm').length, + unique_agents: new Set(logs.map((log) => log.agent_id)).size, + avg_latency_ms: logs.length > 0 ? Math.round(latencyTotal / logs.length) : 0, + max_latency_ms: logs.reduce((max, log) => Math.max(max, log.latency_ms), 0), + attachment_count: logs.reduce((sum, log) => sum + log.attachment_count, 0), + mention_count: logs.reduce((sum, log) => sum + log.mention_count, 0), + }; +} + +export function summarizeAgentStats(logs: ConsoleMessageLog[], limit = 20) { + const byAgent = new Map(); + + for (const log of logs) { + const existing = byAgent.get(log.agent_id) ?? { + agent_id: log.agent_id, + agent_name: log.agent_name, + message_count: 0, + channel_count: 0, + dm_count: 0, + latency_total: 0, + last_message_ms: 0, + }; + existing.message_count += 1; + existing.channel_count += log.delivery_kind === 'channel' ? 1 : 0; + existing.dm_count += log.delivery_kind === 'dm' ? 1 : 0; + existing.latency_total += log.latency_ms; + existing.last_message_ms = Math.max(existing.last_message_ms, Date.parse(log.created_at) || 0); + byAgent.set(log.agent_id, existing); + } + + return [...byAgent.values()] + .sort((left, right) => ( + right.message_count - left.message_count + || right.last_message_ms - left.last_message_ms + || (left.agent_name ?? '').localeCompare(right.agent_name ?? '') + )) + .slice(0, clampLimit(limit)) + .map((row) => ({ + agent_id: row.agent_id, + agent_name: row.agent_name, + message_count: row.message_count, + channel_count: row.channel_count, + dm_count: row.dm_count, + avg_latency_ms: row.message_count > 0 ? Math.round(row.latency_total / row.message_count) : 0, + last_message_at: row.last_message_ms > 0 ? new Date(row.last_message_ms).toISOString() : null, + })); +} + +export function summarizeCostStats(logs: ConsoleMessageLog[], windowDays = 7) { + const byAgent = new Map(); + + for (const log of logs) { + const cost = costMetadata(log); + const existing = byAgent.get(log.agent_id) ?? { + agent_id: log.agent_id, + agent_name: log.agent_name, + message_count: 0, + total_cost_usd: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }; + existing.message_count += 1; + existing.total_cost_usd += cost.total_cost_usd; + existing.prompt_tokens += cost.prompt_tokens; + existing.completion_tokens += cost.completion_tokens; + existing.total_tokens += cost.total_tokens; + byAgent.set(log.agent_id, existing); + } + + const agentsWithCosts = [...byAgent.values()] + .filter((row) => ( + row.total_cost_usd > 0 + || row.prompt_tokens > 0 + || row.completion_tokens > 0 + || row.total_tokens > 0 + )) + .sort((left, right) => ( + right.total_cost_usd - left.total_cost_usd + || right.total_tokens - left.total_tokens + || (left.agent_name ?? '').localeCompare(right.agent_name ?? '') + )) + .map((row) => ({ + agent_id: row.agent_id, + agent_name: row.agent_name, + message_count: row.message_count, + total_cost_usd: row.total_cost_usd, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + total_tokens: row.total_tokens, + })); + + const totals = agentsWithCosts.reduce((acc, row) => { + acc.total_cost_usd += row.total_cost_usd; + acc.prompt_tokens += row.prompt_tokens; + acc.completion_tokens += row.completion_tokens; + acc.total_tokens += row.total_tokens; + return acc; + }, { + total_cost_usd: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }); + + return { + window_days: windowDays, + totals, + agents: agentsWithCosts, + }; } export async function getConsoleOverview( diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index e904f190..7986a6f3 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -109,6 +109,21 @@ function publicObserverToken(row: ObserverToken, token?: string): PublicObserver }; } +function isObserverTokenNameConflict(err: unknown): boolean { + const candidate = err as { code?: string; message?: string }; + const message = candidate.message ?? ''; + return ( + candidate.code === 'SQLITE_CONSTRAINT' + || candidate.code === 'SQLITE_CONSTRAINT_UNIQUE' + || message.includes('observer_tokens_workspace_name_unique') + || message.includes('observer_tokens.workspace_id, observer_tokens.name') + ); +} + +function observerTokenNameConflict(): never { + throw codedError('Observer token name already exists in this workspace', 'observer_token_name_conflict', 409); +} + export async function createObserverToken( db: Db, workspaceId: string, @@ -120,20 +135,29 @@ export async function createObserverToken( const scopes = normalizeScopes(input.scopes); const filters = normalizeObserverFilters(input.filters); - const [created] = await db - .insert(observerTokens) - .values({ - id: `ot_${generateId()}`, - workspaceId, - name: input.name, - description: input.description ?? null, - tokenHash, - scopes, - filters, - expiresAt, - createdByType: 'workspace', - }) - .returning(); + let created: ObserverToken | undefined; + try { + [created] = await db + .insert(observerTokens) + .values({ + id: `ot_${generateId()}`, + workspaceId, + name: input.name, + description: input.description ?? null, + tokenHash, + scopes, + filters, + expiresAt, + createdByType: 'workspace', + }) + .returning(); + } catch (err: unknown) { + if (isObserverTokenNameConflict(err)) observerTokenNameConflict(); + throw err; + } + if (!created) { + throw codedError('Failed to create observer token', 'internal_error', 500); + } return publicObserverToken(created, token); } @@ -173,11 +197,17 @@ export async function updateObserverToken( if (input.filters !== undefined) update.filters = normalizeObserverFilters(input.filters); if (input.expires_at !== undefined) update.expiresAt = parseExpiresAt(input.expires_at); - const [row] = await db - .update(observerTokens) - .set(update) - .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) - .returning(); + let row: ObserverToken | undefined; + try { + [row] = await db + .update(observerTokens) + .set(update) + .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .returning(); + } catch (err: unknown) { + if (isObserverTokenNameConflict(err)) observerTokenNameConflict(); + throw err; + } return row ? publicObserverToken(row) : null; } @@ -265,11 +295,12 @@ export function observerAllowsChannel( } export function observerAllowsConversation( - observer: Pick | undefined, + observer: Pick | undefined, conversationId: string | null | undefined, ): boolean { if (!observer) return true; const filters = normalizeObserverFilters(observer.filters); + if (!hasObserverScope(observer, 'dms:read')) return false; if (!filters.include_dms) return false; const ids = filterList(filters.dm_conversation_ids); if (!ids) return true; @@ -335,16 +366,16 @@ function eventAgentId(event: Record): string | null { ?? (typeof agent.id === 'string' ? agent.id : null); } -function eventCreatedAt(event: Record): string | Date { +function eventCreatedAt(event: Record): string | Date | null { const message = nestedRecord(event.message); return (typeof event.created_at === 'string' ? event.created_at : null) ?? (typeof event.timestamp === 'string' ? event.timestamp : null) ?? (typeof message.created_at === 'string' ? message.created_at : null) - ?? new Date(); + ?? null; } export function observerAllowsEvent( - observer: Pick | undefined, + observer: Pick | undefined, event: Record, ): boolean { if (!observer) return true; @@ -409,14 +440,14 @@ export async function getChannelObserverResource( export async function filterObserverSearchResults( db: Db, workspaceId: string, - observer: Pick | undefined, + observer: Pick | undefined, results: T[], ): Promise { if (!observer || results.length === 0) return results; diff --git a/packages/engine/src/engine/wsTransform.ts b/packages/engine/src/engine/wsTransform.ts index dbe2fcb0..556b6a24 100644 --- a/packages/engine/src/engine/wsTransform.ts +++ b/packages/engine/src/engine/wsTransform.ts @@ -15,12 +15,18 @@ export type WsEvent = { */ export function transformForClient(event: WsEvent): Record { const d = event.data; + const createdAt = typeof d.created_at === 'string' ? d.created_at : event.timestamp; + const withCreatedAt = { created_at: createdAt }; + const agentId = (typeof d.agent_id === 'string' ? d.agent_id : null) + ?? (typeof d.subject_agent_id === 'string' ? d.subject_agent_id : null); + const agentRef = { ...(agentId ? { id: agentId } : {}), name: d.agent_name as string }; switch (event.type) { case 'message.created': return { id: stableRelaycastEventId(d.id as string), type: 'message.created', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, message: { @@ -36,6 +42,7 @@ export function transformForClient(event: WsEvent): Record { case 'message.updated': return { type: 'message.updated', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, message: { @@ -50,6 +57,7 @@ export function transformForClient(event: WsEvent): Record { return { id: stableRelaycastEventId(d.id as string), type: 'thread.reply', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, parent_id: d.thread_id as string, @@ -64,9 +72,13 @@ export function transformForClient(event: WsEvent): Record { case 'message.reacted': return { type: 'message.reacted', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), + ...(d.channel_name ? { channel: d.channel_name as string } : {}), + ...(d.conversation_id ? { conversation_id: d.conversation_id as string } : {}), message_id: d.message_id as string, emoji: d.emoji as string, + ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), agent_name: d.agent_name as string, action: (d.action as string | undefined) ?? 'added', }; @@ -78,6 +90,7 @@ export function transformForClient(event: WsEvent): Record { return { id: stableRelaycastEventId((msg.id ?? d.id) as string), type: 'dm.received', + ...withCreatedAt, conversation_id: d.conversation_id as string, message: { id: (msg.id ?? d.id) as string, @@ -97,6 +110,7 @@ export function transformForClient(event: WsEvent): Record { return { id: stableRelaycastEventId((msg.id ?? d.id) as string), type: 'group_dm.received', + ...withCreatedAt, conversation_id: d.conversation_id as string, message: { id: (msg.id ?? d.id) as string, @@ -112,48 +126,55 @@ export function transformForClient(event: WsEvent): Record { case 'agent.status.active': return { type: 'agent.status.active', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: 'active', }; case 'agent.status.offline': return { type: 'agent.status.offline', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: 'offline', }; case 'agent.status.changed': return { type: 'agent.status.changed', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: d.status as string, }; case 'agent.status.idle': return { type: 'agent.status.idle', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: 'idle', }; case 'agent.status.blocked': return { type: 'agent.status.blocked', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: 'blocked', }; case 'agent.status.waiting': return { type: 'agent.status.waiting', - agent: { name: d.agent_name as string }, + ...withCreatedAt, + agent: agentRef, status: 'waiting', }; case 'channel.created': return { type: 'channel.created', + ...withCreatedAt, ...(event.channel_id || d.id ? { channel_id: (event.channel_id ?? d.id) as string } : {}), channel: { name: (d.channel_name as string) ?? (d.name as string), topic: (d.topic as string | null) ?? null }, }; @@ -161,6 +182,7 @@ export function transformForClient(event: WsEvent): Record { case 'channel.updated': return { type: 'channel.updated', + ...withCreatedAt, ...(event.channel_id || d.id ? { channel_id: (event.channel_id ?? d.id) as string } : {}), channel: { name: (d.channel_name as string) ?? (d.name as string), topic: (d.topic as string | null) ?? null }, }; @@ -168,6 +190,7 @@ export function transformForClient(event: WsEvent): Record { case 'channel.archived': return { type: 'channel.archived', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: { name: d.channel_name as string }, }; @@ -175,23 +198,30 @@ export function transformForClient(event: WsEvent): Record { case 'member.joined': return { type: 'member.joined', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, + ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), agent_name: d.agent_name as string, }; case 'member.left': return { type: 'member.left', + ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), channel: d.channel_name as string, + ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), agent_name: d.agent_name as string, }; case 'message.read': return { type: 'message.read', + ...withCreatedAt, message_id: d.message_id as string, + ...(d.conversation_id ? { conversation_id: d.conversation_id as string } : {}), + ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), agent_name: d.agent_name as string, read_at: d.read_at as string, }; @@ -199,6 +229,7 @@ export function transformForClient(event: WsEvent): Record { case 'file.uploaded': return { type: 'file.uploaded', + ...withCreatedAt, file: { file_id: (d.file_id as string) ?? (d.id as string), filename: d.filename as string, @@ -209,6 +240,7 @@ export function transformForClient(event: WsEvent): Record { case 'webhook.received': return { type: 'webhook.received', + ...withCreatedAt, webhook_id: d.webhook_id as string, channel: d.channel as string, message: { @@ -222,6 +254,7 @@ export function transformForClient(event: WsEvent): Record { case 'command.invoked': return { type: 'command.invoked', + ...withCreatedAt, command: d.command as string, channel: d.channel as string, invoked_by: d.invoked_by as string, @@ -232,7 +265,7 @@ export function transformForClient(event: WsEvent): Record { default: { const { workspace_id: _workspace_id, channel_id: _channel_id, timestamp: _timestamp, data, ...rest } = event as WsEvent & Record; - return { ...rest, ...data }; + return { ...rest, ...data, ...withCreatedAt }; } } } diff --git a/packages/engine/src/routes/channel.ts b/packages/engine/src/routes/channel.ts index f5872b8c..38105665 100644 --- a/packages/engine/src/routes/channel.ts +++ b/packages/engine/src/routes/channel.ts @@ -305,7 +305,7 @@ channelRoutes.post( const channel = await channelEngine.getChannel(db, workspace.id, name); if (channel) { runInBackground(c, updateChannelMembers(c, channel.id, members.map((m) => m.agent_id)), 'update-members member.joined'); - const eventData = { channel_name: name, agent_name: agent!.name }; + const eventData = { channel_name: name, agent_id: agent!.id, agent_name: agent!.name }; runInBackground(c, fanoutToChannel(c, channel.id, 'member.joined', eventData), 'fanout member.joined'); } } catch { @@ -351,7 +351,7 @@ channelRoutes.post( try { const channel = await channelEngine.getChannel(db, workspace.id, name); if (channel) { - const eventData = { channel_name: name, agent_name: agent!.name }; + const eventData = { channel_name: name, agent_id: agent!.id, agent_name: agent!.name }; runInBackground(c, fanoutToChannel(c, channel.id, 'member.left', eventData), 'fanout member.left'); } } catch { @@ -455,7 +455,8 @@ channelRoutes.post( const channel = await channelEngine.getChannel(db, workspace.id, name); if (channel) { runInBackground(c, updateChannelMembers(c, channel.id, members.map((m) => m.agent_id)), 'update-members member.invited'); - const eventData = { channel_name: name, agent_name: agentName }; + const invitedMember = members.find((member) => member.agent_name === agentName); + const eventData = { channel_name: name, agent_id: invitedMember?.agent_id, agent_name: agentName }; runInBackground(c, fanoutToChannel(c, channel.id, 'member.joined', eventData), 'fanout member.invited'); } } catch { @@ -503,7 +504,7 @@ channelRoutes.post( const channel = await channelEngine.getChannel(db, workspace.id, name); if (channel) { runInBackground(c, updateChannelMuted(c, channel.id, mutedIds), 'update-muted member.channel_muted'); - const eventData = { channel_name: name, agent_name: agent!.name }; + const eventData = { channel_name: name, agent_id: agent!.id, agent_name: agent!.name }; runInBackground(c, fanoutToChannel(c, channel.id, 'member.channel_muted', eventData), 'fanout member.channel_muted'); } } catch { @@ -551,7 +552,7 @@ channelRoutes.post( const channel = await channelEngine.getChannel(db, workspace.id, name); if (channel) { runInBackground(c, updateChannelMuted(c, channel.id, mutedIds), 'update-muted member.channel_unmuted'); - const eventData = { channel_name: name, agent_name: agent!.name }; + const eventData = { channel_name: name, agent_id: agent!.id, agent_name: agent!.name }; runInBackground(c, fanoutToChannel(c, channel.id, 'member.channel_unmuted', eventData), 'fanout member.channel_unmuted'); } } catch { diff --git a/packages/engine/src/routes/console.ts b/packages/engine/src/routes/console.ts index d052d714..fd824e73 100644 --- a/packages/engine/src/routes/console.ts +++ b/packages/engine/src/routes/console.ts @@ -32,6 +32,21 @@ const agentStatsQuerySchema = z.object({ limit: positiveIntQueryParam({ defaultValue: 20, max: 100 }), }); +async function visibleMessageLogsForObserver( + db: Parameters[0], + workspaceId: string, + days: number, + observer: ReturnType, +) { + const logs = await consoleEngine.listMessageLogsForWindow(db, workspaceId, days); + return filterObserverSearchResults( + db, + workspaceId, + observer, + logs, + ); +} + consoleRoutes.get('/console/messages', requireWorkspaceRead('messages:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); @@ -75,7 +90,14 @@ consoleRoutes.get('/console/stats', requireWorkspaceRead('activity:read'), rateL return parsed.response; } - const data = await consoleEngine.getConsoleOverview(db, workspace.id, parsed.data.days); + const days = parsed.data.days ?? 7; + const observer = getObserverTokenFromContext(c); + const data = observer + ? consoleEngine.summarizeConsoleOverview( + await visibleMessageLogsForObserver(db, workspace.id, days, observer), + days, + ) + : await consoleEngine.getConsoleOverview(db, workspace.id, days); return jsonOk(c, data); } catch (err: unknown) { return errorResponse(c, err); @@ -91,12 +113,20 @@ consoleRoutes.get('/console/agents', requireWorkspaceRead('agents:read'), rateLi return parsed.response; } - const data = await consoleEngine.getAgentStats( - db, - workspace.id, - parsed.data.days, - parsed.data.limit, - ); + const days = parsed.data.days ?? 7; + const limit = parsed.data.limit ?? 20; + const observer = getObserverTokenFromContext(c); + const data = observer + ? consoleEngine.summarizeAgentStats( + await visibleMessageLogsForObserver(db, workspace.id, days, observer), + limit, + ) + : await consoleEngine.getAgentStats( + db, + workspace.id, + days, + limit, + ); return jsonOk(c, data); } catch (err: unknown) { return errorResponse(c, err); @@ -112,7 +142,14 @@ consoleRoutes.get('/console/costs', requireWorkspaceRead('activity:read'), rateL return parsed.response; } - const data = await consoleEngine.getCostStats(db, workspace.id, parsed.data.days); + const days = parsed.data.days ?? 7; + const observer = getObserverTokenFromContext(c); + const data = observer + ? consoleEngine.summarizeCostStats( + await visibleMessageLogsForObserver(db, workspace.id, days, observer), + days, + ) + : await consoleEngine.getCostStats(db, workspace.id, days); return jsonOk(c, data); } catch (err: unknown) { return errorResponse(c, err); diff --git a/packages/engine/src/routes/reaction.ts b/packages/engine/src/routes/reaction.ts index 10fd8e8a..20632475 100644 --- a/packages/engine/src/routes/reaction.ts +++ b/packages/engine/src/routes/reaction.ts @@ -5,7 +5,7 @@ import { requireAgentToken, requireWorkspaceRead } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as reactionEngine from '../engine/reaction.js'; import { and, eq } from 'drizzle-orm'; -import { channels, messages } from '../db/schema.js'; +import { channels, dmConversations, messages } from '../db/schema.js'; import { getMessageObserverResource, getObserverTokenFromContext, @@ -63,7 +63,20 @@ reactionRoutes.post( // Strip internal channel_id/channel_name before sending to client const { channel_id, channel_name, ...reactionData } = result; - const eventData = { ...reactionData, channel_name, agent_name: agent!.name, action: 'added' }; + const [dmConversation] = channel_id + ? await db + .select({ id: dmConversations.id }) + .from(dmConversations) + .where(eq(dmConversations.channelId, channel_id)) + : []; + const eventData = { + ...reactionData, + channel_name, + ...(dmConversation ? { conversation_id: dmConversation.id } : {}), + agent_id: agent!.id, + agent_name: agent!.name, + action: 'added', + }; if (channel_id) { runInBackground( c, @@ -99,7 +112,7 @@ reactionRoutes.post( await sendWebhookEvent(c, { type: 'message.reacted', workspaceId: workspace.id, - data: { ...reactionData, channel_id, channel_name, agent_name: agent!.name, action: 'added' }, + data: { ...eventData, channel_id }, }); emitServerEvent(c, workspace.id, 'relaycast_server_reaction_added', { message_id: c.req.param('id'), @@ -144,12 +157,17 @@ reactionRoutes.delete( }; try { const [row] = await db - .select({ channelId: messages.channelId, channelName: channels.name }) + .select({ channelId: messages.channelId, channelName: channels.name, conversationId: dmConversations.id }) .from(messages) .innerJoin(channels, eq(messages.channelId, channels.id)) + .leftJoin(dmConversations, eq(dmConversations.channelId, messages.channelId)) .where(and(eq(messages.id, c.req.param('id')), eq(channels.workspaceId, workspace.id))); if (row?.channelId) { - const enriched = { ...eventData, channel_name: row.channelName }; + const enriched = { + ...eventData, + channel_name: row.channelName, + ...(row.conversationId ? { conversation_id: row.conversationId } : {}), + }; runInBackground( c, (async () => { diff --git a/packages/engine/src/routes/receipt.ts b/packages/engine/src/routes/receipt.ts index fc2b8125..55a32025 100644 --- a/packages/engine/src/routes/receipt.ts +++ b/packages/engine/src/routes/receipt.ts @@ -4,7 +4,7 @@ import { requireAuth, requireAgentToken } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as receiptEngine from '../engine/receipt.js'; import { and, eq } from 'drizzle-orm'; -import { messages } from '../db/schema.js'; +import { dmConversations, messages } from '../db/schema.js'; import { fanoutToChannel } from './fanout.js'; import { sendNodeDeliveriesForChannel } from '../engine/nodeDeliver.js'; import { runInBackground } from './background.js'; @@ -38,11 +38,16 @@ receiptRoutes.post( const eventData = { ...result, agent_name: agent!.name }; try { const [row] = await db - .select({ channelId: messages.channelId }) + .select({ channelId: messages.channelId, conversationId: dmConversations.id }) .from(messages) + .leftJoin(dmConversations, eq(dmConversations.channelId, messages.channelId)) .where(and(eq(messages.id, c.req.param('id')), eq(messages.workspaceId, workspace.id))); if (row?.channelId) { - runInBackground(c, fanoutToChannel(c, row.channelId, 'message.read', eventData), 'fanout message.read'); + const enriched = { + ...eventData, + ...(row.conversationId ? { conversation_id: row.conversationId } : {}), + }; + runInBackground(c, fanoutToChannel(c, row.channelId, 'message.read', enriched), 'fanout message.read'); runInBackground( c, sendNodeDeliveriesForChannel( @@ -56,7 +61,7 @@ receiptRoutes.post( messageId: result.message_id, event: 'message.read', eventKey: `${result.message_id}:${result.agent_id}:${result.read_at}`, - data: eventData, + data: enriched, }, ), 'node deliver message.read', From 284b6c8674fbbd9cfb5618134837390f26cc631e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 09:40:16 -0400 Subject: [PATCH 05/15] Block observer access to DM channels --- .../conformance/observerToken.test.ts | 32 ++++++++++++++++++- packages/engine/src/engine/observerToken.ts | 8 +++-- packages/engine/src/routes/channel.ts | 19 +++++++---- packages/engine/src/routes/message.ts | 12 ++++--- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index b79ebfec..ee00db47 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { eq } from 'drizzle-orm'; import { @@ -27,6 +28,15 @@ async function createObserverToken( return { res, data: json.data!, body: json }; } +function dmChannelName(workspaceId: string, agentA: string, agentB: string): string { + const [first, second] = [agentA, agentB].sort(); + const pairKey = createHash('sha256') + .update(`${workspaceId}:${first}:${second}`) + .digest('hex') + .slice(0, 24); + return `dm-${pairKey}`; +} + describe('observer tokens', () => { let stack: TestStack; @@ -293,7 +303,7 @@ describe('observer tokens', () => { it('requires the right read scopes and enforces DM opt-in filters', async () => { const ws = await createWorkspace(stack.app, 'observer-dm-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); - await registerAgent(stack.app, ws.workspaceKey, 'bob'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); const dm = await stack.app.request('/v1/dm', { method: 'POST', @@ -326,6 +336,26 @@ describe('observer tokens', () => { expect(visible.status).toBe(200); expect(visibleBody.data).toHaveLength(1); + const derivedDmChannelName = dmChannelName(ws.workspaceId, alice.agentId, bob.agentId); + const channelPathObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'channel-path-dm-bypass', + scopes: ['messages:read', 'channels:read'], + }); + const channelPathMessages = await stack.app.request(`/v1/channels/${derivedDmChannelName}/messages`, { + headers: { authorization: `Bearer ${channelPathObserver.data.token}` }, + }); + expect(channelPathMessages.status).toBe(404); + + const channelPathDetails = await stack.app.request(`/v1/channels/${derivedDmChannelName}`, { + headers: { authorization: `Bearer ${channelPathObserver.data.token}` }, + }); + expect(channelPathDetails.status).toBe(404); + + const channelPathMembers = await stack.app.request(`/v1/channels/${derivedDmChannelName}/members`, { + headers: { authorization: `Bearer ${channelPathObserver.data.token}` }, + }); + expect(channelPathMembers.status).toBe(404); + const dmSearchWithoutScope = await createObserverToken(stack, ws.workspaceKey, { name: 'dm-search-without-scope', scopes: ['search:read'], diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index 7986a6f3..a4984e80 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -284,9 +284,11 @@ function filterList(values: string[] | undefined): Set | null { export function observerAllowsChannel( observer: Pick | undefined, - channel: { id?: string | null; name?: string | null }, + channel: { id?: string | null; name?: string | null; channel_type?: number | null; channelType?: number | null }, ): boolean { if (!observer) return true; + const channelType = channel.channel_type ?? channel.channelType; + if (channelType != null && channelType !== 0) return false; const filters = normalizeObserverFilters(observer.filters); const ids = filterList(filters.channel_ids); const names = filterList(filters.channel_names); @@ -430,9 +432,9 @@ export async function getChannelObserverResource( db: Db, workspaceId: string, name: string, -): Promise<{ id: string; name: string } | null> { +): Promise<{ id: string; name: string; channel_type: number } | null> { const [row] = await db - .select({ id: channels.id, name: channels.name }) + .select({ id: channels.id, name: channels.name, channel_type: channels.channelType }) .from(channels) .where(and(eq(channels.workspaceId, workspaceId), eq(channels.name, name))); return row ?? null; diff --git a/packages/engine/src/routes/channel.ts b/packages/engine/src/routes/channel.ts index 38105665..73a2b221 100644 --- a/packages/engine/src/routes/channel.ts +++ b/packages/engine/src/routes/channel.ts @@ -49,7 +49,10 @@ function channelNotFound(c: Parameters[0], name: string) { return jsonNotFound(c, 'channel_not_found', `Channel "${name}" not found`); } -function observerChannelAllowed(observer: ObserverToken | undefined, channel: { id?: string | null; name?: string | null }) { +function observerChannelAllowed( + observer: ObserverToken | undefined, + channel: { id?: string | null; name?: string | null; channel_type?: number | null; channelType?: number | null }, +) { return observerAllowsChannel(observer, channel); } @@ -137,13 +140,15 @@ channelRoutes.get( const db = c.get('db'); const workspace = c.get('workspace'); const name = c.req.param('name'); - const channel = await channelEngine.getChannel(db, workspace.id, name); - if (!channel) { - return channelNotFound(c, name); - } - if (!observerChannelAllowed(getObserverTokenFromContext(c), channel)) { - return channelNotFound(c, name); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getChannelObserverResource(db, workspace.id, name); + if (!resource || !observerChannelAllowed(observer, resource)) { + return channelNotFound(c, name); + } } + const channel = await channelEngine.getChannel(db, workspace.id, name); + if (!channel) return channelNotFound(c, name); return jsonOk(c, channel); } catch (err: unknown) { return errorResponse(c, err); diff --git a/packages/engine/src/routes/message.ts b/packages/engine/src/routes/message.ts index 160ac9d9..d4a6f914 100644 --- a/packages/engine/src/routes/message.ts +++ b/packages/engine/src/routes/message.ts @@ -12,6 +12,7 @@ import { publishWorkspaceEvent } from './fanout.js'; import { notifyDeliveryRejections, routeDeliveryOutcomes } from './deliveryRouting.js'; import { buildMessageCreatedEventData } from '../engine/deliveryWire.js'; import { + getChannelObserverResource, getMessageObserverResource, getObserverTokenFromContext, hasObserverScope, @@ -200,14 +201,17 @@ messageRoutes.get( const db = c.get('db'); const workspace = c.get('workspace'); const channelName = c.req.param('name'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getChannelObserverResource(db, workspace.id, channelName); + if (!resource || !observerAllowsChannel(observer, resource)) { + return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); + } + } const channel = await channelEngine.getChannel(db, workspace.id, channelName); if (!channel) { return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); } - const observer = getObserverTokenFromContext(c); - if (!observerAllowsChannel(observer, channel)) { - return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`); - } const query = parsePaginationQuery(c); if (!query.ok) { From c0cd16cb19170e948e314402049353b54a8d6744 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 09:50:57 -0400 Subject: [PATCH 06/15] Align observer file and stream filters --- .../conformance/observerToken.test.ts | 189 +++++++++++++++++- packages/engine/src/engine/observerToken.ts | 147 ++++++++++++-- packages/engine/src/engine/wsTransform.ts | 3 + packages/engine/src/routes/agent.ts | 6 +- packages/engine/src/routes/file.ts | 24 ++- packages/engine/src/routes/thread.ts | 4 - 6 files changed, 346 insertions(+), 27 deletions(-) diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index ee00db47..2f33eebd 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -37,6 +37,26 @@ function dmChannelName(workspaceId: string, agentA: string, agentB: string): str return `dm-${pairKey}`; } +async function uploadCompletedFile(stack: TestStack, token: string, filename: string) { + const upload = await stack.app.request('/v1/files/upload', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + filename, + content_type: 'text/plain', + size_bytes: 12, + }), + }); + expect(upload.status).toBe(201); + const uploadBody = await upload.json() as { data: { id: string } }; + const complete = await stack.app.request(`/v1/files/${uploadBody.data.id}/complete`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + expect(complete.status).toBe(200); + return uploadBody.data.id; +} + describe('observer tokens', () => { let stack: TestStack; @@ -65,6 +85,17 @@ describe('observer tokens', () => { expect(listed.data[0].id).toBe(created.data.id); expect(listed.data[0].token).toBeUndefined(); + await stack.runtime.deps.db + .update(observerTokens) + .set({ scopes: ['stream:read', 'retired:read'] }) + .where(eq(observerTokens.id, created.data.id)); + const legacyScope = await stack.app.request(`/v1/observer-tokens/${created.data.id}`, { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const legacyScopeBody = await legacyScope.json() as { data: { scopes: string[] } }; + expect(legacyScope.status).toBe(200); + expect(legacyScopeBody.data.scopes).toEqual(['stream:read']); + const rotate = await stack.app.request(`/v1/observer-tokens/${created.data.id}/rotate`, { method: 'POST', headers: { authorization: `Bearer ${ws.workspaceKey}` }, @@ -272,6 +303,24 @@ describe('observer tokens', () => { expect(futureSearch.status).toBe(200); expect(futureSearchBody.data).toHaveLength(0); + const event = await stack.app.request('/v1/agents/alice/events', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ type: 'log', payload: { message: 'old event' } }), + }); + expect(event.status).toBe(201); + const futureActivity = await createObserverToken(stack, ws.workspaceKey, { + name: 'future-activity', + scopes: ['activity:read'], + filters: { agent_ids: [alice.agentId], created_after: '2999-01-01T00:00:00.000Z' }, + }); + const futureEvents = await stack.app.request('/v1/agents/alice/events', { + headers: { authorization: `Bearer ${futureActivity.data.token}` }, + }); + const futureEventsBody = await futureEvents.json() as { data: unknown[] }; + expect(futureEvents.status).toBe(200); + expect(futureEventsBody.data).toHaveLength(0); + const reactionsWithMessageScope = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { headers: { authorization: `Bearer ${token}` }, }); @@ -394,6 +443,93 @@ describe('observer tokens', () => { }); }); + it('filters file metadata by observer channel, agent, and time filters', async () => { + const ws = await createWorkspace(stack.app, 'observer-file-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); + + const createTeam = await stack.app.request('/v1/channels', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ name: 'team-chat' }), + }); + expect(createTeam.status).toBe(201); + + const aliceGeneralFileId = await uploadCompletedFile(stack, alice.token, 'general.txt'); + const bobTeamFileId = await uploadCompletedFile(stack, bob.token, 'team.txt'); + const aliceLooseFileId = await uploadCompletedFile(stack, alice.token, 'loose.txt'); + + const generalMessage = await stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, + body: JSON.stringify({ text: 'general file', attachments: [aliceGeneralFileId] }), + }); + expect(generalMessage.status).toBe(201); + + const teamMessage = await stack.app.request('/v1/channels/team-chat/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, + body: JSON.stringify({ text: 'team file', attachments: [bobTeamFileId] }), + }); + expect(teamMessage.status).toBe(201); + + const generalAliceFiles = await createObserverToken(stack, ws.workspaceKey, { + name: 'general-alice-files', + scopes: ['files:read'], + filters: { channel_names: ['general'], agent_ids: [alice.agentId] }, + }); + const visibleFiles = await stack.app.request('/v1/files', { + headers: { authorization: `Bearer ${generalAliceFiles.data.token}` }, + }); + const visibleFilesBody = await visibleFiles.json() as { data: Array<{ id: string }> }; + expect(visibleFiles.status).toBe(200); + expect(visibleFilesBody.data.map((file) => file.id)).toEqual([aliceGeneralFileId]); + + const visibleSingle = await stack.app.request(`/v1/files/${aliceGeneralFileId}`, { + headers: { authorization: `Bearer ${generalAliceFiles.data.token}` }, + }); + expect(visibleSingle.status).toBe(200); + + const hiddenByChannel = await stack.app.request(`/v1/files/${bobTeamFileId}`, { + headers: { authorization: `Bearer ${generalAliceFiles.data.token}` }, + }); + expect(hiddenByChannel.status).toBe(404); + + const hiddenUnattached = await stack.app.request(`/v1/files/${aliceLooseFileId}`, { + headers: { authorization: `Bearer ${generalAliceFiles.data.token}` }, + }); + expect(hiddenUnattached.status).toBe(404); + + const aliceFiles = await createObserverToken(stack, ws.workspaceKey, { + name: 'alice-files', + scopes: ['files:read'], + filters: { agent_ids: [alice.agentId] }, + }); + const aliceFileList = await stack.app.request('/v1/files', { + headers: { authorization: `Bearer ${aliceFiles.data.token}` }, + }); + const aliceFileListBody = await aliceFileList.json() as { data: Array<{ id: string }> }; + expect(aliceFileList.status).toBe(200); + expect(aliceFileListBody.data.map((file) => file.id).sort()).toEqual([aliceLooseFileId, aliceGeneralFileId].sort()); + + const futureFiles = await createObserverToken(stack, ws.workspaceKey, { + name: 'future-files', + scopes: ['files:read'], + filters: { created_after: '2999-01-01T00:00:00.000Z' }, + }); + const futureFileList = await stack.app.request('/v1/files', { + headers: { authorization: `Bearer ${futureFiles.data.token}` }, + }); + const futureFileListBody = await futureFileList.json() as { data: unknown[] }; + expect(futureFileList.status).toBe(200); + expect(futureFileListBody.data).toHaveLength(0); + + const futureSingle = await stack.app.request(`/v1/files/${aliceGeneralFileId}`, { + headers: { authorization: `Bearer ${futureFiles.data.token}` }, + }); + expect(futureSingle.status).toBe(404); + }); + it('requires stream-scoped observer tokens for workspace WebSockets and filters events per socket', async () => { const ws = await createWorkspace(stack.app, 'observer-stream-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); @@ -403,8 +539,14 @@ describe('observer tokens', () => { body: JSON.stringify({ name: 'team-chat' }), }); - const observer = await createObserverToken(stack, ws.workspaceKey, { - name: 'stream-general', + const generalChannel = await stack.app.request('/v1/channels/general', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const generalChannelBody = await generalChannel.json() as { data: { id: string } }; + expect(generalChannel.status).toBe(200); + + const streamOnly = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-only', scopes: ['stream:read'], filters: { channel_names: ['general'] }, }); @@ -413,9 +555,21 @@ describe('observer tokens', () => { ok: false, message: 'Observer token required for workspace stream', }); - const accepted = await authenticateRealtimeWs(auth, observer.data.token!); + const accepted = await authenticateRealtimeWs(auth, streamOnly.data.token!); expect(accepted.ok).toBe(true); + const [streamOnlyRow] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, streamOnly.data.id)); + const streamOnlySock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, streamOnlySock, streamOnlyRow); + + const observer = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-general', + scopes: ['stream:read', 'messages:read'], + filters: { channel_names: ['general'] }, + }); const [row] = await stack.runtime.deps.db .select() .from(observerTokens) @@ -423,23 +577,48 @@ describe('observer tokens', () => { const sock = new FakeSocket(); stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, sock, row); - await stack.app.request('/v1/channels/general/messages', { + const readObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-general-id-reads', + scopes: ['stream:read', 'messages:read'], + filters: { channel_ids: [generalChannelBody.data.id], event_types: ['message.read'] }, + }); + const [readRow] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, readObserver.data.id)); + const readSock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, readSock, readRow); + + const generalMessage = await stack.app.request('/v1/channels/general/messages', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, body: JSON.stringify({ text: 'stream visible' }), }); + const generalMessageBody = await generalMessage.json() as { data: { id: string } }; await stack.app.request('/v1/channels/team-chat/messages', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, body: JSON.stringify({ text: 'stream hidden' }), }); + const read = await stack.app.request(`/v1/messages/${generalMessageBody.data.id}/read`, { + method: 'POST', + headers: { authorization: `Bearer ${alice.token}` }, + }); + expect(read.status).toBe(200); await new Promise((resolve) => setTimeout(resolve, 50)); + expect(streamOnlySock.ofType('message.created')).toHaveLength(0); + expect(streamOnlySock.ofType('message.read')).toHaveLength(0); expect(sock.ofType('message.created')).toHaveLength(1); expect(sock.ofType('message.created')[0]).toMatchObject({ channel: 'general', message: { text: 'stream visible' }, }); + expect(readSock.ofType('message.read')).toHaveLength(1); + expect(readSock.ofType('message.read')[0]).toMatchObject({ + channel_id: generalChannelBody.data.id, + message_id: generalMessageBody.data.id, + }); }); it('allows DM stream events when channel filters are present and include_dms is enabled', async () => { @@ -480,7 +659,7 @@ describe('observer tokens', () => { const observer = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms', - scopes: ['stream:read', 'dms:read'], + scopes: ['stream:read', 'dms:read', 'reactions:read'], filters: { channel_names: ['general'], include_dms: true }, }); diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index a4984e80..717e9dfe 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -1,6 +1,6 @@ -import { and, eq, or, gt, isNull, inArray } from 'drizzle-orm'; +import { and, eq, or, gt, isNull, inArray, ne } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { channels, dmConversations, messages, observerTokens } from '../db/schema.js'; +import { channels, dmConversations, files as fileRows, messageAttachments, messages, observerTokens } from '../db/schema.js'; import type { ObserverTokenFilters } from '../db/schema.js'; import { randomHex, sha256Hex } from '../lib/crypto.js'; import { codedError } from '../lib/httpError.js'; @@ -8,6 +8,7 @@ import { generateId } from './snowflake.js'; type Db = ReturnType; export type ObserverToken = typeof observerTokens.$inferSelect; +const LAST_USED_DEBOUNCE_MS = 30_000; export const OBSERVER_SCOPES = [ 'stream:read', @@ -92,12 +93,17 @@ function normalizeScopes(scopes: string[]): ObserverScope[] { return unique as ObserverScope[]; } +function publicScopes(scopes: string[] | null | undefined): ObserverScope[] { + const unique = [...new Set(scopes ?? [])]; + return unique.filter((scope): scope is ObserverScope => OBSERVER_SCOPE_SET.has(scope)); +} + function publicObserverToken(row: ObserverToken, token?: string): PublicObserverToken { return { id: row.id, name: row.name, description: row.description, - scopes: normalizeScopes(row.scopes ?? []), + scopes: publicScopes(row.scopes), filters: normalizeObserverFilters(row.filters), status: row.status, expires_at: row.expiresAt?.toISOString() ?? null, @@ -148,6 +154,7 @@ export async function createObserverToken( scopes, filters, expiresAt, + createdBy: workspaceId, createdByType: 'workspace', }) .returning(); @@ -256,17 +263,19 @@ export async function getActiveObserverTokenByHash( )); if (!row) return null; - void (async () => { - try { - await db - .update(observerTokens) - .set({ lastUsedAt: now }) - .where(eq(observerTokens.id, row.id)); - } catch { - // last_used_at is best-effort audit metadata; authentication should not fail - // solely because a read replica or adapter rejects this update. - } - })(); + if (!row.lastUsedAt || now.getTime() - row.lastUsedAt.getTime() > LAST_USED_DEBOUNCE_MS) { + void (async () => { + try { + await db + .update(observerTokens) + .set({ lastUsedAt: now }) + .where(eq(observerTokens.id, row.id)); + } catch { + // last_used_at is best-effort audit metadata; authentication should not fail + // solely because a read replica or adapter rejects this update. + } + })(); + } return row; } @@ -348,6 +357,39 @@ export function observerAllowsMessage( && observerAllowsCreatedAt(observer, message.created_at ?? message.createdAt); } +export type FileObserverResource = { + id: string; + uploaded_by_id: string; + created_at: string; + attachments: Array<{ + channel_id: string; + channel_name: string; + channel_type: number; + conversation_id: string | null; + }>; +}; + +export function observerAllowsFile( + observer: Pick | undefined, + file: FileObserverResource, +): boolean { + if (!observer) return true; + if (!observerAllowsAgent(observer, file.uploaded_by_id)) return false; + if (!observerAllowsCreatedAt(observer, file.created_at)) return false; + if (file.attachments.length === 0) { + return observerAllowsChannel(observer, {}); + } + return file.attachments.some((attachment) => ( + attachment.conversation_id + ? observerAllowsConversation(observer, attachment.conversation_id) + : observerAllowsChannel(observer, { + id: attachment.channel_id, + name: attachment.channel_name, + channel_type: attachment.channel_type, + }) + )); +} + function nestedRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -376,6 +418,19 @@ function eventCreatedAt(event: Record): string | Date | null { ?? null; } +function eventRequiredScopes(type: string): ObserverScope[] { + if (type === 'message.created' || type === 'message.updated' || type === 'message.read' || type === 'webhook.received') return ['messages:read']; + if (type === 'thread.reply') return ['threads:read']; + if (type === 'message.reacted') return ['reactions:read']; + if (type === 'dm.received' || type === 'group_dm.received') return ['dms:read']; + if (type === 'file.uploaded') return ['files:read']; + if (type.startsWith('agent.status.')) return ['agents:read']; + if (type.startsWith('member.') || type.startsWith('channel.')) return ['channels:read']; + if (type.startsWith('delivery.')) return ['deliveries:read']; + if (type.startsWith('node.')) return ['nodes:read']; + return ['activity:read']; +} + export function observerAllowsEvent( observer: Pick | undefined, event: Record, @@ -388,6 +443,10 @@ export function observerAllowsEvent( return false; } + if (!hasAnyObserverScope(observer, eventRequiredScopes(type))) { + return false; + } + if (!observerAllowsCreatedAt(observer, eventCreatedAt(event))) { return false; } @@ -440,6 +499,66 @@ export async function getChannelObserverResource( return row ?? null; } +export async function getFileObserverResource( + db: Db, + workspaceId: string, + fileId: string, +): Promise { + const [file] = await db + .select({ + id: fileRows.id, + uploaded_by_id: fileRows.uploadedBy, + created_at: fileRows.createdAt, + }) + .from(fileRows) + .where(and( + eq(fileRows.id, fileId), + eq(fileRows.workspaceId, workspaceId), + ne(fileRows.status, 'deleted'), + )); + if (!file) return null; + + const attachments = await db + .select({ + channel_id: channels.id, + channel_name: channels.name, + channel_type: channels.channelType, + conversation_id: dmConversations.id, + }) + .from(messageAttachments) + .innerJoin(messages, eq(messageAttachments.messageId, messages.id)) + .innerJoin(channels, eq(messages.channelId, channels.id)) + .leftJoin(dmConversations, eq(dmConversations.channelId, channels.id)) + .where(and( + eq(messageAttachments.fileId, fileId), + eq(messages.workspaceId, workspaceId), + )); + + return { + id: file.id, + uploaded_by_id: file.uploaded_by_id, + created_at: file.created_at.toISOString(), + attachments, + }; +} + +export async function filterObserverFileResults( + db: Db, + workspaceId: string, + observer: Pick | undefined, + results: T[], +): Promise { + if (!observer || results.length === 0) return results; + const visible: T[] = []; + for (const result of results) { + const resource = await getFileObserverResource(db, workspaceId, result.id); + if (resource && observerAllowsFile(observer, resource)) { + visible.push(result); + } + } + return visible; +} + export async function filterObserverSearchResults { return { type: 'message.read', ...withCreatedAt, + ...(event.channel_id ? { channel_id: event.channel_id } : {}), message_id: d.message_id as string, ...(d.conversation_id ? { conversation_id: d.conversation_id as string } : {}), ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), @@ -230,6 +231,7 @@ export function transformForClient(event: WsEvent): Record { return { type: 'file.uploaded', ...withCreatedAt, + ...(d.agent_id ? { agent_id: d.agent_id as string } : {}), file: { file_id: (d.file_id as string) ?? (d.id as string), filename: d.filename as string, @@ -241,6 +243,7 @@ export function transformForClient(event: WsEvent): Record { return { type: 'webhook.received', ...withCreatedAt, + ...(event.channel_id ? { channel_id: event.channel_id } : {}), webhook_id: d.webhook_id as string, channel: d.channel as string, message: { diff --git a/packages/engine/src/routes/agent.ts b/packages/engine/src/routes/agent.ts index 174be771..5f1b38bc 100644 --- a/packages/engine/src/routes/agent.ts +++ b/packages/engine/src/routes/agent.ts @@ -12,6 +12,7 @@ import * as sessionEventEngine from '../engine/sessionEvent.js'; import { getObserverTokenFromContext, observerAllowsAgent, + observerAllowsCreatedAt, } from '../engine/observerToken.js'; import { fanoutToWorkspace } from './fanout.js'; import { sendNodePresenceContext } from '../engine/nodeContext.js'; @@ -568,7 +569,8 @@ agentRoutes.get( if (!agent) { return agentNotFound(c, name); } - if (!observerAllowsAgent(getObserverTokenFromContext(c), agent.id)) { + const observer = getObserverTokenFromContext(c); + if (!observerAllowsAgent(observer, agent.id)) { return agentNotFound(c, name); } @@ -577,7 +579,7 @@ agentRoutes.get( limit, }); - return jsonOk(c, events); + return jsonOk(c, events.filter((event) => observerAllowsCreatedAt(observer, event.created_at))); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/file.ts b/packages/engine/src/routes/file.ts index 3eb660a3..e79f1e6e 100644 --- a/packages/engine/src/routes/file.ts +++ b/packages/engine/src/routes/file.ts @@ -15,6 +15,12 @@ import { LimitQuerySchema } from '../lib/httpQuery.js'; import { requireWorkspaceRead, requireAgentToken } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import * as fileEngine from '../engine/file.js'; +import { + filterObserverFileResults, + getFileObserverResource, + getObserverTokenFromContext, + observerAllowsFile, +} from '../engine/observerToken.js'; import { fanoutToWorkspace } from './fanout.js'; import { runInBackground } from './background.js'; import { sendWebhookEvent } from './webhookOutbox.js'; @@ -124,12 +130,20 @@ fileRoutes.get('/files/:id', requireWorkspaceRead('files:read'), rateLimit, asyn const db = c.get('db'); const workspace = c.get('workspace'); const storage = c.get('engine').files; + const fileId = c.req.param('id'); + const observer = getObserverTokenFromContext(c); + if (observer) { + const resource = await getFileObserverResource(db, workspace.id, fileId); + if (!resource || !observerAllowsFile(observer, resource)) { + return jsonNotFound(c, 'file_not_found', 'File not found'); + } + } const result = await fileEngine.getFile( db, storage, workspace.id, - c.req.param('id'), + fileId, ); if (!result) { return jsonNotFound(c, 'file_not_found', 'File not found'); @@ -183,8 +197,14 @@ fileRoutes.get('/files', requireWorkspaceRead('files:read'), rateLimit, async (c uploaded_by, limit, }); + const visible = await filterObserverFileResults( + db, + workspace.id, + getObserverTokenFromContext(c), + result, + ); - return jsonOk(c, result); + return jsonOk(c, visible); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/thread.ts b/packages/engine/src/routes/thread.ts index 8fd7bce3..4aae5e21 100644 --- a/packages/engine/src/routes/thread.ts +++ b/packages/engine/src/routes/thread.ts @@ -176,10 +176,6 @@ threadRoutes.get( const replies = result.replies.filter((reply) => observerAllowsMessage(observer, reply)); return jsonOk(c, { ...result, - parent: { - ...result.parent, - reply_count: replies.length, - }, replies, }); } catch (err: unknown) { From 38e945501ac6a00389ec5682758f8e864a3568d2 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 10:04:14 -0400 Subject: [PATCH 07/15] Close observer stream DM thread gaps --- README.md | 1 + openapi.yaml | 4 +- .../conformance/observerToken.test.ts | 53 ++++++++++++- packages/engine/src/engine/observerToken.ts | 74 +++++++++++++------ packages/engine/src/engine/thread.ts | 1 + packages/engine/src/engine/wsTransform.ts | 1 + 6 files changed, 109 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f05dd2bd..44b41319 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ Realtime transport: - `/v1/node/ws` is the node control/delivery stream and requires a node token. - Agent SDKs use `at_live_*` for REST, mint a direct `nt_live_*` token, and receive realtime events as node `deliver` frames. - Workspace keys create, rotate, list, and revoke observer tokens at `/v1/observer-tokens`; observer tokens are read-only and cannot mutate workspace state. Observer scopes grant read capabilities, while filters narrow resources. DM content requires both `dms:read` and `filters.include_dms: true`. Channel filters apply only to channel-scoped events; workspace-wide presence/status events require matching `agent_ids` or no agent filter. +- `file.uploaded` stream events are emitted when the upload completes, before any message attachment exists, so observer filtering for that event is limited to `files:read`, `agent_ids`, `event_types`, and `created_after`. Channel and DM visibility are enforced when files are read through REST or as message attachments. Core endpoints: diff --git a/openapi.yaml b/openapi.yaml index 841c3fc8..54a5bd30 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -807,7 +807,7 @@ components: created_after: type: string format: date-time - description: Filters narrow the resources visible to a scoped observer token. Channel filters apply to channel-scoped resources; workspace-wide presence/status events have no channel and are matched by `agent_ids` when present. + description: Filters narrow the resources visible to a scoped observer token. Channel filters apply to channel-scoped resources; workspace-wide presence/status events have no channel and are matched by `agent_ids` when present. `file.uploaded` stream events are upload-time events emitted before message attachment, so channel and DM filters are enforced on later file REST reads and message attachment reads instead. status: type: string enum: [active, revoked] @@ -4004,7 +4004,7 @@ paths: /ws: get: summary: Workspace observer WebSocket stream - description: Upgrade to the workspace observer realtime stream with an `ot_live_*` token that has `stream:read`. Workspace, agent, and node tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. Prefer Authorization headers when a WebSocket client supports them; query-param tokens can appear in access logs. + description: Upgrade to the workspace observer realtime stream with an `ot_live_*` token that has `stream:read`. Workspace, agent, and node tokens cannot open this stream; agent SDK realtime uses `/v1/node/ws` with a direct node token. Prefer Authorization headers when a WebSocket client supports them; query-param tokens can appear in access logs. `file.uploaded` is emitted at upload completion before channel or DM attachment exists; channel and DM visibility are enforced on file REST reads and message attachment reads. tags: - System security: diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index 2f33eebd..7d1c7117 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -110,6 +110,19 @@ describe('observer tokens', () => { headers: { authorization: `Bearer ${ws.workspaceKey}` }, }); expect(revoke.status).toBe(204); + + const rotateRevoked = await stack.app.request(`/v1/observer-tokens/${created.data.id}/rotate`, { + method: 'POST', + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(rotateRevoked.status).toBe(404); + + const updateRevoked = await stack.app.request(`/v1/observer-tokens/${created.data.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ description: 'should not update revoked tokens' }), + }); + expect(updateRevoked.status).toBe(404); }); it('rejects non-ISO observer token timestamps', async () => { @@ -624,7 +637,7 @@ describe('observer tokens', () => { it('allows DM stream events when channel filters are present and include_dms is enabled', async () => { const ws = await createWorkspace(stack.app, 'observer-stream-dm-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); - await registerAgent(stack.app, ws.workspaceKey, 'bob'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); const withoutDmScope = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms-without-scope', @@ -638,6 +651,18 @@ describe('observer tokens', () => { const withoutDmScopeSock = new FakeSocket(); stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, withoutDmScopeSock, withoutDmScopeRow); + const threadWithoutDmScope = await createObserverToken(stack, ws.workspaceKey, { + name: 'stream-dm-threads-without-scope', + scopes: ['stream:read', 'threads:read'], + filters: { channel_names: ['general'], include_dms: true }, + }); + const [threadWithoutDmScopeRow] = await stack.runtime.deps.db + .select() + .from(observerTokens) + .where(eq(observerTokens.id, threadWithoutDmScope.data.id)); + const threadWithoutDmScopeSock = new FakeSocket(); + stack.runtime.realtime.attachWorkspaceSocket(ws.workspaceId, threadWithoutDmScopeSock, threadWithoutDmScopeRow); + const hiddenDm = await stack.app.request('/v1/dm', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, @@ -653,13 +678,22 @@ describe('observer tokens', () => { }); expect(hiddenDmReaction.status).toBe(201); + const hiddenDmReply = await stack.app.request(`/v1/messages/${hiddenDmBody.data.id}/replies`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, + body: JSON.stringify({ text: 'dm thread reply hidden without dms:read' }), + }); + expect(hiddenDmReply.status).toBe(201); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); + expect(threadWithoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); const observer = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms', - scopes: ['stream:read', 'dms:read', 'reactions:read'], + scopes: ['stream:read', 'dms:read', 'reactions:read', 'threads:read'], filters: { channel_names: ['general'], include_dms: true }, }); @@ -685,6 +719,13 @@ describe('observer tokens', () => { }); expect(visibleDmReaction.status).toBe(201); + const visibleDmReply = await stack.app.request(`/v1/messages/${dmBody.data.id}/replies`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, + body: JSON.stringify({ text: 'dm thread reply visible with dms:read' }), + }); + expect(visibleDmReply.status).toBe(201); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(sock.ofType('dm.received')).toHaveLength(1); expect(sock.ofType('dm.received')[0]).toMatchObject({ @@ -696,7 +737,15 @@ describe('observer tokens', () => { message_id: dmBody.data.id, emoji: 'thumbsup', }); + expect(sock.ofType('thread.reply')).toHaveLength(1); + expect(sock.ofType('thread.reply')[0]).toMatchObject({ + conversation_id: expect.any(String), + parent_id: dmBody.data.id, + message: { text: 'dm thread reply visible with dms:read' }, + }); expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); + expect(threadWithoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); }); }); diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index 717e9dfe..83583a68 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -209,7 +209,11 @@ export async function updateObserverToken( [row] = await db .update(observerTokens) .set(update) - .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .where(and( + eq(observerTokens.workspaceId, workspaceId), + eq(observerTokens.id, id), + eq(observerTokens.status, 'active'), + )) .returning(); } catch (err: unknown) { if (isObserverTokenNameConflict(err)) observerTokenNameConflict(); @@ -229,7 +233,11 @@ export async function rotateObserverToken( const [row] = await db .update(observerTokens) .set({ tokenHash, updatedAt: new Date(), lastUsedAt: null }) - .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .where(and( + eq(observerTokens.workspaceId, workspaceId), + eq(observerTokens.id, id), + eq(observerTokens.status, 'active'), + )) .returning(); return row ? publicObserverToken(row, token) : null; } @@ -280,7 +288,7 @@ export async function getActiveObserverTokenByHash( } export function hasObserverScope(observer: Pick, scope: ObserverScope): boolean { - return (observer.scopes ?? []).includes(scope); + return publicScopes(observer.scopes).includes(scope); } export function hasAnyObserverScope(observer: Pick, scopes: ObserverScope[]): boolean { @@ -504,7 +512,19 @@ export async function getFileObserverResource( workspaceId: string, fileId: string, ): Promise { - const [file] = await db + return (await getFileObserverResources(db, workspaceId, [fileId])).get(fileId) ?? null; +} + +export async function getFileObserverResources( + db: Db, + workspaceId: string, + fileIds: string[], +): Promise> { + const ids = [...new Set(fileIds)].filter((id) => id.length > 0); + const resources = new Map(); + if (ids.length === 0) return resources; + + const files = await db .select({ id: fileRows.id, uploaded_by_id: fileRows.uploadedBy, @@ -512,14 +532,23 @@ export async function getFileObserverResource( }) .from(fileRows) .where(and( - eq(fileRows.id, fileId), eq(fileRows.workspaceId, workspaceId), ne(fileRows.status, 'deleted'), + inArray(fileRows.id, ids), )); - if (!file) return null; + for (const file of files) { + resources.set(file.id, { + id: file.id, + uploaded_by_id: file.uploaded_by_id, + created_at: file.created_at.toISOString(), + attachments: [], + }); + } + if (resources.size === 0) return resources; const attachments = await db .select({ + file_id: messageAttachments.fileId, channel_id: channels.id, channel_name: channels.name, channel_type: channels.channelType, @@ -530,16 +559,22 @@ export async function getFileObserverResource( .innerJoin(channels, eq(messages.channelId, channels.id)) .leftJoin(dmConversations, eq(dmConversations.channelId, channels.id)) .where(and( - eq(messageAttachments.fileId, fileId), eq(messages.workspaceId, workspaceId), + inArray(messageAttachments.fileId, [...resources.keys()]), )); - return { - id: file.id, - uploaded_by_id: file.uploaded_by_id, - created_at: file.created_at.toISOString(), - attachments, - }; + for (const attachment of attachments) { + const resource = resources.get(attachment.file_id); + if (!resource) continue; + resource.attachments.push({ + channel_id: attachment.channel_id, + channel_name: attachment.channel_name, + channel_type: attachment.channel_type, + conversation_id: attachment.conversation_id, + }); + } + + return resources; } export async function filterObserverFileResults( @@ -549,14 +584,11 @@ export async function filterObserverFileResults( results: T[], ): Promise { if (!observer || results.length === 0) return results; - const visible: T[] = []; - for (const result of results) { - const resource = await getFileObserverResource(db, workspaceId, result.id); - if (resource && observerAllowsFile(observer, resource)) { - visible.push(result); - } - } - return visible; + const resources = await getFileObserverResources(db, workspaceId, results.map((result) => result.id)); + return results.filter((result) => { + const resource = resources.get(result.id); + return Boolean(resource && observerAllowsFile(observer, resource)); + }); } export async function filterObserverSearchResults { type: 'thread.reply', ...withCreatedAt, ...(event.channel_id ? { channel_id: event.channel_id } : {}), + ...(d.conversation_id ? { conversation_id: d.conversation_id as string } : {}), channel: d.channel_name as string, parent_id: d.thread_id as string, message: { From 3eb1bcf41cbf9ef81e680da5e8aa0ddc588afe19 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 10:18:57 -0400 Subject: [PATCH 08/15] Filter observer node and channel results --- README.md | 4 + openapi.yaml | 2 +- .../conformance/observerToken.test.ts | 142 +++++++++++++++++- packages/engine/src/engine/activity.ts | 2 + packages/engine/src/engine/console.ts | 4 + packages/engine/src/engine/observerToken.ts | 18 ++- packages/engine/src/engine/search.ts | 4 +- packages/engine/src/routes/console.ts | 3 +- packages/engine/src/routes/node.ts | 62 +++++++- packages/engine/src/routes/search.ts | 6 +- packages/engine/src/routes/workspace.ts | 3 +- packages/types/src/workspace.ts | 1 + 12 files changed, 235 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 44b41319..b874cdd5 100644 --- a/README.md +++ b/README.md @@ -362,8 +362,12 @@ POST /messages/:id/replies POST /dm GET /inbox GET /search +GET /activity ``` +Activity feed channel-message items include `channel_id` and `channel_name`; DM items include +`conversation_id`. + Durable delivery (server-backed, per-recipient delivery contract): ```text diff --git a/openapi.yaml b/openapi.yaml index 54a5bd30..56711ff7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2599,7 +2599,7 @@ paths: /activity: get: summary: Get workspace activity feed - description: Get recent workspace activity events + description: Get recent workspace activity events. Channel message items include `channel_id` and `channel_name`; DM items include `conversation_id`. tags: - Workspaces security: diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index 7d1c7117..8976f16e 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -111,6 +111,12 @@ describe('observer tokens', () => { }); expect(revoke.status).toBe(204); + const revokeAgain = await stack.app.request(`/v1/observer-tokens/${created.data.id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(revokeAgain.status).toBe(404); + const rotateRevoked = await stack.app.request(`/v1/observer-tokens/${created.data.id}/rotate`, { method: 'POST', headers: { authorization: `Bearer ${ws.workspaceKey}` }, @@ -174,6 +180,12 @@ describe('observer tokens', () => { }); expect(createTeam.status).toBe(201); + const generalChannel = await stack.app.request('/v1/channels/general', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const generalChannelBody = await generalChannel.json() as { data: { id: string } }; + expect(generalChannel.status).toBe(200); + const general = await stack.app.request('/v1/channels/general/messages', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.token}` }, @@ -334,6 +346,21 @@ describe('observer tokens', () => { expect(futureEvents.status).toBe(200); expect(futureEventsBody.data).toHaveLength(0); + const channelIdActivity = await createObserverToken(stack, ws.workspaceKey, { + name: 'general-activity-by-id', + scopes: ['activity:read'], + filters: { channel_ids: [generalChannelBody.data.id] }, + }); + const activity = await stack.app.request('/v1/activity', { + headers: { authorization: `Bearer ${channelIdActivity.data.token}` }, + }); + const activityBody = await activity.json() as { data: Array<{ text: string; channel_id?: string }> }; + expect(activity.status).toBe(200); + expect(activityBody.data.map((item) => item.text)).toContain('visible general message'); + expect(activityBody.data.map((item) => item.text)).toContain('hidden bob message'); + expect(activityBody.data.map((item) => item.text)).not.toContain('hidden team message'); + expect(activityBody.data.every((item) => item.channel_id === generalChannelBody.data.id)).toBe(true); + const reactionsWithMessageScope = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { headers: { authorization: `Bearer ${token}` }, }); @@ -543,6 +570,97 @@ describe('observer tokens', () => { expect(futureSingle.status).toBe(404); }); + it('filters node roster and node agent bindings by observer agent filters', async () => { + const ws = await createWorkspace(stack.app, 'observer-node-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); + await registerAgent(stack.app, ws.workspaceKey, 'charlie'); + + for (const node of [ + { node_id: 'observer_shared_node', name: 'shared-node' }, + { node_id: 'observer_hidden_node', name: 'hidden-node' }, + ]) { + const created = await stack.app.request('/v1/nodes', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ + ...node, + kind: 'ws', + role: 'broker', + max_agents: 4, + capabilities: ['observe:test'], + }), + }); + expect(created.status).toBe(201); + } + + for (const agentName of ['alice', 'bob']) { + const bound = await stack.app.request('/v1/nodes/shared-node/agents', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ agent_name: agentName }), + }); + expect(bound.status).toBe(201); + } + const hiddenBound = await stack.app.request('/v1/nodes/hidden-node/agents', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ agent_name: 'charlie' }), + }); + expect(hiddenBound.status).toBe(201); + + const observer = await createObserverToken(stack, ws.workspaceKey, { + name: 'alice-node-observer', + scopes: ['nodes:read'], + filters: { agent_ids: [alice.agentId] }, + }); + + const nodeAgents = await stack.app.request('/v1/nodes/shared-node/agents', { + headers: { authorization: `Bearer ${observer.data.token}` }, + }); + const nodeAgentsBody = await nodeAgents.json() as { data: Array<{ agent_id: string; agent_name: string }> }; + expect(nodeAgents.status).toBe(200); + expect(nodeAgentsBody.data).toEqual([ + expect.objectContaining({ agent_id: alice.agentId, agent_name: 'alice' }), + ]); + expect(nodeAgentsBody.data.map((binding) => binding.agent_name)).not.toContain('bob'); + + const roster = await stack.app.request('/v1/nodes', { + headers: { authorization: `Bearer ${observer.data.token}` }, + }); + const rosterBody = await roster.json() as { data: Array<{ name: string; active_agents: number }> }; + expect(roster.status).toBe(200); + expect(rosterBody.data).toEqual([ + expect.objectContaining({ name: 'shared-node', active_agents: 1 }), + ]); + + const visibleNode = await stack.app.request('/v1/nodes/shared-node', { + headers: { authorization: `Bearer ${observer.data.token}` }, + }); + const visibleNodeBody = await visibleNode.json() as { data: { name: string; active_agents: number } }; + expect(visibleNode.status).toBe(200); + expect(visibleNodeBody.data).toMatchObject({ name: 'shared-node', active_agents: 1 }); + + const hiddenNode = await stack.app.request('/v1/nodes/hidden-node', { + headers: { authorization: `Bearer ${observer.data.token}` }, + }); + expect(hiddenNode.status).toBe(404); + + const bobObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'bob-node-observer', + scopes: ['nodes:read'], + filters: { agent_ids: [bob.agentId] }, + }); + const bobNodeAgents = await stack.app.request('/v1/nodes/shared-node/agents', { + headers: { authorization: `Bearer ${bobObserver.data.token}` }, + }); + const bobNodeAgentsBody = await bobNodeAgents.json() as { data: Array<{ agent_id: string; agent_name: string }> }; + expect(bobNodeAgents.status).toBe(200); + expect(bobNodeAgentsBody.data).toEqual([ + expect.objectContaining({ agent_id: bob.agentId, agent_name: 'bob' }), + ]); + }); + it('requires stream-scoped observer tokens for workspace WebSockets and filters events per socket', async () => { const ws = await createWorkspace(stack.app, 'observer-stream-ws'); const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); @@ -641,7 +759,7 @@ describe('observer tokens', () => { const withoutDmScope = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms-without-scope', - scopes: ['stream:read'], + scopes: ['stream:read', 'messages:read', 'reactions:read', 'threads:read'], filters: { channel_names: ['general'], include_dms: true }, }); const [withoutDmScopeRow] = await stack.runtime.deps.db @@ -678,6 +796,12 @@ describe('observer tokens', () => { }); expect(hiddenDmReaction.status).toBe(201); + const hiddenDmRead = await stack.app.request(`/v1/messages/${hiddenDmBody.data.id}/read`, { + method: 'POST', + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect(hiddenDmRead.status).toBe(200); + const hiddenDmReply = await stack.app.request(`/v1/messages/${hiddenDmBody.data.id}/replies`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, @@ -687,13 +811,14 @@ describe('observer tokens', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('message.read')).toHaveLength(0); expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); expect(withoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); expect(threadWithoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); const observer = await createObserverToken(stack, ws.workspaceKey, { name: 'stream-dms', - scopes: ['stream:read', 'dms:read', 'reactions:read', 'threads:read'], + scopes: ['stream:read', 'dms:read', 'messages:read', 'reactions:read', 'threads:read'], filters: { channel_names: ['general'], include_dms: true }, }); @@ -719,6 +844,12 @@ describe('observer tokens', () => { }); expect(visibleDmReaction.status).toBe(201); + const visibleDmRead = await stack.app.request(`/v1/messages/${dmBody.data.id}/read`, { + method: 'POST', + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect(visibleDmRead.status).toBe(200); + const visibleDmReply = await stack.app.request(`/v1/messages/${dmBody.data.id}/replies`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bob.token}` }, @@ -731,6 +862,12 @@ describe('observer tokens', () => { expect(sock.ofType('dm.received')[0]).toMatchObject({ message: { text: 'dm visible despite channel filters' }, }); + expect(sock.ofType('message.read')).toHaveLength(1); + expect(sock.ofType('message.read')[0]).toMatchObject({ + conversation_id: expect.any(String), + message_id: dmBody.data.id, + agent_id: bob.agentId, + }); expect(sock.ofType('message.reacted')).toHaveLength(1); expect(sock.ofType('message.reacted')[0]).toMatchObject({ conversation_id: expect.any(String), @@ -744,6 +881,7 @@ describe('observer tokens', () => { message: { text: 'dm thread reply visible with dms:read' }, }); expect(withoutDmScopeSock.ofType('dm.received')).toHaveLength(0); + expect(withoutDmScopeSock.ofType('message.read')).toHaveLength(0); expect(withoutDmScopeSock.ofType('message.reacted')).toHaveLength(0); expect(withoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); expect(threadWithoutDmScopeSock.ofType('thread.reply')).toHaveLength(0); diff --git a/packages/engine/src/engine/activity.ts b/packages/engine/src/engine/activity.ts index 7da51042..81e1ce32 100644 --- a/packages/engine/src/engine/activity.ts +++ b/packages/engine/src/engine/activity.ts @@ -50,7 +50,9 @@ export async function getActivityFeed( return { type: 'message' as const, id: r.id, + channel_id: r.channelId, channel_name: r.channelName, + channel_type: r.channelType, agent_id: r.agentId, agent_name: r.agentName, text: r.body, diff --git a/packages/engine/src/engine/console.ts b/packages/engine/src/engine/console.ts index 8ef07498..819924f6 100644 --- a/packages/engine/src/engine/console.ts +++ b/packages/engine/src/engine/console.ts @@ -35,6 +35,7 @@ type MessageLogRow = { messageId: string; channelId: string; channelName: string | null; + channelType: number | null; agentId: string; agentName: string | null; conversationId: string | null; @@ -62,6 +63,7 @@ function publicMessageLog(row: MessageLogRow) { message_id: row.messageId, channel_id: row.channelId, channel_name: row.channelName, + channel_type: row.channelType, agent_id: row.agentId, agent_name: row.agentName, conversation_id: row.conversationId, @@ -148,6 +150,7 @@ export async function listMessageLogs( messageId: messageLogs.messageId, channelId: messageLogs.channelId, channelName: channels.name, + channelType: channels.channelType, agentId: messageLogs.agentId, agentName: agents.name, conversationId: messageLogs.conversationId, @@ -182,6 +185,7 @@ export async function listMessageLogsForWindow( messageId: messageLogs.messageId, channelId: messageLogs.channelId, channelName: channels.name, + channelType: channels.channelType, agentId: messageLogs.agentId, agentName: agents.name, conversationId: messageLogs.conversationId, diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index 83583a68..a2f3d19c 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -251,7 +251,11 @@ export async function revokeObserverToken( const [row] = await db .update(observerTokens) .set({ status: 'revoked', revokedAt: now, updatedAt: now }) - .where(and(eq(observerTokens.workspaceId, workspaceId), eq(observerTokens.id, id))) + .where(and( + eq(observerTokens.workspaceId, workspaceId), + eq(observerTokens.id, id), + eq(observerTokens.status, 'active'), + )) .returning({ id: observerTokens.id }); return Boolean(row); } @@ -594,6 +598,8 @@ export async function filterObserverFileResults( export async function filterObserverSearchResults !result.channel_name && result.channel_id) .map((result) => result.channel_id as string))]; - const namesById = new Map(); + const channelsById = new Map(); if (missingChannelIds.length > 0) { const rows = await db - .select({ id: channels.id, name: channels.name }) + .select({ id: channels.id, name: channels.name, channel_type: channels.channelType }) .from(channels) .where(and(eq(channels.workspaceId, workspaceId), inArray(channels.id, missingChannelIds))); - for (const row of rows) namesById.set(row.id, row.name); + for (const row of rows) channelsById.set(row.id, { name: row.name, channel_type: row.channel_type }); } return results.filter((result) => { @@ -622,9 +628,11 @@ export async function filterObserverSearchResults(sql` - SELECT m.id, m.channel_id, c.name AS channel_name, dc.id AS conversation_id, m.agent_id, a.name AS agent_name, m.body, m.created_at, + SELECT m.id, m.channel_id, c.name AS channel_name, c.channel_type, dc.id AS conversation_id, m.agent_id, a.name AS agent_name, m.body, m.created_at, bm25(messages_fts) AS rank FROM messages_fts fts JOIN messages m ON m.id = fts.id @@ -80,6 +81,7 @@ export async function searchMessages( id: row.id, channel_id: row.channel_id, channel_name: row.channel_name || 'unknown', + channel_type: row.channel_type, conversation_id: row.conversation_id, agent_id: row.agent_id, agent_name: row.agent_name || 'unknown', diff --git a/packages/engine/src/routes/console.ts b/packages/engine/src/routes/console.ts index fd824e73..12d4a5c9 100644 --- a/packages/engine/src/routes/console.ts +++ b/packages/engine/src/routes/console.ts @@ -75,7 +75,8 @@ consoleRoutes.get('/console/messages', requireWorkspaceRead('messages:read'), ra })), ); - return jsonOk(c, visible); + const response = visible.map(({ channel_type: _channelType, ...item }) => item); + return jsonOk(c, response); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/engine/src/routes/node.ts b/packages/engine/src/routes/node.ts index a887c2d2..9b45a4e7 100644 --- a/packages/engine/src/routes/node.ts +++ b/packages/engine/src/routes/node.ts @@ -14,6 +14,11 @@ import { parseJsonBody, } from '../lib/httpResponse.js'; import * as nodeEngine from '../engine/node.js'; +import { + getObserverTokenFromContext, + normalizeObserverFilters, + observerAllowsAgent, +} from '../engine/observerToken.js'; export const nodeRoutes = new Hono(); @@ -76,6 +81,36 @@ function strictExternalUrl(c: Parameters[0]): boolean { return environment !== 'test'; } +type NodeRosterEntry = Awaited>[number]; +type NodeAgentBinding = NonNullable>>[number]; +type ObserverContext = ReturnType; + +function observerHasAgentFilter(observer: ObserverContext): boolean { + return Boolean(observer && normalizeObserverFilters(observer.filters).agent_ids?.length); +} + +function filterNodeAgentsForObserver(observer: ObserverContext, bindings: NodeAgentBinding[]): NodeAgentBinding[] { + return bindings.filter((binding) => observerAllowsAgent(observer, binding.agent_id)); +} + +async function filterNodesForObserver( + db: Parameters[0], + workspaceId: string, + observer: ObserverContext, + roster: NodeRosterEntry[], +): Promise { + if (!observerHasAgentFilter(observer)) return roster; + const visible: NodeRosterEntry[] = []; + for (const node of roster) { + const bindings = await nodeEngine.listNodeAgents(db, workspaceId, node.name); + const visibleBindings = bindings ? filterNodeAgentsForObserver(observer, bindings) : []; + if (visibleBindings.length > 0) { + visible.push({ ...node, active_agents: visibleBindings.length }); + } + } + return visible; +} + // POST /v1/nodes - enroll or rotate a node token (workspace-key only) nodeRoutes.post('/nodes', requireWorkspaceKey, rateLimit, async (c) => { try { @@ -122,11 +157,18 @@ nodeRoutes.post('/nodes', requireWorkspaceKey, rateLimit, async (c) => { // GET /v1/nodes?capability=&name= - node roster nodeRoutes.get('/nodes', requireWorkspaceRead('nodes:read'), rateLimit, async (c) => { try { - const result = await nodeEngine.listNodes(c.get('db'), c.get('workspace').id, { + const db = c.get('db'); + const workspace = c.get('workspace'); + const result = await nodeEngine.listNodes(db, workspace.id, { capability: c.req.query('capability'), name: c.req.query('name'), }); - return jsonOk(c, result); + return jsonOk(c, await filterNodesForObserver( + db, + workspace.id, + getObserverTokenFromContext(c), + result, + )); } catch (err: unknown) { return errorResponse(c, err); } @@ -139,7 +181,7 @@ nodeRoutes.get('/nodes/:name/agents', requireWorkspaceRead('nodes:read'), rateLi if (!result) { return jsonNotFound(c, 'node_not_found', 'Node not found'); } - return jsonOk(c, result); + return jsonOk(c, filterNodeAgentsForObserver(getObserverTokenFromContext(c), result)); } catch (err: unknown) { return errorResponse(c, err); } @@ -192,10 +234,22 @@ nodeRoutes.delete('/nodes/:name/agents/:agentName', requireWorkspaceKey, rateLim // GET /v1/nodes/:name - single node roster entry nodeRoutes.get('/nodes/:name', requireWorkspaceRead('nodes:read'), rateLimit, async (c) => { try { - const result = await nodeEngine.getPublicNode(c.get('db'), c.get('workspace').id, c.req.param('name')); + const db = c.get('db'); + const workspace = c.get('workspace'); + const name = c.req.param('name'); + const result = await nodeEngine.getPublicNode(db, workspace.id, name); if (!result) { return jsonNotFound(c, 'node_not_found', 'Node not found'); } + const observer = getObserverTokenFromContext(c); + if (observerHasAgentFilter(observer)) { + const bindings = await nodeEngine.listNodeAgents(db, workspace.id, name); + const visibleBindings = bindings ? filterNodeAgentsForObserver(observer, bindings) : []; + if (visibleBindings.length === 0) { + return jsonNotFound(c, 'node_not_found', 'Node not found'); + } + return jsonOk(c, { ...result, active_agents: visibleBindings.length }); + } return jsonOk(c, result); } catch (err: unknown) { return errorResponse(c, err); diff --git a/packages/engine/src/routes/search.ts b/packages/engine/src/routes/search.ts index 01da0524..5b66e9df 100644 --- a/packages/engine/src/routes/search.ts +++ b/packages/engine/src/routes/search.ts @@ -54,7 +54,11 @@ searchRoutes.get( getObserverTokenFromContext(c), results, ); - const responseResults = visibleResults.map(({ agent_id: _agentId, ...result }) => result); + const responseResults = visibleResults.map(({ + agent_id: _agentId, + channel_type: _channelType, + ...result + }) => result); emitServerEvent(c, workspace.id, 'relaycast_server_search_executed', { query_length: q.trim().length, diff --git a/packages/engine/src/routes/workspace.ts b/packages/engine/src/routes/workspace.ts index ea877ef1..ab1f7752 100644 --- a/packages/engine/src/routes/workspace.ts +++ b/packages/engine/src/routes/workspace.ts @@ -235,7 +235,8 @@ workspaceRoutes.get('/activity', requireWorkspaceRead('activity:read', { allowAg getObserverTokenFromContext(c), items, ); - return jsonOk(c, visibleItems); + const responseItems = visibleItems.map(({ channel_type: _channelType, ...item }) => item); + return jsonOk(c, responseItems); } catch (err: unknown) { return errorResponse(c, err); } diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index 32d96923..d858de3c 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -52,6 +52,7 @@ export type SetSystemPromptRequest = z.infer Date: Thu, 25 Jun 2026 10:40:49 -0400 Subject: [PATCH 09/15] Make observer DM gating authoritative --- openapi.yaml | 18 ++++ .../conformance/observerToken.test.ts | 97 ++++++++++++++++++- packages/engine/src/engine/observerToken.ts | 56 ++++++++++- packages/engine/src/routes/console.ts | 6 ++ packages/engine/src/routes/message.ts | 15 +-- packages/engine/src/routes/reaction.ts | 9 +- packages/engine/src/routes/thread.ts | 8 +- packages/engine/src/routes/workspace.ts | 5 + packages/sdk-rust/src/types.rs | 29 +++++- packages/sdk-rust/tests/parity.rs | 6 +- .../sdk-swift/Sources/Relaycast/Models.swift | 57 ++++++++++- .../Tests/RelaycastTests/RelaycastTests.swift | 11 ++- 12 files changed, 275 insertions(+), 42 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 56711ff7..73587809 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1082,6 +1082,12 @@ paths: type: boolean data: $ref: '#/components/schemas/ObserverToken' + '409': + description: Observer token name already exists in this workspace + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' get: summary: List observer tokens description: List observer token metadata. Raw token material is not returned. @@ -1182,6 +1188,12 @@ paths: type: boolean data: $ref: '#/components/schemas/ObserverToken' + '409': + description: Observer token name already exists in this workspace + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: summary: Revoke observer token tags: @@ -1224,6 +1236,12 @@ paths: type: boolean data: $ref: '#/components/schemas/ObserverToken' + '404': + description: Observer token not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /agent: get: diff --git a/packages/engine/src/__tests__/conformance/observerToken.test.ts b/packages/engine/src/__tests__/conformance/observerToken.test.ts index 8976f16e..3c34f19b 100644 --- a/packages/engine/src/__tests__/conformance/observerToken.test.ts +++ b/packages/engine/src/__tests__/conformance/observerToken.test.ts @@ -8,7 +8,7 @@ import { registerAgent, type TestStack, } from './harness.js'; -import { messageLogs, observerTokens } from '../../db/schema.js'; +import { channels, messageLogs, messages, observerTokens } from '../../db/schema.js'; import { authenticateRealtimeWs } from '../../engine/wsAuth.js'; async function createObserverToken( @@ -147,6 +147,21 @@ describe('observer tokens', () => { ok: false, error: { code: 'invalid_request' }, }); + + const expired = await stack.app.request('/v1/observer-tokens', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ + name: 'already-expired', + scopes: ['messages:read'], + expires_at: '2000-01-01T00:00:00.000Z', + }), + }); + expect(expired.status).toBe(400); + await expect(expired.json()).resolves.toMatchObject({ + ok: false, + error: { code: 'invalid_request' }, + }); }); it('returns a conflict for duplicate observer token names in one workspace', async () => { @@ -280,6 +295,22 @@ describe('observer tokens', () => { expect(consoleStatsBody.data.total_messages).toBe(1); expect(consoleStatsBody.data.channel_messages).toBe(1); + const consoleDmOnly = await createObserverToken(stack, ws.workspaceKey, { + name: 'console-general-dm-event-only', + scopes: ['activity:read'], + filters: { channel_names: ['general'], event_types: ['dm.received'] }, + }); + const consoleDmOnlyStats = await stack.app.request('/v1/console/stats', { + headers: { authorization: `Bearer ${consoleDmOnly.data.token}` }, + }); + const consoleDmOnlyStatsBody = await consoleDmOnlyStats.json() as { data: { total_messages: number; channel_messages: number; dm_messages: number } }; + expect(consoleDmOnlyStats.status).toBe(200); + expect(consoleDmOnlyStatsBody.data).toMatchObject({ + total_messages: 0, + channel_messages: 0, + dm_messages: 0, + }); + const consoleAgents = await stack.app.request('/v1/console/agents', { headers: { authorization: `Bearer ${consoleObserver.data.token}` }, }); @@ -361,6 +392,18 @@ describe('observer tokens', () => { expect(activityBody.data.map((item) => item.text)).not.toContain('hidden team message'); expect(activityBody.data.every((item) => item.channel_id === generalChannelBody.data.id)).toBe(true); + const dmEventActivity = await createObserverToken(stack, ws.workspaceKey, { + name: 'activity-dm-event-only', + scopes: ['activity:read'], + filters: { channel_ids: [generalChannelBody.data.id], event_types: ['dm.received'] }, + }); + const dmEventOnlyActivity = await stack.app.request('/v1/activity', { + headers: { authorization: `Bearer ${dmEventActivity.data.token}` }, + }); + const dmEventOnlyActivityBody = await dmEventOnlyActivity.json() as { data: unknown[] }; + expect(dmEventOnlyActivity.status).toBe(200); + expect(dmEventOnlyActivityBody.data).toHaveLength(0); + const reactionsWithMessageScope = await stack.app.request(`/v1/messages/${generalBody.data.id}/reactions`, { headers: { authorization: `Bearer ${token}` }, }); @@ -469,6 +512,46 @@ describe('observer tokens', () => { expect(visibleSearch.status).toBe(200); expect(visibleSearchBody.data.map((result) => result.text)).toContain('private note'); + await stack.runtime.deps.db.insert(channels).values({ + id: 'orphan_private_channel', + workspaceId: ws.workspaceId, + name: 'orphan-private-channel', + channelType: 1, + }); + await stack.runtime.deps.db.insert(messages).values({ + id: 'orphan_private_message', + workspaceId: ws.workspaceId, + channelId: 'orphan_private_channel', + agentId: alice.agentId, + body: 'orphan private body', + }); + const orphanMessageObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'orphan-message-reader', + scopes: ['messages:read'], + }); + const orphanMessage = await stack.app.request('/v1/messages/orphan_private_message', { + headers: { authorization: `Bearer ${orphanMessageObserver.data.token}` }, + }); + expect(orphanMessage.status).toBe(404); + + const orphanReactionObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'orphan-reaction-reader', + scopes: ['reactions:read'], + }); + const orphanReactions = await stack.app.request('/v1/messages/orphan_private_message/reactions', { + headers: { authorization: `Bearer ${orphanReactionObserver.data.token}` }, + }); + expect(orphanReactions.status).toBe(404); + + const orphanThreadObserver = await createObserverToken(stack, ws.workspaceKey, { + name: 'orphan-thread-reader', + scopes: ['threads:read'], + }); + const orphanThread = await stack.app.request('/v1/messages/orphan_private_message/replies', { + headers: { authorization: `Bearer ${orphanThreadObserver.data.token}` }, + }); + expect(orphanThread.status).toBe(404); + const underScoped = await createObserverToken(stack, ws.workspaceKey, { name: 'channels-only', scopes: ['channels:read'], @@ -552,6 +635,18 @@ describe('observer tokens', () => { expect(aliceFileList.status).toBe(200); expect(aliceFileListBody.data.map((file) => file.id).sort()).toEqual([aliceLooseFileId, aliceGeneralFileId].sort()); + const dmScopedFiles = await createObserverToken(stack, ws.workspaceKey, { + name: 'dm-scoped-files', + scopes: ['files:read'], + filters: { include_dms: true }, + }); + const dmScopedFileList = await stack.app.request('/v1/files', { + headers: { authorization: `Bearer ${dmScopedFiles.data.token}` }, + }); + const dmScopedFileListBody = await dmScopedFileList.json() as { data: Array<{ id: string }> }; + expect(dmScopedFileList.status).toBe(200); + expect(dmScopedFileListBody.data.map((file) => file.id)).not.toContain(aliceLooseFileId); + const futureFiles = await createObserverToken(stack, ws.workspaceKey, { name: 'future-files', scopes: ['files:read'], diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index a2f3d19c..64c5da98 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -66,6 +66,9 @@ function parseExpiresAt(value: string | null | undefined): Date | null { if (Number.isNaN(date.getTime())) { throw codedError('expires_at must be an ISO-8601 timestamp', 'invalid_request', 400); } + if (date.getTime() <= Date.now()) { + throw codedError('expires_at must be in the future', 'invalid_request', 400); + } return date; } @@ -303,6 +306,15 @@ function filterList(values: string[] | undefined): Set | null { return values && values.length > 0 ? new Set(values) : null; } +function hasChannelOrDmFilter(filters: ObserverTokenFilters): boolean { + return Boolean( + filters.channel_ids?.length + || filters.channel_names?.length + || filters.dm_conversation_ids?.length + || filters.include_dms, + ); +} + export function observerAllowsChannel( observer: Pick | undefined, channel: { id?: string | null; name?: string | null; channel_type?: number | null; channelType?: number | null }, @@ -369,6 +381,33 @@ export function observerAllowsMessage( && observerAllowsCreatedAt(observer, message.created_at ?? message.createdAt); } +export type MessageObserverResource = { + channel_id: string; + channel_name: string; + channel_type: number; + conversation_id: string | null; + agent_id: string; + created_at: string; +}; + +export function observerAllowsMessageResource( + observer: Pick | undefined, + resource: MessageObserverResource, + options: { normalScope?: ObserverScope } = {}, +): boolean { + if (!observer) return true; + if (!observerAllowsMessage(observer, resource)) return false; + if (resource.channel_type !== 0) { + return observerAllowsConversation(observer, resource.conversation_id); + } + if (options.normalScope && !hasObserverScope(observer, options.normalScope)) return false; + return observerAllowsChannel(observer, { + id: resource.channel_id, + name: resource.channel_name, + channel_type: resource.channel_type, + }); +} + export type FileObserverResource = { id: string; uploaded_by_id: string; @@ -389,7 +428,7 @@ export function observerAllowsFile( if (!observerAllowsAgent(observer, file.uploaded_by_id)) return false; if (!observerAllowsCreatedAt(observer, file.created_at)) return false; if (file.attachments.length === 0) { - return observerAllowsChannel(observer, {}); + return !hasChannelOrDmFilter(normalizeObserverFilters(observer.filters)); } return file.attachments.some((attachment) => ( attachment.conversation_id @@ -443,6 +482,16 @@ function eventRequiredScopes(type: string): ObserverScope[] { return ['activity:read']; } +export function observerAllowsAnyEventType( + observer: Pick | undefined, + eventTypes: string[], +): boolean { + if (!observer) return true; + const filters = normalizeObserverFilters(observer.filters); + if (!filters.event_types?.length) return true; + return eventTypes.some((type) => filters.event_types?.includes(type)); +} + export function observerAllowsEvent( observer: Pick | undefined, event: Record, @@ -483,11 +532,12 @@ export async function getMessageObserverResource( db: Db, workspaceId: string, messageId: string, -): Promise<{ channel_id: string; channel_name: string; conversation_id: string | null; agent_id: string; created_at: string } | null> { +): Promise { const [row] = await db .select({ channel_id: channels.id, channel_name: channels.name, + channel_type: channels.channelType, conversation_id: dmConversations.id, agent_id: messages.agentId, created_at: messages.createdAt, @@ -608,6 +658,7 @@ export async function filterObserverSearchResults | undefined, results: T[], + opts: { eventTypes?: (result: T) => string[] } = {}, ): Promise { if (!observer || results.length === 0) return results; @@ -624,6 +675,7 @@ export async function filterObserverSearchResults { + if (opts.eventTypes && !observerAllowsAnyEventType(observer, opts.eventTypes(result))) return false; if (!observerAllowsMessage(observer, result)) return false; if (result.conversation_id) { return observerAllowsConversation(observer, result.conversation_id); diff --git a/packages/engine/src/routes/console.ts b/packages/engine/src/routes/console.ts index 12d4a5c9..858227e5 100644 --- a/packages/engine/src/routes/console.ts +++ b/packages/engine/src/routes/console.ts @@ -44,9 +44,14 @@ async function visibleMessageLogsForObserver( workspaceId, observer, logs, + { eventTypes: consoleMessageLogEventTypes }, ); } +function consoleMessageLogEventTypes(log: { conversation_id?: string | null }): string[] { + return log.conversation_id ? ['dm.received', 'group_dm.received'] : ['message.created']; +} + consoleRoutes.get('/console/messages', requireWorkspaceRead('messages:read'), rateLimit, async (c) => { try { const workspace = c.get('workspace'); @@ -73,6 +78,7 @@ consoleRoutes.get('/console/messages', requireWorkspaceRead('messages:read'), ra ...item, channel_name: item.channel_name ?? undefined, })), + { eventTypes: consoleMessageLogEventTypes }, ); const response = visible.map(({ channel_type: _channelType, ...item }) => item); diff --git a/packages/engine/src/routes/message.ts b/packages/engine/src/routes/message.ts index d4a6f914..11ab6c4a 100644 --- a/packages/engine/src/routes/message.ts +++ b/packages/engine/src/routes/message.ts @@ -15,9 +15,8 @@ import { getChannelObserverResource, getMessageObserverResource, getObserverTokenFromContext, - hasObserverScope, observerAllowsChannel, - observerAllowsConversation, + observerAllowsMessageResource, observerAllowsMessage, } from '../engine/observerToken.js'; import { runInBackground } from './background.js'; @@ -248,17 +247,7 @@ messageRoutes.get( if (!resource) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } - if (!observerAllowsMessage(observer, resource)) { - return jsonNotFound(c, 'message_not_found', 'Message not found'); - } - if (resource.conversation_id) { - if (!hasObserverScope(observer, 'dms:read') || !observerAllowsConversation(observer, resource.conversation_id)) { - return jsonNotFound(c, 'message_not_found', 'Message not found'); - } - } else if (!hasObserverScope(observer, 'messages:read') || !observerAllowsChannel(observer, { - id: resource.channel_id, - name: resource.channel_name, - })) { + if (!observerAllowsMessageResource(observer, resource, { normalScope: 'messages:read' })) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } } diff --git a/packages/engine/src/routes/reaction.ts b/packages/engine/src/routes/reaction.ts index 20632475..94bb4d96 100644 --- a/packages/engine/src/routes/reaction.ts +++ b/packages/engine/src/routes/reaction.ts @@ -9,9 +9,7 @@ import { channels, dmConversations, messages } from '../db/schema.js'; import { getMessageObserverResource, getObserverTokenFromContext, - observerAllowsChannel, - observerAllowsConversation, - observerAllowsMessage, + observerAllowsMessageResource, } from '../engine/observerToken.js'; import { fanoutToChannel, fanoutToAgents, getDmParticipantAgentIds } from './fanout.js'; import { sendNodeDeliveriesForChannel } from '../engine/nodeDeliver.js'; @@ -235,10 +233,7 @@ reactionRoutes.get( if (!resource) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } - const allowed = observerAllowsMessage(observer, resource) && (resource.conversation_id - ? observerAllowsConversation(observer, resource.conversation_id) - : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name })); - if (!allowed) { + if (!observerAllowsMessageResource(observer, resource)) { return jsonNotFound(c, 'message_not_found', 'Message not found'); } } diff --git a/packages/engine/src/routes/thread.ts b/packages/engine/src/routes/thread.ts index 4aae5e21..ef0cfb30 100644 --- a/packages/engine/src/routes/thread.ts +++ b/packages/engine/src/routes/thread.ts @@ -12,8 +12,7 @@ import { buildThreadReplyEventData } from '../engine/deliveryWire.js'; import { getMessageObserverResource, getObserverTokenFromContext, - observerAllowsChannel, - observerAllowsConversation, + observerAllowsMessageResource, observerAllowsMessage, } from '../engine/observerToken.js'; import { runInBackground } from './background.js'; @@ -159,10 +158,7 @@ threadRoutes.get( if (!resource) { return jsonError(c, 'message_not_found', 'Parent message not found', 404); } - const allowed = observerAllowsMessage(observer, resource) && (resource.conversation_id - ? observerAllowsConversation(observer, resource.conversation_id) - : observerAllowsChannel(observer, { id: resource.channel_id, name: resource.channel_name })); - if (!allowed) { + if (!observerAllowsMessageResource(observer, resource)) { return jsonError(c, 'message_not_found', 'Parent message not found', 404); } } diff --git a/packages/engine/src/routes/workspace.ts b/packages/engine/src/routes/workspace.ts index ab1f7752..184a5850 100644 --- a/packages/engine/src/routes/workspace.ts +++ b/packages/engine/src/routes/workspace.ts @@ -234,6 +234,11 @@ workspaceRoutes.get('/activity', requireWorkspaceRead('activity:read', { allowAg workspace.id, getObserverTokenFromContext(c), items, + { + eventTypes: (item) => (item.conversation_id + ? ['dm.received', 'group_dm.received'] + : ['message.created']), + }, ); const responseItems = visibleItems.map(({ channel_type: _channelType, ...item }) => item); return jsonOk(c, responseItems); diff --git a/packages/sdk-rust/src/types.rs b/packages/sdk-rust/src/types.rs index 1333ea02..9177e100 100644 --- a/packages/sdk-rust/src/types.rs +++ b/packages/sdk-rust/src/types.rs @@ -95,6 +95,7 @@ pub struct ActivityItem { #[serde(rename = "type")] pub item_type: String, pub id: String, + pub channel_id: Option, pub channel_name: Option, pub conversation_id: Option, pub agent_name: String, @@ -136,7 +137,33 @@ pub struct TokenRotateResponse { // === Observer tokens === -pub type ObserverScope = String; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ObserverScope { + #[serde(rename = "stream:read")] + StreamRead, + #[serde(rename = "messages:read")] + MessagesRead, + #[serde(rename = "threads:read")] + ThreadsRead, + #[serde(rename = "dms:read")] + DmsRead, + #[serde(rename = "channels:read")] + ChannelsRead, + #[serde(rename = "search:read")] + SearchRead, + #[serde(rename = "agents:read")] + AgentsRead, + #[serde(rename = "nodes:read")] + NodesRead, + #[serde(rename = "deliveries:read")] + DeliveriesRead, + #[serde(rename = "activity:read")] + ActivityRead, + #[serde(rename = "files:read")] + FilesRead, + #[serde(rename = "reactions:read")] + ReactionsRead, +} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ObserverTokenFilters { diff --git a/packages/sdk-rust/tests/parity.rs b/packages/sdk-rust/tests/parity.rs index 9d0714d3..ffdff07b 100644 --- a/packages/sdk-rust/tests/parity.rs +++ b/packages/sdk-rust/tests/parity.rs @@ -6,7 +6,7 @@ use relaycast::{ EmitSessionEventRequest, FailDeliveryRequest, HttpClient, HttpPushNodeDelivery, ListDeliveriesOptions, ListSessionEventsQuery, MessageInjectionMode, MessageListQuery, MonitorCertificationRequest, NodeDeliveryAuth, NodeDeliveryConfig, NodeListQuery, - ObserverTokenFilters, RateDirectoryAgentRequest, RegisterA2aOptions, RegisterActionRequest, + ObserverScope, ObserverTokenFilters, RateDirectoryAgentRequest, RegisterA2aOptions, RegisterActionRequest, RelayCast, RelayCastOptions, ReleaseAgentRequest, RouteFeedbackRequest, SearchDirectoryQuery, SpawnAgentRequest, SubmitCertificationRequest, UpdateObserverTokenRequest, UpdateRoutingConfigRequest, WebhookTriggerRequest, WsClient, WsClientOptions, WsEvent, @@ -2178,7 +2178,7 @@ async fn observer_tokens_use_expected_endpoints() { let created = relay .create_observer_token(CreateObserverTokenRequest { name: "dashboard".to_string(), - scopes: vec!["stream:read".to_string(), "messages:read".to_string()], + scopes: vec![ObserverScope::StreamRead, ObserverScope::MessagesRead], description: None, filters: Some(ObserverTokenFilters { channel_names: vec!["general".to_string()], @@ -2226,7 +2226,7 @@ async fn observer_tokens_use_expected_endpoints() { .update_observer_token( "ot_1", UpdateObserverTokenRequest { - scopes: Some(vec!["messages:read".to_string()]), + scopes: Some(vec![ObserverScope::MessagesRead]), ..Default::default() }, ) diff --git a/packages/sdk-swift/Sources/Relaycast/Models.swift b/packages/sdk-swift/Sources/Relaycast/Models.swift index fe84d5c0..266aedb6 100644 --- a/packages/sdk-swift/Sources/Relaycast/Models.swift +++ b/packages/sdk-swift/Sources/Relaycast/Models.swift @@ -90,6 +90,7 @@ public struct WorkspaceStats: Codable, Equatable, Sendable { public struct ActivityItem: Codable, Equatable, Sendable { public let type: String public let id: String + public let channelId: String? public let channelName: String? public let conversationId: String? public let agentName: String @@ -153,16 +154,31 @@ public struct ObserverTokenFilters: Codable, Equatable, Sendable { } } +public enum ObserverScope: String, Codable, Equatable, Sendable { + case streamRead = "stream:read" + case messagesRead = "messages:read" + case threadsRead = "threads:read" + case dmsRead = "dms:read" + case channelsRead = "channels:read" + case searchRead = "search:read" + case agentsRead = "agents:read" + case nodesRead = "nodes:read" + case deliveriesRead = "deliveries:read" + case activityRead = "activity:read" + case filesRead = "files:read" + case reactionsRead = "reactions:read" +} + public struct CreateObserverTokenRequest: Codable, Equatable, Sendable { public var name: String - public var scopes: [String] + public var scopes: [ObserverScope] public var description: String? public var filters: ObserverTokenFilters? public var expiresAt: String? public init( name: String, - scopes: [String], + scopes: [ObserverScope], description: String? = nil, filters: ObserverTokenFilters? = nil, expiresAt: String? = nil @@ -177,14 +193,14 @@ public struct CreateObserverTokenRequest: Codable, Equatable, Sendable { public struct UpdateObserverTokenRequest: Codable, Equatable, Sendable { public var name: String? - public var scopes: [String]? + public var scopes: [ObserverScope]? public var description: String? public var filters: ObserverTokenFilters? public var expiresAt: String? public init( name: String? = nil, - scopes: [String]? = nil, + scopes: [ObserverScope]? = nil, description: String? = nil, filters: ObserverTokenFilters? = nil, expiresAt: String? = nil @@ -201,7 +217,7 @@ public struct ObserverToken: Codable, Equatable, Sendable { public let id: String public let name: String public let description: String? - public let scopes: [String] + public let scopes: [ObserverScope] public let filters: ObserverTokenFilters public let status: String public let expiresAt: String? @@ -210,6 +226,37 @@ public struct ObserverToken: Codable, Equatable, Sendable { public let revokedAt: String? public let lastUsedAt: String? public let token: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case scopes + case filters + case status + case expiresAt + case createdAt + case updatedAt + case revokedAt + case lastUsedAt + case token + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + description = try container.decodeIfPresent(String.self, forKey: .description) + scopes = try container.decode([ObserverScope].self, forKey: .scopes) + filters = try container.decodeIfPresent(ObserverTokenFilters.self, forKey: .filters) ?? ObserverTokenFilters() + status = try container.decode(String.self, forKey: .status) + expiresAt = try container.decodeIfPresent(String.self, forKey: .expiresAt) + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + revokedAt = try container.decodeIfPresent(String.self, forKey: .revokedAt) + lastUsedAt = try container.decodeIfPresent(String.self, forKey: .lastUsedAt) + token = try container.decodeIfPresent(String.self, forKey: .token) + } } // MARK: - Agents diff --git a/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift b/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift index 740b91f7..6c6c0e16 100644 --- a/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift +++ b/packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift @@ -444,9 +444,11 @@ final class RelaycastTests: XCTestCase { "data": [observerTokenData(token: NSNull())] ]) case ("GET", "/v1/observer-tokens/ot_1"): + var data = observerTokenData(token: NSNull()) + data.removeValue(forKey: "filters") return jsonResponse([ "ok": true, - "data": observerTokenData(token: NSNull()) + "data": data ]) case ("PATCH", "/v1/observer-tokens/ot_1"): let body = try XCTUnwrap(requestBodyData(request)) @@ -473,7 +475,7 @@ final class RelaycastTests: XCTestCase { let created = try await relay.observerTokens.create(CreateObserverTokenRequest( name: "dashboard", - scopes: ["stream:read", "messages:read"], + scopes: [.streamRead, .messagesRead], filters: ObserverTokenFilters(channelNames: ["general"], includeDms: false) )) XCTAssertEqual(created.token, "ot_live_secret") @@ -484,9 +486,10 @@ final class RelaycastTests: XCTestCase { let fetched = try await relay.observerTokens.get("ot_1") XCTAssertEqual(fetched.name, "dashboard") + XCTAssertNil(fetched.filters.channelNames) - let updated = try await relay.observerTokens.update("ot_1", data: UpdateObserverTokenRequest(scopes: ["messages:read"])) - XCTAssertEqual(updated.scopes, ["stream:read", "messages:read"]) + let updated = try await relay.observerTokens.update("ot_1", data: UpdateObserverTokenRequest(scopes: [.messagesRead])) + XCTAssertEqual(updated.scopes, [.streamRead, .messagesRead]) let rotated = try await relay.observerTokens.rotate("ot_1") XCTAssertEqual(rotated.token, "ot_live_rotated") From 5e0161a06b6fd54a69306bb3592b631f274144d9 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 10:53:58 -0400 Subject: [PATCH 10/15] Remove relay workspace state and SDK workflow Delete generated relay workspace metadata (memory/workspace/.relay/outbox/capabilities.json and memory/workspace/.relay/state.json) and remove the large SDK setup workflow script (workflows/sdk-setup-client-80-100.ts). These are cleanup deletions of workspace state/artifact files and an obsolete workflow; no functional source code changes were made. --- .../workspace/.relay/outbox/capabilities.json | 1 - memory/workspace/.relay/state.json | 1 - workflows/sdk-setup-client-80-100.ts | 704 ------------------ 3 files changed, 706 deletions(-) delete mode 100644 memory/workspace/.relay/outbox/capabilities.json delete mode 100644 memory/workspace/.relay/state.json delete mode 100644 workflows/sdk-setup-client-80-100.ts diff --git a/memory/workspace/.relay/outbox/capabilities.json b/memory/workspace/.relay/outbox/capabilities.json deleted file mode 100644 index d527376e..00000000 --- a/memory/workspace/.relay/outbox/capabilities.json +++ /dev/null @@ -1 +0,0 @@ -{"schemaVersion":2,"dispatchReceipts":true} \ No newline at end of file diff --git a/memory/workspace/.relay/state.json b/memory/workspace/.relay/state.json deleted file mode 100644 index 2ca5f49c..00000000 --- a/memory/workspace/.relay/state.json +++ /dev/null @@ -1 +0,0 @@ -{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-15T19:00:12.987546713Z","lastSuccessfulReconcileAt":"2026-06-15T19:00:12.987546713Z","staleAfter":"2026-06-15T19:00:22.987546713Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":6},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file diff --git a/workflows/sdk-setup-client-80-100.ts b/workflows/sdk-setup-client-80-100.ts deleted file mode 100644 index b42fe647..00000000 --- a/workflows/sdk-setup-client-80-100.ts +++ /dev/null @@ -1,704 +0,0 @@ -import { workflow, ClaudeModels, CodexModels } from '@agent-relay/sdk/workflows'; - -const dryRun = process.argv.includes('--dry-run'); -const specCoverageAuditCommand = String.raw`/bin/bash <<'BASH' -set -euo pipefail -fail=0 - -need_file() { - local file="$1" - if [ ! -f "$file" ]; then - echo "CHANGES_REQUIRED: missing file $file" - fail=1 - fi -} - -need_fixed() { - local needle="$1" - local file="$2" - if ! rg -F "$needle" "$file" >/dev/null 2>&1; then - echo "CHANGES_REQUIRED: $file missing literal marker: $needle" - fail=1 - fi -} - -need_regex() { - local pattern="$1" - local file="$2" - if ! rg "$pattern" "$file" >/dev/null 2>&1; then - echo "CHANGES_REQUIRED: $file missing regex marker: $pattern" - fail=1 - fi -} - -reject_fixed() { - local needle="$1" - local file="$2" - if rg -F "$needle" "$file" >/dev/null 2>&1; then - echo "CHANGES_REQUIRED: $file contains forbidden marker: $needle" - fail=1 - fi -} - -need_file docs/sdk-setup-client.md -need_file docs/sdk-setup-client-acceptance.md -need_file packages/sdk-typescript/src/setup.ts -need_file packages/sdk-typescript/src/setup-types.ts -need_file packages/sdk-typescript/src/setup-errors.ts -need_file packages/sdk-typescript/src/communicate/types.ts -need_file packages/sdk-typescript/src/communicate/relay.ts -need_file packages/sdk-typescript/src/communicate/index.ts -need_file packages/sdk-typescript/src/index.ts -need_file packages/sdk-typescript/src/__tests__/setup.test.ts -need_file packages/sdk-typescript/src/__tests__/communicate.test.ts -need_file scripts/e2e-sdk-setup-client.ts - -need_fixed "export { RelaycastSetup" packages/sdk-typescript/src/index.ts -need_fixed "from './setup.js'" packages/sdk-typescript/src/index.ts -need_fixed "from './setup-types.js'" packages/sdk-typescript/src/index.ts -need_fixed "from './setup-errors.js'" packages/sdk-typescript/src/index.ts -need_fixed "export { Relay } from './communicate/relay.js'" packages/sdk-typescript/src/index.ts -need_fixed "MessageCallback" packages/sdk-typescript/src/index.ts -need_fixed "RelayConfig" packages/sdk-typescript/src/index.ts -reject_fixed "RelayCast as Relay" packages/sdk-typescript/src/index.ts -need_fixed "\"./communicate\"" packages/sdk-typescript/package.json - -need_regex "class RelaycastSetup" packages/sdk-typescript/src/setup.ts -need_regex "class WorkspaceHandle" packages/sdk-typescript/src/setup.ts -need_regex "createWorkspace\\(" packages/sdk-typescript/src/setup.ts -need_regex "joinWorkspace\\(" packages/sdk-typescript/src/setup.ts -need_regex "lookupWorkspace\\(" packages/sdk-typescript/src/setup.ts -need_regex "registerAgent\\(" packages/sdk-typescript/src/setup.ts -need_fixed "https://cast.agentrelay.com" packages/sdk-typescript/src/setup.ts -need_fixed "CreateWorkspaceResponseSchema" packages/sdk-typescript/src/setup.ts -need_fixed "WorkspaceLookupSchema" packages/sdk-typescript/src/setup.ts -need_fixed "camelizeKeys" packages/sdk-typescript/src/setup.ts -need_fixed "X-SDK-Version" packages/sdk-typescript/src/setup.ts -need_fixed "SDK_ORIGIN" packages/sdk-typescript/src/setup.ts -need_fixed "Retry-After" packages/sdk-typescript/src/setup.ts -need_fixed "AbortSignal.timeout" packages/sdk-typescript/src/setup.ts - -for symbol in RelaycastSetupOptions CreateWorkspaceOptions JoinWorkspaceOptions WorkspaceInfo RegisterAgentOptions AgentRecord; do - need_regex "interface $symbol" packages/sdk-typescript/src/setup-types.ts -done -for symbol in RelaycastSetupError RelaycastApiError MalformedApiResponseError WorkspaceNotFoundError AgentNotRegisteredError MissingApiKeyError; do - need_regex "class $symbol" packages/sdk-typescript/src/setup-errors.ts -done -for marker in "api_error" "malformed_api_response" "workspace_not_found" "agent_not_registered" "missing_api_key"; do - need_fixed "$marker" packages/sdk-typescript/src/setup-errors.ts -done - -need_regex "interface RelayConfig" packages/sdk-typescript/src/communicate/types.ts -need_regex "type MessageCallback|interface MessageCallback" packages/sdk-typescript/src/communicate/types.ts -need_regex "class Relay" packages/sdk-typescript/src/communicate/relay.ts -for marker in "post(" "send(" "reply(" "inbox(" "onMessage("; do - need_fixed "$marker" packages/sdk-typescript/src/communicate/relay.ts -done - -for marker in "createWorkspace" "joinWorkspace" "lookupWorkspace" "registerAgent" "AgentNotRegisteredError" "RelaycastApiError" "MalformedApiResponseError" "Retry-After" "requestTimeoutMs" "local mode" "listRegisteredAgents" "X-SDK-Version"; do - need_fixed "$marker" packages/sdk-typescript/src/__tests__/setup.test.ts -done -for marker in "post()" "send()" "reply()" "inbox()" "onMessage()" "connect"; do - need_fixed "$marker" packages/sdk-typescript/src/__tests__/communicate.test.ts -done -for marker in "RelaycastSetup" "createWorkspace" "joinWorkspace" "lookupWorkspace" "registerAgent" ".as(" ".relay(" "relayCast()" "onMessage" "mkdtemp"; do - need_fixed "$marker" scripts/e2e-sdk-setup-client.ts -done - -if [ "$fail" -ne 0 ]; then - exit 1 -fi - -echo "APPROVED: spec coverage markers, SDK exports, unit coverage, and local E2E coverage are present." -BASH`; - -async function runWorkflow() { - const result = await workflow('sdk-setup-client-80-100') - .description( - 'Implement docs/sdk-setup-client.md for @relaycast/sdk and drive it from 80 percent code-complete to 100 percent locally verified.', - ) - .pattern('supervisor') - .channel('wf-sdk-setup-client') - .maxConcurrency(5) - .timeout(7_200_000) - .idleNudge({ nudgeAfterMs: 120_000, escalateAfterMs: 180_000, maxNudges: 2 }) - .trajectories({ enabled: true, reflectOnBarriers: true, reflectOnConverge: true, autoDecisions: true }) - - .agent('lead', { - cli: 'claude', - model: ClaudeModels.OPUS, - preset: 'lead', - role: 'Implementation lead. Resolve spec conflicts, coordinate workers, and require evidence before merge.', - retries: 1, - }) - .agent('types-worker', { - cli: 'codex', - model: CodexModels.GPT_5_4, - preset: 'worker', - role: 'Owns setup types and setup-specific errors only.', - retries: 2, - }) - .agent('communicate-worker', { - cli: 'codex', - model: CodexModels.GPT_5_4, - preset: 'worker', - role: 'Owns the communicate-style Relay adapter files only.', - retries: 2, - }) - .agent('setup-worker', { - cli: 'codex', - model: CodexModels.GPT_5_4, - preset: 'worker', - role: 'Owns RelaycastSetup and WorkspaceHandle implementation only.', - retries: 2, - }) - .agent('test-worker', { - cli: 'codex', - model: CodexModels.GPT_5_4, - preset: 'worker', - role: 'Owns unit tests and the local end-to-end setup client script.', - retries: 2, - }) - .agent('reviewer', { - cli: 'claude', - model: ClaudeModels.SONNET, - preset: 'reviewer', - role: 'Reviews spec coverage, snake_case wire behavior, and the 80-to-100 validation evidence.', - retries: 1, - }) - - .step('read-spec-and-surface', { - type: 'deterministic', - command: String.raw`/bin/bash -lc ' -set -euo pipefail -printf "===== docs/sdk-setup-client.md =====\n" -sed -n "1,980p" docs/sdk-setup-client.md -printf "\n===== sdk index =====\n" -sed -n "1,220p" packages/sdk-typescript/src/index.ts -printf "\n===== relay client surface =====\n" -sed -n "1,620p" packages/sdk-typescript/src/relay.ts -printf "\n===== agent client surface =====\n" -sed -n "1,620p" packages/sdk-typescript/src/agent.ts -printf "\n===== http client retry surface =====\n" -sed -n "1,260p" packages/sdk-typescript/src/client.ts -'`, - captureOutput: true, - failOnError: true, - }) - - .step('lead-acceptance-contract', { - agent: 'lead', - dependsOn: ['read-spec-and-surface'], - task: `Create docs/sdk-setup-client-acceptance.md as the acceptance contract from docs/sdk-setup-client.md. -Call out any spec contradictions before implementation. -Prefer existing SDK naming and snake_case HTTP wire behavior. -Require zod or existing typed schemas where validation is practical. -Define every path that must be proven by unit tests and local E2E. -Post concise file ownership assignments for the workers. -Workers are not alone in the codebase; coordinate before editing shared files. - -Context: -{{steps.read-spec-and-surface.output}}`, - verification: { type: 'exit_code', value: '0' }, - retries: 1, - }) - - .step('implement-setup-types', { - agent: 'types-worker', - dependsOn: ['lead-acceptance-contract'], - task: `Create packages/sdk-typescript/src/setup-types.ts. -Define RelaycastSetupOptions, CreateWorkspaceOptions, JoinWorkspaceOptions. -Define WorkspaceInfo, RegisterAgentOptions, and AgentRecord. -Use camelCase TypeScript fields and match the accepted contract below. -Import existing SDK types only when it reduces duplication. -Do not edit other files in this step. -Report the exported type names and any spec ambiguity. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('verify-setup-types', { - type: 'deterministic', - dependsOn: ['implement-setup-types'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -test -f packages/sdk-typescript/src/setup-types.ts -rg "interface RelaycastSetupOptions" packages/sdk-typescript/src/setup-types.ts -rg "interface CreateWorkspaceOptions" packages/sdk-typescript/src/setup-types.ts -rg "interface JoinWorkspaceOptions" packages/sdk-typescript/src/setup-types.ts -rg "interface WorkspaceInfo" packages/sdk-typescript/src/setup-types.ts -rg "interface RegisterAgentOptions" packages/sdk-typescript/src/setup-types.ts -rg "interface AgentRecord" packages/sdk-typescript/src/setup-types.ts -echo "setup-types.ts contract present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('implement-setup-errors', { - agent: 'types-worker', - dependsOn: ['verify-setup-types'], - task: `Create packages/sdk-typescript/src/setup-errors.ts. -Implement RelaycastSetupError plus every subclass from the accepted contract. -Each error must expose the documented readonly code and metadata fields. -Use small constructors with useful messages. -Do not add mixed-case compatibility fallbacks. -Do not edit other files in this step. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('verify-setup-errors', { - type: 'deterministic', - dependsOn: ['implement-setup-errors'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -test -f packages/sdk-typescript/src/setup-errors.ts -for symbol in RelaycastSetupError RelaycastApiError MalformedApiResponseError WorkspaceNotFoundError AgentNotRegisteredError MissingApiKeyError; do - rg "class $symbol" packages/sdk-typescript/src/setup-errors.ts >/dev/null -done -rg "readonly code = '\''api_error'\''" packages/sdk-typescript/src/setup-errors.ts -rg "readonly code = '\''malformed_api_response'\''" packages/sdk-typescript/src/setup-errors.ts -rg "readonly code = '\''agent_not_registered'\''" packages/sdk-typescript/src/setup-errors.ts -echo "setup-errors.ts contract present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('implement-communicate-relay', { - agent: 'communicate-worker', - dependsOn: ['lead-acceptance-contract'], - task: `Create packages/sdk-typescript/src/communicate/types.ts, relay.ts, and index.ts. -Implement the simple Relay wrapper from the accepted contract: send, post, reply, inbox, onMessage, and the documented communicate types such as Message, MessageCallback, and RelayConfig. -Build on AgentClient instead of duplicating HTTP logic. -Preserve channel hash ergonomics and existing AgentClient event behavior. -Keep files focused on communicate-style convenience only. -Do not edit setup.ts or index.ts in this step. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('verify-communicate-relay', { - type: 'deterministic', - dependsOn: ['implement-communicate-relay'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -for f in packages/sdk-typescript/src/communicate/types.ts packages/sdk-typescript/src/communicate/relay.ts packages/sdk-typescript/src/communicate/index.ts; do - test -f "$f" || { echo "Missing $f"; exit 1; } -done -rg "class Relay" packages/sdk-typescript/src/communicate/relay.ts -rg "RelayConfig" packages/sdk-typescript/src/communicate/types.ts -rg "send\\(" packages/sdk-typescript/src/communicate/relay.ts -rg "post\\(" packages/sdk-typescript/src/communicate/relay.ts -rg "onMessage\\(" packages/sdk-typescript/src/communicate/relay.ts -echo "communicate Relay contract present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('implement-setup-client', { - agent: 'setup-worker', - dependsOn: ['verify-setup-types', 'verify-setup-errors', 'verify-communicate-relay'], - task: `Create packages/sdk-typescript/src/setup.ts. -Implement RelaycastSetup and WorkspaceHandle exactly as the accepted contract states. -Default cloud base URL is https://cast.agentrelay.com (the hosted engine); a self-hosted engine is reached by passing baseUrl (e.g. http://localhost:8787). -Use fetch with JSON headers, X-SDK-Version, optional Authorization, timeout, retry, Retry-After, and jitter. -Wrap non-2xx API envelopes in RelaycastApiError and validate required fields. -WorkspaceHandle must manage agent tokens for registerAgent(), relay(), getAgentToken(), and listRegisteredAgents(). -Use RelayCast, AgentClient, HttpClient, and Relay instead of reimplementing existing clients. -Only edit setup.ts in this step. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('verify-setup-client', { - type: 'deterministic', - dependsOn: ['implement-setup-client'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -test -f packages/sdk-typescript/src/setup.ts -rg "class RelaycastSetup" packages/sdk-typescript/src/setup.ts -rg "class WorkspaceHandle" packages/sdk-typescript/src/setup.ts -rg "createWorkspace\\(" packages/sdk-typescript/src/setup.ts -rg "joinWorkspace\\(" packages/sdk-typescript/src/setup.ts -rg "lookupWorkspace\\(" packages/sdk-typescript/src/setup.ts -rg "registerAgent\\(" packages/sdk-typescript/src/setup.ts -rg "AgentNotRegisteredError" packages/sdk-typescript/src/setup.ts -rg "Retry-After" packages/sdk-typescript/src/setup.ts -rg "AbortSignal.timeout" packages/sdk-typescript/src/setup.ts -echo "setup.ts contract present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('wire-sdk-exports', { - agent: 'setup-worker', - dependsOn: ['verify-setup-client'], - task: `Update packages/sdk-typescript/src/index.ts and package exports if needed. -Export RelaycastSetup, setup option/result types, and setup errors from the root index. -Export the communicate Relay and communicate types from the root index exactly as docs/sdk-setup-client.md shows. -Remove any existing root Relay alias that points to RelayCast so Relay names the communicate wrapper. -Expose the communicate Relay and communicate types through ./communicate as well. -Add or preserve the package.json ./communicate subpath without changing unrelated metadata. -Only edit index.ts and package.json in this step. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('verify-sdk-exports', { - type: 'deterministic', - dependsOn: ['wire-sdk-exports'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -rg "RelaycastSetup" packages/sdk-typescript/src/index.ts -rg "setup-types" packages/sdk-typescript/src/index.ts -rg "setup-errors" packages/sdk-typescript/src/index.ts -rg "export \\{ Relay \\} from '\''\\./communicate/relay\\.js'\''" packages/sdk-typescript/src/index.ts -rg "MessageCallback" packages/sdk-typescript/src/index.ts -rg "RelayConfig" packages/sdk-typescript/src/index.ts -! rg "RelayCast as Relay" packages/sdk-typescript/src/index.ts -test -f packages/sdk-typescript/src/communicate/index.ts -rg "export \\{ Relay \\}" packages/sdk-typescript/src/communicate/index.ts -rg "MessageCallback" packages/sdk-typescript/src/communicate/index.ts -rg "\"\\./communicate\"" packages/sdk-typescript/package.json -echo "SDK export contract present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('write-setup-unit-tests', { - agent: 'test-worker', - dependsOn: ['verify-sdk-exports'], - task: `Create packages/sdk-typescript/src/__tests__/setup.test.ts and packages/sdk-typescript/src/__tests__/communicate.test.ts. -Use vitest and vi.stubGlobal('fetch') like existing SDK tests. -Cover every spec path: createWorkspace, joinWorkspace, lookupWorkspace, local mode, custom baseUrl, apiKey function, timeout, retry, 429 Retry-After, malformed response, API error, registerAgent, duplicate handling, relay(), as(), relayCast(), getAgentToken(), listRegisteredAgents(), and exports. -In communicate.test.ts, cover post(), send(), reply(), inbox(), and onMessage() lazy connect/unsubscribe behavior. -Assert snake_case HTTP bodies and camelCase TypeScript results. -For lookup not found, follow the accepted contract from the lead step. -Do not edit source implementation files in this step. - -Accepted contract: -{{steps.lead-acceptance-contract.output}}`, - verification: { type: 'file_exists', value: 'packages/sdk-typescript/src/__tests__/setup.test.ts' }, - }) - .step('verify-setup-unit-tests', { - type: 'deterministic', - dependsOn: ['write-setup-unit-tests'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -test -f packages/sdk-typescript/src/__tests__/setup.test.ts -test -f packages/sdk-typescript/src/__tests__/communicate.test.ts -for token in "createWorkspace" "joinWorkspace" "lookupWorkspace" "registerAgent" "AgentNotRegisteredError" "RelaycastApiError" "MalformedApiResponseError" "Retry-After" "requestTimeoutMs" "local mode" "listRegisteredAgents"; do - rg -F "$token" packages/sdk-typescript/src/__tests__/setup.test.ts >/dev/null || { echo "Missing setup unit coverage marker: $token"; exit 1; } -done -for token in "post()" "send()" "reply()" "inbox()" "onMessage()" "connect"; do - rg -F "$token" packages/sdk-typescript/src/__tests__/communicate.test.ts >/dev/null || { echo "Missing communicate unit coverage marker: $token"; exit 1; } -done -echo "setup and communicate unit coverage markers present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('write-local-e2e-script', { - agent: 'test-worker', - dependsOn: ['verify-sdk-exports'], - task: `Create scripts/e2e-sdk-setup-client.ts. -It must run against a real base URL argument, defaulting to http://localhost:8787. -Exercise createWorkspace, registerAgent Alice and Bob, workspace.as(), relayCast(), relay(), send, post, onMessage, joinWorkspace, lookupWorkspace, local mode, and persisted workspace id/key files in a temp directory. -Use real SDK imports from packages/sdk-typescript/src/index.js. -Fail with clear errors when a setup path does not work. -Do not edit existing scripts/e2e.ts in this step.`, - verification: { type: 'file_exists', value: 'scripts/e2e-sdk-setup-client.ts' }, - }) - .step('verify-local-e2e-script', { - type: 'deterministic', - dependsOn: ['write-local-e2e-script'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -test -f scripts/e2e-sdk-setup-client.ts -for token in "RelaycastSetup" "createWorkspace" "joinWorkspace" "lookupWorkspace" "registerAgent" ".as(" ".relay(" "relayCast()" "onMessage" "mkdtemp"; do - rg -F "$token" scripts/e2e-sdk-setup-client.ts >/dev/null || { echo "Missing E2E coverage marker: $token"; exit 1; } -done -echo "e2e setup script coverage markers present" -'`, - captureOutput: true, - failOnError: true, - }) - - .step('run-setup-tests-initial', { - type: 'deterministic', - dependsOn: ['verify-setup-unit-tests', 'verify-local-e2e-script'], - command: String.raw`/bin/bash -lc ' -set -o pipefail -npm --workspace @relaycast/sdk run test -- src/__tests__/setup.test.ts src/__tests__/communicate.test.ts 2>&1 | tail -120 -'`, - captureOutput: true, - failOnError: false, - }) - .step('fix-setup-tests', { - agent: 'test-worker', - dependsOn: ['run-setup-tests-initial'], - task: `Fix failures from the setup unit test run. -Output: -{{steps.run-setup-tests-initial.output}} - -If all tests already passed, make no changes. -If tests failed, inspect the failing source and test files, fix the smallest correct issue, and rerun: -npm --workspace @relaycast/sdk run test -- src/__tests__/setup.test.ts src/__tests__/communicate.test.ts -Keep looping locally until the setup tests pass. -Do not skip or weaken spec coverage.`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('run-setup-tests-final', { - type: 'deterministic', - dependsOn: ['fix-setup-tests'], - command: 'npm --workspace @relaycast/sdk run test -- src/__tests__/setup.test.ts src/__tests__/communicate.test.ts', - captureOutput: true, - failOnError: true, - }) - - .step('run-sdk-build-initial', { - type: 'deterministic', - dependsOn: ['run-setup-tests-final'], - command: String.raw`/bin/bash -lc ' -set -o pipefail -npm --workspace @relaycast/types run build && npm --workspace @relaycast/sdk run build 2>&1 | tail -120 -'`, - captureOutput: true, - failOnError: false, - }) - .step('fix-sdk-build', { - agent: 'setup-worker', - dependsOn: ['run-sdk-build-initial'], - task: `Fix TypeScript or packaging failures from the SDK build. -Output: -{{steps.run-sdk-build-initial.output}} - -If the build already passed, make no changes. -If it failed, fix source types rather than weakening strictness. -Rerun: -npm --workspace @relaycast/types run build -npm --workspace @relaycast/sdk run build`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('run-sdk-build-final', { - type: 'deterministic', - dependsOn: ['fix-sdk-build'], - command: 'npm --workspace @relaycast/types run build && npm --workspace @relaycast/sdk run build', - captureOutput: true, - failOnError: true, - }) - - .step('run-sdk-regression-initial', { - type: 'deterministic', - dependsOn: ['run-sdk-build-final'], - command: String.raw`/bin/bash -lc ' -set -o pipefail -npm --workspace @relaycast/sdk test 2>&1 | tail -160 -'`, - captureOutput: true, - failOnError: false, - }) - .step('fix-sdk-regressions', { - agent: 'setup-worker', - dependsOn: ['run-sdk-regression-initial'], - task: `Fix regressions from the full SDK test suite. -Output: -{{steps.run-sdk-regression-initial.output}} - -If all tests passed, make no changes. -If existing tests broke, preserve existing behavior and adapt the setup client around it. -Rerun npm --workspace @relaycast/sdk test until the full SDK suite is green.`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('run-sdk-regression-final', { - type: 'deterministic', - dependsOn: ['fix-sdk-regressions'], - command: 'npm --workspace @relaycast/sdk test', - captureOutput: true, - failOnError: true, - }) - - .step('run-local-e2e-initial', { - type: 'deterministic', - dependsOn: ['run-sdk-regression-final'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -mkdir -p .relay -log=.relay/sdk-setup-client-server.log -rm -f "$log" -node packages/engine/dist/bin/serve.js --port 8799 >"$log" 2>&1 & -server_pid=$! -cleanup() { - kill "$server_pid" >/dev/null 2>&1 || true - wait "$server_pid" >/dev/null 2>&1 || true -} -trap cleanup EXIT -ready=0 -for _ in {1..90}; do - if curl -fsS http://127.0.0.1:8799/health >/dev/null 2>&1; then - ready=1 - break - fi - sleep 1 -done -if [ "$ready" != "1" ]; then - echo "Server did not become ready. Last log lines:" - tail -120 "$log" || true - exit 1 -fi -npx tsx scripts/e2e-sdk-setup-client.ts http://127.0.0.1:8799 2>&1 | tail -200 -'`, - captureOutput: true, - failOnError: false, - }) - .step('fix-local-e2e', { - agent: 'test-worker', - dependsOn: ['run-local-e2e-initial'], - task: `Fix failures from the real local server E2E run. -Output: -{{steps.run-local-e2e-initial.output}} - -This is the 100 percent gate. Do not replace it with mocks. -Fix source, tests, or the E2E script as needed, then rerun the same server-backed command. -The E2E must prove create, join, lookup, register, as(), relayCast(), relay(), channel post, DM send, onMessage, local mode, and persisted workspace paths.`, - verification: { type: 'exit_code', value: '0' }, - }) - .step('run-local-e2e-final', { - type: 'deterministic', - dependsOn: ['fix-local-e2e'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -mkdir -p .relay -log=.relay/sdk-setup-client-server-final.log -rm -f "$log" -node packages/engine/dist/bin/serve.js --port 8799 >"$log" 2>&1 & -server_pid=$! -cleanup() { - kill "$server_pid" >/dev/null 2>&1 || true - wait "$server_pid" >/dev/null 2>&1 || true -} -trap cleanup EXIT -ready=0 -for _ in {1..90}; do - if curl -fsS http://127.0.0.1:8799/health >/dev/null 2>&1; then - ready=1 - break - fi - sleep 1 -done -if [ "$ready" != "1" ]; then - echo "Server did not become ready. Last log lines:" - tail -120 "$log" || true - exit 1 -fi -npx tsx scripts/e2e-sdk-setup-client.ts http://127.0.0.1:8799 -'`, - captureOutput: true, - failOnError: true, - }) - - .step('run-focused-server-regressions', { - type: 'deterministic', - dependsOn: ['run-local-e2e-final'], - command: 'npm --workspace @relaycast/engine run test', - captureOutput: true, - failOnError: true, - }) - .step('run-monorepo-regression-final', { - type: 'deterministic', - dependsOn: ['run-focused-server-regressions'], - command: 'npx turbo test --filter=@relaycast/sdk --filter=@relaycast/engine', - captureOutput: true, - failOnError: true, - }) - - .step('audit-spec-coverage-initial', { - type: 'deterministic', - dependsOn: ['run-monorepo-regression-final'], - command: specCoverageAuditCommand, - captureOutput: true, - failOnError: false, - }) - .step('fix-spec-coverage', { - agent: 'reviewer', - dependsOn: ['audit-spec-coverage-initial'], - task: `Close any remaining spec coverage gaps from the deterministic audit. - -Audit output: -{{steps.audit-spec-coverage-initial.output}} - -If the audit output contains APPROVED, make no changes. -If it contains CHANGES_REQUIRED, fix the exact files named by the audit. -Preserve the green tests and local E2E behavior; do not weaken coverage markers. -After edits, rerun the audit command embedded in workflows/sdk-setup-client-80-100.ts and then rerun any affected focused tests.`, - verification: { type: 'exit_code', value: '0' }, - retries: 1, - }) - .step('review-spec-coverage', { - type: 'deterministic', - dependsOn: ['fix-spec-coverage'], - command: specCoverageAuditCommand, - captureOutput: true, - failOnError: true, - verification: { type: 'output_contains', value: 'APPROVED' }, - retries: 1, - }) - .step('final-diff-and-status', { - type: 'deterministic', - dependsOn: ['review-spec-coverage'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -git status --short -git diff --stat -git diff --check -'`, - captureOutput: true, - failOnError: true, - }) - .step('commit-after-green', { - type: 'deterministic', - dependsOn: ['final-diff-and-status'], - command: String.raw`/bin/bash -lc ' -set -euo pipefail -git add \ - docs/sdk-setup-client-acceptance.md \ - packages/sdk-typescript/src/setup.ts \ - packages/sdk-typescript/src/setup-types.ts \ - packages/sdk-typescript/src/setup-errors.ts \ - packages/sdk-typescript/src/communicate \ - packages/sdk-typescript/src/index.ts \ - packages/sdk-typescript/package.json \ - packages/sdk-typescript/src/__tests__/setup.test.ts \ - packages/sdk-typescript/src/__tests__/communicate.test.ts \ - scripts/e2e-sdk-setup-client.ts -git commit -m "feat(sdk): add setup client" -'`, - captureOutput: true, - failOnError: true, - }) - .onError('retry', { maxRetries: 1, retryDelayMs: 10_000 }) - .run({ cwd: process.cwd(), dryRun }); - - if ('valid' in result) { - console.log('Workflow dry-run valid:', result.valid); - return; - } - - console.log('Workflow status:', result.status); -} - -runWorkflow().catch((error) => { - console.error(error); - process.exit(1); -}); From 027e212d3015cd56600d4c55e7535532d05eadd1 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 10:57:15 -0400 Subject: [PATCH 11/15] Align observer auth contracts --- openapi.yaml | 295 ++++++++++++++++++-- packages/engine/src/engine/observerToken.ts | 34 +-- packages/sdk-rust/src/types.rs | 2 - packages/sdk-typescript/src/types.ts | 64 +---- packages/types/src/workspace.ts | 63 +++++ scripts/e2e-actions.ts | 10 +- 6 files changed, 362 insertions(+), 106 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 73587809..fc01c95f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -834,6 +834,138 @@ components: type: string description: Raw ot_live_* token, returned only on create and rotate. + ConsoleMessageLog: + type: object + properties: + id: + type: string + message_id: + type: string + nullable: true + channel_id: + type: string + nullable: true + channel_name: + type: string + nullable: true + agent_id: + type: string + nullable: true + agent_name: + type: string + nullable: true + conversation_id: + type: string + nullable: true + delivery_kind: + type: string + enum: [channel, dm] + text: + type: string + nullable: true + content_type: + type: string + nullable: true + metadata: + type: object + additionalProperties: true + attachment_count: + type: integer + mention_count: + type: integer + latency_ms: + type: integer + nullable: true + created_at: + type: string + format: date-time + + ConsoleOverview: + type: object + properties: + window_days: + type: integer + since: + type: string + format: date-time + total_messages: + type: integer + channel_messages: + type: integer + dm_messages: + type: integer + unique_agents: + type: integer + avg_latency_ms: + type: integer + max_latency_ms: + type: integer + attachment_count: + type: integer + mention_count: + type: integer + + ConsoleAgentStat: + type: object + properties: + agent_id: + type: string + agent_name: + type: string + nullable: true + message_count: + type: integer + channel_count: + type: integer + dm_count: + type: integer + avg_latency_ms: + type: integer + last_message_at: + type: string + format: date-time + nullable: true + + ConsoleCostAgent: + type: object + properties: + agent_id: + type: string + agent_name: + type: string + nullable: true + message_count: + type: integer + total_cost_usd: + type: number + prompt_tokens: + type: integer + completion_tokens: + type: integer + total_tokens: + type: integer + + ConsoleCostStats: + type: object + properties: + window_days: + type: integer + totals: + type: object + properties: + total_cost_usd: + type: number + prompt_tokens: + type: integer + completion_tokens: + type: integer + total_tokens: + type: integer + agents: + type: array + items: + $ref: '#/components/schemas/ConsoleCostAgent' + Error: type: object properties: @@ -1338,11 +1470,12 @@ paths: get: summary: List agents - description: List all agents in the workspace + description: List all agents in the workspace. Observer tokens require `agents:read`. tags: - Agents security: - workspaceKey: [] + - observerToken: [] parameters: - name: status in: query @@ -1367,11 +1500,12 @@ paths: /agents/{name}: get: summary: Get agent - description: Get agent by name + description: Get agent by name. Observer tokens require `agents:read`. tags: - Agents security: - workspaceKey: [] + - observerToken: [] parameters: - name: name in: path @@ -1599,12 +1733,13 @@ paths: get: summary: List channels - description: List all channels in the workspace + description: List all channels in the workspace. Observer tokens require `channels:read`. tags: - Channels security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: include_archived in: query @@ -1629,12 +1764,13 @@ paths: /channels/{name}: get: summary: Get channel - description: Get channel with members + description: Get channel with members. Observer tokens require `channels:read`. tags: - Channels security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: name in: path @@ -1799,12 +1935,13 @@ paths: /channels/{name}/members: get: summary: List channel members - description: Get all members of a channel + description: Get all members of a channel. Observer tokens require `channels:read`. tags: - Channels security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: name in: path @@ -2004,12 +2141,13 @@ paths: get: summary: List messages - description: Get messages from a channel + description: Get messages from a channel. Observer tokens require `messages:read`. tags: - Messages security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: name in: path @@ -2049,12 +2187,13 @@ paths: /messages/{id}: get: summary: Get message - description: Get a single message by ID + description: Get a single message by ID. Observer tokens require `messages:read` for channel messages or `dms:read` for DM-backed messages, subject to filters. tags: - Messages security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: id in: path @@ -2128,12 +2267,13 @@ paths: get: summary: Get thread - description: Get all replies to a message + description: Get all replies to a message. Observer tokens require `threads:read`, and DM-backed threads also require `dms:read` through DM filters. tags: - Threads security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: id in: path @@ -2212,12 +2352,13 @@ paths: get: summary: Get reactions - description: Get aggregated reactions for a message + description: Get aggregated reactions for a message. Observer tokens require `reactions:read`; DM-backed messages also require `dms:read` and DM filters. tags: - Reactions security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: id in: path @@ -2431,12 +2572,13 @@ paths: /search: get: summary: Search messages - description: Full-text search across messages + description: Full-text search across messages. Observer tokens require `search:read`; DM search results require `dms:read` and DM filters. tags: - Search security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: q in: query @@ -2503,12 +2645,13 @@ paths: /agents/presence: get: summary: List agent presence - description: Get current online/offline presence state for workspace agents + description: Get current online/offline presence state for workspace agents. Observer tokens require `agents:read`. tags: - Presence security: - workspaceKey: [] - agentToken: [] + - observerToken: [] responses: '200': description: Presence list @@ -2617,11 +2760,12 @@ paths: /activity: get: summary: Get workspace activity feed - description: Get recent workspace activity events. Channel message items include `channel_id` and `channel_name`; DM items include `conversation_id`. + description: Get recent workspace activity events. Channel message items include `channel_id` and `channel_name`; DM items include `conversation_id`. Observer tokens require `activity:read`, and DM activity also requires `dms:read` plus DM filters. tags: - Workspaces security: - workspaceKey: [] + - observerToken: [] parameters: - name: limit in: query @@ -2725,11 +2869,12 @@ paths: /dm/conversations/all: get: summary: List all workspace DM conversations - description: Workspace-admin view of all DM conversations + description: Workspace-admin view of all DM conversations. Observer tokens require `dms:read` and DM filters. tags: - Direct Messages security: - workspaceKey: [] + - observerToken: [] responses: '200': description: Workspace DM conversations @@ -2741,11 +2886,12 @@ paths: /dm/conversations/{conversation_id}/messages: get: summary: Get workspace DM conversation messages - description: Workspace-admin access to messages for any DM conversation + description: Workspace-admin access to messages for any DM conversation. Observer tokens require `dms:read` and DM filters. tags: - Direct Messages security: - workspaceKey: [] + - observerToken: [] parameters: - name: conversation_id in: path @@ -3075,12 +3221,13 @@ paths: /files: get: summary: List files - description: List files in workspace + description: List files in workspace. Observer tokens require `files:read`; DM-attached files also require `dms:read` and DM filters. Channel, agent, and time filters are applied where attachment context exists. tags: - Files security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: uploaded_by in: query @@ -3101,12 +3248,13 @@ paths: /files/{id}: get: summary: Get file metadata - description: Get file metadata and download URL information + description: Get file metadata and download URL information. Observer tokens require `files:read`; DM-attached files also require `dms:read` and DM filters. Channel, agent, and time filters are applied where attachment context exists. tags: - Files security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: id in: path @@ -3407,12 +3555,13 @@ paths: $ref: '#/components/schemas/SuccessResponse' get: summary: List nodes - description: List delivery nodes in the workspace, optionally filtered by capability or name. + description: List delivery nodes in the workspace, optionally filtered by capability or name. Observer tokens require `nodes:read`; agent filters constrain active-agent details. tags: - Nodes security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: capability in: query @@ -3435,12 +3584,13 @@ paths: /nodes/{name}: get: summary: Get node - description: Get one node roster entry by name. + description: Get one node roster entry by name. Observer tokens require `nodes:read`; agent filters constrain active-agent details. tags: - Nodes security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: name in: path @@ -3464,12 +3614,13 @@ paths: /nodes/{name}/agents: get: summary: List node agent bindings - description: List active agent bindings for a node. + description: List active agent bindings for a node. Observer tokens require `nodes:read`; `agent_ids` filters constrain returned bindings. tags: - Nodes security: - workspaceKey: [] - agentToken: [] + - observerToken: [] parameters: - name: name in: path @@ -3778,11 +3929,12 @@ paths: $ref: '#/components/schemas/SuccessResponse' get: summary: List agent session events - description: List recorded session events for an agent + description: List recorded session events for an agent. Observer tokens require `activity:read`; `agent_ids` and `created_after` filters apply. tags: - Agents security: - workspaceKey: [] + - observerToken: [] parameters: - name: name in: path @@ -4232,7 +4384,40 @@ paths: /console/messages: get: summary: List console messages + description: List observability console message logs. Observer tokens require `messages:read`; DM logs also require `dms:read` and DM filters. tags: [Console] + security: + - workspaceKey: [] + - agentToken: [] + - observerToken: [] + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + - name: before + in: query + schema: + type: string + - name: agent_id + in: query + schema: + type: string + - name: channel_id + in: query + schema: + type: string + - name: conversation_id + in: query + schema: + type: string + - name: delivery_kind + in: query + schema: + type: string + enum: [channel, dm] responses: '200': description: Console messages @@ -4240,11 +4425,31 @@ paths: application/json: schema: type: object + properties: + ok: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/ConsoleMessageLog' /console/stats: get: summary: Get console stats + description: Get observability console summary stats. Observer tokens require `activity:read`. tags: [Console] + security: + - workspaceKey: [] + - agentToken: [] + - observerToken: [] + parameters: + - name: days + in: query + schema: + type: integer + minimum: 1 + maximum: 30 + default: 7 responses: '200': description: Console stats @@ -4252,11 +4457,36 @@ paths: application/json: schema: type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ConsoleOverview' /console/agents: get: summary: List console agents + description: List per-agent observability console stats. Observer tokens require `agents:read`; `agent_ids` filters constrain the aggregate. tags: [Console] + security: + - workspaceKey: [] + - agentToken: [] + - observerToken: [] + parameters: + - name: days + in: query + schema: + type: integer + minimum: 1 + maximum: 30 + default: 7 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 responses: '200': description: Console agents @@ -4264,11 +4494,31 @@ paths: application/json: schema: type: object + properties: + ok: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/ConsoleAgentStat' /console/costs: get: summary: Get cost breakdown + description: Get observability console cost breakdown. Observer tokens require `activity:read`. tags: [Console] + security: + - workspaceKey: [] + - agentToken: [] + - observerToken: [] + parameters: + - name: days + in: query + schema: + type: integer + minimum: 1 + maximum: 30 + default: 7 responses: '200': description: Cost data @@ -4276,6 +4526,11 @@ paths: application/json: schema: type: object + properties: + ok: + type: boolean + data: + $ref: '#/components/schemas/ConsoleCostStats' # ── Directory ─────────────────────────────────────────────────────── /directory/agents: diff --git a/packages/engine/src/engine/observerToken.ts b/packages/engine/src/engine/observerToken.ts index 64c5da98..affa9d9f 100644 --- a/packages/engine/src/engine/observerToken.ts +++ b/packages/engine/src/engine/observerToken.ts @@ -1,4 +1,6 @@ import { and, eq, or, gt, isNull, inArray, ne } from 'drizzle-orm'; +import { OBSERVER_SCOPES } from '@relaycast/types'; +import type { ObserverScope as SharedObserverScope } from '@relaycast/types'; import type { getDb } from '../db/index.js'; import { channels, dmConversations, files as fileRows, messageAttachments, messages, observerTokens } from '../db/schema.js'; import type { ObserverTokenFilters } from '../db/schema.js'; @@ -10,22 +12,8 @@ type Db = ReturnType; export type ObserverToken = typeof observerTokens.$inferSelect; const LAST_USED_DEBOUNCE_MS = 30_000; -export const OBSERVER_SCOPES = [ - 'stream:read', - 'messages:read', - 'threads:read', - 'dms:read', - 'channels:read', - 'search:read', - 'agents:read', - 'nodes:read', - 'deliveries:read', - 'activity:read', - 'files:read', - 'reactions:read', -] as const; - -export type ObserverScope = typeof OBSERVER_SCOPES[number]; +export { OBSERVER_SCOPES }; +export type ObserverScope = SharedObserverScope; export const OBSERVER_SCOPE_SET = new Set(OBSERVER_SCOPES); @@ -317,11 +305,10 @@ function hasChannelOrDmFilter(filters: ObserverTokenFilters): boolean { export function observerAllowsChannel( observer: Pick | undefined, - channel: { id?: string | null; name?: string | null; channel_type?: number | null; channelType?: number | null }, + channel: { id?: string | null; name?: string | null; channel_type?: number | null }, ): boolean { if (!observer) return true; - const channelType = channel.channel_type ?? channel.channelType; - if (channelType != null && channelType !== 0) return false; + if (channel.channel_type != null && channel.channel_type !== 0) return false; const filters = normalizeObserverFilters(observer.filters); const ids = filterList(filters.channel_ids); const names = filterList(filters.channel_names); @@ -375,10 +362,10 @@ export function observerAllowsCreatedAt( export function observerAllowsMessage( observer: Pick | undefined, - message: { agent_id?: string | null; agentId?: string | null; created_at?: string | null; createdAt?: Date | string | number | null }, + message: { agent_id?: string | null; created_at?: string | Date | number | null }, ): boolean { - return observerAllowsAgent(observer, message.agent_id ?? message.agentId) - && observerAllowsCreatedAt(observer, message.created_at ?? message.createdAt); + return observerAllowsAgent(observer, message.agent_id) + && observerAllowsCreatedAt(observer, message.created_at); } export type MessageObserverResource = { @@ -649,7 +636,6 @@ export async function filterObserverSearchResults; export type UploadRequest = Camelize; export type UploadResponse = Camelize; -export type ObserverScope = - | 'stream:read' - | 'messages:read' - | 'threads:read' - | 'dms:read' - | 'channels:read' - | 'search:read' - | 'agents:read' - | 'nodes:read' - | 'deliveries:read' - | 'activity:read' - | 'files:read' - | 'reactions:read'; - -export interface ObserverTokenFilters { - channelIds?: string[]; - channelNames?: string[]; - includeDms?: boolean; - dmConversationIds?: string[]; - agentIds?: string[]; - eventTypes?: string[]; - createdAfter?: string; -} - -export interface CreateObserverTokenRequest { - name: string; - description?: string | null; - scopes: ObserverScope[]; - filters?: ObserverTokenFilters; - expiresAt?: string | null; -} - -export interface UpdateObserverTokenRequest { - name?: string; - description?: string | null; - scopes?: ObserverScope[]; - filters?: ObserverTokenFilters; - expiresAt?: string | null; -} - -export interface ObserverToken { - id: string; - name: string; - description: string | null; - scopes: ObserverScope[]; - filters: ObserverTokenFilters; - status: string; - expiresAt: string | null; - createdAt: string; - updatedAt: string | null; - revokedAt: string | null; - lastUsedAt: string | null; - token?: string; -} +export type ObserverScope = Raw.ObserverScope; +export type ObserverTokenFilters = Camelize; +export type CreateObserverTokenRequest = Camelize; +export type UpdateObserverTokenRequest = Camelize; +export type ObserverToken = Camelize; export type ActionInvokedEvent = Camelize; export type ActionCompletedEvent = Camelize; @@ -729,3 +680,8 @@ type _AssertNodeCapability = Assert> // Action output is any JSON value (scalars/arrays/null legal), not object-only. type _AssertInvocationOutput = Assert>; type _AssertCompletionOutput = Assert>; +type _AssertObserverScope = Assert>; +type _AssertObserverTokenFilters = Assert>>; +type _AssertCreateObserverTokenRequest = Assert>>; +type _AssertUpdateObserverTokenRequest = Assert>>; +type _AssertObserverToken = Assert>>; diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index d858de3c..3ab13ccc 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -80,3 +80,66 @@ export const TokenRotateResponseSchema = z.object({ token: z.string(), }); export type TokenRotateResponse = z.infer; + +// --- Observer tokens --- + +export const OBSERVER_SCOPES = [ + 'stream:read', + 'messages:read', + 'threads:read', + 'dms:read', + 'channels:read', + 'search:read', + 'agents:read', + 'nodes:read', + 'deliveries:read', + 'activity:read', + 'files:read', + 'reactions:read', +] as const; +export type ObserverScope = typeof OBSERVER_SCOPES[number]; + +export const ObserverTokenFiltersSchema = z.object({ + channel_ids: z.array(z.string()).optional(), + channel_names: z.array(z.string()).optional(), + include_dms: z.boolean().optional(), + dm_conversation_ids: z.array(z.string()).optional(), + agent_ids: z.array(z.string()).optional(), + event_types: z.array(z.string()).optional(), + created_after: z.string().optional(), +}); +export type ObserverTokenFilters = z.infer; + +export const CreateObserverTokenRequestSchema = z.object({ + name: z.string(), + description: z.string().nullable().optional(), + scopes: z.array(z.enum(OBSERVER_SCOPES)).min(1), + filters: ObserverTokenFiltersSchema.optional(), + expires_at: z.string().nullable().optional(), +}); +export type CreateObserverTokenRequest = z.infer; + +export const UpdateObserverTokenRequestSchema = z.object({ + name: z.string().optional(), + description: z.string().nullable().optional(), + scopes: z.array(z.enum(OBSERVER_SCOPES)).min(1).optional(), + filters: ObserverTokenFiltersSchema.optional(), + expires_at: z.string().nullable().optional(), +}); +export type UpdateObserverTokenRequest = z.infer; + +export const ObserverTokenSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + scopes: z.array(z.enum(OBSERVER_SCOPES)), + filters: ObserverTokenFiltersSchema, + status: z.enum(['active', 'revoked']), + expires_at: z.string().nullable(), + created_at: z.string(), + updated_at: z.string().nullable(), + revoked_at: z.string().nullable(), + last_used_at: z.string().nullable(), + token: z.string().optional(), +}); +export type ObserverToken = z.infer; diff --git a/scripts/e2e-actions.ts b/scripts/e2e-actions.ts index 86b0bc0a..304eeecf 100644 --- a/scripts/e2e-actions.ts +++ b/scripts/e2e-actions.ts @@ -104,7 +104,7 @@ async function main(): Promise { const wsName = `actions-e2e-${Date.now()}`; const wsRes = await req('POST', '/v1/workspaces', { body: { name: wsName } }); if (wsRes.status >= 300) throw new Error(`create workspace failed: ${wsRes.status}`); - const workspaceKey: string = wsRes.json.data.api_key ?? wsRes.json.data.key; + const workspaceKey: string = wsRes.json.data.api_key; const mkAgent = async (name: string) => { const r = await req('POST', '/v1/agents', { token: workspaceKey, body: { name } }); @@ -157,9 +157,7 @@ async function main(): Promise { const r = await req('GET', '/v1/actions/deploy', { token: caller.token }); if (r.status !== 200) throw new Error(`status ${r.status}`); if (r.json.data?.name !== 'deploy') throw new Error(`expected action name "deploy", got ${JSON.stringify(r.json.data?.name)}`); - // handler is exposed either as id or name depending on shape — accept either. - const handlerOk = r.json.data.handler_agent_id === handler.id || r.json.data.handler_agent === handler.name; - if (!handlerOk) throw new Error(`handler mismatch: ${JSON.stringify(r.json.data)}`); + if (r.json.data.handler_agent !== handler.name) throw new Error(`handler mismatch: ${JSON.stringify(r.json.data)}`); }); // ── 4. Caller invokes → gets invocation_id with status 'invoked' ── @@ -177,7 +175,7 @@ async function main(): Promise { await test('Handler receives action.invoked over WebSocket', async () => { const e = await handlerWs.waitFor('action.invoked'); const data = e.data ?? e; - if ((data.action_name ?? e.action_name) !== 'deploy') throw new Error('wrong action in event'); + if (data.action_name !== 'deploy') throw new Error('wrong action in event'); }); // ── 6. Handler completes the invocation ── @@ -194,7 +192,7 @@ async function main(): Promise { await test('Caller receives action.completed over WebSocket', async () => { const e = await callerWs.waitFor('action.completed'); const data = e.data ?? e; - if ((data.action_name ?? e.action_name) !== 'deploy') throw new Error('wrong action in completion event'); + if (data.action_name !== 'deploy') throw new Error('wrong action in completion event'); }); // ── 8. Get invocation reflects completion + output ── From fa6aca4d12e3268f3d2e9d91ab7498059559a696 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 11:01:04 -0400 Subject: [PATCH 12/15] Clean up migration leftovers and React races --- packages/engine/src/engine/agent.ts | 225 ------------------ packages/engine/src/engine/console.ts | 4 - packages/engine/src/engine/wsTransform.ts | 12 - packages/mcp/src/__tests__/ws-bridge.test.ts | 20 +- packages/mcp/src/resources/ws-bridge.ts | 8 +- .../src/components/ChatFeed.tsx | 17 +- .../src/__tests__/fetchRaceGuards.test.tsx | 156 ++++++++++++ .../react/src/__tests__/provider.test.tsx | 102 ++++++++ packages/react/src/hooks/useChannel.ts | 9 +- packages/react/src/hooks/useMessages.ts | 9 +- packages/react/src/hooks/useThread.ts | 9 +- packages/react/src/provider.tsx | 29 ++- packages/types/src/command.ts | 58 ----- packages/types/src/events.ts | 13 - packages/types/src/index.ts | 1 - packages/types/src/subscription.ts | 1 - 16 files changed, 337 insertions(+), 336 deletions(-) create mode 100644 packages/react/src/__tests__/fetchRaceGuards.test.tsx create mode 100644 packages/react/src/__tests__/provider.test.tsx delete mode 100644 packages/types/src/command.ts diff --git a/packages/engine/src/engine/agent.ts b/packages/engine/src/engine/agent.ts index b3eee64a..a3c94c5e 100644 --- a/packages/engine/src/engine/agent.ts +++ b/packages/engine/src/engine/agent.ts @@ -307,228 +307,3 @@ export async function sweepStaleAgents(db: Db): Promise { return result.length; } - -// === Spawn/Release (Agent Lifecycle) === - -export interface SpawnAgentData { - name: string; - cli: string; - task: string; - channel?: string; - persona?: string; - metadata?: Record; -} - -export interface SpawnAgentResult { - id: string; - name: string; - handle: string; - token: string; - cli: string; - task: string; - channel: string | null; - status: string; - created_at: string; - already_existed: boolean; -} - -export async function spawnAgent( - db: Db, - workspaceId: string, - data: SpawnAgentData, -): Promise { - // Check if agent already exists - const [existing] = await db - .select() - .from(agents) - .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, data.name))); - - let agentId: string; - let token: string; - let createdAt: string; - let alreadyExisted = false; - - if (existing) { - // Agent exists - rotate token and update metadata - alreadyExisted = true; - agentId = existing.id; - token = `at_live_${randomHex(16)}`; - const tokenHash = await sha256Hex(token); - - // Update agent with new token, set online, store spawn metadata - const spawnMetadata = { - ...(existing.metadata ?? {}), - ...(data.metadata || {}), - cli: data.cli, - spawn: { - cli: data.cli, - task: data.task, - spawned_at: new Date().toISOString(), - }, - }; - - await db - .update(agents) - .set({ - tokenHash, - status: 'active', - lastSeen: new Date(), - persona: data.persona ?? existing.persona, - metadata: spawnMetadata, - }) - .where(eq(agents.id, agentId)); - - await ensureDirectNodeForAgent(db, workspaceId, existing); - - createdAt = existing.createdAt.toISOString(); - } else { - // Create new agent - agentId = generateId(); - token = `at_live_${randomHex(16)}`; - const tokenHash = await sha256Hex(token); - - const spawnMetadata = { - ...(data.metadata || {}), - cli: data.cli, - spawn: { - cli: data.cli, - task: data.task, - spawned_at: new Date().toISOString(), - }, - }; - - const [agent] = await db - .insert(agents) - .values({ - id: agentId, - workspaceId, - name: data.name, - handle: `@${data.name}`, - type: 'agent', - tokenHash, - status: 'active', - persona: data.persona ?? null, - metadata: spawnMetadata, - }) - .returning(); - - createdAt = agent.createdAt.toISOString(); - - // Auto-join #general - const [generalChannel] = await db - .select() - .from(channels) - .where( - and(eq(channels.workspaceId, workspaceId), eq(channels.name, 'general')), - ); - - if (generalChannel) { - await db.insert(channelMembers).values({ - channelId: generalChannel.id, - agentId, - role: 'member', - }).onConflictDoNothing(); - } - - await ensureDirectNodeForAgent(db, workspaceId, agent); - } - - // Join specified channel if provided - let joinedChannel: string | null = null; - if (data.channel) { - const channelName = data.channel.startsWith('#') ? data.channel.slice(1) : data.channel; - const [channel] = await db - .select() - .from(channels) - .where( - and(eq(channels.workspaceId, workspaceId), eq(channels.name, channelName)), - ); - - if (channel && !channel.isArchived) { - await db.insert(channelMembers).values({ - channelId: channel.id, - agentId, - role: 'member', - }).onConflictDoNothing(); - joinedChannel = channel.name; - } - } - - return { - id: agentId, - name: data.name, - handle: alreadyExisted ? (existing.handle ?? `@${data.name}`) : `@${data.name}`, - token, - cli: data.cli, - task: data.task, - channel: joinedChannel, - status: 'active', - created_at: createdAt, - already_existed: alreadyExisted, - }; -} - -export interface ReleaseAgentData { - name: string; - reason?: string; - delete_agent?: boolean; -} - -export interface ReleaseAgentResult { - name: string; - released: boolean; - deleted: boolean; - reason: string | null; -} - -export async function releaseAgent( - db: Db, - workspaceId: string, - data: ReleaseAgentData, -): Promise { - const [agent] = await db - .select() - .from(agents) - .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, data.name))); - - if (!agent) { - return null; - } - - if (data.delete_agent) { - // Delete the agent entirely - await db.delete(agents).where(eq(agents.id, agent.id)); - await db.delete(nodes).where(eq(nodes.id, directNodeIdForAgent(agent.id))); - return { - name: data.name, - released: true, - deleted: true, - reason: data.reason ?? null, - }; - } - - // Mark as offline and clear spawn metadata - const existingMetadata = agent.metadata ?? {}; - const { spawn: _spawn, cli: _cli, ...restMetadata } = existingMetadata; - - await db - .update(agents) - .set({ - status: 'offline', - metadata: { - ...restMetadata, - release: { - reason: data.reason ?? null, - released_at: new Date().toISOString(), - }, - }, - }) - .where(eq(agents.id, agent.id)); - - return { - name: data.name, - released: true, - deleted: false, - reason: data.reason ?? null, - }; -} diff --git a/packages/engine/src/engine/console.ts b/packages/engine/src/engine/console.ts index 819924f6..2be97dc1 100644 --- a/packages/engine/src/engine/console.ts +++ b/packages/engine/src/engine/console.ts @@ -128,10 +128,6 @@ export function buildMessageLogWrite(db: Db, input: LogMessageInput): AtomicWrit .onConflictDoNothing(); } -export async function logMessage(db: Db, input: LogMessageInput): Promise { - await buildMessageLogWrite(db, input); -} - export async function listMessageLogs( db: Db, workspaceId: string, diff --git a/packages/engine/src/engine/wsTransform.ts b/packages/engine/src/engine/wsTransform.ts index 4a80d3ea..efa7cf4f 100644 --- a/packages/engine/src/engine/wsTransform.ts +++ b/packages/engine/src/engine/wsTransform.ts @@ -255,18 +255,6 @@ export function transformForClient(event: WsEvent): Record { }, }; - case 'command.invoked': - return { - type: 'command.invoked', - ...withCreatedAt, - command: d.command as string, - channel: d.channel as string, - invoked_by: d.invoked_by as string, - handler_agent_id: d.handler_agent_id as string, - args: (d.args as string | null) ?? null, - parameters: (d.parameters as Record | null) ?? null, - }; - default: { const { workspace_id: _workspace_id, channel_id: _channel_id, timestamp: _timestamp, data, ...rest } = event as WsEvent & Record; return { ...rest, ...data, ...withCreatedAt }; diff --git a/packages/mcp/src/__tests__/ws-bridge.test.ts b/packages/mcp/src/__tests__/ws-bridge.test.ts index f41b5f74..1b548ce8 100644 --- a/packages/mcp/src/__tests__/ws-bridge.test.ts +++ b/packages/mcp/src/__tests__/ws-bridge.test.ts @@ -200,17 +200,15 @@ describe('eventToResourceUris', () => { ]); }); - it('maps command.invoked to channel messages', () => { - const event = { - type: 'command.invoked', - command: '/deploy', - channel: 'general', - invokedBy: 'agent1', - args: null, - } as WsClientEvent; - expect(eventToResourceUris(event)).toEqual([ - 'relay://channels/general/messages', - ]); + it('maps action lifecycle events to inbox', () => { + for (const type of ['action.invoked', 'action.completed', 'action.failed', 'action.denied'] as const) { + const event = { + type, + invocationId: 'inv_1', + actionName: 'deploy', + } as WsClientEvent; + expect(eventToResourceUris(event)).toEqual(['relay://inbox']); + } }); it('returns empty array for unknown event types', () => { diff --git a/packages/mcp/src/resources/ws-bridge.ts b/packages/mcp/src/resources/ws-bridge.ts index 361b7e5b..5015f81a 100644 --- a/packages/mcp/src/resources/ws-bridge.ts +++ b/packages/mcp/src/resources/ws-bridge.ts @@ -47,11 +47,15 @@ export function eventToResourceUris(event: WsClientEvent): string[] { case 'member.joined': case 'member.left': return ['relay://channels']; - case 'webhook.received': - case 'command.invoked': { + case 'webhook.received': { const channel = getStringEventField(event, 'channel'); return channel ? [`relay://channels/${channel}/messages`] : []; } + case 'action.invoked': + case 'action.completed': + case 'action.failed': + case 'action.denied': + return ['relay://inbox']; case 'message.read': return []; case 'file.uploaded': diff --git a/packages/observer-dashboard/src/components/ChatFeed.tsx b/packages/observer-dashboard/src/components/ChatFeed.tsx index bde458a2..30e8b139 100644 --- a/packages/observer-dashboard/src/components/ChatFeed.tsx +++ b/packages/observer-dashboard/src/components/ChatFeed.tsx @@ -216,13 +216,24 @@ function DmMessages({ conversationId, scrollRef, mentionNames, onOpenAgent }: { const relay = useRelay(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const requestSeq = useRef(0); useEffect(() => { + const seq = ++requestSeq.current; setLoading(true); relay.dmMessages(conversationId, { limit: PAGE_SIZE }) - .then((dms) => setMessages(dms.map(toMessageWithMeta))) + .then((dms) => { + if (seq !== requestSeq.current) return; + setMessages(dms.map(toMessageWithMeta)); + }) .catch(() => {}) - .finally(() => setLoading(false)); + .finally(() => { + if (seq !== requestSeq.current) return; + setLoading(false); + }); + return () => { + requestSeq.current += 1; + }; }, [conversationId, relay]); const sorted = sortMessagesChronologically(messages); @@ -230,7 +241,9 @@ function DmMessages({ conversationId, scrollRef, mentionNames, onOpenAgent }: { const loadOlderPage = useCallback(async () => { if (!oldestId) return 0; + const seq = requestSeq.current; const older = await relay.dmMessages(conversationId, { before: oldestId, limit: PAGE_SIZE }); + if (seq !== requestSeq.current) return 0; if (older.length > 0) { setMessages((prev) => [...older.map(toMessageWithMeta), ...prev]); } diff --git a/packages/react/src/__tests__/fetchRaceGuards.test.tsx b/packages/react/src/__tests__/fetchRaceGuards.test.tsx new file mode 100644 index 00000000..4217f054 --- /dev/null +++ b/packages/react/src/__tests__/fetchRaceGuards.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi } from 'vitest'; +import React, { type ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { MessageWithMeta } from '@relaycast/sdk'; +import { ClientContext } from '../context.js'; +import { StoreContext } from '../context.js'; +import { createStore } from '../store.js'; +import { useChannel } from '../hooks/useChannel.js'; +import { useMessages } from '../hooks/useMessages.js'; +import { useThread } from '../hooks/useThread.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeMessage(id: string, createdAt: string): MessageWithMeta { + return { + id, + agentName: 'Alice', + agentId: 'a1', + text: `message-${id}`, + blocks: null, + attachments: [], + createdAt, + replyCount: 0, + reactions: [], + readByCount: 0, + }; +} + +function createWrapper(agent: Record) { + const store = createStore(); + const client = { + relay: {}, + agent, + ws: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }, + }; + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { wrapper, store }; +} + +describe('React data fetch race guards', () => { + it('ignores stale useMessages responses after the channel changes', async () => { + const general = deferred(); + const random = deferred(); + const messages = vi.fn() + .mockReturnValueOnce(general.promise) + .mockReturnValueOnce(random.promise); + const { wrapper, store } = createWrapper({ messages }); + + const { result, rerender } = renderHook(({ channel }) => useMessages(channel), { + wrapper, + initialProps: { channel: 'general' }, + }); + + rerender({ channel: 'random' }); + + await act(async () => { + random.resolve([makeMessage('r1', '2026-01-01T00:00:01.000Z')]); + await random.promise; + }); + + await waitFor(() => { + expect(result.current.messages.map((message) => message.id)).toEqual(['r1']); + }); + + await act(async () => { + general.resolve([makeMessage('g1', '2026-01-01T00:00:02.000Z')]); + await general.promise; + }); + + expect(result.current.messages.map((message) => message.id)).toEqual(['r1']); + expect(store.getState().channelMessages.general?.messages).toEqual([]); + }); + + it('ignores stale useChannel responses after the channel changes', async () => { + const alpha = deferred(); + const beta = deferred(); + const get = vi.fn() + .mockReturnValueOnce(alpha.promise) + .mockReturnValueOnce(beta.promise); + const { wrapper, store } = createWrapper({ channels: { get } }); + + const { result, rerender } = renderHook(({ name }) => useChannel(name), { + wrapper, + initialProps: { name: 'alpha' }, + }); + + rerender({ name: 'beta' }); + + await act(async () => { + beta.resolve({ name: 'beta', topic: null, members: [] }); + await beta.promise; + }); + + await waitFor(() => { + expect(result.current.channel?.name).toBe('beta'); + }); + + await act(async () => { + alpha.resolve({ name: 'alpha', topic: null, members: [] }); + await alpha.promise; + }); + + expect(result.current.channel?.name).toBe('beta'); + expect(store.getState().channelDetails.alpha?.channel).toBeNull(); + }); + + it('ignores stale useThread responses after the message id changes', async () => { + const first = deferred(); + const second = deferred(); + const thread = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const { wrapper, store } = createWrapper({ thread }); + + const { result, rerender } = renderHook(({ messageId }) => useThread(messageId), { + wrapper, + initialProps: { messageId: 'm1' }, + }); + + rerender({ messageId: 'm2' }); + + await act(async () => { + second.resolve({ parent: makeMessage('m2', '2026-01-01T00:00:02.000Z'), replies: [] }); + await second.promise; + }); + + await waitFor(() => { + expect(result.current.parent?.id).toBe('m2'); + }); + + await act(async () => { + first.resolve({ parent: makeMessage('m1', '2026-01-01T00:00:01.000Z'), replies: [] }); + await first.promise; + }); + + expect(result.current.parent?.id).toBe('m2'); + expect(store.getState().threads.m1?.parent).toBeNull(); + }); +}); diff --git a/packages/react/src/__tests__/provider.test.tsx b/packages/react/src/__tests__/provider.test.tsx new file mode 100644 index 00000000..64001ca3 --- /dev/null +++ b/packages/react/src/__tests__/provider.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { RelayProvider } from '../provider.js'; + +const sdkMock = vi.hoisted(() => { + const wsInstances: Array<{ + connect: ReturnType; + disconnect: ReturnType; + subscribe: ReturnType; + unsubscribe: ReturnType; + emit(type: string): void; + }> = []; + + class MockRelayCast { + as = vi.fn(() => ({})); + } + + class MockWsClient { + connect = vi.fn(); + disconnect = vi.fn(() => { + this.isConnected = false; + }); + subscribe = vi.fn(); + unsubscribe = vi.fn(); + private handlers = new Map void>>(); + private isConnected = false; + + constructor() { + wsInstances.push(this); + } + + get connected(): boolean { + return this.isConnected; + } + + on(event: string, handler: (event: { type: string }) => void): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + return () => { + this.handlers.get(event)?.delete(handler); + }; + } + + emit(type: string): void { + if (type === 'open') { + this.isConnected = true; + } + const event = { type }; + this.handlers.get(type)?.forEach((handler) => handler(event)); + this.handlers.get('*')?.forEach((handler) => handler(event)); + } + } + + return { MockRelayCast, MockWsClient, wsInstances }; +}); + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: sdkMock.MockRelayCast, + WsClient: sdkMock.MockWsClient, +})); + +beforeEach(() => { + sdkMock.wsInstances.length = 0; + vi.clearAllMocks(); +}); + +describe('RelayProvider', () => { + it('does not reconnect when channels receives a new array with the same contents', async () => { + const { rerender, unmount } = render( + +
+ , + ); + + await waitFor(() => { + expect(sdkMock.wsInstances[0]?.connect).toHaveBeenCalledTimes(1); + }); + + act(() => { + sdkMock.wsInstances[0]!.emit('open'); + }); + + expect(sdkMock.wsInstances[0]!.subscribe).toHaveBeenCalledWith(['general']); + + rerender( + +
+ , + ); + + expect(sdkMock.wsInstances).toHaveLength(1); + expect(sdkMock.wsInstances[0]!.connect).toHaveBeenCalledTimes(1); + expect(sdkMock.wsInstances[0]!.disconnect).not.toHaveBeenCalled(); + expect(sdkMock.wsInstances[0]!.subscribe).toHaveBeenCalledTimes(1); + + unmount(); + expect(sdkMock.wsInstances[0]!.disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/hooks/useChannel.ts b/packages/react/src/hooks/useChannel.ts index b65e0aa0..cc5e6b68 100644 --- a/packages/react/src/hooks/useChannel.ts +++ b/packages/react/src/hooks/useChannel.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useSyncExternalStore } from 'react'; +import { useContext, useEffect, useRef, useSyncExternalStore } from 'react'; import { ClientContext, StoreContext } from '../context.js'; import type { UseChannelReturn, ChannelDetail } from '../types.js'; @@ -7,23 +7,30 @@ const EMPTY: ChannelDetail = { channel: null, members: [], loading: true, error: export function useChannel(name: string): UseChannelReturn { const ctx = useContext(ClientContext); const store = useContext(StoreContext); + const requestSeq = useRef(0); if (!ctx || !store) throw new Error('useChannel must be used within '); useEffect(() => { + const seq = ++requestSeq.current; store.updateChannelDetail(name, (prev) => ({ ...prev, loading: true })); ctx.agent.channels.get(name) .then((result) => { + if (seq !== requestSeq.current) return; const { members, ...channel } = result; store.updateChannelDetail(name, () => ({ channel, members, loading: false, error: null })); }) .catch((error: unknown) => { + if (seq !== requestSeq.current) return; store.updateChannelDetail(name, (prev) => ({ ...prev, loading: false, error: error instanceof Error ? error : new Error(String(error)), })); }); + return () => { + requestSeq.current += 1; + }; }, [name, ctx.agent, store]); const data = useSyncExternalStore( diff --git a/packages/react/src/hooks/useMessages.ts b/packages/react/src/hooks/useMessages.ts index b6904b04..81f78dd6 100644 --- a/packages/react/src/hooks/useMessages.ts +++ b/packages/react/src/hooks/useMessages.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useCallback, useSyncExternalStore } from 'react'; +import { useContext, useEffect, useCallback, useRef, useSyncExternalStore } from 'react'; import { ClientContext, StoreContext } from '../context.js'; import type { UseMessagesReturn, ChannelMessages } from '../types.js'; import { sortMessagesChronologically } from '../adapters/messages.js'; @@ -8,17 +8,21 @@ const EMPTY: ChannelMessages = { messages: [], loading: true, error: null }; export function useMessages(channel: string): UseMessagesReturn { const ctx = useContext(ClientContext); const store = useContext(StoreContext); + const requestSeq = useRef(0); if (!ctx || !store) throw new Error('useMessages must be used within '); useEffect(() => { + const seq = ++requestSeq.current; store.updateChannelMessages(channel, (prev) => ({ ...prev, loading: true })); ctx.agent.messages(channel, { limit: 50 }) .then((messages) => { + if (seq !== requestSeq.current) return; const sortedMessages = sortMessagesChronologically(messages); store.updateChannelMessages(channel, () => ({ messages: sortedMessages, loading: false, error: null })); }) .catch((error: unknown) => { + if (seq !== requestSeq.current) return; store.updateChannelMessages(channel, (prev) => ({ ...prev, loading: false, @@ -28,6 +32,7 @@ export function useMessages(channel: string): UseMessagesReturn { ctx.ws.subscribe([channel]); return () => { + requestSeq.current += 1; ctx.ws.unsubscribe([channel]); }; }, [channel, ctx.agent, ctx.ws, store]); @@ -38,6 +43,7 @@ export function useMessages(channel: string): UseMessagesReturn { ); const fetchMore = useCallback(async () => { + const seq = requestSeq.current; const current = store.getState().channelMessages[channel]; if (!current || current.messages.length === 0) return 0; @@ -45,6 +51,7 @@ export function useMessages(channel: string): UseMessagesReturn { if (!oldest) return 0; const older = await ctx.agent.messages(channel, { before: oldest.id, limit: 50 }); + if (seq !== requestSeq.current) return 0; if (older.length === 0) return 0; const sortedOlder = sortMessagesChronologically(older); diff --git a/packages/react/src/hooks/useThread.ts b/packages/react/src/hooks/useThread.ts index 38117a16..2a81ccea 100644 --- a/packages/react/src/hooks/useThread.ts +++ b/packages/react/src/hooks/useThread.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useSyncExternalStore } from 'react'; +import { useContext, useEffect, useRef, useSyncExternalStore } from 'react'; import { ClientContext, StoreContext } from '../context.js'; import type { UseThreadReturn, ThreadData } from '../types.js'; @@ -8,24 +8,31 @@ const IDLE: ThreadData = { parent: null, replies: [], loading: false, error: nul export function useThread(messageId: string): UseThreadReturn { const ctx = useContext(ClientContext); const store = useContext(StoreContext); + const requestSeq = useRef(0); if (!ctx || !store) throw new Error('useThread must be used within '); useEffect(() => { if (!messageId) return; + const seq = ++requestSeq.current; store.updateThread(messageId, (prev) => ({ ...prev, loading: true })); ctx.agent.thread(messageId) .then(({ parent, replies }) => { + if (seq !== requestSeq.current) return; store.updateThread(messageId, () => ({ parent, replies, loading: false, error: null })); }) .catch((error: unknown) => { + if (seq !== requestSeq.current) return; store.updateThread(messageId, (prev) => ({ ...prev, loading: false, error: error instanceof Error ? error : new Error(String(error)), })); }); + return () => { + requestSeq.current += 1; + }; }, [messageId, ctx.agent, store]); const fallback = messageId ? EMPTY : IDLE; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index aa024923..72bed9ee 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { RelayCast, WsClient } from '@relaycast/sdk'; import type { WsClientEvent } from '@relaycast/sdk'; import { ClientContext, StoreContext } from './context.js'; @@ -16,6 +16,10 @@ export interface RelayProviderProps { } export function RelayProvider({ apiKey, agentToken, wsToken, baseUrl, channels, debug, children }: RelayProviderProps) { + const channelKey = JSON.stringify(channels ?? []); + const subscribedChannels = useMemo(() => JSON.parse(channelKey) as string[], [channelKey]); + const subscribedChannelsRef = useRef(subscribedChannels); + const clients = useMemo(() => { const relay = new RelayCast({ apiKey, baseUrl }); const agent = relay.as(agentToken); @@ -25,6 +29,10 @@ export function RelayProvider({ apiKey, agentToken, wsToken, baseUrl, channels, const store = useMemo(() => createStore(), []); + useEffect(() => { + subscribedChannelsRef.current = subscribedChannels; + }, [subscribedChannels]); + useEffect(() => { const { ws } = clients; store.setState({ connectionStatus: 'connecting' }); @@ -32,8 +40,9 @@ export function RelayProvider({ apiKey, agentToken, wsToken, baseUrl, channels, const offOpen = ws.on('open', () => { store.setState({ connectionStatus: 'connected' }); - if (channels && channels.length > 0) { - ws.subscribe(channels); + const currentChannels = subscribedChannelsRef.current; + if (currentChannels.length > 0) { + ws.subscribe(currentChannels); } }); @@ -59,7 +68,19 @@ export function RelayProvider({ apiKey, agentToken, wsToken, baseUrl, channels, store.setState({ connectionStatus: 'disconnected' }); ws.disconnect(); }; - }, [clients, store, channels]); + }, [clients, store]); + + useEffect(() => { + const { ws } = clients; + if (!ws.connected || subscribedChannels.length === 0) { + return; + } + + ws.subscribe(subscribedChannels); + return () => { + ws.unsubscribe(subscribedChannels); + }; + }, [clients, subscribedChannels]); return ( diff --git a/packages/types/src/command.ts b/packages/types/src/command.ts deleted file mode 100644 index b5ab3c4a..00000000 --- a/packages/types/src/command.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod'; - -export const CommandParameterSchema = z.object({ - name: z.string(), - description: z.string().optional(), - type: z.enum(['string', 'number', 'boolean']), - required: z.boolean().optional(), -}); -export type CommandParameter = z.infer; - -export const AgentCommandSchema = z.object({ - id: z.string(), - command: z.string(), - description: z.string(), - handler_agent: z.string(), - parameters: z.array(CommandParameterSchema), - created_at: z.string(), - is_active: z.boolean(), -}); -export type AgentCommand = z.infer; - -export const CreateCommandRequestSchema = z.object({ - command: z.string(), - description: z.string(), - handler_agent: z.string(), - parameters: z.array(CommandParameterSchema).optional(), -}); -export type CreateCommandRequest = z.infer; - -export const CreateCommandResponseSchema = z.object({ - id: z.string(), - command: z.string(), - description: z.string(), - handler_agent: z.string(), - parameters: z.array(CommandParameterSchema), - created_at: z.string(), -}); -export type CreateCommandResponse = z.infer; - -export const InvokeCommandRequestSchema = z.object({ - channel: z.string(), - args: z.string().optional(), - parameters: z.record(z.string(), z.unknown()).optional(), -}); -export type InvokeCommandRequest = z.infer; - -export const CommandInvocationSchema = z.object({ - id: z.string(), - command: z.string(), - channel: z.string(), - invoked_by: z.string(), - handler_agent_id: z.string(), - args: z.string().nullable(), - parameters: z.record(z.string(), z.unknown()).nullable(), - message_id: z.string(), - created_at: z.string(), -}); -export type CommandInvocation = z.infer; diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 279e54a0..d58e7de6 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -234,17 +234,6 @@ export const WebhookReceivedEventSchema = z.object({ }); export type WebhookReceivedEvent = z.infer; -export const CommandInvokedEventSchema = z.object({ - type: z.literal('command.invoked'), - command: z.string(), - channel: z.string(), - invoked_by: z.string(), - handler_agent_id: z.string(), - args: z.string().nullable(), - parameters: z.record(z.string(), z.unknown()).nullable(), -}); -export type CommandInvokedEvent = z.infer; - // Action (agent-to-agent RPC) events. Delivered to the handler agent on invoke, // and to the caller agent on completion/failure. export const ActionInvokedEventSchema = z.object({ @@ -381,7 +370,6 @@ export const ServerEventSchema = z.discriminatedUnion('type', [ MessageReadEventSchema, FileUploadedEventSchema, WebhookReceivedEventSchema, - CommandInvokedEventSchema, ActionInvokedEventSchema, ActionCompletedEventSchema, ActionFailedEventSchema, @@ -430,7 +418,6 @@ export const WsClientEventSchema = z.discriminatedUnion('type', [ MessageReadEventSchema, FileUploadedEventSchema, WebhookReceivedEventSchema, - CommandInvokedEventSchema, ActionInvokedEventSchema, ActionCompletedEventSchema, ActionFailedEventSchema, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 44c8faec..a280af80 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,7 +11,6 @@ export * from './events.js'; export * from './api.js'; export * from './webhook.js'; export * from './subscription.js'; -export * from './command.js'; export * from './telemetry.js'; export * from './emoji.js'; export * from './event-id.js'; diff --git a/packages/types/src/subscription.ts b/packages/types/src/subscription.ts index 2687069f..c8697b96 100644 --- a/packages/types/src/subscription.ts +++ b/packages/types/src/subscription.ts @@ -21,7 +21,6 @@ export const SubscribableEventTypeSchema = z.enum([ 'message.read', 'file.uploaded', 'webhook.received', - 'command.invoked', 'delivery.accepted', 'delivery.delivered', 'delivery.deferred', From 296afefdc323369058987258f319de00a6457652 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 11:03:44 -0400 Subject: [PATCH 13/15] Remove dead dashboard auth and feed code --- .../src/app/api/auth/check/route.ts | 70 ----------------- .../src/hooks/use-activity-feed.ts | 76 ------------------- packages/observer-dashboard/src/lib/auth.ts | 13 ---- .../observer-dashboard/src/types/dashboard.ts | 16 ---- 4 files changed, 175 deletions(-) delete mode 100644 packages/observer-dashboard/src/app/api/auth/check/route.ts delete mode 100644 packages/observer-dashboard/src/hooks/use-activity-feed.ts diff --git a/packages/observer-dashboard/src/app/api/auth/check/route.ts b/packages/observer-dashboard/src/app/api/auth/check/route.ts deleted file mode 100644 index cefd93b7..00000000 --- a/packages/observer-dashboard/src/app/api/auth/check/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { - orderByRemembered, - pickRememberedEngine, - resolveRelayServerCandidatesFromRequest, - selectEngineForKey, -} from '../../../../lib/relay-server'; - -export const runtime = 'edge'; - -const COOKIE_NAME = 'relaycast_key'; -const AGENT_COOKIE_NAME = 'relaycast_agent_token'; -const ENGINE_COOKIE_NAME = 'relaycast_engine'; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days - -/** - * GET /api/auth/check - * Validates the auth cookie against the candidate engines (remembered one - * first, then gateway/api fallback). - */ -export async function GET(request: NextRequest) { - const cookieStore = await cookies(); - const cookie = cookieStore.get(COOKIE_NAME); - - if (!cookie?.value || !cookie.value.startsWith('rk_live_')) { - return NextResponse.json( - { authenticated: false }, - { status: 401 } - ); - } - - const candidates = resolveRelayServerCandidatesFromRequest(request); - const remembered = pickRememberedEngine( - cookieStore.get(ENGINE_COOKIE_NAME)?.value, - candidates - ); - const { baseUrl, rejectedAny, inconclusiveAny } = await selectEngineForKey( - orderByRemembered(candidates, remembered), - cookie.value - ); - - if (baseUrl) { - if (baseUrl !== remembered) { - cookieStore.set(ENGINE_COOKIE_NAME, baseUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: COOKIE_MAX_AGE, - }); - } - return NextResponse.json({ authenticated: true }); - } - - if (rejectedAny && !inconclusiveAny) { - // Every reachable engine rejected the key — it is revoked or invalid. - cookieStore.delete(COOKIE_NAME); - cookieStore.delete(AGENT_COOKIE_NAME); - cookieStore.delete(ENGINE_COOKIE_NAME); - return NextResponse.json( - { authenticated: false }, - { status: 401 } - ); - } - - // Validation was inconclusive — allow the session to continue - // (downstream API calls will fail with proper errors). - return NextResponse.json({ authenticated: true }); -} diff --git a/packages/observer-dashboard/src/hooks/use-activity-feed.ts b/packages/observer-dashboard/src/hooks/use-activity-feed.ts deleted file mode 100644 index edf5c7e2..00000000 --- a/packages/observer-dashboard/src/hooks/use-activity-feed.ts +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { useCallback, useRef, useState } from 'react'; -import { useEvent } from '@relaycast/react'; -import type { ActivityEvent, ActivityEventType } from '../types/dashboard'; -import type { - MessageCreatedEvent, - ThreadReplyEvent, - MessageReactedEvent, - AgentStatusActiveEvent, - AgentStatusOfflineEvent, - DmReceivedEvent, - GroupDmReceivedEvent, -} from '@relaycast/sdk'; - -export function useActivityFeed(): ActivityEvent[] { - const eventsRef = useRef([]); - const [, forceUpdate] = useState(0); - - const pushLiveEvent = useCallback( - (type: ActivityEventType, summary: string, agent?: string) => { - const event: ActivityEvent = { - id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, - type, - summary, - timestamp: new Date().toISOString(), - agent, - }; - eventsRef.current = [...eventsRef.current, event].slice(-200); - forceUpdate((n) => n + 1); - }, - [] - ); - - // Live WS events for instant updates (supplements polling) - useEvent('message.created', (evt) => { - const e = evt as MessageCreatedEvent; - pushLiveEvent('message_sent', `${e.message.agentName} sent a message in #${e.channel}`, e.message.agentName); - }); - - useEvent('thread.reply', (evt) => { - const e = evt as ThreadReplyEvent; - pushLiveEvent('thread_reply', `${e.message.agentName} replied in a thread`, e.message.agentName); - }); - - useEvent('message.reacted', (evt) => { - const e = evt as MessageReactedEvent; - pushLiveEvent('reaction', `${e.agentName} reacted ${e.emoji}`, e.agentName); - }); - - useEvent('agent.status.active', (evt) => { - const e = evt as AgentStatusActiveEvent; - if (!e.agent.name.startsWith('_dashboard_')) { - pushLiveEvent('connection', `${e.agent.name} is online`, e.agent.name); - } - }); - - useEvent('agent.status.offline', (evt) => { - const e = evt as AgentStatusOfflineEvent; - if (!e.agent.name.startsWith('_dashboard_')) { - pushLiveEvent('agent_idle', `${e.agent.name} went offline`, e.agent.name); - } - }); - - useEvent('dm.received', (evt) => { - const e = evt as DmReceivedEvent; - pushLiveEvent('message_sent', `${e.message.agentName} sent a DM`, e.message.agentName); - }); - - useEvent('group_dm.received', (evt) => { - const e = evt as GroupDmReceivedEvent; - pushLiveEvent('message_sent', `${e.message.agentName} sent a group DM`, e.message.agentName); - }); - - return eventsRef.current; -} diff --git a/packages/observer-dashboard/src/lib/auth.ts b/packages/observer-dashboard/src/lib/auth.ts index c5b53f69..849c2cf9 100644 --- a/packages/observer-dashboard/src/lib/auth.ts +++ b/packages/observer-dashboard/src/lib/auth.ts @@ -26,16 +26,3 @@ export async function clearAuth(): Promise { // Best effort } } - -/** - * Check if user is authenticated by querying the session endpoint. - * The httpOnly cookies are sent automatically. - */ -export async function isAuthenticated(): Promise { - try { - const res = await fetch('/observer/api/auth/session'); - return res.ok; - } catch { - return false; - } -} diff --git a/packages/observer-dashboard/src/types/dashboard.ts b/packages/observer-dashboard/src/types/dashboard.ts index bef8fe08..2a46147f 100644 --- a/packages/observer-dashboard/src/types/dashboard.ts +++ b/packages/observer-dashboard/src/types/dashboard.ts @@ -1,22 +1,6 @@ -export type ActivityEventType = - | 'message_sent' - | 'connection' - | 'agent_idle' - | 'reaction' - | 'thread_reply'; - -export interface ActivityEvent { - id: string; - type: ActivityEventType; - summary: string; - timestamp: string; - agent?: string; -} - export interface WebSocketFeedEvent { id: string; eventType: string; summary: string; timestamp: string; } - From 6e305127a346aa831a589e0d2b9317b1a439a390 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 11:04:52 -0400 Subject: [PATCH 14/15] Validate channel message attachments --- .../conformance/messageAttachments.test.ts | 113 ++++++++++++++++++ packages/engine/src/engine/attachments.ts | 51 ++++++++ packages/engine/src/engine/dm.ts | 54 ++------- packages/engine/src/engine/groupDm.ts | 43 ++----- packages/engine/src/engine/message.ts | 47 ++------ packages/sdk-python/src/relay_sdk/models.py | 4 +- packages/sdk-python/tests/test_models.py | 20 +++- 7 files changed, 207 insertions(+), 125 deletions(-) create mode 100644 packages/engine/src/__tests__/conformance/messageAttachments.test.ts diff --git a/packages/engine/src/__tests__/conformance/messageAttachments.test.ts b/packages/engine/src/__tests__/conformance/messageAttachments.test.ts new file mode 100644 index 00000000..3c74e462 --- /dev/null +++ b/packages/engine/src/__tests__/conformance/messageAttachments.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { + createWorkspace, + makeNodeStack, + registerAgent, + type TestStack, +} from './harness.js'; +import { messageAttachments, messages } from '../../db/schema.js'; + +describe('channel message attachments', () => { + let stack: TestStack; + + beforeEach(() => { stack = makeNodeStack(); }); + afterEach(() => stack.close()); + + async function uploadFile(token: string, filename: string, complete = true): Promise { + const upload = await stack.app.request('/v1/files/upload', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + filename, + content_type: 'text/plain', + size_bytes: 12, + }), + }); + expect(upload.status).toBe(201); + const uploadBody = await upload.json() as { data: { id: string } }; + + if (complete) { + const completeRes = await stack.app.request(`/v1/files/${uploadBody.data.id}/complete`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + expect(completeRes.status).toBe(200); + } + + return uploadBody.data.id; + } + + async function postMessage(token: string, attachments: string[]): Promise { + return stack.app.request('/v1/channels/general/messages', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ text: 'see attached', attachments }), + }); + } + + it('persists complete same-workspace file attachments in caller order', async () => { + const ws = await createWorkspace(stack.app, 'channel-attachments-valid-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const first = await uploadFile(alice.token, 'first.txt'); + const second = await uploadFile(alice.token, 'second.txt'); + + const res = await postMessage(alice.token, [second, first]); + const body = await res.json() as { + data: { + attachments: Array<{ + file_id: string; + filename: string; + content_type: string; + size_bytes: number; + }>; + }; + }; + + expect(res.status).toBe(201); + expect(body.data.attachments).toEqual([ + { file_id: second, filename: 'second.txt', content_type: 'text/plain', size_bytes: 12 }, + { file_id: first, filename: 'first.txt', content_type: 'text/plain', size_bytes: 12 }, + ]); + + const attachmentRows = await stack.runtime.deps.db.select().from(messageAttachments); + expect(attachmentRows.map((row) => row.fileId)).toEqual([second, first]); + }); + + it('rejects missing, incomplete, foreign-workspace, and duplicate file ids', async () => { + const ws = await createWorkspace(stack.app, 'channel-attachments-invalid-ws'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const pendingFile = await uploadFile(alice.token, 'pending.txt', false); + const completeFile = await uploadFile(alice.token, 'complete.txt'); + + const otherWs = await createWorkspace(stack.app, 'channel-attachments-other-ws'); + const otherAgent = await registerAgent(stack.app, otherWs.workspaceKey, 'other'); + const foreignFile = await uploadFile(otherAgent.token, 'foreign.txt'); + + const invalidAttachments = [ + ['file_missing'], + [pendingFile], + [foreignFile], + [completeFile, completeFile], + ]; + + for (const attachments of invalidAttachments) { + const res = await postMessage(alice.token, attachments); + const body = await res.json() as { error: { code: string; message: string } }; + expect(res.status).toBe(400); + expect(body.error).toEqual({ + code: 'invalid_attachments', + message: attachments.length > 1 + ? 'Invalid attachments: duplicate file ids are not allowed' + : 'Invalid attachments: file ids must exist in workspace and be complete', + }); + } + + const messageRows = await stack.runtime.deps.db + .select() + .from(messages) + .where(eq(messages.workspaceId, ws.workspaceId)); + expect(messageRows).toHaveLength(0); + expect(await stack.runtime.deps.db.select().from(messageAttachments)).toHaveLength(0); + }); +}); diff --git a/packages/engine/src/engine/attachments.ts b/packages/engine/src/engine/attachments.ts index de02a04b..4e1e3008 100644 --- a/packages/engine/src/engine/attachments.ts +++ b/packages/engine/src/engine/attachments.ts @@ -1,6 +1,7 @@ import { and, asc, eq, inArray } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; import { messageAttachments, files } from '../db/schema.js'; +import { codedError } from '../lib/httpError.js'; type Db = ReturnType; @@ -12,6 +13,56 @@ export type AttachmentRow = { size_bytes: number; }; +/** + * Validate attachment ids for a send path and hydrate the details needed for + * response payloads and logs. Unknown, foreign-workspace, incomplete, and + * duplicate file ids are rejected before any message rows are written. + */ +export async function resolveSendAttachments( + db: Db, + workspaceId: string, + fileIds?: string[], +): Promise { + if (!fileIds || fileIds.length === 0) return []; + + const unique = new Set(fileIds); + if (unique.size !== fileIds.length) { + throw codedError('Invalid attachments: duplicate file ids are not allowed', 'invalid_attachments', 400); + } + + const rows = await db + .select({ + fileId: files.id, + filename: files.filename, + contentType: files.contentType, + sizeBytes: files.sizeBytes, + }) + .from(files) + .where( + and( + eq(files.workspaceId, workspaceId), + eq(files.status, 'complete'), + inArray(files.id, fileIds), + ), + ); + + const byId = new Map(rows.map((row) => [row.fileId, row])); + const invalid = fileIds.filter((id) => !byId.has(id)); + if (invalid.length > 0) { + throw codedError('Invalid attachments: file ids must exist in workspace and be complete', 'invalid_attachments', 400); + } + + return fileIds.map((id) => { + const row = byId.get(id)!; + return { + file_id: row.fileId, + filename: row.filename, + content_type: row.contentType, + size_bytes: row.sizeBytes, + }; + }); +} + /** * Load attachments for a batch of message ids, grouped by message id. * diff --git a/packages/engine/src/engine/dm.ts b/packages/engine/src/engine/dm.ts index 2ecd4fa3..39812133 100644 --- a/packages/engine/src/engine/dm.ts +++ b/packages/engine/src/engine/dm.ts @@ -8,7 +8,6 @@ import { dmConversations, dmParticipants, messageAttachments, - files, } from '../db/schema.js'; import { sha256Hex } from '../lib/crypto.js'; import { runAtomicWrites, type AtomicWrite } from '../ports/database.js'; @@ -22,7 +21,7 @@ import { } from './deliveryWrites.js'; import { DEFAULT_MAILBOX_DEPTH_CAP, DEFAULT_MAILBOX_TTL_MS, type MailboxConfig } from './mailboxConfig.js'; import { codedError } from '../lib/httpError.js'; -import { fetchAttachmentsBatch, type AttachmentRow } from './attachments.js'; +import { fetchAttachmentsBatch, resolveSendAttachments, type AttachmentRow } from './attachments.js'; type Db = ReturnType; @@ -108,44 +107,6 @@ async function resolveConversation( return conv; } -async function resolveAttachments( - db: Db, - workspaceId: string, - attachmentIds?: string[], -): Promise { - if (!attachmentIds || attachmentIds.length === 0) return []; - - const unique = new Set(attachmentIds); - if (unique.size !== attachmentIds.length) { - throw codedError('Invalid attachments: duplicate file ids are not allowed', 'invalid_attachments', 400); - } - - const validFiles = await db - .select({ - file_id: files.id, - filename: files.filename, - content_type: files.contentType, - size_bytes: files.sizeBytes, - }) - .from(files) - .where( - and( - eq(files.workspaceId, workspaceId), - eq(files.status, 'complete'), - inArray(files.id, attachmentIds), - ), - ); - - const validIds = new Set(validFiles.map((f) => f.file_id)); - const invalid = attachmentIds.filter((id) => !validIds.has(id)); - if (invalid.length > 0) { - throw codedError('Invalid attachments: file ids must exist in workspace and be complete', 'invalid_attachments', 400); - } - - const ordered = new Map(validFiles.map((file) => [file.file_id, file])); - return attachmentIds.map((id) => ordered.get(id)!); -} - /** * Build the message + attachment-junction inserts for a DM without executing * them, so the send path can run them inside one atomic unit. The message @@ -157,9 +118,10 @@ function buildDmMessageWrites( fromAgentId: string, channelId: string, data: { text: string; attachments?: string[]; mode?: 'wait' | 'steer' }, + attachments: AttachmentRow[], messageId: string, ): AtomicWrite[] { - const hasAttachments = !!(data.attachments && data.attachments.length > 0); + const hasAttachments = attachments.length > 0; const writes: AtomicWrite[] = [ db .insert(messages) @@ -175,10 +137,10 @@ function buildDmMessageWrites( .returning(), ]; - if (data.attachments && data.attachments.length > 0) { - const attachmentValues = data.attachments.map((fileId, idx) => ({ + if (attachments.length > 0) { + const attachmentValues = attachments.map((attachment, idx) => ({ messageId, - fileId, + fileId: attachment.file_id, position: idx, })); writes.push(db.insert(messageAttachments).values(attachmentValues)); @@ -219,7 +181,7 @@ export async function sendDm( } const conv = await resolveConversation(db, workspaceId, fromAgentId, toAgent.id); - const attachments = await resolveAttachments(db, workspaceId, data.attachments); + const attachments = await resolveSendAttachments(db, workspaceId, data.attachments); const a2aTarget = options.skipA2aIntercept ? null : await a2aEngine.getA2aAgentByRelayName(db, workspaceId, toAgent.name); @@ -262,7 +224,7 @@ export async function sendDm( // Durable writes (message + attachments + delivery + message_log) run as one // atomic unit when the adapter supports it; fanout stays in routes. const results = await runAtomicWrites(db, (writeDb) => { - const writes = buildDmMessageWrites(writeDb, workspaceId, fromAgentId, conv.channelId, data, messageId); + const writes = buildDmMessageWrites(writeDb, workspaceId, fromAgentId, conv.channelId, data, attachments, messageId); if (deliveryId) { writes.push( diff --git a/packages/engine/src/engine/groupDm.ts b/packages/engine/src/engine/groupDm.ts index a169bd95..9e967589 100644 --- a/packages/engine/src/engine/groupDm.ts +++ b/packages/engine/src/engine/groupDm.ts @@ -1,6 +1,6 @@ -import { eq, and, isNull, inArray } from 'drizzle-orm'; +import { eq, and, isNull } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { messages, channels, agents, dmConversations, dmParticipants, messageAttachments, files } from '../db/schema.js'; +import { messages, channels, agents, dmConversations, dmParticipants, messageAttachments } from '../db/schema.js'; import { runAtomicWrites, type AtomicWrite } from '../ports/database.js'; import { generateId } from './snowflake.js'; import { @@ -10,7 +10,7 @@ import { } from './deliveryWrites.js'; import { DEFAULT_MAILBOX_DEPTH_CAP, DEFAULT_MAILBOX_TTL_MS, type MailboxConfig } from './mailboxConfig.js'; import { codedError } from '../lib/httpError.js'; -import { fetchAttachmentsBatch, type AttachmentRow } from './attachments.js'; +import { resolveSendAttachments } from './attachments.js'; type Db = ReturnType; @@ -126,31 +126,9 @@ export async function postGroupMessage( throw codedError('Sender agent not found', 'internal_error', 500); } - if (data.attachments && data.attachments.length > 0) { - const unique = new Set(data.attachments); - if (unique.size !== data.attachments.length) { - throw codedError('Invalid attachments: duplicate file ids are not allowed', 'invalid_attachments', 400); - } - - const validFiles = await db - .select({ id: files.id }) - .from(files) - .where( - and( - eq(files.workspaceId, workspaceId), - eq(files.status, 'complete'), - inArray(files.id, data.attachments), - ), - ); - const validIds = new Set(validFiles.map((f) => f.id)); - const invalid = data.attachments.filter((id) => !validIds.has(id)); - if (invalid.length > 0) { - throw codedError('Invalid attachments: file ids must exist in workspace and be complete', 'invalid_attachments', 400); - } - } - + const attachments = await resolveSendAttachments(db, workspaceId, data.attachments); const messageId = generateId(); - const hasAttachments = !!(data.attachments && data.attachments.length > 0); + const hasAttachments = attachments.length > 0; const mailbox = options.mailbox ?? { ttlMs: DEFAULT_MAILBOX_TTL_MS, depthCap: DEFAULT_MAILBOX_DEPTH_CAP, @@ -175,10 +153,10 @@ export async function postGroupMessage( .returning(), ]; - if (data.attachments && data.attachments.length > 0) { - const attachmentValues = data.attachments.map((fileId, idx) => ({ + if (attachments.length > 0) { + const attachmentValues = attachments.map((attachment, idx) => ({ messageId, - fileId, + fileId: attachment.file_id, position: idx, })); writes.push(writeDb.insert(messageAttachments).values(attachmentValues)); @@ -205,11 +183,6 @@ export async function postGroupMessage( senderAgentId: agentId, }); - const attachmentMap = hasAttachments - ? await fetchAttachmentsBatch(db, workspaceId, [messageId]) - : new Map(); - const attachments = attachmentMap.get(messageId) || []; - const injectionMode = data.mode ?? 'wait'; return { // Canonical converged shape (new) diff --git a/packages/engine/src/engine/message.ts b/packages/engine/src/engine/message.ts index c7491493..47082ebf 100644 --- a/packages/engine/src/engine/message.ts +++ b/packages/engine/src/engine/message.ts @@ -1,6 +1,6 @@ import { eq, and, sql, isNull, lt, gt, inArray } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { messages, agents, reactions, readReceipts, messageAttachments, files } from '../db/schema.js'; +import { messages, agents, reactions, readReceipts, messageAttachments } from '../db/schema.js'; import { runAtomicWrites, type AtomicWrite } from '../ports/database.js'; import { generateId } from './snowflake.js'; import { buildMessageLogWrite } from './console.js'; @@ -11,43 +11,10 @@ import { } from './deliveryWrites.js'; import { displayAgentName, publicMessageMetadata, sanitizeUserMessageMetadata } from './messageMetadata.js'; import { DEFAULT_MAILBOX_DEPTH_CAP, DEFAULT_MAILBOX_TTL_MS, type MailboxConfig } from './mailboxConfig.js'; -import { fetchAttachmentsBatch, type AttachmentRow } from './attachments.js'; +import { fetchAttachmentsBatch, resolveSendAttachments, type AttachmentRow } from './attachments.js'; type Db = ReturnType; -/** - * Fetch attachment details straight from `files` by id, preserving the - * caller's ordering. Used by the send path, which needs the details *before* - * the `message_attachments` rows exist — the junction insert is part of the - * atomic write batch, so it cannot be joined against mid-send. - */ -async function fetchAttachmentDetails(db: Db, fileIds: string[]): Promise { - if (fileIds.length === 0) return []; - - const rows = await db - .select({ - fileId: files.id, - filename: files.filename, - contentType: files.contentType, - sizeBytes: files.sizeBytes, - }) - .from(files) - .where(inArray(files.id, fileIds)); - - const byId = new Map(rows.map((row) => [row.fileId, row])); - return fileIds - .filter((id) => byId.has(id)) - .map((id) => { - const row = byId.get(id)!; - return { - file_id: row.fileId, - filename: row.filename, - content_type: row.contentType, - size_bytes: row.sizeBytes, - }; - }); -} - export async function postMessage( db: Db, workspaceId: string, @@ -73,7 +40,6 @@ export async function postMessage( mentionedHandles.add(match[1]); } - const hasAttachments = !!(data.attachments && data.attachments.length > 0); const metadata = sanitizeUserMessageMetadata(data.data); const mailbox = options.mailbox ?? { @@ -82,9 +48,10 @@ export async function postMessage( }; const [attachments, [agent]] = await Promise.all([ - fetchAttachmentDetails(db, data.attachments ?? []), + resolveSendAttachments(db, workspaceId, data.attachments), db.select({ name: agents.name }).from(agents).where(eq(agents.id, agentId)), ]); + const hasAttachments = attachments.length > 0; // Durable writes run as one atomic unit when the adapter supports it; fanout // stays in routes. Delivery recipients are derived by the delivery insert @@ -106,10 +73,10 @@ export async function postMessage( .returning(), ]; - if (data.attachments && data.attachments.length > 0) { - const attachmentValues = data.attachments.map((fileId, idx) => ({ + if (attachments.length > 0) { + const attachmentValues = attachments.map((attachment, idx) => ({ messageId, - fileId, + fileId: attachment.file_id, position: idx, })); writes.push(writeDb.insert(messageAttachments).values(attachmentValues)); diff --git a/packages/sdk-python/src/relay_sdk/models.py b/packages/sdk-python/src/relay_sdk/models.py index 86631d46..ed8b1302 100644 --- a/packages/sdk-python/src/relay_sdk/models.py +++ b/packages/sdk-python/src/relay_sdk/models.py @@ -195,8 +195,8 @@ class InviteRequest(BaseModel): class FileAttachment(BaseModel): file_id: str filename: str - url: str - size: int + content_type: str + size_bytes: int class ReactionGroup(BaseModel): diff --git a/packages/sdk-python/tests/test_models.py b/packages/sdk-python/tests/test_models.py index 4b0314d3..4181e5f7 100644 --- a/packages/sdk-python/tests/test_models.py +++ b/packages/sdk-python/tests/test_models.py @@ -59,7 +59,7 @@ def _message_with_meta_dict(**overrides): "agent_id": "ag_1", "text": "hello", "attachments": [ - {"file_id": "f_1", "filename": "a.txt", "url": "https://x/y", "size": 12} + {"file_id": "f_1", "filename": "a.txt", "content_type": "text/plain", "size_bytes": 12} ], "created_at": "2026-02-08T00:00:00Z", "reply_count": 0, @@ -199,10 +199,25 @@ def test_channel_model_creation(): def test_message_with_meta_creation_with_nested_attachment_and_reaction_group(): msg = MessageWithMeta(**_message_with_meta_dict()) assert msg.attachments and isinstance(msg.attachments[0], FileAttachment) + assert msg.attachments[0].content_type == "text/plain" + assert msg.attachments[0].size_bytes == 12 assert msg.reactions and isinstance(msg.reactions[0], ReactionGroup) assert msg.reactions[0].agents == ["ag_1", "ag_2"] +def test_file_attachment_requires_canonical_fields(): + attachment = FileAttachment( + file_id="f_1", + filename="a.txt", + content_type="text/plain", + size_bytes=12, + ) + assert attachment.content_type == "text/plain" + + with pytest.raises(ValidationError): + FileAttachment(file_id="f_1", filename="a.txt", url="https://x/y", size=12) + + def test_message_with_meta_accepts_injection_mode(): msg = MessageWithMeta(**_message_with_meta_dict(injection_mode="steer")) assert msg.injection_mode == "steer" @@ -321,7 +336,7 @@ def test_ws_message_created_event_model(): "agent_name": "Alpha", "text": "hi", "attachments": [ - {"file_id": "f_1", "filename": "a.txt", "url": "https://x/y", "size": 1} + {"file_id": "f_1", "filename": "a.txt", "content_type": "text/plain", "size_bytes": 1} ], "injection_mode": "wait", }, @@ -329,6 +344,7 @@ def test_ws_message_created_event_model(): ) assert ev.type == "message.created" assert ev.message.attachments[0].file_id == "f_1" + assert ev.message.attachments[0].size_bytes == 1 assert ev.message.injection_mode == "wait" From 6c14fe1920297da7513003f308497b05132b2db6 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 25 Jun 2026 11:08:04 -0400 Subject: [PATCH 15/15] Wire API usage quotas --- .../src/__tests__/conformance/harness.ts | 4 +- .../__tests__/conformance/usageQuota.test.ts | 79 +++++++++++++++++++ packages/engine/src/middleware/planLimits.ts | 10 ++- packages/engine/src/middleware/rateLimit.ts | 15 +++- .../engine/src/middleware/usageTracker.ts | 3 +- packages/engine/src/ports/entitlements.ts | 4 +- .../src/providers/static-entitlements.ts | 8 +- 7 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 packages/engine/src/__tests__/conformance/usageQuota.test.ts diff --git a/packages/engine/src/__tests__/conformance/harness.ts b/packages/engine/src/__tests__/conformance/harness.ts index 599f8bed..3d26fddc 100644 --- a/packages/engine/src/__tests__/conformance/harness.ts +++ b/packages/engine/src/__tests__/conformance/harness.ts @@ -2,7 +2,7 @@ import type { Hono } from 'hono'; import { createEngine } from '../../engine.js'; import { createNodeRuntime, type NodeRuntime, type EngineSocket } from '../../adapters/node/index.js'; import type { AppEnv } from '../../env.js'; -import type { EngineConfig } from '../../ports/index.js'; +import type { EngineConfig, EntitlementsProvider } from '../../ports/index.js'; export interface TestStack { app: Hono; @@ -15,6 +15,7 @@ export function makeNodeStack(options?: { ttlMs?: number; mailbox?: EngineConfig['mailbox']; environment?: string; + entitlements?: EntitlementsProvider; }): TestStack { const runtime = createNodeRuntime({ dbPath: ':memory:', @@ -24,6 +25,7 @@ export function makeNodeStack(options?: { environment: options?.environment ?? 'test', mailbox: options?.mailbox, }, + entitlements: options?.entitlements, // Disable the auto-sweep timer; tests drive presence.sweep() explicitly. presence: { ttlMs: options?.ttlMs ?? 60_000, sweepIntervalMs: 0 }, }); diff --git a/packages/engine/src/__tests__/conformance/usageQuota.test.ts b/packages/engine/src/__tests__/conformance/usageQuota.test.ts new file mode 100644 index 00000000..40e069e0 --- /dev/null +++ b/packages/engine/src/__tests__/conformance/usageQuota.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { EntitlementsProvider, PlanLimits, UsageMetric, Workspace } from '../../ports/index.js'; +import { createWorkspace, makeNodeStack, registerAgent, type TestStack } from './harness.js'; + +class ApiCallQuotaEntitlements implements EntitlementsProvider { + constructor( + private readonly stackRef: () => TestStack, + private readonly apiCallLimit: number, + ) {} + + async getLimits(_workspace: Workspace): Promise { + return { + messages: Infinity, + agents: Infinity, + file_bytes: Infinity, + api_calls: this.apiCallLimit, + rate_per_min: 300, + }; + } + + async getUsage(workspaceId: string, metric: UsageMetric): Promise { + const raw = await this.stackRef().runtime.deps.kv.get(`usage:${workspaceId}:${metric}`); + return Number.parseInt(raw || '0', 10) || 0; + } +} + +describe('usage quotas', () => { + let stack: TestStack | undefined; + + afterEach(() => stack?.close()); + + it('records authenticated API calls and enforces the api_calls plan limit', async () => { + stack = makeNodeStack({ + entitlements: new ApiCallQuotaEntitlements(() => stack, 2), + }); + const ws = await createWorkspace(stack.app, 'api-call-quota-ws'); + + const register = await stack.app.request('/v1/agents', { + method: 'POST', + headers: { + authorization: `Bearer ${ws.workspaceKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ name: 'quota-agent' }), + }); + expect(register.status).toBe(201); + await expect(stack.runtime.deps.kv.get(`usage:${ws.workspaceId}:api_calls`)).resolves.toBe('1'); + + const allowed = await stack.app.request('/v1/workspace', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(allowed.status).toBe(200); + await expect(stack.runtime.deps.kv.get(`usage:${ws.workspaceId}:api_calls`)).resolves.toBe('2'); + + const blocked = await stack.app.request('/v1/workspace', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(blocked.status).toBe(429); + await expect(blocked.json()).resolves.toMatchObject({ + ok: false, + error: { code: 'plan_limit_exceeded' }, + }); + await expect(stack.runtime.deps.kv.get(`usage:${ws.workspaceId}:api_calls`)).resolves.toBe('2'); + }); + + it('refreshes agent presence on authenticated API calls', async () => { + stack = makeNodeStack(); + const ws = await createWorkspace(stack.app, 'presence-refresh-ws'); + const agent = await registerAgent(stack.app, ws.workspaceKey, 'present-agent'); + + await expect(stack.runtime.presence.getOnline(ws.workspaceId)).resolves.toEqual([]); + + const inbox = await stack.app.request('/v1/inbox', { + headers: { authorization: `Bearer ${agent.token}` }, + }); + expect(inbox.status).toBe(200); + await expect(stack.runtime.presence.getOnline(ws.workspaceId)).resolves.toContain(agent.agentId); + }); +}); diff --git a/packages/engine/src/middleware/planLimits.ts b/packages/engine/src/middleware/planLimits.ts index e33d2699..e78c34a4 100644 --- a/packages/engine/src/middleware/planLimits.ts +++ b/packages/engine/src/middleware/planLimits.ts @@ -12,9 +12,15 @@ export function checkPlanLimit(metric: UsageMetric) { if (!workspace) { await next(); return; } const { entitlements } = c.get('engine'); - const limits = await entitlements.getLimits(workspace); + let limits; + try { + limits = await entitlements.getLimits(workspace); + } catch { + await next(); + return; + } const limit = limits[metric]; - if (limit === Infinity) { await next(); return; } + if (typeof limit !== 'number' || limit === Infinity) { await next(); return; } try { const current = await entitlements.getUsage(workspace.id, metric); diff --git a/packages/engine/src/middleware/rateLimit.ts b/packages/engine/src/middleware/rateLimit.ts index c9840c07..09f9c825 100644 --- a/packages/engine/src/middleware/rateLimit.ts +++ b/packages/engine/src/middleware/rateLimit.ts @@ -1,10 +1,14 @@ import { createMiddleware } from 'hono/factory'; import type { AppEnv } from '../env.js'; import { jsonError } from '../lib/httpResponse.js'; +import { checkPlanLimit } from './planLimits.js'; +import { presenceRefresh } from './presenceRefresh.js'; +import { usageTracker } from './usageTracker.js'; // Conservative per-minute ceiling applied when the entitlements lookup fails, // so an entitlements outage degrades to a safe default rather than no limit. const FALLBACK_RATE_PER_MIN = 300; +const checkApiCallPlanLimit = checkPlanLimit('api_calls'); // Per-route rate limit multipliers (fraction of the global per-minute limit). // POST endpoints get tighter limits, GET endpoints get looser. @@ -35,6 +39,14 @@ export const rateLimit = createMiddleware(async (c, next) => { return; } + let planAllowed = false; + const planResponse = await checkApiCallPlanLimit(c, async () => { + planAllowed = true; + }); + if (!planAllowed) { + return planResponse; + } + const { entitlements, rateLimiter } = c.get('engine'); // Apply route-specific multiplier if applicable @@ -68,5 +80,6 @@ export const rateLimit = createMiddleware(async (c, next) => { // shouldn't 500 the request (the limit was still computed above). } - await next(); + await presenceRefresh(c, async () => {}); + return usageTracker(c, next); }); diff --git a/packages/engine/src/middleware/usageTracker.ts b/packages/engine/src/middleware/usageTracker.ts index ac8564c9..8813a0f5 100644 --- a/packages/engine/src/middleware/usageTracker.ts +++ b/packages/engine/src/middleware/usageTracker.ts @@ -1,11 +1,12 @@ import { createMiddleware } from 'hono/factory'; import type { AppEnv } from '../env.js'; +import { incrementUsage } from '../engine/usage.js'; export const usageTracker = createMiddleware(async (c, next) => { const workspace = c.get('workspace'); if (workspace) { // Fire-and-forget atomic usage increment via the key/value port. - c.get('engine').kv.increment(`usage:${workspace.id}:api_calls`, 1).catch(() => {}); + incrementUsage(c.get('engine').kv, workspace.id, 'api_calls').catch(() => {}); } await next(); }); diff --git a/packages/engine/src/ports/entitlements.ts b/packages/engine/src/ports/entitlements.ts index 555edd78..c5ac7620 100644 --- a/packages/engine/src/ports/entitlements.ts +++ b/packages/engine/src/ports/entitlements.ts @@ -1,11 +1,13 @@ import type { Workspace } from './auth.js'; -export type UsageMetric = 'messages' | 'agents' | 'file_bytes'; +export type UsageMetric = 'messages' | 'agents' | 'file_bytes' | 'api_calls'; export interface PlanLimits { messages: number; agents: number; file_bytes: number; + /** Total authenticated API calls allowed for the current billing window. */ + api_calls: number; /** Global requests-per-minute ceiling, fed into the rate limiter. */ rate_per_min: number; } diff --git a/packages/engine/src/providers/static-entitlements.ts b/packages/engine/src/providers/static-entitlements.ts index 488a1fdb..d721c460 100644 --- a/packages/engine/src/providers/static-entitlements.ts +++ b/packages/engine/src/providers/static-entitlements.ts @@ -8,10 +8,10 @@ import type { KeyValueStore } from '../ports/kv.js'; * tiered deployment if a workspace's `plan` column is set. */ export const PLAN_LIMITS: Record = { - free: { messages: 10_000, agents: 5, file_bytes: 100 * 1024 * 1024, rate_per_min: 300 }, - pro: { messages: 1_000_000, agents: 100, file_bytes: 50 * 1024 * 1024 * 1024, rate_per_min: 6000 }, - enterprise: { messages: Infinity, agents: Infinity, file_bytes: 500 * 1024 * 1024 * 1024, rate_per_min: 30000 }, - selfhost: { messages: Infinity, agents: Infinity, file_bytes: Infinity, rate_per_min: 30000 }, + free: { messages: 10_000, agents: 5, file_bytes: 100 * 1024 * 1024, api_calls: 100_000, rate_per_min: 300 }, + pro: { messages: 1_000_000, agents: 100, file_bytes: 50 * 1024 * 1024 * 1024, api_calls: 10_000_000, rate_per_min: 6000 }, + enterprise: { messages: Infinity, agents: Infinity, file_bytes: 500 * 1024 * 1024 * 1024, api_calls: Infinity, rate_per_min: 30000 }, + selfhost: { messages: Infinity, agents: Infinity, file_bytes: Infinity, api_calls: Infinity, rate_per_min: 30000 }, }; /**