ulda-auth 1.0.0 → 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/CHANGELOG.md +15 -1
- package/MANIFEST.md +14 -3
- package/README.md +6 -0
- package/SECURITY.md +2 -0
- package/dist/ulda-library.js +1 -1
- package/docs/API.md +5 -1
- package/docs/GUIDE.md +23 -0
- package/docs/LIMITS.md +102 -0
- package/docs/PIPELINES.md +3 -1
- package/docs/PROTOCOL.md +35 -2
- package/package.json +3 -2
- package/src/client.js +35 -25
- package/src/crypto.js +6 -6
- package/src/diagnostics.js +1 -1
- package/src/protocol.js +27 -27
- package/src/transfer.js +80 -0
- package/src/transport.js +110 -5
- package/src/values.js +11 -7
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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,
|
package/src/diagnostics.js
CHANGED
|
@@ -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];
|