diff --git a/README.md b/README.md index 3e2e2b1..b6db00a 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,9 @@ mode, a `read:public` (or `write:public`) collection is reachable anonymously by project's **non-secret project id** — pass it as `X-Project-Id: ` (or `?project=` for links/downloads), e.g. `Zero(base, { project: 'prj_…' })`. That lets a published frontend read public content without embedding the secret -project key; private collections still require it. +project key; private collections still require it. A `read:public` collection's +**live feed** is public too: `Zero(base, { project }).data('posts').subscribe(…)` +streams changes over a read-only websocket with no secret. ### Files — upload / download @@ -318,6 +320,11 @@ Single-process and in-memory by default. For **horizontal scale**, set `REDIS_URL` and run multiple server processes: messages and presence fan out across them via Redis, with no change to the API or SDK. +In multi-tenant mode, the live feed of a `read:public` collection +(`data:`) can be followed anonymously with just the **non-secret +project id** (`{ project }`) — a read-only subscription, so a published frontend +gets live updates without a secret. Other channels still require a project key. + ## Multi-tenant projects By default the server runs in **open mode**: one implicit project, no auth — exactly diff --git a/public/docs.html b/public/docs.html index 519ffac..2e8418f 100644 --- a/public/docs.html +++ b/public/docs.html @@ -98,7 +98,7 @@

Auth & modes

ModeHowAuth Open (default)no ADMIN_KEYnone, unless API_KEY is set (then send Authorization: Bearer <API_KEY>) - Multi-tenantADMIN_KEY setevery data request needs a project keyZero(base, { apiKey: '<key>' }) or a bearer header. Projects are managed via /v1/admin. A collection marked public is reachable by the project's non-secret id alone — Zero(base, { project: 'prj_…' }) — no key embedded. + Multi-tenantADMIN_KEY setevery data request needs a project keyZero(base, { apiKey: '<key>' }) or a bearer header. Projects are managed via /v1/admin. A collection marked public (and its live feed) is reachable by the project's non-secret id alone — Zero(base, { project: 'prj_…' }) — no key embedded.

GET /v1 reports the active mode and capability map; GET /v1/stats adds version, uptime, usage counts, and the AI provider.

@@ -271,6 +271,8 @@

REST API — Realtime

room.subscribe((m) => console.log(m.data)); // { hello: 'world' } room.presence((e) => console.log(e.event, e.member)); // join | leave | typing | sync room.typing(true); await room.members(); // [{ cid, identity }] +# Public live feed (multi-tenant): follow a read:public collection with no secret — +# Zero(base, { project: 'prj_…' }).data('posts').subscribe(fn) // read-only # Scale out: set REDIS_URL and run many processes — frames + presence fan out via Redis.

REST API — Admin & backup

diff --git a/src/modules/realtime/routes.ts b/src/modules/realtime/routes.ts index 2def73c..37f1bbe 100644 --- a/src/modules/realtime/routes.ts +++ b/src/modules/realtime/routes.ts @@ -67,6 +67,9 @@ export async function realtimeRoutes(app: FastifyInstance): Promise { }); socket.on('message', (raw: Buffer) => { + // Anonymous (public-by-project-id) subscribers are read-only: they follow + // the channel but cannot publish or signal typing. + if (!req.authed) return; let data: unknown; try { data = JSON.parse(raw.toString()); diff --git a/src/server.ts b/src/server.ts index 2a68b36..c601636 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,27 @@ function dataAccess(method: string, url: string): { collection: string; op: 'rea return { collection: parts[2]!, op: method === 'GET' ? 'read' : 'write' }; } +/** + * For a read-only realtime request (WS subscribe, or GET history/presence) on a + * `data:` channel, the collection it mirrors — used to allow a + * published frontend to follow a read:public collection's live feed anonymously + * via the project id. Returns null for writes, the channel list, and non-data + * channels (those still require a key). + */ +function publicRealtimeCollection(method: string, url: string): string | null { + if (method !== 'GET') return null; // subscribe (WS upgrade) + history/presence are GETs + const parts = url.split('/').filter(Boolean); // url is already query-stripped + if (parts[0] !== 'v1' || parts[1] !== 'realtime' || parts.length < 3 || parts.length > 4) return null; + if (parts.length === 4 && parts[3] !== 'history' && parts[3] !== 'presence') return null; + let channel: string; + try { + channel = decodeURIComponent(parts[2]!); + } catch { + return null; + } + return channel.startsWith('data:') ? channel.slice('data:'.length) : null; +} + /** A safe, bounded request id taken from an inbound X-Request-Id, or freshly minted. */ function requestId(headers: Record): string { const incoming = headers['x-request-id']; @@ -169,17 +190,27 @@ export async function buildServer(opts: BuildOptions = {}): Promise` channel). `authed` stays false, so + // reads redact hidden fields and the realtime socket is read-only. const pid = publicProjectId(req); - if (pid && access) { + if (pid) { const project = await app.projects.get(pid); - if (project && (await app.acl.get(project.id, access.collection))[access.op] === 'public') { - req.projectId = project.id; - req.authed = false; - return; + if (project) { + const access = dataAccess(req.method, url); + if (access && (await app.acl.get(project.id, access.collection))[access.op] === 'public') { + req.projectId = project.id; + req.authed = false; + return; + } + const rtCollection = publicRealtimeCollection(req.method, url); + if (rtCollection && (await app.acl.get(project.id, rtCollection)).read === 'public') { + req.projectId = project.id; + req.authed = false; + return; + } } } return reply.code(401).send({ error: 'unauthorized', message: 'Provide a project key.' }); diff --git a/test/api.test.ts b/test/api.test.ts index 903b909..332b88e 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -973,6 +973,53 @@ test('multi-tenant: projects are isolated and require a key', async () => { } }); +test('multi-tenant: a read:public collection exposes a live feed by project id (read-only)', async () => { + const ADMIN = 'admin-secret'; + const adminH = { Authorization: `Bearer ${ADMIN}`, 'content-type': 'application/json' }; + const server = await buildServer({ logger: false, adminKey: ADMIN }); + await server.listen({ host: '127.0.0.1', port: 0 }); + const { port } = server.server.address() as AddressInfo; + const b = `http://127.0.0.1:${port}`; + const wait = (ms = 150) => new Promise((r) => setTimeout(r, ms)); + try { + const proj = await (await fetch(`${b}/v1/admin/projects`, { method: 'POST', headers: adminH, body: JSON.stringify({ name: 'site' }) })).json(); + const owner = Zero(b, { apiKey: proj.key }); + await owner.data('posts').setAcl({ read: 'public' }); + + // A public client (project id only, no secret) follows the collection's live feed. + const pub = Zero(b, { project: proj.id }); + const got: any[] = []; + const room = pub.data('posts').subscribe((e: any) => got.push(e)); + await room.ready; + await wait(); + + // The owner writes → the public subscriber receives the change event. + await owner.data('posts').create({ title: 'live' }); + await wait(250); + assert.ok(got.some((e) => e.type === 'created' && e.document?.title === 'live'), 'public feed should deliver changes'); + + // The public socket is read-only: anything it sends is ignored (no echo back). + const before = got.length; + room.publish({ hello: 'noise' }); + await wait(200); + assert.equal(got.length, before, 'public subscriber must not be able to publish'); + room.close(); + + // Subscribing to a non-public collection's feed by project id is rejected. + const wsUrl = `${b.replace('http', 'ws')}/v1/realtime/${encodeURIComponent('data:secrets')}?project=${proj.id}`; + const denied = new WebSocket(wsUrl); + const outcome = await new Promise((resolve) => { + denied.addEventListener('open', () => resolve('open')); + denied.addEventListener('error', () => resolve('error')); + denied.addEventListener('close', () => resolve('close')); + }); + assert.notEqual(outcome, 'open', 'a private collection feed must not be publicly subscribable'); + try { denied.close(); } catch { /* already closed */ } + } finally { + await server.close(); + } +}); + test('multi-tenant: a public collection is readable by non-secret project id (no key)', async () => { const ADMIN = 'admin-secret'; const adminH = { Authorization: `Bearer ${ADMIN}`, 'content-type': 'application/json' };