@openlfcp/shared-objects 0.1.0-rc.1

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/dist/values.js ADDED
@@ -0,0 +1,163 @@
1
+ import { fromBase64url, ID32_LENGTH, LfcpError, principalId, toBase64url, } from "@openlfcp/core";
2
+ import { sha256 } from "@openlfcp/crypto";
3
+ import { ProfileInvalidError } from "./profile-invalid.js";
4
+ /**
5
+ * Value-level rules of SHARED-OBJECTS-PROFILE-01: Principal references,
6
+ * Local Dates, timestamps, reverse-domain names, namespaced values, the
7
+ * Automerge actor ID and the profile framing of Data Unit and Snapshot
8
+ * plaintexts.
9
+ */
10
+ /** §6: the profile identifier. */
11
+ export const PROFILE_ID = "org.openlfcp.shared-objects.v1";
12
+ /** §27: the reference of a Principal ID. */
13
+ export function principalRef(id) {
14
+ return `p:${toBase64url(principalId(id))}`;
15
+ }
16
+ /** True when `text` is a canonical Principal reference of exactly 32 bytes. */
17
+ export function isPrincipalRef(text) {
18
+ if (typeof text !== "string" || !text.startsWith("p:"))
19
+ return false;
20
+ try {
21
+ return fromBase64url(text.slice(2)).length === ID32_LENGTH;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /** The Principal ID a reference names; throws INVALID_BASE64URL / INVALID_LENGTH otherwise. */
28
+ export function parsePrincipalRef(text) {
29
+ if (!text.startsWith("p:"))
30
+ throw new LfcpError("INVALID_BASE64URL", "a Principal reference starts with p:");
31
+ return principalId(fromBase64url(text.slice(2)));
32
+ }
33
+ /** §35: a real Gregorian date YYYY-MM-DD (no time zone). */
34
+ export function isLocalDate(text) {
35
+ if (typeof text !== "string")
36
+ return false;
37
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
38
+ if (m === null)
39
+ return false;
40
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
41
+ const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
42
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
43
+ return mo >= 1 && mo <= 12 && d >= 1 && d <= days[mo - 1];
44
+ }
45
+ /** §28: an RFC 3339 UTC timestamp ending in Z with a real date and time (second 60 for leap seconds). */
46
+ export function isUtcTimestamp(text) {
47
+ if (typeof text !== "string")
48
+ return false;
49
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?Z$/.exec(text);
50
+ return (m !== null &&
51
+ isLocalDate(m[1]) &&
52
+ Number(m[2]) <= 23 &&
53
+ Number(m[3]) <= 59 &&
54
+ Number(m[4]) <= 60);
55
+ }
56
+ const LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
57
+ const REVERSE_DOMAIN = new RegExp(`^${LABEL}(?:\\.${LABEL})+$`);
58
+ const NAMESPACED = new RegExp(`^x/${LABEL}(?:\\.${LABEL})+/[^/]+$`, "u");
59
+ /** §18 ABNF reverse-domain: at least two lowercase LDH labels without leading or trailing hyphen. */
60
+ export const isReverseDomain = (text) => typeof text === "string" && REVERSE_DOMAIN.test(text);
61
+ /** §33 ABNF namespaced-value: x/<reverse-domain>/<value>, value non-empty without "/". */
62
+ export const isNamespacedValue = (text) => typeof text === "string" && NAMESPACED.test(text);
63
+ const ACTOR_DOMAIN = Uint8Array.from("OPENLFCP-SHARED-OBJECTS-ACTOR-v1", (c) => c.charCodeAt(0));
64
+ /**
65
+ * §8: the Automerge actor ID of `principal` editing `resource`:
66
+ * SHA-256(ASCII("OPENLFCP-SHARED-OBJECTS-ACTOR-v1") || resource_id || principal_id),
67
+ * from the raw 32-byte IDs.
68
+ */
69
+ export function deriveActorId(resource, principal) {
70
+ for (const [what, id] of [
71
+ ["the Resource ID", resource],
72
+ ["the Principal ID", principal],
73
+ ]) {
74
+ if (!(id instanceof Uint8Array) || id.length !== ID32_LENGTH)
75
+ throw new LfcpError("INVALID_LENGTH", `${what} must be the raw 32-byte ID`);
76
+ }
77
+ const input = new Uint8Array(ACTOR_DOMAIN.length + 2 * ID32_LENGTH);
78
+ input.set(ACTOR_DOMAIN, 0);
79
+ input.set(resource, ACTOR_DOMAIN.length);
80
+ input.set(principal, ACTOR_DOMAIN.length + ID32_LENGTH);
81
+ return sha256(input);
82
+ }
83
+ /** §11, §13: the only framing version. */
84
+ export const FRAMING_VERSION = 1;
85
+ /** The deterministic CBOR head of a byte string of `length` bytes (major type 2, shortest form). */
86
+ function bstrHead(length) {
87
+ if (length < 24)
88
+ return [0x40 | length];
89
+ if (length < 0x100)
90
+ return [0x58, length];
91
+ if (length < 0x10000)
92
+ return [0x59, length >> 8, length & 0xff];
93
+ if (length <= 0xffffffff)
94
+ return [
95
+ 0x5a,
96
+ (length >>> 24) & 0xff,
97
+ (length >>> 16) & 0xff,
98
+ (length >>> 8) & 0xff,
99
+ length & 0xff,
100
+ ];
101
+ throw new LfcpError("OUT_OF_RANGE", "the framed bytes are too long");
102
+ }
103
+ /**
104
+ * Deterministic CBOR of [1, bstr] (§11 shared-objects-change, §13
105
+ * shared-objects-snapshot): the profile plaintext of a Data Unit (one
106
+ * Automerge change) or a Snapshot (one Automerge full save). The bytes are
107
+ * framed as given; checking that they are a valid Automerge change or save
108
+ * is the Automerge binding (LFCP-031).
109
+ */
110
+ export function frameProfilePayload(bytes) {
111
+ if (!(bytes instanceof Uint8Array))
112
+ throw new LfcpError("INVALID_LENGTH", "framing takes bytes");
113
+ const head = bstrHead(bytes.length);
114
+ const out = new Uint8Array(2 + head.length + bytes.length);
115
+ out.set([0x82, FRAMING_VERSION, ...head], 0);
116
+ out.set(bytes, 2 + head.length);
117
+ return out;
118
+ }
119
+ /**
120
+ * The inner bytes of a framed profile plaintext. §11: a receiver MUST
121
+ * reject plaintext that is not valid CBOR, not a two-element array, or
122
+ * uses an unsupported framing version; this also requires the
123
+ * deterministic encoding and no trailing bytes. Throws PROFILE_INVALID /
124
+ * INVALID_AUTOMERGE_BYTES (§74.1).
125
+ */
126
+ export function unframeProfilePayload(framed) {
127
+ const fail = (why) => {
128
+ throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `invalid profile framing: ${why}`);
129
+ };
130
+ if (!(framed instanceof Uint8Array) || framed.length < 3)
131
+ return fail("too short");
132
+ if (framed[0] !== 0x82)
133
+ return fail("not a two-element array");
134
+ if (framed[1] !== FRAMING_VERSION)
135
+ return fail("unsupported framing version");
136
+ const first = framed[2];
137
+ if (first >> 5 !== 2)
138
+ return fail("the second element is not a byte string");
139
+ const info = first & 0x1f;
140
+ let length;
141
+ let offset;
142
+ const at = (i) => framed[i];
143
+ if (info < 24)
144
+ [length, offset] = [info, 3];
145
+ else if (info === 24)
146
+ [length, offset] = [at(3), 4];
147
+ else if (info === 25)
148
+ [length, offset] = [(at(3) << 8) | at(4), 5];
149
+ else if (info === 26)
150
+ [length, offset] = [((at(3) << 24) >>> 0) + (at(4) << 16) + (at(5) << 8) + at(6), 7];
151
+ else
152
+ return fail("unsupported byte string length");
153
+ if (offset > framed.length)
154
+ return fail("truncated");
155
+ const bytes = framed.subarray(offset);
156
+ if (bytes.length !== length)
157
+ return fail(bytes.length < length ? "truncated" : "trailing bytes");
158
+ const canonical = bstrHead(length);
159
+ if (canonical.length !== offset - 2)
160
+ return fail("non-shortest byte string length");
161
+ return Uint8Array.from(bytes);
162
+ }
163
+ //# sourceMappingURL=values.js.map
package/dist/wasm.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /** What initializing Automerge needs (injectable for tests). */
2
+ export interface AutomergeWasmHooks {
3
+ readonly isInitialized: () => boolean;
4
+ readonly loadBase64: () => Promise<string>;
5
+ readonly initialize: (wasm: Uint8Array) => Promise<void>;
6
+ }
7
+ /**
8
+ * Standard base64 to bytes, fast: Uint8Array.fromBase64 where the runtime
9
+ * has it, else atob and a plain loop. Automerge's own initializeBase64Wasm
10
+ * decodes with Uint8Array.from(atob(s), callback), which takes about 200 ms
11
+ * of main-thread time for its 3.5 MB wasm (measured in Node 24, LFCP-059);
12
+ * this takes under 10 ms.
13
+ */
14
+ export declare function decodeBase64(text: string): Uint8Array;
15
+ /**
16
+ * An idempotent, concurrency-safe initializer: concurrent callers share one
17
+ * in-flight promise; a failure clears it so a later call can retry.
18
+ */
19
+ export declare function automergeInitializer(hooks: AutomergeWasmHooks): () => Promise<void>;
20
+ /**
21
+ * Makes sure Automerge's WebAssembly is ready. Every build of
22
+ * `@automerge/automerge` except `/slim` initializes it on import, so this is
23
+ * a no-op there. A host that aliases `@automerge/automerge` to its `/slim`
24
+ * entry (e.g. a renderer where compiling 3.5 MB of wasm synchronously on the
25
+ * main thread is refused, as Chromium does above 4 KB) awaits this once
26
+ * before using this package: it loads the base64 wasm through a dynamic
27
+ * import, decodes it quickly and compiles it asynchronously. Idempotent and
28
+ * safe to call concurrently; a failed attempt can be retried.
29
+ */
30
+ export declare const initializeAutomerge: () => Promise<void>;
31
+ /** Whether Automerge's WebAssembly is ready (always, except on `/slim` before initializeAutomerge). */
32
+ export declare const isAutomergeInitialized: () => boolean;
33
+ //# sourceMappingURL=wasm.d.ts.map
package/dist/wasm.js ADDED
@@ -0,0 +1,58 @@
1
+ import * as A from "@automerge/automerge";
2
+ /**
3
+ * Standard base64 to bytes, fast: Uint8Array.fromBase64 where the runtime
4
+ * has it, else atob and a plain loop. Automerge's own initializeBase64Wasm
5
+ * decodes with Uint8Array.from(atob(s), callback), which takes about 200 ms
6
+ * of main-thread time for its 3.5 MB wasm (measured in Node 24, LFCP-059);
7
+ * this takes under 10 ms.
8
+ */
9
+ export function decodeBase64(text) {
10
+ const native = Uint8Array.fromBase64;
11
+ if (native !== undefined)
12
+ return native(text);
13
+ const bin = atob(text);
14
+ const out = new Uint8Array(bin.length);
15
+ for (let i = 0; i < bin.length; i++)
16
+ out[i] = bin.charCodeAt(i);
17
+ return out;
18
+ }
19
+ /**
20
+ * An idempotent, concurrency-safe initializer: concurrent callers share one
21
+ * in-flight promise; a failure clears it so a later call can retry.
22
+ */
23
+ export function automergeInitializer(hooks) {
24
+ let inFlight = null;
25
+ return () => {
26
+ if (hooks.isInitialized())
27
+ return Promise.resolve();
28
+ inFlight ??= (async () => {
29
+ try {
30
+ await hooks.initialize(decodeBase64(await hooks.loadBase64()));
31
+ }
32
+ catch (e) {
33
+ inFlight = null;
34
+ throw e;
35
+ }
36
+ })();
37
+ return inFlight;
38
+ };
39
+ }
40
+ /**
41
+ * Makes sure Automerge's WebAssembly is ready. Every build of
42
+ * `@automerge/automerge` except `/slim` initializes it on import, so this is
43
+ * a no-op there. A host that aliases `@automerge/automerge` to its `/slim`
44
+ * entry (e.g. a renderer where compiling 3.5 MB of wasm synchronously on the
45
+ * main thread is refused, as Chromium does above 4 KB) awaits this once
46
+ * before using this package: it loads the base64 wasm through a dynamic
47
+ * import, decodes it quickly and compiles it asynchronously. Idempotent and
48
+ * safe to call concurrently; a failed attempt can be retried.
49
+ */
50
+ export const initializeAutomerge = automergeInitializer({
51
+ isInitialized: () => A.isWasmInitialized(),
52
+ loadBase64: async () => (await import("@automerge/automerge/automerge.wasm.base64")).automergeWasmBase64,
53
+ // initializeWasm compiles and instantiates asynchronously (WebAssembly.instantiate).
54
+ initialize: (wasm) => A.initializeWasm(wasm),
55
+ });
56
+ /** Whether Automerge's WebAssembly is ready (always, except on `/slim` before initializeAutomerge). */
57
+ export const isAutomergeInitialized = () => A.isWasmInitialized();
58
+ //# sourceMappingURL=wasm.js.map
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@openlfcp/shared-objects",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
5
+ "keywords": [
6
+ "openlfcp",
7
+ "lfcp",
8
+ "local-first",
9
+ "end-to-end-encryption",
10
+ "automerge",
11
+ "crdt",
12
+ "tasks",
13
+ "shared-objects"
14
+ ],
15
+ "license": "Apache-2.0",
16
+ "homepage": "https://github.com/openlfcp/sdk-ts/tree/main/packages/shared-objects#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/openlfcp/sdk-ts/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/openlfcp/sdk-ts.git",
23
+ "directory": "packages/shared-objects"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "!dist/**/*.map",
39
+ "!dist/**/*.tsbuildinfo"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "tag": "next"
44
+ },
45
+ "dependencies": {
46
+ "@automerge/automerge": "3.5.0",
47
+ "fflate": "0.8.3",
48
+ "@openlfcp/core": "^0.1.0-rc.1",
49
+ "@openlfcp/crypto": "^0.1.0-rc.1"
50
+ },
51
+ "devDependencies": {
52
+ "fast-check": "4.10.2"
53
+ },
54
+ "scripts": {
55
+ "test": "cd ../.. && vitest run packages/shared-objects/test/",
56
+ "test:property": "cd ../.. && vitest run packages/shared-objects/test/property/",
57
+ "test:property:extended": "cd ../.. && LFCP_PROPERTY_EXTENDED=1 vitest run packages/shared-objects/test/property/"
58
+ }
59
+ }