@zubra/sync 5.8.0 → 5.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,4 +12,4 @@ Local-first state for Zubra components. `'use collection tasks'` at the top of a
12
12
  <task-item doc="">…</task-item>
13
13
  ```
14
14
 
15
- `<task-item doc="t42">` is the document `tasks/t42`. To share documents between devices, `connect()` from `@zubra/sync/connect` and serve the log with `createSyncServer()` from `@zubra/sync/server`; components don't change. see [docs/sync.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/sync.md).
15
+ `<task-item doc="t42">` is the document `tasks/t42`. To share documents between devices, `connect()` from `@zubra/sync/connect` and serve the log with `createSyncServer({ authorize })` from `@zubra/sync/server` (without `authorize`, pages may read but not write); components don't change. see [docs/sync.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/sync.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/sync",
3
- "version": "5.8.0",
3
+ "version": "5.9.1",
4
4
  "description": "Local-first state for Zubra components: 'use collection' keeps a component's state in IndexedDB, across reloads, offline, and in step across tabs.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -32,7 +32,7 @@
32
32
  "test:binary": "ZUBRA_SYNC_BACKEND=binary node --test test/browser.test.js"
33
33
  },
34
34
  "peerDependencies": {
35
- "@zubra/runtime": "^5.8.0"
35
+ "@zubra/runtime": "^5.9.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@zubra/compiler": "file:../compiler",
package/src/connect.js CHANGED
@@ -117,6 +117,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
117
117
  });
118
118
 
119
119
  let timer = null;
120
+ let warned = false;
120
121
  const schedule = () => {
121
122
  clearTimeout(timer);
122
123
  timer = setTimeout(flush, 30);
@@ -131,7 +132,16 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
131
132
  headers: { 'content-type': 'application/json' },
132
133
  body: JSON.stringify({ changes: sending }),
133
134
  });
134
- if (!response.ok) return;
135
+ if (!response.ok) {
136
+ // A refused write is kept (it may be allowed later), but say why once: a server with no
137
+ // `authorize` refuses every write from pages, and a silent outbox looks like a bug.
138
+ if (response.status === 403 && !warned) {
139
+ warned = true;
140
+ const why = await response.json().then((body) => body?.error, () => '');
141
+ console.warn(`@zubra/sync: the server refused this page's changes${why ? `: ${why}` : ''}`);
142
+ }
143
+ return;
144
+ }
135
145
  outbox.done(sending);
136
146
  } catch {
137
147
  // Offline: the outbox stays (in localStorage too), and goes with the next flush.
@@ -7,7 +7,8 @@ import { durableObjectLog } from './durable-log.js';
7
7
  export { MOUNT };
8
8
 
9
9
  /**
10
- * `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does.
10
+ * `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does;
11
+ * without one, pages may read and not write.
11
12
  * `heartbeat` is shorter than on Node (2 s, not 15): a Durable Object isn't told when a page's
12
13
  * connection is cut, only that a keep-alive couldn't be written, which on workerd is the third
13
14
  * after the cut. So a crashed page's presence lingers about three keep-alives (6 s). A page that
@@ -20,7 +21,8 @@ export function syncObject(ctx, owner, { mount = MOUNT, heartbeat = 2_000, devic
20
21
  heartbeat,
21
22
  device,
22
23
  origins,
23
- authorize: (asked) => (typeof owner.authorize === 'function' ? owner.authorize(asked) : true),
24
+ // Without an authorize of its own, the object takes reads only, as createSyncServer does.
25
+ authorize: typeof owner.authorize === 'function' ? (asked) => owner.authorize(asked) : undefined,
24
26
  });
25
27
  return {
26
28
  async fetch(request) {
package/src/durable.d.ts CHANGED
@@ -24,7 +24,7 @@ export class SyncObject<Env = unknown> {
24
24
  protected ctx: any;
25
25
  protected env: Env;
26
26
  /** Refuse a write or hide a change; as `createSyncServer`'s. Allows everything if not defined. */
27
- authorize?(asked: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
27
+ authorize?(asked: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
28
28
  fetch(request: Request): Promise<Response>;
29
29
  documents(): Promise<Documents>;
30
30
  write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
package/src/server.d.ts CHANGED
@@ -40,6 +40,9 @@ export function parseScope(text: string | null | undefined): Scope | null;
40
40
  /** Whether a document is in a scope (null is everything). */
41
41
  export function inScope(scope: Scope | null, collection: string, key: string): boolean;
42
42
 
43
+ /** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
44
+ export const allowAnyone: () => true;
45
+
43
46
  export function createSyncServer(options?: {
44
47
  log?: Log;
45
48
  /** Default `/_zb/sync`. */
@@ -47,9 +50,13 @@ export function createSyncServer(options?: {
47
50
  /**
48
51
  * Refuse a write, or hide a change from a reader, by returning false. `kind` is what's asked:
49
52
  * 'change' (a push), 'presence' (joining or signaling in a document's room; nothing is stored)
50
- * or 'read' (a change about to be sent to this request). `write` is true for the first two.
53
+ * or 'read' (a change about to be sent to this request). `write` is true for the first two. A
54
+ * 'change' has the `field` and `value` it sets.
55
+ *
56
+ * Without it, pages may read but every write from a page is refused (403, saying why). Pass
57
+ * `allowAnyone` for a page everyone may edit. The server's own `write()` is never asked.
51
58
  */
52
- authorize?: (from: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
59
+ authorize?: (from: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
53
60
  /** Other origins allowed to push. */
54
61
  /**
55
62
  * The scope a request may see and write (tenancy: a business's keys under its own prefix), or
package/src/server.js CHANGED
@@ -10,7 +10,13 @@
10
10
  // `authorize({ request, collection, key, write, kind })` may refuse a change or hide one (return
11
11
  // false). `kind` says what's asked: 'read' (a change being sent to this request), 'change' (one
12
12
  // it pushed) or 'presence' (joining a document's room, or signaling in it: nothing is stored).
13
- // `write` is true for the last two.
13
+ // `write` is true for the last two. A 'change' also has the `field` and `value` it sets, so an
14
+ // app can keep fields (or whole collections) for the server alone.
15
+ //
16
+ // Without `authorize`, pages may read but not write: every push, presence and signal is refused
17
+ // with a 403 saying so. Say who may change what, or, for a page everyone may edit (a shared
18
+ // board on a trusted network), `createSyncServer({ authorize: allowAnyone })`. The server's own
19
+ // `write()` is never asked.
14
20
  //
15
21
  // The server can take part too. `await sync.documents()` is every document as the devices see
16
22
  // it (last writer wins, per field), kept current; `sync.write(collection, key, fields)` writes as
@@ -188,8 +194,19 @@ function documentsOf() {
188
194
  };
189
195
  }
190
196
 
191
- export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
197
+ /** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
198
+ export const allowAnyone = () => true;
199
+
200
+ /** The default: pages may read; writes from pages need an `authorize` that says who may make them. */
201
+ const readsOnly = ({ write }) => !write;
202
+ const NO_AUTHORIZE = 'This sync server takes no writes from pages until it is told who may make them: pass createSyncServer({ authorize }) (or authorize: allowAnyone, for a page anyone may edit). See docs/sync.md, "Who may write".';
203
+
204
+ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
192
205
  if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
206
+ if (given !== undefined && typeof given !== 'function') throw new TypeError('createSyncServer: `authorize` is a function ({ request, collection, key, field, value, write, kind }) => boolean');
207
+ const authorize = given ?? readsOnly;
208
+ /** What a refused write says: why, when no authorize was given; the app's own refusal otherwise. */
209
+ const refused = (fallback) => json(403, { error: given ? fallback : NO_AUTHORIZE });
193
210
  const base = mount.replace(/\/$/, '');
194
211
  // Appends, one after another, so the log's order is the order they were made in.
195
212
  let appending = Promise.resolve();
@@ -318,7 +335,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
318
335
  if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
319
336
  for (const change of changes) {
320
337
  if (!(await mayWrite(request, change.collection, change.key))) return json(403, { error: 'Not yours to change' });
321
- if (!(await authorize({ request, collection: change.collection, key: change.key, write: true, kind: 'change' }))) return json(403, { error: 'Not yours to change' });
338
+ if (!(await authorize({ request, collection: change.collection, key: change.key, field: change.field, value: change.value, write: true, kind: 'change' }))) return refused('Not yours to change');
322
339
  }
323
340
  const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
324
341
  const cursor = await append(appended);
@@ -340,7 +357,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
340
357
  if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
341
358
  return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
342
359
  }
343
- if (!(await mayWrite(request, room.collection, room.key)) || !(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
360
+ if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
361
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
344
362
  const clean = { room: message.room, peer: message.peer, state: message.state };
345
363
  for (const listener of listeners) if (listener.peer === clean.peer) {
346
364
  if (clean.state === null) listener.rooms.delete(clean.room);
@@ -365,7 +383,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
365
383
  if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
366
384
  return json(400, { error: 'Expected { room, from, to, data: {…} }' });
367
385
  }
368
- if (!(await mayWrite(request, room.collection, room.key)) || !(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
386
+ if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
387
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
369
388
  const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
370
389
  for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
371
390
  return json(200, { ok: true });