From 4b82491bc6c42ec177d63b6d9ea41f125d3eb003 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 08:06:26 +0200 Subject: [PATCH 1/3] Allow anonymous SSE hub connections with caller-bound controls --- ContractTests/Client/observable-hub.test.mjs | 60 +++++++ .../Client/observable-origin.test.mjs | 4 +- .../Core/queries/observable/HubConnection.ts | 2 +- .../queries/observable/ObservableQueryHub.ts | 25 +-- ...controlling_an_anonymous_sse_connection.ts | 147 ++++++++++++++++++ .../with_anonymous_connections.ts | 8 +- .../queries/observable/observableCallerKey.ts | 8 +- 7 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 Source/Core/queries/observable/for_ObservableQueryHub/when_controlling_an_anonymous_sse_connection.ts diff --git a/ContractTests/Client/observable-hub.test.mjs b/ContractTests/Client/observable-hub.test.mjs index af21101e..7e375a3d 100644 --- a/ContractTests/Client/observable-hub.test.mjs +++ b/ContractTests/Client/observable-hub.test.mjs @@ -3,6 +3,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { z } from 'zod'; +import React from 'react'; +import { Arc, ArcContext } from '@cratis/arc.react'; import { ArcServer, AuthenticationStatus, CurrentValueSubject, ObservableEmissionDecision, defineObservableQuery, serviceToken } from '@cratis/arc.core'; import { Globals } from '@cratis/arc'; @@ -255,6 +257,64 @@ class Numbers extends ObservableQueryFor { get requiredRequestParameters() { return []; } } +for (const kind of ['express', 'fastify', 'hono']) test(`anonymous published React default SSE hub on ${kind}`, async () => { + const subject = new CurrentValueSubject([{ id: '1', name: 'first' }]); + const server = new ArcServer({ observableQueries: [defineObservableQuery({ name: 'Numbers', + schema: z.object({}), observe: () => subject })] }); + const listening = await observableHost(kind, server); + const previous = { method: Globals.queryTransportMethod, direct: Globals.queryDirectMode, + mode: Globals.observableQueryTransferMode, factory: Globals.eventSourceFactory, + headers: Globals.httpHeadersCallback, eventSource: globalThis.EventSource, fetch: globalThis.fetch }; + let subscription; + let unsubscribeFinished; + try { + const provider = React.createElement(Arc, { origin: listening.origin }); + assert.equal(provider.props.queryTransportMethod, undefined); + assert.equal(ArcContext._currentValue.queryTransportMethod, QueryTransportMethod.ServerSentEvents); + resetSharedMultiplexer(); + Globals.queryTransportMethod = ArcContext._currentValue.queryTransportMethod; + Globals.queryDirectMode = ArcContext._currentValue.queryDirectMode; + Globals.observableQueryTransferMode = 'full'; + Globals.httpHeadersCallback = () => ({}); + globalThis.EventSource = FetchEventSource; + Globals.eventSourceFactory = url => new FetchEventSource(url); + unsubscribeFinished = new Promise(resolve => { + globalThis.fetch = (url, options) => { + const response = previous.fetch(url, options); + if (String(url).endsWith('/sse/unsubscribe')) void response.then(resolve, resolve); + return response; + }; + }); + const query = new Numbers(); + query.setOrigin(provider.props.origin); + let firstReceived; + let updateReceived; + const initial = new Promise(resolve => { firstReceived = resolve; }); + const updated = new Promise(resolve => { updateReceived = resolve; }); + subscription = query.subscribe(result => { + if (result.data?.[0]?.name === 'first') firstReceived(result); + if (result.data?.[0]?.name === 'second') updateReceived(result); + }); + assert.equal((await within(initial, 'anonymous SSE initial')).isAuthorized, true); + subject.next([{ id: '1', name: 'second' }]); + assert.equal((await within(updated, 'anonymous SSE update')).data[0].name, 'second'); + } finally { + subscription?.unsubscribe(); + if (subscription) await within(unsubscribeFinished, 'anonymous SSE unsubscribe'); + resetSharedMultiplexer(); + Globals.queryTransportMethod = previous.method; + Globals.queryDirectMode = previous.direct; + Globals.observableQueryTransferMode = previous.mode; + Globals.eventSourceFactory = previous.factory; + Globals.httpHeadersCallback = previous.headers; + globalThis.fetch = previous.fetch; + if (previous.eventSource === undefined) delete globalThis.EventSource; + else globalThis.EventSource = previous.eventSource; + await server.dispose(); + await listening.close(); + } +}); + for (const kind of ['express', 'fastify', 'hono']) { for (const [method, mode] of [ [QueryTransportMethod.WebSocket, 'full'], [QueryTransportMethod.WebSocket, 'delta'], diff --git a/ContractTests/Client/observable-origin.test.mjs b/ContractTests/Client/observable-origin.test.mjs index 66b8acb7..5108dd55 100644 --- a/ContractTests/Client/observable-origin.test.mjs +++ b/ContractTests/Client/observable-origin.test.mjs @@ -74,7 +74,7 @@ for (const kind of ['express', 'fastify', 'hono']) { { authorization: 'Bearer bad' }), 401); const sse = await openSse(listening.origin, listening.origin); try { - assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://evil.example')).status, 404); + assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://evil.example')).status, 403); assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 200); } finally { await sse.reader.cancel(); } } finally { await server.dispose(); await listening.close(); } @@ -92,7 +92,7 @@ for (const kind of ['express', 'fastify', 'hono']) { { authorization: 'Bearer alice' }), 403); const sse = await openSse(listening.origin, 'https://app.example.com'); try { - assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 404); + assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 403); assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://app.example.com')).status, 200); } finally { await sse.reader.cancel(); } } finally { await server.dispose(); await listening.close(); } diff --git a/Source/Core/queries/observable/HubConnection.ts b/Source/Core/queries/observable/HubConnection.ts index 438b99b4..fa84cf10 100644 --- a/Source/Core/queries/observable/HubConnection.ts +++ b/Source/Core/queries/observable/HubConnection.ts @@ -37,7 +37,7 @@ export class HubConnection { readonly onClose: () => void, readonly onChange: () => void ) { - this.ownerKey = observableCallerKey(context, false); + this.ownerKey = observableCallerKey(context); this.#context = Object.freeze({ ...context, connectionId: this.id, principal: clonePrincipal(context.principal), signal: AbortSignal.any([context.signal, output.signal]) }); this.#states = new SubscriptionRevisions(server.observableLimits.tombstones); diff --git a/Source/Core/queries/observable/ObservableQueryHub.ts b/Source/Core/queries/observable/ObservableQueryHub.ts index 093295c5..958229d6 100644 --- a/Source/Core/queries/observable/ObservableQueryHub.ts +++ b/Source/Core/queries/observable/ObservableQueryHub.ts @@ -38,7 +38,7 @@ export class ObservableQueryHub { canAdmit(context: ExecutionContext): boolean { if (this.#disposed || this.#connections.size >= this.server.observableLimits.hubConnections) return false; - const key = observableCallerKey(context, false); + const key = observableCallerKey(context); return this.connections.filter(connection => connection.ownerKey === key).length < this.server.observableLimits.hubConnectionsPerCaller; } @@ -82,7 +82,7 @@ export class ObservableQueryHub { } } - /** SSE opening is authenticated; an anonymous connection ID never authorizes control POSTs. */ + /** SSE connections capture their caller so later control requests cannot cross identity boundaries. */ async http(request: Request, native?: NativeRequestContext): Promise { const path = new URL(request.url).pathname; if (path === ssePath) { @@ -109,8 +109,7 @@ export class ObservableQueryHub { if (!await originAllowed(request.headers.get('origin'), request, native, this.server.options)) return new Response(null, { status: 403 }); const identity = await resolveConnectionContext(this.server, request, native); - if (identity.authenticationFailed || !identity.context.principal?.isAuthenticated) - return new Response(null, { status: 401 }); + if (identity.authenticationFailed) return new Response(null, { status: 401 }); if (!this.canAdmit(identity.context)) return new Response(null, { status: 503, headers: { 'retry-after': '1' } }); const output = new SseHubTransport(this.server.observableLimits); @@ -141,6 +140,8 @@ export class ObservableQueryHub { const contentType = request.headers.get('content-type'); if (!contentType || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(contentType)) return new Response(null, { status: 415 }); + if (!await originAllowed(request.headers.get('origin'), request, native, this.server.options)) + return new Response(null, { status: 403 }); const maximum = Math.min(this.server.options.hosting?.maxBodyBytes ?? 1024 * 1024, this.server.observableLimits.inboundFrameBytes); const payload = await body(request, maximum); @@ -151,7 +152,7 @@ export class ObservableQueryHub { const connection = this.#connections.get(raw.connectionId); const identity = await resolveConnectionContext(this.server, request, native); if (!connection || connection.protocol !== 'SSE' || connection.closed || identity.authenticationFailed || - !await this.sameCaller(connection, identity.context.principal?.id, identity.context.tenantId, request, native)) + !this.sameCaller(connection, identity.context)) return new Response(null, { status: 404 }); const revision = connection.revision(raw); const queryId = connection.queryId(raw.queryId); @@ -169,11 +170,15 @@ export class ObservableQueryHub { } } - private async sameCaller(connection: HubConnection, id: string | undefined, tenant: string | undefined, - request: Request, native?: NativeRequestContext): Promise { - const owner = connection.context.principal; - if (!owner?.isAuthenticated || !id || owner.id !== id || connection.context.tenantId !== tenant) return false; - return originAllowed(request.headers.get('origin'), request, native, this.server.options); + private sameCaller(connection: HubConnection, caller: ExecutionContext): boolean { + const owner = connection.context; + if (owner.tenantId !== caller.tenantId) return false; + const ownerAuthenticated = owner.principal?.isAuthenticated === true; + if (ownerAuthenticated !== (caller.principal?.isAuthenticated === true)) return false; + if (ownerAuthenticated) return !!caller.principal?.id && owner.principal?.id === caller.principal.id; + // Anonymous callers have no identity to distinguish them. Bind to the peer address when + // the host supplies one; never treat a missing address as equivalent to a known address. + return owner.remoteAddress === caller.remoteAddress; } private methodNotAllowed(allow: string): Response { diff --git a/Source/Core/queries/observable/for_ObservableQueryHub/when_controlling_an_anonymous_sse_connection.ts b/Source/Core/queries/observable/for_ObservableQueryHub/when_controlling_an_anonymous_sse_connection.ts new file mode 100644 index 00000000..0a49f10e --- /dev/null +++ b/Source/Core/queries/observable/for_ObservableQueryHub/when_controlling_an_anonymous_sse_connection.ts @@ -0,0 +1,147 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { should } from 'vitest'; +import { z } from 'zod'; +import { ArcServer } from '../../../ArcServer.js'; +import { AuthenticationStatus } from '../../../authentication/AuthenticationStatus.js'; +import { CurrentValueSubject } from '../CurrentValueSubject.js'; +import { defineObservableQuery } from '../defineObservableQuery.js'; + +should(); + +const path = 'http://localhost/.cratis/queries/sse'; + +describe('when controlling an anonymous SSE connection', () => { + let server: ArcServer; + let stream: Response; + let connectionId: string; + let called: number; + let reader: ReadableStreamDefaultReader; + let draining: Promise; + + beforeEach(async () => { + called = 0; + server = new ArcServer({ authentication: [request => request.headers.get('authorization') === 'Bearer alice' + ? { status: AuthenticationStatus.Authenticated, principal: { id: 'alice', isAuthenticated: true, roles: [] } } + : { status: AuthenticationStatus.Anonymous }], + query: { maxObservableHubConnectionsPerCaller: 1 }, + observableQueries: [ + defineObservableQuery({ name: 'Public', schema: z.object({}), observe: () => { + called++; return CurrentValueSubject.of([1]); + } }), + defineObservableQuery({ name: 'Private', schema: z.object({}), authorization: { roles: ['reader'] }, + observe: () => { called++; return CurrentValueSubject.of([2]); } }) + ] }); + stream = (await server.handle(new Request(path), { remoteAddress: '10.0.0.1' }))!; + reader = stream.body!.getReader(); + const frame = new TextDecoder().decode((await reader.read()).value); + connectionId = JSON.parse(frame.slice(6)).payload; + draining = (async () => { while (!(await reader.read()).done) { /* Keep control frames flowing. */ } })(); + }); + + afterEach(async () => { + await reader.cancel(); + await draining; + await server.dispose(); + }); + + const control = async (route: 'subscribe' | 'unsubscribe', queryId: string, options: { + address?: string; authorization?: string; origin?: string; tenant?: string; contentType?: string; + } = {}): Promise => { + const response = await server.handle(new Request(`${path}/${route}`, { method: 'POST', + headers: { 'content-type': options.contentType ?? 'application/json', + ...(options.authorization ? { authorization: options.authorization } : {}), + ...(options.origin ? { origin: options.origin } : {}), + ...(options.tenant ? { 'x-cratis-tenant-id': options.tenant } : {}) }, + body: JSON.stringify({ connectionId, queryId, revision: 1, + ...(route === 'subscribe' ? { request: { queryName: queryId } } : {}) }) + }), options.address === undefined ? undefined : { remoteAddress: options.address }); + return response!.status; + }; + + it('should accept anonymous control from the opening address and preserve query authorization', async () => { + (await control('subscribe', 'Public', { address: '10.0.0.1' })).should.equal(200); + (await control('subscribe', 'Private', { address: '10.0.0.1' })).should.equal(401); + called.should.equal(1); + (await control('unsubscribe', 'Public', { address: '10.0.0.1' })).should.equal(200); + }); + + it('should hide the connection from another address, an absent address, authenticated callers and another tenant', async () => { + (await control('subscribe', 'Public', { address: '10.0.0.2' })).should.equal(404); + (await control('unsubscribe', 'Public')).should.equal(404); + (await control('subscribe', 'Public', { address: '10.0.0.1', authorization: 'Bearer alice' })).should.equal(404); + (await control('unsubscribe', 'Public', { address: '10.0.0.1', tenant: 'other' })).should.equal(404); + called.should.equal(0); + }); + + it('should reject unsafe control content and origins before executing a subscription', async () => { + (await control('subscribe', 'Public', { address: '10.0.0.1', contentType: 'text/plain' })).should.equal(415); + (await control('subscribe', 'Public', { address: '10.0.0.1', origin: 'https://evil.example' })).should.equal(403); + called.should.equal(0); + }); + + it('should apply a per-caller subscription budget across anonymous connections', async () => { + const limitedServer = new ArcServer({ query: { maxObservableSubscriptionsPerCaller: 1 }, + observableQueries: [defineObservableQuery({ name: 'Public', schema: z.object({}), + observe: () => CurrentValueSubject.of([1]) })] }); + const streams: ReadableStreamDefaultReader[] = []; + try { + const ids: string[] = []; + for (let index = 0; index < 2; index++) { + const response = (await limitedServer.handle(new Request(path), { remoteAddress: '10.0.0.1' }))!; + const streamReader = response.body!.getReader(); + streams.push(streamReader); + ids.push(JSON.parse(new TextDecoder().decode((await streamReader.read()).value).slice(6)).payload); + void (async () => { while (!(await streamReader.read()).done) { /* Drain SSE frames. */ } })(); + } + const subscribe = (id: string) => limitedServer.handle(new Request(`${path}/subscribe`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ connectionId: id, queryId: 'q', request: { queryName: 'Public' } }) + }), { remoteAddress: '10.0.0.1' }); + (await subscribe(ids[0]!))?.status.should.equal(200); + (await subscribe(ids[1]!))?.status.should.equal(503); + } finally { + for (const streamReader of streams) await streamReader.cancel(); + await limitedServer.dispose(); + } + }); + + it('should apply a per-caller connection budget to anonymous peers', async () => { + const limited = await server.handle(new Request(path), { remoteAddress: '10.0.0.1' }); + limited?.status.should.equal(503); + const other = await server.handle(new Request(path), { remoteAddress: '10.0.0.2' }); + other?.status.should.equal(200); + await other?.body?.cancel(); + }); + + it('should group anonymous connections without an address under one budget', async () => { + const anonymous = (await server.handle(new Request(path)))!; + anonymous.status.should.equal(200); + const another = await server.handle(new Request(path)); + another?.status.should.equal(503); + const anonymousReader = anonymous.body!.getReader(); + const frame = new TextDecoder().decode((await anonymousReader.read()).value); + const anonymousId = JSON.parse(frame.slice(6)).payload; + const accepted = await server.handle(new Request(`${path}/unsubscribe`, { method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ connectionId: anonymousId, queryId: 'Public' }) })); + accepted?.status.should.equal(200); + await anonymousReader.cancel(); + }); + + it('should not allow an anonymous request to control an authenticated connection', async () => { + const authenticated = (await server.handle(new Request(path, { + headers: { authorization: 'Bearer alice' } + }), { remoteAddress: '10.0.0.1' }))!; + const authenticatedReader = authenticated.body!.getReader(); + const frame = new TextDecoder().decode((await authenticatedReader.read()).value); + const authenticatedId = JSON.parse(frame.slice(6)).payload; + const unsubscribe = (authorization?: string) => server.handle(new Request(`${path}/unsubscribe`, { + method: 'POST', headers: { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }, + body: JSON.stringify({ connectionId: authenticatedId, queryId: 'Public' }) + }), { remoteAddress: '10.0.0.2' }); + (await unsubscribe())?.status.should.equal(404); + (await unsubscribe('Bearer alice'))?.status.should.equal(200); + await authenticatedReader.cancel(); + }); +}); diff --git a/Source/Core/queries/observable/for_observableCallerKey/when_identifying_callers/with_anonymous_connections.ts b/Source/Core/queries/observable/for_observableCallerKey/when_identifying_callers/with_anonymous_connections.ts index d7495f53..0652bc00 100644 --- a/Source/Core/queries/observable/for_observableCallerKey/when_identifying_callers/with_anonymous_connections.ts +++ b/Source/Core/queries/observable/for_observableCallerKey/when_identifying_callers/with_anonymous_connections.ts @@ -22,11 +22,11 @@ describe('when identifying callers with anonymous connections', () => { first = observableCallerKey(one); second = observableCallerKey(two); named = observableCallerKey({ ...anonymous(), principal: { id: 'anonymous', isAuthenticated: true, roles: [] } }); - shared = observableCallerKey(one, false); - otherShared = observableCallerKey(two, false); + shared = observableCallerKey(one); + otherShared = observableCallerKey({ ...two, remoteAddress: '10.0.0.2' }); }); - it('should distinguish anonymous connections', () => { first.should.not.equal(second); }); + it('should share a budget across anonymous connections from one address', () => { first.should.equal(second); }); it('should distinguish authenticated principals named anonymous', () => { first.should.not.equal(named); }); - it('should share a key when connection identity is disabled', () => { shared.should.equal(otherShared); }); + it('should distinguish callers from different addresses', () => { shared.should.not.equal(otherShared); }); }); diff --git a/Source/Core/queries/observable/observableCallerKey.ts b/Source/Core/queries/observable/observableCallerKey.ts index cf592eee..148a8a2b 100644 --- a/Source/Core/queries/observable/observableCallerKey.ts +++ b/Source/Core/queries/observable/observableCallerKey.ts @@ -3,13 +3,13 @@ import type { ExecutionContext } from '../../execution/ExecutionContext.js'; /** Keep anonymous and authenticated namespaces distinct, even for an id named "anonymous". */ -export function observableCallerKey(context: ExecutionContext, preferConnection = true): string { +export function observableCallerKey(context: ExecutionContext): string { const tenant = context.tenantId ?? ''; if (context.principal?.isAuthenticated) return JSON.stringify([tenant, 'principal', context.principal.id]); - if (preferConnection && context.connectionId) - return JSON.stringify([tenant, 'anonymous-connection', context.connectionId]); if (context.remoteAddress) return JSON.stringify([tenant, 'anonymous-address', context.remoteAddress]); - return JSON.stringify([tenant, 'anonymous-request', context.correlationId]); + // Without a peer address, share a conservative tenant-wide budget rather than giving + // each new anonymous connection a fresh per-caller allowance. + return JSON.stringify([tenant, 'anonymous-unattributed']); } From 024f5065d073898e25b78f7a1300099ddcf48320 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 08:06:32 +0200 Subject: [PATCH 2/3] Document anonymous SSE hub security and default React transport --- Documentation/getting-started/continue-in-the-browser.md | 9 +++------ Documentation/queries/observable-queries.md | 2 +- Documentation/queries/observable-query-demultiplexer.md | 6 ++++-- Documentation/reference/capabilities.md | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Documentation/getting-started/continue-in-the-browser.md b/Documentation/getting-started/continue-in-the-browser.md index ff3c58d3..f110dfde 100644 --- a/Documentation/getting-started/continue-in-the-browser.md +++ b/Documentation/getting-started/continue-in-the-browser.md @@ -210,11 +210,10 @@ Create `src/main.tsx`: import 'reflect-metadata'; import { createRoot } from 'react-dom/client'; import { Arc } from '@cratis/arc.react'; -import { QueryTransportMethod } from '@cratis/arc/queries'; import { TaskBoard } from './TaskBoard'; createRoot(document.getElementById('root')!).render( - + ); @@ -222,9 +221,7 @@ createRoot(document.getElementById('root')!).render( `` gives every hook below it the same configuration: the API origin (here the page's own origin, which Vite forwards) and how live queries travel. -:::caution[Choose the WebSocket hub for an anonymous server] -`` connects live queries through the server-sent events hub by default. On this server the SSE hub requires an authenticated caller, and the Tasks sample has no authentication, so the list would stay empty while the browser console reports `SSE hub connection error`. `queryTransportMethod={QueryTransportMethod.WebSocket}` uses the WebSocket hub at `/.cratis/queries/ws`, which accepts anonymous callers. An application with real sign-in can keep the default; see [Multiplexed observable queries](../queries/observable-query-demultiplexer.md). -::: +`` uses the server-sent events hub by default. It accepts anonymous connections, and the Tasks sample's observable query permits anonymous subscriptions. See [Multiplexed observable queries](../queries/observable-query-demultiplexer.md) for its control-request security model. ## Run it @@ -243,7 +240,7 @@ Open . The list shows any tasks you registered with `curl | `!Loud` | `A title cannot begin with an exclamation mark` | The browser had no copy of this rule, so the server checked it and answered 400 | | `Try the browser` | `Task registered.` | The command succeeded, and the list grows by one without a reload | -The last row is the observable query at work. The WebSocket hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample's `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too. +The last row is the observable query at work. The SSE hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample's `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too. ## Recap diff --git a/Documentation/queries/observable-queries.md b/Documentation/queries/observable-queries.md index aa4f3405..1d693e85 100644 --- a/Documentation/queries/observable-queries.md +++ b/Documentation/queries/observable-queries.md @@ -141,7 +141,7 @@ Direct WebSocket frames are `{"type":"Data","data":}`; a `Ping` re ## Use the installed client -The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](observable-query-demultiplexer.md). The plain client defaults to the WebSocket hub. The `` provider from `@cratis/arc.react` defaults to the SSE hub instead, and on this server the SSE hub requires an authenticated caller. For an application without sign-in, set ``, as [Continue in the browser](../getting-started/continue-in-the-browser.md) does. +The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](observable-query-demultiplexer.md). The plain client defaults to the WebSocket hub. The `` provider from `@cratis/arc.react` defaults to the SSE hub instead; both accept anonymous connections. Each subscription still passes through query authorization, so an anonymous caller can only observe queries that permit anonymous access. The default `` configuration works for the [Tasks browser example](../getting-started/continue-in-the-browser.md) without switching transports. For the direct transports above, set these before subscribing: diff --git a/Documentation/queries/observable-query-demultiplexer.md b/Documentation/queries/observable-query-demultiplexer.md index c3d19e95..4bce6626 100644 --- a/Documentation/queries/observable-query-demultiplexer.md +++ b/Documentation/queries/observable-query-demultiplexer.md @@ -5,7 +5,7 @@ description: Carry many observable-query subscriptions over one WebSocket or ser A dashboard with ten live widgets should not open ten sockets. The multiplexed hub carries every subscription from one client over a single connection, and it is the default transport of the published `@cratis/arc` client. -The plain `@cratis/arc` client uses the WebSocket hub by default. The `` provider from `@cratis/arc.react` uses the SSE hub by default, which on this server requires an authenticated caller; set `` when your callers are anonymous. +The plain `@cratis/arc` client uses the WebSocket hub by default. The `` provider from `@cratis/arc.react` uses the SSE hub by default. Both work with anonymous callers; every subscription still runs through query authorization. ## The WebSocket hub @@ -29,7 +29,9 @@ hub.onmessage = event => { ## The server-sent-events hub -The SSE hub uses `GET /.cratis/queries/sse` for its `Connected` stream, plus `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` controls. It requires a **trusted authenticated principal** on the stream and on every control request; anonymous callers cannot use it, which differs from Arc on .NET. Browser `EventSource` sends same-origin cookies but cannot set an `Authorization` header, so authenticate the stream with your application's real session cookie and send the same cookie with the control requests. A control request from a different principal or tenant returns the same 404 as an unknown connection ID; subscribing to an unauthorized query answers 401. The `.cratis-identity` display cookie is not a credential. +The SSE hub uses `GET /.cratis/queries/sse` for its `Connected` stream, plus `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` controls. The stream can be opened anonymously. Its connection ID is crypto-random; a control request must match the opening caller's authentication state and tenant. Authenticated connections require the same authenticated identity on every control request, while anonymous callers cannot control authenticated connections (or vice versa). With anonymous connections, callers have no identity to distinguish them: when the host supplies a peer address, Arc for TypeScript additionally requires that address on control requests to match the opener. Without a peer address, other anonymous callers in the same tenant who learn the connection ID cannot be distinguished. This address check is deliberately stricter than Arc on .NET; do not treat it as user authentication, especially behind a shared proxy. Unknown or unowned connections return 404, and a query the caller may not access answers 401 with an `Unauthorized` frame. + +Browser `EventSource` sends same-origin cookies but cannot set an `Authorization` header. If your queries require sign-in, use your application's real session cookie on the stream and controls; the `.cratis-identity` display cookie is not a credential. SSE controls require `application/json` (optionally `charset=utf-8`) and reject an untrusted browser `Origin` with 403. Same-origin is allowed by default; configure `query.allowedOrigins` for trusted cross-origin frontends. Anonymous per-caller connection limits group requests by peer address, or by tenant when no address is available. ## Configure the installed client diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index 2efc4cd2..18075cc6 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -54,7 +54,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_ Date: Fri, 25 Sep 2026 08:06:58 +0200 Subject: [PATCH 3/3] Prepare the v0.24.1 source preview --- ContractTests/Client/package.json | 2 +- Documentation/index.md | 2 +- Documentation/reference/packages.md | 2 +- README.md | 2 +- Source/Chronicle/package.json | 6 +++--- Source/CodeAnalysis/package.json | 2 +- Source/Core/package.json | 2 +- Source/Cratis/package.json | 2 +- Source/Drizzle/package.json | 4 ++-- Source/Express/package.json | 2 +- Source/Fastify/package.json | 2 +- Source/Hono/package.json | 2 +- Source/MongoDB/package.json | 4 ++-- Source/Testing/package.json | 2 +- Source/Tools/ProxyGenerator/package.json | 2 +- yarn.lock | 8 ++++---- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index cf4f27ff..92790e96 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.24.0", + "version": "0.24.1", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index 9800f999..704e90da 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.24.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.24.1 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index 62fbeec3..f4a44c5f 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.24.0, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only. +Every package in this repository is at version 0.24.1, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only. ## Server packages diff --git a/README.md b/README.md index 94e703d2..42438812 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.7.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.24.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths ran in Deno 2.9.7, while Bun, Cloudflare Workers, and Next.js deployments remain unverified. The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.24.1. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths ran in Deno 2.9.7, while Bun, Cloudflare Workers, and Next.js deployments remain unverified. The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index bb83547e..44d789f3 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.24.0", + "version": "0.24.1", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.24.0", - "@cratis/arc.testing": "^0.24.0", + "@cratis/arc.core": "^0.24.1", + "@cratis/arc.testing": "^0.24.1", "@cratis/chronicle": "^6.7.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index 7fc98e60..4fdcc200 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/package.json b/Source/Core/package.json index 502ae0ec..e9b5f1f9 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 1d4cced0..bf15295c 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 99966999..f6c942bc 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.24.0", + "@cratis/arc.core": "^0.24.1", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0" }, diff --git a/Source/Express/package.json b/Source/Express/package.json index 2dc8ff40..f511f110 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index d67da6e9..53430784 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index 50a19be5..3ad76c93 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index 5e1700d2..8a3865c2 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.24.0", + "@cratis/arc.core": "^0.24.1", "@cratis/fundamentals": "^7.19.6", "mongodb": "^6.21.0", "rxjs": "^7.8.2" diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 2b201324..aef84d7e 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.24.0", + "version": "0.24.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index 109fa498..be023d4f 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.24.0", + "version": "0.24.1", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 5e67e688..3f46b56c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,8 +45,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.24.0 - "@cratis/arc.testing": ^0.24.0 + "@cratis/arc.core": ^0.24.1 + "@cratis/arc.testing": ^0.24.1 "@cratis/chronicle": ^6.7.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -144,7 +144,7 @@ __metadata: postgres: "npm:^3.4.9" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.24.0 + "@cratis/arc.core": ^0.24.1 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 languageName: unknown @@ -208,7 +208,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.24.0 + "@cratis/arc.core": ^0.24.1 "@cratis/fundamentals": ^7.19.6 mongodb: ^6.21.0 rxjs: ^7.8.2