ulda-auth 1.0.1 → 1.1.0

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.
@@ -0,0 +1,80 @@
1
+ import { check } from './errors.js';
2
+ import { LIMITS, binary, exact, id, equal } from './values.js';
3
+
4
+ export function needsTransfer(items) {
5
+ return items.length > LIMITS.batch || items.some(x => (x.payload?.byteLength ?? 0) > LIMITS.payloadBytes)
6
+ || items.reduce((n, x) => n + x.witness.byteLength + (x.payload?.byteLength ?? 0), 0) > LIMITS.messageBytes;
7
+ }
8
+
9
+ // Only ciphertext/proofs from SDK packet handles enter this generator.
10
+ export function* fragments(items) {
11
+ let group = [], bytes = 0;
12
+ for (const item of items) {
13
+ const size = item.witness.byteLength + (item.payload?.byteLength ?? 0);
14
+ if (group.length && (group.length === LIMITS.batch || bytes + size > LIMITS.messageBytes || size > LIMITS.payloadBytes)) {
15
+ yield { kind: 'append', items: group }; group = []; bytes = 0;
16
+ }
17
+ if ((item.payload?.byteLength ?? 0) > LIMITS.payloadBytes) {
18
+ for (let offset = 0; offset < item.payload.length; offset += LIMITS.jsonBytes) {
19
+ yield { kind: 'part', ...(item.id === undefined ? {} : { resourceId: item.id }),
20
+ witness: item.witness, total: item.payload.length, offset,
21
+ payload: item.payload.subarray(offset, offset + LIMITS.jsonBytes) };
22
+ }
23
+ } else { group.push(item); bytes += size; }
24
+ }
25
+ if (group.length) yield { kind: 'append', items: group };
26
+ }
27
+
28
+ export class ChangeAssembly {
29
+ #items = [];
30
+ #partial;
31
+ #bytes;
32
+ constructor(count) {
33
+ check(Number.isSafeInteger(count) && count > 0 && count * 256 <= LIMITS.transferBytes, 'LIMIT');
34
+ this.count = count; this.#bytes = count * 256;
35
+ }
36
+ accept(frame) {
37
+ check(this.#items.length < this.count, 'INVALID_PACKET');
38
+ if (frame.kind === 'append') {
39
+ exact(frame, ['kind', 'items']);
40
+ check(!this.#partial && Array.isArray(frame.items) && frame.items.length > 0 && frame.items.length <= LIMITS.batch, 'INVALID_PACKET');
41
+ check(this.#items.length + frame.items.length <= this.count, 'INVALID_PACKET');
42
+ const prepared = frame.items.map(item => {
43
+ const payload = Object.hasOwn(item, 'payload');
44
+ exact(item, payload ? ['id', 'witness', 'payload'] : ['id']);
45
+ const witness = payload ? binary(item.witness, 167) : undefined;
46
+ const content = payload ? binary(item.payload) : undefined;
47
+ return { id: id(item.id), ...(payload ? { witness, payload: content } : {}) };
48
+ });
49
+ const bytes = prepared.reduce((n, x) => n + (x.witness?.length ?? 0) + (x.payload?.length ?? 0), 0);
50
+ check(bytes <= LIMITS.messageBytes && this.#bytes + bytes <= LIMITS.transferBytes, 'LIMIT');
51
+ this.#bytes += bytes;
52
+ for (const item of prepared) this.#items.push({ ...item, ...(item.payload ? { witness: item.witness.slice(), payload: item.payload.slice() } : {}) });
53
+ } else {
54
+ exact(frame, ['kind', 'resourceId', 'witness', 'total', 'offset', 'payload']);
55
+ check(frame.kind === 'part', 'INVALID_PACKET');
56
+ id(frame.resourceId);
57
+ const witness = binary(frame.witness, 167), payload = binary(frame.payload, LIMITS.jsonBytes);
58
+ check(Number.isSafeInteger(frame.total) && frame.total > 0 && frame.total <= LIMITS.resourceBytes, 'LIMIT');
59
+ check(Number.isSafeInteger(frame.offset) && frame.offset >= 0 && frame.offset + payload.length <= frame.total, 'INVALID_PACKET');
60
+ if (!this.#partial) {
61
+ check(frame.offset === 0 && this.#bytes + frame.total + witness.length <= LIMITS.transferBytes, 'LIMIT');
62
+ this.#bytes += frame.total + witness.length;
63
+ this.#partial = { id: frame.resourceId, witness: witness.slice(), payload: new Uint8Array(frame.total), offset: 0 };
64
+ }
65
+ const item = this.#partial;
66
+ check(item.id === frame.resourceId && item.offset === frame.offset && item.payload.length === frame.total && equal(item.witness, witness), 'INVALID_PACKET');
67
+ item.payload.set(payload, item.offset); item.offset += payload.length;
68
+ if (item.offset === item.payload.length) {
69
+ this.#items.push({ id: item.id, witness: item.witness, payload: item.payload }); this.#partial = undefined;
70
+ }
71
+ }
72
+ }
73
+ finish() {
74
+ check(!this.#partial && this.#items.length === this.count, 'INVALID_PACKET');
75
+ const updated = [], deleted = [];
76
+ for (const item of this.#items) item.payload === undefined ? deleted.push(item.id) : updated.push(item);
77
+ this.#items = [];
78
+ return { updated, deleted };
79
+ }
80
+ }
package/src/transport.js CHANGED
@@ -5,8 +5,10 @@ import {
5
5
  requestShape,
6
6
  acknowledgement,
7
7
  changed,
8
+ resource,
8
9
  } from "./protocol.js";
9
- import { LIMITS } from "./values.js";
10
+ import { LIMITS, binary, equal, exact } from "./values.js";
11
+ import { needsTransfer, fragments, ChangeAssembly } from './transfer.js';
10
12
  import { serverDiagnostic } from "./diagnostics.js";
11
13
  import { socketParser } from "./socket-parser.js";
12
14
  import { checkSignal } from "./reconnect.js";
@@ -40,6 +42,11 @@ export class SocketIoTransport {
40
42
  #generation = 0;
41
43
  #request = 0;
42
44
  #intentional = false;
45
+ #writeTail = Promise.resolve();
46
+ #readTail = Promise.resolve();
47
+ #transfers = 0;
48
+ #assembly;
49
+ #assemblyTimer;
43
50
  constructor(
44
51
  endpoint,
45
52
  { timeout = 10_000, allowInsecureLocalhost = false, diagnostics = false } = {},
@@ -68,6 +75,7 @@ export class SocketIoTransport {
68
75
  retries: 0,
69
76
  timeout,
70
77
  withCredentials: false,
78
+ auth: { uldaTransfer: 1 },
71
79
  });
72
80
  if (diagnostics) this.#socket.on("ulda:diagnostic", raw => {
73
81
  try {
@@ -78,13 +86,39 @@ export class SocketIoTransport {
78
86
  }
79
87
  });
80
88
  this.#socket.on("disconnect", () => {
89
+ clearTimeout(this.#assemblyTimer); this.#assembly = undefined;
81
90
  this.#trace(() => ({ phase: "connection", event: "disconnect" }));
82
91
  this.#generation++;
83
92
  this.#rejectPending();
84
93
  this.#notify("disconnect", { intentional: this.#intentional || this.#closed });
85
94
  });
95
+ this.#socket.on('ulda:changed.part', raw => {
96
+ try {
97
+ if (raw?.kind === 'begin') {
98
+ exact(raw, ['kind', 'count']);
99
+ check(!this.#assembly, 'INVALID_PACKET');
100
+ this.#assembly = new ChangeAssembly(raw.count);
101
+ this.#assemblyTimer = setTimeout(() => {
102
+ this.#assembly = undefined;
103
+ this.#notify('error', new UldaError('TIMEOUT'));
104
+ }, this.#timeout);
105
+ } else {
106
+ check(this.#assembly, 'INVALID_PACKET');
107
+ if (raw?.kind === 'end') {
108
+ exact(raw, ['kind']);
109
+ const value = changed(this.#assembly.finish(), true);
110
+ clearTimeout(this.#assemblyTimer); this.#assembly = undefined;
111
+ this.#notify('changed', value);
112
+ } else this.#assembly.accept(raw);
113
+ }
114
+ } catch {
115
+ clearTimeout(this.#assemblyTimer); this.#assembly = undefined;
116
+ this.#notify('error', new UldaError('INVALID_PACKET'));
117
+ }
118
+ });
86
119
  this.#socket.on("ulda:changed", (raw) => {
87
120
  try {
121
+ check(!this.#assembly, 'INVALID_PACKET');
88
122
  const value = changed(raw);
89
123
  this.#trace(() => ({
90
124
  phase: "push", event: "ulda:changed",
@@ -178,12 +212,81 @@ export class SocketIoTransport {
178
212
  async send(packet, { volatile = false, signal } = {}) {
179
213
  checkSignal(signal);
180
214
  const { action, body } = outgoing(packet);
181
- requestShape(action, body);
215
+ requestShape(action, body, true);
216
+ if (['create', 'update', 'delete'].includes(action) && needsTransfer(body)) {
217
+ return this.#queued('write', () => this.#writeTransfer(action, body, signal));
218
+ }
219
+ try {
220
+ return await this.#sendWire(action, body, { volatile, signal });
221
+ } catch (error) {
222
+ if (action === 'read' && body.length === 1 && error.code === 'LIMIT') {
223
+ return this.#queued('read', () => this.#readTransfer(body[0], signal));
224
+ }
225
+ if (['create', 'update', 'delete'].includes(action) && error.code === 'LIMIT') {
226
+ return this.#queued('write', () => this.#writeTransfer(action, body, signal));
227
+ }
228
+ throw error;
229
+ }
230
+ }
231
+ #queued(kind, work) {
232
+ check(this.#transfers < LIMITS.queue, 'LIMIT');
233
+ this.#transfers++;
234
+ const generation = this.#generation;
235
+ const task = (kind === 'write' ? this.#writeTail : this.#readTail).then(() => {
236
+ check(generation === this.#generation, 'OFFLINE'); return work();
237
+ });
238
+ const tail = task.then(() => {}, () => {}).finally(() => this.#transfers--);
239
+ if (kind === 'write') this.#writeTail = tail; else this.#readTail = tail;
240
+ return task;
241
+ }
242
+ async #writeTransfer(action, body, signal) {
243
+ let token;
244
+ try {
245
+ token = await this.#sendWire('transfer', { kind: 'begin', action, count: body.length }, { signal }, raw => {
246
+ if (raw?.ok === false) acknowledgement('hello', {}, raw);
247
+ exact(raw, ['ok', 'token']); check(raw.ok === true && /^[a-f0-9]{32}$/.test(raw.token), 'INVALID_PACKET');
248
+ return raw.token;
249
+ });
250
+ for (const frame of fragments(body)) await this.#sendWire('transfer', { ...frame, token }, { signal });
251
+ return await this.#sendWire('transfer', { kind: 'commit', token }, { signal, commit: true, volatile: true }, raw => acknowledgement(action, body, raw, true));
252
+ } catch (error) {
253
+ // No replay. Closing discards any uncommitted staging. A lost commit ACK
254
+ // keeps UNKNOWN_OUTCOME even if the connection is subsequently closed.
255
+ if (token || error.code === 'TIMEOUT' || error.code === 'INVALID_PACKET') this.disconnect();
256
+ throw error;
257
+ }
258
+ }
259
+ async #readTransfer(id, signal) {
260
+ let result, offset = 0;
261
+ try { do {
262
+ const part = await this.#sendWire('transfer', { kind: 'read', resourceId: id, offset }, { signal }, raw => {
263
+ if (raw?.ok === false) acknowledgement('hello', {}, raw);
264
+ exact(raw, ['ok', 'data']); check(raw.ok === true, 'INVALID_PACKET');
265
+ const p = raw.data; exact(p, ['id', 'witness', 'payload', 'total', 'offset']);
266
+ check(p.id === id && p.offset === offset, 'INVALID_PACKET');
267
+ check(Number.isSafeInteger(p.total) && p.total > 0 && p.total <= LIMITS.resourceBytes, 'LIMIT');
268
+ const witness = binary(p.witness, 167), payload = binary(p.payload, LIMITS.jsonBytes);
269
+ check(offset + payload.length <= p.total, 'INVALID_PACKET');
270
+ return { ...p, witness, payload };
271
+ });
272
+ if (!result) result = { id, witness: part.witness.slice(), payload: new Uint8Array(part.total) };
273
+ check(result.payload.length === part.total && equal(result.witness, part.witness), 'INVALID_PACKET');
274
+ result.payload.set(part.payload, offset); offset += part.payload.length;
275
+ } while (offset < result.payload.length);
276
+ return [resource(result)];
277
+ } catch (error) {
278
+ // Release the server's pinned encrypted snapshot on a failed assembly.
279
+ this.disconnect();
280
+ throw error;
281
+ }
282
+ }
283
+ async #sendWire(action, body, { volatile = false, signal, commit = false } = {}, decode = raw => acknowledgement(action, body, raw)) {
284
+ checkSignal(signal);
182
285
  check(!this.#closed, "CLOSED");
183
286
  check(this.connected, "OFFLINE");
184
287
  check(this.#pending.size < LIMITS.queue, "LIMIT");
185
288
  const generation = this.#generation;
186
- const write = ["create", "update", "delete"].includes(action);
289
+ const write = commit || ["create", "update", "delete"].includes(action);
187
290
  const tracing = !!this.#listeners.get("trace")?.size;
188
291
  const request = tracing ? ++this.#request : 0;
189
292
  const started = tracing ? performance.now() : 0;
@@ -214,14 +317,15 @@ export class SocketIoTransport {
214
317
  if (tracing) this.#trace(() => ({
215
318
  phase: "send", event, request, volatile: !!volatile,
216
319
  ...(action === "read" ? { ids: [...body] } : {}),
217
- ...(write ? { items: resourceSummary(body) } : {}),
320
+ ...(write && Array.isArray(body) ? { items: resourceSummary(body) } : {}),
321
+ ...(action === 'transfer' ? { step: body.kind } : {}),
218
322
  }));
219
323
  sender.emit(event, body, (error, raw) => {
220
324
  if (done) return;
221
325
  if (error || generation !== this.#generation)
222
326
  return finish(new UldaError(write ? "UNKNOWN_OUTCOME" : "TIMEOUT"));
223
327
  try {
224
- finish(undefined, acknowledgement(action, body, raw), true);
328
+ finish(undefined, decode(raw), true);
225
329
  } catch (e) {
226
330
  const clean = safeError(e);
227
331
  finish(
@@ -252,6 +356,7 @@ export class SocketIoTransport {
252
356
  close() {
253
357
  if (this.#closed) return;
254
358
  this.#closed = true;
359
+ clearTimeout(this.#assemblyTimer); this.#assembly = undefined;
255
360
  this.#generation++;
256
361
  this.#rejectPending();
257
362
  this.#socket.disconnect();
package/src/values.js CHANGED
@@ -5,7 +5,9 @@ export const LIMITS = Object.freeze({
5
5
  messageBytes: 8_388_608,
6
6
  incoming: 8,
7
7
  batch: 64,
8
- resources: 64,
8
+ // Resource/transfer byte budgets are independent of catalog entry count.
9
+ resourceBytes: 64 * 1_048_576,
10
+ transferBytes: 128 * 1_048_576,
9
11
  queue: 128,
10
12
  depth: 48,
11
13
  nodes: 40_000,
@@ -82,15 +84,17 @@ export function exact(value, keys) {
82
84
  );
83
85
  }
84
86
  // Clone JSON without invoking getters, toJSON or custom prototypes.
85
- export function json(value) {
87
+ export function json(value, catalog = false) {
88
+ const bytesLimit = catalog ? LIMITS.resourceBytes - 4096 : LIMITS.jsonBytes;
89
+ const nodesLimit = catalog ? bytesLimit : LIMITS.nodes;
86
90
  const seen = new Set();
87
91
  let nodes = 0,
88
92
  characters = 0;
89
93
  function visit(v, depth) {
90
- check(++nodes <= LIMITS.nodes && depth <= LIMITS.depth, "LIMIT");
94
+ check(++nodes <= nodesLimit && depth <= LIMITS.depth, "LIMIT");
91
95
  if (typeof v === "string") {
92
96
  characters += v.length;
93
- check(characters <= LIMITS.jsonBytes, "LIMIT");
97
+ check(characters <= bytesLimit, "LIMIT");
94
98
  return v;
95
99
  }
96
100
  if (v === null || typeof v === "boolean") return v;
@@ -109,11 +113,11 @@ export function json(value) {
109
113
  seen.add(v);
110
114
  const result = array ? [] : Object.create(null);
111
115
  const keys = Reflect.ownKeys(v).filter((k) => !(array && k === "length"));
112
- if (array) check(keys.length === v.length && v.length <= LIMITS.nodes);
116
+ if (array) check(keys.length === v.length && v.length <= nodesLimit);
113
117
  for (const key of keys) {
114
118
  check(typeof key === "string");
115
119
  characters += key.length;
116
- check(characters <= LIMITS.jsonBytes, "LIMIT");
120
+ check(characters <= bytesLimit, "LIMIT");
117
121
  if (array) check(/^(0|[1-9][0-9]*)$/.test(key) && Number(key) < v.length);
118
122
  const desc = Object.getOwnPropertyDescriptor(v, key);
119
123
  check(desc.enumerable && Object.hasOwn(desc, "value"));
@@ -128,7 +132,7 @@ export function json(value) {
128
132
  return result;
129
133
  }
130
134
  const result = visit(value, 0);
131
- check(utf8(JSON.stringify(result)).length <= LIMITS.jsonBytes, "LIMIT");
135
+ check(utf8(JSON.stringify(result)).length <= bytesLimit, "LIMIT");
132
136
  return result;
133
137
  }
134
138
  export function pathValue(value, path) {