@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,139 @@
|
|
|
1
|
+
import { requireLength, verifyDevice } from '../crypto/primitives.js';
|
|
2
|
+
import { ProtocolError } from '../errors.js';
|
|
3
|
+
import { validateIdentifier } from '../identity/identifiers.js';
|
|
4
|
+
import { validateAccountId } 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, parseJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
9
|
+
import { validateCompleteObject } from '../protocol/validation.js';
|
|
10
|
+
import { relayFailureCodec, type RelayFailure } from '../transport/relay-error.js';
|
|
11
|
+
import { contentReferenceCodec, messageBodyCodec, validateAttachments, validateMessageBody, type ContentReference, type MessageBody } from './content.js';
|
|
12
|
+
import { contactAuthorizationCodec, validateContactGrant, validateContactInvite, type ContactAuthorization } from './contacts.js';
|
|
13
|
+
import { certificateId, deviceCertificateCodec, validateCertificate, type DeviceCertificate } from './identity.js';
|
|
14
|
+
|
|
15
|
+
/** Complete wire business object. Unknown types may be stored but cannot authorize protocol state changes. */
|
|
16
|
+
export const messagePayloadCodec: ValueCodec<JsonObject> = {
|
|
17
|
+
decode(value) {
|
|
18
|
+
const object = requireObject(value); validateCompleteObject(object);
|
|
19
|
+
if (typeof object['$type'] !== 'string') throw new ProtocolError('invalid_type', 'A message payload requires a string $type.');
|
|
20
|
+
return requireObject(parseJson(canonicalJson(object)));
|
|
21
|
+
},
|
|
22
|
+
encode(value) { return this.decode(value); },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface EncryptedPayload extends ExtensibleModel { readonly alg: string; readonly nonce: Uint8Array; readonly ciphertext: Uint8Array }
|
|
26
|
+
export const encryptedPayloadCodec = defineCodec<EncryptedPayload>({ alg: { wire: 'alg', codec: text }, nonce: { wire: 'nonce', codec: bytes }, ciphertext: { wire: 'ciphertext', codec: bytes } });
|
|
27
|
+
export function validateEncryptedPayload(value: EncryptedPayload): void {
|
|
28
|
+
validateCompleteObject(encryptedPayloadCodec.encode(value));
|
|
29
|
+
if (value.alg !== 'AES-256-GCM') throw new ProtocolError('unsupported_algorithm', 'Message payload encryption must use AES-256-GCM.');
|
|
30
|
+
requireLength(value.nonce, 12, 'Payload nonce');
|
|
31
|
+
if (value.ciphertext.length <= 16) throw new ProtocolError('invalid_ciphertext', 'Encrypted payload requires nonempty ciphertext and a GCM tag.');
|
|
32
|
+
}
|
|
33
|
+
export interface MessageEnvelope extends ExtensibleModel {
|
|
34
|
+
readonly messageId: string; readonly createdAt: number; readonly from: string; readonly fromDeviceId: string; readonly to: string;
|
|
35
|
+
readonly payload: EncryptedPayload; readonly deviceSignature: Uint8Array;
|
|
36
|
+
}
|
|
37
|
+
export const messageEnvelopeCodec = defineCodec<MessageEnvelope>({ messageId: { wire: 'message_id', codec: text }, createdAt: { wire: 'created_at', codec: integer },
|
|
38
|
+
from: { wire: 'from', codec: text }, fromDeviceId: { wire: 'from_device_id', codec: text }, to: { wire: 'to', codec: text },
|
|
39
|
+
payload: { wire: 'payload', codec: encryptedPayloadCodec }, deviceSignature: { wire: 'device_signature', codec: bytes } }, 'meshline.message.envelope');
|
|
40
|
+
export const messageEnvelopeInput = (value: MessageEnvelope, context: NetworkContext): Uint8Array => signingInput(messageEnvelopeCodec.encode(value), context, ['device_signature']);
|
|
41
|
+
export function validateMessageEnvelope(value: MessageEnvelope): void {
|
|
42
|
+
validateCompleteObject(messageEnvelopeCodec.encode(value));
|
|
43
|
+
validateIdentifier('message', value.messageId); validateIdentifier('device', value.fromDeviceId); requireSafeInteger(value.createdAt, 0);
|
|
44
|
+
validateAccountId(value.from); validateAccountId(value.to); requireLength(value.deviceSignature, 64, 'Envelope signature'); validateEncryptedPayload(value.payload);
|
|
45
|
+
if (encodeUtf8(messageEnvelopeCodec.stringify(value)).length > 262144) throw new ProtocolError('invalid_size', 'Message envelope exceeds 262144 canonical UTF-8 bytes.');
|
|
46
|
+
}
|
|
47
|
+
/** Historical signature integrity only. Current device authority is checked separately by the receiver's business workflow. */
|
|
48
|
+
export function verifyMessageEnvelope(value: MessageEnvelope, sender: DeviceCertificate, context: NetworkContext): void {
|
|
49
|
+
validateMessageEnvelope(value); validateCertificate(sender, context);
|
|
50
|
+
if (sender.account !== value.from || certificateId(sender, context) !== value.fromDeviceId) throw new ProtocolError('invalid_identity', 'Envelope signing certificate belongs to another account or device.');
|
|
51
|
+
if (!verifyDevice(messageEnvelopeInput(value, context), value.deviceSignature, sender.signingPublicKey)) throw new ProtocolError('invalid_signature', 'Invalid message envelope signature.');
|
|
52
|
+
}
|
|
53
|
+
export interface MessageKeyBox extends ExtensibleModel { readonly deviceId: string; readonly alg: string; readonly enc: Uint8Array; readonly sealedKey: Uint8Array }
|
|
54
|
+
export const messageKeyBoxCodec = defineCodec<MessageKeyBox>({ deviceId: { wire: 'device_id', codec: text }, alg: { wire: 'alg', codec: text }, enc: { wire: 'enc', codec: bytes }, sealedKey: { wire: 'sealed_key', codec: bytes } });
|
|
55
|
+
export function validateMessageKeyBox(value: MessageKeyBox): void {
|
|
56
|
+
validateCompleteObject(messageKeyBoxCodec.encode(value)); validateIdentifier('device', value.deviceId);
|
|
57
|
+
if (value.alg !== 'X25519-HKDF-SHA256-AES256GCM') throw new ProtocolError('unsupported_algorithm', 'Unsupported message key box algorithm.');
|
|
58
|
+
requireLength(value.enc, 32, 'Ephemeral public key'); requireLength(value.sealedKey, 60, 'Sealed content key');
|
|
59
|
+
}
|
|
60
|
+
export function validateMessageKeyBoxes(values: readonly MessageKeyBox[]): void {
|
|
61
|
+
if (values.length < 1 || values.length > 8) throw new ProtocolError('invalid_size', 'Key box sets require between one and eight devices.');
|
|
62
|
+
const ids = new Set<string>();
|
|
63
|
+
for (const value of values) { validateMessageKeyBox(value); if (ids.has(value.deviceId)) throw new ProtocolError('duplicate_device', 'Key box set contains a duplicate device.'); ids.add(value.deviceId); }
|
|
64
|
+
}
|
|
65
|
+
export interface MessageSendRequest extends ExtensibleModel {
|
|
66
|
+
readonly envelope: MessageEnvelope; readonly senderBoxes?: readonly MessageKeyBox[]; readonly recipientBoxes: readonly MessageKeyBox[]; readonly authorization?: ContactAuthorization;
|
|
67
|
+
}
|
|
68
|
+
export const messageSendRequestCodec = defineCodec<MessageSendRequest>({ envelope: { wire: 'envelope', codec: messageEnvelopeCodec }, senderBoxes: { wire: 'sender_boxes', codec: array(messageKeyBoxCodec), optional: true },
|
|
69
|
+
recipientBoxes: { wire: 'recipient_boxes', codec: array(messageKeyBoxCodec) }, authorization: { wire: 'authorization', codec: contactAuthorizationCodec, optional: true } });
|
|
70
|
+
export function validateMessageSendRequest(value: MessageSendRequest, now: number): void {
|
|
71
|
+
validateCompleteObject(messageSendRequestCodec.encode(value)); requireSafeInteger(now, 0);
|
|
72
|
+
validateMessageEnvelope(value.envelope); validateMessageKeyBoxes(value.recipientBoxes);
|
|
73
|
+
if (value.senderBoxes) validateMessageKeyBoxes(value.senderBoxes);
|
|
74
|
+
if (value.envelope.from === value.envelope.to && (value.senderBoxes !== undefined || value.authorization !== undefined))
|
|
75
|
+
throw new ProtocolError('invalid_self_delivery', 'Self messages must omit sender boxes and contact authorization.');
|
|
76
|
+
if (value.authorization) {
|
|
77
|
+
if ('grantor' in value.authorization) {
|
|
78
|
+
validateContactGrant(value.authorization, now);
|
|
79
|
+
if (value.authorization.grantor !== value.envelope.to || value.authorization.grantee !== value.envelope.from) throw new ProtocolError('invalid_authorization', 'Message grant has inconsistent account bindings.');
|
|
80
|
+
} else {
|
|
81
|
+
validateContactInvite(value.authorization, now);
|
|
82
|
+
if (value.authorization.inviter !== value.envelope.to) throw new ProtocolError('invalid_authorization', 'Message invitation belongs to another recipient.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export interface DirectMessageReference extends ExtensibleModel { readonly from: string; readonly messageId: string }
|
|
87
|
+
export const directMessageReferenceCodec = defineCodec<DirectMessageReference>({ from: { wire: 'from', codec: text }, messageId: { wire: 'message_id', codec: text } });
|
|
88
|
+
export interface DirectMessage extends ExtensibleModel { readonly body?: MessageBody; readonly attachments?: readonly ContentReference[]; readonly replyTo?: DirectMessageReference }
|
|
89
|
+
export const directMessageCodec = defineCodec<DirectMessage>({ body: { wire: 'body', codec: messageBodyCodec, optional: true }, attachments: { wire: 'attachments', codec: array(contentReferenceCodec), optional: true },
|
|
90
|
+
replyTo: { wire: 'reply_to', codec: directMessageReferenceCodec, optional: true } }, 'meshline.message.direct');
|
|
91
|
+
export function validateDirectMessage(value: DirectMessage): void {
|
|
92
|
+
validateCompleteObject(directMessageCodec.encode(value));
|
|
93
|
+
if (value.body) validateMessageBody(value.body);
|
|
94
|
+
if (value.attachments) validateAttachments(value.attachments);
|
|
95
|
+
if (!value.body && !value.attachments?.length) throw new ProtocolError('invalid_message', 'A direct message requires a body or at least one attachment.');
|
|
96
|
+
if (value.replyTo) { validateAccountId(value.replyTo.from); validateIdentifier('message', value.replyTo.messageId); }
|
|
97
|
+
}
|
|
98
|
+
export interface MessageTimelineEntry extends ExtensibleModel { readonly sequence: number; readonly envelope: MessageEnvelope; readonly keyBox: MessageKeyBox; readonly acceptedAt: number }
|
|
99
|
+
export const messageTimelineEntryCodec = defineCodec<MessageTimelineEntry>({ sequence: { wire: 'sequence', codec: integer }, envelope: { wire: 'envelope', codec: messageEnvelopeCodec }, keyBox: { wire: 'key_box', codec: messageKeyBoxCodec }, acceptedAt: { wire: 'accepted_at', codec: integer } });
|
|
100
|
+
export interface MessageTimelinePage extends ExtensibleModel { readonly items: readonly MessageTimelineEntry[]; readonly certificates: readonly DeviceCertificate[]; readonly hasMore: boolean; readonly hasRetentionGap?: boolean }
|
|
101
|
+
export const messageTimelinePageCodec = defineCodec<MessageTimelinePage>({ items: { wire: 'items', codec: array(messageTimelineEntryCodec) }, certificates: { wire: 'certificates', codec: array(deviceCertificateCodec) },
|
|
102
|
+
hasMore: { wire: 'has_more', codec: boolean }, hasRetentionGap: { wire: 'has_retention_gap', codec: boolean, optional: true } });
|
|
103
|
+
/** Checks page ordering and certificate availability. Each entry still requires decryption and business validation before cursor commit. */
|
|
104
|
+
export function validateMessageTimelinePage(value: MessageTimelinePage, context: NetworkContext, after = -1): void {
|
|
105
|
+
requireSafeInteger(after, -1); validateCompleteObject(messageTimelinePageCodec.encode(value));
|
|
106
|
+
if (value.hasMore && !value.items.length) throw new ProtocolError('invalid_pagination', 'An empty timeline page cannot have more items.');
|
|
107
|
+
const ids = new Map<string, string>();
|
|
108
|
+
for (const certificate of value.certificates) { validateCertificate(certificate, context); const id = certificateId(certificate, context);
|
|
109
|
+
if (ids.has(id)) throw new ProtocolError('duplicate_device', 'Timeline page has duplicate signing certificates.'); ids.set(id, certificate.account); }
|
|
110
|
+
for (const item of value.items) {
|
|
111
|
+
requireSafeInteger(item.sequence, 0); requireSafeInteger(item.acceptedAt, 0);
|
|
112
|
+
if (item.sequence <= after) throw new ProtocolError('invalid_sequence', 'Timeline sequences must increase strictly after the requested cursor.');
|
|
113
|
+
if (!ids.has(item.envelope.fromDeviceId)) throw new ProtocolError('missing_certificate', 'Timeline message has no signing certificate.');
|
|
114
|
+
if (ids.get(item.envelope.fromDeviceId) !== item.envelope.from) throw new ProtocolError('invalid_identity', 'Timeline signing certificate belongs to another account.');
|
|
115
|
+
after = item.sequence;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export interface MessageTimelineQuery extends ExtensibleModel { readonly after?: number; readonly limit?: number }
|
|
119
|
+
export const messageTimelineQueryCodec = defineCodec<MessageTimelineQuery>({ after: { wire: 'after', codec: integer, optional: true }, limit: { wire: 'limit', codec: integer, optional: true } });
|
|
120
|
+
export function validateMessageTimelineQuery(value: MessageTimelineQuery): void {
|
|
121
|
+
validateCompleteObject(messageTimelineQueryCodec.encode(value));
|
|
122
|
+
if (value.after !== undefined) requireSafeInteger(value.after, -1);
|
|
123
|
+
if (value.limit !== undefined) requireSafeInteger(value.limit, 1);
|
|
124
|
+
}
|
|
125
|
+
export interface MessageDeliveryQuery extends ExtensibleModel { readonly messageId: string }
|
|
126
|
+
export const messageDeliveryQueryCodec = defineCodec<MessageDeliveryQuery>({ messageId: { wire: 'message_id', codec: text } });
|
|
127
|
+
export type MessageDeliveryState = 'delivering' | 'target_accepted' | 'failed';
|
|
128
|
+
export interface MessageDeliveryStatus extends ExtensibleModel { readonly status: MessageDeliveryState; readonly error?: RelayFailure; readonly acceptedAt: number }
|
|
129
|
+
export const messageDeliveryStatusCodec = defineCodec<MessageDeliveryStatus>({ status: { wire: 'status', codec: enumeration('delivering', 'target_accepted', 'failed') }, error: { wire: 'error', codec: relayFailureCodec, optional: true }, acceptedAt: { wire: 'accepted_at', codec: integer } });
|
|
130
|
+
export function validateMessageDeliveryStatus(value: MessageDeliveryStatus): void {
|
|
131
|
+
validateCompleteObject(messageDeliveryStatusCodec.encode(value)); requireSafeInteger(value.acceptedAt, 0);
|
|
132
|
+
if ((value.status === 'failed') !== (value.error !== undefined)) throw new ProtocolError('invalid_delivery', 'Only failed delivery results must contain an error.');
|
|
133
|
+
}
|
|
134
|
+
export interface DeviceStateChanged extends ExtensibleModel { readonly revision: number }
|
|
135
|
+
export const deviceStateChangedCodec = defineCodec<DeviceStateChanged>({ revision: { wire: 'revision', codec: integer } }, 'meshline.device.state.changed');
|
|
136
|
+
export function validateDeviceStateChanged(value: DeviceStateChanged): void { validateCompleteObject(deviceStateChangedCodec.encode(value)); requireSafeInteger(value.revision, 0); }
|
|
137
|
+
export interface MessageTimelineChangedNotification extends ExtensibleModel { readonly head: number }
|
|
138
|
+
export const messageTimelineChangedCodec = defineCodec<MessageTimelineChangedNotification>({ head: { wire: 'head', codec: integer } });
|
|
139
|
+
export function validateMessageTimelineChanged(value: MessageTimelineChangedNotification): void { validateCompleteObject(messageTimelineChangedCodec.encode(value)); requireSafeInteger(value.head, 0); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { requireLength, verifyDevice } from '../crypto/primitives.js';
|
|
2
|
+
import { ProtocolError } from '../errors.js';
|
|
3
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
4
|
+
import { boolean, bytes, defineCodec, integer, text, type ExtensibleModel } from '../protocol/codec.js';
|
|
5
|
+
import { signingInput, type NetworkContext } from '../protocol/context.js';
|
|
6
|
+
import { encodeUtf8 } from '../protocol/encoding.js';
|
|
7
|
+
import { requireSafeInteger } from '../protocol/json.js';
|
|
8
|
+
import { validateCompleteObject } from '../protocol/validation.js';
|
|
9
|
+
import { containsNonWhitespace, contentReferenceCodec, validateContentReference, type ContentReference } from './content.js';
|
|
10
|
+
import { deviceCertificateCodec, validateCertificate, type DeviceCertificate } from './identity.js';
|
|
11
|
+
|
|
12
|
+
export interface AccountProfile extends ExtensibleModel {
|
|
13
|
+
readonly account: string; readonly nickname?: string; readonly avatar?: ContentReference; readonly bio?: string;
|
|
14
|
+
readonly publicDiscovery: boolean; readonly updatedAt: number; readonly deviceSignature: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
export const accountProfileCodec = defineCodec<AccountProfile>({
|
|
17
|
+
account: { wire: 'account', codec: text }, nickname: { wire: 'nickname', codec: text, optional: true },
|
|
18
|
+
avatar: { wire: 'avatar', codec: contentReferenceCodec, optional: true }, bio: { wire: 'bio', codec: text, optional: true },
|
|
19
|
+
publicDiscovery: { wire: 'public_discovery', codec: boolean }, updatedAt: { wire: 'updated_at', codec: integer }, deviceSignature: { wire: 'device_signature', codec: bytes },
|
|
20
|
+
}, 'meshline.profile');
|
|
21
|
+
export const profileInput = (value: AccountProfile, context: NetworkContext): Uint8Array => signingInput(accountProfileCodec.encode(value), context, ['device_signature']);
|
|
22
|
+
export function validateProfile(value: AccountProfile): void {
|
|
23
|
+
validateCompleteObject(accountProfileCodec.encode(value));
|
|
24
|
+
validateAccountId(value.account); requireSafeInteger(value.updatedAt, 0); requireLength(value.deviceSignature, 64, 'Profile signature');
|
|
25
|
+
for (const [field, limit] of [[value.nickname, 256], [value.bio, 2048]] as const) {
|
|
26
|
+
if (field !== undefined && field !== '' && (!containsNonWhitespace(field) || encodeUtf8(field).length > limit))
|
|
27
|
+
throw new ProtocolError('invalid_profile_text', `Nonempty profile text must contain non-whitespace characters and fit ${limit} UTF-8 bytes.`);
|
|
28
|
+
}
|
|
29
|
+
if (value.avatar) {
|
|
30
|
+
validateContentReference(value.avatar);
|
|
31
|
+
if (!value.avatar.contentType.toLowerCase().startsWith('image/')) throw new ProtocolError('invalid_avatar', 'An avatar requires an image media type.');
|
|
32
|
+
}
|
|
33
|
+
if (encodeUtf8(accountProfileCodec.stringify(value)).length > 8192) throw new ProtocolError('invalid_size', 'Profile exceeds 8192 canonical JSON bytes.');
|
|
34
|
+
}
|
|
35
|
+
export interface ProfileResolveResult extends ExtensibleModel { readonly profile: AccountProfile; readonly signerCertificate: DeviceCertificate }
|
|
36
|
+
export const profileResolveResultCodec = defineCodec<ProfileResolveResult>({
|
|
37
|
+
profile: { wire: 'profile', codec: accountProfileCodec }, signerCertificate: { wire: 'signer_certificate', codec: deviceCertificateCodec },
|
|
38
|
+
});
|
|
39
|
+
/** The certificate is evidence at publication acceptance, not a claim of current device authorization. */
|
|
40
|
+
export function validateProfileResult(value: ProfileResolveResult, context: NetworkContext, account = value.profile.account): void {
|
|
41
|
+
validateCompleteObject(profileResolveResultCodec.encode(value));
|
|
42
|
+
validateProfile(value.profile); validateCertificate(value.signerCertificate, context);
|
|
43
|
+
if (value.profile.account !== account || value.signerCertificate.account !== account) throw new ProtocolError('invalid_identity', 'Profile or signer certificate belongs to another account.');
|
|
44
|
+
if (!verifyDevice(profileInput(value.profile, context), value.profile.deviceSignature, value.signerCertificate.signingPublicKey))
|
|
45
|
+
throw new ProtocolError('invalid_signature', 'Invalid profile device signature.');
|
|
46
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { multiaddr } from '@multiformats/multiaddr';
|
|
2
|
+
import { bases } from 'multiformats/basics';
|
|
3
|
+
import { base58btc } from 'multiformats/bases/base58';
|
|
4
|
+
import { create as createDigest, decode as decodeDigest } from 'multiformats/hashes/digest';
|
|
5
|
+
import { requireLength } from '../crypto/primitives.js';
|
|
6
|
+
import { ProtocolError } from '../errors.js';
|
|
7
|
+
import { validateRelayId, verifyRelay } from '../identity/neo.js';
|
|
8
|
+
import { array, bytes, defineCodec, integer, text, type ExtensibleModel } from '../protocol/codec.js';
|
|
9
|
+
import { NetworkContext, signingInput } from '../protocol/context.js';
|
|
10
|
+
import { encodeUtf8, equalBytes } from '../protocol/encoding.js';
|
|
11
|
+
import { requireSafeInteger } from '../protocol/json.js';
|
|
12
|
+
import { webEndpoint } from '../transport/endpoint.js';
|
|
13
|
+
|
|
14
|
+
export interface RelayDescriptor extends ExtensibleModel {
|
|
15
|
+
readonly relayId: string;
|
|
16
|
+
readonly publicKey: Uint8Array;
|
|
17
|
+
readonly endpoints: readonly string[];
|
|
18
|
+
readonly capabilities?: readonly string[];
|
|
19
|
+
readonly expiresAt: number;
|
|
20
|
+
readonly relaySignature: Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
export const relayDescriptorCodec = defineCodec<RelayDescriptor>({
|
|
23
|
+
relayId: { wire: 'relay_id', codec: text }, publicKey: { wire: 'public_key', codec: bytes },
|
|
24
|
+
endpoints: { wire: 'endpoints', codec: array(text) }, capabilities: { wire: 'capabilities', codec: array(text), optional: true },
|
|
25
|
+
expiresAt: { wire: 'expires_at', codec: integer }, relaySignature: { wire: 'relay_signature', codec: bytes },
|
|
26
|
+
}, 'meshline.relay.descriptor');
|
|
27
|
+
|
|
28
|
+
export function relayDescriptorInput(descriptor: RelayDescriptor, context: NetworkContext): Uint8Array {
|
|
29
|
+
return signingInput(relayDescriptorCodec.encode(descriptor), context, ['relay_signature']);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Normalized multihash identity, independent of a peer ID's allowed base/CID spelling. */
|
|
33
|
+
function peerIdentity(value: string): string {
|
|
34
|
+
let hashBytes: Uint8Array;
|
|
35
|
+
if (value.startsWith('Qm') || value.startsWith('1')) hashBytes = base58btc.decode('z' + value);
|
|
36
|
+
else {
|
|
37
|
+
const base = Object.values(bases).find(base => value.startsWith(base.prefix));
|
|
38
|
+
if (!base) throw new ProtocolError('invalid_peer', 'Unknown peer ID multibase.');
|
|
39
|
+
const cid = base.decode(value);
|
|
40
|
+
if (cid[0] !== 1 || cid[1] !== 0x72) throw new ProtocolError('invalid_peer', 'Peer IDs require CIDv1 libp2p-key.');
|
|
41
|
+
hashBytes = cid.subarray(2);
|
|
42
|
+
}
|
|
43
|
+
const digest = decodeDigest(hashBytes);
|
|
44
|
+
if (!(digest.code === 0x12 && digest.size === 32 || digest.code === 0 && digest.size > 0 && digest.size <= 42)
|
|
45
|
+
|| !equalBytes(hashBytes, createDigest(digest.code, digest.digest).bytes))
|
|
46
|
+
throw new ProtocolError('invalid_peer', 'Invalid or noncanonical peer multihash.');
|
|
47
|
+
return base58btc.encode(hashBytes).slice(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RelayEndpoint = { readonly kind: 'https' | 'wss' | 'unknown' } | { readonly kind: 'libp2p-tcp'; readonly peerId: string };
|
|
51
|
+
export function validateRelayEndpoint(endpoint: string): RelayEndpoint {
|
|
52
|
+
if (typeof endpoint !== 'string' || endpoint.length === 0) throw new ProtocolError('invalid_endpoint', 'Relay endpoints must be nonempty strings.');
|
|
53
|
+
const candidate = endpoint.trimStart();
|
|
54
|
+
if (/^(https|wss):/i.test(candidate)) { const url = webEndpoint(endpoint); return { kind: url.protocol === 'https:' ? 'https' : 'wss' }; }
|
|
55
|
+
if (!candidate.startsWith('/') || !candidate.includes('/tcp/') && !candidate.endsWith('/tcp')) return { kind: 'unknown' };
|
|
56
|
+
if (!endpoint.startsWith('/') || endpoint.endsWith('/') || endpoint.includes('//') || /[\s\p{Cc}]/u.test(endpoint))
|
|
57
|
+
throw new ProtocolError('invalid_endpoint', 'Malformed libp2p TCP endpoint.');
|
|
58
|
+
try {
|
|
59
|
+
const parts = endpoint.split('/');
|
|
60
|
+
if (parts.at(-2) !== 'p2p') throw new Error('Endpoint must end with a peer ID.');
|
|
61
|
+
const peerId = peerIdentity(parts.at(-1)!);
|
|
62
|
+
for (let i = 0; i < parts.length; i++) if (parts[i] === 'tcp' && !/^[0-9]+$/.test(parts[i + 1] ?? '')) throw new Error('Invalid TCP port.');
|
|
63
|
+
const address = multiaddr(endpoint.slice(0, endpoint.lastIndexOf('/p2p/')) + '/p2p/' + peerId);
|
|
64
|
+
// Parsing textual components alone is lazy; encoding validates IP and port values.
|
|
65
|
+
void address.bytes;
|
|
66
|
+
const components = address.getComponents();
|
|
67
|
+
if (components.length < 3 || components[1]!.name !== 'tcp' || Number(components[1]!.value) <= 0
|
|
68
|
+
|| components.filter(item => item.name === 'tcp').length !== 1 || components.filter(item => item.name === 'p2p').length !== 1
|
|
69
|
+
|| components.at(-1)!.name !== 'p2p') throw new Error('Expected host/TCP/.../peer endpoint.');
|
|
70
|
+
const host = components[0]!;
|
|
71
|
+
if (!['ip4', 'ip6', 'dns', 'dns4', 'dns6'].includes(host.name)) throw new Error('Invalid multiaddr host type.');
|
|
72
|
+
if (host.name.startsWith('dns')) {
|
|
73
|
+
const hostname = parseAbsoluteUrl(`https://${host.value}`).hostname;
|
|
74
|
+
if (hostname.startsWith('[') || /^[0-9.]+$/.test(hostname) || hostname.length > 255
|
|
75
|
+
|| !hostname.replace(/\.$/, '').split('.').every(label => /^[a-z0-9_][a-z0-9_-]{0,62}$/i.test(label))) throw new Error('Invalid DNS host.');
|
|
76
|
+
}
|
|
77
|
+
return { kind: 'libp2p-tcp', peerId };
|
|
78
|
+
} catch (cause) { throw new ProtocolError('invalid_endpoint', 'Invalid libp2p TCP relay endpoint.', { cause }); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function validateRelayDescriptor(descriptor: RelayDescriptor, context: NetworkContext, now: number): void {
|
|
82
|
+
validateCompleteObject(relayDescriptorCodec.encode(descriptor));
|
|
83
|
+
requireSafeInteger(now, 0); requireSafeInteger(descriptor.expiresAt, 0);
|
|
84
|
+
validateRelayId(descriptor.relayId);
|
|
85
|
+
requireLength(descriptor.publicKey, 33, 'Relay public key'); requireLength(descriptor.relaySignature, 64, 'Relay signature');
|
|
86
|
+
if (descriptor.expiresAt <= now) throw new ProtocolError('expired_descriptor', 'The relay descriptor has expired.');
|
|
87
|
+
if (!Array.isArray(descriptor.endpoints) || descriptor.endpoints.length === 0) throw new ProtocolError('invalid_endpoint', 'Relay endpoints must be a nonempty array.');
|
|
88
|
+
const endpoints = new Set<string>();
|
|
89
|
+
let hasHttps = false;
|
|
90
|
+
let peer: string | undefined;
|
|
91
|
+
for (const endpoint of descriptor.endpoints) {
|
|
92
|
+
if (endpoints.has(endpoint)) throw new ProtocolError('duplicate_endpoint', 'Relay endpoints cannot contain duplicates.');
|
|
93
|
+
endpoints.add(endpoint);
|
|
94
|
+
const validated = validateRelayEndpoint(endpoint);
|
|
95
|
+
if (validated.kind === 'https') hasHttps = true;
|
|
96
|
+
if (validated.kind === 'libp2p-tcp') {
|
|
97
|
+
if (peer !== undefined && peer !== validated.peerId) throw new ProtocolError('invalid_peer', 'All libp2p TCP endpoints must identify the same peer.');
|
|
98
|
+
peer = validated.peerId;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!hasHttps || peer === undefined) throw new ProtocolError('missing_endpoint', 'A relay must advertise HTTPS and libp2p TCP endpoints.');
|
|
102
|
+
const capabilities = descriptor.capabilities ?? [];
|
|
103
|
+
if (!Array.isArray(capabilities) || capabilities.length > 64 || new Set(capabilities).size !== capabilities.length)
|
|
104
|
+
throw new ProtocolError('invalid_capabilities', 'Capabilities must be distinct and contain at most 64 entries.');
|
|
105
|
+
for (const value of capabilities) if (value.length === 0 || encodeUtf8(value).length > 128) throw new ProtocolError('invalid_capabilities', 'Capabilities must contain 1 to 128 UTF-8 bytes.');
|
|
106
|
+
if (!verifyRelay(descriptor.relayId, descriptor.publicKey, relayDescriptorInput(descriptor, context), descriptor.relaySignature))
|
|
107
|
+
throw new ProtocolError('invalid_signature', 'The descriptor signature does not identify this relay.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RelayLimits extends ExtensibleModel {
|
|
111
|
+
readonly messageRetention: number;
|
|
112
|
+
readonly channelTimelineRetention?: number;
|
|
113
|
+
readonly maxChannelSubscriptions?: number;
|
|
114
|
+
readonly groupMessageRetention?: number;
|
|
115
|
+
readonly maxGroupMembers?: number;
|
|
116
|
+
readonly maxGroupSubscriptions?: number;
|
|
117
|
+
readonly maxGroupInviteTtl?: number;
|
|
118
|
+
}
|
|
119
|
+
export const relayLimitsCodec = defineCodec<RelayLimits>({
|
|
120
|
+
messageRetention: { wire: 'message_retention', codec: integer },
|
|
121
|
+
channelTimelineRetention: { wire: 'channel_timeline_retention', codec: integer, optional: true },
|
|
122
|
+
maxChannelSubscriptions: { wire: 'max_channel_subscriptions', codec: integer, optional: true },
|
|
123
|
+
groupMessageRetention: { wire: 'group_message_retention', codec: integer, optional: true },
|
|
124
|
+
maxGroupMembers: { wire: 'max_group_members', codec: integer, optional: true },
|
|
125
|
+
maxGroupSubscriptions: { wire: 'max_group_subscriptions', codec: integer, optional: true },
|
|
126
|
+
maxGroupInviteTtl: { wire: 'max_group_invite_ttl', codec: integer, optional: true },
|
|
127
|
+
});
|
|
128
|
+
export interface RelayInfo extends ExtensibleModel { readonly relayId: string; readonly name: string; readonly serverTime: number; readonly limits: RelayLimits }
|
|
129
|
+
export const relayInfoCodec = defineCodec<RelayInfo>({
|
|
130
|
+
relayId: { wire: 'relay_id', codec: text }, name: { wire: 'name', codec: text }, serverTime: { wire: 'server_time', codec: integer }, limits: { wire: 'limits', codec: relayLimitsCodec },
|
|
131
|
+
});
|
|
132
|
+
export function validateRelayInfo(info: RelayInfo, descriptor?: RelayDescriptor): void {
|
|
133
|
+
validateCompleteObject(relayInfoCodec.encode(info));
|
|
134
|
+
validateRelayId(info.relayId); requireSafeInteger(info.serverTime, 0);
|
|
135
|
+
if (info.name.trim().length === 0 || encodeUtf8(info.name).length > 256) throw new ProtocolError('invalid_name', 'Relay name must be nonblank and at most 256 UTF-8 bytes.');
|
|
136
|
+
requireSafeInteger(info.limits.messageRetention, 1);
|
|
137
|
+
for (const value of [info.limits.channelTimelineRetention, info.limits.groupMessageRetention, info.limits.maxGroupMembers, info.limits.maxGroupInviteTtl])
|
|
138
|
+
if (value !== undefined) requireSafeInteger(value, 1);
|
|
139
|
+
for (const value of [info.limits.maxChannelSubscriptions, info.limits.maxGroupSubscriptions]) if (value !== undefined) requireSafeInteger(value, 0);
|
|
140
|
+
if (descriptor) {
|
|
141
|
+
if (info.relayId !== descriptor.relayId) throw new ProtocolError('invalid_identity', 'Relay info belongs to a different relay.');
|
|
142
|
+
const sockets = descriptor.endpoints.some(endpoint => endpoint.startsWith('wss://'));
|
|
143
|
+
const limits = info.limits;
|
|
144
|
+
if (descriptor.capabilities?.includes('channel.host.v1') && (limits.channelTimelineRetention === undefined || sockets && limits.maxChannelSubscriptions === undefined)
|
|
145
|
+
|| descriptor.capabilities?.includes('group.host.v1') && (limits.groupMessageRetention === undefined || limits.maxGroupMembers === undefined
|
|
146
|
+
|| limits.maxGroupInviteTtl === undefined || sockets && limits.maxGroupSubscriptions === undefined))
|
|
147
|
+
throw new ProtocolError('missing_limits', 'Relay info omits limits required by its advertised capabilities.');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
import { validateCompleteObject } from '../protocol/validation.js';
|
|
151
|
+
import { parseAbsoluteUrl } from '../runtime/url.js';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import { decodeBase64Url, encodeBase64Url } from './encoding.js';
|
|
3
|
+
import { canonicalJson, parseJson, requireObject, requireSafeInteger, type JsonObject, type JsonValue } from './json.js';
|
|
4
|
+
|
|
5
|
+
/** Unknown wire fields remain distinct from the application's camelCase members. */
|
|
6
|
+
export interface ExtensibleModel { readonly additionalProperties?: JsonObject }
|
|
7
|
+
|
|
8
|
+
/** A model codec enforces wire representation; domain validators enforce authorization. */
|
|
9
|
+
export interface ProtocolCodec<T> {
|
|
10
|
+
decode(value: JsonValue): T;
|
|
11
|
+
encode(value: T): JsonObject;
|
|
12
|
+
parse(input: string | Uint8Array): T;
|
|
13
|
+
stringify(value: T): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValueCodec<T> {
|
|
17
|
+
decode(value: JsonValue): T;
|
|
18
|
+
encode(value: T): JsonValue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const text: ValueCodec<string> = {
|
|
22
|
+
decode(value) {
|
|
23
|
+
if (typeof value !== 'string') throw new ProtocolError('invalid_field', 'Expected a string.');
|
|
24
|
+
return value;
|
|
25
|
+
},
|
|
26
|
+
encode(value) { return this.decode(value); },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const integer: ValueCodec<number> = {
|
|
30
|
+
decode(value) { requireSafeInteger(value); return value; },
|
|
31
|
+
encode(value) { return this.decode(value); },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const boolean: ValueCodec<boolean> = {
|
|
35
|
+
decode(value) {
|
|
36
|
+
if (typeof value !== 'boolean') throw new ProtocolError('invalid_field', 'Expected a boolean.');
|
|
37
|
+
return value;
|
|
38
|
+
},
|
|
39
|
+
encode(value) { return this.decode(value); },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const bytes: ValueCodec<Uint8Array> = {
|
|
43
|
+
decode(value) { return decodeBase64Url(text.decode(value)); },
|
|
44
|
+
encode(value) {
|
|
45
|
+
if (!(value instanceof Uint8Array)) throw new ProtocolError('invalid_field', 'Expected a Uint8Array.');
|
|
46
|
+
return encodeBase64Url(value);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function array<T>(item: ValueCodec<T>): ValueCodec<readonly T[]> {
|
|
51
|
+
return {
|
|
52
|
+
decode(value) {
|
|
53
|
+
if (!Array.isArray(value)) throw new ProtocolError('invalid_field', 'Expected an array.');
|
|
54
|
+
return value.map(element => item.decode(element));
|
|
55
|
+
},
|
|
56
|
+
encode(value) {
|
|
57
|
+
if (!Array.isArray(value)) throw new ProtocolError('invalid_field', 'Expected an array.');
|
|
58
|
+
return value.map(element => item.encode(element));
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function dictionary<T>(item: ValueCodec<T>): ValueCodec<Readonly<Record<string, T>>> {
|
|
64
|
+
return {
|
|
65
|
+
decode(value) {
|
|
66
|
+
const result: Record<string, T> = Object.create(null) as Record<string, T>;
|
|
67
|
+
for (const [key, field] of Object.entries(requireObject(value))) result[key] = item.decode(field);
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
encode(value) {
|
|
71
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new ProtocolError('invalid_field', 'Expected a dictionary.');
|
|
72
|
+
const result: JsonObject = Object.create(null) as JsonObject;
|
|
73
|
+
for (const [key, field] of Object.entries(value)) result[key] = item.encode(field);
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function enumeration<const T extends string>(...values: T[]): ValueCodec<T> {
|
|
80
|
+
return {
|
|
81
|
+
decode(value) {
|
|
82
|
+
if (typeof value !== 'string' || !values.includes(value as T)) throw new ProtocolError('invalid_field', 'Unknown enum value.');
|
|
83
|
+
return value as T;
|
|
84
|
+
},
|
|
85
|
+
encode(value) { return this.decode(value); },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Field<T> { readonly wire: string; readonly codec: ValueCodec<T>; readonly optional?: boolean; readonly nullable?: boolean }
|
|
90
|
+
type Fields<T> = { [K in Exclude<keyof T, 'additionalProperties'>]-?: Field<Exclude<T[K], null | undefined>> };
|
|
91
|
+
|
|
92
|
+
/** Creates an explicit mapping without guessing wire names or discarding signed extensions. */
|
|
93
|
+
export function defineCodec<T extends ExtensibleModel>(fields: Fields<T>, type?: string): ProtocolCodec<T> {
|
|
94
|
+
const entries = Object.entries(fields) as Array<[string, Field<unknown>]>;
|
|
95
|
+
const wireNames = new Set(entries.map(([, field]) => field.wire));
|
|
96
|
+
if (type !== undefined) wireNames.add('$type');
|
|
97
|
+
if (wireNames.size !== entries.length + (type === undefined ? 0 : 1)) throw new Error('Duplicate codec field definition.');
|
|
98
|
+
const modelNames = new Set(entries.map(([name]) => name));
|
|
99
|
+
const codec: ProtocolCodec<T> = {
|
|
100
|
+
decode(value) {
|
|
101
|
+
const object = requireObject(value);
|
|
102
|
+
// Also enforces scalar, integer and depth rules for direct object inputs.
|
|
103
|
+
canonicalJson(object);
|
|
104
|
+
if (type !== undefined && object['$type'] !== type) throw new ProtocolError('invalid_type', `Expected ${type}.`);
|
|
105
|
+
const result: Record<string, unknown> = {};
|
|
106
|
+
for (const [name, field] of entries) {
|
|
107
|
+
if (!Object.hasOwn(object, field.wire)) {
|
|
108
|
+
if (!field.optional) throw new ProtocolError('missing_field', `Missing ${field.wire}.`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const input = object[field.wire]!;
|
|
112
|
+
if (input === null && field.nullable) result[name] = null;
|
|
113
|
+
else result[name] = field.codec.decode(input);
|
|
114
|
+
}
|
|
115
|
+
const extras: JsonObject = Object.create(null) as JsonObject;
|
|
116
|
+
for (const [name, field] of Object.entries(object)) if (!wireNames.has(name)) extras[name] = field;
|
|
117
|
+
if (Object.keys(extras).length !== 0) result.additionalProperties = requireObject(parseJson(canonicalJson(extras)));
|
|
118
|
+
return result as T;
|
|
119
|
+
},
|
|
120
|
+
encode(value) {
|
|
121
|
+
if (value === null || typeof value !== 'object') throw new ProtocolError('invalid_model', 'Expected a model object.');
|
|
122
|
+
for (const name of Object.keys(value))
|
|
123
|
+
if (name !== 'additionalProperties' && !modelNames.has(name)) throw new ProtocolError('invalid_model', `Unknown model member ${name}; use additionalProperties for wire extensions.`);
|
|
124
|
+
const result: JsonObject = Object.create(null) as JsonObject;
|
|
125
|
+
if (type !== undefined) result['$type'] = type;
|
|
126
|
+
for (const [name, field] of entries) {
|
|
127
|
+
const input = (value as Record<string, unknown>)[name];
|
|
128
|
+
if (input === undefined && field.optional) continue;
|
|
129
|
+
if (input === undefined) throw new ProtocolError('missing_field', `Missing ${name}.`);
|
|
130
|
+
if (input === null && field.nullable) result[field.wire] = null;
|
|
131
|
+
else result[field.wire] = field.codec.encode(input);
|
|
132
|
+
}
|
|
133
|
+
for (const [name, field] of Object.entries(value.additionalProperties ?? {})) {
|
|
134
|
+
if (wireNames.has(name)) throw new ProtocolError('conflicting_extension', `Extension conflicts with ${name}.`);
|
|
135
|
+
result[name] = field;
|
|
136
|
+
}
|
|
137
|
+
// Copy extensions and validate the entire resulting tree without normalizing fields.
|
|
138
|
+
return requireObject(parseJson(canonicalJson(result)));
|
|
139
|
+
},
|
|
140
|
+
parse(input) { return this.decode(parseJson(input)); },
|
|
141
|
+
stringify(value) { return canonicalJson(this.encode(value)); },
|
|
142
|
+
};
|
|
143
|
+
return codec;
|
|
144
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import { canonicalBytes, requireSafeInteger, type JsonObject } from './json.js';
|
|
3
|
+
|
|
4
|
+
/** Trusted network identity; a remote document cannot supply this configuration. */
|
|
5
|
+
export class NetworkContext {
|
|
6
|
+
constructor(readonly reference: number, readonly registry: string) {
|
|
7
|
+
requireSafeInteger(reference, 0);
|
|
8
|
+
if (reference > 0xffff_ffff || registry.length !== 42 || !/^0x[0-9a-f]{40}$/.test(registry))
|
|
9
|
+
throw new ProtocolError('invalid_context', 'Expected a Neo uint32 network reference and lowercase registry hash.');
|
|
10
|
+
Object.freeze(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static parse(value: string): NetworkContext {
|
|
14
|
+
const match = /^neo:(0|[1-9][0-9]{0,9}):(0x[0-9a-f]{40})$/.exec(value);
|
|
15
|
+
if (!match || match[0] !== value) throw new ProtocolError('invalid_context', 'Expected a canonical network context.');
|
|
16
|
+
return new NetworkContext(Number(match[1]), match[2]!);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
toString(): string { return `neo:${this.reference}:${this.registry}`; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Binds a complete object to the trusted network, excluding only named root fields. */
|
|
23
|
+
export function signingInput(value: JsonObject, context: NetworkContext, excluded: readonly string[] = []): Uint8Array {
|
|
24
|
+
if (Object.hasOwn(value, '$context'))
|
|
25
|
+
throw new ProtocolError('invalid_context', 'A protocol object must not contain a root $context property.');
|
|
26
|
+
const result: JsonObject = Object.create(null) as JsonObject;
|
|
27
|
+
for (const [name, field] of Object.entries(value)) if (!excluded.includes(name)) result[name] = field;
|
|
28
|
+
result['$context'] = context.toString();
|
|
29
|
+
return canonicalBytes(result);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Advances a counter without overflow, approximation, or reuse of a previous value. */
|
|
33
|
+
export function nextRevision(known: number, requested?: number): number {
|
|
34
|
+
requireSafeInteger(known, -1);
|
|
35
|
+
const result = requested ?? known + 1;
|
|
36
|
+
requireSafeInteger(result, 0);
|
|
37
|
+
if (result <= known) throw new ProtocolError('revision_exhausted', 'Revision must exceed every known revision.');
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
4
|
+
|
|
5
|
+
/** Rejects unpaired UTF-16 surrogates without normalizing valid text. */
|
|
6
|
+
export function validateUnicode(value: string): void {
|
|
7
|
+
for (let index = 0; index < value.length; index++) {
|
|
8
|
+
const unit = value.charCodeAt(index);
|
|
9
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
10
|
+
const next = value.charCodeAt(++index);
|
|
11
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
12
|
+
throw new ProtocolError('invalid_unicode', 'Unpaired high surrogate.');
|
|
13
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
14
|
+
throw new ProtocolError('invalid_unicode', 'Unpaired low surrogate.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Encodes Unicode scalar values as UTF-8 without silently replacing invalid text. */
|
|
20
|
+
export function encodeUtf8(value: string): Uint8Array {
|
|
21
|
+
validateUnicode(value);
|
|
22
|
+
return new TextEncoder().encode(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Strict UTF-8 decoding, independent of platform TextDecoder error handling. */
|
|
26
|
+
export function decodeUtf8(bytes: Uint8Array): string {
|
|
27
|
+
const chunks: string[] = [];
|
|
28
|
+
let chunk = '';
|
|
29
|
+
for (let index = 0; index < bytes.length;) {
|
|
30
|
+
const first = bytes[index++]!;
|
|
31
|
+
let scalar: number;
|
|
32
|
+
let count: number;
|
|
33
|
+
let minimum: number;
|
|
34
|
+
if (first <= 0x7f) { scalar = first; count = 0; minimum = 0; }
|
|
35
|
+
else if (first >= 0xc2 && first <= 0xdf) { scalar = first & 31; count = 1; minimum = 0x80; }
|
|
36
|
+
else if (first >= 0xe0 && first <= 0xef) { scalar = first & 15; count = 2; minimum = 0x800; }
|
|
37
|
+
else if (first >= 0xf0 && first <= 0xf4) { scalar = first & 7; count = 3; minimum = 0x10000; }
|
|
38
|
+
else throw new ProtocolError('invalid_utf8', 'Invalid UTF-8 leading byte.');
|
|
39
|
+
for (let part = 0; part < count; part++) {
|
|
40
|
+
const continuation = bytes[index++];
|
|
41
|
+
if (continuation === undefined || (continuation & 0xc0) !== 0x80)
|
|
42
|
+
throw new ProtocolError('invalid_utf8', 'Invalid or incomplete UTF-8 sequence.');
|
|
43
|
+
scalar = (scalar << 6) | (continuation & 63);
|
|
44
|
+
}
|
|
45
|
+
if (scalar < minimum || scalar > 0x10ffff || (scalar >= 0xd800 && scalar <= 0xdfff))
|
|
46
|
+
throw new ProtocolError('invalid_utf8', 'Noncanonical UTF-8 scalar.');
|
|
47
|
+
chunk += String.fromCodePoint(scalar);
|
|
48
|
+
if (chunk.length >= 4096) { chunks.push(chunk); chunk = ''; }
|
|
49
|
+
}
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
return chunks.join('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Encodes bytes as canonical unpadded base64url. */
|
|
55
|
+
export function encodeBase64Url(bytes: Uint8Array): string {
|
|
56
|
+
const chunks: string[] = [];
|
|
57
|
+
let chunk = '';
|
|
58
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
59
|
+
const first = bytes[index]!;
|
|
60
|
+
const second = bytes[index + 1];
|
|
61
|
+
const third = bytes[index + 2];
|
|
62
|
+
chunk += alphabet[first >>> 2]! + alphabet[((first & 3) << 4) | ((second ?? 0) >>> 4)]!;
|
|
63
|
+
if (second !== undefined) chunk += alphabet[((second & 15) << 2) | ((third ?? 0) >>> 6)]!;
|
|
64
|
+
if (third !== undefined) chunk += alphabet[third & 63]!;
|
|
65
|
+
if (chunk.length >= 4096) { chunks.push(chunk); chunk = ''; }
|
|
66
|
+
}
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
return chunks.join('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Decodes only canonical unpadded base64url, including zero trailing pad bits. */
|
|
72
|
+
export function decodeBase64Url(value: string, expectedLength?: number): Uint8Array {
|
|
73
|
+
if (/[^A-Za-z0-9_-]/.test(value) || value.length % 4 === 1)
|
|
74
|
+
throw new ProtocolError('invalid_base64url', 'Expected canonical unpadded base64url.');
|
|
75
|
+
const tail = value.length === 0 ? 0 : alphabet.indexOf(value.at(-1)!);
|
|
76
|
+
if ((value.length % 4 === 2 && (tail & 15) !== 0) || (value.length % 4 === 3 && (tail & 3) !== 0))
|
|
77
|
+
throw new ProtocolError('invalid_base64url', 'Nonzero base64url padding bits.');
|
|
78
|
+
const result = new Uint8Array(Math.floor(value.length * 6 / 8));
|
|
79
|
+
if (expectedLength !== undefined && result.length !== expectedLength)
|
|
80
|
+
throw new ProtocolError('invalid_length', `Expected ${expectedLength} bytes.`);
|
|
81
|
+
let bits = 0;
|
|
82
|
+
let available = 0;
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (const character of value) {
|
|
85
|
+
bits = (bits << 6) | alphabet.indexOf(character);
|
|
86
|
+
available += 6;
|
|
87
|
+
if (available >= 8) { available -= 8; result[offset++] = (bits >>> available) & 255; }
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Concatenates byte arrays without retaining references to the inputs. */
|
|
93
|
+
export function concatBytes(...values: readonly Uint8Array[]): Uint8Array {
|
|
94
|
+
const result = new Uint8Array(values.reduce((size, value) => size + value.length, 0));
|
|
95
|
+
let offset = 0;
|
|
96
|
+
for (const value of values) { result.set(value, offset); offset += value.length; }
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Compares byte values without an early exit on unequal content. */
|
|
101
|
+
export function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
102
|
+
if (left.length !== right.length) return false;
|
|
103
|
+
let difference = 0;
|
|
104
|
+
for (let index = 0; index < left.length; index++) difference |= left[index]! ^ right[index]!;
|
|
105
|
+
return difference === 0;
|
|
106
|
+
}
|