@fairfox/polly 0.20.1 → 0.22.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 +83 -3
- package/dist/cli/polly.js +21 -1
- package/dist/cli/polly.js.map +3 -3
- package/dist/src/background/index.js.map +7 -7
- package/dist/src/background/message-router.js.map +7 -7
- package/dist/src/elysia/index.d.ts +2 -0
- package/dist/src/elysia/index.js +177 -17
- package/dist/src/elysia/index.js.map +8 -5
- package/dist/src/elysia/peer-repo-plugin.d.ts +79 -0
- package/dist/src/elysia/signaling-server-plugin.d.ts +121 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +90 -1
- package/dist/src/index.js.map +15 -13
- package/dist/src/mesh.d.ts +29 -0
- package/dist/src/mesh.js +1502 -0
- package/dist/src/mesh.js.map +22 -0
- package/dist/src/peer.d.ts +29 -0
- package/dist/src/peer.js +928 -0
- package/dist/src/peer.js.map +20 -0
- package/dist/src/shared/adapters/index.js.map +6 -6
- package/dist/src/shared/lib/_client-only.d.ts +38 -0
- package/dist/src/shared/lib/access.d.ts +124 -0
- package/dist/src/shared/lib/blob-ref.d.ts +72 -0
- package/dist/src/shared/lib/context-helpers.js.map +7 -7
- package/dist/src/shared/lib/crdt-specialised.d.ts +129 -0
- package/dist/src/shared/lib/crdt-state.d.ts +86 -0
- package/dist/src/shared/lib/encryption.d.ts +117 -0
- package/dist/src/shared/lib/mesh-network-adapter.d.ts +130 -0
- package/dist/src/shared/lib/mesh-signaling-client.d.ts +85 -0
- package/dist/src/shared/lib/mesh-state.d.ts +102 -0
- package/dist/src/shared/lib/mesh-webrtc-adapter.d.ts +132 -0
- package/dist/src/shared/lib/message-bus.js.map +7 -7
- package/dist/src/shared/lib/migrate-primitive.d.ts +100 -0
- package/dist/src/shared/lib/pairing.d.ts +170 -0
- package/dist/src/shared/lib/peer-relay-adapter.d.ts +80 -0
- package/dist/src/shared/lib/peer-repo-server.d.ts +83 -0
- package/dist/src/shared/lib/peer-state.d.ts +117 -0
- package/dist/src/shared/lib/primitive-registry.d.ts +88 -0
- package/dist/src/shared/lib/resource.js.map +4 -4
- package/dist/src/shared/lib/revocation.d.ts +126 -0
- package/dist/src/shared/lib/schema-version.d.ts +129 -0
- package/dist/src/shared/lib/signing.d.ts +118 -0
- package/dist/src/shared/lib/state.js.map +4 -4
- package/dist/src/shared/state/app-state.js.map +5 -5
- package/dist/tools/init/src/cli.js.map +1 -1
- package/dist/tools/quality/src/cli.js +162 -0
- package/dist/tools/quality/src/cli.js.map +11 -0
- package/dist/tools/test/src/adapters/index.js.map +2 -2
- package/dist/tools/test/src/browser/harness.d.ts +80 -0
- package/dist/tools/test/src/browser/index.d.ts +32 -0
- package/dist/tools/test/src/browser/index.js +243 -0
- package/dist/tools/test/src/browser/index.js.map +10 -0
- package/dist/tools/test/src/browser/run.d.ts +26 -0
- package/dist/tools/test/src/index.js.map +2 -2
- package/dist/tools/verify/specs/tla/MeshState.cfg +21 -0
- package/dist/tools/verify/specs/tla/MeshState.tla +247 -0
- package/dist/tools/verify/specs/tla/PeerState.cfg +27 -0
- package/dist/tools/verify/specs/tla/PeerState.tla +238 -0
- package/dist/tools/verify/specs/tla/README.md +27 -3
- package/dist/tools/verify/src/cli.js.map +8 -8
- package/dist/tools/visualize/src/cli.js.map +7 -7
- package/package.json +51 -5
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crdt-specialised — text, counter, and list variants for Polly's peer-first
|
|
3
|
+
* state primitives.
|
|
4
|
+
*
|
|
5
|
+
* Where the base $crdtState binds a signal to a whole Automerge document with
|
|
6
|
+
* naive top-level structural assignment, the specialised variants each own a
|
|
7
|
+
* single field inside their document and use type-specific Automerge
|
|
8
|
+
* operations to mutate it. This is the difference between "your write
|
|
9
|
+
* survives a concurrent edit" and "your write clobbers the other peer's
|
|
10
|
+
* edit," and it matters most for text editing where every keystroke from a
|
|
11
|
+
* concurrent peer would otherwise be lost.
|
|
12
|
+
*
|
|
13
|
+
* - **$crdtText** stores its value in `doc.text` and writes via
|
|
14
|
+
* `Automerge.updateText`, which computes the minimal sequence of character
|
|
15
|
+
* splices between the previous and new strings and records each one as a
|
|
16
|
+
* CRDT operation. Concurrent text edits merge.
|
|
17
|
+
*
|
|
18
|
+
* - **$crdtCounter** stores its value in `doc.count` as an `Automerge.Counter`
|
|
19
|
+
* instance and writes via `counter.increment(delta)`. Increments commute
|
|
20
|
+
* across peers, so two clients each adding 1 to a counter at 5 produce a
|
|
21
|
+
* counter at 7 after merging — last-write-wins semantics on a plain number
|
|
22
|
+
* would lose one of the increments.
|
|
23
|
+
*
|
|
24
|
+
* - **$crdtList** stores its value in `doc.items` and, for the Phase 0 cut,
|
|
25
|
+
* uses naive whole-array replacement on every write. This is correct for
|
|
26
|
+
* single-writer scenarios and good enough to exercise the rest of the
|
|
27
|
+
* pipeline. Phase 1 will replace the write path with proper diff-to-splice
|
|
28
|
+
* logic so that concurrent inserts and removes preserve list ordering.
|
|
29
|
+
*
|
|
30
|
+
* All three variants share a single internal factory that handles the
|
|
31
|
+
* primitive-registry guard, migration-registry guard, schema-version
|
|
32
|
+
* migration, hydration promise, two-way binding with an `updating` flag,
|
|
33
|
+
* and the {@link MigratableState} interface. Variants differ only in the
|
|
34
|
+
* `extractValue` and `applyWrite` hooks they pass.
|
|
35
|
+
*/
|
|
36
|
+
import { Counter, type DocHandle } from "@automerge/automerge-repo";
|
|
37
|
+
import type { Access } from "./access";
|
|
38
|
+
import { type MigratableState } from "./migrate-primitive";
|
|
39
|
+
import { type PrimitiveKind } from "./primitive-registry";
|
|
40
|
+
import { type Migrations, type VersionedDoc } from "./schema-version";
|
|
41
|
+
/**
|
|
42
|
+
* Public interface for a specialised primitive. The signal value type V is
|
|
43
|
+
* not the same as the underlying Automerge document — for example, $crdtText
|
|
44
|
+
* exposes a string signal whose value lives at `doc.text` inside the
|
|
45
|
+
* document. Implements {@link MigratableState} so the cross-primitive
|
|
46
|
+
* migration helper can consume it directly.
|
|
47
|
+
*/
|
|
48
|
+
export interface SpecialisedPrimitive<V> extends MigratableState<V> {
|
|
49
|
+
readonly key: string;
|
|
50
|
+
readonly primitive: PrimitiveKind;
|
|
51
|
+
value: V;
|
|
52
|
+
readonly loaded: Promise<void>;
|
|
53
|
+
readonly handle: DocHandle<unknown> | undefined;
|
|
54
|
+
}
|
|
55
|
+
/** The Automerge document shape backing a $crdtText primitive. */
|
|
56
|
+
export type TextDoc = VersionedDoc & {
|
|
57
|
+
text?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Options for {@link $crdtText}. */
|
|
60
|
+
export interface CrdtTextOptions {
|
|
61
|
+
/** Primitive kind label. Defaults to "peerState"; Phase 2's $meshState
|
|
62
|
+
* wrapper passes "meshState" instead. */
|
|
63
|
+
primitive?: PrimitiveKind;
|
|
64
|
+
/** Async factory that returns a ready DocHandle for the text document. */
|
|
65
|
+
getHandle: () => Promise<DocHandle<TextDoc>>;
|
|
66
|
+
schemaVersion?: number;
|
|
67
|
+
migrations?: Migrations;
|
|
68
|
+
access?: Access;
|
|
69
|
+
callSite?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create a CRDT-backed text primitive. The signal exposes a plain string;
|
|
73
|
+
* writes are diffed into character-level splices via `Automerge.updateText`
|
|
74
|
+
* so that concurrent edits from peers merge cleanly rather than clobbering
|
|
75
|
+
* each other.
|
|
76
|
+
*/
|
|
77
|
+
export declare function $crdtText(key: string, initialValue: string, options: CrdtTextOptions): SpecialisedPrimitive<string>;
|
|
78
|
+
/** The Automerge document shape backing a $crdtCounter primitive. */
|
|
79
|
+
export type CounterDoc = VersionedDoc & {
|
|
80
|
+
count?: Counter;
|
|
81
|
+
};
|
|
82
|
+
/** Options for {@link $crdtCounter}. */
|
|
83
|
+
export interface CrdtCounterOptions {
|
|
84
|
+
primitive?: PrimitiveKind;
|
|
85
|
+
getHandle: () => Promise<DocHandle<CounterDoc>>;
|
|
86
|
+
schemaVersion?: number;
|
|
87
|
+
migrations?: Migrations;
|
|
88
|
+
access?: Access;
|
|
89
|
+
callSite?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a CRDT-backed counter primitive. The signal exposes a plain number;
|
|
93
|
+
* writes compute the delta from the document's current value and call
|
|
94
|
+
* `counter.increment(delta)` on the underlying `Automerge.Counter`. Concurrent
|
|
95
|
+
* increments from peers commute, so two clients each adding 1 to a counter at
|
|
96
|
+
* 5 produce a counter at 7 after merging.
|
|
97
|
+
*
|
|
98
|
+
* Application code that wants to express increments idiomatically can write
|
|
99
|
+
* `counter.value = counter.value + 1`; the signal's reactivity captures the
|
|
100
|
+
* read-then-write pattern and the factory translates it into a proper CRDT
|
|
101
|
+
* increment operation underneath.
|
|
102
|
+
*/
|
|
103
|
+
export declare function $crdtCounter(key: string, initialValue: number, options: CrdtCounterOptions): SpecialisedPrimitive<number>;
|
|
104
|
+
/** The Automerge document shape backing a $crdtList primitive. */
|
|
105
|
+
export type ListDoc<T> = VersionedDoc & {
|
|
106
|
+
items?: T[];
|
|
107
|
+
};
|
|
108
|
+
/** Options for {@link $crdtList}. */
|
|
109
|
+
export interface CrdtListOptions<T> {
|
|
110
|
+
primitive?: PrimitiveKind;
|
|
111
|
+
getHandle: () => Promise<DocHandle<ListDoc<T>>>;
|
|
112
|
+
schemaVersion?: number;
|
|
113
|
+
migrations?: Migrations;
|
|
114
|
+
access?: Access;
|
|
115
|
+
callSite?: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Create a CRDT-backed list primitive. The signal exposes a plain array;
|
|
119
|
+
* for the Phase 0 cut, writes replace the underlying array wholesale inside
|
|
120
|
+
* an `Automerge.change` block. This is correct for single-writer scenarios
|
|
121
|
+
* and is the simplest way to ship a working list primitive on the same
|
|
122
|
+
* pipeline as text and counter.
|
|
123
|
+
*
|
|
124
|
+
* Phase 1 will replace the write path with proper structural diffing into
|
|
125
|
+
* insert and remove operations so that concurrent edits from peers preserve
|
|
126
|
+
* list ordering. Until then, applications using $crdtList in a multi-writer
|
|
127
|
+
* setting should expect last-writer-wins semantics on the array as a whole.
|
|
128
|
+
*/
|
|
129
|
+
export declare function $crdtList<T>(key: string, initialValue: T[], options: CrdtListOptions<T>): SpecialisedPrimitive<T[]>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crdt-state — base machinery for Polly's peer-first state primitives.
|
|
3
|
+
*
|
|
4
|
+
* This module is transport-agnostic: it takes a caller-supplied async factory
|
|
5
|
+
* that produces a ready {@link DocHandle}, binds it bidirectionally to a
|
|
6
|
+
* Preact signal, runs any pending schema migrations on load, and integrates
|
|
7
|
+
* with the primitive-registry and migration-registry guards. Phase 1's
|
|
8
|
+
* $peerState and Phase 2's $meshState both construct these base primitives
|
|
9
|
+
* with their own handle factories — one over Automerge-Repo's WebSocket
|
|
10
|
+
* client adapter, the other over WebRTC — and the base never knows which.
|
|
11
|
+
*
|
|
12
|
+
* The signal-to-handle binding uses an `updating` guard flag to prevent write
|
|
13
|
+
* loops: when a local signal assignment runs the effect that pushes the value
|
|
14
|
+
* into `handle.change`, the flag is raised so that the 'change' event the
|
|
15
|
+
* handle fires back is ignored. The same flag protects in the other direction
|
|
16
|
+
* when a remote change seeds the signal.
|
|
17
|
+
*
|
|
18
|
+
* For the Phase 0 cut, writes are applied with a naive top-level structural
|
|
19
|
+
* replacement inside the `Automerge.change` block. This is correct for
|
|
20
|
+
* JSON-shaped documents with scalar and flat-object fields and is good enough
|
|
21
|
+
* to exercise the rest of the pipeline. The specialised variants for text,
|
|
22
|
+
* counters, and lists (which require type-specific operation capture to
|
|
23
|
+
* preserve concurrent-edit semantics) land in Phase 1's crdt-specialised.ts.
|
|
24
|
+
*/
|
|
25
|
+
import type { DocHandle } from "@automerge/automerge-repo";
|
|
26
|
+
import type { Access } from "./access";
|
|
27
|
+
import { type MigratableState } from "./migrate-primitive";
|
|
28
|
+
import { type PrimitiveKind } from "./primitive-registry";
|
|
29
|
+
import { type Migrations, type VersionedDoc } from "./schema-version";
|
|
30
|
+
/**
|
|
31
|
+
* The interface a Polly peer-first primitive exposes at the call site. It
|
|
32
|
+
* satisfies {@link MigratableState} so that the cross-primitive migration
|
|
33
|
+
* helper can consume it directly.
|
|
34
|
+
*/
|
|
35
|
+
export interface CrdtPrimitive<T extends VersionedDoc> extends MigratableState<T> {
|
|
36
|
+
/** Stable logical key the primitive was registered under. */
|
|
37
|
+
readonly key: string;
|
|
38
|
+
/** Primitive kind — one of the {@link PrimitiveKind} labels. */
|
|
39
|
+
readonly primitive: PrimitiveKind;
|
|
40
|
+
/** Current value. Writes push into the backing Automerge document. */
|
|
41
|
+
value: T;
|
|
42
|
+
/** Resolves when the handle is ready and migrations have run. */
|
|
43
|
+
readonly loaded: Promise<void>;
|
|
44
|
+
/** The underlying {@link DocHandle}, populated after {@link loaded} resolves.
|
|
45
|
+
* Intended for advanced escape hatches; most callers should stay at the
|
|
46
|
+
* signal surface. */
|
|
47
|
+
readonly handle: DocHandle<T> | undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Options for constructing a base CRDT-backed primitive. Phase 1 and Phase 2
|
|
51
|
+
* primitive constructors pass a transport-specific {@link getHandle} factory
|
|
52
|
+
* and their own {@link primitive} label; everything else is shared.
|
|
53
|
+
*/
|
|
54
|
+
export interface CrdtStateOptions<T extends VersionedDoc> {
|
|
55
|
+
/** Stable logical key identifying this piece of state. */
|
|
56
|
+
key: string;
|
|
57
|
+
/** Primitive kind label for registry and error-message purposes. */
|
|
58
|
+
primitive: PrimitiveKind;
|
|
59
|
+
/** Initial value if no stored document exists yet. Applied by the caller's
|
|
60
|
+
* handle factory; the base module does not create documents itself. */
|
|
61
|
+
initialValue: T;
|
|
62
|
+
/** Async factory that resolves to a ready {@link DocHandle}. The factory is
|
|
63
|
+
* responsible for repo lookup, document creation, and any transport-specific
|
|
64
|
+
* setup. The base module calls this once, during hydration. */
|
|
65
|
+
getHandle: () => Promise<DocHandle<T>>;
|
|
66
|
+
/** Target schema version for the application. If set, migrations run on
|
|
67
|
+
* load to bring the document up to this version before the signal hydrates. */
|
|
68
|
+
schemaVersion?: number;
|
|
69
|
+
/** Migration table. Ignored if {@link schemaVersion} is not set. */
|
|
70
|
+
migrations?: Migrations;
|
|
71
|
+
/** Declarative access predicates. Not consumed by the base module; the
|
|
72
|
+
* transport-specific constructors compile it to their enforcement layer. */
|
|
73
|
+
access?: Access;
|
|
74
|
+
/** Optional free-text call-site label for primitive-registry error messages. */
|
|
75
|
+
callSite?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Construct a base CRDT-backed Polly primitive. Integrates with
|
|
79
|
+
* primitive-registry (for collision detection), migration-registry (for
|
|
80
|
+
* cross-family migration guards), and schema-version (for on-load migrations).
|
|
81
|
+
*
|
|
82
|
+
* @throws {MigrationError} if the source key has been marked as migrated.
|
|
83
|
+
* @throws {PrimitiveCollisionError} if the key is already registered under a
|
|
84
|
+
* different primitive kind.
|
|
85
|
+
*/
|
|
86
|
+
export declare function $crdtState<T extends VersionedDoc>(options: CrdtStateOptions<T>): CrdtPrimitive<T>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* encryption — symmetric authenticated encryption for Polly's $meshState
|
|
3
|
+
* primitive (Phase 2). Wraps tweetnacl's secretbox (XSalsa20-Poly1305) with
|
|
4
|
+
* a small Polly-flavoured API so the rest of the codebase never imports
|
|
5
|
+
* tweetnacl directly.
|
|
6
|
+
*
|
|
7
|
+
* Every $meshState document has a per-document symmetric key that is
|
|
8
|
+
* provisioned to authorised peers at pairing time and never held by any
|
|
9
|
+
* server. Outgoing operations are encrypted under this key before they
|
|
10
|
+
* touch the network adapter; incoming operations are decrypted on receipt.
|
|
11
|
+
* The signing layer in {@link signing.ts} provides authenticity (proof of
|
|
12
|
+
* who sent the message); this layer provides confidentiality (the bytes
|
|
13
|
+
* are unreadable to anything that does not hold the document key).
|
|
14
|
+
*
|
|
15
|
+
* tweetnacl's secretbox uses a 32-byte symmetric key and a 24-byte nonce.
|
|
16
|
+
* The output of `nacl.secretbox` is the ciphertext concatenated with a
|
|
17
|
+
* 16-byte Poly1305 authentication tag. We package the nonce + ciphertext
|
|
18
|
+
* into a single binary blob using a small length-prefixed envelope so the
|
|
19
|
+
* receiver can recover the nonce without out-of-band coordination.
|
|
20
|
+
*
|
|
21
|
+
* - {@link generateDocumentKey} returns a fresh 32-byte symmetric key.
|
|
22
|
+
*
|
|
23
|
+
* - {@link encrypt} produces a sealed blob from a payload and a key.
|
|
24
|
+
*
|
|
25
|
+
* - {@link decrypt} recovers the payload from a sealed blob and a key.
|
|
26
|
+
* Returns undefined if the blob is malformed or the authentication
|
|
27
|
+
* tag does not match (i.e. wrong key or tampered ciphertext) — the
|
|
28
|
+
* undefined signal lets call sites distinguish "wrong key" from
|
|
29
|
+
* "structurally invalid" without throwing.
|
|
30
|
+
*
|
|
31
|
+
* - {@link sealEnvelope} and {@link openEnvelope} are convenience helpers
|
|
32
|
+
* that wrap encrypt/decrypt in a structured EncryptedEnvelope shape so
|
|
33
|
+
* the mesh transport layer can handle the binary plumbing uniformly.
|
|
34
|
+
*/
|
|
35
|
+
/** Length in bytes of a secretbox symmetric key. */
|
|
36
|
+
export declare const KEY_BYTES = 32;
|
|
37
|
+
/** Length in bytes of a secretbox nonce. */
|
|
38
|
+
export declare const NONCE_BYTES = 24;
|
|
39
|
+
/** Length in bytes of the Poly1305 authentication tag. */
|
|
40
|
+
export declare const TAG_BYTES = 16;
|
|
41
|
+
/**
|
|
42
|
+
* A sealed blob suitable for storage or network transmission. The wire
|
|
43
|
+
* layout is the concatenation of the nonce and the ciphertext+tag from
|
|
44
|
+
* tweetnacl. Callers should not depend on the exact bytes — round-trip
|
|
45
|
+
* through {@link encrypt} / {@link decrypt} or the envelope helpers.
|
|
46
|
+
*/
|
|
47
|
+
export type SealedBytes = Uint8Array;
|
|
48
|
+
/** Errors thrown by the encryption subsystem. */
|
|
49
|
+
export declare class EncryptionError extends Error {
|
|
50
|
+
readonly code: "invalid-key-length" | "decrypt-failed" | "envelope-malformed";
|
|
51
|
+
constructor(message: string, code: EncryptionError["code"]);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Generate a fresh 32-byte symmetric document key. Calls into tweetnacl's
|
|
55
|
+
* CSPRNG.
|
|
56
|
+
*/
|
|
57
|
+
export declare function generateDocumentKey(): Uint8Array;
|
|
58
|
+
/**
|
|
59
|
+
* Encrypt a payload under a symmetric key. The returned blob includes a
|
|
60
|
+
* fresh nonce so the receiver does not need any out-of-band coordination
|
|
61
|
+
* to decrypt.
|
|
62
|
+
*/
|
|
63
|
+
export declare function encrypt(payload: Uint8Array, key: Uint8Array): SealedBytes;
|
|
64
|
+
/**
|
|
65
|
+
* Decrypt a sealed blob under a symmetric key. Returns the original
|
|
66
|
+
* payload on success. Returns undefined if the blob is too short to
|
|
67
|
+
* contain a nonce and tag, or if the authentication tag does not match
|
|
68
|
+
* (which indicates either a wrong key or a tampered ciphertext).
|
|
69
|
+
*
|
|
70
|
+
* The undefined return is deliberate: call sites typically want to fall
|
|
71
|
+
* back to a different key or surface a "could not decrypt" message rather
|
|
72
|
+
* than catching an exception. Use {@link decryptOrThrow} when an exception
|
|
73
|
+
* is preferred.
|
|
74
|
+
*/
|
|
75
|
+
export declare function decrypt(sealed: SealedBytes, key: Uint8Array): Uint8Array | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Decrypt a sealed blob, throwing {@link EncryptionError} on failure
|
|
78
|
+
* instead of returning undefined.
|
|
79
|
+
*/
|
|
80
|
+
export declare function decryptOrThrow(sealed: SealedBytes, key: Uint8Array): Uint8Array;
|
|
81
|
+
/**
|
|
82
|
+
* A high-level structured envelope combining encryption with a small
|
|
83
|
+
* amount of metadata the receiver needs to find the right key. The mesh
|
|
84
|
+
* network adapter uses this shape on the wire.
|
|
85
|
+
*/
|
|
86
|
+
export interface EncryptedEnvelope {
|
|
87
|
+
/** Stable identifier for the document this payload belongs to. The
|
|
88
|
+
* receiver looks this up in its local key store to find the right key. */
|
|
89
|
+
documentId: string;
|
|
90
|
+
/** Sealed blob containing the encrypted payload plus its nonce. */
|
|
91
|
+
sealed: SealedBytes;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Encrypt a payload and pack it into an {@link EncryptedEnvelope} along
|
|
95
|
+
* with the document id.
|
|
96
|
+
*/
|
|
97
|
+
export declare function sealEnvelope(payload: Uint8Array, documentId: string, key: Uint8Array): EncryptedEnvelope;
|
|
98
|
+
/**
|
|
99
|
+
* Decrypt an {@link EncryptedEnvelope} using the given key. Throws on
|
|
100
|
+
* failure for symmetry with {@link sealEnvelope}.
|
|
101
|
+
*/
|
|
102
|
+
export declare function openEnvelope(envelope: EncryptedEnvelope, key: Uint8Array): Uint8Array;
|
|
103
|
+
/**
|
|
104
|
+
* Serialise an {@link EncryptedEnvelope} to a single binary blob.
|
|
105
|
+
*
|
|
106
|
+
* Wire format:
|
|
107
|
+
*
|
|
108
|
+
* [4 bytes BE: documentId byte length]
|
|
109
|
+
* [N bytes: documentId UTF-8]
|
|
110
|
+
* [remaining: sealed blob (nonce + ciphertext + tag)]
|
|
111
|
+
*/
|
|
112
|
+
export declare function encodeEncryptedEnvelope(envelope: EncryptedEnvelope): Uint8Array;
|
|
113
|
+
/**
|
|
114
|
+
* Deserialise a binary envelope produced by {@link encodeEncryptedEnvelope}.
|
|
115
|
+
* Throws on malformed input.
|
|
116
|
+
*/
|
|
117
|
+
export declare function decodeEncryptedEnvelope(bytes: Uint8Array): EncryptedEnvelope;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-network-adapter — Phase 2 wrapping NetworkAdapter that adds Polly's
|
|
3
|
+
* mesh-transport semantics on top of any underlying Automerge NetworkAdapter.
|
|
4
|
+
*
|
|
5
|
+
* The mesh transport's job is to make every message between peers signed
|
|
6
|
+
* and encrypted before it reaches the wire. Rather than reimplementing the
|
|
7
|
+
* Automerge sync protocol, this adapter takes a base adapter (in production
|
|
8
|
+
* a real WebRTC or WebSocket adapter; in tests an in-memory loopback) and
|
|
9
|
+
* applies the crypto envelope to every message that flows through.
|
|
10
|
+
*
|
|
11
|
+
* Outgoing path (Repo → wire):
|
|
12
|
+
* 1. The Repo's NetworkSubsystem calls send(message) on this adapter.
|
|
13
|
+
* 2. We serialise the message to bytes, encrypt them under the local
|
|
14
|
+
* keyring's document key, sign the resulting blob with the local
|
|
15
|
+
* identity's secret key, and pack the pair into a MeshFrame.
|
|
16
|
+
* 3. We hand the MeshFrame off to the base adapter, which puts it on
|
|
17
|
+
* whatever wire it owns.
|
|
18
|
+
*
|
|
19
|
+
* Incoming path (wire → Repo):
|
|
20
|
+
* 1. The base adapter emits a 'message' event with bytes from the wire.
|
|
21
|
+
* 2. We unpack the MeshFrame, look up the sender's public key in the
|
|
22
|
+
* keyring, verify the signature, look up the document key, decrypt
|
|
23
|
+
* the payload, and deserialise it back to the original message.
|
|
24
|
+
* 3. We re-emit the 'message' event upward to the Repo's NetworkSubsystem
|
|
25
|
+
* with the decrypted message.
|
|
26
|
+
*
|
|
27
|
+
* The keyring is an injected dependency. In production it's backed by
|
|
28
|
+
* persistent storage and populated through the pairing flow. For tests it
|
|
29
|
+
* is just a Map of publicly-known fixtures that both sides share.
|
|
30
|
+
*
|
|
31
|
+
* Caveat for the Phase 2 first cut: Automerge sync messages don't have a
|
|
32
|
+
* stable "what document does this belong to" field at the wire level (the
|
|
33
|
+
* documentId is part of the message contents). The mesh adapter therefore
|
|
34
|
+
* uses a single per-Repo encryption key for now rather than per-document
|
|
35
|
+
* keys, and stores the key once in the keyring under the well-known id
|
|
36
|
+
* "polly-mesh-default". The plan describes per-document keys as the right
|
|
37
|
+
* end state; that requires either parsing the message to extract the
|
|
38
|
+
* documentId before encrypting (peeking inside the binary protocol) or
|
|
39
|
+
* threading the document context through the network subsystem (which
|
|
40
|
+
* needs upstream support). A follow-up will address this.
|
|
41
|
+
*/
|
|
42
|
+
import { type Message, NetworkAdapter, type PeerId, type PeerMetadata } from "@automerge/automerge-repo";
|
|
43
|
+
import { type SigningKeyPair } from "./signing";
|
|
44
|
+
/** The well-known document id used for the Phase 2 first-cut single-key
|
|
45
|
+
* encryption mode. See the file-level comment for the per-document key
|
|
46
|
+
* follow-up. */
|
|
47
|
+
export declare const DEFAULT_MESH_KEY_ID = "polly-mesh-default";
|
|
48
|
+
/**
|
|
49
|
+
* A mesh keyring holds the local peer's signing identity, the public keys
|
|
50
|
+
* of every peer the local node will accept messages from, the symmetric
|
|
51
|
+
* encryption keys for documents the local node has access to, and the set
|
|
52
|
+
* of peers whose keys have been revoked.
|
|
53
|
+
*/
|
|
54
|
+
export interface MeshKeyring {
|
|
55
|
+
/** The local peer's signing keypair. The secret never leaves this
|
|
56
|
+
* keyring; the public key is gossiped through the access set. */
|
|
57
|
+
identity: SigningKeyPair;
|
|
58
|
+
/** Map from peer id (string) to that peer's signing public key. The
|
|
59
|
+
* mesh adapter rejects messages from peers not present in this map. */
|
|
60
|
+
knownPeers: Map<string, Uint8Array>;
|
|
61
|
+
/** Map from document key id (typically the documentId, or the well-known
|
|
62
|
+
* default for the single-key first cut) to the symmetric encryption key. */
|
|
63
|
+
documentKeys: Map<string, Uint8Array>;
|
|
64
|
+
/** Set of peer ids whose keys have been revoked. The mesh adapter drops
|
|
65
|
+
* incoming messages from any peer in this set, even if the peer is still
|
|
66
|
+
* present in {@link knownPeers}. Revocation is applied via the revocation
|
|
67
|
+
* module; the set is kept separate from knownPeers so that an application
|
|
68
|
+
* can audit who was once authorised without losing the revocation record. */
|
|
69
|
+
revokedPeers: Set<string>;
|
|
70
|
+
/** Optional set of peer ids authorised to issue revocations. When present
|
|
71
|
+
* and non-empty, `decodeRevocation` accepts a signed record only if the
|
|
72
|
+
* issuer is in this set. When undefined or empty, any signed revocation
|
|
73
|
+
* from a known peer is accepted (the Phase 2 first-cut default). This
|
|
74
|
+
* field layers a "who can revoke whom" check on top of the signature
|
|
75
|
+
* layer without breaking existing callers. */
|
|
76
|
+
revocationAuthority?: Set<string>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Constructor options for {@link MeshNetworkAdapter}.
|
|
80
|
+
*/
|
|
81
|
+
export interface MeshNetworkAdapterOptions {
|
|
82
|
+
/** The underlying NetworkAdapter that puts crypto-wrapped bytes on the
|
|
83
|
+
* wire. In production this is a WebRTC or WebSocket adapter; in tests
|
|
84
|
+
* it's an in-memory loopback. */
|
|
85
|
+
base: NetworkAdapter;
|
|
86
|
+
/** The local node's keyring. The adapter signs every outgoing message
|
|
87
|
+
* with `identity.secretKey` and verifies every incoming message against
|
|
88
|
+
* the public keys in `knownPeers`. */
|
|
89
|
+
keyring: MeshKeyring;
|
|
90
|
+
/** When false, the adapter signs but does not encrypt. Outgoing messages
|
|
91
|
+
* carry a signature envelope but the payload is plaintext; incoming
|
|
92
|
+
* messages are verified against the sender's public key without a
|
|
93
|
+
* decryption step. This mode is used by $peerState's `sign: true`
|
|
94
|
+
* option, where the server must still be able to parse Automerge sync
|
|
95
|
+
* messages. Defaults to true (encrypt + sign, the full $meshState
|
|
96
|
+
* posture). */
|
|
97
|
+
encryptionEnabled?: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* NetworkAdapter that wraps another adapter with Polly's mesh-transport
|
|
101
|
+
* crypto envelope. Every outgoing message is encrypted then signed; every
|
|
102
|
+
* incoming message is verified then decrypted before being forwarded to
|
|
103
|
+
* the Repo's network subsystem.
|
|
104
|
+
*
|
|
105
|
+
* The adapter delegates lifecycle (connect, disconnect, isReady, peer
|
|
106
|
+
* discovery) to the base adapter unchanged. Only the message body is
|
|
107
|
+
* intercepted.
|
|
108
|
+
*/
|
|
109
|
+
export declare class MeshNetworkAdapter extends NetworkAdapter {
|
|
110
|
+
readonly base: NetworkAdapter;
|
|
111
|
+
readonly keyring: MeshKeyring;
|
|
112
|
+
readonly encryptionEnabled: boolean;
|
|
113
|
+
constructor(options: MeshNetworkAdapterOptions);
|
|
114
|
+
isReady(): boolean;
|
|
115
|
+
whenReady(): Promise<void>;
|
|
116
|
+
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void;
|
|
117
|
+
disconnect(): void;
|
|
118
|
+
send(message: Message): void;
|
|
119
|
+
/**
|
|
120
|
+
* Wrap an outgoing Automerge message in an encrypt-then-sign envelope.
|
|
121
|
+
* The wrapped payload is returned as a Message with the original sender
|
|
122
|
+
* and target ids and the crypto blob in the `data` field.
|
|
123
|
+
*/
|
|
124
|
+
private wrap;
|
|
125
|
+
/**
|
|
126
|
+
* Try to unwrap an incoming crypto-wrapped message. Returns the original
|
|
127
|
+
* Message on success, undefined on verification or decryption failure.
|
|
128
|
+
*/
|
|
129
|
+
private tryUnwrap;
|
|
130
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-signaling-client — browser-side client for Polly's signalingServer
|
|
3
|
+
* Elysia plugin. Connects to the signalling WebSocket, registers a peer
|
|
4
|
+
* id, and relays SDP/ICE messages between local WebRTC connections and
|
|
5
|
+
* remote peers.
|
|
6
|
+
*
|
|
7
|
+
* This module is the companion to {@link signalingServer} from the Elysia
|
|
8
|
+
* plugin family. The server accepts "join" and "signal" messages; this
|
|
9
|
+
* client produces them. The protocol matches: opening the connection,
|
|
10
|
+
* sending a "join" with the local peer id, and then using sendSignal()
|
|
11
|
+
* to forward SDP and ICE messages to specific target peers.
|
|
12
|
+
*
|
|
13
|
+
* Because this client is browser-only in its first incarnation — it
|
|
14
|
+
* assumes the global `WebSocket` is available — it cannot be exercised
|
|
15
|
+
* under bun:test the way the server-side plugin is. The first validation
|
|
16
|
+
* of this code path is either a Playwright harness or a human running
|
|
17
|
+
* the browser-side example that consumes it.
|
|
18
|
+
*/
|
|
19
|
+
/** A signal message either sent to or received from the signalling server.
|
|
20
|
+
* Matches the wire format produced by the Elysia signalingServer plugin. */
|
|
21
|
+
export interface SignalingMessage {
|
|
22
|
+
type: "join" | "signal" | "error";
|
|
23
|
+
peerId?: string;
|
|
24
|
+
targetPeerId?: string;
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
reason?: "unknown-target" | "not-joined" | "malformed";
|
|
27
|
+
}
|
|
28
|
+
/** Options for constructing a {@link MeshSignalingClient}. */
|
|
29
|
+
export interface MeshSignalingClientOptions {
|
|
30
|
+
/** The signalling server URL (ws:// or wss://). */
|
|
31
|
+
url: string;
|
|
32
|
+
/** The local peer id that this client will register with on join. */
|
|
33
|
+
peerId: string;
|
|
34
|
+
/** Callback invoked whenever a signal message from another peer arrives.
|
|
35
|
+
* The receiver dispatches to the right PeerConnection based on the
|
|
36
|
+
* `fromPeerId`. */
|
|
37
|
+
onSignal: (fromPeerId: string, payload: unknown) => void;
|
|
38
|
+
/** Optional callback invoked when the server returns an error (for
|
|
39
|
+
* diagnostic UI or reconnection logic). */
|
|
40
|
+
onError?: (reason: string, targetPeerId?: string) => void;
|
|
41
|
+
/** Optional callback for the open and close lifecycle events. */
|
|
42
|
+
onOpen?: () => void;
|
|
43
|
+
onClose?: () => void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Thin wrapper around a WebSocket connection to a Polly signalling server.
|
|
47
|
+
* Handles the join handshake, routes incoming signals to the supplied
|
|
48
|
+
* callback, and exposes a {@link sendSignal} method for outgoing signals.
|
|
49
|
+
*
|
|
50
|
+
* This class is deliberately small. It has no opinion on the signal
|
|
51
|
+
* payload shape (the wire carries it as `unknown`), so it can carry SDP
|
|
52
|
+
* offers, SDP answers, ICE candidates, or any other message the
|
|
53
|
+
* WebRTC adapter wants to exchange with peers.
|
|
54
|
+
*/
|
|
55
|
+
export declare class MeshSignalingClient {
|
|
56
|
+
readonly url: string;
|
|
57
|
+
readonly peerId: string;
|
|
58
|
+
private readonly onSignal;
|
|
59
|
+
private readonly onError?;
|
|
60
|
+
private readonly onOpen?;
|
|
61
|
+
private readonly onClose?;
|
|
62
|
+
private socket;
|
|
63
|
+
private joined;
|
|
64
|
+
constructor(options: MeshSignalingClientOptions);
|
|
65
|
+
/**
|
|
66
|
+
* Open the WebSocket and send the join message. Resolves once the
|
|
67
|
+
* connection is open; callers should not send signals before this
|
|
68
|
+
* promise resolves.
|
|
69
|
+
*/
|
|
70
|
+
connect(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Send a signal to another peer via the signalling server. The server
|
|
73
|
+
* validates the sender (replacing the claimed peerId with the
|
|
74
|
+
* authenticated join id) and routes to the target. Returns true if
|
|
75
|
+
* the message was sent, false if the connection is not open.
|
|
76
|
+
*/
|
|
77
|
+
sendSignal(targetPeerId: string, payload: unknown): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Close the underlying WebSocket connection. The server's close handler
|
|
80
|
+
* will evict this peer from its routing table.
|
|
81
|
+
*/
|
|
82
|
+
close(): void;
|
|
83
|
+
/** True if the signalling connection is open and joined. */
|
|
84
|
+
get isConnected(): boolean;
|
|
85
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-state — Phase 2 wrappers exposing $meshState, $meshText, $meshCounter,
|
|
3
|
+
* and $meshList. These are the application-facing constructors for the
|
|
4
|
+
* strongest resilience tier in RFC-041: every device is a full Automerge
|
|
5
|
+
* replica, the server is *not on the data path at all*, and the application
|
|
6
|
+
* functions with zero server uptime once direct peer connections are
|
|
7
|
+
* established.
|
|
8
|
+
*
|
|
9
|
+
* Each primitive wraps the corresponding Phase 0 base ($crdtState, $crdtText,
|
|
10
|
+
* $crdtCounter, $crdtList) with three additions:
|
|
11
|
+
*
|
|
12
|
+
* 1. The `primitive` label is hard-coded to "meshState" so the
|
|
13
|
+
* primitive-registry collision detection knows which family the key
|
|
14
|
+
* belongs to.
|
|
15
|
+
*
|
|
16
|
+
* 2. A handle factory that resolves the application's logical key to an
|
|
17
|
+
* Automerge DocumentId via a per-Repo key map, identical in shape to
|
|
18
|
+
* the $peerState factory but registered against a separate Repo
|
|
19
|
+
* configured for the mesh transport (signed and encrypted at the
|
|
20
|
+
* network layer).
|
|
21
|
+
*
|
|
22
|
+
* 3. Signing and encryption are mandatory, not optional. Where $peerState
|
|
23
|
+
* accepts encrypt/sign as opt-in flags (currently throwing in Phase 1),
|
|
24
|
+
* $meshState requires every operation to be signed by the originating
|
|
25
|
+
* peer's key and encrypted under the document's symmetric key. The
|
|
26
|
+
* mechanism lives in the wrapping MeshNetworkAdapter that the Repo
|
|
27
|
+
* uses for transport.
|
|
28
|
+
*
|
|
29
|
+
* The Repo itself is supplied by the application via {@link configureMeshState}
|
|
30
|
+
* or per-call via the `repo` option. In Phase 2 the production transport will
|
|
31
|
+
* be a WebRTC mesh adapter wrapping signing+encryption around an in-process
|
|
32
|
+
* RTCDataChannel; for tests and for the early Phase 2 cut, an in-memory
|
|
33
|
+
* loopback adapter pair satisfies the same contract.
|
|
34
|
+
*/
|
|
35
|
+
import type { Repo } from "@automerge/automerge-repo";
|
|
36
|
+
import type { Access } from "./access";
|
|
37
|
+
import { type SpecialisedPrimitive } from "./crdt-specialised";
|
|
38
|
+
import { type CrdtPrimitive } from "./crdt-state";
|
|
39
|
+
import type { Migrations, VersionedDoc } from "./schema-version";
|
|
40
|
+
/** Common option shape across all four $mesh* primitives. */
|
|
41
|
+
export interface MeshStateOptions {
|
|
42
|
+
/** Override the default Repo for this primitive. The Repo must be
|
|
43
|
+
* configured with the mesh transport (signing and encryption at the
|
|
44
|
+
* network layer). */
|
|
45
|
+
repo?: Repo;
|
|
46
|
+
/** Schema version target for the application. Migrations run on load. */
|
|
47
|
+
schemaVersion?: number;
|
|
48
|
+
/** Migration table keyed by target version. Required if schemaVersion is set. */
|
|
49
|
+
migrations?: Migrations;
|
|
50
|
+
/** Declarative read/write access. The mesh transport compiles this into
|
|
51
|
+
* a public-key set used by the signing layer to verify incoming ops. */
|
|
52
|
+
access?: Access;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Set the default Repo that the $mesh* primitives use when no `repo` option
|
|
56
|
+
* is supplied. Calling this with a new Repo clears the per-Repo key map so
|
|
57
|
+
* that tests start each scenario with a fresh document space.
|
|
58
|
+
*
|
|
59
|
+
* Production code typically calls this once at application startup with a
|
|
60
|
+
* Repo configured for the mesh transport. Tests call it before each
|
|
61
|
+
* scenario with an in-memory or loopback Repo.
|
|
62
|
+
*/
|
|
63
|
+
export declare function configureMeshState(repo: Repo): void;
|
|
64
|
+
/**
|
|
65
|
+
* Reset the mesh-state subsystem to its initial unconfigured state.
|
|
66
|
+
* Intended for tests; production code should not call this.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resetMeshState(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Create a peer-replicated state primitive backed by Automerge with a mesh
|
|
71
|
+
* transport. Every device holds a full replica; no central server holds a
|
|
72
|
+
* replica. Operations flow peer-to-peer through signed and encrypted
|
|
73
|
+
* messages on the underlying transport.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const journal = $meshState<Journal>("journal", { entries: [] });
|
|
78
|
+
* await journal.loaded;
|
|
79
|
+
* journal.value = { entries: ["my private thoughts"] };
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function $meshState<T extends VersionedDoc>(key: string, initialValue: T, options?: MeshStateOptions): CrdtPrimitive<T>;
|
|
83
|
+
/**
|
|
84
|
+
* Create a peer-replicated text primitive backed by a mesh transport.
|
|
85
|
+
* Concurrent character-level edits from peers merge cleanly via Automerge's
|
|
86
|
+
* updateText splicing, and every operation is signed and encrypted before
|
|
87
|
+
* leaving the originating peer.
|
|
88
|
+
*/
|
|
89
|
+
export declare function $meshText(key: string, initialValue: string, options?: MeshStateOptions): SpecialisedPrimitive<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Create a peer-replicated counter primitive backed by a mesh transport.
|
|
92
|
+
* Concurrent increments commute, and every operation is signed and
|
|
93
|
+
* encrypted before leaving the originating peer.
|
|
94
|
+
*/
|
|
95
|
+
export declare function $meshCounter(key: string, initialValue: number, options?: MeshStateOptions): SpecialisedPrimitive<number>;
|
|
96
|
+
/**
|
|
97
|
+
* Create a peer-replicated list primitive backed by a mesh transport.
|
|
98
|
+
* Phase 0 naive replacement applies to writes for now; Phase 1.1 will
|
|
99
|
+
* refine with structural diff-to-splice for concurrent insert/remove
|
|
100
|
+
* preservation.
|
|
101
|
+
*/
|
|
102
|
+
export declare function $meshList<T>(key: string, initialValue: T[], options?: MeshStateOptions): SpecialisedPrimitive<T[]>;
|