@meshline/sdk 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/channels/repository.d.ts +73 -0
- package/dist/channels/repository.d.ts.map +1 -0
- package/dist/channels/repository.js +367 -0
- package/dist/channels/repository.js.map +1 -0
- package/dist/client/account-operations.d.ts +41 -0
- package/dist/client/account-operations.d.ts.map +1 -0
- package/dist/client/account-operations.js +304 -0
- package/dist/client/account-operations.js.map +1 -0
- package/dist/client/conversations.d.ts +36 -0
- package/dist/client/conversations.d.ts.map +1 -0
- package/dist/client/conversations.js +144 -0
- package/dist/client/conversations.js.map +1 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +162 -0
- package/dist/client.js.map +1 -0
- package/dist/components/account.d.ts +38 -0
- package/dist/components/account.d.ts.map +1 -0
- package/dist/components/account.js +243 -0
- package/dist/components/account.js.map +1 -0
- package/dist/components/channel.d.ts +78 -0
- package/dist/components/channel.d.ts.map +1 -0
- package/dist/components/channel.js +475 -0
- package/dist/components/channel.js.map +1 -0
- package/dist/components/component.d.ts +49 -0
- package/dist/components/component.d.ts.map +1 -0
- package/dist/components/component.js +194 -0
- package/dist/components/component.js.map +1 -0
- package/dist/components/device.d.ts +60 -0
- package/dist/components/device.d.ts.map +1 -0
- package/dist/components/device.js +648 -0
- package/dist/components/device.js.map +1 -0
- package/dist/components/group.d.ts +141 -0
- package/dist/components/group.d.ts.map +1 -0
- package/dist/components/group.js +933 -0
- package/dist/components/group.js.map +1 -0
- package/dist/components/message.d.ts +63 -0
- package/dist/components/message.d.ts.map +1 -0
- package/dist/components/message.js +439 -0
- package/dist/components/message.js.map +1 -0
- package/dist/components/profile.d.ts +37 -0
- package/dist/components/profile.d.ts.map +1 -0
- package/dist/components/profile.js +232 -0
- package/dist/components/profile.js.map +1 -0
- package/dist/crypto/groups.d.ts +56 -0
- package/dist/crypto/groups.d.ts.map +1 -0
- package/dist/crypto/groups.js +163 -0
- package/dist/crypto/groups.js.map +1 -0
- package/dist/crypto/messages.d.ts +52 -0
- package/dist/crypto/messages.d.ts.map +1 -0
- package/dist/crypto/messages.js +180 -0
- package/dist/crypto/messages.js.map +1 -0
- package/dist/crypto/primitives.d.ts +30 -0
- package/dist/crypto/primitives.d.ts.map +1 -0
- package/dist/crypto/primitives.js +102 -0
- package/dist/crypto/primitives.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -0
- package/dist/groups/account-sync.d.ts +28 -0
- package/dist/groups/account-sync.d.ts.map +1 -0
- package/dist/groups/account-sync.js +235 -0
- package/dist/groups/account-sync.js.map +1 -0
- package/dist/groups/messages.d.ts +39 -0
- package/dist/groups/messages.d.ts.map +1 -0
- package/dist/groups/messages.js +141 -0
- package/dist/groups/messages.js.map +1 -0
- package/dist/groups/operations.d.ts +57 -0
- package/dist/groups/operations.d.ts.map +1 -0
- package/dist/groups/operations.js +225 -0
- package/dist/groups/operations.js.map +1 -0
- package/dist/groups/repository.d.ts +56 -0
- package/dist/groups/repository.d.ts.map +1 -0
- package/dist/groups/repository.js +254 -0
- package/dist/groups/repository.js.map +1 -0
- package/dist/groups/secrets.d.ts +67 -0
- package/dist/groups/secrets.d.ts.map +1 -0
- package/dist/groups/secrets.js +452 -0
- package/dist/groups/secrets.js.map +1 -0
- package/dist/groups/state.d.ts +39 -0
- package/dist/groups/state.d.ts.map +1 -0
- package/dist/groups/state.js +201 -0
- package/dist/groups/state.js.map +1 -0
- package/dist/identity/identifiers.d.ts +9 -0
- package/dist/identity/identifiers.d.ts.map +1 -0
- package/dist/identity/identifiers.js +37 -0
- package/dist/identity/identifiers.js.map +1 -0
- package/dist/identity/neo.d.ts +11 -0
- package/dist/identity/neo.d.ts.map +1 -0
- package/dist/identity/neo.js +77 -0
- package/dist/identity/neo.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions.d.ts +31 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +2 -0
- package/dist/interactions.js.map +1 -0
- package/dist/messages/account-sender.d.ts +9 -0
- package/dist/messages/account-sender.d.ts.map +1 -0
- package/dist/messages/account-sender.js +46 -0
- package/dist/messages/account-sender.js.map +1 -0
- package/dist/messages/contacts.d.ts +103 -0
- package/dist/messages/contacts.d.ts.map +1 -0
- package/dist/messages/contacts.js +525 -0
- package/dist/messages/contacts.js.map +1 -0
- package/dist/messages/outbox.d.ts +66 -0
- package/dist/messages/outbox.d.ts.map +1 -0
- package/dist/messages/outbox.js +167 -0
- package/dist/messages/outbox.js.map +1 -0
- package/dist/messages/receiver.d.ts +35 -0
- package/dist/messages/receiver.d.ts.map +1 -0
- package/dist/messages/receiver.js +113 -0
- package/dist/messages/receiver.js.map +1 -0
- package/dist/messages/repository.d.ts +89 -0
- package/dist/messages/repository.d.ts.map +1 -0
- package/dist/messages/repository.js +295 -0
- package/dist/messages/repository.js.map +1 -0
- package/dist/models/authentication.d.ts +37 -0
- package/dist/models/authentication.d.ts.map +1 -0
- package/dist/models/authentication.js +53 -0
- package/dist/models/authentication.js.map +1 -0
- package/dist/models/channels.d.ts +141 -0
- package/dist/models/channels.d.ts.map +1 -0
- package/dist/models/channels.js +258 -0
- package/dist/models/channels.js.map +1 -0
- package/dist/models/contacts.d.ts +68 -0
- package/dist/models/contacts.d.ts.map +1 -0
- package/dist/models/contacts.js +186 -0
- package/dist/models/contacts.js.map +1 -0
- package/dist/models/content.d.ts +37 -0
- package/dist/models/content.d.ts.map +1 -0
- package/dist/models/content.js +137 -0
- package/dist/models/content.js.map +1 -0
- package/dist/models/device-state.d.ts +8 -0
- package/dist/models/device-state.d.ts.map +1 -0
- package/dist/models/device-state.js +22 -0
- package/dist/models/device-state.js.map +1 -0
- package/dist/models/group-admission.d.ts +111 -0
- package/dist/models/group-admission.d.ts.map +1 -0
- package/dist/models/group-admission.js +153 -0
- package/dist/models/group-admission.js.map +1 -0
- package/dist/models/group-keys.d.ts +30 -0
- package/dist/models/group-keys.d.ts.map +1 -0
- package/dist/models/group-keys.js +52 -0
- package/dist/models/group-keys.js.map +1 -0
- package/dist/models/group-management.d.ts +190 -0
- package/dist/models/group-management.d.ts.map +1 -0
- package/dist/models/group-management.js +263 -0
- package/dist/models/group-management.js.map +1 -0
- package/dist/models/groups.d.ts +81 -0
- package/dist/models/groups.d.ts.map +1 -0
- package/dist/models/groups.js +127 -0
- package/dist/models/groups.js.map +1 -0
- package/dist/models/identity.d.ts +47 -0
- package/dist/models/identity.d.ts.map +1 -0
- package/dist/models/identity.js +136 -0
- package/dist/models/identity.js.map +1 -0
- package/dist/models/messages.d.ts +104 -0
- package/dist/models/messages.d.ts.map +1 -0
- package/dist/models/messages.js +168 -0
- package/dist/models/messages.js.map +1 -0
- package/dist/models/profile.d.ts +24 -0
- package/dist/models/profile.d.ts.map +1 -0
- package/dist/models/profile.js +47 -0
- package/dist/models/profile.js.map +1 -0
- package/dist/models/relay.d.ts +39 -0
- package/dist/models/relay.d.ts.map +1 -0
- package/dist/models/relay.js +160 -0
- package/dist/models/relay.js.map +1 -0
- package/dist/protocol/codec.d.ts +36 -0
- package/dist/protocol/codec.d.ts.map +1 -0
- package/dist/protocol/codec.js +144 -0
- package/dist/protocol/codec.js.map +1 -0
- package/dist/protocol/context.d.ts +14 -0
- package/dist/protocol/context.d.ts.map +1 -0
- package/dist/protocol/context.js +43 -0
- package/dist/protocol/context.js.map +1 -0
- package/dist/protocol/encoding.d.ts +15 -0
- package/dist/protocol/encoding.d.ts.map +1 -0
- package/dist/protocol/encoding.js +133 -0
- package/dist/protocol/encoding.js.map +1 -0
- package/dist/protocol/json.d.ts +16 -0
- package/dist/protocol/json.d.ts.map +1 -0
- package/dist/protocol/json.js +172 -0
- package/dist/protocol/json.js.map +1 -0
- package/dist/protocol/validation.d.ts +4 -0
- package/dist/protocol/validation.d.ts.map +1 -0
- package/dist/protocol/validation.js +18 -0
- package/dist/protocol/validation.js.map +1 -0
- package/dist/runtime/abort.d.ts +6 -0
- package/dist/runtime/abort.d.ts.map +1 -0
- package/dist/runtime/abort.js +32 -0
- package/dist/runtime/abort.js.map +1 -0
- package/dist/runtime/async-gate.d.ts +6 -0
- package/dist/runtime/async-gate.d.ts.map +1 -0
- package/dist/runtime/async-gate.js +15 -0
- package/dist/runtime/async-gate.js.map +1 -0
- package/dist/runtime/async-pulse.d.ts +7 -0
- package/dist/runtime/async-pulse.d.ts.map +1 -0
- package/dist/runtime/async-pulse.js +23 -0
- package/dist/runtime/async-pulse.js.map +1 -0
- package/dist/runtime/clock.d.ts +16 -0
- package/dist/runtime/clock.d.ts.map +1 -0
- package/dist/runtime/clock.js +62 -0
- package/dist/runtime/clock.js.map +1 -0
- package/dist/runtime/events.d.ts +11 -0
- package/dist/runtime/events.d.ts.map +1 -0
- package/dist/runtime/events.js +45 -0
- package/dist/runtime/events.js.map +1 -0
- package/dist/runtime/url.d.ts +3 -0
- package/dist/runtime/url.d.ts.map +1 -0
- package/dist/runtime/url.js +23 -0
- package/dist/runtime/url.js.map +1 -0
- package/dist/storage/signed-request.d.ts +14 -0
- package/dist/storage/signed-request.d.ts.map +1 -0
- package/dist/storage/signed-request.js +26 -0
- package/dist/storage/signed-request.js.map +1 -0
- package/dist/storage/store.d.ts +58 -0
- package/dist/storage/store.d.ts.map +1 -0
- package/dist/storage/store.js +52 -0
- package/dist/storage/store.js.map +1 -0
- package/dist/storage/transaction.d.ts +7 -0
- package/dist/storage/transaction.d.ts.map +1 -0
- package/dist/storage/transaction.js +22 -0
- package/dist/storage/transaction.js.map +1 -0
- package/dist/transport/client.d.ts +61 -0
- package/dist/transport/client.d.ts.map +1 -0
- package/dist/transport/client.js +392 -0
- package/dist/transport/client.js.map +1 -0
- package/dist/transport/discovery.d.ts +19 -0
- package/dist/transport/discovery.d.ts.map +1 -0
- package/dist/transport/discovery.js +75 -0
- package/dist/transport/discovery.js.map +1 -0
- package/dist/transport/endpoint.d.ts +6 -0
- package/dist/transport/endpoint.d.ts.map +1 -0
- package/dist/transport/endpoint.js +35 -0
- package/dist/transport/endpoint.js.map +1 -0
- package/dist/transport/http.d.ts +52 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +128 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/pool.d.ts +31 -0
- package/dist/transport/pool.d.ts.map +1 -0
- package/dist/transport/pool.js +137 -0
- package/dist/transport/pool.js.map +1 -0
- package/dist/transport/relay-error.d.ts +23 -0
- package/dist/transport/relay-error.d.ts.map +1 -0
- package/dist/transport/relay-error.js +47 -0
- package/dist/transport/relay-error.js.map +1 -0
- package/dist/transport/rpc.d.ts +31 -0
- package/dist/transport/rpc.d.ts.map +1 -0
- package/dist/transport/rpc.js +56 -0
- package/dist/transport/rpc.js.map +1 -0
- package/dist/transport/session.d.ts +49 -0
- package/dist/transport/session.d.ts.map +1 -0
- package/dist/transport/session.js +169 -0
- package/dist/transport/session.js.map +1 -0
- package/dist/transport/socket.d.ts +55 -0
- package/dist/transport/socket.d.ts.map +1 -0
- package/dist/transport/socket.js +266 -0
- package/dist/transport/socket.js.map +1 -0
- package/dist/transport/subscription.d.ts +36 -0
- package/dist/transport/subscription.d.ts.map +1 -0
- package/dist/transport/subscription.js +169 -0
- package/dist/transport/subscription.js.map +1 -0
- package/package.json +35 -4
- package/src/channels/repository.ts +244 -0
- package/src/client/account-operations.ts +183 -0
- package/src/client/conversations.ts +95 -0
- package/src/client.ts +108 -0
- package/src/components/account.ts +206 -0
- package/src/components/channel.ts +253 -0
- package/src/components/component.ts +151 -0
- package/src/components/device.ts +455 -0
- package/src/components/group.ts +546 -0
- package/src/components/message.ts +253 -0
- package/src/components/profile.ts +179 -0
- package/src/crypto/groups.ts +107 -0
- package/src/crypto/messages.ts +129 -0
- package/src/crypto/primitives.ts +103 -0
- package/src/errors.ts +13 -0
- package/src/groups/account-sync.ts +144 -0
- package/src/groups/messages.ts +89 -0
- package/src/groups/operations.ts +145 -0
- package/src/groups/repository.ts +161 -0
- package/src/groups/secrets.ts +256 -0
- package/src/groups/state.ts +138 -0
- package/src/identity/identifiers.ts +41 -0
- package/src/identity/neo.ts +72 -0
- package/src/index.ts +52 -0
- package/src/interactions.ts +35 -0
- package/src/messages/account-sender.ts +33 -0
- package/src/messages/contacts.ts +379 -0
- package/src/messages/outbox.ts +152 -0
- package/src/messages/receiver.ts +86 -0
- package/src/messages/repository.ts +230 -0
- package/src/models/authentication.ts +63 -0
- package/src/models/channels.ts +155 -0
- package/src/models/contacts.ts +141 -0
- package/src/models/content.ts +105 -0
- package/src/models/device-state.ts +18 -0
- package/src/models/group-admission.ts +110 -0
- package/src/models/group-keys.ts +34 -0
- package/src/models/group-management.ts +182 -0
- package/src/models/groups.ts +100 -0
- package/src/models/identity.ts +164 -0
- package/src/models/messages.ts +139 -0
- package/src/models/profile.ts +46 -0
- package/src/models/relay.ts +151 -0
- package/src/protocol/codec.ts +144 -0
- package/src/protocol/context.ts +39 -0
- package/src/protocol/encoding.ts +106 -0
- package/src/protocol/json.ts +140 -0
- package/src/protocol/validation.ts +13 -0
- package/src/runtime/abort.ts +27 -0
- package/src/runtime/async-gate.ts +16 -0
- package/src/runtime/async-pulse.ts +20 -0
- package/src/runtime/clock.ts +63 -0
- package/src/runtime/events.ts +31 -0
- package/src/runtime/url.ts +17 -0
- package/src/storage/signed-request.ts +26 -0
- package/src/storage/store.ts +90 -0
- package/src/storage/transaction.ts +17 -0
- package/src/transport/client.ts +328 -0
- package/src/transport/discovery.ts +64 -0
- package/src/transport/endpoint.ts +32 -0
- package/src/transport/http.ts +129 -0
- package/src/transport/pool.ts +115 -0
- package/src/transport/relay-error.ts +46 -0
- package/src/transport/rpc.ts +56 -0
- package/src/transport/session.ts +154 -0
- package/src/transport/socket.ts +248 -0
- package/src/transport/subscription.ts +117 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createAbortController } from '../runtime/abort.js';
|
|
2
|
+
import type { AccountManager } from '../components/account.js';
|
|
3
|
+
import type { DeviceManager } from '../components/device.js';
|
|
4
|
+
import type { ProfileManager } from '../components/profile.js';
|
|
5
|
+
import type { BackgroundFailure } from '../components/component.js';
|
|
6
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
7
|
+
import { validateRelayId } from '../identity/neo.js';
|
|
8
|
+
import { accountDeviceStateCodec, authorizedDevice, certificateId, validateDeviceState, type AccountDeviceState, type AccountRoute } from '../models/identity.js';
|
|
9
|
+
import { accountProfileCodec, validateProfile, type AccountProfile } from '../models/profile.js';
|
|
10
|
+
import type { NetworkContext } from '../protocol/context.js';
|
|
11
|
+
import { canonicalJson, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
12
|
+
import { abortScope, throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
|
|
13
|
+
import type { MeshlineStore, RecordKey } from '../storage/store.js';
|
|
14
|
+
import { updateStore } from '../storage/transaction.js';
|
|
15
|
+
import type { RelayClientPool } from '../transport/pool.js';
|
|
16
|
+
import { RelayError } from '../transport/relay-error.js';
|
|
17
|
+
|
|
18
|
+
export interface AccountEstablishmentOptions { readonly relayId?: string; readonly certificateValiditySeconds?: number; readonly routeValiditySeconds?: number }
|
|
19
|
+
export interface AccountRecoveryOptions extends AccountEstablishmentOptions { readonly previousDeviceState?: AccountDeviceState; readonly deviceStateRevision?: number; readonly routeRevision?: number }
|
|
20
|
+
export interface AccountOperationsOptions {
|
|
21
|
+
readonly store: MeshlineStore; readonly pool: RelayClientPool; readonly account: AccountManager; readonly device: DeviceManager; readonly profile: ProfileManager;
|
|
22
|
+
readonly context: NetworkContext; readonly accountId: string; readonly clock: RuntimeClock;
|
|
23
|
+
report(failure: BackgroundFailure): void;
|
|
24
|
+
}
|
|
25
|
+
const establishmentKey: RecordKey = { collection: 'client_account_operations', key: 'establishment' };
|
|
26
|
+
const migrationKey: RecordKey = { collection: 'client_account_operations', key: 'migration' };
|
|
27
|
+
const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
|
|
28
|
+
const day = 86400;
|
|
29
|
+
function settings(source: AccountEstablishmentOptions) {
|
|
30
|
+
if (source.relayId !== undefined) validateRelayId(source.relayId);
|
|
31
|
+
const certificateValiditySeconds = source.certificateValiditySeconds ?? 365 * day; const routeValiditySeconds = source.routeValiditySeconds ?? 365 * day;
|
|
32
|
+
requireSafeInteger(certificateValiditySeconds, 1); requireSafeInteger(routeValiditySeconds, 1);
|
|
33
|
+
if (certificateValiditySeconds > 720 * day || routeValiditySeconds > 3650 * day) throw new RangeError('Account certificate or route validity exceeds its supported limit.');
|
|
34
|
+
return { ...source, certificateValiditySeconds, routeValiditySeconds };
|
|
35
|
+
}
|
|
36
|
+
function unavailable(error: unknown): boolean {
|
|
37
|
+
if (error instanceof AggregateError) return error.errors.length > 0 && error.errors.every(unavailable);
|
|
38
|
+
return error instanceof TypeError || error instanceof DOMException && ['TimeoutError', 'NetworkError'].includes(error.name)
|
|
39
|
+
|| error instanceof RelayError && ['temporarily_unavailable', 'bad_gateway', 'rate_limited', 'route_stale', 'target_not_local', 'route_not_found'].includes(error.code);
|
|
40
|
+
}
|
|
41
|
+
function lookupFailure(error: unknown): boolean {
|
|
42
|
+
if (error instanceof AggregateError) return error.errors.length > 0 && error.errors.every(lookupFailure);
|
|
43
|
+
return unavailable(error) || error instanceof RelayError || error instanceof ProtocolError && !['invalid_storage', 'invalid_binding', 'context_changed', 'not_initialized', 'disposed'].includes(error.code);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Called under the owning client's account-operation gate. Child publications retain their exact signed requests. */
|
|
47
|
+
export class ClientAccountOperations {
|
|
48
|
+
constructor(readonly host: AccountOperationsOptions) {}
|
|
49
|
+
async #select(requested: string | undefined, signal: AbortSignal): Promise<string> {
|
|
50
|
+
const verify = async (id: string) => { validateRelayId(id); await (await this.host.pool.get(id, undefined, signal)).getDescriptor(signal); return id; };
|
|
51
|
+
if (requested !== undefined) return verify(requested);
|
|
52
|
+
const failures: unknown[] = []; const seen = new Set<string>();
|
|
53
|
+
for await (const entry of this.host.pool.registry.getRelays(signal)) {
|
|
54
|
+
throwIfAborted(signal); if (entry.status !== 'active' || seen.has(entry.relayId)) continue; seen.add(entry.relayId);
|
|
55
|
+
try { return await verify(entry.relayId); }
|
|
56
|
+
catch (error) { throwIfAborted(signal); if (!lookupFailure(error)) throw error; failures.push(error); this.host.report({ operation: 'select_relay', resource: entry.relayId, error }); }
|
|
57
|
+
}
|
|
58
|
+
throw new ProtocolError('no_active_relay', 'No verified active relay is available.', { cause: new AggregateError(failures) });
|
|
59
|
+
}
|
|
60
|
+
async #prepareDevice(validity: number, signal: AbortSignal) {
|
|
61
|
+
const local = this.host.device.local; const now = this.host.clock.nowSeconds();
|
|
62
|
+
if (!local) return this.host.device.createDevice(validity, signal);
|
|
63
|
+
return local.notBefore <= now && now < local.expiresAt ? local : this.host.device.renewDevice(validity, signal);
|
|
64
|
+
}
|
|
65
|
+
async #confirm(relayId: string, signal: AbortSignal): Promise<void> {
|
|
66
|
+
const local = this.host.device.local; if (!local) throw new ProtocolError('device_required', 'No local device has been created.');
|
|
67
|
+
const state = await this.host.device.getOwnDeviceState(relayId, signal); if (!state) throw new ProtocolError('device_state_required', 'Home relay returned no device state after publication.');
|
|
68
|
+
authorizedDevice(state, certificateId(local, this.host.context), this.host.context, this.host.clock.nowSeconds());
|
|
69
|
+
await this.host.pool.get(relayId, { mode: 'device', signer: this.host.device }, signal);
|
|
70
|
+
}
|
|
71
|
+
async #clear(keys: readonly RecordKey[], signal: AbortSignal): Promise<void> {
|
|
72
|
+
await updateStore(this.host.store, keys, snapshot => ({ mutations: keys.flatMap((key, index) => snapshot.sets[index]!.length ? [{ kind: 'delete' as const, ...key }] : []), result: undefined }), signal);
|
|
73
|
+
}
|
|
74
|
+
async establish(source: AccountEstablishmentOptions, signal: AbortSignal): Promise<void> {
|
|
75
|
+
const options = settings(source); const { account, device, store } = this.host;
|
|
76
|
+
const route = await account.getRoute(undefined, signal);
|
|
77
|
+
if (route) {
|
|
78
|
+
if (device.local) {
|
|
79
|
+
const state = await device.getDeviceState(undefined, signal);
|
|
80
|
+
if (state && device.getAuthorizationState(certificateId(device.local, this.host.context)) === 'authorized') { await this.#clear([establishmentKey], signal); return; }
|
|
81
|
+
}
|
|
82
|
+
throw new StateConflictError('The account already has a route. Start its authorized local device or explicitly recover the account.');
|
|
83
|
+
}
|
|
84
|
+
if (account.route) throw new StateConflictError('A previous route is known. Use explicit account recovery instead of initial establishment.');
|
|
85
|
+
const initial = (await store.read([establishmentKey], signal)).sets[0]![0]?.value;
|
|
86
|
+
if (initial) { if (typeof initial.relayId !== 'string') throw new ProtocolError('invalid_storage', 'Stored establishment has no target relay.'); validateRelayId(initial.relayId); }
|
|
87
|
+
if (initial && options.relayId !== undefined && initial.relayId !== options.relayId) throw new StateConflictError('Resume establishment at its original relay or recover explicitly.');
|
|
88
|
+
const relayId = await this.#select(initial?.relayId as string | undefined ?? options.relayId, signal);
|
|
89
|
+
if (!initial) await updateStore(store, [establishmentKey], snapshot => {
|
|
90
|
+
if (snapshot.sets[0]!.length) throw new StateConflictError('Another establishment was staged concurrently.');
|
|
91
|
+
return { mutations: [{ kind: 'put', ...establishmentKey, value: { relayId } }], result: undefined };
|
|
92
|
+
}, signal);
|
|
93
|
+
const certificate = await this.#prepareDevice(options.certificateValiditySeconds, signal);
|
|
94
|
+
const publication = await device.publishDeviceState(relayId, { certificates: [certificate], signal });
|
|
95
|
+
if (publication.status !== 'staged') throw new StateConflictError('Relay already considers this account established. Resolve its route before continuing.');
|
|
96
|
+
await account.publishRoute(relayId, { validitySeconds: options.routeValiditySeconds, signal }); await this.#confirm(relayId, signal);
|
|
97
|
+
await this.#clear([establishmentKey], signal);
|
|
98
|
+
}
|
|
99
|
+
async recover(source: AccountRecoveryOptions, signal: AbortSignal): Promise<void> {
|
|
100
|
+
const options = settings(source); const previous = source.previousDeviceState && accountDeviceStateCodec.decode(accountDeviceStateCodec.encode(source.previousDeviceState));
|
|
101
|
+
if (previous) { validateDeviceState(previous, this.host.context); if (previous.account !== this.host.accountId) throw new ProtocolError('invalid_identity', 'Previous device state belongs to another account.'); }
|
|
102
|
+
if (source.deviceStateRevision !== undefined) requireSafeInteger(source.deviceStateRevision, 0); if (source.routeRevision !== undefined) requireSafeInteger(source.routeRevision, 0);
|
|
103
|
+
const { account, device } = this.host; let route: AccountRoute | undefined;
|
|
104
|
+
try { route = await account.getRoute(undefined, signal); }
|
|
105
|
+
catch (error) { throwIfAborted(signal); if (!lookupFailure(error)) throw error; this.host.report({ operation: 'resolve_recovery_route', error }); }
|
|
106
|
+
let relayId: string;
|
|
107
|
+
if (options.relayId === undefined && route) {
|
|
108
|
+
try { relayId = await this.#select(route.relayId, signal); }
|
|
109
|
+
catch (error) { throwIfAborted(signal); if (!lookupFailure(error)) throw error; this.host.report({ operation: 'select_recovery_relay', resource: route.relayId, error }); relayId = await this.#select(undefined, signal); }
|
|
110
|
+
} else relayId = await this.#select(options.relayId, signal);
|
|
111
|
+
if (account.route) {
|
|
112
|
+
try { await device.getOwnDeviceState(account.route.relayId, signal); }
|
|
113
|
+
catch (error) { throwIfAborted(signal); if (!lookupFailure(error)) throw error; this.host.report({ operation: 'resolve_recovery_devices', resource: account.route.relayId, error }); }
|
|
114
|
+
}
|
|
115
|
+
await this.#prepareDevice(options.certificateValiditySeconds, signal);
|
|
116
|
+
await device.publishDeviceState(relayId, { ...(previous ? { previousState: previous } : {}), recovery: true, ...(source.deviceStateRevision === undefined ? {} : { revision: source.deviceStateRevision }), signal });
|
|
117
|
+
await account.publishRoute(relayId, { validitySeconds: options.routeValiditySeconds, recovery: true, ...(source.routeRevision === undefined ? {} : { revision: source.routeRevision }), signal });
|
|
118
|
+
await this.#confirm(relayId, signal); await this.#clear([establishmentKey, migrationKey], signal);
|
|
119
|
+
}
|
|
120
|
+
async #route(signal: AbortSignal): Promise<AccountRoute | undefined> {
|
|
121
|
+
const previous = this.host.account.route;
|
|
122
|
+
try { return await this.host.account.getRoute(undefined, signal) ?? previous; }
|
|
123
|
+
catch (error) { throwIfAborted(signal); if (!previous || !unavailable(error)) throw error; this.host.report({ operation: 'resolve_migration_route', resource: previous.relayId, error }); return previous; }
|
|
124
|
+
}
|
|
125
|
+
async #deviceState(source: string, fallback: AccountDeviceState | undefined, signal: AbortSignal): Promise<AccountDeviceState> {
|
|
126
|
+
try { await this.host.device.getOwnDeviceState(source, signal); }
|
|
127
|
+
catch (error) { throwIfAborted(signal); if (!unavailable(error)) throw error; this.host.report({ operation: 'resolve_migration_devices', resource: source, error }); }
|
|
128
|
+
const current = this.host.device.deviceState; const state = current && (!fallback || current.revision > fallback.revision) ? current : fallback;
|
|
129
|
+
if (!state) throw new StateConflictError('The complete previous device state is unavailable. Recover explicitly rather than discarding other devices.');
|
|
130
|
+
const local = this.host.device.local;
|
|
131
|
+
if (!local || !state.certificates.some(value => certificateId(value, this.host.context) === certificateId(local, this.host.context))) throw new StateConflictError('Authorize the local device or recover explicitly before migrating.');
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
async #profile(signal: AbortSignal): Promise<AccountProfile | undefined> {
|
|
135
|
+
const previous = this.host.profile.profile;
|
|
136
|
+
try { return await this.host.profile.getProfile(undefined, signal) ?? previous; }
|
|
137
|
+
catch (error) { throwIfAborted(signal); if (!unavailable(error)) throw error; this.host.report({ operation: 'resolve_migration_profile', error }); return previous; }
|
|
138
|
+
}
|
|
139
|
+
#migration(value: JsonObject) {
|
|
140
|
+
if (typeof value.source !== 'string' || typeof value.target !== 'string') throw new ProtocolError('invalid_storage', 'Stored migration has no source or target relay.');
|
|
141
|
+
validateRelayId(value.source); validateRelayId(value.target); requireSafeInteger(value.routeValiditySeconds, 1);
|
|
142
|
+
const state = accountDeviceStateCodec.decode(value.deviceState!); validateDeviceState(state, this.host.context);
|
|
143
|
+
const profile = value.profile === undefined ? undefined : accountProfileCodec.decode(value.profile); if (profile) validateProfile(profile);
|
|
144
|
+
if (state.account !== this.host.accountId || profile && profile.account !== this.host.accountId) throw new ProtocolError('invalid_storage', 'Migration snapshot belongs to another account.');
|
|
145
|
+
return { source: value.source, target: value.target, state, profile, routeValiditySeconds: value.routeValiditySeconds };
|
|
146
|
+
}
|
|
147
|
+
async resumeMigration(signal: AbortSignal): Promise<void> { const value = (await this.host.store.read([migrationKey], signal)).sets[0]![0]?.value; if (value) await this.migrate(this.#migration(value).target, signal); }
|
|
148
|
+
async migrate(relayId: string, signal: AbortSignal): Promise<void> {
|
|
149
|
+
validateRelayId(relayId); const { store, account, device, profile, clock } = this.host;
|
|
150
|
+
let value = (await store.read([migrationKey], signal)).sets[0]![0]?.value; let migration = value && this.#migration(value);
|
|
151
|
+
if (migration && migration.target !== relayId) throw new StateConflictError('Resume migration to its original target or recover explicitly.');
|
|
152
|
+
const route = await this.#route(signal); if (!route) throw new StateConflictError('No previous route is available. Establish or recover the account.');
|
|
153
|
+
if (!migration && route.relayId === relayId && route.expiresAt > clock.nowSeconds()) return;
|
|
154
|
+
await this.#select(relayId, signal);
|
|
155
|
+
if (!migration) {
|
|
156
|
+
const state = await this.#deviceState(route.relayId, undefined, signal); const previousProfile = await this.#profile(signal);
|
|
157
|
+
if (account.route && account.route.relayId !== route.relayId) throw new StateConflictError('Account moved while migration was being prepared.');
|
|
158
|
+
value = { source: route.relayId, target: relayId, deviceState: accountDeviceStateCodec.encode(state), routeValiditySeconds: route.expiresAt - route.updatedAt, ...(previousProfile ? { profile: accountProfileCodec.encode(previousProfile) } : {}) };
|
|
159
|
+
const prepared = value;
|
|
160
|
+
await updateStore(store, [migrationKey, { collection: 'account_routes', key: this.host.accountId }], snapshot => {
|
|
161
|
+
if (snapshot.sets[0]!.length || snapshot.sets[1]![0]?.value.relay_id !== route.relayId) throw new StateConflictError('Account migration or route changed before staging.');
|
|
162
|
+
return { mutations: [{ kind: 'put', ...migrationKey, value: prepared }], result: undefined };
|
|
163
|
+
}, signal); migration = this.#migration(value);
|
|
164
|
+
}
|
|
165
|
+
if (route.relayId !== migration.source && route.relayId !== migration.target) throw new StateConflictError('Account moved to a third relay after this migration began. Resolve the concurrent change or recover explicitly.');
|
|
166
|
+
if (route.relayId !== relayId || route.expiresAt <= clock.nowSeconds()) {
|
|
167
|
+
const state = await this.#deviceState(migration.source, migration.state, signal); const local = device.local!;
|
|
168
|
+
if (local.notBefore > clock.nowSeconds()) throw new StateConflictError('Local device certificate is not yet valid.');
|
|
169
|
+
await this.#prepareDevice(local.expiresAt - local.notBefore, signal); const publication = await device.publishDeviceState(relayId, { previousState: state, signal });
|
|
170
|
+
if (publication.status === 'staged' && publication.stagedUntil! - clock.nowSeconds() < 60) throw new StateConflictError('Staged device state must remain available for at least one minute.');
|
|
171
|
+
if (account.route && account.route.relayId !== migration.source && account.route.relayId !== migration.target) throw new StateConflictError('Account moved to a third relay during migration preparation.');
|
|
172
|
+
const deadline = createAbortController(); const window = abortScope([signal, deadline.signal]);
|
|
173
|
+
const timer = publication.status === 'staged' ? clock.delay(60000, deadline.signal).then(() => deadline.abort(new DOMException('Migration route publication deadline expired.', 'TimeoutError')), error => { if (!deadline.signal.aborted) deadline.abort(error); }) : undefined;
|
|
174
|
+
try { await account.publishRoute(relayId, { validitySeconds: migration.routeValiditySeconds, signal: window.signal }); }
|
|
175
|
+
finally { deadline.abort(); window.dispose(); await timer; }
|
|
176
|
+
}
|
|
177
|
+
await this.#confirm(relayId, signal); await profile.publishProfile(migration.profile, signal);
|
|
178
|
+
await updateStore(store, [migrationKey], snapshot => {
|
|
179
|
+
if (fingerprint(snapshot.sets[0]![0]?.value) !== fingerprint(value)) throw new StateConflictError('Pending migration changed before completion.');
|
|
180
|
+
return { mutations: [{ kind: 'delete', ...migrationKey }], result: undefined };
|
|
181
|
+
}, signal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import { decodeGroupProjection } from '../groups/repository.js';
|
|
3
|
+
import { validateIdentifier } from '../identity/identifiers.js';
|
|
4
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
5
|
+
import { snapshotReader } from '../messages/repository.js';
|
|
6
|
+
import { channelPostCodec, validateChannelRef } from '../models/channels.js';
|
|
7
|
+
import { groupEventCodec } from '../models/group-management.js';
|
|
8
|
+
import { groupMessageCodec, groupMessageEnvelopeCodec } from '../models/groups.js';
|
|
9
|
+
import { directMessageCodec } from '../models/messages.js';
|
|
10
|
+
import { requireObject, requireSafeInteger } from '../protocol/json.js';
|
|
11
|
+
import type { MeshlineStore, QueryReader, RecordQuery, StoreSnapshot } from '../storage/store.js';
|
|
12
|
+
import { updateStore } from '../storage/transaction.js';
|
|
13
|
+
|
|
14
|
+
export type ConversationKind = 'direct' | 'group' | 'channel';
|
|
15
|
+
export interface ConversationSummary { readonly sender: string; readonly timestamp: number; readonly text?: string; readonly hasAttachments: boolean }
|
|
16
|
+
export interface Conversation { readonly conversationId: string; readonly kind: ConversationKind; readonly latest?: ConversationSummary; readonly unreadCount: number }
|
|
17
|
+
export interface ConversationQuery { readonly kinds?: readonly ConversationKind[]; readonly unreadOnly?: boolean; readonly hasMessages?: boolean }
|
|
18
|
+
export interface ConversationChange { readonly conversationId: string; readonly kind: 'created' | 'updated' | 'removed' }
|
|
19
|
+
interface Content { sequence: number; summary: ConversationSummary }
|
|
20
|
+
interface Projection { conversation: Conversation; head?: number }
|
|
21
|
+
const queries: readonly RecordQuery[] = ['messages', 'groups', 'group_events', 'channels', 'channel_posts', 'conversation_reads'].map(collection => ({ collection }));
|
|
22
|
+
const readKey = (conversationId: string) => ({ collection: 'conversation_reads', key: conversationId });
|
|
23
|
+
const compare = (a: string, b: string) => a === b ? 0 : a < b ? -1 : 1;
|
|
24
|
+
export function conversationKind(id: string): ConversationKind {
|
|
25
|
+
if (id.startsWith('grp_')) { validateIdentifier('group', id); return 'group'; }
|
|
26
|
+
if (id.startsWith('chan_')) { validateIdentifier('channel', id); return 'channel'; }
|
|
27
|
+
validateAccountId(id); return 'direct';
|
|
28
|
+
}
|
|
29
|
+
function content(sequence: unknown, sender: unknown, timestamp: unknown, value: { readonly body?: { readonly text?: string }; readonly attachments?: readonly unknown[] }): Content {
|
|
30
|
+
requireSafeInteger(sequence, 0); requireSafeInteger(timestamp, 0); if (typeof sender !== 'string') throw new ProtocolError('invalid_storage', 'Conversation item has no sender.'); validateAccountId(sender);
|
|
31
|
+
return { sequence, summary: { sender, timestamp, ...(value.body?.text === undefined ? {} : { text: value.body.text }), hasAttachments: Boolean(value.attachments?.length) } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** All summaries and read positions come from one fixed storage snapshot. */
|
|
35
|
+
export class ClientConversations {
|
|
36
|
+
constructor(readonly store: MeshlineStore, readonly accountId: string) {}
|
|
37
|
+
#project(snapshot: StoreSnapshot, includeUnfollowed = false): readonly Projection[] {
|
|
38
|
+
const [messages, groups, groupEvents, channels, channelPosts, reads] = snapshot.sets; const positions = new Map<string, number>();
|
|
39
|
+
for (const row of reads!) { conversationKind(row.key); requireSafeInteger(row.value.sequence, 0); positions.set(row.key, row.value.sequence); }
|
|
40
|
+
const records = new Map<string, { kind: ConversationKind; items: Content[] }>();
|
|
41
|
+
const add = (id: string, kind: ConversationKind, item?: Content) => { const record = records.get(id) ?? { kind, items: [] }; if (item) record.items.push(item); records.set(id, record); };
|
|
42
|
+
for (const row of messages!) {
|
|
43
|
+
const value = row.value; if (value.payloadType !== 'meshline.message.direct' || value.sender !== this.accountId && value.recipient !== this.accountId) continue;
|
|
44
|
+
if (typeof value.sender !== 'string' || typeof value.recipient !== 'string') throw new ProtocolError('invalid_storage', 'Direct conversation has malformed participants.');
|
|
45
|
+
const peer = value.sender === this.accountId ? value.recipient : value.sender; validateAccountId(peer);
|
|
46
|
+
add(peer, 'direct', content(value.localSequence, value.sender, value.createdAt, directMessageCodec.decode(value.payload!)));
|
|
47
|
+
}
|
|
48
|
+
const knownGroups = new Set(groups!.map(row => row.key));
|
|
49
|
+
for (const row of groups!) {
|
|
50
|
+
const group = { groupId: row.key, relayId: String(row.value.relayId) }; if (!row.value.projection) continue;
|
|
51
|
+
const state = decodeGroupProjection(requireObject(row.value.projection), group);
|
|
52
|
+
if (row.value.localDepartureAfter !== undefined) { requireSafeInteger(row.value.localDepartureAfter, 0); if (state.sequence <= row.value.localDepartureAfter) continue; }
|
|
53
|
+
if (state.members.some(value => value.account === this.accountId)) add(row.key, 'group');
|
|
54
|
+
}
|
|
55
|
+
for (const row of groupEvents!) {
|
|
56
|
+
const value = row.value; if (value.isMessage !== true || value.decryptedPayload === undefined) continue;
|
|
57
|
+
const event = groupEventCodec.decode(value.event!); const envelope = groupMessageEnvelopeCodec.decode(event.payload);
|
|
58
|
+
if (!knownGroups.has(envelope.groupId) || row.key !== `${envelope.groupId}|${String(event.sequence).padStart(16, '0')}`) throw new ProtocolError('invalid_storage', 'Group conversation event has no consistent hosting association.');
|
|
59
|
+
add(envelope.groupId, 'group', content(event.sequence, requireObject(value.message!).sender, envelope.createdAt, groupMessageCodec.decode(value.decryptedPayload)));
|
|
60
|
+
}
|
|
61
|
+
const followed = new Set<string>();
|
|
62
|
+
for (const row of channels!) {
|
|
63
|
+
validateChannelRef({ channelId: row.key, relayId: String(row.value.relayId) });
|
|
64
|
+
if (typeof row.value.isFollowed !== 'boolean') throw new ProtocolError('invalid_storage', 'Channel conversation has no followed state.');
|
|
65
|
+
if (row.value.isFollowed || includeUnfollowed) { followed.add(row.key); add(row.key, 'channel'); }
|
|
66
|
+
}
|
|
67
|
+
for (const row of channelPosts!) {
|
|
68
|
+
const value = row.value; const channelId = row.key.split('|')[0]!; if (!followed.has(channelId) || value.isDeleted === true || value.post === undefined) continue;
|
|
69
|
+
const post = channelPostCodec.decode(value.post); if (post.channelId !== channelId || row.key !== `${channelId}|${String(value.sequence).padStart(16, '0')}`) throw new ProtocolError('invalid_storage', 'Channel conversation post differs from its storage identity.');
|
|
70
|
+
add(channelId, 'channel', content(value.sequence, value.author, value.acceptedAt, post));
|
|
71
|
+
}
|
|
72
|
+
return [...records].map(([conversationId, record]) => {
|
|
73
|
+
const position = positions.get(conversationId) ?? -1; let latest: Content | undefined; let unreadCount = 0;
|
|
74
|
+
for (const item of record.items) { if (!latest || item.sequence > latest.sequence) latest = item; if (item.summary.sender !== this.accountId && item.sequence > position) unreadCount++; }
|
|
75
|
+
return { conversation: { conversationId, kind: record.kind, unreadCount, ...(latest ? { latest: latest.summary } : {}) }, ...(latest ? { head: latest.sequence } : {}) };
|
|
76
|
+
}).sort((a, b) => (b.conversation.latest?.timestamp ?? -1) - (a.conversation.latest?.timestamp ?? -1) || compare(a.conversation.conversationId, b.conversation.conversationId));
|
|
77
|
+
}
|
|
78
|
+
async list(query: ConversationQuery = {}, signal?: AbortSignal): Promise<QueryReader<Conversation>> {
|
|
79
|
+
const kinds = query.kinds && [...query.kinds]; const { unreadOnly, hasMessages } = query;
|
|
80
|
+
if (kinds?.some(value => !['direct', 'group', 'channel'].includes(value)) || unreadOnly !== undefined && typeof unreadOnly !== 'boolean' || hasMessages !== undefined && typeof hasMessages !== 'boolean') throw new TypeError('Invalid conversation filter.');
|
|
81
|
+
const records = this.#project(await this.store.read(queries, signal));
|
|
82
|
+
return snapshotReader(records.map(value => value.conversation).filter(value => (!kinds || kinds.includes(value.kind)) && (!unreadOnly || value.unreadCount > 0) && (hasMessages === undefined || Boolean(value.latest) === hasMessages)));
|
|
83
|
+
}
|
|
84
|
+
async get(id: string, signal?: AbortSignal): Promise<Conversation | undefined> { conversationKind(id); return this.#project(await this.store.read(queries, signal)).find(value => value.conversation.conversationId === id)?.conversation; }
|
|
85
|
+
async ids(signal?: AbortSignal): Promise<ReadonlySet<string>> { return new Set(this.#project(await this.store.read(queries, signal)).map(value => value.conversation.conversationId)); }
|
|
86
|
+
async markRead(id: string, signal?: AbortSignal): Promise<boolean> {
|
|
87
|
+
conversationKind(id);
|
|
88
|
+
return updateStore(this.store, queries, snapshot => {
|
|
89
|
+
const projection = this.#project(snapshot, true).find(value => value.conversation.conversationId === id); const sequence = projection?.head;
|
|
90
|
+
const before = snapshot.sets[5]!.find(row => row.key === id)?.value.sequence ?? -1; requireSafeInteger(before, -1);
|
|
91
|
+
if (sequence === undefined || sequence <= before) return { mutations: [], result: false };
|
|
92
|
+
return { mutations: [{ kind: 'put', ...readKey(id), value: { sequence } }], result: true };
|
|
93
|
+
}, signal);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createAbortController } from './runtime/abort.js';
|
|
2
|
+
import { AccountManager } from './components/account.js';
|
|
3
|
+
import { ChannelManager } from './components/channel.js';
|
|
4
|
+
import { ClientComponent, type ClientOptions } from './components/component.js';
|
|
5
|
+
import { DeviceManager } from './components/device.js';
|
|
6
|
+
import { GroupManager } from './components/group.js';
|
|
7
|
+
import { MessageManager } from './components/message.js';
|
|
8
|
+
import { ProfileManager } from './components/profile.js';
|
|
9
|
+
import { ClientAccountOperations, type AccountEstablishmentOptions, type AccountRecoveryOptions } from './client/account-operations.js';
|
|
10
|
+
import { ClientConversations, type ConversationChange, type ConversationQuery } from './client/conversations.js';
|
|
11
|
+
import type { RandomSource } from './crypto/primitives.js';
|
|
12
|
+
import type { AccountSigner, SecretProtector } from './interactions.js';
|
|
13
|
+
import { accountDeviceStateCodec } from './models/identity.js';
|
|
14
|
+
import { AsyncGate } from './runtime/async-gate.js';
|
|
15
|
+
import { AsyncPulse } from './runtime/async-pulse.js';
|
|
16
|
+
import { EventHub, type EventListener } from './runtime/events.js';
|
|
17
|
+
import type { MeshlineStore } from './storage/store.js';
|
|
18
|
+
import type { RelayClientPool } from './transport/pool.js';
|
|
19
|
+
|
|
20
|
+
export type { AccountEstablishmentOptions, AccountRecoveryOptions } from './client/account-operations.js';
|
|
21
|
+
export type { Conversation, ConversationChange, ConversationKind, ConversationQuery, ConversationSummary } from './client/conversations.js';
|
|
22
|
+
export interface MeshlineClientEvents { readonly conversationChanged: ConversationChange }
|
|
23
|
+
export interface MeshlineClientOptions extends ClientOptions {
|
|
24
|
+
readonly store: MeshlineStore; readonly relayClients: RelayClientPool; readonly secretProtector: SecretProtector;
|
|
25
|
+
readonly accountSigner?: AccountSigner; readonly random?: RandomSource;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Owns six managers; the application retains ownership of the migrated store, pool, signer and protector. */
|
|
29
|
+
export class MeshlineClient extends ClientComponent {
|
|
30
|
+
readonly accountManager: AccountManager; readonly deviceManager: DeviceManager; readonly profileManager: ProfileManager;
|
|
31
|
+
readonly messageManager: MessageManager; readonly channelManager: ChannelManager; readonly groupManager: GroupManager;
|
|
32
|
+
readonly #components: readonly ClientComponent[]; readonly #detach: (() => void)[] = [];
|
|
33
|
+
readonly #accountGate = new AsyncGate(); readonly #accountOperations: ClientAccountOperations;
|
|
34
|
+
readonly #conversations: ClientConversations; readonly #events = new EventHub<MeshlineClientEvents>();
|
|
35
|
+
readonly #conversationLifetime = createAbortController(); readonly #conversationPulse = new AsyncPulse(); readonly #pendingConversations = new Set<string>();
|
|
36
|
+
#conversationIds: ReadonlySet<string> = new Set(); #conversationJob: Promise<void> | undefined;
|
|
37
|
+
constructor(options: MeshlineClientOptions) {
|
|
38
|
+
super(options);
|
|
39
|
+
this.accountManager = new AccountManager(options);
|
|
40
|
+
this.deviceManager = new DeviceManager({ ...options, accountManager: this.accountManager });
|
|
41
|
+
this.profileManager = new ProfileManager({ ...options, accountManager: this.accountManager, deviceSigner: this.deviceManager });
|
|
42
|
+
this.messageManager = new MessageManager({ ...options, accountManager: this.accountManager, deviceManager: this.deviceManager });
|
|
43
|
+
this.channelManager = new ChannelManager({ ...options, deviceManager: this.deviceManager });
|
|
44
|
+
this.groupManager = new GroupManager({ ...options, deviceManager: this.deviceManager, messageManager: this.messageManager });
|
|
45
|
+
this.#components = [this.accountManager, this.deviceManager, this.profileManager, this.messageManager, this.channelManager, this.groupManager];
|
|
46
|
+
for (const component of this.#components) this.#detach.push(component.onLifecycle('backgroundError', error => this.notifyBackgroundError(error)));
|
|
47
|
+
this.#accountOperations = new ClientAccountOperations({ store: options.store, pool: options.relayClients, account: this.accountManager, device: this.deviceManager, profile: this.profileManager,
|
|
48
|
+
context: this.context, accountId: this.accountId, clock: this.clock, report: failure => this.notifyBackgroundError(failure) });
|
|
49
|
+
this.#conversations = new ClientConversations(options.store, this.accountId);
|
|
50
|
+
this.#detach.push(this.messageManager.on('messageReceived', messages => { for (const message of messages) this.#queueConversation(message.key.sender === this.accountId ? message.recipient : message.key.sender); }),
|
|
51
|
+
this.messageManager.on('sendStatusChanged', status => { if (status.state === 'queued') this.#queueConversation(status.recipient); }),
|
|
52
|
+
this.channelManager.on('timelineChanged', value => this.#queueConversation(value.channel.channelId)), this.channelManager.on('followChanged', value => this.#queueConversation(value.channel.channelId)),
|
|
53
|
+
this.groupManager.on('timelineChanged', value => { if (value.message) this.#queueConversation(value.group.groupId); }), this.groupManager.on('groupChanged', value => this.#queueConversation(value.ref.groupId)));
|
|
54
|
+
}
|
|
55
|
+
get route() { return this.accountManager.route; }
|
|
56
|
+
get device() { return this.deviceManager.local; }
|
|
57
|
+
get deviceState() { return this.deviceManager.deviceState; }
|
|
58
|
+
get profile() { return this.profileManager.profile; }
|
|
59
|
+
on<K extends keyof MeshlineClientEvents>(event: K, listener: EventListener<MeshlineClientEvents[K]>): () => void { return this.#events.on(event, listener); }
|
|
60
|
+
getConversations(query: ConversationQuery = {}, signal?: AbortSignal) { const copy = { ...query, ...(query.kinds ? { kinds: [...query.kinds] } : {}) }; return this.runOperation(scope => this.#conversations.list(copy, scope), signal); }
|
|
61
|
+
getConversation(conversationId: string, signal?: AbortSignal) { return this.runOperation(scope => this.#conversations.get(conversationId, scope), signal); }
|
|
62
|
+
async markRead(conversationId: string, signal?: AbortSignal): Promise<void> { if (await this.runOperation(scope => this.#conversations.markRead(conversationId, scope), signal)) this.#queueConversation(conversationId); }
|
|
63
|
+
#queueConversation(id: string): void { if (this.#conversationLifetime.signal.aborted) return; this.#pendingConversations.add(id); this.#conversationPulse.pulse(); }
|
|
64
|
+
async #observeConversations(): Promise<void> {
|
|
65
|
+
const signal = this.#conversationLifetime.signal;
|
|
66
|
+
try { for (;;) {
|
|
67
|
+
await this.#conversationPulse.wait(signal); const pending = [...this.#pendingConversations]; this.#pendingConversations.clear(); if (!pending.length) continue;
|
|
68
|
+
let present: ReadonlySet<string>;
|
|
69
|
+
try { present = await this.#conversations.ids(signal); }
|
|
70
|
+
catch (error) { if (signal.aborted) throw error; this.notifyBackgroundError({ operation: 'synchronize_conversations', error }); for (const id of pending) this.#queueConversation(id); await this.clock.delay(1000, signal); continue; }
|
|
71
|
+
for (const id of pending) {
|
|
72
|
+
if (signal.aborted) return;
|
|
73
|
+
const kind = present.has(id) ? this.#conversationIds.has(id) ? 'updated' : 'created' : this.#conversationIds.has(id) ? 'removed' : undefined;
|
|
74
|
+
if (kind) this.#events.notify('conversationChanged', { conversationId: id, kind }, error => this.notifyBackgroundError({ operation: 'observer', resource: 'conversationChanged', error }));
|
|
75
|
+
}
|
|
76
|
+
// Preserve membership for other queued IDs until their own notification is processed.
|
|
77
|
+
const next = new Set(this.#conversationIds); for (const id of pending) { if (present.has(id)) next.add(id); else next.delete(id); } this.#conversationIds = next;
|
|
78
|
+
} } catch (error) { if (!signal.aborted) this.notifyBackgroundError({ operation: 'observe_conversations', error }); }
|
|
79
|
+
}
|
|
80
|
+
establishAccount(options: AccountEstablishmentOptions = {}, signal?: AbortSignal): Promise<void> {
|
|
81
|
+
const copy = { ...options }; return this.runOperation(scope => this.#accountGate.run(() => this.#accountOperations.establish(copy, scope), scope), signal);
|
|
82
|
+
}
|
|
83
|
+
recoverAccount(options: AccountRecoveryOptions = {}, signal?: AbortSignal): Promise<void> {
|
|
84
|
+
const copy = { ...options, ...(options.previousDeviceState ? { previousDeviceState: accountDeviceStateCodec.decode(accountDeviceStateCodec.encode(options.previousDeviceState)) } : {}) };
|
|
85
|
+
return this.runOperation(scope => this.#accountGate.run(() => this.#accountOperations.recover(copy, scope), scope), signal);
|
|
86
|
+
}
|
|
87
|
+
changeHomeRelay(relayId: string, signal?: AbortSignal): Promise<void> { return this.runOperation(scope => this.#accountGate.run(() => this.#accountOperations.migrate(relayId, scope), scope), signal); }
|
|
88
|
+
protected override async onInitialize(signal: AbortSignal): Promise<void> {
|
|
89
|
+
for (const component of this.#components) await component.initialize(signal);
|
|
90
|
+
this.#conversationIds = await this.#conversations.ids(signal); this.#conversationJob ??= this.#observeConversations();
|
|
91
|
+
}
|
|
92
|
+
protected override async onStart(signal: AbortSignal): Promise<void> {
|
|
93
|
+
await this.accountManager.start(signal);
|
|
94
|
+
await this.#accountGate.run(() => this.#accountOperations.resumeMigration(signal), signal);
|
|
95
|
+
for (const component of this.#components.slice(1)) await component.start(signal);
|
|
96
|
+
}
|
|
97
|
+
protected override async onStop(): Promise<void> {
|
|
98
|
+
const errors: unknown[] = []; for (const component of [...this.#components].reverse()) { try { await component.stop(); } catch (error) { errors.push(error); } }
|
|
99
|
+
if (errors.length) throw new AggregateError(errors, 'One or more client components failed to stop.');
|
|
100
|
+
}
|
|
101
|
+
protected override async onDispose(): Promise<void> {
|
|
102
|
+
this.#conversationLifetime.abort(); await this.#conversationJob;
|
|
103
|
+
const errors: unknown[] = []; for (const component of [...this.#components].reverse()) { try { await component.dispose(); } catch (error) { errors.push(error); } }
|
|
104
|
+
for (const detach of this.#detach) detach();
|
|
105
|
+
this.#events.clear();
|
|
106
|
+
if (errors.length) throw new AggregateError(errors, 'One or more client components failed to dispose.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
2
|
+
import { matchesAccount, validateAccountId, validateRelayId, verifyRelay } from '../identity/neo.js';
|
|
3
|
+
import type { AccountSigner } from '../interactions.js';
|
|
4
|
+
import { accountRouteCodec, compareRoutes, routeAccountInput, routeRelayInput, validateRoute, type AccountRoute } from '../models/identity.js';
|
|
5
|
+
import { nextRevision } from '../protocol/context.js';
|
|
6
|
+
import { canonicalJson, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
7
|
+
import { AsyncGate } from '../runtime/async-gate.js';
|
|
8
|
+
import { throwIfAborted } from '../runtime/clock.js';
|
|
9
|
+
import { EventHub, type EventListener } from '../runtime/events.js';
|
|
10
|
+
import type { MeshlineStore, RecordKey, StoreMutation } from '../storage/store.js';
|
|
11
|
+
import { finishSignedRequest, putSignedRequest, readSignedRequest, requestKey, type SignedRequest } from '../storage/signed-request.js';
|
|
12
|
+
import { updateStore } from '../storage/transaction.js';
|
|
13
|
+
import type { RelayClientPool } from '../transport/pool.js';
|
|
14
|
+
import { RelayError } from '../transport/relay-error.js';
|
|
15
|
+
import { ClientComponent, type BackgroundFailure, type ClientOptions } from './component.js';
|
|
16
|
+
|
|
17
|
+
export type AccountState = 'unknown' | 'notEstablished' | 'established';
|
|
18
|
+
export interface AccountSnapshot { readonly state: AccountState; readonly route: AccountRoute | undefined }
|
|
19
|
+
export interface AccountEvents { readonly accountChanged: AccountSnapshot }
|
|
20
|
+
export interface AccountManagerOptions extends ClientOptions { readonly store: MeshlineStore; readonly relayClients: RelayClientPool; readonly accountSigner?: AccountSigner }
|
|
21
|
+
export interface PublishRouteOptions { readonly validitySeconds: number; readonly revision?: number; readonly recovery?: boolean; readonly signal?: AbortSignal }
|
|
22
|
+
const method = 'account.route.publish';
|
|
23
|
+
const routeKey = (account: string): RecordKey => ({ collection: 'account_routes', key: account });
|
|
24
|
+
const clone = (route: AccountRoute): AccountRoute => accountRouteCodec.decode(accountRouteCodec.encode(route));
|
|
25
|
+
const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
|
|
26
|
+
|
|
27
|
+
/** Signed route discovery and durable, explicit publication retries. */
|
|
28
|
+
export class AccountManager extends ClientComponent {
|
|
29
|
+
readonly #store: MeshlineStore;
|
|
30
|
+
readonly #pool: RelayClientPool;
|
|
31
|
+
readonly #signer: AccountSigner | undefined;
|
|
32
|
+
readonly #gate = new AsyncGate();
|
|
33
|
+
readonly #events = new EventHub<AccountEvents>();
|
|
34
|
+
#state: AccountState = 'unknown';
|
|
35
|
+
#route: AccountRoute | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(options: AccountManagerOptions) {
|
|
38
|
+
super(options);
|
|
39
|
+
if (options.relayClients.accountId !== this.accountId || options.relayClients.context.toString() !== this.context.toString())
|
|
40
|
+
throw new ProtocolError('invalid_context', 'Relay pool belongs to another account or network.');
|
|
41
|
+
this.#store = options.store; this.#pool = options.relayClients; this.#signer = options.accountSigner;
|
|
42
|
+
}
|
|
43
|
+
get state(): AccountState { return this.#state; }
|
|
44
|
+
get route(): AccountRoute | undefined { return this.#route && clone(this.#route); }
|
|
45
|
+
on<K extends keyof AccountEvents>(event: K, listener: EventListener<AccountEvents[K]>): () => void { return this.#events.on(event, listener); }
|
|
46
|
+
protected override async onInitialize(signal: AbortSignal): Promise<void> {
|
|
47
|
+
await this.#store.initialize({ context: this.context.toString(), accountId: this.accountId }, signal);
|
|
48
|
+
const value = (await this.#store.read([routeKey(this.accountId)], signal)).sets[0]![0]?.value;
|
|
49
|
+
const route = value === undefined ? undefined : accountRouteCodec.decode(value);
|
|
50
|
+
if (route) {
|
|
51
|
+
validateRoute(route, this.context, 0);
|
|
52
|
+
if (route.account !== this.accountId) throw new ProtocolError('invalid_storage', 'Stored route belongs to another account.');
|
|
53
|
+
}
|
|
54
|
+
this.#route = route; this.#state = route && route.expiresAt > this.clock.nowSeconds() ? 'established' : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
#accept(route: AccountRoute | undefined): boolean {
|
|
57
|
+
const state = route ? 'established' : 'unknown';
|
|
58
|
+
const changed = this.#state !== state || route !== undefined && accountRouteCodec.stringify(route) !== (this.#route && accountRouteCodec.stringify(this.#route));
|
|
59
|
+
this.#state = state;
|
|
60
|
+
if (route) this.#route = clone(route);
|
|
61
|
+
return changed;
|
|
62
|
+
}
|
|
63
|
+
#changed(): void { this.#events.notify('accountChanged', { state: this.state, route: this.route }, error => this.notifyBackgroundError({ operation: 'observer', resource: 'accountChanged', error })); }
|
|
64
|
+
async getRoute(accountId = this.accountId, signal?: AbortSignal): Promise<AccountRoute | undefined> {
|
|
65
|
+
validateAccountId(accountId);
|
|
66
|
+
const failures: BackgroundFailure[] = [];
|
|
67
|
+
let changed = false;
|
|
68
|
+
let route: AccountRoute | undefined;
|
|
69
|
+
let operationError: unknown; let failed = false;
|
|
70
|
+
try {
|
|
71
|
+
route = await this.runOperation(scope => this.#gate.run(async () => {
|
|
72
|
+
const resolved = await this.#resolve(accountId, failures, scope);
|
|
73
|
+
if (accountId === this.accountId) changed = this.#accept(resolved);
|
|
74
|
+
return resolved;
|
|
75
|
+
}, scope), signal);
|
|
76
|
+
} catch (error) { failed = true; operationError = error; }
|
|
77
|
+
for (const failure of failures) this.notifyBackgroundError(failure);
|
|
78
|
+
if (changed) this.#changed();
|
|
79
|
+
if (failed) throw operationError;
|
|
80
|
+
return route && clone(route);
|
|
81
|
+
}
|
|
82
|
+
async #resolve(accountId: string, failures: BackgroundFailure[], signal: AbortSignal): Promise<AccountRoute | undefined> {
|
|
83
|
+
const previous = (await this.#store.read([routeKey(accountId)], signal)).sets[0]![0]?.value;
|
|
84
|
+
const known = previous === undefined ? undefined : accountRouteCodec.decode(previous);
|
|
85
|
+
let attempted = false;
|
|
86
|
+
for await (const entry of this.#pool.registry.getRelays(signal)) {
|
|
87
|
+
throwIfAborted(signal);
|
|
88
|
+
if (entry.status !== 'active') continue;
|
|
89
|
+
attempted = true;
|
|
90
|
+
let route: AccountRoute;
|
|
91
|
+
try {
|
|
92
|
+
const client = await this.#pool.get(entry.relayId, undefined, signal);
|
|
93
|
+
const response = await client.requestHttp('GET', 'account.route.resolve', { account: accountId }, { authenticated: false, signal });
|
|
94
|
+
if (response === undefined) throw new ProtocolError('invalid_response', 'Route resolution returned no document.');
|
|
95
|
+
route = accountRouteCodec.decode(response);
|
|
96
|
+
if (route.account !== accountId) throw new ProtocolError('invalid_identity', 'Resolved route belongs to another account.');
|
|
97
|
+
if (known && route.revision < known.revision) throw new ProtocolError('stale_route', 'Relay returned an older route.');
|
|
98
|
+
await this.#verifyRelay(route, signal);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throwIfAborted(signal);
|
|
101
|
+
if (error instanceof RelayError) {
|
|
102
|
+
if (error.code === 'not_found') continue;
|
|
103
|
+
if (!['temporarily_unavailable', 'bad_gateway', 'rate_limited'].includes(error.code)) throw error;
|
|
104
|
+
} else if (!(error instanceof Error)) throw error;
|
|
105
|
+
failures.push({ operation: 'connect', resource: entry.relayId, error }); continue;
|
|
106
|
+
}
|
|
107
|
+
await this.#save(route, signal); return route;
|
|
108
|
+
}
|
|
109
|
+
if (failures.length) throw new AggregateError(failures.map(value => value.error), 'Account route discovery failed through the available relays.');
|
|
110
|
+
if (!attempted) throw new ProtocolError('no_active_relay', 'No active relay is available for route discovery.');
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
async publishRoute(relayId: string, options: PublishRouteOptions): Promise<AccountRoute> {
|
|
114
|
+
options = { ...options };
|
|
115
|
+
validateRelayId(relayId); requireSafeInteger(options.validitySeconds, 1);
|
|
116
|
+
if (options.validitySeconds > 3650 * 86400) throw new RangeError('Route validity cannot exceed 3650 days.');
|
|
117
|
+
if (options.revision !== undefined) requireSafeInteger(options.revision, 0);
|
|
118
|
+
const signer = this.#signer;
|
|
119
|
+
if (!signer) throw new ProtocolError('signer_required', 'An account signer is required to publish a route.');
|
|
120
|
+
let changed = false;
|
|
121
|
+
const result = await this.runOperation(signal => this.#gate.run(async () => {
|
|
122
|
+
const signed = await this.#prepare(relayId, options, signer, signal);
|
|
123
|
+
const request = accountRouteCodec.decode(signed.document);
|
|
124
|
+
const client = await this.#pool.get(relayId, { mode: 'account', signer }, signal);
|
|
125
|
+
let response;
|
|
126
|
+
try { response = await client.requestHttp('PUT', method, signed.document, { signal }); }
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof RelayError && error.isDefinitiveRejection) {
|
|
129
|
+
try { await finishSignedRequest(this.#store, method, signed); }
|
|
130
|
+
catch (storageError) { throw new AggregateError([error, storageError], 'Relay rejected publication but its durable result could not be saved.'); }
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (response === undefined) throw new ProtocolError('invalid_response', 'Route publication returned no acknowledgement.');
|
|
135
|
+
const route = accountRouteCodec.decode(response);
|
|
136
|
+
const acknowledged = accountRouteCodec.encode(route); delete acknowledged.relay_signature;
|
|
137
|
+
if (canonicalJson(acknowledged) !== accountRouteCodec.stringify(request)) throw new ProtocolError('invalid_response', 'Relay modified the submitted route.');
|
|
138
|
+
await this.#verifyRelay(route, signal); await this.#save(route, signal);
|
|
139
|
+
changed = this.#accept(route); return route;
|
|
140
|
+
}, signal), options.signal);
|
|
141
|
+
if (changed) this.#changed();
|
|
142
|
+
return clone(result);
|
|
143
|
+
}
|
|
144
|
+
async #prepare(relayId: string, options: PublishRouteOptions, signer: AccountSigner, signal: AbortSignal): Promise<SignedRequest> {
|
|
145
|
+
const queries = [routeKey(this.accountId), requestKey(method)];
|
|
146
|
+
const snapshot = await this.#store.read(queries, signal);
|
|
147
|
+
const previous = snapshot.sets[0]![0]?.value;
|
|
148
|
+
const saved = snapshot.sets[1]![0]?.value;
|
|
149
|
+
const known = previous === undefined ? undefined : accountRouteCodec.decode(previous);
|
|
150
|
+
const signed = readSignedRequest(saved);
|
|
151
|
+
const knownRevision = Math.max(known?.revision ?? -1, signed?.revision ?? -1);
|
|
152
|
+
const pending = signed?.pending ? accountRouteCodec.decode(signed.document) : undefined;
|
|
153
|
+
const now = this.clock.nowSeconds(); requireSafeInteger(now, 0);
|
|
154
|
+
let revision = options.revision;
|
|
155
|
+
if (options.recovery && revision === undefined && (!pending || signed!.relayId !== relayId || pending.expiresAt <= now))
|
|
156
|
+
revision = Math.max(nextRevision(knownRevision), now * 1000);
|
|
157
|
+
else if (revision === undefined && pending && signed!.relayId === relayId && pending.expiresAt <= now) revision = nextRevision(knownRevision);
|
|
158
|
+
if (signed?.pending && (revision === undefined || revision === signed.revision)) {
|
|
159
|
+
const route = pending!;
|
|
160
|
+
validateRoute(route, this.context, now);
|
|
161
|
+
if (route.account !== this.accountId || signed.relayId !== relayId || route.relayId !== relayId || route.revision !== signed.revision
|
|
162
|
+
|| route.expiresAt - route.updatedAt !== options.validitySeconds)
|
|
163
|
+
throw new StateConflictError('A route publication has an unknown result. Retry the original publication or resolve it first.');
|
|
164
|
+
return signed;
|
|
165
|
+
}
|
|
166
|
+
const publicKey = signer.publicKey.slice();
|
|
167
|
+
if (signer.accountId !== this.accountId || !matchesAccount(this.accountId, publicKey)) throw new ProtocolError('invalid_identity', 'Account signer identity changed.');
|
|
168
|
+
let request: AccountRoute = { account: this.accountId, accountPublicKey: publicKey, relayId, revision: nextRevision(knownRevision, revision),
|
|
169
|
+
updatedAt: now, expiresAt: now + options.validitySeconds, accountSignature: new Uint8Array(64) };
|
|
170
|
+
requireSafeInteger(request.expiresAt, 0);
|
|
171
|
+
request = { ...request, accountSignature: (await signer.sign(routeAccountInput(request, this.context), signal)).slice() };
|
|
172
|
+
validateRoute(request, this.context, this.clock.nowSeconds());
|
|
173
|
+
const prepared: SignedRequest = { relayId, revision: request.revision, document: accountRouteCodec.encode(request), pending: true };
|
|
174
|
+
await updateStore(this.#store, queries, current => {
|
|
175
|
+
if (fingerprint(current.sets[0]![0]?.value) !== fingerprint(previous) || fingerprint(current.sets[1]![0]?.value) !== fingerprint(saved))
|
|
176
|
+
throw new StateConflictError('The route changed while it was being signed. Resolve current state before retrying.');
|
|
177
|
+
return { mutations: [putSignedRequest(method, prepared)], result: undefined };
|
|
178
|
+
}, signal);
|
|
179
|
+
return prepared;
|
|
180
|
+
}
|
|
181
|
+
async #verifyRelay(route: AccountRoute, signal: AbortSignal): Promise<void> {
|
|
182
|
+
validateRoute(route, this.context, this.clock.nowSeconds());
|
|
183
|
+
const client = await this.#pool.get(route.relayId, undefined, signal);
|
|
184
|
+
const descriptor = await client.getDescriptor(signal);
|
|
185
|
+
if (!route.relaySignature || !verifyRelay(route.relayId, descriptor.publicKey, routeRelayInput(route, this.context), route.relaySignature))
|
|
186
|
+
throw new ProtocolError('invalid_signature', 'Account route lacks a valid signature from its home relay.');
|
|
187
|
+
}
|
|
188
|
+
async #save(route: AccountRoute, signal: AbortSignal): Promise<void> {
|
|
189
|
+
await updateStore(this.#store, [routeKey(route.account), requestKey(method)], snapshot => {
|
|
190
|
+
const current = snapshot.sets[0]![0]?.value;
|
|
191
|
+
if (current) {
|
|
192
|
+
const comparison = compareRoutes(route, accountRouteCodec.decode(current));
|
|
193
|
+
if (comparison === 'older' || comparison === 'conflict') throw new ProtocolError('stale_route', 'Returned route is older or conflicts at the same revision.');
|
|
194
|
+
}
|
|
195
|
+
const mutations: StoreMutation[] = [{ kind: 'put', ...routeKey(route.account), value: accountRouteCodec.encode(route) }];
|
|
196
|
+
const signed = readSignedRequest(snapshot.sets[1]![0]?.value);
|
|
197
|
+
if (signed?.pending) {
|
|
198
|
+
const request = accountRouteCodec.decode(signed.document);
|
|
199
|
+
if (request.account === route.account && ['equivalent', 'newer'].includes(compareRoutes(route, request)))
|
|
200
|
+
mutations.push(putSignedRequest(method, { ...signed, pending: false }));
|
|
201
|
+
}
|
|
202
|
+
return { mutations, result: undefined };
|
|
203
|
+
}, signal);
|
|
204
|
+
}
|
|
205
|
+
protected override async onDispose(): Promise<void> { this.#events.clear(); }
|
|
206
|
+
}
|