Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <prj_…>` (or
`?project=<prj_…>` 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

Expand Down Expand Up @@ -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:<collection>`) 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
Expand Down
4 changes: 3 additions & 1 deletion public/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ <h2 id="auth">Auth &amp; modes</h2>
<thead><tr><th>Mode</th><th>How</th><th>Auth</th></tr></thead>
<tbody>
<tr><td><b>Open</b> (default)</td><td>no <code>ADMIN_KEY</code></td><td>none, unless <code>API_KEY</code> is set (then send <code>Authorization: Bearer &lt;API_KEY&gt;</code>)</td></tr>
<tr><td><b>Multi-tenant</b></td><td><code>ADMIN_KEY</code> set</td><td>every data request needs a <b>project key</b> — <code>Zero(base, { apiKey: '&lt;key&gt;' })</code> or a bearer header. Projects are managed via <code>/v1/admin</code>. A collection marked <code>public</code> is reachable by the project's <b>non-secret id</b> alone — <code>Zero(base, { project: 'prj_…' })</code> — no key embedded.</td></tr>
<tr><td><b>Multi-tenant</b></td><td><code>ADMIN_KEY</code> set</td><td>every data request needs a <b>project key</b> — <code>Zero(base, { apiKey: '&lt;key&gt;' })</code> or a bearer header. Projects are managed via <code>/v1/admin</code>. A collection marked <code>public</code> (and its live feed) is reachable by the project's <b>non-secret id</b> alone — <code>Zero(base, { project: 'prj_…' })</code> — no key embedded.</td></tr>
</tbody>
</table>
<p class="note"><code>GET /v1</code> reports the active <code>mode</code> and capability map; <code>GET /v1/stats</code> adds version, uptime, usage counts, and the AI provider.</p>
Expand Down Expand Up @@ -271,6 +271,8 @@ <h2 id="rest-realtime">REST API — Realtime</h2>
room.subscribe((m) =&gt; console.log(m.data)); <span class="c">// { hello: 'world' }</span>
room.presence((e) =&gt; console.log(e.event, e.member)); <span class="c">// join | leave | typing | sync</span>
room.typing(<span class="k">true</span>); <span class="k">await</span> room.members(); <span class="c">// [{ cid, identity }]</span>
<span class="c"># Public live feed (multi-tenant): follow a read:public collection with no secret —</span>
<span class="c"># Zero(base, { project: 'prj_…' }).data('posts').subscribe(fn) // read-only</span>
<span class="c"># Scale out: set REDIS_URL and run many processes — frames + presence fan out via Redis.</span></pre>

<h2 id="rest-admin">REST API — Admin &amp; backup</h2>
Expand Down
3 changes: 3 additions & 0 deletions src/modules/realtime/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export async function realtimeRoutes(app: FastifyInstance): Promise<void> {
});

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());
Expand Down
49 changes: 40 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<collection>` 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, unknown>): string {
const incoming = headers['x-request-id'];
Expand Down Expand Up @@ -169,17 +190,27 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
req.projectId = project.id;
return;
}
// No secret key: permit anonymous access to a project's public collection,
// addressed by its non-secret project id. Same model as open-mode public
// ACL — `authed` stays false so read routes redact `hidden` fields.
const access = dataAccess(req.method, url);
// No secret key: permit anonymous access addressed by the project's
// non-secret id — to a public data collection (read/write per its ACL), or
// to the *live feed* of a read:public collection (WS subscribe + history/
// presence on its `data:<collection>` 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.' });
Expand Down
47 changes: 47 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((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' };
Expand Down
Loading