@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,86 @@
|
|
|
1
|
+
import { decryptMessage, type MessageDecryptor } from '../crypto/messages.js';
|
|
2
|
+
import { ProtocolError } from '../errors.js';
|
|
3
|
+
import { certificateId, type DeviceCertificate } from '../models/identity.js';
|
|
4
|
+
import { directMessageCodec, messageTimelinePageCodec, validateDirectMessage, validateMessageTimelinePage, type MessageTimelineEntry } from '../models/messages.js';
|
|
5
|
+
import type { NetworkContext } from '../protocol/context.js';
|
|
6
|
+
import type { JsonObject, JsonValue } from '../protocol/json.js';
|
|
7
|
+
import { AsyncGate } from '../runtime/async-gate.js';
|
|
8
|
+
import { throwIfAborted } from '../runtime/clock.js';
|
|
9
|
+
import { MessageRepository, type MessageEffects, type MessageInfo } from './repository.js';
|
|
10
|
+
|
|
11
|
+
export interface MessageReceptionPolicy {
|
|
12
|
+
/** Performs current authority checks and prepares a synchronous transaction effect. No mutations before commit. */
|
|
13
|
+
prepare(entry: MessageTimelineEntry, payload: JsonObject, signal?: AbortSignal): Promise<MessageEffects | undefined>;
|
|
14
|
+
/** Only permanent business validation failures qualify. Network, key access and storage failures must remain retryable. */
|
|
15
|
+
isPermanentRejection(error: unknown): error is ProtocolError;
|
|
16
|
+
}
|
|
17
|
+
export interface ReceptionResult { readonly messages: readonly MessageInfo[]; readonly rejected: readonly { readonly sequence: number; readonly error: ProtocolError }[]; readonly inserted: number }
|
|
18
|
+
export interface TimelineSource { read(after: number, signal?: AbortSignal): Promise<JsonValue> }
|
|
19
|
+
const permanentCryptographyCodes = new Set(['invalid_signature', 'invalid_identity', 'invalid_length', 'invalid_ciphertext', 'invalid_key', 'invalid_type',
|
|
20
|
+
'invalid_size', 'invalid_field', 'missing_field', 'invalid_null', 'unsupported_algorithm', 'invalid_identifier', 'invalid_account', 'invalid_json',
|
|
21
|
+
'duplicate_key', 'invalid_integer', 'invalid_unicode', 'invalid_utf8', 'invalid_object', 'invalid_encoding', 'invalid_base64url', 'invalid_context', 'invalid_model', 'conflicting_extension', 'invalid_number', 'invalid_depth']);
|
|
22
|
+
|
|
23
|
+
/** A serial receiver keeps the first transiently failing entry ahead of the persisted cursor. */
|
|
24
|
+
export class MessageReceiver {
|
|
25
|
+
readonly #gate = new AsyncGate();
|
|
26
|
+
constructor(readonly repository: MessageRepository, readonly context: NetworkContext, readonly device: MessageDecryptor, readonly policy: MessageReceptionPolicy) {}
|
|
27
|
+
/** Results are emitted after every committed page; callbacks must not block on stopping the owning runtime. */
|
|
28
|
+
synchronize(relayId: string, source: TimelineSource, committed: (result: ReceptionResult) => void, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
return this.#gate.run(async () => {
|
|
30
|
+
while (true) {
|
|
31
|
+
throwIfAborted(signal);
|
|
32
|
+
const after = (await this.repository.getProgress(relayId, signal)).sequence;
|
|
33
|
+
const page = messageTimelinePageCodec.decode(await source.read(after, signal)); validateMessageTimelinePage(page, this.context, after);
|
|
34
|
+
const certificates = new Map(page.certificates.map(value => [certificateId(value, this.context), value]));
|
|
35
|
+
const messages: MessageInfo[] = []; const rejected: { sequence: number; error: ProtocolError }[] = []; let inserted = 0;
|
|
36
|
+
try {
|
|
37
|
+
for (const entry of page.items) {
|
|
38
|
+
const result = await this.#receive(relayId, entry, certificates.get(entry.envelope.fromDeviceId)!, page.hasRetentionGap === true, signal);
|
|
39
|
+
if (result.error) rejected.push({ sequence: entry.sequence, error: result.error });
|
|
40
|
+
if (result.inserted) { inserted++; if (result.message) messages.push(result.message); }
|
|
41
|
+
}
|
|
42
|
+
if (!page.items.length) await this.repository.acceptEmptyPage(relayId, page.hasRetentionGap === true, signal);
|
|
43
|
+
} finally {
|
|
44
|
+
// Earlier entries stay committed if a later entry fails, and their notifications must not be lost.
|
|
45
|
+
if (inserted || rejected.length) committed({ messages, rejected, inserted });
|
|
46
|
+
}
|
|
47
|
+
if (!page.hasMore) return;
|
|
48
|
+
}
|
|
49
|
+
}, signal);
|
|
50
|
+
}
|
|
51
|
+
async #receive(relayId: string, entry: MessageTimelineEntry, sender: DeviceCertificate, hasRetentionGap: boolean, signal?: AbortSignal): Promise<{ inserted: boolean; error?: ProtocolError; message?: MessageInfo }> {
|
|
52
|
+
if (await this.repository.acceptKnown(relayId, entry, hasRetentionGap, signal)) return { inserted: false };
|
|
53
|
+
let payload: JsonObject;
|
|
54
|
+
try { payload = await decryptMessage({ context: this.context, receiver: this.device, envelope: entry.envelope, keyBox: entry.keyBox, sender, ...(signal ? { signal } : {}) }); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
if (!(error instanceof ProtocolError) || !permanentCryptographyCodes.has(error.code)) throw error;
|
|
58
|
+
await this.repository.reject(relayId, entry.sequence, error, hasRetentionGap, signal); return { inserted: false, error };
|
|
59
|
+
}
|
|
60
|
+
// The plaintext direct-message schema is deterministic; its rejection does not depend on local key/storage availability.
|
|
61
|
+
if (payload['$type'] === 'meshline.message.direct') {
|
|
62
|
+
try { validateDirectMessage(directMessageCodec.decode(payload)); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!(error instanceof ProtocolError)) throw error;
|
|
65
|
+
await this.repository.reject(relayId, entry.sequence, error, hasRetentionGap, signal); return { inserted: false, error };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let effects: MessageEffects | undefined;
|
|
69
|
+
try { effects = await this.policy.prepare(entry, payload, signal); }
|
|
70
|
+
catch (error) { return this.#rejectBusiness(relayId, entry, error, hasRetentionGap, signal); }
|
|
71
|
+
// Protection failures are local infrastructure failures, even when the protector throws a ProtocolError.
|
|
72
|
+
const message = await this.repository.prepare(entry.envelope, payload, signal);
|
|
73
|
+
try {
|
|
74
|
+
const result = await this.repository.accept(relayId, entry, message, hasRetentionGap, effects, signal);
|
|
75
|
+
return { ...result, ...(result.inserted && message.payloadType === 'meshline.message.direct' ? { message: {
|
|
76
|
+
...directMessageCodec.decode(payload), key: { sender: message.sender, messageId: message.messageId }, senderDeviceId: message.senderDeviceId, recipient: message.recipient, createdAt: message.createdAt,
|
|
77
|
+
} } : {}) };
|
|
78
|
+
}
|
|
79
|
+
catch (error) { return this.#rejectBusiness(relayId, entry, error, hasRetentionGap, signal); }
|
|
80
|
+
}
|
|
81
|
+
async #rejectBusiness(relayId: string, entry: MessageTimelineEntry, error: unknown, hasRetentionGap: boolean, signal?: AbortSignal): Promise<{ inserted: false; error: ProtocolError }> {
|
|
82
|
+
throwIfAborted(signal);
|
|
83
|
+
if (!this.policy.isPermanentRejection(error)) throw error;
|
|
84
|
+
await this.repository.reject(relayId, entry.sequence, error, hasRetentionGap, signal); return { inserted: false, error };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
2
|
+
import { validateIdentifier } from '../identity/identifiers.js';
|
|
3
|
+
import { validateAccountId, validateRelayId } from '../identity/neo.js';
|
|
4
|
+
import type { SecretProtector } from '../interactions.js';
|
|
5
|
+
import { directMessageCodec, messagePayloadCodec, validateDirectMessage, type DirectMessage, type MessageEnvelope, type MessageTimelineEntry } from '../models/messages.js';
|
|
6
|
+
import type { NetworkContext } from '../protocol/context.js';
|
|
7
|
+
import { decodeBase64Url, encodeBase64Url } from '../protocol/encoding.js';
|
|
8
|
+
import { canonicalBytes, canonicalJson, parseJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
9
|
+
import { throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
|
|
10
|
+
import { requireBatchCount, type MeshlineStore, type QueryReader, type RecordKey, type RecordQuery, type StoredRecord, type StoreMutation, type StoreSnapshot } from '../storage/store.js';
|
|
11
|
+
import { updateStore } from '../storage/transaction.js';
|
|
12
|
+
import { encodeOutbox, outboxKey, type OutboxRecord } from './outbox.js';
|
|
13
|
+
|
|
14
|
+
export interface MessageKey { readonly sender: string; readonly messageId: string }
|
|
15
|
+
export interface MessageInfo extends DirectMessage {
|
|
16
|
+
readonly key: MessageKey; readonly senderDeviceId: string; readonly recipient: string; readonly createdAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface AccountMessage {
|
|
19
|
+
readonly localSequence: number; readonly sender: string; readonly senderDeviceId: string; readonly recipient: string; readonly messageId: string; readonly payload: JsonObject;
|
|
20
|
+
}
|
|
21
|
+
export interface StoredMessage extends MessageKey {
|
|
22
|
+
readonly senderDeviceId: string; readonly recipient: string; readonly createdAt: number; readonly payloadType: string;
|
|
23
|
+
readonly payload?: JsonObject; readonly protectedPayload?: string; readonly localSequence: number;
|
|
24
|
+
}
|
|
25
|
+
export type PreparedMessage = Omit<StoredMessage, 'localSequence'>;
|
|
26
|
+
export interface TimelineProgress { readonly sequence: number; readonly hasRetentionGap: boolean; readonly lastSynchronizedAt?: number }
|
|
27
|
+
export interface RecordGuard { readonly query: RecordKey; readonly value: JsonObject | undefined }
|
|
28
|
+
/** Synchronous business validation and mutations join the same compare-and-commit as messages and cursors. */
|
|
29
|
+
export interface MessageEffects {
|
|
30
|
+
readonly queries: readonly RecordQuery[];
|
|
31
|
+
plan(snapshot: StoreSnapshot): readonly StoreMutation[];
|
|
32
|
+
/** Owning runtime notification only; it must return immediately without awaiting application observers. */
|
|
33
|
+
committed?(snapshot: StoreSnapshot, mutations: readonly StoreMutation[]): void;
|
|
34
|
+
}
|
|
35
|
+
export interface MessageRepositoryOptions {
|
|
36
|
+
readonly store: MeshlineStore; readonly context: NetworkContext; readonly accountId: string; readonly deviceId: () => string;
|
|
37
|
+
readonly clock: RuntimeClock; readonly secretProtector?: SecretProtector;
|
|
38
|
+
}
|
|
39
|
+
export const messageKey = (value: MessageKey): RecordKey => {
|
|
40
|
+
validateAccountId(value.sender); validateIdentifier('message', value.messageId); return { collection: 'messages', key: `${value.sender}|${value.messageId}` };
|
|
41
|
+
};
|
|
42
|
+
export const timelineKey = (relayId: string): RecordKey => { validateRelayId(relayId); return { collection: 'message_timelines', key: relayId }; };
|
|
43
|
+
const sequenceKey: RecordKey = { collection: 'message_meta', key: 'local_sequence' };
|
|
44
|
+
const secretTypes = new Set(['meshline.account.group.state.sync', 'meshline.account.group.history_secret.sync']);
|
|
45
|
+
const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
|
|
46
|
+
function progress(value: JsonObject | undefined): TimelineProgress {
|
|
47
|
+
if (!value) return { sequence: -1, hasRetentionGap: false };
|
|
48
|
+
requireSafeInteger(value.sequence, -1);
|
|
49
|
+
if (typeof value.hasRetentionGap !== 'boolean') throw new ProtocolError('invalid_storage', 'Invalid persisted timeline retention flag.');
|
|
50
|
+
if (value.lastSynchronizedAt !== undefined) requireSafeInteger(value.lastSynchronizedAt, 0);
|
|
51
|
+
return { sequence: value.sequence, hasRetentionGap: value.hasRetentionGap, ...(value.lastSynchronizedAt === undefined ? {} : { lastSynchronizedAt: value.lastSynchronizedAt }) };
|
|
52
|
+
}
|
|
53
|
+
function encodeMessage(value: StoredMessage): JsonObject {
|
|
54
|
+
return { sender: value.sender, messageId: value.messageId, senderDeviceId: value.senderDeviceId, recipient: value.recipient, createdAt: value.createdAt,
|
|
55
|
+
payloadType: value.payloadType, localSequence: value.localSequence, ...(value.payload === undefined ? {} : { payload: value.payload }),
|
|
56
|
+
...(value.protectedPayload === undefined ? {} : { protectedPayload: value.protectedPayload }) };
|
|
57
|
+
}
|
|
58
|
+
function decodeMessage(row: StoredRecord): StoredMessage {
|
|
59
|
+
const value = row.value;
|
|
60
|
+
for (const name of ['sender', 'messageId', 'senderDeviceId', 'recipient', 'payloadType']) if (typeof value[name] !== 'string') throw new ProtocolError('invalid_storage', `Stored message has no ${name}.`);
|
|
61
|
+
const key = { sender: value.sender as string, messageId: value.messageId as string };
|
|
62
|
+
if (messageKey(key).key !== row.key) throw new ProtocolError('invalid_storage', 'Stored message identity differs from its key.');
|
|
63
|
+
validateAccountId(value.recipient as string); validateIdentifier('device', value.senderDeviceId as string); requireSafeInteger(value.createdAt, 0); requireSafeInteger(value.localSequence, 1);
|
|
64
|
+
const secret = secretTypes.has(value.payloadType as string);
|
|
65
|
+
if (secret ? value.payload !== undefined || typeof value.protectedPayload !== 'string' : value.protectedPayload !== undefined || value.payload === undefined)
|
|
66
|
+
throw new ProtocolError('invalid_storage', 'Message payload protection differs from its declared type.');
|
|
67
|
+
const payload = secret ? undefined : messagePayloadCodec.decode(value.payload!);
|
|
68
|
+
if (payload && payload['$type'] !== value.payloadType) throw new ProtocolError('invalid_storage', 'Stored message type differs from its payload.');
|
|
69
|
+
if (secret && decodeBase64Url(value.protectedPayload as string).length === 0) throw new ProtocolError('invalid_storage', 'Protected payload cannot be empty.');
|
|
70
|
+
return { ...key, senderDeviceId: value.senderDeviceId as string, recipient: value.recipient as string, createdAt: value.createdAt, payloadType: value.payloadType as string,
|
|
71
|
+
localSequence: value.localSequence, ...(payload ? { payload } : { protectedPayload: value.protectedPayload as string }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Internal persistence; callers must verify envelope signatures, decryption and business authorization before preparing an incoming record. */
|
|
75
|
+
export class MessageRepository {
|
|
76
|
+
constructor(readonly options: MessageRepositoryOptions) {}
|
|
77
|
+
#belongsToAccount(value: Pick<StoredMessage, 'sender' | 'recipient'>): void {
|
|
78
|
+
if (value.sender !== this.options.accountId && value.recipient !== this.options.accountId) throw new ProtocolError('invalid_identity', 'Message does not belong to this account.');
|
|
79
|
+
}
|
|
80
|
+
#purpose(value: MessageKey): string {
|
|
81
|
+
const deviceId = this.options.deviceId(); validateIdentifier('device', deviceId);
|
|
82
|
+
return `Meshline/${this.options.context}/${this.options.accountId}/${deviceId}/messages/${value.sender}/${value.messageId}`;
|
|
83
|
+
}
|
|
84
|
+
async prepare(envelope: MessageEnvelope, source: JsonObject, signal?: AbortSignal): Promise<PreparedMessage> {
|
|
85
|
+
throwIfAborted(signal);
|
|
86
|
+
const payload = messagePayloadCodec.decode(source); const type = payload['$type'] as string;
|
|
87
|
+
if (type === 'meshline.message.direct') validateDirectMessage(directMessageCodec.decode(payload));
|
|
88
|
+
const value = { sender: envelope.from, messageId: envelope.messageId, senderDeviceId: envelope.fromDeviceId, recipient: envelope.to, createdAt: envelope.createdAt, payloadType: type };
|
|
89
|
+
messageKey(value); this.#belongsToAccount(value); validateIdentifier('device', value.senderDeviceId); requireSafeInteger(value.createdAt, 0);
|
|
90
|
+
if (!secretTypes.has(type)) return { ...value, payload };
|
|
91
|
+
const protector = this.options.secretProtector; if (!protector) throw new ProtocolError('protector_required', 'A secret protector is required to store group synchronization secrets.');
|
|
92
|
+
const plaintext = canonicalBytes(payload);
|
|
93
|
+
try {
|
|
94
|
+
const protectedData = await protector.protect(plaintext, this.#purpose(value), signal); throwIfAborted(signal);
|
|
95
|
+
if (!(protectedData instanceof Uint8Array) || !protectedData.length) throw new ProtocolError('invalid_protection', 'Secret protector returned no protected payload.');
|
|
96
|
+
return { ...value, protectedPayload: encodeBase64Url(protectedData) };
|
|
97
|
+
} finally { plaintext.fill(0); }
|
|
98
|
+
}
|
|
99
|
+
async #payload(value: StoredMessage, signal?: AbortSignal): Promise<JsonObject> {
|
|
100
|
+
this.#belongsToAccount(value); throwIfAborted(signal);
|
|
101
|
+
if (value.payload) return messagePayloadCodec.decode(value.payload);
|
|
102
|
+
const protector = this.options.secretProtector; if (!protector) throw new ProtocolError('protector_required', 'A secret protector is required to read group synchronization secrets.');
|
|
103
|
+
const plaintext = await protector.unprotect(decodeBase64Url(value.protectedPayload!), this.#purpose(value), signal);
|
|
104
|
+
try {
|
|
105
|
+
throwIfAborted(signal); const payload = messagePayloadCodec.decode(parseJson(plaintext));
|
|
106
|
+
if (payload['$type'] !== value.payloadType) throw new ProtocolError('invalid_storage', 'Unprotected payload type differs from the stored type.');
|
|
107
|
+
return payload;
|
|
108
|
+
} finally { plaintext.fill(0); }
|
|
109
|
+
}
|
|
110
|
+
async get(key: MessageKey, signal?: AbortSignal): Promise<MessageInfo | undefined> {
|
|
111
|
+
const row = (await this.options.store.read([messageKey(key)], signal)).sets[0]![0];
|
|
112
|
+
if (!row) return undefined; const value = decodeMessage(row); this.#belongsToAccount(value);
|
|
113
|
+
if (value.payloadType !== 'meshline.message.direct') return undefined;
|
|
114
|
+
return this.#info(value);
|
|
115
|
+
}
|
|
116
|
+
async contains(key: MessageKey, signal?: AbortSignal): Promise<boolean> {
|
|
117
|
+
const row = (await this.options.store.read([messageKey(key)], signal)).sets[0]![0];
|
|
118
|
+
if (!row) return false; this.#belongsToAccount(decodeMessage(row)); return true;
|
|
119
|
+
}
|
|
120
|
+
/** A previously committed message can advance another relay's cursor without needing historical decryption keys again. */
|
|
121
|
+
async acceptKnown(relayId: string, entry: MessageTimelineEntry, hasRetentionGap: boolean, signal?: AbortSignal): Promise<boolean> {
|
|
122
|
+
const key = timelineKey(relayId); requireSafeInteger(entry.sequence, 0); requireSafeInteger(entry.acceptedAt, 0);
|
|
123
|
+
const now = this.options.clock.nowSeconds(); requireSafeInteger(now, 0);
|
|
124
|
+
return updateStore(this.options.store, [key, messageKey({ sender: entry.envelope.from, messageId: entry.envelope.messageId })], snapshot => {
|
|
125
|
+
const known = snapshot.sets[1]![0]; if (!known) return { mutations: [], result: false };
|
|
126
|
+
this.#belongsToAccount(decodeMessage(known)); const current = progress(snapshot.sets[0]![0]?.value);
|
|
127
|
+
return { mutations: current.sequence >= entry.sequence ? [] : [{ kind: 'put', ...key, value: { sequence: entry.sequence, hasRetentionGap: current.hasRetentionGap || hasRetentionGap, lastSynchronizedAt: now } }], result: true };
|
|
128
|
+
}, signal);
|
|
129
|
+
}
|
|
130
|
+
#info(value: StoredMessage): MessageInfo {
|
|
131
|
+
this.#belongsToAccount(value); const direct = directMessageCodec.decode(value.payload!); validateDirectMessage(direct);
|
|
132
|
+
return { ...direct, key: { sender: value.sender, messageId: value.messageId }, senderDeviceId: value.senderDeviceId, recipient: value.recipient, createdAt: value.createdAt };
|
|
133
|
+
}
|
|
134
|
+
async history(peer?: string, signal?: AbortSignal): Promise<QueryReader<MessageInfo>> {
|
|
135
|
+
if (peer !== undefined) validateAccountId(peer);
|
|
136
|
+
const snapshot = await this.options.store.read([{ collection: 'messages' }], signal);
|
|
137
|
+
const records = snapshot.sets[0]!.map(decodeMessage).filter(value => value.payloadType === 'meshline.message.direct' && (peer === undefined
|
|
138
|
+
|| value.sender === this.options.accountId && value.recipient === peer || value.sender === peer && value.recipient === this.options.accountId))
|
|
139
|
+
.sort((a, b) => a.createdAt - b.createdAt || compare(a.messageId, b.messageId) || compare(a.sender, b.sender));
|
|
140
|
+
return snapshotReader(records.map(value => this.#info(value)));
|
|
141
|
+
}
|
|
142
|
+
async readTimeline(after: number, count: number, signal?: AbortSignal): Promise<readonly AccountMessage[]> {
|
|
143
|
+
requireSafeInteger(after, 0); requireBatchCount(count);
|
|
144
|
+
const snapshot = await this.options.store.read([{ collection: 'messages' }], signal);
|
|
145
|
+
const records = snapshot.sets[0]!.map(decodeMessage).filter(value => value.localSequence > after).sort((a, b) => a.localSequence - b.localSequence).slice(0, count);
|
|
146
|
+
const results: AccountMessage[] = [];
|
|
147
|
+
for (const value of records) results.push({ localSequence: value.localSequence, sender: value.sender, senderDeviceId: value.senderDeviceId, recipient: value.recipient,
|
|
148
|
+
messageId: value.messageId, payload: await this.#payload(value, signal) });
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
async getProgress(relayId: string, signal?: AbortSignal): Promise<TimelineProgress> {
|
|
152
|
+
return progress((await this.options.store.read([timelineKey(relayId)], signal)).sets[0]![0]?.value);
|
|
153
|
+
}
|
|
154
|
+
async observeRelay(relayId: string, signal?: AbortSignal): Promise<void> {
|
|
155
|
+
const key = timelineKey(relayId);
|
|
156
|
+
await updateStore(this.options.store, [key], snapshot => ({ mutations: snapshot.sets[0]!.length ? [] : [{ kind: 'put', ...key, value: { sequence: -1, hasRetentionGap: false } }], result: undefined }), signal);
|
|
157
|
+
}
|
|
158
|
+
/** The caller provides state fingerprints captured before signing; unrelated store writes may be retried without signing again. */
|
|
159
|
+
async enqueue(outbox: OutboxRecord, message: PreparedMessage | undefined, guards: readonly RecordGuard[], effects?: MessageEffects, signal?: AbortSignal): Promise<void> {
|
|
160
|
+
const envelope = outbox.request.envelope; const key = outboxKey(envelope.messageId);
|
|
161
|
+
if (envelope.from !== this.options.accountId || outbox.state !== 'queued' || outbox.acceptedAt !== undefined) throw new ProtocolError('invalid_outbox', 'Only new local queued messages may be enqueued.');
|
|
162
|
+
if (outbox.isDirect !== Boolean(message) || message && message.payloadType !== 'meshline.message.direct') throw new ProtocolError('invalid_outbox', 'Only direct outgoing messages are inserted into local history at enqueue time.');
|
|
163
|
+
if (message && !matchesEnvelope(message, envelope)) throw new ProtocolError('invalid_outbox', 'Local message identity differs from the outgoing envelope.');
|
|
164
|
+
const queries: RecordQuery[] = [key, messageKey({ sender: envelope.from, messageId: envelope.messageId }), sequenceKey, ...guards.map(value => value.query), ...effects?.queries ?? []];
|
|
165
|
+
const document = encodeOutbox(outbox);
|
|
166
|
+
const committed = await updateStore(this.options.store, queries, snapshot => {
|
|
167
|
+
if (snapshot.sets[0]!.length || snapshot.sets[1]!.length) throw new StateConflictError('Outgoing message ID already exists.');
|
|
168
|
+
guards.forEach((guard, index) => { if (fingerprint(snapshot.sets[index + 3]![0]?.value) !== fingerprint(guard.value)) throw new StateConflictError('Message authorization changed while preparing the encrypted request.'); });
|
|
169
|
+
const mutations: StoreMutation[] = [{ kind: 'put', ...key, value: document }];
|
|
170
|
+
if (message) mutations.push(...insertMessage(message, snapshot.sets[2]![0]?.value));
|
|
171
|
+
const effectSnapshot = { ...snapshot, sets: snapshot.sets.slice(3 + guards.length) }; const effectMutations = effects?.plan(effectSnapshot) ?? [];
|
|
172
|
+
mutations.push(...effectMutations);
|
|
173
|
+
return { mutations, result: { snapshot: effectSnapshot, mutations: effectMutations } };
|
|
174
|
+
}, signal);
|
|
175
|
+
effects?.committed?.(committed.snapshot, committed.mutations);
|
|
176
|
+
}
|
|
177
|
+
async accept(relayId: string, entry: MessageTimelineEntry, message: PreparedMessage, hasRetentionGap: boolean, effects?: MessageEffects, signal?: AbortSignal): Promise<{ readonly advanced: boolean; readonly inserted: boolean }> {
|
|
178
|
+
if (!matchesEnvelope(message, entry.envelope)) throw new ProtocolError('invalid_message', 'Prepared message identity differs from the timeline envelope.');
|
|
179
|
+
this.#belongsToAccount(message); requireSafeInteger(entry.sequence, 0); requireSafeInteger(entry.acceptedAt, 0);
|
|
180
|
+
const key = timelineKey(relayId); const now = this.options.clock.nowSeconds(); requireSafeInteger(now, 0);
|
|
181
|
+
const result = await updateStore<{ advanced: boolean; inserted: boolean; snapshot?: StoreSnapshot; mutations?: readonly StoreMutation[] }>(this.options.store, [key, messageKey(message), sequenceKey, ...effects?.queries ?? []], snapshot => {
|
|
182
|
+
const current = progress(snapshot.sets[0]![0]?.value);
|
|
183
|
+
if (current.sequence >= entry.sequence) return { mutations: [], result: { advanced: false, inserted: false } };
|
|
184
|
+
const inserted = !snapshot.sets[1]!.length;
|
|
185
|
+
const mutations: StoreMutation[] = [{ kind: 'put', ...key, value: { sequence: entry.sequence, hasRetentionGap: hasRetentionGap || current.hasRetentionGap, lastSynchronizedAt: now } }];
|
|
186
|
+
const effectSnapshot = { ...snapshot, sets: snapshot.sets.slice(3) }; const effectMutations = inserted ? effects?.plan(effectSnapshot) ?? [] : [];
|
|
187
|
+
if (inserted) {
|
|
188
|
+
mutations.push(...effectMutations);
|
|
189
|
+
mutations.push(...insertMessage(message, snapshot.sets[2]![0]?.value));
|
|
190
|
+
}
|
|
191
|
+
return { mutations, result: { advanced: true, inserted, snapshot: effectSnapshot, mutations: effectMutations } };
|
|
192
|
+
}, signal);
|
|
193
|
+
if (result.inserted) effects?.committed?.(result.snapshot!, result.mutations!); return { advanced: result.advanced, inserted: result.inserted };
|
|
194
|
+
}
|
|
195
|
+
/** Rejected authenticated entries advance only when the receiver classifies the error as permanent protocol rejection. */
|
|
196
|
+
async reject(relayId: string, sequence: number, error: ProtocolError, hasRetentionGap: boolean, signal?: AbortSignal): Promise<boolean> {
|
|
197
|
+
requireSafeInteger(sequence, 0); const key = timelineKey(relayId); const now = this.options.clock.nowSeconds(); requireSafeInteger(now, 0);
|
|
198
|
+
return updateStore(this.options.store, [key], snapshot => {
|
|
199
|
+
const current = progress(snapshot.sets[0]![0]?.value); if (current.sequence >= sequence) return { mutations: [], result: false };
|
|
200
|
+
return { mutations: [
|
|
201
|
+
{ kind: 'put', ...key, value: { sequence, hasRetentionGap: hasRetentionGap || current.hasRetentionGap, lastSynchronizedAt: now } },
|
|
202
|
+
{ kind: 'put', collection: 'message_rejections', key: `${relayId}|${sequence.toString().padStart(16, '0')}`, value: { relayId, sequence, code: error.code, message: error.message, rejectedAt: now } },
|
|
203
|
+
], result: true };
|
|
204
|
+
}, signal);
|
|
205
|
+
}
|
|
206
|
+
async acceptEmptyPage(relayId: string, hasRetentionGap: boolean, signal?: AbortSignal): Promise<void> {
|
|
207
|
+
const key = timelineKey(relayId); const now = this.options.clock.nowSeconds(); requireSafeInteger(now, 0);
|
|
208
|
+
await updateStore(this.options.store, [key], snapshot => {
|
|
209
|
+
const current = progress(snapshot.sets[0]![0]?.value);
|
|
210
|
+
return { mutations: [{ kind: 'put', ...key, value: { sequence: current.sequence, hasRetentionGap: current.hasRetentionGap || hasRetentionGap, lastSynchronizedAt: now } }], result: undefined };
|
|
211
|
+
}, signal);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function insertMessage(value: PreparedMessage, meta: JsonObject | undefined): StoreMutation[] {
|
|
215
|
+
const previous = meta?.sequence ?? 0; requireSafeInteger(previous, 0); const sequence = previous + 1; requireSafeInteger(sequence, 1);
|
|
216
|
+
return [{ kind: 'put', ...messageKey(value), value: encodeMessage({ ...value, localSequence: sequence }) }, { kind: 'put', ...sequenceKey, value: { sequence } }];
|
|
217
|
+
}
|
|
218
|
+
function matchesEnvelope(value: PreparedMessage, envelope: MessageEnvelope): boolean {
|
|
219
|
+
return value.sender === envelope.from && value.messageId === envelope.messageId && value.senderDeviceId === envelope.fromDeviceId && value.recipient === envelope.to && value.createdAt === envelope.createdAt;
|
|
220
|
+
}
|
|
221
|
+
function compare(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; }
|
|
222
|
+
/** Materialized immutable query results; caller mutation of a returned page cannot affect later pages. */
|
|
223
|
+
export function snapshotReader<T>(source: readonly T[]): QueryReader<T> {
|
|
224
|
+
let values: readonly T[] | undefined = source; let offset = 0;
|
|
225
|
+
return {
|
|
226
|
+
async readNext(count, signal) { throwIfAborted(signal); requireBatchCount(count); if (!values) throw new ProtocolError('disposed', 'Query reader is disposed.');
|
|
227
|
+
const result = values.slice(offset, offset + count); offset += result.length; return result; },
|
|
228
|
+
async dispose() { values = undefined; },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import { bytes, defineCodec, enumeration, integer, text, type ExtensibleModel } from '../protocol/codec.js';
|
|
3
|
+
import { NetworkContext, signingInput } from '../protocol/context.js';
|
|
4
|
+
import { encodeBase64Url } from '../protocol/encoding.js';
|
|
5
|
+
import { requireSafeInteger } from '../protocol/json.js';
|
|
6
|
+
import { validateCompleteObject } from '../protocol/validation.js';
|
|
7
|
+
import { validateRelayId } from '../identity/neo.js';
|
|
8
|
+
import { relayOrigin } from '../transport/endpoint.js';
|
|
9
|
+
import { certificateId, deviceCertificateCodec, type DeviceCertificate } from './identity.js';
|
|
10
|
+
|
|
11
|
+
export type SessionMode = 'account' | 'device';
|
|
12
|
+
export interface AuthenticationChallenge extends ExtensibleModel { readonly nonce: string; readonly createdAt: number; readonly expiresAt: number }
|
|
13
|
+
/** The token is a credential: never include this object in diagnostic logs. */
|
|
14
|
+
export interface SessionCredentials extends ExtensibleModel { readonly token: string; readonly mode: SessionMode; readonly expiresAt: number }
|
|
15
|
+
export interface AccountAuthenticationRequest extends ExtensibleModel { readonly nonce: string; readonly accountPublicKey: Uint8Array; readonly accountSignature: Uint8Array }
|
|
16
|
+
export interface DeviceAuthenticationRequest extends ExtensibleModel { readonly nonce: string; readonly timestamp: number; readonly signerCertificate: DeviceCertificate; readonly deviceSignature: Uint8Array }
|
|
17
|
+
|
|
18
|
+
export const authenticationChallengeCodec = defineCodec<AuthenticationChallenge>({
|
|
19
|
+
nonce: { wire: 'nonce', codec: text }, createdAt: { wire: 'created_at', codec: integer }, expiresAt: { wire: 'expires_at', codec: integer },
|
|
20
|
+
});
|
|
21
|
+
export const sessionCredentialsCodec = defineCodec<SessionCredentials>({
|
|
22
|
+
token: { wire: 'token', codec: text }, mode: { wire: 'mode', codec: enumeration('account', 'device') }, expiresAt: { wire: 'expires_at', codec: integer },
|
|
23
|
+
});
|
|
24
|
+
export const accountAuthenticationCodec = defineCodec<AccountAuthenticationRequest>({
|
|
25
|
+
nonce: { wire: 'nonce', codec: text }, accountPublicKey: { wire: 'account_public_key', codec: bytes }, accountSignature: { wire: 'account_signature', codec: bytes },
|
|
26
|
+
});
|
|
27
|
+
export const deviceAuthenticationCodec = defineCodec<DeviceAuthenticationRequest>({
|
|
28
|
+
nonce: { wire: 'nonce', codec: text }, timestamp: { wire: 'timestamp', codec: integer },
|
|
29
|
+
signerCertificate: { wire: 'signer_certificate', codec: deviceCertificateCodec }, deviceSignature: { wire: 'device_signature', codec: bytes },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function validateAuthToken(value: string): void {
|
|
33
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 256 || /[^\x21-\x7e]/.test(value))
|
|
34
|
+
throw new ProtocolError('invalid_auth_token', 'Authentication tokens must contain 1 to 256 visible ASCII characters.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateChallenge(challenge: AuthenticationChallenge): void {
|
|
38
|
+
validateCompleteObject(authenticationChallengeCodec.encode(challenge));
|
|
39
|
+
validateAuthToken(challenge.nonce);
|
|
40
|
+
requireSafeInteger(challenge.createdAt, 0);
|
|
41
|
+
requireSafeInteger(challenge.expiresAt, 0);
|
|
42
|
+
if (challenge.expiresAt <= challenge.createdAt) throw new ProtocolError('invalid_time', 'Challenge expiry must follow its creation time.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateSession(credentials: SessionCredentials): void {
|
|
46
|
+
validateCompleteObject(sessionCredentialsCodec.encode(credentials));
|
|
47
|
+
validateAuthToken(credentials.token);
|
|
48
|
+
requireSafeInteger(credentials.expiresAt, 1);
|
|
49
|
+
if (credentials.mode !== 'account' && credentials.mode !== 'device') throw new ProtocolError('invalid_mode', 'Invalid session mode.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Method-defined projection: request extensions cannot override the trusted target. */
|
|
53
|
+
export function accountAuthenticationInput(request: AccountAuthenticationRequest, account: string, relayId: string, endpoint: string, context: NetworkContext): Uint8Array {
|
|
54
|
+
validateRelayId(relayId);
|
|
55
|
+
return signingInput({ $type: 'meshline.relay.account_auth', relay_id: relayId, origin: relayOrigin(endpoint),
|
|
56
|
+
account, account_public_key: encodeBase64Url(request.accountPublicKey), nonce: request.nonce }, context, []);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deviceAuthenticationInput(request: DeviceAuthenticationRequest, relayId: string, endpoint: string, context: NetworkContext): Uint8Array {
|
|
60
|
+
validateRelayId(relayId);
|
|
61
|
+
return signingInput({ $type: 'meshline.relay.auth', relay_id: relayId, origin: relayOrigin(endpoint), account: request.signerCertificate.account,
|
|
62
|
+
device_id: certificateId(request.signerCertificate, context), nonce: request.nonce, timestamp: request.timestamp }, context, []);
|
|
63
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { requireLength, verifyDevice } from '../crypto/primitives.js';
|
|
2
|
+
import { ProtocolError } from '../errors.js';
|
|
3
|
+
import { deriveResourceId, validateIdentifier } from '../identity/identifiers.js';
|
|
4
|
+
import { validateAccountId, validateRelayId } from '../identity/neo.js';
|
|
5
|
+
import { array, boolean, bytes, defineCodec, enumeration, integer, text, type ExtensibleModel, type ValueCodec } from '../protocol/codec.js';
|
|
6
|
+
import { signingInput, type NetworkContext } from '../protocol/context.js';
|
|
7
|
+
import { encodeUtf8 } from '../protocol/encoding.js';
|
|
8
|
+
import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
9
|
+
import { validateCompleteObject } from '../protocol/validation.js';
|
|
10
|
+
import { containsNonWhitespace, contentReferenceCodec, messageBodyCodec, validateAttachments, validateMessageBody, type ContentReference, type MessageBody } from './content.js';
|
|
11
|
+
import { certificateId, deviceCertificateCodec, validateCertificate, type DeviceCertificate } from './identity.js';
|
|
12
|
+
|
|
13
|
+
export interface ChannelRef { readonly channelId: string; readonly relayId: string }
|
|
14
|
+
export interface ChannelPostRef { readonly channel: ChannelRef; readonly sequence: number }
|
|
15
|
+
export function validateChannelRef(value: ChannelRef): void { validateIdentifier('channel', value.channelId); validateRelayId(value.relayId); }
|
|
16
|
+
export function validateChannelPostRef(value: ChannelPostRef): void { validateChannelRef(value.channel); requireSafeInteger(value.sequence, 1); }
|
|
17
|
+
export interface ChannelDescriptor extends ExtensibleModel {
|
|
18
|
+
readonly channelId: string; readonly nonce: Uint8Array; readonly creator: string; readonly relayId: string;
|
|
19
|
+
readonly name: string; readonly description?: string; readonly moderators?: readonly string[];
|
|
20
|
+
readonly revision: number; readonly status: 'active' | 'closed'; readonly createdAt: number; readonly updatedAt: number; readonly deviceSignature: Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
export const channelDescriptorCodec = defineCodec<ChannelDescriptor>({
|
|
23
|
+
channelId: { wire: 'channel_id', codec: text }, nonce: { wire: 'nonce', codec: bytes }, creator: { wire: 'creator', codec: text }, relayId: { wire: 'relay_id', codec: text },
|
|
24
|
+
name: { wire: 'name', codec: text }, description: { wire: 'description', codec: text, optional: true }, moderators: { wire: 'moderators', codec: array(text), optional: true },
|
|
25
|
+
revision: { wire: 'revision', codec: integer }, status: { wire: 'status', codec: enumeration('active', 'closed') },
|
|
26
|
+
createdAt: { wire: 'created_at', codec: integer }, updatedAt: { wire: 'updated_at', codec: integer }, deviceSignature: { wire: 'device_signature', codec: bytes },
|
|
27
|
+
}, 'meshline.channel.descriptor');
|
|
28
|
+
export const channelDescriptorInput = (value: ChannelDescriptor, context: NetworkContext): Uint8Array => signingInput(channelDescriptorCodec.encode(value), context, ['device_signature']);
|
|
29
|
+
function size(value: JsonObject, limit: number): void { if (encodeUtf8(canonicalJson(value)).length > limit) throw new ProtocolError('invalid_size', `Channel document exceeds ${limit} canonical JSON bytes.`); }
|
|
30
|
+
export function validateChannelDescriptor(value: ChannelDescriptor, context: NetworkContext): void {
|
|
31
|
+
const wire = channelDescriptorCodec.encode(value); validateCompleteObject(wire); validateChannelRef(value); validateAccountId(value.creator); requireLength(value.nonce, 16, 'Channel nonce');
|
|
32
|
+
if (!containsNonWhitespace(value.name) || encodeUtf8(value.name).length > 256) throw new ProtocolError('invalid_name', 'Channel name requires text and at most 256 UTF-8 bytes.');
|
|
33
|
+
if (value.description !== undefined && value.description.length > 0 && (!containsNonWhitespace(value.description) || encodeUtf8(value.description).length > 4096)) throw new ProtocolError('invalid_description', 'Nonempty channel description requires text and at most 4096 UTF-8 bytes.');
|
|
34
|
+
requireSafeInteger(value.revision, 0); requireSafeInteger(value.createdAt, 0); requireSafeInteger(value.updatedAt, 0); requireLength(value.deviceSignature, 64, 'Channel signature');
|
|
35
|
+
if (value.revision === 0 && (value.status !== 'active' || value.createdAt !== value.updatedAt)) throw new ProtocolError('invalid_initial_state', 'Initial channel descriptor must be active with equal creation and update times.');
|
|
36
|
+
if (value.moderators) {
|
|
37
|
+
if (value.moderators.length > 10) throw new ProtocolError('invalid_size', 'At most ten channel moderators are permitted.');
|
|
38
|
+
const seen = new Set([value.creator]); for (const account of value.moderators) { validateAccountId(account); if (seen.has(account)) throw new ProtocolError('duplicate_moderator', 'Moderators must be distinct and cannot include the creator.'); seen.add(account); }
|
|
39
|
+
}
|
|
40
|
+
if (deriveResourceId('channel', value.creator, value.relayId, value.nonce, context) !== value.channelId) throw new ProtocolError('invalid_identity', 'Channel ID does not bind its creator, relay, nonce and context.'); size(wire, 8192);
|
|
41
|
+
}
|
|
42
|
+
export interface ChannelPost extends ExtensibleModel { readonly channelId: string; readonly messageId: string; readonly body?: MessageBody; readonly attachments?: readonly ContentReference[]; readonly deviceSignature: Uint8Array }
|
|
43
|
+
export const channelPostCodec = defineCodec<ChannelPost>({ channelId: { wire: 'channel_id', codec: text }, messageId: { wire: 'message_id', codec: text }, body: { wire: 'body', codec: messageBodyCodec, optional: true },
|
|
44
|
+
attachments: { wire: 'attachments', codec: array(contentReferenceCodec), optional: true }, deviceSignature: { wire: 'device_signature', codec: bytes } }, 'meshline.channel.post');
|
|
45
|
+
export const channelPostInput = (value: ChannelPost, context: NetworkContext): Uint8Array => signingInput(channelPostCodec.encode(value), context, ['device_signature']);
|
|
46
|
+
export function validateChannelPost(value: ChannelPost): void {
|
|
47
|
+
const wire = channelPostCodec.encode(value); validateCompleteObject(wire); validateIdentifier('channel', value.channelId); validateIdentifier('message', value.messageId); requireLength(value.deviceSignature, 64, 'Post signature');
|
|
48
|
+
if (!value.body && !value.attachments?.length) throw new ProtocolError('content_required', 'Channel posts require a body or at least one attachment.');
|
|
49
|
+
if (value.body) validateMessageBody(value.body); if (value.attachments) validateAttachments(value.attachments); size(wire, 65536);
|
|
50
|
+
}
|
|
51
|
+
export interface ChannelPostEdit extends ExtensibleModel { readonly channelId: string; readonly targetSequence: number; readonly body?: MessageBody | null; readonly attachments?: readonly ContentReference[] | null; readonly deviceSignature: Uint8Array }
|
|
52
|
+
export const channelPostEditCodec = defineCodec<ChannelPostEdit>({ channelId: { wire: 'channel_id', codec: text }, targetSequence: { wire: 'target_sequence', codec: integer },
|
|
53
|
+
body: { wire: 'body', codec: messageBodyCodec, optional: true, nullable: true }, attachments: { wire: 'attachments', codec: array(contentReferenceCodec), optional: true, nullable: true },
|
|
54
|
+
deviceSignature: { wire: 'device_signature', codec: bytes } }, 'meshline.channel.post.edit');
|
|
55
|
+
export const channelPostEditInput = (value: ChannelPostEdit, context: NetworkContext): Uint8Array => signingInput(channelPostEditCodec.encode(value), context, ['device_signature']);
|
|
56
|
+
export function validateChannelPostEdit(value: ChannelPostEdit): void {
|
|
57
|
+
const wire = channelPostEditCodec.encode(value); validateIdentifier('channel', value.channelId); requireSafeInteger(value.targetSequence, 1); requireLength(value.deviceSignature, 64, 'Post edit signature');
|
|
58
|
+
if (value.body === undefined && value.attachments === undefined && !Object.keys(value.additionalProperties ?? {}).length) throw new ProtocolError('empty_edit', 'A channel edit must change at least one field.');
|
|
59
|
+
for (const key of Object.keys(value.additionalProperties ?? {})) if (['$type', '$context', 'channel_id', 'message_id', 'body', 'attachments', 'device_signature'].includes(key)) throw new ProtocolError('invalid_extension', 'Edit extensions cannot replace defined post fields or context.');
|
|
60
|
+
// Only root field deletion markers may be null. Nested values remain complete objects.
|
|
61
|
+
for (const [key, field] of Object.entries(wire)) if (field !== null) validateCompleteObject({ [key]: field });
|
|
62
|
+
if (value.body) validateMessageBody(value.body); if (value.attachments) validateAttachments(value.attachments);
|
|
63
|
+
if (value.body === null && (value.attachments === null || value.attachments?.length === 0)) throw new ProtocolError('content_required', 'An edit cannot remove both body and every attachment.'); size(wire, 65536);
|
|
64
|
+
}
|
|
65
|
+
/** Applies replacement/deletion/omission in acceptance order and validates the resulting complete post. Does not alter signed evidence. */
|
|
66
|
+
export function applyChannelPostEdit(post: ChannelPost, edit: ChannelPostEdit): ChannelPost {
|
|
67
|
+
validateChannelPost(post); validateChannelPostEdit(edit);
|
|
68
|
+
if (post.channelId !== edit.channelId) throw new ProtocolError('invalid_binding', 'Post edit belongs to another channel.');
|
|
69
|
+
const wire = channelPostCodec.encode(post); const changes = channelPostEditCodec.encode(edit);
|
|
70
|
+
for (const [key, value] of Object.entries(changes)) if (!['$type', 'channel_id', 'target_sequence', 'device_signature'].includes(key)) { if (value === null) delete wire[key]; else wire[key] = value; }
|
|
71
|
+
const result = channelPostCodec.decode(wire); validateChannelPost(result); return result;
|
|
72
|
+
}
|
|
73
|
+
export interface ChannelPostDelete extends ExtensibleModel { readonly channelId: string; readonly targetSequence: number; readonly reason?: string; readonly deviceSignature: Uint8Array }
|
|
74
|
+
export const channelPostDeleteCodec = defineCodec<ChannelPostDelete>({ channelId: { wire: 'channel_id', codec: text }, targetSequence: { wire: 'target_sequence', codec: integer }, reason: { wire: 'reason', codec: text, optional: true }, deviceSignature: { wire: 'device_signature', codec: bytes } }, 'meshline.channel.post.delete');
|
|
75
|
+
export const channelPostDeleteInput = (value: ChannelPostDelete, context: NetworkContext): Uint8Array => signingInput(channelPostDeleteCodec.encode(value), context, ['device_signature']);
|
|
76
|
+
export function validateChannelPostDelete(value: ChannelPostDelete): void {
|
|
77
|
+
const wire = channelPostDeleteCodec.encode(value); validateCompleteObject(wire); validateIdentifier('channel', value.channelId); requireSafeInteger(value.targetSequence, 1); requireLength(value.deviceSignature, 64, 'Post deletion signature');
|
|
78
|
+
if (value.reason !== undefined && !containsNonWhitespace(value.reason)) throw new ProtocolError('invalid_reason', 'Deletion reason must contain text when present.'); size(wire, 65536);
|
|
79
|
+
}
|
|
80
|
+
export type ChannelPayload = { readonly kind: 'descriptor'; readonly value: ChannelDescriptor } | { readonly kind: 'post'; readonly value: ChannelPost } | { readonly kind: 'edit'; readonly value: ChannelPostEdit } | { readonly kind: 'delete'; readonly value: ChannelPostDelete };
|
|
81
|
+
export const channelPayloadCodec: ValueCodec<ChannelPayload> = {
|
|
82
|
+
decode(value) { switch (requireObject(value)['$type']) {
|
|
83
|
+
case 'meshline.channel.descriptor': return { kind: 'descriptor', value: channelDescriptorCodec.decode(value) };
|
|
84
|
+
case 'meshline.channel.post': return { kind: 'post', value: channelPostCodec.decode(value) };
|
|
85
|
+
case 'meshline.channel.post.edit': return { kind: 'edit', value: channelPostEditCodec.decode(value) };
|
|
86
|
+
case 'meshline.channel.post.delete': return { kind: 'delete', value: channelPostDeleteCodec.decode(value) };
|
|
87
|
+
default: throw new ProtocolError('unsupported_type', 'Unsupported channel event payload.');
|
|
88
|
+
} },
|
|
89
|
+
encode(payload) { switch (payload.kind) { case 'descriptor': return channelDescriptorCodec.encode(payload.value); case 'post': return channelPostCodec.encode(payload.value); case 'edit': return channelPostEditCodec.encode(payload.value); case 'delete': return channelPostDeleteCodec.encode(payload.value); } },
|
|
90
|
+
};
|
|
91
|
+
export function validateChannelPayload(payload: ChannelPayload, context: NetworkContext): void {
|
|
92
|
+
switch (payload.kind) { case 'descriptor': return validateChannelDescriptor(payload.value, context); case 'post': return validateChannelPost(payload.value); case 'edit': return validateChannelPostEdit(payload.value); case 'delete': return validateChannelPostDelete(payload.value); }
|
|
93
|
+
}
|
|
94
|
+
export const channelPayloadInput = (payload: ChannelPayload, context: NetworkContext): Uint8Array => signingInput(requireObject(channelPayloadCodec.encode(payload)), context, ['device_signature']);
|
|
95
|
+
export interface ChannelEvent extends ExtensibleModel { readonly sequence: number; readonly descriptorRev: number; readonly payload: ChannelPayload; readonly acceptedAt: number; readonly signerDeviceId: string }
|
|
96
|
+
export const channelEventCodec = defineCodec<ChannelEvent>({ sequence: { wire: 'sequence', codec: integer }, descriptorRev: { wire: 'descriptor_rev', codec: integer }, payload: { wire: 'payload', codec: channelPayloadCodec }, acceptedAt: { wire: 'accepted_at', codec: integer }, signerDeviceId: { wire: 'signer_device_id', codec: text } });
|
|
97
|
+
export function validateChannelEvent(value: ChannelEvent, context: NetworkContext): void {
|
|
98
|
+
const { payload, ...outer } = channelEventCodec.encode(value); validateCompleteObject(outer); validateChannelPayload(value.payload, context);
|
|
99
|
+
requireSafeInteger(value.sequence, 0); requireSafeInteger(value.descriptorRev, 0); requireSafeInteger(value.acceptedAt, 0); validateIdentifier('device', value.signerDeviceId);
|
|
100
|
+
if (value.sequence === 0 && (value.payload.kind !== 'descriptor' || value.payload.value.revision !== 0)) throw new ProtocolError('invalid_sequence', 'Sequence zero requires the initial channel descriptor.');
|
|
101
|
+
if (value.payload.kind === 'descriptor' && (value.payload.value.revision !== value.descriptorRev || value.sequence > 0 && value.payload.value.revision === 0)) throw new ProtocolError('invalid_revision', 'Descriptor event revision or initial sequence is inconsistent.');
|
|
102
|
+
if ((value.payload.kind === 'edit' || value.payload.kind === 'delete') && value.payload.value.targetSequence >= value.sequence) throw new ProtocolError('invalid_sequence', 'A channel edit or deletion must target an earlier positive post sequence.');
|
|
103
|
+
}
|
|
104
|
+
export interface ChannelResolveResult extends ExtensibleModel { readonly descriptor: ChannelDescriptor; readonly signerCertificate: DeviceCertificate }
|
|
105
|
+
export const channelResolveResultCodec = defineCodec<ChannelResolveResult>({ descriptor: { wire: 'descriptor', codec: channelDescriptorCodec }, signerCertificate: { wire: 'signer_certificate', codec: deviceCertificateCodec } });
|
|
106
|
+
export function verifyChannelDescriptor(value: ChannelResolveResult, context: NetworkContext, channel: ChannelRef): void {
|
|
107
|
+
validateCompleteObject(channelResolveResultCodec.encode(value)); validateChannelRef(channel); validateChannelDescriptor(value.descriptor, context); validateCertificate(value.signerCertificate, context);
|
|
108
|
+
if (value.descriptor.channelId !== channel.channelId || value.descriptor.relayId !== channel.relayId || value.signerCertificate.account !== value.descriptor.creator) throw new ProtocolError('invalid_binding', 'Channel descriptor or signer belongs to another resource or account.');
|
|
109
|
+
if (!verifyDevice(channelDescriptorInput(value.descriptor, context), value.descriptor.deviceSignature, value.signerCertificate.signingPublicKey)) throw new ProtocolError('invalid_signature', 'Invalid channel descriptor signature.');
|
|
110
|
+
}
|
|
111
|
+
export function verifyChannelEvent(value: ChannelEvent, certificate: DeviceCertificate, descriptor: ChannelDescriptor, context: NetworkContext, channel: ChannelRef): void {
|
|
112
|
+
validateChannelEvent(value, context); validateCertificate(certificate, context); validateChannelRef(channel);
|
|
113
|
+
if (certificateId(certificate, context) !== value.signerDeviceId || value.payload.value.channelId !== channel.channelId) throw new ProtocolError('invalid_binding', 'Channel event signer or resource binding is invalid.');
|
|
114
|
+
if (value.payload.kind === 'descriptor') { verifyChannelDescriptor({ descriptor: value.payload.value, signerCertificate: certificate }, context, channel); return; }
|
|
115
|
+
validateChannelDescriptor(descriptor, context);
|
|
116
|
+
if (descriptor.channelId !== channel.channelId || descriptor.relayId !== channel.relayId || descriptor.revision !== value.descriptorRev || descriptor.status !== 'active'
|
|
117
|
+
|| descriptor.creator !== certificate.account && !descriptor.moderators?.includes(certificate.account)) throw new ProtocolError('unauthorized_event', 'Channel content signer is not authorized by the accepted descriptor revision.');
|
|
118
|
+
if (!verifyDevice(channelPayloadInput(value.payload, context), value.payload.value.deviceSignature, certificate.signingPublicKey)) throw new ProtocolError('invalid_signature', 'Invalid channel content signature.');
|
|
119
|
+
}
|
|
120
|
+
export interface ChannelReadQuery extends ExtensibleModel { readonly channelId: string; readonly before?: number; readonly after?: number; readonly limit?: number }
|
|
121
|
+
export const channelReadQueryCodec = defineCodec<ChannelReadQuery>({ channelId: { wire: 'channel_id', codec: text }, before: { wire: 'before', codec: integer, optional: true }, after: { wire: 'after', codec: integer, optional: true }, limit: { wire: 'limit', codec: integer, optional: true } });
|
|
122
|
+
export function validateChannelReadQuery(value: ChannelReadQuery): void {
|
|
123
|
+
validateCompleteObject(channelReadQueryCodec.encode(value)); validateIdentifier('channel', value.channelId); if (value.before !== undefined) requireSafeInteger(value.before, 0); if (value.after !== undefined) requireSafeInteger(value.after, -1); if (value.limit !== undefined) requireSafeInteger(value.limit, 1);
|
|
124
|
+
if (value.before !== undefined && value.after !== undefined) throw new ProtocolError('invalid_bounds', 'Channel before and after cursors cannot be combined.');
|
|
125
|
+
}
|
|
126
|
+
export interface ChannelReadPage extends ExtensibleModel { readonly events: readonly ChannelEvent[]; readonly certificates: readonly DeviceCertificate[]; readonly hasMore: boolean }
|
|
127
|
+
export const channelReadPageCodec = defineCodec<ChannelReadPage>({ events: { wire: 'events', codec: array(channelEventCodec) }, certificates: { wire: 'certificates', codec: array(deviceCertificateCodec) }, hasMore: { wire: 'has_more', codec: boolean } });
|
|
128
|
+
/** Validates page boundaries and historical certificate coverage; descriptor authorization is checked by the reader before persistence. */
|
|
129
|
+
export function validateChannelReadPage(value: ChannelReadPage, query: ChannelReadQuery, context: NetworkContext): ReadonlyMap<string, DeviceCertificate> {
|
|
130
|
+
validateChannelReadQuery(query); const { events, ...outer } = channelReadPageCodec.encode(value); validateCompleteObject(outer);
|
|
131
|
+
if (value.hasMore && !value.events.length || query.limit !== undefined && value.events.length > query.limit) throw new ProtocolError('invalid_page', 'Invalid channel page size or pagination.');
|
|
132
|
+
const certificates = new Map<string, DeviceCertificate>();
|
|
133
|
+
for (const certificate of value.certificates) { validateCertificate(certificate, context); const id = certificateId(certificate, context); if (certificates.has(id)) throw new ProtocolError('duplicate_certificate', 'Channel page contains duplicate device identities.'); certificates.set(id, certificate); }
|
|
134
|
+
let previous = query.after ?? -1; const revisions = new Set<number>();
|
|
135
|
+
for (const event of value.events) {
|
|
136
|
+
validateChannelEvent(event, context);
|
|
137
|
+
if (event.sequence <= previous || query.before !== undefined && event.sequence >= query.before) throw new ProtocolError('invalid_sequence', 'Channel page violates requested bounds or sequence order.'); previous = event.sequence;
|
|
138
|
+
if (!certificates.has(event.signerDeviceId)) throw new ProtocolError('missing_certificate', 'Channel event lacks signing evidence.');
|
|
139
|
+
if (event.payload.kind === 'descriptor') { if (revisions.has(event.descriptorRev)) throw new ProtocolError('duplicate_revision', 'Descriptor revision occurs twice in one page.'); revisions.add(event.descriptorRev); }
|
|
140
|
+
}
|
|
141
|
+
return certificates;
|
|
142
|
+
}
|
|
143
|
+
export interface ChannelResolveQuery extends ExtensibleModel { readonly channelId: string; readonly revision?: number }
|
|
144
|
+
export const channelResolveQueryCodec = defineCodec<ChannelResolveQuery>({ channelId: { wire: 'channel_id', codec: text }, revision: { wire: 'revision', codec: integer, optional: true } });
|
|
145
|
+
export interface ChannelCloseRequest extends ExtensibleModel { readonly channelId: string; readonly revision: number; readonly updatedAt: number; readonly deviceSignature: Uint8Array }
|
|
146
|
+
export const channelCloseRequestCodec = defineCodec<ChannelCloseRequest>({ channelId: { wire: 'channel_id', codec: text }, revision: { wire: 'revision', codec: integer }, updatedAt: { wire: 'updated_at', codec: integer }, deviceSignature: { wire: 'device_signature', codec: bytes } });
|
|
147
|
+
export interface ChannelReportRequest extends ExtensibleModel { readonly channelId: string; readonly targetSequence: number; readonly reason: string }
|
|
148
|
+
export const channelReportRequestCodec = defineCodec<ChannelReportRequest>({ channelId: { wire: 'channel_id', codec: text }, targetSequence: { wire: 'target_sequence', codec: integer }, reason: { wire: 'reason', codec: text } });
|
|
149
|
+
export function validateChannelReportRequest(value: ChannelReportRequest): void { validateCompleteObject(channelReportRequestCodec.encode(value)); validateIdentifier('channel', value.channelId); requireSafeInteger(value.targetSequence, 1); if (!containsNonWhitespace(value.reason)) throw new ProtocolError('invalid_reason', 'A channel report requires a reason.'); }
|
|
150
|
+
export interface ChannelSubscriptionRequest extends ExtensibleModel { readonly channelIds: readonly string[] }
|
|
151
|
+
export const channelSubscriptionRequestCodec = defineCodec<ChannelSubscriptionRequest>({ channelIds: { wire: 'channel_ids', codec: array(text) } });
|
|
152
|
+
export function validateChannelSubscriptionRequest(value: ChannelSubscriptionRequest): void { validateCompleteObject(channelSubscriptionRequestCodec.encode(value)); const seen = new Set<string>(); for (const id of value.channelIds) { validateIdentifier('channel', id); if (seen.has(id)) throw new ProtocolError('duplicate_channel', 'Subscription contains a duplicate channel.'); seen.add(id); } }
|
|
153
|
+
export interface ChannelTimelineChangedNotification extends ExtensibleModel { readonly channelId: string; readonly head: number }
|
|
154
|
+
export const channelTimelineChangedCodec = defineCodec<ChannelTimelineChangedNotification>({ channelId: { wire: 'channel_id', codec: text }, head: { wire: 'head', codec: integer } });
|
|
155
|
+
export function validateChannelTimelineChanged(value: ChannelTimelineChangedNotification): void { validateCompleteObject(channelTimelineChangedCodec.encode(value)); validateIdentifier('channel', value.channelId); requireSafeInteger(value.head, 0); }
|