@openlfcp/shared-objects 0.2.2 → 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/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/replica.js +6 -6
- package/dist/sections/dispatch.d.ts +5 -1
- package/dist/sections/dispatch.js +14 -1
- package/dist/sections/replica.js +3 -4
- package/dist/sections/tree.js +2 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -37,6 +37,69 @@ edits, the effective tree and the section admission rules are not there
|
|
|
37
37
|
yet. It may change
|
|
38
38
|
without notice until the profile is in a baseline.
|
|
39
39
|
|
|
40
|
+
## Identity profile (MVP 0.4 Working Draft)
|
|
41
|
+
|
|
42
|
+
`@openlfcp/shared-objects/identity` implements IDENTITY-PROFILE-01 §§2–4
|
|
43
|
+
(`org.openlfcp.identity.v1`, `mvp-0.4-baseline.2`, LFCP-04-409 and 409b).
|
|
44
|
+
`IdentityReplica` admits changes with AI0–AI5, validates every concurrent
|
|
45
|
+
field value and exposes `roster()`, `devices()` and `requests()`. Invalid
|
|
46
|
+
entries are omitted; an invalid root makes all three readers empty.
|
|
47
|
+
Concurrent request maps remain invalid: readers never select a winning
|
|
48
|
+
request. Device records here are display metadata and key hints, not authority.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { IdentityReplica } from "@openlfcp/shared-objects/identity";
|
|
52
|
+
|
|
53
|
+
const identity = IdentityReplica.empty({ resource, root });
|
|
54
|
+
const initial = identity.write(
|
|
55
|
+
{ signer: root, actorSeq: 1n, writerClass: "root" },
|
|
56
|
+
{ intent: "identity.initialize" },
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Persist and send `initial.bytes` in the root's Data Unit at actor sequence 1;
|
|
61
|
+
retry those exact bytes. Initialization creates only the six root keys,
|
|
62
|
+
with empty roster/devices/requests/extensions. Write entries in later changes
|
|
63
|
+
with `roster.put`, `device.put`, or `request.put`; matching `.delete` intents
|
|
64
|
+
remove entries. Updates preserve unknown fields; omitted optional fields
|
|
65
|
+
remain unchanged. Writers produce scalar strings and uints, including
|
|
66
|
+
lossless bigint epochs in `missing_keys` (safe epochs read back as numbers).
|
|
67
|
+
The writer permits `missing_keys` only on the signer's own device entry, and
|
|
68
|
+
receivers refuse any other writer's `missing_keys` as `UNAUTHORIZED_WRITER`
|
|
69
|
+
(§3 AI5, decision I8). AI2 and AI5 are decided on the objects of the causal
|
|
70
|
+
history, values the engine does not select and maps since replaced or deleted
|
|
71
|
+
included; AI2 looks only at the objects a write names as its predecessors.
|
|
72
|
+
Reserved `recovery`/`succession` keys have no writer intent; readers preserve them.
|
|
73
|
+
|
|
74
|
+
The receive boundary is `receiveChanges([{ bytes, signer, actorSeq, writerClass }])`,
|
|
75
|
+
or `receive(plaintext, writer)` for SOP-framed bytes. All unit metadata must
|
|
76
|
+
come from verified LFCP units. Derive `writerClass` from this Identity Resource's
|
|
77
|
+
Control state **at the unit's `control_ref`**, never its latest head. Wire
|
|
78
|
+
authorization, decryption, equivocation and epoch cutoffs remain the caller's
|
|
79
|
+
responsibility. The actor domain is inherited unchanged from SOP §8.
|
|
80
|
+
Waiting dependencies and held actor sequences are retained; `rebuildWithout`
|
|
81
|
+
takes excluded change hashes and replays remaining units, removing dependants.
|
|
82
|
+
|
|
83
|
+
`fromSave(save, options, verifiedUnits)` and `fromSnapshot` verify the initial
|
|
84
|
+
change against the root's actual unit 1 before exposing state. They replay all
|
|
85
|
+
snapshot changes with their verified carrying units, which also verifies AI4
|
|
86
|
+
ownership and historical writer classes. Missing proof refuses the snapshot;
|
|
87
|
+
fetch/decrypt units and retry, including when an earlier epoch is KEY_BLOCKED.
|
|
88
|
+
`identityCodec` and `identitySnapshotCodec` provide the shared SOP framing.
|
|
89
|
+
|
|
90
|
+
Diagnostics are `PROFILE_INVALID` with a profile diagnostic and JSON Pointer
|
|
91
|
+
for state errors. Conflicted requests/kind report the first invalid value's
|
|
92
|
+
diagnostic in table order; `INVALID_FIELD_TYPE` is the fallback when every
|
|
93
|
+
concurrent value is valid on its own (§4 rule 2). Unknown request fields also
|
|
94
|
+
invalidate the entry when conflicted. Tests use §12 ID-05 names and synthetic
|
|
95
|
+
canonical changes, including the diagnostic cases beyond the corpus;
|
|
96
|
+
`conformance/identity/profile.test.ts` runs IDENTITY-PROFILE-TEST-VECTORS-01.
|
|
97
|
+
Admission retains causal, object and sequence indexes across calls. It applies
|
|
98
|
+
new changes without cloning history and replays the trusted byte log only to
|
|
99
|
+
recover from an engine error; local writes validate one candidate on a fork.
|
|
100
|
+
Client DataProfileHandler wiring, durable journals, and pairing flows belong
|
|
101
|
+
to LFCP-04-410.
|
|
102
|
+
|
|
40
103
|
## Automerge and WebAssembly
|
|
41
104
|
|
|
42
105
|
The package depends on `@automerge/automerge` **3.5.0**, pinned exactly,
|
|
@@ -19,12 +19,18 @@ export interface ParsedOp {
|
|
|
19
19
|
readonly pred: readonly string[];
|
|
20
20
|
/** The value's type, the low 4 bits of its metadata (8: a counter). */
|
|
21
21
|
readonly valueType: number;
|
|
22
|
+
/** Lossless scalar value (Automerge JS decodeChange cannot decode integers above 2^53). */
|
|
23
|
+
readonly value: unknown;
|
|
24
|
+
readonly expand: boolean;
|
|
25
|
+
readonly name: string | null;
|
|
22
26
|
}
|
|
23
27
|
/** A change read from its bytes (§11.3), without the engine. */
|
|
24
28
|
export interface ParsedChange {
|
|
25
29
|
readonly actor: string;
|
|
26
30
|
readonly seq: number;
|
|
27
31
|
readonly startOp: number;
|
|
32
|
+
readonly time: number;
|
|
33
|
+
readonly message: string;
|
|
28
34
|
/** Hex dependency hashes, ascending. */
|
|
29
35
|
readonly deps: readonly string[];
|
|
30
36
|
/** Hex other actors, ascending: actor index i is otherActors[i - 1]. */
|
|
@@ -329,25 +329,25 @@ function checkValue(type, bytes) {
|
|
|
329
329
|
if (type <= 2) {
|
|
330
330
|
if (bytes.length !== 0)
|
|
331
331
|
refuse("a null or boolean value has bytes");
|
|
332
|
-
return;
|
|
332
|
+
return type === 0 ? null : type === 2;
|
|
333
333
|
}
|
|
334
334
|
if (type === 3 || type === 4 || type === 8 || type === 9) {
|
|
335
335
|
const r = new Reader(bytes);
|
|
336
|
-
|
|
337
|
-
r.ubig();
|
|
338
|
-
else
|
|
339
|
-
r.sbig();
|
|
336
|
+
const value = type === 3 ? r.ubig() : r.sbig();
|
|
340
337
|
if (!r.done || bytes.length === 0)
|
|
341
338
|
refuse("a number value is not exactly one shortest LEB128");
|
|
342
|
-
return
|
|
339
|
+
return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
340
|
+
? Number(value)
|
|
341
|
+
: value;
|
|
343
342
|
}
|
|
344
343
|
if (type === 5) {
|
|
345
344
|
if (bytes.length !== 8)
|
|
346
345
|
refuse("a float value is not 8 bytes");
|
|
347
|
-
return;
|
|
346
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(0, true);
|
|
348
347
|
}
|
|
349
348
|
if (type === 6)
|
|
350
|
-
utf8(bytes, "a string value");
|
|
349
|
+
return utf8(bytes, "a string value");
|
|
350
|
+
return bytes.slice();
|
|
351
351
|
}
|
|
352
352
|
/**
|
|
353
353
|
* §14.1: whether the extra bytes after a change's columns begin with an
|
|
@@ -403,8 +403,10 @@ export function checkCanonicalChange(bytes) {
|
|
|
403
403
|
const startOp = r.ubig();
|
|
404
404
|
if (seq < 1n || startOp < 1n)
|
|
405
405
|
refuse("the sequence number or the start op is 0");
|
|
406
|
-
r.sbig();
|
|
407
|
-
|
|
406
|
+
const time = r.sbig();
|
|
407
|
+
if (seq >= 1n << 53n || time <= -(1n << 53n) || time >= 1n << 53n)
|
|
408
|
+
refuse("the sequence number or time is outside the exact JavaScript integer range");
|
|
409
|
+
const message = utf8(r.take(r.unum()), "the message");
|
|
408
410
|
const otherCount = r.unum();
|
|
409
411
|
const others = [];
|
|
410
412
|
for (let i = 0; i < otherCount; i++)
|
|
@@ -507,6 +509,7 @@ export function checkCanonicalChange(bytes) {
|
|
|
507
509
|
const raw = data.get(VAL_RAW) ?? new Uint8Array(0);
|
|
508
510
|
let rawAt = 0;
|
|
509
511
|
const valueTypes = [];
|
|
512
|
+
const values = [];
|
|
510
513
|
for (let i = 0; i < N; i++) {
|
|
511
514
|
const m = meta?.[i] ?? null;
|
|
512
515
|
if (m === null)
|
|
@@ -516,7 +519,7 @@ export function checkCanonicalChange(bytes) {
|
|
|
516
519
|
const len = mv >> 4n;
|
|
517
520
|
if (len > BigInt(raw.length - rawAt))
|
|
518
521
|
refuse("the value bytes are shorter than their metadata");
|
|
519
|
-
checkValue(type, raw.subarray(rawAt, rawAt + Number(len)));
|
|
522
|
+
values.push(checkValue(type, raw.subarray(rawAt, rawAt + Number(len))));
|
|
520
523
|
rawAt += Number(len);
|
|
521
524
|
valueTypes.push(type);
|
|
522
525
|
}
|
|
@@ -614,6 +617,9 @@ export function checkCanonicalChange(bytes) {
|
|
|
614
617
|
action: act,
|
|
615
618
|
pred: Object.freeze(pred),
|
|
616
619
|
valueType: valueTypes[i],
|
|
620
|
+
value: values[i],
|
|
621
|
+
expand: exp,
|
|
622
|
+
name: mark === null ? null : String(mark),
|
|
617
623
|
}));
|
|
618
624
|
}
|
|
619
625
|
// Rule 3: each column present exactly when the table says.
|
|
@@ -644,6 +650,8 @@ export function checkCanonicalChange(bytes) {
|
|
|
644
650
|
actor: hex(actor),
|
|
645
651
|
seq: Number(seq),
|
|
646
652
|
startOp: start,
|
|
653
|
+
time: Number(time),
|
|
654
|
+
message,
|
|
647
655
|
deps: Object.freeze(deps.map(hex)),
|
|
648
656
|
otherActors: Object.freeze(others.map(hex)),
|
|
649
657
|
ops: Object.freeze(ops),
|
|
@@ -52,7 +52,47 @@ export function checkChange(bytes) {
|
|
|
52
52
|
decoded = A.decodeChange(bytes);
|
|
53
53
|
}
|
|
54
54
|
catch (e) {
|
|
55
|
-
|
|
55
|
+
// Canonical uint64 values are legal (SOP §11.3 rule 6), but the JS
|
|
56
|
+
// engine's JSON decoder refuses integers it cannot represent as number.
|
|
57
|
+
// The canonical parser has already checked and decoded them losslessly.
|
|
58
|
+
if (!(e instanceof RangeError) ||
|
|
59
|
+
!/^Error: -?\d+ can't be represented as a JavaScript number$/.test(e.message) ||
|
|
60
|
+
!parsed.ops.some((op) => typeof op.value === "bigint"))
|
|
61
|
+
return reject(`invalid Automerge change bytes (§11): ${e.message}`);
|
|
62
|
+
const hash = refusedChangeHash(bytes);
|
|
63
|
+
if (hash === undefined)
|
|
64
|
+
return reject("the Automerge change checksum does not match its hash (§11)");
|
|
65
|
+
const actions = ["makeMap", "set", "makeList", "del", "makeText", "inc", "makeTable", "mark"];
|
|
66
|
+
const types = {
|
|
67
|
+
3: "uint",
|
|
68
|
+
4: "int",
|
|
69
|
+
5: "float64",
|
|
70
|
+
8: "counter",
|
|
71
|
+
9: "timestamp",
|
|
72
|
+
};
|
|
73
|
+
decoded = {
|
|
74
|
+
actor: parsed.actor,
|
|
75
|
+
seq: parsed.seq,
|
|
76
|
+
startOp: parsed.startOp,
|
|
77
|
+
time: parsed.time,
|
|
78
|
+
message: parsed.message,
|
|
79
|
+
author: null,
|
|
80
|
+
hash,
|
|
81
|
+
deps: [...parsed.deps],
|
|
82
|
+
ops: parsed.ops.map((op) => ({
|
|
83
|
+
action: actions[op.action],
|
|
84
|
+
obj: op.obj,
|
|
85
|
+
pred: [...op.pred],
|
|
86
|
+
...(op.key.kind === "prop"
|
|
87
|
+
? { key: op.key.name }
|
|
88
|
+
: { elemId: op.key.kind === "head" ? "_head" : op.key.id }),
|
|
89
|
+
...(op.insert ? { insert: true } : {}),
|
|
90
|
+
...([1, 5, 7].includes(op.action) ? { value: op.value } : {}),
|
|
91
|
+
...(types[op.valueType] === undefined ? {} : { datatype: types[op.valueType] }),
|
|
92
|
+
...(op.name === null ? {} : { name: op.name }),
|
|
93
|
+
...(op.expand ? { expand: true } : {}),
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
56
96
|
}
|
|
57
97
|
if (toHex(bytes.subarray(4, 8)) !== decoded.hash.slice(0, 8))
|
|
58
98
|
reject("the Automerge change checksum does not match its hash (§11)");
|
|
@@ -127,7 +167,14 @@ export function parsedOf(change) {
|
|
|
127
167
|
const DECODED = new WeakMap();
|
|
128
168
|
/** The decoded change, reusing the decode of checkChange when there was one. */
|
|
129
169
|
export function decodedOf(change) {
|
|
130
|
-
|
|
170
|
+
const decoded = DECODED.get(change);
|
|
171
|
+
if (decoded !== undefined)
|
|
172
|
+
return decoded;
|
|
173
|
+
// CheckedChange is structural: callers can pass a copy without the cached decode.
|
|
174
|
+
const checked = checkChange(change.bytes);
|
|
175
|
+
const result = DECODED.get(checked);
|
|
176
|
+
DECODED.set(change, result);
|
|
177
|
+
return result;
|
|
131
178
|
}
|
|
132
179
|
/**
|
|
133
180
|
* Checks that `bytes` starts as an Automerge full save (a document chunk,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// its actor's changes and their counter ranges.
|
|
15
15
|
import * as A from "@automerge/automerge";
|
|
16
16
|
import { checkCanonicalChange } from "./canonical.js";
|
|
17
|
+
import { refusedChangeHash } from "./framing.js";
|
|
17
18
|
const MAKE = new Set([0, 2, 4, 6]);
|
|
18
19
|
const MAP_LIKE = new Set([0, 6]);
|
|
19
20
|
function slotOf(op, id) {
|
|
@@ -196,7 +197,9 @@ export function referenceHistoryOf(doc) {
|
|
|
196
197
|
const parsed = new Map();
|
|
197
198
|
for (const bytes of A.getAllChanges(doc)) {
|
|
198
199
|
try {
|
|
199
|
-
|
|
200
|
+
const hash = refusedChangeHash(bytes);
|
|
201
|
+
if (hash !== undefined)
|
|
202
|
+
parsed.set(hash, checkCanonicalChange(bytes));
|
|
200
203
|
}
|
|
201
204
|
catch {
|
|
202
205
|
// not readable: left out
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { LfcpError, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
3
|
+
import { type CheckedChange } from "../admission/framing.js";
|
|
4
|
+
import { type IdentityWriter } from "./values.js";
|
|
5
|
+
export declare const IDENTITY_ADMISSION_ORDER: readonly ["INVALID_AUTOMERGE_BYTES", "CHANGE_ACTOR_MISMATCH", "INVALID_ROOT", "CONTAINER_REPLACED", "IMMUTABLE_FIELD_MUTATED", "UNAUTHORIZED_WRITER", "INVALID_FIELD_TYPE"];
|
|
6
|
+
export type IdentityAdmissionDiagnostic = (typeof IDENTITY_ADMISSION_ORDER)[number];
|
|
7
|
+
export declare class IdentityAdmissionError extends LfcpError {
|
|
8
|
+
readonly diagnostic: IdentityAdmissionDiagnostic;
|
|
9
|
+
constructor(diagnostic: IdentityAdmissionDiagnostic, message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function refuseIdentity(diagnostic: IdentityAdmissionDiagnostic, message: string): never;
|
|
12
|
+
/** Must run on every unit, even a duplicate change, BEFORE SOP §14.1 sequence holding. */
|
|
13
|
+
export declare function checkIdentityUnit(change: CheckedChange, writer: IdentityWriter, resource: ResourceId, root: PrincipalId): void;
|
|
14
|
+
/** Operations are inspected before applying them. Object provenance includes overwritten maps. */
|
|
15
|
+
export declare class IdentityAdmission {
|
|
16
|
+
#private;
|
|
17
|
+
readonly root: PrincipalId;
|
|
18
|
+
constructor(doc: A.Doc<unknown>, root: PrincipalId);
|
|
19
|
+
get document(): A.Doc<unknown>;
|
|
20
|
+
accept(change: CheckedChange, writer: IdentityWriter): void;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=admission.d.ts.map
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { actorSequence, bytesEqual, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
|
|
3
|
+
import { checkChangeActor } from "../admission/actor.js";
|
|
4
|
+
import { checkChange, decodedOf, parsedOf } from "../admission/framing.js";
|
|
5
|
+
import { MAX_DOCUMENT_DEPTH } from "../admission/limits.js";
|
|
6
|
+
import { ReferenceHistory } from "../admission/references.js";
|
|
7
|
+
import { isWasmTrap } from "../replica.js";
|
|
8
|
+
import { principalRef } from "../values.js";
|
|
9
|
+
import { distinct } from "./reader.js";
|
|
10
|
+
import { deriveIdentityActorId, IDENTITY_PROFILE_ID } from "./values.js";
|
|
11
|
+
export const IDENTITY_ADMISSION_ORDER = Object.freeze([
|
|
12
|
+
"INVALID_AUTOMERGE_BYTES",
|
|
13
|
+
"CHANGE_ACTOR_MISMATCH",
|
|
14
|
+
"INVALID_ROOT",
|
|
15
|
+
"CONTAINER_REPLACED",
|
|
16
|
+
"IMMUTABLE_FIELD_MUTATED",
|
|
17
|
+
"UNAUTHORIZED_WRITER",
|
|
18
|
+
"INVALID_FIELD_TYPE",
|
|
19
|
+
]);
|
|
20
|
+
export class IdentityAdmissionError extends LfcpError {
|
|
21
|
+
diagnostic;
|
|
22
|
+
constructor(diagnostic, message) {
|
|
23
|
+
super("PROFILE_INVALID", message);
|
|
24
|
+
this.diagnostic = diagnostic;
|
|
25
|
+
this.name = "IdentityAdmissionError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function refuseIdentity(diagnostic, message) {
|
|
29
|
+
throw new IdentityAdmissionError(diagnostic, message);
|
|
30
|
+
}
|
|
31
|
+
/** Must run on every unit, even a duplicate change, BEFORE SOP §14.1 sequence holding. */
|
|
32
|
+
export function checkIdentityUnit(change, writer, resource, root) {
|
|
33
|
+
// A dependency-free seq 2 is R1, even if the root/sequence eligibility also fails.
|
|
34
|
+
if (change.deps.length === 0 && new ReferenceHistory().check(parsedOf(change)) !== null)
|
|
35
|
+
refuseIdentity("INVALID_AUTOMERGE_BYTES", "the initial change fails SOP §11.4");
|
|
36
|
+
checkChangeActor(change, toHex(deriveIdentityActorId(resource, writer.signer)));
|
|
37
|
+
if (change.deps.length === 0 &&
|
|
38
|
+
(change.actor !== toHex(deriveIdentityActorId(resource, root)) ||
|
|
39
|
+
change.seq !== 1 ||
|
|
40
|
+
!bytesEqual(writer.signer, root) ||
|
|
41
|
+
(writer.actorSeq !== 1 && writer.actorSeq !== 1n)))
|
|
42
|
+
refuseIdentity("INVALID_ROOT", "AI0: the initial change must be in the root's actor sequence 1 unit");
|
|
43
|
+
actorSequence(writer.actorSeq);
|
|
44
|
+
if ((writer.writerClass === "root") !== bytesEqual(writer.signer, root) ||
|
|
45
|
+
!["root", "personal", "pairing", "other"].includes(writer.writerClass))
|
|
46
|
+
refuseIdentity("UNAUTHORIZED_WRITER", "writer class disagrees with the verified Genesis owner");
|
|
47
|
+
}
|
|
48
|
+
const MAKE = new Map([
|
|
49
|
+
["makeMap", "map"],
|
|
50
|
+
["makeList", "list"],
|
|
51
|
+
["makeText", "text"],
|
|
52
|
+
["makeTable", "table"],
|
|
53
|
+
]);
|
|
54
|
+
const CONTAINERS = new Set(["roster", "devices", "requests", "extensions"]);
|
|
55
|
+
const TABLES = new Set(["roster", "devices", "requests"]);
|
|
56
|
+
const FIELDS = {
|
|
57
|
+
roster: new Set(["profile", "endpoints", "coordinator", "name", "archived"]),
|
|
58
|
+
devices: new Set(["label", "kind", "platform", "added_at", "revoked", "missing_keys"]),
|
|
59
|
+
requests: new Set([
|
|
60
|
+
"descriptor",
|
|
61
|
+
"pairing",
|
|
62
|
+
"label",
|
|
63
|
+
"kind",
|
|
64
|
+
"scope",
|
|
65
|
+
"platform",
|
|
66
|
+
"requested_at",
|
|
67
|
+
]),
|
|
68
|
+
};
|
|
69
|
+
/** Operations are inspected before applying them. Object provenance includes overwritten maps. */
|
|
70
|
+
export class IdentityAdmission {
|
|
71
|
+
root;
|
|
72
|
+
#doc;
|
|
73
|
+
#changes;
|
|
74
|
+
#requests;
|
|
75
|
+
#objects = new Map([["_root", { path: [], type: "map" }]]);
|
|
76
|
+
constructor(doc, root) {
|
|
77
|
+
this.root = root;
|
|
78
|
+
// One independent working document, reused for every accepted change.
|
|
79
|
+
this.#doc = A.clone(doc);
|
|
80
|
+
this.#changes = A.getAllChanges(doc);
|
|
81
|
+
for (const bytes of this.#changes)
|
|
82
|
+
this.#index(decodedOf(checkChange(bytes)));
|
|
83
|
+
}
|
|
84
|
+
get document() {
|
|
85
|
+
return this.#doc;
|
|
86
|
+
}
|
|
87
|
+
#index(change) {
|
|
88
|
+
for (const [i, op] of change.ops.entries()) {
|
|
89
|
+
const type = MAKE.get(op.action);
|
|
90
|
+
const parent = this.#objects.get(op.obj);
|
|
91
|
+
if (type === undefined || parent === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
const id = `${change.startOp + i}@${change.actor}`;
|
|
94
|
+
const path = [...parent.path, op.key ?? -1];
|
|
95
|
+
if (path.length === 1 && path[0] === "requests")
|
|
96
|
+
this.#requests = id;
|
|
97
|
+
const requestMap = path[0] === "requests" && path.length === 2 ? id : parent.requestMap;
|
|
98
|
+
this.#objects.set(id, { path, type, ...(requestMap === undefined ? {} : { requestMap }) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
accept(change, writer) {
|
|
102
|
+
const decoded = decodedOf(change);
|
|
103
|
+
const ops = decoded.ops;
|
|
104
|
+
const initial = change.deps.length === 0;
|
|
105
|
+
const found = new Set();
|
|
106
|
+
const local = new Map();
|
|
107
|
+
const info = (id) => local.get(id) ?? this.#objects.get(id);
|
|
108
|
+
const backend = A.getBackend(this.#doc);
|
|
109
|
+
const all = (obj, key) => local.has(obj) ? [] : backend.getAll(obj, key, [...change.deps]);
|
|
110
|
+
const restricted = writer.writerClass === "pairing" || writer.writerClass === "other";
|
|
111
|
+
const own = principalRef(writer.signer);
|
|
112
|
+
const touched = new Set();
|
|
113
|
+
const createdRequests = new Set();
|
|
114
|
+
const pairings = new Map();
|
|
115
|
+
if (initial) {
|
|
116
|
+
const roots = ops.filter((op) => op.obj === "_root");
|
|
117
|
+
const identity = roots.find((op) => op.key === "identity" && op.action === "makeMap");
|
|
118
|
+
const identityIndex = identity === undefined ? -1 : ops.indexOf(identity);
|
|
119
|
+
const rootValue = ops.find((op) => op.obj === `${decoded.startOp + identityIndex}@${change.actor}`);
|
|
120
|
+
if (ops.length !== 7 ||
|
|
121
|
+
roots.length !== 6 ||
|
|
122
|
+
new Set(roots.map((op) => op.key)).size !== 6 ||
|
|
123
|
+
!roots.every((op) => op.key === "profile"
|
|
124
|
+
? op.action === "set" && op.value === IDENTITY_PROFILE_ID && op.datatype === undefined
|
|
125
|
+
: op.key !== undefined &&
|
|
126
|
+
["identity", ...CONTAINERS].includes(op.key) &&
|
|
127
|
+
op.action === "makeMap") ||
|
|
128
|
+
rootValue?.key !== "root" ||
|
|
129
|
+
rootValue.action !== "set" ||
|
|
130
|
+
rootValue.datatype !== undefined ||
|
|
131
|
+
rootValue.value !== principalRef(this.root))
|
|
132
|
+
found.add("INVALID_ROOT");
|
|
133
|
+
}
|
|
134
|
+
for (const [i, op] of ops.entries()) {
|
|
135
|
+
const id = `${decoded.startOp + i}@${change.actor}`;
|
|
136
|
+
const parent = info(op.obj);
|
|
137
|
+
if (parent === undefined)
|
|
138
|
+
refuseIdentity("INVALID_AUTOMERGE_BYTES", "unknown operation object");
|
|
139
|
+
const path = parent.path;
|
|
140
|
+
const top = String(path[0]);
|
|
141
|
+
const key = op.key;
|
|
142
|
+
const made = MAKE.get(op.action);
|
|
143
|
+
if (made !== undefined) {
|
|
144
|
+
if (path.length + 1 > MAX_DOCUMENT_DEPTH)
|
|
145
|
+
refuseIdentity("INVALID_AUTOMERGE_BYTES", "the change exceeds SOP §11.2 depth");
|
|
146
|
+
const child = [...path, key ?? -1];
|
|
147
|
+
const requestMap = child[0] === "requests" && child.length === 2 ? id : parent.requestMap;
|
|
148
|
+
local.set(id, {
|
|
149
|
+
path: child,
|
|
150
|
+
type: made,
|
|
151
|
+
...(requestMap === undefined ? {} : { requestMap }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (op.action === "makeText")
|
|
155
|
+
found.add("INVALID_FIELD_TYPE");
|
|
156
|
+
if (!initial && op.obj === "_root") {
|
|
157
|
+
if (key !== undefined && CONTAINERS.has(key))
|
|
158
|
+
found.add("CONTAINER_REPLACED");
|
|
159
|
+
if (key === "profile" || key === "identity")
|
|
160
|
+
found.add("IMMUTABLE_FIELD_MUTATED");
|
|
161
|
+
}
|
|
162
|
+
if (!initial && top === "identity")
|
|
163
|
+
found.add("IMMUTABLE_FIELD_MUTATED");
|
|
164
|
+
// AI2 on the causal history: the objects a scalar replaces are those its
|
|
165
|
+
// operation names as predecessors (SOP §11.4 R6), made in the history or
|
|
166
|
+
// earlier in this change, selected by the engine or not. Two levels
|
|
167
|
+
// only, unlike AI5: an entry map, and a list or map held in a field of
|
|
168
|
+
// an entry map. Deeper values, and a list in place of an entry map, are
|
|
169
|
+
// §4's to judge.
|
|
170
|
+
if (TABLES.has(top) && key !== undefined && op.action === "set")
|
|
171
|
+
for (const pred of op.pred) {
|
|
172
|
+
const type = info(pred)?.type;
|
|
173
|
+
if ((path.length === 1 && type === "map") ||
|
|
174
|
+
(path.length === 2 && parent.type === "map" && (type === "list" || type === "map")))
|
|
175
|
+
found.add("CONTAINER_REPLACED");
|
|
176
|
+
}
|
|
177
|
+
// AI5: devices[k].missing_keys and everything under it, in every entry
|
|
178
|
+
// map of the history (hidden, replaced or deleted ones included), is
|
|
179
|
+
// written only by the Principal k names.
|
|
180
|
+
const hint = path[0] === "devices" &&
|
|
181
|
+
((path.length === 2 && key === "missing_keys") ||
|
|
182
|
+
(path.length >= 3 && path[2] === "missing_keys"));
|
|
183
|
+
if (hint && path[1] !== own)
|
|
184
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
185
|
+
// AI3 checks structural value kinds, leaving scalar field semantics to §4.
|
|
186
|
+
const defined = (path.length === 1 && TABLES.has(top)) ||
|
|
187
|
+
(path.length === 2 && key !== undefined && FIELDS[top]?.has(key)) ||
|
|
188
|
+
(path.length >= 3 && ["endpoints", "scope", "missing_keys"].includes(String(path[2])));
|
|
189
|
+
if (defined && op.action !== "del") {
|
|
190
|
+
const scalar = op.action === "set" &&
|
|
191
|
+
((typeof op.value === "string" && op.datatype === undefined) ||
|
|
192
|
+
typeof op.value === "boolean" ||
|
|
193
|
+
(op.datatype === "uint" &&
|
|
194
|
+
((typeof op.value === "number" && Number.isSafeInteger(op.value) && op.value >= 0) ||
|
|
195
|
+
(typeof op.value === "bigint" && op.value >= 0n && op.value <= UINT64_MAX))));
|
|
196
|
+
if (!scalar && made !== "map" && made !== "list")
|
|
197
|
+
found.add("INVALID_FIELD_TYPE");
|
|
198
|
+
if (parent.type === "list" && !scalar)
|
|
199
|
+
found.add("INVALID_FIELD_TYPE");
|
|
200
|
+
}
|
|
201
|
+
if (!restricted)
|
|
202
|
+
continue;
|
|
203
|
+
if (op.action === "del")
|
|
204
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
205
|
+
if (top !== "requests" || path.length < 1) {
|
|
206
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const entryKey = path.length === 1 ? key : String(path[1]);
|
|
210
|
+
if (entryKey === undefined) {
|
|
211
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
touched.add(entryKey);
|
|
215
|
+
const requestMap = path.length === 1 ? (op.action === "makeMap" ? id : undefined) : parent.requestMap;
|
|
216
|
+
const requests = path.length === 1 ? op.obj : this.#requests;
|
|
217
|
+
const history = requests === undefined ? [] : all(requests, entryKey);
|
|
218
|
+
if (path.length === 1) {
|
|
219
|
+
if (op.action !== "makeMap" || history.length !== 0 || createdRequests.has(entryKey))
|
|
220
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
221
|
+
createdRequests.add(entryKey);
|
|
222
|
+
}
|
|
223
|
+
else if (requestMap === undefined)
|
|
224
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
225
|
+
else if (!local.has(requestMap)) {
|
|
226
|
+
const pairing = distinct(all(requestMap, "pairing"));
|
|
227
|
+
if (history.length !== 1 ||
|
|
228
|
+
history[0]?.[0] !== "map" ||
|
|
229
|
+
history[0][1] !== requestMap ||
|
|
230
|
+
!requestMap.endsWith(`@${change.actor}`) ||
|
|
231
|
+
pairing.length !== 1 ||
|
|
232
|
+
pairing[0]?.[0] !== "str" ||
|
|
233
|
+
pairing[0][1] !== own)
|
|
234
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
235
|
+
}
|
|
236
|
+
if (requestMap !== undefined && !pairings.has(requestMap))
|
|
237
|
+
pairings.set(requestMap, new Map(all(requestMap, "pairing").map((v) => [String(v[2]), v])));
|
|
238
|
+
if (path.length === 2 && key === "pairing" && requestMap !== undefined) {
|
|
239
|
+
const values = pairings.get(requestMap);
|
|
240
|
+
for (const pred of op.pred)
|
|
241
|
+
values.delete(pred);
|
|
242
|
+
if (op.action !== "del")
|
|
243
|
+
values.set(id, [
|
|
244
|
+
made ?? op.datatype ?? (typeof op.value === "string" ? "str" : typeof op.value),
|
|
245
|
+
op.value,
|
|
246
|
+
id,
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (restricted) {
|
|
251
|
+
if (touched.size !== 1)
|
|
252
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
253
|
+
for (const values of pairings.values()) {
|
|
254
|
+
const final = distinct([...values.values()]);
|
|
255
|
+
if (final.length !== 1 || final[0]?.[0] !== "str" || final[0][1] !== own)
|
|
256
|
+
found.add("UNAUTHORIZED_WRITER");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const diagnostic = IDENTITY_ADMISSION_ORDER.find((d) => found.has(d));
|
|
260
|
+
if (diagnostic !== undefined)
|
|
261
|
+
refuseIdentity(diagnostic, `IDENTITY-PROFILE-01 §3: ${diagnostic}`);
|
|
262
|
+
// Do not copy the accumulated history on every accepted change. The byte
|
|
263
|
+
// log and object index are committed only after apply succeeds; if the
|
|
264
|
+
// engine aborts, replay that trusted log into a fresh working document.
|
|
265
|
+
const actor = A.getActorId(this.#doc);
|
|
266
|
+
try {
|
|
267
|
+
[this.#doc] = A.applyChanges(this.#doc, [change.bytes]);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (isWasmTrap(e))
|
|
271
|
+
throw e;
|
|
272
|
+
A.free(this.#doc);
|
|
273
|
+
[this.#doc] = A.applyChanges(A.init({ actor }), this.#changes);
|
|
274
|
+
refuseIdentity("INVALID_AUTOMERGE_BYTES", `the engine refused the change: ${String(e)}`);
|
|
275
|
+
}
|
|
276
|
+
this.#changes.push(change.bytes);
|
|
277
|
+
this.#index(decoded);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=admission.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** IDENTITY-PROFILE-01 §§2–4. No authority is inferred from document metadata. */
|
|
2
|
+
export { checkIdentityUnit, IDENTITY_ADMISSION_ORDER, IdentityAdmission, type IdentityAdmissionDiagnostic, IdentityAdmissionError, } from "./admission.js";
|
|
3
|
+
export { type IdentityIntent, type IdentityOptions, type IdentityReceiveResult, type IdentityRefusal, IdentityReplica, type IdentityUnit, identityCodec, identitySnapshotCodec, } from "./replica.js";
|
|
4
|
+
export { IDENTITY_DIAGNOSTIC_ORDER, type IdentityDiagnostic, type IdentityProblem, type IdentityValidation, validateIdentity, } from "./schema.js";
|
|
5
|
+
export { type DeviceEntry, deriveIdentityActorId, IDENTITY_PROFILE_ID, type IdentityDocument, type IdentityEpoch, type IdentityWriter, type IdentityWriterClass, isResourceRef, type PairingRequest, type ResourceRef, type RosterEntry, } from "./values.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** IDENTITY-PROFILE-01 §§2–4. No authority is inferred from document metadata. */
|
|
2
|
+
export { checkIdentityUnit, IDENTITY_ADMISSION_ORDER, IdentityAdmission, IdentityAdmissionError, } from "./admission.js";
|
|
3
|
+
export { IdentityReplica, identityCodec, identitySnapshotCodec, } from "./replica.js";
|
|
4
|
+
export { IDENTITY_DIAGNOSTIC_ORDER, validateIdentity, } from "./schema.js";
|
|
5
|
+
export { deriveIdentityActorId, IDENTITY_PROFILE_ID, isResourceRef, } from "./values.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
export type Value = readonly [type: string, value: unknown, op?: string | undefined];
|
|
3
|
+
/** SOP §45: identical scalar writes count once; distinct objects never compare equal. */
|
|
4
|
+
export declare function distinct(values: readonly Value[]): Value[];
|
|
5
|
+
/** Backend types distinguish scalar strings from Text and uint from other numeric types. */
|
|
6
|
+
export declare class IdentityReader {
|
|
7
|
+
readonly backend: ReturnType<typeof A.getBackend>;
|
|
8
|
+
constructor(doc: A.Doc<unknown>);
|
|
9
|
+
all(obj: string, key: string | number): Value[];
|
|
10
|
+
keys(obj: string): string[];
|
|
11
|
+
map(obj: string, key: string): string | undefined;
|
|
12
|
+
list(obj: string): Value[][];
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=reader.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
const OBJECTS = new Set(["map", "list", "text", "table"]);
|
|
3
|
+
/** SOP §45: identical scalar writes count once; distinct objects never compare equal. */
|
|
4
|
+
export function distinct(values) {
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
return values.filter(([type, value]) => {
|
|
7
|
+
if (OBJECTS.has(type))
|
|
8
|
+
return true;
|
|
9
|
+
const key = `${type}:${String(value)}`;
|
|
10
|
+
if (seen.has(key))
|
|
11
|
+
return false;
|
|
12
|
+
seen.add(key);
|
|
13
|
+
return true;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Backend types distinguish scalar strings from Text and uint from other numeric types. */
|
|
17
|
+
export class IdentityReader {
|
|
18
|
+
backend;
|
|
19
|
+
constructor(doc) {
|
|
20
|
+
this.backend = A.getBackend(doc);
|
|
21
|
+
}
|
|
22
|
+
all(obj, key) {
|
|
23
|
+
return distinct(this.backend.getAll(obj, key)).map(([type, value, op]) => [
|
|
24
|
+
type,
|
|
25
|
+
typeof value === "bigint" &&
|
|
26
|
+
value <= BigInt(Number.MAX_SAFE_INTEGER) &&
|
|
27
|
+
value >= BigInt(Number.MIN_SAFE_INTEGER)
|
|
28
|
+
? Number(value)
|
|
29
|
+
: value,
|
|
30
|
+
op,
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
keys(obj) {
|
|
34
|
+
return this.backend.keys(obj).sort();
|
|
35
|
+
}
|
|
36
|
+
map(obj, key) {
|
|
37
|
+
const values = this.all(obj, key);
|
|
38
|
+
return values.length === 1 && values[0]?.[0] === "map" ? String(values[0][1]) : undefined;
|
|
39
|
+
}
|
|
40
|
+
list(obj) {
|
|
41
|
+
return Array.from({ length: this.backend.length(obj) }, (_, i) => this.all(obj, i));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=reader.js.map
|