@openlfcp/shared-objects 0.2.1 → 0.3.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/README.md +63 -0
- package/dist/admission/canonical.d.ts +6 -0
- package/dist/admission/canonical.js +19 -11
- package/dist/admission/framing.js +49 -2
- package/dist/admission/references.js +4 -1
- package/dist/data-profile.d.ts +18 -1
- package/dist/data-profile.js +27 -7
- package/dist/identity/admission.d.ts +22 -0
- package/dist/identity/admission.js +280 -0
- package/dist/identity/index.d.ts +6 -0
- package/dist/identity/index.js +6 -0
- package/dist/identity/reader.d.ts +14 -0
- package/dist/identity/reader.js +44 -0
- package/dist/identity/replica.d.ts +112 -0
- package/dist/identity/replica.js +384 -0
- package/dist/identity/schema.d.ts +22 -0
- package/dist/identity/schema.js +271 -0
- package/dist/identity/values.d.ts +56 -0
- package/dist/identity/values.js +41 -0
- package/dist/index.d.ts +1 -1
- package/dist/listeners.d.ts +28 -0
- package/dist/listeners.js +93 -0
- package/dist/replica.js +6 -6
- package/dist/sections/data-profile.d.ts +18 -1
- package/dist/sections/data-profile.js +27 -6
- package/dist/sections/dispatch.d.ts +5 -1
- package/dist/sections/dispatch.js +14 -1
- package/dist/sections/index.d.ts +1 -1
- package/dist/sections/replica.js +3 -4
- package/dist/sections/tree.js +2 -1
- package/package.json +7 -3
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { UINT64_MAX } from "@openlfcp/core";
|
|
2
|
+
import { pointerToken } from "../validate.js";
|
|
3
|
+
import { isPrincipalRef, isReverseDomain, isUtcTimestamp, principalRef } from "../values.js";
|
|
4
|
+
import { IdentityReader } from "./reader.js";
|
|
5
|
+
import { IDENTITY_PROFILE_ID, isIdentityDescriptor, isResourceRef, } from "./values.js";
|
|
6
|
+
export const IDENTITY_DIAGNOSTIC_ORDER = Object.freeze([
|
|
7
|
+
"INVALID_ROOT",
|
|
8
|
+
"MISSING_REQUIRED_FIELD",
|
|
9
|
+
"INVALID_FIELD_TYPE",
|
|
10
|
+
"INVALID_RESOURCE_REF",
|
|
11
|
+
"INVALID_PRINCIPAL_REF",
|
|
12
|
+
"INVALID_DESCRIPTOR",
|
|
13
|
+
"INVALID_ENUM_VALUE",
|
|
14
|
+
"INVALID_TIMESTAMP",
|
|
15
|
+
"INVALID_EXTENSION_NAMESPACE",
|
|
16
|
+
]);
|
|
17
|
+
const at = (base, key) => `${base}/${pointerToken(String(key))}`;
|
|
18
|
+
const uint = ([type, value]) => type === "uint" &&
|
|
19
|
+
((typeof value === "number" && Number.isSafeInteger(value) && value >= 0) ||
|
|
20
|
+
(typeof value === "bigint" && value >= 0n && value <= UINT64_MAX));
|
|
21
|
+
const epochOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
22
|
+
const textOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
23
|
+
class Fields {
|
|
24
|
+
reader;
|
|
25
|
+
problems = new Map();
|
|
26
|
+
conflicts = new Set();
|
|
27
|
+
constructor(reader) {
|
|
28
|
+
this.reader = reader;
|
|
29
|
+
}
|
|
30
|
+
fail(diagnostic, pointer) {
|
|
31
|
+
const previous = this.problems.get(pointer);
|
|
32
|
+
if (previous !== undefined &&
|
|
33
|
+
IDENTITY_DIAGNOSTIC_ORDER.indexOf(previous.diagnostic) <=
|
|
34
|
+
IDENTITY_DIAGNOSTIC_ORDER.indexOf(diagnostic))
|
|
35
|
+
return;
|
|
36
|
+
this.problems.set(pointer, Object.freeze({
|
|
37
|
+
code: "PROFILE_INVALID",
|
|
38
|
+
diagnostic,
|
|
39
|
+
pointer,
|
|
40
|
+
message: `IDENTITY-PROFILE-01 §4: ${diagnostic} at ${pointer || "/"}`,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
field(obj, key, base, required, request) {
|
|
44
|
+
const values = this.reader.all(obj, key);
|
|
45
|
+
if (required && values.length === 0)
|
|
46
|
+
this.fail("MISSING_REQUIRED_FIELD", at(base, key));
|
|
47
|
+
if (request && values.length > 1)
|
|
48
|
+
this.conflicts.add(at(base, key));
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
finishEntry(base) {
|
|
52
|
+
const belongs = (p) => p === base || p.startsWith(`${base}/`);
|
|
53
|
+
// §4 rule 2: report invalid values first. A conflict is INVALID_FIELD_TYPE
|
|
54
|
+
// only when every concurrent value is valid on its own (including maps).
|
|
55
|
+
if ([...this.problems.keys()].some(belongs))
|
|
56
|
+
return;
|
|
57
|
+
for (const pointer of this.conflicts)
|
|
58
|
+
if (belongs(pointer))
|
|
59
|
+
this.fail("INVALID_FIELD_TYPE", pointer);
|
|
60
|
+
}
|
|
61
|
+
scalar(values, type, pointer, validate, diagnostic = "INVALID_FIELD_TYPE") {
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
if (value[0] !== type)
|
|
64
|
+
this.fail("INVALID_FIELD_TYPE", pointer);
|
|
65
|
+
else if (validate !== undefined && !validate(value[1]))
|
|
66
|
+
this.fail(diagnostic, pointer);
|
|
67
|
+
}
|
|
68
|
+
return values[0]?.[1];
|
|
69
|
+
}
|
|
70
|
+
list(values, pointer, type, request, resource = false) {
|
|
71
|
+
const lists = [];
|
|
72
|
+
for (const [kind, id] of values) {
|
|
73
|
+
if (kind !== "list") {
|
|
74
|
+
this.fail("INVALID_FIELD_TYPE", pointer);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const result = [];
|
|
78
|
+
for (const [i, entries] of this.reader.list(String(id)).entries()) {
|
|
79
|
+
const pos = at(pointer, i);
|
|
80
|
+
if (request && entries.length > 1)
|
|
81
|
+
this.conflicts.add(pos);
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry[0] !== type || (type === "uint" && !uint(entry)))
|
|
84
|
+
this.fail("INVALID_FIELD_TYPE", pos);
|
|
85
|
+
else if (resource && !isResourceRef(entry[1]))
|
|
86
|
+
this.fail("INVALID_RESOURCE_REF", pos);
|
|
87
|
+
}
|
|
88
|
+
result.push(entries[0]?.[1]);
|
|
89
|
+
}
|
|
90
|
+
if (resource &&
|
|
91
|
+
(result.length === 0 || result.length > 64 || new Set(result).size !== result.length))
|
|
92
|
+
this.fail("INVALID_FIELD_TYPE", pointer);
|
|
93
|
+
lists.push(result);
|
|
94
|
+
}
|
|
95
|
+
return lists[0] ?? [];
|
|
96
|
+
}
|
|
97
|
+
entry(obj, key, table) {
|
|
98
|
+
const base = at(`/${table}`, key);
|
|
99
|
+
const request = table === "requests";
|
|
100
|
+
const roster = table === "roster";
|
|
101
|
+
const output = {};
|
|
102
|
+
if (roster ? !isResourceRef(key) : !isPrincipalRef(key))
|
|
103
|
+
this.fail(roster ? "INVALID_RESOURCE_REF" : "INVALID_PRINCIPAL_REF", base);
|
|
104
|
+
const required = roster
|
|
105
|
+
? ["profile", "endpoints", "coordinator"]
|
|
106
|
+
: request
|
|
107
|
+
? ["descriptor", "pairing", "label", "kind"]
|
|
108
|
+
: ["label", "kind"];
|
|
109
|
+
const strings = roster
|
|
110
|
+
? ["profile", "coordinator", "name"]
|
|
111
|
+
: request
|
|
112
|
+
? ["descriptor", "pairing", "label", "platform", "requested_at"]
|
|
113
|
+
: ["label", "platform", "added_at"];
|
|
114
|
+
const field = (name) => this.field(obj, name, base, required.includes(name), request);
|
|
115
|
+
for (const name of strings) {
|
|
116
|
+
const values = field(name);
|
|
117
|
+
if (values.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
const pointer = at(base, name);
|
|
120
|
+
output[name] = this.scalar(values, "str", pointer);
|
|
121
|
+
for (const [type, value] of values) {
|
|
122
|
+
if (type !== "str")
|
|
123
|
+
continue;
|
|
124
|
+
if (name === "pairing" && !isPrincipalRef(value))
|
|
125
|
+
this.fail("INVALID_PRINCIPAL_REF", pointer);
|
|
126
|
+
if (name === "descriptor" && !isIdentityDescriptor(String(value), key))
|
|
127
|
+
this.fail("INVALID_DESCRIPTOR", pointer);
|
|
128
|
+
if ((name === "added_at" || name === "requested_at") && !isUtcTimestamp(value))
|
|
129
|
+
this.fail("INVALID_TIMESTAMP", pointer);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (roster)
|
|
133
|
+
output.endpoints = this.list(field("endpoints"), at(base, "endpoints"), "str", false);
|
|
134
|
+
else {
|
|
135
|
+
const kinds = field("kind");
|
|
136
|
+
if (kinds.length > 1)
|
|
137
|
+
this.conflicts.add(at(base, "kind"));
|
|
138
|
+
output.kind = this.scalar(kinds, "uint", at(base, "kind"), (v) => v === 0 || v === 1, "INVALID_ENUM_VALUE");
|
|
139
|
+
if (request) {
|
|
140
|
+
const scope = this.field(obj, "scope", base, kinds.some((v) => v[1] === 1), true);
|
|
141
|
+
if (scope.length > 0) {
|
|
142
|
+
if (kinds.some((v) => v[1] === 0))
|
|
143
|
+
this.fail("INVALID_FIELD_TYPE", at(base, "scope"));
|
|
144
|
+
output.scope = this.list(scope, at(base, "scope"), "str", true, true);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const bool = roster ? "archived" : "revoked";
|
|
149
|
+
if (!request) {
|
|
150
|
+
const values = field(bool);
|
|
151
|
+
if (values.length > 0)
|
|
152
|
+
output[bool] = this.scalar(values, "boolean", at(base, bool));
|
|
153
|
+
}
|
|
154
|
+
if (table === "devices") {
|
|
155
|
+
const values = field("missing_keys");
|
|
156
|
+
const union = new Map();
|
|
157
|
+
for (const [type, id] of values) {
|
|
158
|
+
if (type !== "map") {
|
|
159
|
+
this.fail("INVALID_FIELD_TYPE", at(base, "missing_keys"));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (const resource of this.reader.keys(String(id))) {
|
|
163
|
+
const pointer = at(at(base, "missing_keys"), resource);
|
|
164
|
+
if (!isResourceRef(resource))
|
|
165
|
+
this.fail("INVALID_RESOURCE_REF", pointer);
|
|
166
|
+
const epochs = union.get(resource) ?? new Set();
|
|
167
|
+
for (const list of this.reader.all(String(id), resource)) {
|
|
168
|
+
this.list([list], pointer, "uint", false);
|
|
169
|
+
if (list[0] === "list")
|
|
170
|
+
for (const items of this.reader.list(String(list[1])))
|
|
171
|
+
for (const item of items)
|
|
172
|
+
if (uint(item))
|
|
173
|
+
epochs.add(item[1]);
|
|
174
|
+
}
|
|
175
|
+
union.set(resource, epochs);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (values.length > 0)
|
|
179
|
+
output.missing_keys = Object.freeze(Object.fromEntries([...union]
|
|
180
|
+
.sort(([a], [b]) => textOrder(a, b))
|
|
181
|
+
.map(([k, v]) => [k, Object.freeze([...v].sort(epochOrder))])));
|
|
182
|
+
}
|
|
183
|
+
// Unknown request fields are preserved/ignored too, but any conflict invalidates a request.
|
|
184
|
+
if (request)
|
|
185
|
+
for (const name of this.reader.keys(obj))
|
|
186
|
+
if (this.reader.all(obj, name).length > 1)
|
|
187
|
+
this.conflicts.add(at(base, name));
|
|
188
|
+
for (const value of Object.values(output))
|
|
189
|
+
if (Array.isArray(value))
|
|
190
|
+
Object.freeze(value);
|
|
191
|
+
return Object.freeze(output);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** §4: validate every concurrent value; return only usable entries, never request winners. */
|
|
195
|
+
export function validateIdentity(doc, root) {
|
|
196
|
+
const r = new IdentityReader(doc);
|
|
197
|
+
const f = new Fields(r);
|
|
198
|
+
const profiles = r.all("_root", "profile");
|
|
199
|
+
if (profiles.length !== 1 || profiles[0]?.[0] !== "str" || profiles[0][1] !== IDENTITY_PROFILE_ID)
|
|
200
|
+
f.fail("INVALID_ROOT", "/profile");
|
|
201
|
+
for (const key of ["identity", "roster", "devices", "requests", "extensions"])
|
|
202
|
+
if (r.map("_root", key) === undefined)
|
|
203
|
+
f.fail("INVALID_ROOT", at("", key));
|
|
204
|
+
const identity = r.map("_root", "identity");
|
|
205
|
+
if (identity !== undefined) {
|
|
206
|
+
const roots = r.all(identity, "root");
|
|
207
|
+
if (roots.length !== 1 || roots[0]?.[0] !== "str" || roots[0][1] !== principalRef(root))
|
|
208
|
+
f.fail("INVALID_ROOT", "/identity/root");
|
|
209
|
+
}
|
|
210
|
+
const usable = ![...f.problems.values()].some((p) => p.diagnostic === "INVALID_ROOT");
|
|
211
|
+
const extensions = r.map("_root", "extensions");
|
|
212
|
+
if (extensions !== undefined)
|
|
213
|
+
for (const key of r.keys(extensions))
|
|
214
|
+
if (!isReverseDomain(key))
|
|
215
|
+
f.fail("INVALID_EXTENSION_NAMESPACE", at("/extensions", key));
|
|
216
|
+
const tables = {};
|
|
217
|
+
for (const table of ["roster", "devices", "requests"]) {
|
|
218
|
+
const entries = [];
|
|
219
|
+
const container = r.map("_root", table);
|
|
220
|
+
if (container !== undefined)
|
|
221
|
+
for (const key of r.keys(container)) {
|
|
222
|
+
const base = at(`/${table}`, key);
|
|
223
|
+
const values = r.all(container, key);
|
|
224
|
+
if (table === "requests" && values.length > 1)
|
|
225
|
+
f.conflicts.add(base);
|
|
226
|
+
const projections = [];
|
|
227
|
+
for (const [type, id] of values) {
|
|
228
|
+
if (type !== "map")
|
|
229
|
+
f.fail("INVALID_FIELD_TYPE", base);
|
|
230
|
+
else
|
|
231
|
+
projections.push(f.entry(String(id), key, table));
|
|
232
|
+
}
|
|
233
|
+
// Concurrent device maps also cannot disagree about kind; hints union across maps.
|
|
234
|
+
if (table === "devices" && new Set(projections.map((p) => p.kind)).size > 1)
|
|
235
|
+
f.conflicts.add(at(base, "kind"));
|
|
236
|
+
f.finishEntry(base);
|
|
237
|
+
if (usable && ![...f.problems.keys()].some((p) => p === base || p.startsWith(`${base}/`))) {
|
|
238
|
+
const first = projections[0];
|
|
239
|
+
if (first !== undefined) {
|
|
240
|
+
if (table === "devices" && projections.some((p) => p.missing_keys !== undefined)) {
|
|
241
|
+
const union = new Map();
|
|
242
|
+
for (const p of projections)
|
|
243
|
+
for (const [ref, epochs] of Object.entries((p.missing_keys ?? {})))
|
|
244
|
+
union.set(ref, new Set([...(union.get(ref) ?? []), ...epochs]));
|
|
245
|
+
entries.push([
|
|
246
|
+
key,
|
|
247
|
+
Object.freeze({
|
|
248
|
+
...first,
|
|
249
|
+
missing_keys: Object.freeze(Object.fromEntries([...union]
|
|
250
|
+
.sort(([a], [b]) => textOrder(a, b))
|
|
251
|
+
.map(([k, v]) => [k, Object.freeze([...v].sort(epochOrder))]))),
|
|
252
|
+
}),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
else
|
|
256
|
+
entries.push([key, first]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
tables[table] = Object.freeze(Object.fromEntries(entries));
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
usable,
|
|
264
|
+
problems: Object.freeze([...f.problems.values()].sort((a, b) => IDENTITY_DIAGNOSTIC_ORDER.indexOf(a.diagnostic) -
|
|
265
|
+
IDENTITY_DIAGNOSTIC_ORDER.indexOf(b.diagnostic) || textOrder(a.pointer, b.pointer))),
|
|
266
|
+
roster: tables.roster,
|
|
267
|
+
devices: tables.devices,
|
|
268
|
+
requests: tables.requests,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
export declare const IDENTITY_PROFILE_ID = "org.openlfcp.identity.v1";
|
|
3
|
+
export { deriveActorId as deriveIdentityActorId } from "../values.js";
|
|
4
|
+
export type ResourceRef = string & {
|
|
5
|
+
readonly __resourceRef: true;
|
|
6
|
+
};
|
|
7
|
+
export declare function isResourceRef(value: unknown): value is ResourceRef;
|
|
8
|
+
/** W §7 has exactly one deterministic encoding: a3, then 0/1/2 + bstr32. */
|
|
9
|
+
export declare function isIdentityDescriptor(text: string, key: string): boolean;
|
|
10
|
+
/** Supplied from the verified Identity Resource Control state at control_ref (§2.3). */
|
|
11
|
+
export type IdentityWriterClass = "root" | "personal" | "pairing" | "other";
|
|
12
|
+
export interface IdentityWriter {
|
|
13
|
+
readonly signer: PrincipalId;
|
|
14
|
+
/** The LFCP Data Unit actor sequence, not the Automerge sequence. */
|
|
15
|
+
readonly actorSeq: bigint | number;
|
|
16
|
+
readonly writerClass: IdentityWriterClass;
|
|
17
|
+
}
|
|
18
|
+
export interface RosterEntry {
|
|
19
|
+
readonly profile: string;
|
|
20
|
+
readonly endpoints: readonly string[];
|
|
21
|
+
readonly coordinator: string;
|
|
22
|
+
readonly name?: string;
|
|
23
|
+
readonly archived?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface DeviceEntry {
|
|
26
|
+
readonly label: string;
|
|
27
|
+
readonly kind: 0 | 1;
|
|
28
|
+
readonly platform?: string;
|
|
29
|
+
readonly added_at?: string;
|
|
30
|
+
readonly revoked?: boolean;
|
|
31
|
+
/** Safe values are numbers; larger uint64 epochs are lossless bigints. */
|
|
32
|
+
readonly missing_keys?: Readonly<Record<string, readonly IdentityEpoch[]>>;
|
|
33
|
+
}
|
|
34
|
+
export type IdentityEpoch = bigint | number;
|
|
35
|
+
export interface PairingRequest {
|
|
36
|
+
readonly descriptor: string;
|
|
37
|
+
readonly pairing: string;
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly kind: 0 | 1;
|
|
40
|
+
readonly scope?: readonly string[];
|
|
41
|
+
readonly platform?: string;
|
|
42
|
+
readonly requested_at?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Logical §2.1 document; the engine stores strings as ImmutableString, never Text. */
|
|
45
|
+
export interface IdentityDocument {
|
|
46
|
+
readonly profile: typeof IDENTITY_PROFILE_ID;
|
|
47
|
+
readonly identity: {
|
|
48
|
+
readonly root: string;
|
|
49
|
+
};
|
|
50
|
+
readonly roster: Readonly<Record<string, RosterEntry>>;
|
|
51
|
+
readonly devices: Readonly<Record<string, DeviceEntry>>;
|
|
52
|
+
readonly requests: Readonly<Record<string, PairingRequest>>;
|
|
53
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
54
|
+
readonly [unknownKey: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=values.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { bytesEqual, fromBase64url, toBase64url } from "@openlfcp/core";
|
|
2
|
+
import { isValidEd25519PublicKey, sha256 } from "@openlfcp/crypto";
|
|
3
|
+
export const IDENTITY_PROFILE_ID = "org.openlfcp.identity.v1";
|
|
4
|
+
// IDENTITY-PROFILE-01 §2 inherits SOP §8 without changing its domain.
|
|
5
|
+
export { deriveActorId as deriveIdentityActorId } from "../values.js";
|
|
6
|
+
export function isResourceRef(value) {
|
|
7
|
+
if (typeof value !== "string" || value.length !== 43)
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
return fromBase64url(value).length === 32;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** W §7 has exactly one deterministic encoding: a3, then 0/1/2 + bstr32. */
|
|
17
|
+
export function isIdentityDescriptor(text, key) {
|
|
18
|
+
try {
|
|
19
|
+
const bytes = fromBase64url(text);
|
|
20
|
+
if (bytes.length !== 106 || bytes[0] !== 0xa3)
|
|
21
|
+
return false;
|
|
22
|
+
for (let k = 0; k < 3; k++) {
|
|
23
|
+
const at = 1 + 35 * k;
|
|
24
|
+
if (bytes[at] !== k || bytes[at + 1] !== 0x58 || bytes[at + 2] !== 32)
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const id = bytes.subarray(4, 36);
|
|
28
|
+
const ed = bytes.subarray(39, 71);
|
|
29
|
+
const x = bytes.subarray(74, 106);
|
|
30
|
+
const domain = Uint8Array.from("LFCP-PRINCIPAL-v1", (c) => c.charCodeAt(0));
|
|
31
|
+
const input = new Uint8Array(domain.length + 64);
|
|
32
|
+
input.set(domain);
|
|
33
|
+
input.set(ed, domain.length);
|
|
34
|
+
input.set(x, domain.length + 32);
|
|
35
|
+
return (key === `p:${toBase64url(id)}` && bytesEqual(id, sha256(input)) && isValidEd25519PublicKey(ed));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=values.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
export declare const PACKAGE = "@openlfcp/shared-objects";
|
|
7
7
|
export { type CheckedChange, checkChange, checkSaveHeader, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./admission/framing.js";
|
|
8
8
|
export { CHANGE_LIMITS, type ChunkExpansion, checkChangeExpansion, checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, type SnapshotLimits, } from "./admission/limits.js";
|
|
9
|
-
export { type SharedObjectsApplyResult, type SharedObjectsBatchResult, type SharedObjectsCheckpoint, type SharedObjectsCodec, SharedObjectsDataProfile, type SharedObjectsDiagnostic, type SharedObjectsExcludeResult, type SharedObjectsSnapshotCodec, type SharedObjectsUnit, } from "./data-profile.js";
|
|
9
|
+
export { type SharedObjectsApplyResult, type SharedObjectsBatchResult, type SharedObjectsCheckpoint, type SharedObjectsCodec, SharedObjectsDataProfile, type SharedObjectsDiagnostic, type SharedObjectsExcludeResult, type SharedObjectsListenerFailure, type SharedObjectsSnapshotCodec, type SharedObjectsUnit, } from "./data-profile.js";
|
|
10
10
|
export { applyBatchChecked, applyChecked, type BatchReceiveResult, type BuiltReplica, isWasmTrap, type LocalChange, type ObjectChange, ObjectIdCollisionError, type ObjectStatus, type ReceiveResult, type ReplicaIntent, type ReplicaOptions, type ReplicaValidation, type ResolveFieldConflict, resolveFieldConflict, SCALAR_FIELDS, type ScalarField, type ScalarView, SharedObjectsReplica, type TaskView, } from "./replica.js";
|
|
11
11
|
export { addTag, assign, cancel, clearDue, clearScheduled, complete, createTask, deleteTask, type NewTask, type ParsedTask, ProfileError, parseTask, removeTag, reopen, restoreTask, setDue, setPriority, setScheduled, setStatus, setTitle, type Task, type TaskChange, type TaskIntent, type TaskPriority, type TaskStatus, taskToJson, unassign, } from "./task.js";
|
|
12
12
|
export { DIAGNOSTIC_ORDER, firstPerField, isMap, type Json, objectProblems, type ProfileDiagnostic, ProfileInvalidError, type ProfileProblem, pointerToken, type RootValidation, validateRoot, validateTransition, } from "./validate.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarded change notifications for the Data Profile handlers
|
|
3
|
+
* (REVIEW-20261015-03): every listener gets each event, and a listener that
|
|
4
|
+
* throws never turns the operation that changed the model into a failure.
|
|
5
|
+
* Its error goes to the handlers registered with onError, or, without one,
|
|
6
|
+
* to the runtime's reportError where there is one (a browser, Electron);
|
|
7
|
+
* else it is dropped. A handler that throws, or a failure while one is
|
|
8
|
+
* being reported, is not reported again (no recursion). An engine trap is
|
|
9
|
+
* the exception: it is thrown on once every listener has had the event, as
|
|
10
|
+
* the client's SyncClient does, since the engine is dead.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* A thrown value that looks like an engine trap (WebAssembly RuntimeError, a
|
|
14
|
+
* terminated module): the rule of the client's isEngineTrap, which this
|
|
15
|
+
* package cannot import.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isEngineTrap(e: unknown): boolean;
|
|
18
|
+
/** Listeners of events `E`, and the handlers of their failures `F`. */
|
|
19
|
+
export declare class GuardedListeners<E, F> {
|
|
20
|
+
#private;
|
|
21
|
+
/** `failure` builds what a handler receives from the error and the event. */
|
|
22
|
+
constructor(failure: (error: unknown, event: E) => F);
|
|
23
|
+
add(listener: (event: E) => void): () => void;
|
|
24
|
+
onError(handler: (failure: F) => void): () => void;
|
|
25
|
+
/** Delivers each event to every listener; see the module comment. */
|
|
26
|
+
emit(events: readonly E[]): void;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=listeners.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarded change notifications for the Data Profile handlers
|
|
3
|
+
* (REVIEW-20261015-03): every listener gets each event, and a listener that
|
|
4
|
+
* throws never turns the operation that changed the model into a failure.
|
|
5
|
+
* Its error goes to the handlers registered with onError, or, without one,
|
|
6
|
+
* to the runtime's reportError where there is one (a browser, Electron);
|
|
7
|
+
* else it is dropped. A handler that throws, or a failure while one is
|
|
8
|
+
* being reported, is not reported again (no recursion). An engine trap is
|
|
9
|
+
* the exception: it is thrown on once every listener has had the event, as
|
|
10
|
+
* the client's SyncClient does, since the engine is dead.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* A thrown value that looks like an engine trap (WebAssembly RuntimeError, a
|
|
14
|
+
* terminated module): the rule of the client's isEngineTrap, which this
|
|
15
|
+
* package cannot import.
|
|
16
|
+
*/
|
|
17
|
+
export function isEngineTrap(e) {
|
|
18
|
+
const wasm = globalThis
|
|
19
|
+
.WebAssembly?.RuntimeError;
|
|
20
|
+
if (wasm !== undefined && e instanceof wasm)
|
|
21
|
+
return true;
|
|
22
|
+
if (!(e instanceof Error))
|
|
23
|
+
return false;
|
|
24
|
+
return (e.name === "RuntimeError" ||
|
|
25
|
+
/\b(module|instance)\b.*\bterminated\b|\bunreachable\b executed|\bwasm trap\b/i.test(e.message));
|
|
26
|
+
}
|
|
27
|
+
/** Listeners of events `E`, and the handlers of their failures `F`. */
|
|
28
|
+
export class GuardedListeners {
|
|
29
|
+
#listeners = new Set();
|
|
30
|
+
#errorHandlers = new Set();
|
|
31
|
+
#failure;
|
|
32
|
+
#reporting = false;
|
|
33
|
+
/** `failure` builds what a handler receives from the error and the event. */
|
|
34
|
+
constructor(failure) {
|
|
35
|
+
this.#failure = failure;
|
|
36
|
+
}
|
|
37
|
+
add(listener) {
|
|
38
|
+
this.#listeners.add(listener);
|
|
39
|
+
return () => this.#listeners.delete(listener);
|
|
40
|
+
}
|
|
41
|
+
onError(handler) {
|
|
42
|
+
this.#errorHandlers.add(handler);
|
|
43
|
+
return () => this.#errorHandlers.delete(handler);
|
|
44
|
+
}
|
|
45
|
+
/** Delivers each event to every listener; see the module comment. */
|
|
46
|
+
emit(events) {
|
|
47
|
+
let trap;
|
|
48
|
+
const failed = [];
|
|
49
|
+
for (const event of events)
|
|
50
|
+
for (const l of [...this.#listeners])
|
|
51
|
+
try {
|
|
52
|
+
l(event);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (isEngineTrap(error))
|
|
56
|
+
trap ??= { error };
|
|
57
|
+
else
|
|
58
|
+
failed.push({ error, event });
|
|
59
|
+
}
|
|
60
|
+
for (const { error, event } of failed)
|
|
61
|
+
this.#report(error, event);
|
|
62
|
+
if (trap !== undefined)
|
|
63
|
+
throw trap.error;
|
|
64
|
+
}
|
|
65
|
+
#report(error, event) {
|
|
66
|
+
if (this.#reporting)
|
|
67
|
+
return;
|
|
68
|
+
this.#reporting = true;
|
|
69
|
+
try {
|
|
70
|
+
if (this.#errorHandlers.size === 0) {
|
|
71
|
+
const report = globalThis.reportError;
|
|
72
|
+
if (typeof report === "function")
|
|
73
|
+
report(error);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const failure = this.#failure(error, event);
|
|
77
|
+
for (const h of [...this.#errorHandlers])
|
|
78
|
+
try {
|
|
79
|
+
h(failure);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A failing error handler is not reported again (no recursion).
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// reportError itself failed: nothing left to report to.
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.#reporting = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=listeners.js.map
|
package/dist/replica.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as A from "@automerge/automerge";
|
|
2
2
|
import { fromHex, LfcpError, toHex, } from "@openlfcp/core";
|
|
3
|
-
import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot, } from "./admission/framing.js";
|
|
3
|
+
import { checkChange, checkSaveHeader, decodedOf, parsedOf, refusedChangeHash, unframeChange, unframeSnapshot, } from "./admission/framing.js";
|
|
4
4
|
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./admission/limits.js";
|
|
5
5
|
import { referenceHistoryOf } from "./admission/references.js";
|
|
6
6
|
import { admitBatch, admitChange } from "./admission/sequence.js";
|
|
@@ -540,7 +540,7 @@ export class SharedObjectsReplica {
|
|
|
540
540
|
};
|
|
541
541
|
}
|
|
542
542
|
#createdDepths(change, prior) {
|
|
543
|
-
const decoded =
|
|
543
|
+
const decoded = decodedOf(change);
|
|
544
544
|
const created = new Map();
|
|
545
545
|
decoded.ops.forEach((op, i) => {
|
|
546
546
|
if (!MAKE_ACTIONS.has(op.action))
|
|
@@ -577,7 +577,7 @@ export class SharedObjectsReplica {
|
|
|
577
577
|
return path.length;
|
|
578
578
|
}
|
|
579
579
|
for (const bytes of A.getAllChanges(this.#doc)) {
|
|
580
|
-
const c =
|
|
580
|
+
const c = decodedOf(checkChange(bytes));
|
|
581
581
|
c.ops.forEach((op, i) => {
|
|
582
582
|
if (!MAKE_ACTIONS.has(op.action))
|
|
583
583
|
return;
|
|
@@ -657,7 +657,7 @@ export class SharedObjectsReplica {
|
|
|
657
657
|
*/
|
|
658
658
|
rebuildWithout(exclude) {
|
|
659
659
|
const out = new Set(exclude);
|
|
660
|
-
const kept = A.getAllChanges(this.#doc).filter((c) => !out.has(
|
|
660
|
+
const kept = A.getAllChanges(this.#doc).filter((c) => !out.has(checkChange(c).hash));
|
|
661
661
|
return SharedObjectsReplica.fromChanges(kept, {
|
|
662
662
|
resource: this.resource,
|
|
663
663
|
principal: this.#principal,
|
|
@@ -835,8 +835,8 @@ export class SharedObjectsReplica {
|
|
|
835
835
|
catch (e) {
|
|
836
836
|
// §11.1: a writer never emits a change over the limits. The handle
|
|
837
837
|
// this replica held is outdated by A.change: rebuild it without the change.
|
|
838
|
-
const hash =
|
|
839
|
-
this.#doc = A.applyChanges(A.init({ actor: this.#actor }), A.getAllChanges(next).filter((c) =>
|
|
838
|
+
const hash = refusedChangeHash(bytes);
|
|
839
|
+
this.#doc = A.applyChanges(A.init({ actor: this.#actor }), A.getAllChanges(next).filter((c) => refusedChangeHash(c) !== hash))[0];
|
|
840
840
|
this.#refs = null;
|
|
841
841
|
throw new LfcpError("CHANGE_TOO_LARGE", `the transaction "${message}" is larger than one change may be (§11.1: ${e.message}); split it into several (§12)`);
|
|
842
842
|
}
|
|
@@ -105,14 +105,31 @@ export interface SectionsNodesChanged {
|
|
|
105
105
|
readonly origin: "local" | "remote" | "rebuild";
|
|
106
106
|
readonly modelRevision: string;
|
|
107
107
|
}
|
|
108
|
+
/** A nodes-changed listener that threw: the error, and the event it was given. */
|
|
109
|
+
export interface SectionsListenerFailure {
|
|
110
|
+
readonly error: unknown;
|
|
111
|
+
readonly event: SectionsNodesChanged;
|
|
112
|
+
}
|
|
108
113
|
export declare class SharedSectionsDataProfile {
|
|
109
114
|
#private;
|
|
110
115
|
readonly dataProfile = "org.openlfcp.shared-sections.v1";
|
|
111
116
|
constructor(replica: SectionReplica);
|
|
112
117
|
/** The current replica (a rebuild replaces it). */
|
|
113
118
|
get replica(): SectionReplica;
|
|
114
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* §5 nodes-changed for remote merges, rebuilds and recorded local changes.
|
|
121
|
+
* A listener that changes the model again (re-entrantly) makes the
|
|
122
|
+
* nested event reach the listeners after it before the outer one does,
|
|
123
|
+
* as in 0.2.1.
|
|
124
|
+
*/
|
|
115
125
|
onNodesChanged(listener: (event: SectionsNodesChanged) => void): () => void;
|
|
126
|
+
/**
|
|
127
|
+
* Called when a nodes-changed listener throws (REVIEW-20261015-03), with
|
|
128
|
+
* the error and the event. Without a handler the error goes to the
|
|
129
|
+
* runtime's reportError where there is one (a browser, Electron), else it
|
|
130
|
+
* is dropped. A handler that throws is not reported again.
|
|
131
|
+
*/
|
|
132
|
+
onListenerError(handler: (failure: SectionsListenerFailure) => void): () => void;
|
|
116
133
|
/** The codec of one unit, bound to its signer's section actor (§2). */
|
|
117
134
|
codecFor(unit: {
|
|
118
135
|
readonly resourceId: ResourceId;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LfcpError, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { checkChangeActor } from "../admission/actor.js";
|
|
3
3
|
import { checkChange, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "../admission/framing.js";
|
|
4
|
+
import { GuardedListeners } from "../listeners.js";
|
|
4
5
|
import { SectionReplica } from "./replica.js";
|
|
5
6
|
import { deriveSectionActorId, SECTIONS_PROFILE_ID } from "./values.js";
|
|
6
7
|
/** Each node's (and the title's) projected state, to tell which ones a merge changed. */
|
|
@@ -62,7 +63,7 @@ export class SharedSectionsDataProfile {
|
|
|
62
63
|
#pending = new Map();
|
|
63
64
|
/** LFCP-accepted units whose actor sequence a different change has (SOP §14.1), by unit ID hex. */
|
|
64
65
|
#held = new Map();
|
|
65
|
-
#listeners = new
|
|
66
|
+
#listeners = new GuardedListeners((error, event) => Object.freeze({ error, event }));
|
|
66
67
|
constructor(replica) {
|
|
67
68
|
this.#replica = replica;
|
|
68
69
|
}
|
|
@@ -70,17 +71,37 @@ export class SharedSectionsDataProfile {
|
|
|
70
71
|
get replica() {
|
|
71
72
|
return this.#replica;
|
|
72
73
|
}
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* §5 nodes-changed for remote merges, rebuilds and recorded local changes.
|
|
76
|
+
* A listener that changes the model again (re-entrantly) makes the
|
|
77
|
+
* nested event reach the listeners after it before the outer one does,
|
|
78
|
+
* as in 0.2.1.
|
|
79
|
+
*/
|
|
74
80
|
onNodesChanged(listener) {
|
|
75
|
-
this.#listeners.add(listener);
|
|
76
|
-
return () => this.#listeners.delete(listener);
|
|
81
|
+
return this.#listeners.add(listener);
|
|
77
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Called when a nodes-changed listener throws (REVIEW-20261015-03), with
|
|
85
|
+
* the error and the event. Without a handler the error goes to the
|
|
86
|
+
* runtime's reportError where there is one (a browser, Electron), else it
|
|
87
|
+
* is dropped. A handler that throws is not reported again.
|
|
88
|
+
*/
|
|
89
|
+
onListenerError(handler) {
|
|
90
|
+
return this.#listeners.onError(handler);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every listener gets the event, and a listener that throws never turns
|
|
94
|
+
* the operation that changed the model (a merge, a recorded local change,
|
|
95
|
+
* a rebuild, a loaded Snapshot) into a failure: the change stands and the
|
|
96
|
+
* error is reported (onListenerError), never recursively. An engine trap
|
|
97
|
+
* is the exception and is thrown on once every listener has had the
|
|
98
|
+
* event, as before and as SyncClient does: the engine is dead.
|
|
99
|
+
*/
|
|
78
100
|
#emit(nodeIds, origin) {
|
|
79
101
|
if (nodeIds.length === 0)
|
|
80
102
|
return;
|
|
81
103
|
const event = Object.freeze({ nodeIds, origin, modelRevision: this.#replica.revision() });
|
|
82
|
-
|
|
83
|
-
l(event);
|
|
104
|
+
this.#listeners.emit([event]);
|
|
84
105
|
}
|
|
85
106
|
/** The codec of one unit, bound to its signer's section actor (§2). */
|
|
86
107
|
codecFor(unit) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IDENTITY_PROFILE_ID } from "../identity/values.js";
|
|
1
2
|
import { PROFILE_ID } from "../values.js";
|
|
2
3
|
import { SECTIONS_PROFILE_ID } from "./values.js";
|
|
3
4
|
/**
|
|
@@ -9,13 +10,16 @@ import { SECTIONS_PROFILE_ID } from "./values.js";
|
|
|
9
10
|
* textual shape.
|
|
10
11
|
*/
|
|
11
12
|
/** The models this package implements, by Data Profile. */
|
|
12
|
-
export declare const SUPPORTED_DATA_PROFILES: readonly ["org.openlfcp.shared-objects.v1", "org.openlfcp.shared-sections.v1"];
|
|
13
|
+
export declare const SUPPORTED_DATA_PROFILES: readonly ["org.openlfcp.shared-objects.v1", "org.openlfcp.shared-sections.v1", "org.openlfcp.identity.v1"];
|
|
13
14
|
export type ProfileModel = {
|
|
14
15
|
readonly kind: "shared-objects";
|
|
15
16
|
readonly dataProfile: typeof PROFILE_ID;
|
|
16
17
|
} | {
|
|
17
18
|
readonly kind: "shared-sections";
|
|
18
19
|
readonly dataProfile: typeof SECTIONS_PROFILE_ID;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: "identity";
|
|
22
|
+
readonly dataProfile: typeof IDENTITY_PROFILE_ID;
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
21
25
|
* LFCP-WIRE-01 §62: no model here reads this profile. The Resource is
|