@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pairing — Phase 2 first-cut pairing flow for $meshState.
|
|
3
|
+
*
|
|
4
|
+
* Two devices that want to share a $meshState document must exchange three
|
|
5
|
+
* things before sync can begin: the issuer's Ed25519 signing public key
|
|
6
|
+
* (so the receiver can verify ops authored by the issuer), the symmetric
|
|
7
|
+
* document encryption key (so both sides can encrypt and decrypt the
|
|
8
|
+
* shared document), and the issuer's stable peer id (so the receiver
|
|
9
|
+
* knows which entry in its keyring the public key belongs to). This
|
|
10
|
+
* module packs all three into a {@link PairingToken}, serialises it to a
|
|
11
|
+
* compact binary format suitable for QR codes or copy-paste, and provides
|
|
12
|
+
* the matching parse-and-apply flow on the receiving side.
|
|
13
|
+
*
|
|
14
|
+
* Threat model: pairing tokens are transmitted over an out-of-band channel
|
|
15
|
+
* that the user can authenticate visually — typically a QR code on the
|
|
16
|
+
* issuer's device, scanned by the receiver. Because anyone with the token
|
|
17
|
+
* can decrypt and impersonate, the OOB channel is the only authentication.
|
|
18
|
+
* The token includes a TTL (default 10 minutes) so that a token displayed
|
|
19
|
+
* briefly and then dismissed cannot be replayed by an attacker who later
|
|
20
|
+
* gains access to a screenshot. A production deployment would layer a
|
|
21
|
+
* Short Authentication String (SAS) on top — both devices display a code
|
|
22
|
+
* derived from the shared state, and the user verifies they match — but
|
|
23
|
+
* that is a follow-up.
|
|
24
|
+
*
|
|
25
|
+
* The pairing flow is one-way in the Phase 2 first cut. The issuer
|
|
26
|
+
* generates a token and displays it; the receiver applies it and picks
|
|
27
|
+
* up the issuer's keys. The receiver's own keys reach the issuer through
|
|
28
|
+
* the access set: when the receiver sends its first signed op, the issuer
|
|
29
|
+
* records the receiver's public key alongside its peer id and adds it to
|
|
30
|
+
* the keyring. A bidirectional pairing flow that exchanges both sides'
|
|
31
|
+
* keys in a single QR exchange is straightforward to add later but adds
|
|
32
|
+
* UX surface area that is not needed for the mesh transport to work.
|
|
33
|
+
*/
|
|
34
|
+
import type { MeshKeyring } from "./mesh-network-adapter";
|
|
35
|
+
import { type SigningKeyPair } from "./signing";
|
|
36
|
+
/** Current pairing-token format version. Bumped if the wire format changes. */
|
|
37
|
+
export declare const PAIRING_TOKEN_VERSION = 1;
|
|
38
|
+
/** Magic header bytes for sanity-checking parsed tokens. ASCII "PPT1". */
|
|
39
|
+
export declare const PAIRING_TOKEN_MAGIC: Uint8Array<ArrayBuffer>;
|
|
40
|
+
/** Length of the random nonce embedded in every token. */
|
|
41
|
+
export declare const PAIRING_NONCE_BYTES = 16;
|
|
42
|
+
/** Default TTL applied when {@link createPairingToken} is called without an
|
|
43
|
+
* explicit `ttlMs` option. */
|
|
44
|
+
export declare const DEFAULT_PAIRING_TTL_MS: number;
|
|
45
|
+
/**
|
|
46
|
+
* The contents of a pairing token. Both sides operate on this shape; the
|
|
47
|
+
* binary serialisation is purely for transport.
|
|
48
|
+
*/
|
|
49
|
+
export interface PairingToken {
|
|
50
|
+
/** Format version. {@link PAIRING_TOKEN_VERSION} at the time of writing. */
|
|
51
|
+
version: number;
|
|
52
|
+
/** Stable peer id of the issuing device. The receiver records this as
|
|
53
|
+
* the lookup key for the issuer's public key in its keyring. */
|
|
54
|
+
issuerPeerId: string;
|
|
55
|
+
/** Issuer's Ed25519 signing public key (32 bytes). */
|
|
56
|
+
issuerPublicKey: Uint8Array;
|
|
57
|
+
/** Shared document encryption key (32 bytes). The receiver stores this
|
|
58
|
+
* under {@link documentKeyId} in its keyring. */
|
|
59
|
+
documentKey: Uint8Array;
|
|
60
|
+
/** Identifier under which the receiver stores the document key. For the
|
|
61
|
+
* Phase 2 first cut this is typically the well-known DEFAULT_MESH_KEY_ID
|
|
62
|
+
* from mesh-network-adapter; per-document keys (one entry per Automerge
|
|
63
|
+
* document) are a follow-up. */
|
|
64
|
+
documentKeyId: string;
|
|
65
|
+
/** Unix timestamp (milliseconds) after which the token is considered
|
|
66
|
+
* expired and {@link applyPairingToken} refuses to use it. */
|
|
67
|
+
expiresAt: number;
|
|
68
|
+
/** 16-byte random nonce. Carried through serialisation so two tokens
|
|
69
|
+
* with otherwise-identical contents are still distinguishable. */
|
|
70
|
+
nonce: Uint8Array;
|
|
71
|
+
}
|
|
72
|
+
/** Errors thrown by the pairing subsystem. */
|
|
73
|
+
export declare class PairingError extends Error {
|
|
74
|
+
readonly code: "expired" | "wrong-magic" | "unknown-version" | "truncated" | "invalid-public-key" | "invalid-document-key" | "invalid-nonce";
|
|
75
|
+
constructor(message: string, code: PairingError["code"]);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Options for {@link createPairingToken}. The signing identity and the
|
|
79
|
+
* document key are required; everything else is optional with sensible
|
|
80
|
+
* defaults.
|
|
81
|
+
*/
|
|
82
|
+
export interface CreatePairingTokenOptions {
|
|
83
|
+
/** The issuing device's signing keypair. Only the public key ends up in
|
|
84
|
+
* the token; the secret never leaves the issuer. */
|
|
85
|
+
identity: SigningKeyPair;
|
|
86
|
+
/** Stable peer id for the issuing device. */
|
|
87
|
+
issuerPeerId: string;
|
|
88
|
+
/** The symmetric document key the receiver should adopt. If omitted, a
|
|
89
|
+
* fresh key is generated and the caller is responsible for using the
|
|
90
|
+
* same key on the issuing side too. */
|
|
91
|
+
documentKey?: Uint8Array;
|
|
92
|
+
/** Identifier under which the receiver stores the document key. */
|
|
93
|
+
documentKeyId: string;
|
|
94
|
+
/** Time-to-live in milliseconds. Defaults to {@link DEFAULT_PAIRING_TTL_MS}. */
|
|
95
|
+
ttlMs?: number;
|
|
96
|
+
/** Override the current time. Intended for tests; production code should
|
|
97
|
+
* not pass this. */
|
|
98
|
+
now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Generate a fresh {@link PairingToken}. The token is ready to be
|
|
102
|
+
* serialised and displayed to the receiver via an OOB channel.
|
|
103
|
+
*/
|
|
104
|
+
export declare function createPairingToken(options: CreatePairingTokenOptions): PairingToken;
|
|
105
|
+
/**
|
|
106
|
+
* Generate a fresh pairing token *and* a fresh signing keypair in one call.
|
|
107
|
+
* Convenience for first-time setup where the device has no existing
|
|
108
|
+
* identity yet. Returns both so the caller can persist the keypair and
|
|
109
|
+
* then display the token.
|
|
110
|
+
*/
|
|
111
|
+
export declare function createPairingTokenWithFreshIdentity(args: {
|
|
112
|
+
issuerPeerId: string;
|
|
113
|
+
documentKeyId: string;
|
|
114
|
+
ttlMs?: number;
|
|
115
|
+
now?: () => number;
|
|
116
|
+
}): {
|
|
117
|
+
identity: SigningKeyPair;
|
|
118
|
+
token: PairingToken;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Check whether a token has expired against the current wall-clock time
|
|
122
|
+
* (or an injected `now`).
|
|
123
|
+
*/
|
|
124
|
+
export declare function isPairingTokenExpired(token: PairingToken, now?: () => number): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Apply a parsed and validated token to a {@link MeshKeyring}. Mutates the
|
|
127
|
+
* keyring in place: adds the issuer's public key to {@link MeshKeyring.knownPeers}
|
|
128
|
+
* and the document key to {@link MeshKeyring.documentKeys}.
|
|
129
|
+
*
|
|
130
|
+
* Throws {@link PairingError} with code "expired" if the token's TTL has
|
|
131
|
+
* elapsed. The receiver is expected to apply the token promptly after
|
|
132
|
+
* scanning; rejecting expired tokens prevents replay of long-lived
|
|
133
|
+
* captures.
|
|
134
|
+
*/
|
|
135
|
+
export declare function applyPairingToken(token: PairingToken, keyring: MeshKeyring, options?: {
|
|
136
|
+
now?: () => number;
|
|
137
|
+
}): void;
|
|
138
|
+
/**
|
|
139
|
+
* Serialise a token to a binary blob. The wire format is:
|
|
140
|
+
*
|
|
141
|
+
* [4 bytes: magic "PPT1"]
|
|
142
|
+
* [1 byte: version]
|
|
143
|
+
* [4 bytes BE: issuer id byte length]
|
|
144
|
+
* [N bytes: issuer id UTF-8]
|
|
145
|
+
* [32 bytes: issuer public key]
|
|
146
|
+
* [32 bytes: document key]
|
|
147
|
+
* [4 bytes BE: document key id byte length]
|
|
148
|
+
* [M bytes: document key id UTF-8]
|
|
149
|
+
* [8 bytes BE: expiresAt (uint64 milliseconds)]
|
|
150
|
+
* [16 bytes: nonce]
|
|
151
|
+
*
|
|
152
|
+
* Use {@link encodePairingToken} to round-trip through a base64 string.
|
|
153
|
+
*/
|
|
154
|
+
export declare function serialisePairingToken(token: PairingToken): Uint8Array;
|
|
155
|
+
/**
|
|
156
|
+
* Inverse of {@link serialisePairingToken}. Throws {@link PairingError} on
|
|
157
|
+
* malformed input.
|
|
158
|
+
*/
|
|
159
|
+
export declare function parsePairingToken(bytes: Uint8Array): PairingToken;
|
|
160
|
+
/**
|
|
161
|
+
* Serialise a token and base64-encode it for QR-code or copy-paste display.
|
|
162
|
+
* The encoding uses the standard base64 alphabet (not URL-safe) because
|
|
163
|
+
* QR codes encode bytes directly and do not care about URL safety.
|
|
164
|
+
*/
|
|
165
|
+
export declare function encodePairingToken(token: PairingToken): string;
|
|
166
|
+
/**
|
|
167
|
+
* Decode a base64-encoded pairing token produced by {@link encodePairingToken}.
|
|
168
|
+
* Throws {@link PairingError} on malformed input.
|
|
169
|
+
*/
|
|
170
|
+
export declare function decodePairingToken(encoded: string): PairingToken;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-relay-adapter — Phase 1 client helper for connecting a Polly $peerState
|
|
3
|
+
* application to an Automerge-Repo relay server over WebSocket.
|
|
4
|
+
*
|
|
5
|
+
* The Phase 0 base $crdtState and the Phase 1 $peerState wrapper both consume
|
|
6
|
+
* a caller-supplied `Repo` via `configurePeerState`. This module provides the
|
|
7
|
+
* one-call factory that builds a Repo wired to the relay transport: a
|
|
8
|
+
* `WebSocketClientAdapter` from `@automerge/automerge-repo-network-websocket`
|
|
9
|
+
* pointed at the server URL, an `IndexedDBStorageAdapter` for client-side
|
|
10
|
+
* persistence, and a Polly-shaped connection-state signal that the application
|
|
11
|
+
* can render as a diagnostic UI or feed into reconnection logic.
|
|
12
|
+
*
|
|
13
|
+
* The mirror server-side factory is in {@link peer-repo-server}.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { configurePeerState } from "@fairfox/polly";
|
|
18
|
+
* import { createPeerStateClient } from "@fairfox/polly";
|
|
19
|
+
*
|
|
20
|
+
* const { repo, connectionState } = await createPeerStateClient({
|
|
21
|
+
* url: "wss://yourapp.example.com/polly/peer",
|
|
22
|
+
* });
|
|
23
|
+
* configurePeerState(repo);
|
|
24
|
+
*
|
|
25
|
+
* // connectionState is a Signal<"connecting" | "connected" | "disconnected">
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import { Repo } from "@automerge/automerge-repo";
|
|
29
|
+
import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
30
|
+
import { type Signal } from "@preact/signals";
|
|
31
|
+
import { type MeshKeyring } from "./mesh-network-adapter";
|
|
32
|
+
export type PeerRelayConnectionState = "connecting" | "connected" | "disconnected";
|
|
33
|
+
export interface CreatePeerStateClientOptions {
|
|
34
|
+
/** WebSocket URL of the Polly peer-relay server. Use `ws://` for local
|
|
35
|
+
* development and `wss://` for any deployment that terminates TLS. */
|
|
36
|
+
url: string;
|
|
37
|
+
/** Reconnect interval in milliseconds. Defaults to Automerge-Repo's own
|
|
38
|
+
* default (5 seconds at the time of writing). */
|
|
39
|
+
retryInterval?: number;
|
|
40
|
+
/** Optional storage adapter. Applications running in a browser typically
|
|
41
|
+
* pass an `IndexedDBStorageAdapter`; tests pass nothing for a local-only
|
|
42
|
+
* Repo. The default is no storage, which keeps the client purely in-memory. */
|
|
43
|
+
storage?: ConstructorParameters<typeof Repo>[0] extends infer C ? C extends {
|
|
44
|
+
storage?: infer S;
|
|
45
|
+
} ? S : never : never;
|
|
46
|
+
/** Enable Ed25519 signing on every sync message. Adds Byzantine defence:
|
|
47
|
+
* a compromised client cannot push unsigned writes through the relay.
|
|
48
|
+
* Requires a keyring with the local peer's signing identity and the
|
|
49
|
+
* public keys of peers whose ops should be accepted. The server can
|
|
50
|
+
* still read and mutate document contents because the payload is
|
|
51
|
+
* signed, not encrypted. */
|
|
52
|
+
sign?: boolean;
|
|
53
|
+
/** Keyring for the signing layer. Required when `sign` is true. */
|
|
54
|
+
keyring?: MeshKeyring;
|
|
55
|
+
}
|
|
56
|
+
export interface PeerStateClient {
|
|
57
|
+
/** A configured Repo backed by the WebSocket relay. Pass to
|
|
58
|
+
* {@link configurePeerState}. */
|
|
59
|
+
repo: Repo;
|
|
60
|
+
/** Reactive connection state. Updates as the underlying WebSocket opens,
|
|
61
|
+
* closes, and reconnects. */
|
|
62
|
+
connectionState: Signal<PeerRelayConnectionState>;
|
|
63
|
+
/** The underlying network adapter, exposed for advanced use. */
|
|
64
|
+
adapter: WebSocketClientAdapter;
|
|
65
|
+
/** True if the client was constructed with `sign: true`. Used by
|
|
66
|
+
* $peerState primitives to validate per-primitive sign options. */
|
|
67
|
+
signEnabled: boolean;
|
|
68
|
+
/** Disconnect from the relay and tear down the Repo. Awaiting the
|
|
69
|
+
* returned promise drains the Repo's subsystems cleanly. */
|
|
70
|
+
close: () => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Construct a Polly-flavoured client for the peer-relay transport.
|
|
74
|
+
*
|
|
75
|
+
* The returned object includes the Repo, a connection-state signal, the
|
|
76
|
+
* underlying network adapter, and a close function. Production code typically
|
|
77
|
+
* passes the Repo to {@link configurePeerState} and renders the connection
|
|
78
|
+
* state somewhere visible.
|
|
79
|
+
*/
|
|
80
|
+
export declare function createPeerStateClient(options: CreatePeerStateClientOptions): PeerStateClient;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-repo-server — Phase 1 server-side factory for the Polly peer-relay
|
|
3
|
+
* transport. Constructs an Automerge-Repo `Repo` wired to a WebSocket server
|
|
4
|
+
* and a NodeFS storage backend, ready to relay sync messages between
|
|
5
|
+
* connected $peerState clients.
|
|
6
|
+
*
|
|
7
|
+
* The "always-on peer" role for $peerState lives here. The server holds a
|
|
8
|
+
* full Automerge replica of every document, participates in the sync protocol
|
|
9
|
+
* as an ordinary peer, and persists state to disk so the next process restart
|
|
10
|
+
* picks up where the previous one left off. Server-side cron, HTTP handlers,
|
|
11
|
+
* and other compute can open document handles on the returned Repo and mutate
|
|
12
|
+
* them; mutations propagate to connected clients through the same sync
|
|
13
|
+
* protocol that handles client-to-client traffic.
|
|
14
|
+
*
|
|
15
|
+
* The plan originally called this an "Elysia plugin," but Automerge's
|
|
16
|
+
* `WebSocketServerAdapter` requires an `isomorphic-ws` `WebSocketServer`
|
|
17
|
+
* instance — not Elysia's native WebSocket — so the cleanest first cut is a
|
|
18
|
+
* standalone factory that runs its own `ws` server. Elysia integration for
|
|
19
|
+
* authenticated upgrades is a Phase 1.1 follow-up that wraps this factory.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { createPeerRepoServer } from "@fairfox/polly";
|
|
24
|
+
*
|
|
25
|
+
* const server = await createPeerRepoServer({
|
|
26
|
+
* port: 3030,
|
|
27
|
+
* storagePath: "./data/polly-peer",
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Open a document handle on the server's Repo for cron or compute work.
|
|
31
|
+
* const handle = server.repo.create({ counter: 0 });
|
|
32
|
+
*
|
|
33
|
+
* // On shutdown:
|
|
34
|
+
* await server.close();
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import { Repo } from "@automerge/automerge-repo";
|
|
38
|
+
import { WebSocketServerAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
39
|
+
import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs";
|
|
40
|
+
import * as ws from "ws";
|
|
41
|
+
type WebSocketServer = ws.WebSocketServer;
|
|
42
|
+
export interface CreatePeerRepoServerOptions {
|
|
43
|
+
/** Port to listen on. The factory creates its own `WebSocketServer` and
|
|
44
|
+
* binds to this port. */
|
|
45
|
+
port: number;
|
|
46
|
+
/** Filesystem directory for the NodeFS storage adapter. The directory is
|
|
47
|
+
* created on demand. Defaults to `./automerge-repo-data` (Automerge's own
|
|
48
|
+
* default). */
|
|
49
|
+
storagePath?: string;
|
|
50
|
+
/** Hostname interface to bind to. Defaults to all interfaces. */
|
|
51
|
+
host?: string;
|
|
52
|
+
/** Override the `WebSocketServer` instance entirely. When provided, `port`
|
|
53
|
+
* and `host` are ignored and the caller is responsible for the lifecycle.
|
|
54
|
+
* Useful for tests that want to bind to a random port. */
|
|
55
|
+
webSocketServer?: WebSocketServer;
|
|
56
|
+
}
|
|
57
|
+
export interface PeerRepoServer {
|
|
58
|
+
/** A configured Repo participating as the always-on peer. Server-side
|
|
59
|
+
* cron and HTTP handlers can open document handles on this directly. */
|
|
60
|
+
repo: Repo;
|
|
61
|
+
/** The underlying WebSocket server. Exposed for advanced use such as
|
|
62
|
+
* health checks or graceful shutdown coordination. */
|
|
63
|
+
webSocketServer: WebSocketServer;
|
|
64
|
+
/** The Automerge network adapter wrapping the WebSocket server. */
|
|
65
|
+
adapter: WebSocketServerAdapter;
|
|
66
|
+
/** The NodeFS storage adapter writing to {@link CreatePeerRepoServerOptions.storagePath}. */
|
|
67
|
+
storage: NodeFSStorageAdapter;
|
|
68
|
+
/** Tear down the server: disconnect peers, flush storage, close the
|
|
69
|
+
* underlying WebSocket server. Returns a promise that resolves once the
|
|
70
|
+
* tear-down is complete. */
|
|
71
|
+
close: () => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Construct a Polly peer-relay server. Returns a Repo that participates as
|
|
75
|
+
* the always-on peer, the underlying WebSocket server and storage adapter
|
|
76
|
+
* for advanced use, and a close function for orderly shutdown.
|
|
77
|
+
*
|
|
78
|
+
* Applications typically call this once at startup, hold the returned
|
|
79
|
+
* `repo` reference for cron and compute work, and wire the close function
|
|
80
|
+
* into their process shutdown signal handlers.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createPeerRepoServer(options: CreatePeerRepoServerOptions): Promise<PeerRepoServer>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-state — Phase 1 wrappers exposing $peerState, $peerText, $peerCounter,
|
|
3
|
+
* and $peerList. These are the application-facing constructors for the middle
|
|
4
|
+
* resilience tier in RFC-041: every device is a full Automerge replica, the
|
|
5
|
+
* server included, and server-side code can read and mutate document contents
|
|
6
|
+
* because the server participates in the data plane as an ordinary peer.
|
|
7
|
+
*
|
|
8
|
+
* Each primitive wraps the corresponding Phase 0 base ($crdtState, $crdtText,
|
|
9
|
+
* $crdtCounter, $crdtList) with three additions:
|
|
10
|
+
*
|
|
11
|
+
* 1. The `primitive` label is hard-coded to "peerState" so the
|
|
12
|
+
* primitive-registry collision detection knows which family the key
|
|
13
|
+
* belongs to.
|
|
14
|
+
*
|
|
15
|
+
* 2. A handle factory that resolves the application's logical key to an
|
|
16
|
+
* Automerge DocumentId via a per-Repo key map. The first time a key is
|
|
17
|
+
* registered, the factory creates a new document on the configured Repo
|
|
18
|
+
* and records the mapping. On subsequent constructions of the same key,
|
|
19
|
+
* the factory looks up the existing DocumentId and finds the handle.
|
|
20
|
+
*
|
|
21
|
+
* 3. The `sign` option field validates that the configured Repo was
|
|
22
|
+
* created with signing enabled (via createPeerStateClient with
|
|
23
|
+
* `sign: true`). Signing adds Byzantine defence at the transport
|
|
24
|
+
* level without preventing the server from reading document
|
|
25
|
+
* contents. Encryption is not offered on $peerState because it
|
|
26
|
+
* would prevent the server from participating as an Automerge
|
|
27
|
+
* peer; applications that want encrypted state should use $meshState.
|
|
28
|
+
*
|
|
29
|
+
* The Repo itself is supplied by the application via {@link configurePeerState}
|
|
30
|
+
* or per-call via the `repo` option. There is no transport in this Phase 1
|
|
31
|
+
* cut — applications use a local-only Repo and document operations stay
|
|
32
|
+
* inside the calling process. Phase 1's WebSocket relay adapter will plug in
|
|
33
|
+
* via the same configuration path; Phase 2's mesh adapter does the same for
|
|
34
|
+
* $meshState.
|
|
35
|
+
*/
|
|
36
|
+
import type { Repo } from "@automerge/automerge-repo";
|
|
37
|
+
import type { Access } from "./access";
|
|
38
|
+
import { type SpecialisedPrimitive } from "./crdt-specialised";
|
|
39
|
+
import { type CrdtPrimitive } from "./crdt-state";
|
|
40
|
+
import type { Migrations, VersionedDoc } from "./schema-version";
|
|
41
|
+
/** Common option shape across all four $peer* primitives. */
|
|
42
|
+
export interface PeerStateOptions<T> {
|
|
43
|
+
/** Override the default Repo for this primitive. Useful for tests and for
|
|
44
|
+
* applications that maintain multiple Repos (rare). */
|
|
45
|
+
repo?: Repo;
|
|
46
|
+
/** Request per-op Ed25519 signing for this primitive. Signing is a
|
|
47
|
+
* transport-level concern: pass `sign: true` to `createPeerStateClient`
|
|
48
|
+
* to enable it for all primitives on that Repo. Passing `sign: true`
|
|
49
|
+
* here validates that the configured Repo was created with signing
|
|
50
|
+
* enabled and throws if it was not. */
|
|
51
|
+
sign?: boolean;
|
|
52
|
+
/** Schema version target for the application. Migrations run on load. */
|
|
53
|
+
schemaVersion?: number;
|
|
54
|
+
/** Migration table keyed by target version. Required if schemaVersion is set. */
|
|
55
|
+
migrations?: Migrations;
|
|
56
|
+
/** Declarative read/write access. Compiled into a server share policy
|
|
57
|
+
* once the relay transport is wired in. */
|
|
58
|
+
access?: Access;
|
|
59
|
+
/** Initial value used when this primitive's key has not been registered
|
|
60
|
+
* before. Phase 0 callers passed this positionally; Phase 1 application
|
|
61
|
+
* code does the same. */
|
|
62
|
+
initialValue?: T;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Set the default Repo that the $peer* primitives use when no `repo` option
|
|
66
|
+
* is supplied. Calling this with a new Repo clears the per-Repo key map so
|
|
67
|
+
* that tests start each scenario with a fresh document space.
|
|
68
|
+
*
|
|
69
|
+
* Production code typically calls this once at application startup with a
|
|
70
|
+
* Repo configured for the relay transport. Tests call it before each scenario
|
|
71
|
+
* with an in-memory Repo.
|
|
72
|
+
*/
|
|
73
|
+
export declare function configurePeerState(repo: Repo, options?: {
|
|
74
|
+
signEnabled?: boolean;
|
|
75
|
+
}): void;
|
|
76
|
+
/**
|
|
77
|
+
* Reset the peer-state subsystem to its initial unconfigured state. Intended
|
|
78
|
+
* for tests; production code should not call this.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resetPeerState(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Create a peer-replicated state primitive backed by Automerge. Every device
|
|
83
|
+
* holds a full replica; the server, when one is configured via the relay
|
|
84
|
+
* transport, holds one too and participates in the sync protocol as an
|
|
85
|
+
* ordinary peer. Server-side code can read and mutate document contents.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* const settings = $peerState<Settings>("settings", { theme: "dark" });
|
|
90
|
+
* await settings.loaded;
|
|
91
|
+
* settings.value = { theme: "light" };
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare function $peerState<T extends VersionedDoc>(key: string, initialValue: T, options?: PeerStateOptions<T>): CrdtPrimitive<T>;
|
|
95
|
+
export interface PeerTextOptions extends Omit<PeerStateOptions<unknown>, "initialValue"> {
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Create a peer-replicated text primitive. Concurrent character-level edits
|
|
99
|
+
* from peers merge cleanly via Automerge's updateText splicing.
|
|
100
|
+
*/
|
|
101
|
+
export declare function $peerText(key: string, initialValue: string, options?: PeerTextOptions): SpecialisedPrimitive<string>;
|
|
102
|
+
export interface PeerCounterOptions extends Omit<PeerStateOptions<unknown>, "initialValue"> {
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create a peer-replicated counter primitive. Concurrent increments from
|
|
106
|
+
* peers commute, so two clients each adding 1 to a counter at 5 produce a
|
|
107
|
+
* counter at 7 after merging.
|
|
108
|
+
*/
|
|
109
|
+
export declare function $peerCounter(key: string, initialValue: number, options?: PeerCounterOptions): SpecialisedPrimitive<number>;
|
|
110
|
+
export interface PeerListOptions extends Omit<PeerStateOptions<unknown>, "initialValue"> {
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create a peer-replicated list primitive. The Phase 0 base uses naive
|
|
114
|
+
* whole-array replacement; Phase 1.1 will refine the write path with
|
|
115
|
+
* structural diff-to-splice for concurrent insert/remove preservation.
|
|
116
|
+
*/
|
|
117
|
+
export declare function $peerList<T>(key: string, initialValue: T[], options?: PeerListOptions): SpecialisedPrimitive<T[]>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrimitiveRegistry — runtime namespace collision detection across Polly's
|
|
3
|
+
* synced state primitives.
|
|
4
|
+
*
|
|
5
|
+
* The three primitive families ($sharedState, $peerState, $meshState) each store
|
|
6
|
+
* data under a developer-chosen logical key. If two different primitives both
|
|
7
|
+
* claim the same key, the developer almost certainly has a bug: the on-disk
|
|
8
|
+
* formats are incompatible, no sync happens between them, and whichever primitive
|
|
9
|
+
* resolves first silently "wins" from the developer's perspective. By the time
|
|
10
|
+
* the mistake is noticed, data has diverged.
|
|
11
|
+
*
|
|
12
|
+
* The registry catches the mistake at the first mismatched registration and
|
|
13
|
+
* throws a structured error naming the key, both primitives, and (when available)
|
|
14
|
+
* the call site of each registration. This is run-to-failure by design: a
|
|
15
|
+
* collision is always a bug, and the failure should be loud.
|
|
16
|
+
*
|
|
17
|
+
* Same primitive re-registering the same key is allowed and is a no-op — it
|
|
18
|
+
* supports hot module reloading and component re-mounts without spurious errors.
|
|
19
|
+
* Changing the primitive kind of an existing key is still an error; developers
|
|
20
|
+
* doing that during local HMR should hard-reload to reset the registry.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* primitiveRegistry.register("notes", "sharedState", "src/app.ts:10");
|
|
25
|
+
* primitiveRegistry.register("notes", "peerState", "src/other.ts:22");
|
|
26
|
+
* // throws PrimitiveCollisionError — names both primitives and both call sites
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Canonical identifiers for Polly's synced state primitives. The registry
|
|
31
|
+
* uses these as opaque labels; nothing else in Polly needs to match them.
|
|
32
|
+
*/
|
|
33
|
+
export type PrimitiveKind = "sharedState" | "syncedState" | "persistedState" | "state" | "peerState" | "meshState";
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when a logical key is registered under more than one primitive.
|
|
36
|
+
* The message names the key, both primitives, and (when available) the
|
|
37
|
+
* call site of each registration, so the developer can navigate to both
|
|
38
|
+
* sites from the error output.
|
|
39
|
+
*/
|
|
40
|
+
export declare class PrimitiveCollisionError extends Error {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly firstPrimitive: PrimitiveKind;
|
|
43
|
+
readonly firstCallSite: string | undefined;
|
|
44
|
+
readonly secondPrimitive: PrimitiveKind;
|
|
45
|
+
readonly secondCallSite: string | undefined;
|
|
46
|
+
constructor(key: string, firstPrimitive: PrimitiveKind, firstCallSite: string | undefined, secondPrimitive: PrimitiveKind, secondCallSite: string | undefined);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A small Map-backed registry of "logical key → primitive kind". Exported as
|
|
50
|
+
* a class so tests can construct fresh instances without sharing state; the
|
|
51
|
+
* module-level {@link primitiveRegistry} singleton is what application code
|
|
52
|
+
* actually uses.
|
|
53
|
+
*/
|
|
54
|
+
export declare class PrimitiveRegistry {
|
|
55
|
+
private readonly entries;
|
|
56
|
+
/**
|
|
57
|
+
* Register a key under a primitive kind. Re-registering the same key under
|
|
58
|
+
* the same primitive is a no-op, which is what hot module reloading and
|
|
59
|
+
* component re-mounts produce.
|
|
60
|
+
*
|
|
61
|
+
* @throws {PrimitiveCollisionError} if the key is already registered under
|
|
62
|
+
* a different primitive kind.
|
|
63
|
+
*/
|
|
64
|
+
register(key: string, primitive: PrimitiveKind, callSite?: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* True if the key has been registered (under any primitive kind).
|
|
67
|
+
*/
|
|
68
|
+
has(key: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Look up the primitive kind a key is registered under, if any.
|
|
71
|
+
* Returns undefined for unregistered keys.
|
|
72
|
+
*/
|
|
73
|
+
kindOf(key: string): PrimitiveKind | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Drop every registration. Intended for test setup and teardown; application
|
|
76
|
+
* code should not call this.
|
|
77
|
+
*/
|
|
78
|
+
clear(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Number of registered keys. Intended for tests.
|
|
81
|
+
*/
|
|
82
|
+
get size(): number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The process-wide primitive registry. Application code registers here
|
|
86
|
+
* implicitly via primitive constructors; tests can reset it with `clear()`.
|
|
87
|
+
*/
|
|
88
|
+
export declare const primitiveRegistry: PrimitiveRegistry;
|