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/CHANGELOG.md +8 -0
- 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/src/transfer.js
ADDED
|
@@ -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,
|
|
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
|
-
|
|
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 <=
|
|
94
|
+
check(++nodes <= nodesLimit && depth <= LIMITS.depth, "LIMIT");
|
|
91
95
|
if (typeof v === "string") {
|
|
92
96
|
characters += v.length;
|
|
93
|
-
check(characters <=
|
|
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 <=
|
|
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 <=
|
|
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 <=
|
|
135
|
+
check(utf8(JSON.stringify(result)).length <= bytesLimit, "LIMIT");
|
|
132
136
|
return result;
|
|
133
137
|
}
|
|
134
138
|
export function pathValue(value, path) {
|