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.
package/docs/API.md CHANGED
@@ -121,7 +121,7 @@ Creates a content resource and adds one encrypted entry to the master.
121
121
  { ok: true, name, subscriptionReady }
122
122
  ~~~
123
123
 
124
- The name must not already exist. The master can contain at most 63 content entries. Mode is fixed at creation.
124
+ The name must not already exist. There is no fixed content-entry count limit in a master. Encrypted catalog bytes, operation bytes and the server's admission budgets still apply; see the [manifest](../MANIFEST.md#limits). Mode is fixed at creation.
125
125
 
126
126
  The operation reserves an encrypted empty resource, then sends one atomic Update for the content and master. The reservation is a separate commit. `subscriptionReady` is the transport's connection state at completion, not a permanent delivery guarantee.
127
127
 
@@ -264,3 +264,7 @@ PacketHandle has no public constructor. A plain object supplied to send is not a
264
264
  ## Limits
265
265
 
266
266
  See the [manifest](../MANIFEST.md) for numeric limits and the [security policy](../SECURITY.md) for trust and memory boundaries. Type declarations describe the API but do not replace runtime validation.
267
+
268
+ ## Capacity and compatibility
269
+
270
+ See [Limits and capacity planning](LIMITS.md) for SDK bounds, interacting server budgets, eager-loading costs, atomicity, timeouts and upgrades from 1.0.x.
package/docs/GUIDE.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Usage guide
2
2
 
3
+ ## Large catalogs
4
+
5
+ A master can reference more than 63 contents without changing application calls. Opening or refreshing reads at most 64 content IDs per request, reducing batches if a response is too large. The SDK verifies every required record before publishing the combined local state. Separate batches are not one database snapshot across concurrent writes.
6
+
7
+ Large catalogs need a server implementing the [transfer extension](PROTOCOL.md#large-operation-transfer). Large encrypted resources travel in 1 MiB fragments; a large `superDelete()` uploads all proofs before one atomic commit. Uploading a partial operation never deletes a partial catalog.
8
+
9
+ For workloads whose server commit may exceed the default ten-second ACK deadline, configure a measured timeout, for example:
10
+
11
+ ~~~js
12
+ const ulda = new Ulda(endpoint, { timeout: 60_000 });
13
+ await ulda.connect(masterId, password);
14
+ ~~~
15
+
16
+ This timeout is per transport request, not the time budget for loading the entire catalog. Automatic reconnect has its own overall budget. A timeout after Commit can leave an unknown outcome and never authorizes automatic write replay.
17
+
18
+ There is no numeric catalog-entry quota in the client, but this is not unlimited storage: the encrypted catalog must fit 64 MiB, a logical transfer must fit 128 MiB including item accounting, and the server must have sufficient subscriptions, Router capacity and memory budgets. Application content retains its 1 MiB JSON limit. The client loads/decrypts all listed content eagerly, so opening and refreshing cost time and memory proportional to the data. Nested masters and lazy loading are not implemented.
19
+
20
+ Upgrade the server and all clients using a shared catalog before exceeding the old limits. Existing containers keep their format; older clients retain their old catalog limits.
21
+
3
22
  ## 1. Connect to a compatible server
4
23
 
5
24
  ~~~js
@@ -279,3 +298,7 @@ await ulda.data.notes.done.update(true);
279
298
  ~~~
280
299
 
281
300
  The implementation remains JavaScript. Declarations describe the application's expected shape; they do not add server-side schema validation or validate arbitrary input against your TypeScript type.
301
+
302
+ ## Capacity and compatibility
303
+
304
+ See [Limits and capacity planning](LIMITS.md) for SDK bounds, interacting server budgets, eager-loading costs, atomicity, timeouts and upgrades from 1.0.x.
package/docs/LIMITS.md ADDED
@@ -0,0 +1,102 @@
1
+ # Limits and capacity planning
2
+
3
+ This page describes ULDA Auth 1.1.0. A catalog has no fixed content-entry count cap. Operations still have byte, memory, queue and time limits. These limits protect both endpoints; they are not throughput guarantees.
4
+
5
+ ## SDK bounds
6
+
7
+ | Boundary | Limit |
8
+ |---|---|
9
+ | Application JSON per content | 1,048,576 UTF-8 bytes; depth 48; 40,000 visited nodes |
10
+ | Private catalog JSON | 67,104,768 UTF-8 bytes; depth 48 and a byte-derived node bound |
11
+ | Encrypted resource after reassembly | 67,108,864 bytes (64 MiB) |
12
+ | One logical transfer | 134,217,728 bytes (128 MiB), including 256 bytes of accounting per item |
13
+ | Queued incoming binary data | 128 MiB |
14
+ | Ordinary Read / mutation batch / transfer append | At most 64 items |
15
+ | Ordinary message, combined witness and payload bytes | 8,388,608 bytes (8 MiB) |
16
+ | Binary attachments per packet | 128; each at most 1,100,000 bytes |
17
+ | Large-resource fragment | 1,048,576 bytes (1 MiB) |
18
+ | Decoder text header | 65,536 UTF-8 bytes |
19
+ | Queued incoming handlers | 8 |
20
+ | Jobs per relevant outgoing queue | 128 |
21
+ | Content name | 1–256 UTF-16 code units |
22
+ | Master password input | 1–1024 UTF-16 code units |
23
+ | Resource ID accepted by SDK | Positive decimal string of 1–40 digits, without leading zeroes |
24
+ | Chain age | Unsigned 32-bit integer; no automatic chain rotation at exhaustion |
25
+
26
+ Container overhead, witnesses and catalog metadata consume space. The application JSON maximum does not describe the size of its encrypted wire representation. Bounds are checked together: reaching one maximum does not imply that every other maximum remains available.
27
+
28
+ The standard PostgreSQL server uses positive BIGINT IDs, whose maximum is `9223372036854775807`. The SDK's wider decimal-string validation does not expand that database range.
29
+
30
+ These SDK bounds are currently implementation constants, not constructor options. Changing a server's limits alone does not change the SDK's bounds. Hello does not negotiate these budgets.
31
+
32
+ ## Reading a large catalog
33
+
34
+ 1. Read the master by ID. Reassemble large ciphertext when necessary, then authenticate and decrypt locally.
35
+ 2. Extract content IDs and private credentials locally. Names and credentials never appear as plaintext request fields.
36
+ 3. Read content in groups of at most 64 IDs. Each ordinary request has one ACK containing its result array. Large resources use the transfer extension.
37
+ 4. If the server explicitly rejects the size, reduce the read group. Temporary server saturation is a different error and does not trigger recursive splitting or automatic request replay.
38
+ 5. Verify every required result before replacing the visible local context. A bad final batch must not publish the earlier batches as a partially refreshed catalog.
39
+
40
+ Reads subscribe the current connection to each resource. There is no fixed 63-content cap, no single request containing every ID regardless of size, and no durable server-side download cursor. A failed refresh preserves the last verified local context; the application handles the error and can explicitly refresh again. Separate Read batches are not one database snapshot: concurrent writers can change resources between reads.
41
+
42
+ Loading is eager. All required content is held locally; there is no lazy-loading or nested-master API. Ten thousand contents of 1 MiB each already represent about 10 GiB of plaintext before temporary copies and encryption buffers. A catalog with many small entries and a catalog with the same number of large contents have very different memory costs.
43
+
44
+ ## Mutations and atomicity
45
+
46
+ Changing one field re-encrypts the entire content. Adding, deleting or replacing a catalog link also re-encrypts the entire master. Catalog growth therefore increases the work and traffic of catalog mutations.
47
+
48
+ Large atomic mutations are transmitted as `begin`, `append` / `part`, then `commit`. Staging does not write partial resource changes or hold an open SQL transaction between packets. The server checks the complete operation and commits all resource changes in one transaction. A failed operation must not commit a subset. An expired staging area must be sent again only as a new, explicitly initiated operation; an uncertain commit must not be replayed automatically.
49
+
50
+ `add()` reserves a new ID in one Create, then commits the new content and master together in a second operation. The entire method is **not** one transaction. Interruption can leave an unreferenced resource; there is no automatic orphan garbage collector. `recoverContent()` similarly replaces one master link and intentionally preserves the old resource.
51
+
52
+ `delete()` and `superDelete()` first refresh and verify the current required resources. Missing or damaged content can prevent deletion. A large `superDelete()` remains atomic, but must fit the server's total operation, lock and staging budgets. It is not a sequence of independently committed deletion chunks.
53
+
54
+ X mode permits one pending write per resource. S mode prepares a bounded sequence of local steps: with N=5 the next age must remain less than 5 steps ahead of the confirmed age (at most 4 ahead). S does not merge changes from different clients. Conflicting writes require application handling.
55
+
56
+ ## Server budgets also apply
57
+
58
+ The following are defaults of the companion single-process ULDA server implementing the transfer extension. Operators can configure server admission limits; confirm the actual deployment configuration. Consult that server's `docs/ADMISSION_LIMITS.md` for the complete list.
59
+
60
+ | Boundary | Default / consequence |
61
+ |---|---|
62
+ | Connections | 1,000; configurable admission ceiling, not measured capacity |
63
+ | Subscription edges | 100,000 globally; 32,768 per connection |
64
+ | Router keys | 32,768 globally; at most 100 waiting jobs per key |
65
+ | Incoming copied bytes | 64 MiB globally |
66
+ | Active read reservations | 256 MiB globally |
67
+ | Outbound bytes | 64 MiB globally; 16 MiB per connection |
68
+ | Staging | 256 MiB globally; accounts for `3 × binary bytes + 256 × item count` |
69
+ | One resource / logical operation | 64 MiB / 128 MiB |
70
+ | Transfer lifetime | 60 seconds absolute by default; not renewed indefinitely by progress |
71
+ | Active transfers | One staged upload and one large read per connection |
72
+ | Waiting commands | 64 per connection |
73
+ | Command rate | 1,000/s with burst 2,000 per decoder; not an authentication or per-IP quota |
74
+
75
+ Examples of interacting limits:
76
+
77
+ - A fresh connection needs one subscription for the master and one per content. With the default per-connection ceiling, 32,767 contents is the theoretical maximum for that one connection, before other constraints. This is a **server admission consequence**, not a new SDK catalog-count constant.
78
+ - One hundred connections opening 10,000 contents each need over one million subscription edges, exceeding the default global 100,000 budget.
79
+ - Deleting all resources requires Router capacity for the whole set. Existing unrelated work can reduce available capacity. Transmission in fragments does not remove this requirement.
80
+ - Staging's copy accounting can reject an operation below the logical 128 MiB maximum. Those two maxima are not interchangeable.
81
+ - A valid encrypted resource can exceed the per-connection outbound queue during Changed fan-out. The server can disconnect a slow or over-budget recipient; reconnect and a verified read recover its state. Changed is not a durable message log.
82
+ - The server reserves read memory before database work. Temporary contention waits in a bounded FIFO (default 5 seconds). Exhaustion returns an error; the SDK does not turn saturation into more and smaller requests. A read that cannot fit at all is a size rejection.
83
+
84
+ Subscriptions are looked up by resource and connection. The current server uses two Maps containing arrays; membership checks and removal scan the relevant list. This avoids scanning every connected client for every update, but fan-out and large subscriber lists still have a cost.
85
+
86
+ ## Timeouts and recovery
87
+
88
+ The default SDK timeout is 10 seconds per awaited transport operation, configurable from 50 to 60,000 ms. It is not a deadline for the entire multi-request `connect()` or `superDelete()`. Increasing it does not increase server transfer lifetime, SQL deadlines or memory budgets.
89
+
90
+ Automatic reconnect defaults to at most five attempts within 30 seconds, with delays from 500 to 5,000 ms and jitter. It rereads and verifies state; it does not replay writes. Initial connection failure is not automatically retried in the same way as a lost established connection.
91
+
92
+ A lost or invalid write ACK can mean `UNKNOWN_OUTCOME`: the server may have committed. Volatile emission is not a delivery guarantee. A lost Create ACK has no built-in ID-recovery service. Save master IDs through the application and handle unknown outcomes explicitly; see [recovery](RECOVERY.md) and [API errors](API.md).
93
+
94
+ Refresh/reconnect may accept a newer authenticated snapshot without proving every missed chain transition. The server's ID-only deletion notifications are trusted. Secrets are private to SDK internals by API design, but this does not protect them against malicious code controlling the same page or process. See [security](../SECURITY.md).
95
+
96
+ ## Compatibility and upgrades
97
+
98
+ Version 1.1.0 preserves public method signatures, encrypted container format and the core dependency. It adds bounded multi-request reading and the large-operation transfer extension. A server must implement that extension for large resources or operations; upgrading the SDK alone is insufficient.
99
+
100
+ Version 1.0.1 and earlier enforce the old small-catalog bound and cannot open catalogs that exceed it. Upgrade the server and **all clients** before allowing catalogs to grow beyond old limits. There is no automatic catalog sharding or conversion into nested masters.
101
+
102
+ Capacity measurements apply to their stated hardware, payloads, concurrency and transport conditions. A successful small-content load test does not establish performance for large catalogs, registration KDFs, shared hot resources, high fan-out or WAN/TLS traffic.
package/docs/PIPELINES.md CHANGED
@@ -142,7 +142,8 @@ Refresh does not lock the server. A concurrent writer can still cause conflict a
142
142
  ~~~text
143
143
  acquire exclusive guard -> refresh all listed resources
144
144
  prepare a next-witness deletion proof for each content and the master
145
- check guards -> one Delete batch
145
+ check guards -> one logical Delete operation
146
+ if larger than one frame: begin -> append proof groups -> commit
146
147
  server validates and deletes all or none
147
148
  on valid ACK: close SDK and release the context
148
149
  ~~~
@@ -152,6 +153,7 @@ A missing or invalid required resource stops preflight. The method cannot discov
152
153
  ## Incoming Changed
153
154
 
154
155
  ~~~text
156
+ if fragmented: bounded begin/append/part/end assembly -> one complete event
155
157
  validate event shape and limits -> enqueue in the bounded incoming sequence
156
158
  stage known resource updates; authenticate/decrypt and verify lineage
157
159
  if master changed: validate its catalog and resolve newly referenced content by Read
package/docs/PROTOCOL.md CHANGED
@@ -194,12 +194,45 @@ Live Changed requires valid lineage. Explicit refresh/reconnect may accept a new
194
194
 
195
195
  Oversized Read requests may be split into smaller arrays. Each reply must still match its request. The SDK publishes the combined snapshot only after all required records pass verification. Separate reads do not provide one global database snapshot across concurrent changes.
196
196
 
197
- Atomic Update/Delete batches are never split. The supported limits are in the [manifest](../MANIFEST.md).
197
+ Atomic Update/Delete operations are never split into separate commits. Their transport frames may be split using the extension below. The supported limits are in the [manifest](../MANIFEST.md).
198
198
 
199
- No operation IDs, offline replay, ACL fields, CRDT merge or password-reset protocol are supplied by this contract. A compatible implementation in another language must preserve the byte format, validation rules, atomicity and failure semantics.
199
+ No durable operation IDs, offline replay, ACL fields, CRDT merge or password-reset protocol are supplied by this contract. A compatible implementation in another language must preserve the byte format, validation rules, atomicity and failure semantics.
200
+
201
+ ## Large-operation transfer
202
+
203
+ The transport advertises `auth: { uldaTransfer: 1 }` in the Socket.IO handshake. This is a framing capability, not authentication or a resource role. Ordinary CRUD shapes remain unchanged. A transfer-capable server is required once an operation exceeds those limits; older servers must not be assumed to support large catalogs.
204
+
205
+ Content reads use arrays of at most 64 IDs, sequentially. For 300 entries this means five requests, not 300 individual reads. An explicit `too_large` response causes the SDK to bisect a read group. A missing or malformed reply is not treated as a successful partial result. There is no global database snapshot across these requests.
206
+
207
+ All extension commands use the `ulda:transfer` event and one ACK callback:
208
+
209
+ | Body | Success ACK / meaning |
210
+ |---|---|
211
+ | `{kind:'begin', action:'create'\|'update'\|'delete', count}` | `{ok:true, token}`; reserve an in-memory operation, no database mutation |
212
+ | `{kind:'append', token, items}` | `{ok:true}`; append up to 64 ordinary action entries within 8 MiB |
213
+ | `{kind:'part', token, resourceId?, witness, total, offset, payload}` | `{ok:true}`; append at most 1 MiB of one encrypted resource; omit resourceId only for Create |
214
+ | `{kind:'commit', token}` | Original action's success ACK, only after the complete atomic commit |
215
+ | `{kind:'cancel', token}` | `{ok:true}`; discard an uncommitted upload |
216
+ | `{kind:'read', resourceId, offset}` | `{ok:true, data:{id,witness,payload,total,offset}}`; at most 1 MiB from a pinned encrypted snapshot |
217
+
218
+ Tokens are random 32-character lowercase hexadecimal strings scoped to one connection. They are temporary staging references, not persisted resource metadata or idempotency keys. Each connection permits one staged write and one large read. All offsets and totals are byte counts. Parts for a resource are contiguous, starting at zero, with an unchanged ID, witness and total. No plaintext catalog names, credentials or origins are added.
219
+
220
+ The server checks staging bytes and deadlines before retaining complete data. On Commit it holds every affected resource key, validates all candidate witnesses against current storage, then makes one atomic storage call. A conflict in the last validation group prevents every mutation. No database transaction is held open while the client uploads frames. A disconnect or deadline discards uncommitted staging; a Commit already accepted may finish after disconnect.
221
+
222
+ The SDK never replays an uncertain mutation. A lost/malformed Commit ACK yields `UNKNOWN_OUTCOME`, even if storage committed. A pre-Commit timeout cannot itself commit staged bytes. The transport disconnects after an interrupted transfer to clear staging. A definite `too_large` rejection of an ordinary write may select staged framing; it is not a replay of an uncertain write.
223
+
224
+ A singleton Read rejected as too large selects `kind:'read'`. Offset zero pins one bounded ciphertext snapshot; subsequent offsets continue that same version. The server releases it after the final fragment, expiry or disconnect. The SDK requires a complete matching ID/witness/total, then runs ordinary container and cryptographic validation before exposing anything.
225
+
226
+ Large notifications use ordered `ulda:changed.part` frames without ACKs: `{kind:'begin',count}`, followed by `{kind:'append',items}` and/or `{kind:'part',resourceId,witness,total,offset,payload}`, then `{kind:'end'}`. Append items are updated `{id,witness,payload}` records or deleted `{id}` entries. The client reconstructs and validates the complete logical event before publishing it. Partial, oversized, out-of-order or timed-out assemblies cannot publish partial data.
227
+
228
+ Servers use ordinary Changed whenever it fits. A legacy recipient that cannot decode a larger change is disconnected rather than left silently stale. Upgrade the server and every client sharing a catalog before growing it beyond the old limits. Existing encrypted records require no format migration; an older SDK still enforces its old catalog limits.
200
229
 
201
230
  ## Optional diagnostics
202
231
 
203
232
  A development server may emit `ulda:diagnostic` with `{ action, step, ids, count }`. This is outside CRUD and requires no ACK. The standard transport reads it only when configured with `diagnostics: true`.
204
233
 
205
234
  Only recognized action/step codes and bounded metadata are accepted. Invalid diagnostic input is ignored. Trace timestamps, request sequence numbers and durations are local diagnostics, not fields added to CRUD or server resource records.
235
+
236
+ ## Capacity and compatibility
237
+
238
+ See [Limits and capacity planning](LIMITS.md) for SDK bounds, interacting server budgets, eager-loading costs, atomicity, timeouts and upgrades from 1.0.x.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ulda-auth",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Client SDK for encrypted JSON resources over ULDA and Socket.IO",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  "docs/RECOVERY.md",
34
34
  "docs/SECRET_LIFECYCLE.md",
35
35
  "docs/LICENSING.md",
36
- "docs/GUIDE.md"
36
+ "docs/GUIDE.md",
37
+ "docs/LIMITS.md"
37
38
  ],
38
39
  "engines": {
39
40
  "node": ">=22"
package/src/client.js CHANGED
@@ -45,6 +45,7 @@ export class Ulda {
45
45
  #sentCandidates = new Map();
46
46
  #inbound = Promise.resolve();
47
47
  #inboundCount = 0;
48
+ #inboundBytes = 0;
48
49
  #masterTail = Promise.resolve();
49
50
  #masterTasks = 0;
50
51
  #generation = 0;
@@ -88,10 +89,13 @@ export class Ulda {
88
89
  });
89
90
  this.#transportOff.push(this.#transport.on("changed", (event) => {
90
91
  const epoch = this.#connectionEpoch;
91
- if (this.#inboundCount >= LIMITS.incoming)
92
+ const bytes = event.updated.reduce((n, x) => n + x.witness.length + x.payload.length + 256, event.deleted.length * 256);
93
+ if (this.#inboundCount >= LIMITS.incoming || this.#inboundBytes + bytes > LIMITS.transferBytes)
92
94
  return this.#fault(new UldaError("LIMIT"));
95
+ this.#inboundBytes += bytes;
93
96
  this.#serial(() => epoch === this.#connectionEpoch ? this.#changed(event) : undefined)
94
- .catch((e) => { if (epoch === this.#connectionEpoch) this.#fault(e); });
97
+ .catch((e) => { if (epoch === this.#connectionEpoch) this.#fault(e); })
98
+ .finally(() => { this.#inboundBytes -= bytes; });
95
99
  }));
96
100
  this.#transportOff.push(this.#transport.on("disconnect", (event) => {
97
101
  this.#connectionEpoch++;
@@ -154,7 +158,7 @@ export class Ulda {
154
158
  if (!this.#listeners.get("trace")?.size) return;
155
159
  try {
156
160
  this.#emit("trace", Object.freeze({ time: Date.now(), phase: "client", event: step,
157
- ...stepRecord(CLIENT_STEPS, step, ids, count) }));
161
+ ...stepRecord(CLIENT_STEPS, step, ids.slice(0, LIMITS.batch), count) }));
158
162
  } catch { /* Diagnostic callbacks never change operation results. */ }
159
163
  }
160
164
  async #openRecord(credentials, record, token, kind = "content") {
@@ -163,7 +167,7 @@ export class Ulda {
163
167
  // The role comes only from the client's decrypted catalog / chosen login ID.
164
168
  this.#trace(`${kind}.decrypt`, [record.id]);
165
169
  this.#guard(generation);
166
- const result = await open(credentials, record, token);
170
+ const result = await open(credentials, record, token, kind === "master");
167
171
  try { this.#guard(generation); }
168
172
  catch (error) { result.origin.fill(0); throw error; }
169
173
  this.#trace(`${kind}.opened`, [record.id]);
@@ -250,7 +254,7 @@ export class Ulda {
250
254
  #catalog(data) {
251
255
  exact(data, ["contents"]);
252
256
  check(
253
- Array.isArray(data.contents) && data.contents.length < LIMITS.resources,
257
+ Array.isArray(data.contents),
254
258
  "LIMIT",
255
259
  );
256
260
  const names = new Set(),
@@ -277,11 +281,12 @@ export class Ulda {
277
281
  const generation = this.#generation;
278
282
  this.#guard(generation);
279
283
  this.#trace("write.prepare", [base.id]);
280
- const data = json(change(json(base.data)));
284
+ const catalog = base.id === this.#root?.id;
285
+ const data = json(change(json(base.data, catalog)), catalog);
281
286
  const origin = advance(base.origin);
282
287
  try {
283
288
  this.#trace("chain.advance", [base.id]);
284
- const sealed = await seal(credentials, base.token, origin, data);
289
+ const sealed = await seal(credentials, base.token, origin, data, catalog);
285
290
  this.#guard(generation);
286
291
  this.#trace("write.sealed", [base.id]);
287
292
  const wire = sealedWire(sealed);
@@ -505,23 +510,29 @@ export class Ulda {
505
510
  const map = new Map();
506
511
  if (!entries.length) return map;
507
512
  this.#trace("content.read", entries.map(x => x.id), entries.length);
508
- const records = await this.#readRecords(entries.map((x) => x.id), signal);
509
- for (const entry of entries) {
510
- checkSignal(signal);
511
- const record = records.find((x) => x.id === entry.id);
512
- const credentials = await contentCredentials(entry.key, envelope(record.payload).salt);
513
- this.#trace("content.key", [entry.id]);
514
- const state = {
515
- id: entry.id,
516
- ...(await this.#openRecord(credentials, record, entry.token)),
517
- };
518
- await this.#checkProgress(
519
- this.#resources.get(entry.id)?.state,
520
- state,
521
- fresh,
522
- );
523
- map.set(entry.id, { state, credentials });
524
- this.#trace("content.staged", [entry.id]);
513
+ // Read/decrypt bounded batches; only publish the complete verified map.
514
+ try {
515
+ for (let start = 0; start < entries.length; start += LIMITS.batch) {
516
+ const group = entries.slice(start, start + LIMITS.batch);
517
+ const records = new Map((await this.#readRecords(group.map(x => x.id), signal)).map(r => [r.id, r]));
518
+ for (const entry of group) {
519
+ checkSignal(signal);
520
+ const record = records.get(entry.id);
521
+ const credentials = await contentCredentials(entry.key, envelope(record.payload).salt);
522
+ this.#trace("content.key", [entry.id]);
523
+ const state = {
524
+ id: entry.id,
525
+ ...(await this.#openRecord(credentials, record, entry.token)),
526
+ };
527
+ try { await this.#checkProgress(this.#resources.get(entry.id)?.state, state, fresh); }
528
+ catch (error) { state.origin.fill(0); throw error; }
529
+ map.set(entry.id, { state, credentials });
530
+ this.#trace("content.staged", [entry.id]);
531
+ }
532
+ }
533
+ } catch (error) {
534
+ for (const resource of map.values()) resource.state.origin.fill(0);
535
+ throw error;
525
536
  }
526
537
  return map;
527
538
  }
@@ -616,7 +627,6 @@ export class Ulda {
616
627
  !this.#root.data.contents.some((x) => x.name === content),
617
628
  "EXISTS",
618
629
  );
619
- check(this.#root.data.contents.length + 1 < LIMITS.resources, "LIMIT");
620
630
  let pending = this.#adds.get(content);
621
631
  if (pending)
622
632
  check(
package/src/crypto.js CHANGED
@@ -103,7 +103,7 @@ function checkToken(token) {
103
103
  export const newContentSecret = randomSecret;
104
104
  export const newToken = randomSecret;
105
105
  export function envelope(payload) {
106
- const bytes = binary(payload);
106
+ const bytes = binary(payload, LIMITS.resourceBytes);
107
107
  check(
108
108
  bytes.length >= 48 && equal(bytes.subarray(0, 4), MAGIC),
109
109
  "INVALID_PACKET",
@@ -116,7 +116,7 @@ export function envelope(payload) {
116
116
  header: bytes.subarray(0, 20),
117
117
  };
118
118
  }
119
- export async function seal(credentials, token, origin, data) {
119
+ export async function seal(credentials, token, origin, data, catalog = false) {
120
120
  checkToken(token);
121
121
  const witness = await sign(origin);
122
122
  check(credentials.salt instanceof Uint8Array && credentials.salt.length === 16);
@@ -126,11 +126,11 @@ export async function seal(credentials, token, origin, data) {
126
126
  JSON.stringify({
127
127
  token,
128
128
  origin: b64(origin),
129
- data: json(data),
129
+ data: json(data, catalog),
130
130
  }),
131
131
  );
132
132
  try {
133
- check(plaintext.length <= LIMITS.payloadBytes - 48, "LIMIT");
133
+ check(plaintext.length <= (catalog ? LIMITS.resourceBytes : LIMITS.payloadBytes) - 48, "LIMIT");
134
134
  const cipher = new Uint8Array(
135
135
  await webcrypto().encrypt(
136
136
  {
@@ -165,7 +165,7 @@ export function sealedWire(handle) {
165
165
  const { witness, payload } = sealed.get(handle);
166
166
  return Object.freeze({ witness: witness.slice(), payload: payload.slice() });
167
167
  }
168
- export async function open(credentials, record, expectedToken) {
168
+ export async function open(credentials, record, expectedToken, catalog = false) {
169
169
  let plaintext, origin, accepted = false;
170
170
  try {
171
171
  const witness = binary(record.witness, 167);
@@ -196,7 +196,7 @@ export async function open(credentials, record, expectedToken) {
196
196
  const result = {
197
197
  origin,
198
198
  token: decoded.token,
199
- data: json(decoded.data),
199
+ data: json(decoded.data, catalog),
200
200
  witness,
201
201
  record: Object.freeze({
202
202
  id: record.id,
@@ -23,7 +23,7 @@ export const SERVER_STEPS = new Set([
23
23
  export function stepRecord(steps, step, resourceIds = [], count = 0) {
24
24
  check(steps.has(step), 'INVALID_PACKET');
25
25
  check(Array.isArray(resourceIds) && resourceIds.length <= LIMITS.batch, 'INVALID_PACKET');
26
- check(Number.isSafeInteger(count) && count >= 0 && count <= LIMITS.batch, 'INVALID_PACKET');
26
+ check(Number.isSafeInteger(count) && count >= 0 && (steps === CLIENT_STEPS || count <= LIMITS.batch), 'INVALID_PACKET');
27
27
  return Object.freeze({ step, ids: Object.freeze(resourceIds.map(id)), count });
28
28
  }
29
29
  export function serverDiagnostic(raw) {
package/src/protocol.js CHANGED
@@ -18,23 +18,23 @@ const put = (action, body) => {
18
18
  packets.set(p, { action, body });
19
19
  return p;
20
20
  };
21
- function array(items) {
21
+ function array(items, extended = false) {
22
22
  check(
23
- Array.isArray(items) && items.length > 0 && items.length <= LIMITS.batch,
23
+ Array.isArray(items) && items.length > 0 && items.length <= (extended ? Math.floor(LIMITS.transferBytes / 256) : LIMITS.batch),
24
24
  "INVALID_PACKET",
25
25
  );
26
26
  }
27
- function ids(items) {
28
- array(items);
27
+ function ids(items, extended = false) {
28
+ array(items, extended);
29
29
  items.forEach(id);
30
30
  check(new Set(items).size === items.length, "INVALID_PACKET");
31
31
  }
32
- export function messageSize(items) {
33
- let size = 0;
32
+ export function messageSize(items, extended = false) {
33
+ let size = extended ? items.length * 256 : 0;
34
34
  for (const item of items) {
35
35
  size += binary(item?.witness, 167).byteLength;
36
- if (Object.hasOwn(item, "payload")) size += binary(item.payload).byteLength;
37
- check(size <= LIMITS.messageBytes, "LIMIT");
36
+ if (Object.hasOwn(item, "payload")) size += binary(item.payload, extended ? LIMITS.resourceBytes : LIMITS.payloadBytes).byteLength;
37
+ check(size <= (extended ? LIMITS.transferBytes : LIMITS.messageBytes), "LIMIT");
38
38
  }
39
39
  }
40
40
  export function emptyPacket(action) {
@@ -47,8 +47,8 @@ export function readPacket(resourceIds) {
47
47
  }
48
48
  export function writePacket(action, items) {
49
49
  check(action === "create" || action === "update");
50
- array(items);
51
- if (action === "update") ids(items.map((x) => x.id));
50
+ array(items, true);
51
+ if (action === "update") ids(items.map((x) => x.id), true);
52
52
  const body = items.map((item) =>
53
53
  action === "create"
54
54
  ? { ...sealedWire(item.sealed) }
@@ -66,16 +66,16 @@ export async function deletionProof(resourceId, origin) {
66
66
  return handle;
67
67
  }
68
68
  export function deletePacket(proofs, updates = []) {
69
- array(proofs);
69
+ array(proofs, true);
70
70
  const body = proofs.map((proof) => {
71
71
  check(deletionProofs.has(proof), "INVALID_PACKET");
72
72
  return { ...deletionProofs.get(proof) };
73
73
  });
74
74
  check(Array.isArray(updates), "INVALID_PACKET");
75
75
  for (const item of updates) body.push({ id: id(item.id), ...sealedWire(item.sealed) });
76
- array(body);
77
- messageSize(body);
78
- ids(body.map((x) => x.id));
76
+ array(body, true);
77
+ messageSize(body, true);
78
+ ids(body.map((x) => x.id), true);
79
79
  return put("delete", body);
80
80
  }
81
81
  export function outgoing(packet) {
@@ -86,7 +86,7 @@ export function outgoing(packet) {
86
86
  typeof item === "string" ? item : {
87
87
  ...(item.id === undefined ? {} : { id: item.id }),
88
88
  witness: binary(item.witness, 167).slice(),
89
- ...(item.payload === undefined ? {} : { payload: binary(item.payload).slice() }),
89
+ ...(item.payload === undefined ? {} : { payload: binary(item.payload, LIMITS.resourceBytes).slice() }),
90
90
  }) : {} };
91
91
  }
92
92
  export function resource(value) {
@@ -97,10 +97,10 @@ export function resource(value) {
97
97
  return Object.freeze({
98
98
  id: value.id,
99
99
  witness: binary(value.witness, 167).slice(),
100
- payload: binary(value.payload).slice(),
100
+ payload: binary(value.payload, LIMITS.resourceBytes).slice(),
101
101
  });
102
102
  }
103
- export function requestShape(action, body) {
103
+ export function requestShape(action, body, extended = false) {
104
104
  check(actions.has(action), "INVALID_PACKET");
105
105
  if (action === "hello" || action === "session.close") {
106
106
  exact(body, []);
@@ -110,13 +110,13 @@ export function requestShape(action, body) {
110
110
  ids(body);
111
111
  return;
112
112
  }
113
- array(body);
114
- if (action !== "delete") messageSize(body);
113
+ array(body, extended);
114
+ if (action !== "delete") messageSize(body, extended);
115
115
  else {
116
116
  check(body.some(item => !Object.hasOwn(item, "payload")), "INVALID_PACKET");
117
- messageSize(body);
117
+ messageSize(body, extended);
118
118
  }
119
- if (action !== "create") ids(body.map((x) => x.id));
119
+ if (action !== "create") ids(body.map((x) => x.id), extended);
120
120
  body.forEach((x) => {
121
121
  exact(
122
122
  x,
@@ -130,7 +130,7 @@ export function requestShape(action, body) {
130
130
  if (action !== "delete" || Object.hasOwn(x, "payload")) envelope(x.payload);
131
131
  });
132
132
  }
133
- export function acknowledgement(action, body, raw) {
133
+ export function acknowledgement(action, body, raw, extended = false) {
134
134
  check(
135
135
  raw && typeof raw === "object" && !Array.isArray(raw),
136
136
  "INVALID_PACKET",
@@ -158,10 +158,10 @@ export function acknowledgement(action, body, raw) {
158
158
  check(raw.ok === true, "INVALID_PACKET");
159
159
  if (action === "read" || action === "create") {
160
160
  exact(raw, ["ok", "data"]);
161
- array(raw.data);
161
+ array(raw.data, extended);
162
162
  check(raw.data.length === body.length, "INVALID_PACKET");
163
163
  if (action === "create") {
164
- ids(raw.data);
164
+ ids(raw.data, extended);
165
165
  return [...raw.data];
166
166
  }
167
167
  messageSize(raw.data);
@@ -176,7 +176,7 @@ export function acknowledgement(action, body, raw) {
176
176
  exact(raw, ["ok"]);
177
177
  return undefined;
178
178
  }
179
- export function changed(raw) {
179
+ export function changed(raw, extended = false) {
180
180
  check(raw && typeof raw === "object", "INVALID_PACKET");
181
181
  exact(
182
182
  raw,
@@ -187,10 +187,10 @@ export function changed(raw) {
187
187
  check(
188
188
  Array.isArray(inputUpdated) &&
189
189
  Array.isArray(inputDeleted) &&
190
- inputUpdated.length + inputDeleted.length <= LIMITS.batch,
190
+ inputUpdated.length + inputDeleted.length <= (extended ? Math.floor(LIMITS.transferBytes / 256) : LIMITS.batch),
191
191
  "INVALID_PACKET",
192
192
  );
193
- messageSize(inputUpdated);
193
+ messageSize(inputUpdated, extended);
194
194
  const updated = inputUpdated.map(resource),
195
195
  deleted = inputDeleted.map(id);
196
196
  const all = [...updated.map((x) => x.id), ...deleted];