Files
SHiNE-server/shine-UI/js/services/auth-service.js
T

3263 lines
128 KiB
JavaScript

import { WsJsonClient } from './ws-client.js';
import {
base64ToBytes,
bytesToBase64,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
ed25519PublicToX25519Public,
ed25519SeedToX25519Private,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
extractEd25519SeedFromPkcs8B64,
hkdfSha256,
importPkcs8Ed25519,
publicKeyB64FromPkcs8Ed25519,
randomBase64,
randomBytes,
sha256Bytes,
signBytes,
signBase64,
utf8Bytes,
x25519PublicFromPrivate,
x25519RandomPrivateKey,
x25519SharedSecret,
encryptBytesAesGcm,
decryptBytesAesGcm,
} from './crypto-utils.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
toCanonicalChannelSlug,
validateChannelDisplayName,
} from './channel-name-rules.js';
import {
loadEncryptedUserSecrets,
loadSessionMaterial,
saveEncryptedUserSecrets,
saveSessionMaterial,
} from './key-vault.js';
import { createAns104DataItem } from './ans104-data-item.js';
import { defaultServerWs } from '../deploy-config.js';
const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64);
const ZERO_HASH_HEX = ZERO64;
const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_HEADER = 0;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_REPOST = 50;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
const MSG_SUBTYPE_STATUS_LEARNED = 20;
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
const MSG_SUBTYPE_STATUS_STARTED = 120;
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 1;
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
const SESSION_TYPE_CLIENT = 1;
const SESSION_TYPE_WALLET = 50;
const SESSION_TYPE_HOMESERVER = 100;
const SIGNAL_TARGET_SINGLE = 'single_session';
const SIGNAL_TARGET_ALL = 'all_sessions';
const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
const CONNECTION_SUBTYPES = Object.freeze({
close_friend: { on: 10, off: 11 },
friend: { on: 14, off: 15 },
contact: { on: 20, off: 21 },
// Reserved: пока не используется UI.
follow: { on: 30, off: 31 },
// Reserved: семейные связи пока скрыты из UI.
spouse: { on: 40, off: 41 },
parent: { on: 50, off: 51 },
child: { on: 52, off: 53 },
sibling: { on: 54, off: 55 },
// Legacy 60/61 intentionally absent: старые блоки сервер принимает, новый UI их не создаёт.
shine_confirmed: { on: 70, off: 71 },
// Reserved: пока не используется UI.
shine_seen: { on: 74, off: 75 },
official_account: { on: 80, off: 81 },
});
function normalizeServerUrl(url) {
const value = (url || '').trim();
if (!value) return defaultServerWs;
if (value.startsWith('ws://') || value.startsWith('wss://')) {
try {
const parsed = new URL(value);
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return value;
}
}
if (value.startsWith('https://') || value.startsWith('http://')) {
try {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
}
}
return value;
}
function opError(op, response) {
const payload = response?.payload || {};
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
error.status = response?.status || 0;
return error;
}
function createSignalRequestId(prefix = 'signal') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function makeClientInfo() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
}
function makeClientPlatform() {
return 'Web';
}
function escapeUserSettingPart(value = '') {
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
}
function hexToBytes(hex) {
const clean = String(hex || '').trim().toLowerCase();
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i += 1) {
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(byte)) throw new Error('Некорректный hex');
out[i] = byte;
}
return out;
}
function normalizeHex32(value, fallback = ZERO64) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return fallback;
if (/^0+$/.test(raw)) return ZERO64;
if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
return raw;
}
async function sha256Base64FromText(text) {
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
return bytesToBase64(digest).replace(/=+$/g, '');
}
function concatBytes(...chunks) {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
let offset = 0;
chunks.forEach((chunk) => {
out.set(chunk, offset);
offset += chunk.length;
});
return out;
}
function int32Bytes(value) {
const bytes = new Uint8Array(4);
const view = new DataView(bytes.buffer);
view.setInt32(0, Number(value), false);
return bytes;
}
function int16Bytes(value) {
const bytes = new Uint8Array(2);
const view = new DataView(bytes.buffer);
view.setUint16(0, Number(value), false);
return bytes;
}
function int8Byte(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
return new Uint8Array([n & 0xff]);
}
function int64Bytes(value) {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
view.setBigInt64(0, BigInt(value), false);
return bytes;
}
function uint16Bytes(value) {
const bytes = new Uint8Array(2);
const view = new DataView(bytes.buffer);
view.setUint16(0, Number(value), false);
return bytes;
}
function uint32Bytes(value) {
const bytes = new Uint8Array(4);
const view = new DataView(bytes.buffer);
view.setUint32(0, Number(value), false);
return bytes;
}
function uint64Bytes(value) {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
view.setBigUint64(0, BigInt(value), false);
return bytes;
}
function uint8Bytes(value) {
return new Uint8Array([Number(value) & 0xff]);
}
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
const NTF_PREFIX_V1 = utf8Bytes('SHiNE_NTF');
const NTF_FORMAT_VERSION_MAJOR = 1;
const NTF_FORMAT_VERSION_MINOR = 0;
const NTF_STATE_SEEN_WATERMARK = 1;
const NTF_CATEGORY = { replies: 1, connections: 2, events: 3 };
const DM_TYPE_INCOMING = 1;
const DM_TYPE_OUTGOING_COPY = 2;
const DM_TYPE_READ_INCOMING = 3;
const DM_TYPE_READ_OUTGOING_COPY = 4;
const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5;
const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
const DM_FORMAT_VERSION_MAJOR = 1;
const DM_FORMAT_VERSION_MINOR = 0;
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
const DM_CRYPTO_VERSION_1_0 = 0;
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
const DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT = 'unsupported_format';
const DM_DECRYPT_ERROR_DECRYPT_FAILED = 'decrypt_failed';
const DM_DECRYPT_ERROR_CORRUPTED = 'corrupted';
function ensureAsciiBytes(value, field, min = 1, max = 60) {
const text = String(value || '').trim();
const bytes = utf8Bytes(text);
if (bytes.length < min || bytes.length > max) {
throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`);
}
for (let i = 0; i < bytes.length; i += 1) {
const code = bytes[i];
if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`);
}
return bytes;
}
function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) {
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
}
function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
}
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) {
const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
return concatBytes(
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(refTimeMs),
uint32Bytes(refNonce),
);
}
function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) {
return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 });
}
async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) {
const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim());
if (recipientEd25519Pub.length !== 32) {
throw new Error('Некорректный clientKey получателя');
}
const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub);
const ephemeralPriv = x25519RandomPrivateKey();
const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv);
const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub);
const salt = concatBytes(ephemeralPub, recipientX25519Pub);
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
const iv = randomBytes(12);
const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv);
return concatBytes(
uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM),
uint8Bytes(DM_CRYPTO_VERSION_1_0),
uint8Bytes(ephemeralPub.length),
ephemeralPub,
uint8Bytes(iv.length),
iv,
uint32Bytes(cipherText.length),
cipherText,
);
}
function parseEncryptedDmBodyBytes(payloadBytes) {
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY');
let o = 0;
const read = (n) => {
if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
const out = payloadBytes.slice(o, o + n);
o += n;
return out;
};
const readU8 = () => read(1)[0];
const cryptoMethod = readU8();
const cryptoVersion = readU8();
const ephemeralPubKeyLen = readU8();
const ephemeralPubKey = read(ephemeralPubKeyLen);
const ivLen = readU8();
const iv = read(ivLen);
const cipherTextLenBytes = read(4);
const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false);
const cipherText = read(cipherTextLen);
if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
return {
cryptoMethod,
cryptoVersion,
ephemeralPubKey,
iv,
cipherText,
};
}
function classifyDmDecryptFailure(error) {
const code = String(error?.message || '').trim();
if (code === 'BAD_ENCRYPTED_BODY') return DM_DECRYPT_ERROR_CORRUPTED;
if (code === 'Неподдерживаемый метод шифрования DM') return DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT;
if (code === 'OperationError') return DM_DECRYPT_ERROR_DECRYPT_FAILED;
return DM_DECRYPT_ERROR_DECRYPT_FAILED;
}
function parseReadReceiptBodyBytes(payloadBytes) {
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
let o = 0;
const read = (n) => {
if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
const out = payloadBytes.slice(o, o + n);
o += n;
return out;
};
const readU8 = () => read(1)[0];
const readU32 = () => {
const part = read(4);
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
};
const readU64 = () => {
const part = read(8);
return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false));
};
const readAscii = (code) => {
const len = readU8();
const part = read(len);
for (let i = 0; i < part.length; i += 1) {
const c = part[i];
if (c < 0x20 || c > 0x7e) throw new Error(code);
}
return new TextDecoder().decode(part);
};
const refToLogin = readAscii('BAD_RECEIPT_TO_LOGIN');
const refFromLogin = readAscii('BAD_RECEIPT_FROM_LOGIN');
const refTimeMs = readU64();
const refNonce = readU32();
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
return { refToLogin, refFromLogin, refTimeMs, refNonce };
}
function parseSignedMessageBlockBytes(bytes) {
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
let o = 0;
const read = (n) => {
if (o + n > bytes.length) throw new Error('BAD_LEN');
const out = bytes.slice(o, o + n);
o += n;
return out;
};
const readU8 = () => read(1)[0];
const readU32 = () => {
const part = read(4);
const view = new DataView(part.buffer, part.byteOffset, 4);
return view.getUint32(0, false);
};
const readU64 = () => {
const part = read(8);
const view = new DataView(part.buffer, part.byteOffset, 8);
return Number(view.getBigUint64(0, false));
};
const readAscii = () => {
const len = readU8();
const part = read(len);
const text = new TextDecoder().decode(part);
for (let i = 0; i < part.length; i += 1) {
const c = part[i];
if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII');
}
return text;
};
const startsWith = (prefix) => {
if (bytes.length < prefix.length) return false;
for (let i = 0; i < prefix.length; i += 1) {
if (bytes[i] !== prefix[i]) return false;
}
return true;
};
if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX');
read(DM_PREFIX_V1.length);
const formatVersionMajor = readU8();
const formatVersionMinor = readU8();
if (formatVersionMajor !== DM_FORMAT_VERSION_MAJOR || formatVersionMinor !== DM_FORMAT_VERSION_MINOR) {
throw new Error('BAD_FORMAT_VERSION');
}
const toLogin = readAscii();
const fromLogin = readAscii();
const timeMs = readU64();
const nonce = readU32();
const messageType = readU8();
if (messageType < DM_TYPE_INCOMING || messageType > DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
throw new Error('BAD_MESSAGE_TYPE');
}
const revisionTimeMs = readU64();
const reencryptedAtMs = readU64();
const bodyLen = readU32();
const bodyBytes = read(bodyLen);
const signatureBytes = read(64);
if (o !== bytes.length) throw new Error('BAD_LEN');
const signedBody = bytes.slice(0, bytes.length - 64);
const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
let encryptedBodyPayload = null;
let readReceiptPayload = null;
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_MESSAGE_LEN');
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION');
readReceiptPayload = parseReadReceiptBodyBytes(bodyBytes);
} else if (messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT) {
if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY');
if (revisionTimeMs <= 0) throw new Error('BAD_REVISION_TIME');
if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME');
} else if (messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY');
if (revisionTimeMs !== 0) throw new Error('BAD_REVISION_TIME');
if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME');
}
return {
toLogin,
fromLogin,
timeMs,
nonce,
messageType,
revisionTimeMs,
reencryptedAtMs,
formatVersionMajor,
formatVersionMinor,
encryptedBodyBytes: bodyBytes,
encryptedBodyPayload,
text: '',
bodyAttachments: [],
payloadBytes: bodyBytes,
signatureBytes,
signedBody,
rawBytes: bytes,
baseKey,
messageKey,
legacyFormat: false,
deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER,
isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT,
isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT,
readReceiptPayload,
};
}
function extractContactsFromDialogs(dialogs) {
const seen = new Set();
const out = [];
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
const login = String(dialog?.peerLogin || '').trim();
if (!login) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function makeHeaderBodyBytes({ login, initialBlockchainKey32 }) {
const cleanLogin = String(login || '').trim();
const loginBytes = utf8Bytes(cleanLogin);
const keyBytes = initialBlockchainKey32 instanceof Uint8Array
? initialBlockchainKey32
: new Uint8Array(initialBlockchainKey32 || []);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('HEADER login must be 1..255 UTF-8 bytes');
if (keyBytes.length !== 32 || keyBytes.every((value) => value === 0)) {
throw new Error('HEADER initialBlockchainKey32 must be a non-zero 32-byte key');
}
return concatBytes(
utf8Bytes('SHiNE'),
int8Byte(loginBytes.length),
loginBytes,
keyBytes,
);
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
const prevHashBytes = hexToBytes(prevLineHashHex);
if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра');
if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
prevHashBytes,
int32Bytes(thisLineNumber),
int16Bytes(keyBytes.length),
keyBytes,
int16Bytes(valueBytes.length),
valueBytes,
);
}
function makeReactionLikeBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex }) {
const cleanLogin = String(toLogin || '').trim();
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'like');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'like');
const loginBytes = targetLoginBytes(cleanLogin);
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'like');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes);
}
function makeTextReplyBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'reply');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'reply');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'reply');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Reply text must be 1..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeTextRatingBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'rating');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'rating');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'rating');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Rating text must be 1..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeStatusActionBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text = '' }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'status action');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'status action');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'status action');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Status action text must be 0..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeTextRepostBodyBytes({
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text,
}) {
const message = String(text || '').trim();
if (!message) throw new Error('Комментарий к репосту обязателен');
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'repost');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'repost');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'repost');
const textBytes = utf8Bytes(message);
if (textBytes.length > 65535) throw new Error('Repost comment too long');
return concatBytes(
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
int16Bytes(textBytes.length), textBytes,
);
}
function makeTextEditPostBodyBytes({
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
toBlockNumber, toBlockHashHex, text,
}) {
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit post');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit post');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
return concatBytes(
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes,
);
}
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit reply');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit reply');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
return concatBytes(int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeConnectionBodyBytes({
lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex,
}) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'connection');
return concatBytes(
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
);
}
function targetLoginBytes(value) {
const bytes = utf8Bytes(String(value || '').trim());
if (bytes.length < 1 || bytes.length > 255) throw new Error('toLogin must be 1..255 UTF-8 bytes');
return bytes;
}
function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
return n;
}
function normalizeTargetBlockNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid toBlockNumber for ${actionName}`);
return n;
}
function nonZeroHash32Bytes(value, actionName = 'target') {
const hex = normalizeHex32(value, ZERO64);
if (hex === ZERO64) throw new Error(`Zero target hash is forbidden for ${actionName}`);
return hexToBytes(hex);
}
function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const bytes = utf8Bytes(text);
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
return text;
}
function validateChannelMetaTitle(value) {
const text = String(value || '').trim();
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
if (/[<>;\t\n\r\u0000]/u.test(text)) {
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
}
return text;
}
function normalizeChannelMetaDescription(value) {
const text = String(value == null ? '' : value).trim();
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
return text;
}
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
const cleanTitle = validateChannelMetaTitle(title);
const cleanDescription = normalizeChannelMetaDescription(description);
const rows = [];
if (cleanTitle) rows.push(`<S:title;v=1;${cleanTitle}>`);
if (avatar?.ar) {
const size = Number(avatar.size || 0);
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
const ar = String(avatar.ar || '').trim();
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
rows.push(`<S:ava;v=1;sz=${size};sha256=${sha256};ar=${ar}>`);
}
if (cleanDescription) rows.push(cleanDescription);
return rows.join('\n');
}
function validatePersonalChannelName(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
const length = Array.from(normalized).length;
if (length < 1 || length > 20) {
return { ok: false, error: 'Логин: 1-20 символов.' };
}
if (!/^[A-Za-z0-9_]+$/.test(normalized)) {
return { ok: false, error: 'Логин: разрешены только латиница, цифры и _.' };
}
return { ok: true, normalized };
}
function makeCreateChannelBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName,
channelDescription = '',
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
}) {
const typeCode = Number(channelType);
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
? validatePersonalChannelName(channelName)
: validateChannelDisplayName(channelName);
if (!nameCheck.ok) {
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
}
const cleanName = nameCheck.normalized;
const cleanDescription = normalizeChannelDescription(channelDescription);
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
throw new Error('Channel name must be 1..255 bytes');
}
const descriptionBytes = utf8Bytes(cleanDescription);
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
throw new Error('Некорректный тип канала.');
}
if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
throw new Error('Некорректная версия типа канала.');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(nameBytes.length),
nameBytes,
int16Bytes(descriptionBytes.length),
descriptionBytes,
uint16Bytes(typeCode),
uint16Bytes(typeVer),
);
}
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Message text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int16Bytes(textBytes.length),
textBytes
);
}
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
const textBytes = utf8Bytes(message);
if (textBytes.length > 65535) {
throw new Error('Message text must be 0..65535 UTF-8 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int16Bytes(textBytes.length),
textBytes
);
}
function loginFromBlockchainNameValue(value) {
const name = String(value || '').trim();
const match = name.match(/^(.*)-(\d{3})$/u);
return match?.[1] ? match[1] : '';
}
function forkNumberFromBlockchainNameValue(value) {
const name = String(value || '').trim();
const match = name.match(/^(.*)-(\d{3})$/u);
if (!match) return 0;
const forkNumber = Number(match[2]);
return Number.isInteger(forkNumber) && forkNumber >= 1 && forkNumber <= 999 ? forkNumber : 0;
}
function normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || target?.authorBlockchainName || '').trim();
const cleanLogin = String(target?.login || target?.authorLogin || target?.ownerLogin || loginFromBlockchainNameValue(cleanBch)).trim();
const cleanBlockNumber = Number(target?.blockNumber ?? target?.messageRef?.blockNumber);
const cleanBlockHash = String(target?.blockHash ?? target?.messageRef?.blockHash ?? '').trim().toLowerCase();
if (!cleanLogin) {
throw new Error(`Missing message target login for ${actionName}`);
}
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
throw new Error(`Invalid message target block number for ${actionName}`);
}
if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
throw new Error(`Invalid message target hash for ${actionName}`);
}
const forkNumber = Number(target?.forkNumber ?? target?.toForkNumber ?? forkNumberFromBlockchainNameValue(cleanBch));
if (!Number.isInteger(forkNumber) || forkNumber < 1 || forkNumber > 999) {
throw new Error(`Missing/invalid message target fork for ${actionName}`);
}
return {
login: cleanLogin,
blockchainName: cleanBch,
forkNumber,
blockNumber: cleanBlockNumber,
blockHash: cleanBlockHash,
};
}
function resolveLatestLineStep(message) {
const lineStep = Number(message?.lineStep);
if (Number.isFinite(lineStep) && lineStep >= 0) return lineStep;
const fallbackByVersions = Number(message?.versionsTotal);
if (Number.isFinite(fallbackByVersions) && fallbackByVersions > 0) return Math.max(0, fallbackByVersions - 1);
return null;
}
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
const body = bodyBytes || new Uint8Array(0);
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
return concatBytes(
int16Bytes(1),
prevHashBytes,
int32Bytes(blockSize),
int32Bytes(blockNumber),
int64Bytes(Math.floor(Date.now() / 1000)),
int16Bytes(msgType),
int16Bytes(msgSubType),
int16Bytes(msgVersion),
body
);
}
function buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const body = bodyBytes || new Uint8Array(0);
return concatBytes(
int16Bytes(msgType),
int16Bytes(msgSubType),
int16Bytes(msgVersion),
body,
);
}
export class AuthService {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
this.eventListeners = new Map();
this.wsEventUnsubscribers = new Map();
this.headerHashCache = new Map();
this.writeLocks = new Map();
this.passwordKeyBundleCache = new Map();
this.passwordKeyBundleInFlight = new Map();
this.currentLogin = '';
this.currentSessionId = '';
this.remoteAddBlockSessionId = '';
}
bindRegisteredEventsToCurrentWs() {
this.wsEventUnsubscribers.forEach((unsubscribe) => {
try { unsubscribe?.(); } catch {}
});
this.wsEventUnsubscribers.clear();
this.eventListeners.forEach((_handlers, op) => {
const unsubscribe = this.ws.onEvent(op, (data) => {
const handlers = this.eventListeners.get(op);
if (!handlers) return;
handlers.forEach((handler) => {
try { handler(data); } catch {}
});
});
this.wsEventUnsubscribers.set(op, unsubscribe);
});
}
resetConnection(serverUrl = this.serverUrl, { clearSessionContext = true } = {}) {
const normalized = normalizeServerUrl(serverUrl);
try { this.ws?.close(); } catch {}
this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map();
this.writeLocks.clear();
this.bindRegisteredEventsToCurrentWs();
if (clearSessionContext) this.clearActiveSessionContext();
}
async reconnect(serverUrl) {
const normalized = normalizeServerUrl(serverUrl);
if (normalized === this.serverUrl) return;
this.resetConnection(normalized, { clearSessionContext: false });
}
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
this.currentLogin = String(login || '').trim();
this.currentSessionId = String(sessionId || '').trim();
}
clearActiveSessionContext() {
this.currentLogin = '';
this.currentSessionId = '';
}
setRemoteAddBlockSessionId(sessionId = '') {
this.remoteAddBlockSessionId = String(sessionId || '').trim();
}
runWriteLocked(lockKey, runAction) {
const key = String(lockKey || '').trim() || 'write';
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
const task = (async () => runAction())().finally(() => {
this.writeLocks.delete(key);
});
this.writeLocks.set(key, task);
return task;
}
async getUser(login) {
const response = await this.ws.request('GetUser', { login });
if (response.status !== 200) throw opError('GetUser', response);
return response.payload || {};
}
async resolveLoginForAuth(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const response = await this.ws.request('ResolveLoginForAuth', { login: cleanLogin });
if (response.status !== 200) throw opError('ResolveLoginForAuth', response);
return response.payload || {};
}
async resolveCanonicalDisplayLogin(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return '';
try {
const user = await this.getUser(cleanLogin);
const canonicalLogin = String(user?.login || '').trim();
return canonicalLogin || cleanLogin;
} catch {
return cleanLogin;
}
}
async ensureLoginFree(login) {
const payload = await this.getUser(login);
return payload.exists !== true;
}
async derivePasswordKeyBundle(login, password, options = {}) {
const normalizedLogin = String(login ?? '');
const normalizedPassword = String(password ?? '');
const cacheKey = `${normalizedLogin}\n${normalizedPassword}`;
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null;
if (this.passwordKeyBundleCache.has(cacheKey)) {
if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' });
return this.passwordKeyBundleCache.get(cacheKey);
}
if (this.passwordKeyBundleInFlight.has(cacheKey)) {
return this.passwordKeyBundleInFlight.get(cacheKey);
}
const task = (async () => {
if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' });
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, {
login: normalizedLogin,
onProgress: (value01) => {
if (!onProgress) return;
const v = Math.max(0, Math.min(1, Number(value01) || 0));
const percent = Math.round(5 + (v * 88));
onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' });
},
});
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 97, stage: 'derive', message: 'Вычисление blockchain key...' });
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'blockchain.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 99, stage: 'derive', message: 'Вычисление client key...' });
const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key');
const result = {
masterSecretB64: bytesToBase64(masterSecret),
rootPair,
blockchainPair,
clientPair,
};
this.passwordKeyBundleCache.set(cacheKey, result);
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
return result;
})().finally(() => {
this.passwordKeyBundleInFlight.delete(cacheKey);
});
this.passwordKeyBundleInFlight.set(cacheKey, task);
return task;
}
async createAuthSession(login, keyBundle) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const clientPair = keyBundle?.clientPair;
if (!clientPair) throw new Error('createAuthSession: не передан clientPair');
const sessionPair = await generateEd25519Pair();
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
const sessionKey = `ed25519/${sessionKeyPub}`;
const storagePwd = randomBase64(32);
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
const authNonce = challengeResp?.payload?.authNonce;
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(clientPair.privateKey, preimage);
const createResp = await this.ws.request('CreateAuthSession', {
login: cleanLogin,
storagePwd,
sessionKey,
timeMs,
authNonce,
clientKey: clientPair.publicKeyB64,
signatureB64,
sessionType: SESSION_TYPE_CLIENT,
clientPlatform: makeClientPlatform(),
clientInfo: makeClientInfo(),
});
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const connectionScope = String(createResp?.payload?.connectionScope || '').trim().toUpperCase();
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: canonicalLogin,
sessionId,
storagePwd,
connectionScope,
sessionMaterial: {
sessionId,
sessionKey,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
},
};
}
async createSessionForExistingUser(login, password) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
const session = await this.createAuthSession(cleanLogin, keyBundle);
return { ...session, keyBundle };
}
async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина');
const clientKey = String(secrets?.clientKey || '').trim();
const blockchainKey = String(secrets?.blockchainKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа');
if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна');
const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
const session = await this.createAuthSession(cleanLogin, {
clientPair: {
privateKey,
publicKeyB64,
},
});
return session;
}
async createDelegatedSessionWithClientKey({
login,
clientPrivPkcs8,
sessionKey,
sessionType = SESSION_TYPE_WALLET,
clientPlatform = 'Delegated session',
clientInfo = 'Delegated session via pairing',
}) {
const cleanLogin = String(login || '').trim();
const cleanSessionKey = String(sessionKey || '').trim();
const cleanClientPriv = String(clientPrivPkcs8 || '').trim();
if (!cleanLogin) throw new Error('createDelegatedSessionWithClientKey: пустой login');
if (!cleanSessionKey) throw new Error('createDelegatedSessionWithClientKey: пустой sessionKey');
if (!cleanClientPriv) throw new Error('createDelegatedSessionWithClientKey: пустой client private key');
const clientPrivateKey = await importPkcs8Ed25519(cleanClientPriv);
const clientPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanClientPriv);
const storagePwd = randomBase64(32);
const tempAuth = new AuthService(this.serverUrl);
try {
const challengeResp = await tempAuth.ws.request('AuthChallenge', { login: cleanLogin });
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
const authNonce = challengeResp?.payload?.authNonce;
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(clientPrivateKey, preimage);
const createResp = await tempAuth.ws.request('CreateAuthSession', {
login: cleanLogin,
storagePwd,
sessionKey: cleanSessionKey,
timeMs,
authNonce,
clientKey: clientPublicKeyB64,
signatureB64,
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
clientInfo: String(clientInfo || '').trim() || 'Delegated session via pairing',
});
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: canonicalLogin,
sessionId,
storagePwd,
sessionKey: cleanSessionKey,
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
};
} finally {
tempAuth.ws.close();
}
}
async persistSelectedKeys(login, storagePwd, keyBundle) {
let currentSecrets = {};
try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
if (loaded && typeof loaded === 'object') {
currentSecrets = loaded;
}
} catch {
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
}
const clientKey = String(keyBundle?.clientPair?.privatePkcs8B64 || '').trim();
const blockchainKey = String(keyBundle?.blockchainPair?.privatePkcs8B64 || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('Для входа нужно сохранить client key и blockchain key');
}
const secrets = {
...currentSecrets,
clientKey,
blockchainKey,
};
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш
// нельзя переносить в новую identity: он будет лениво выведен заново.
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
delete secrets.arweaveWallet;
}
delete secrets.rootKey;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
async persistSessionMaterial(login, sessionMaterial) {
await saveSessionMaterial(login, sessionMaterial);
}
async resumeSession(login, preferredSessionId = '') {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Нет login для авто-входа');
const sessionMaterial = await loadSessionMaterial(cleanLogin);
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
throw new Error('На устройстве нет сохраненного ключа сессии');
}
const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
const nonce = challengeResp?.payload?.nonce;
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
const timeMs = Date.now();
const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
const signatureB64 = await signBase64(privateKey, preimage);
const loginResp = await this.ws.request('SessionLogin', {
sessionId: targetSessionId,
sessionKey: sessionMaterial.sessionKey,
timeMs,
signatureB64,
sessionType: SESSION_TYPE_CLIENT,
clientPlatform: makeClientPlatform(),
clientInfo: makeClientInfo(),
});
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
const storagePwd = loginResp?.payload?.storagePwd;
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
const connectionScope = String(loginResp?.payload?.connectionScope || '').trim().toUpperCase();
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: canonicalLogin,
sessionId: targetSessionId,
storagePwd,
connectionScope,
};
}
async listSessions() {
const response = await this.ws.request('ListSessions', {});
if (response.status !== 200) throw opError('ListSessions', response);
return response?.payload?.sessions || [];
}
async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) {
const cleanSignalType = String(signalType || '').trim();
const cleanSignalRequestId = String(signalRequestId || '').trim();
if (!cleanSignalType || !cleanSignalRequestId) {
throw new Error('waitForSignal: не переданы signalType/signalRequestId');
}
return new Promise((resolve, reject) => {
let unsubscribe = () => {};
const timer = window.setTimeout(() => {
unsubscribe();
reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`));
}, timeoutMs);
unsubscribe = this.onEvent('IncomingSignal', (evt) => {
const payload = evt?.payload || {};
if (String(payload?.signalType || '').trim() !== cleanSignalType) return;
if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return;
window.clearTimeout(timer);
unsubscribe();
resolve(payload);
});
});
}
async sendSignal({
toLogin,
targetMode = SIGNAL_TARGET_SINGLE,
targetSessionId = '',
signalType,
signalRequestId,
data = '',
storagePwd = '',
includeClientSignature = true,
timeMs = Date.now(),
} = {}) {
const cleanToLogin = String(toLogin || '').trim();
const cleanTargetMode = String(targetMode || '').trim();
const cleanTargetSessionId = String(targetSessionId || '').trim();
const cleanSignalType = String(signalType || '').trim();
const cleanSignalRequestId = String(signalRequestId || '').trim();
const cleanLogin = String(this.currentLogin || '').trim();
const cleanSessionId = String(this.currentSessionId || '').trim();
if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId');
if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) {
throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId');
}
if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) {
throw new Error('SendSignal: bad targetMode');
}
if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) {
throw new Error('SendSignal: targetSessionId обязателен для single_session');
}
const sessionMaterial = await loadSessionMaterial(cleanLogin);
if (!sessionMaterial?.sessionPrivPkcs8) {
throw new Error('На устройстве нет сохранённого session key для SendSignal');
}
const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
const dataText = typeof data === 'string' ? data : JSON.stringify(data || {});
const dataSha256B64 = await sha256Base64FromText(dataText);
const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage);
let clientSignatureB64 = '';
if (includeClientSignature) {
if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key');
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivatePkcs8) {
throw new Error('На устройстве нет сохранённого client key для SendSignal');
}
const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8);
const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage);
}
const response = await this.ws.request('SendSignal', {
toLogin: cleanToLogin,
targetMode: cleanTargetMode,
targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '',
signalType: cleanSignalType,
signalRequestId: cleanSignalRequestId,
data: dataText,
timeMs: Number(timeMs),
sessionSignatureB64,
clientSignatureB64,
});
if (response.status !== 200) throw opError('SendSignal', response);
return response.payload || {};
}
async closeSession(sessionId) {
const response = await this.ws.request('CloseActiveSession', { sessionId });
if (response.status !== 200) throw opError('CloseActiveSession', response);
}
async getTrustedDeviceLoginSettings() {
const response = await this.ws.request('GetTrustedDeviceLoginSettings', {});
if (response.status !== 200) throw opError('GetTrustedDeviceLoginSettings', response);
return response.payload || {};
}
async upsertTrustedDeviceLoginSettings({ enabled, passwordHash = '' }) {
const response = await this.ws.request('UpsertTrustedDeviceLoginSettings', {
enabled: !!enabled,
passwordHash: String(passwordHash || '').trim(),
});
if (response.status !== 200) throw opError('UpsertTrustedDeviceLoginSettings', response);
return response.payload || {};
}
async startTrustedDeviceLogin({
login,
passwordHash,
requesterSessionKey,
requesterSessionType = SESSION_TYPE_CLIENT,
requesterClientPlatform = makeClientPlatform(),
payloadType = 3,
}) {
const response = await this.ws.request('StartTrustedDeviceLogin', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
payloadType: Number(payloadType) || 3,
});
if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
return response.payload || {};
}
async listTrustedDeviceLoginRequests() {
const response = await this.ws.request('ListTrustedDeviceLoginRequests', {});
if (response.status !== 200) throw opError('ListTrustedDeviceLoginRequests', response);
return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
}
async approveTrustedDeviceLogin(pairingId, encryptedPayload) {
const response = await this.ws.request('ApproveTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(),
encryptedPayload: String(encryptedPayload || '').trim(),
});
if (response.status !== 200) throw opError('ApproveTrustedDeviceLogin', response);
return response.payload || {};
}
async rejectTrustedDeviceLogin(pairingId, reason = '') {
const response = await this.ws.request('RejectTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(),
reason: String(reason || '').trim(),
});
if (response.status !== 200) throw opError('RejectTrustedDeviceLogin', response);
return response.payload || {};
}
async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
});
if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
return response.payload || {};
}
async getTrustedDeviceLoginStatus(pairingId) {
const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
pairingId: String(pairingId || '').trim(),
});
if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
return response.payload || {};
}
async upsertEspPairingSettings(args) { return this.upsertTrustedDeviceLoginSettings(args); }
async startEspPairing(args) { return this.startTrustedDeviceLogin(args); }
async listEspPairingRequests() { return this.listTrustedDeviceLoginRequests(); }
async approveEspPairing(pairingId, encryptedPayload) { return this.approveTrustedDeviceLogin(pairingId, encryptedPayload); }
async rejectEspPairing(pairingId, reason = '') { return this.rejectTrustedDeviceLogin(pairingId, reason); }
async cancelEspPairing(pairingId, requesterSessionKey) { return this.cancelTrustedDeviceLogin(pairingId, requesterSessionKey); }
async getEspPairingStatus(pairingId) { return this.getTrustedDeviceLoginStatus(pairingId); }
async listSubscriptionsFeed(login, limit = 200) {
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
return response.payload || {};
}
async getChannelMessages(channel, limit = null, sort = 'asc', login = '') {
const normalizedChannel = {
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
};
const payload = { channel: normalizedChannel, sort };
const cleanLimit = Number(limit);
if (Number.isFinite(cleanLimit) && cleanLimit > 0) payload.limit = Math.trunc(cleanLimit);
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload);
if (response.status !== 200) throw opError('GetChannelMessages', response);
return response.payload || {};
}
async getPersonalDiary(login, limit = 200, sort = 'asc') {
const payload = { login: String(login || '').trim(), limit, sort };
const response = await this.ws.request('GetPersonalDiary', payload);
if (response.status !== 200) throw opError('GetPersonalDiary', response);
return response.payload || {};
}
async getMessageLikes(message) {
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(),
};
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage });
if (response.status !== 200) throw opError('GetMessageLikes', response);
return response.payload || {};
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(),
};
const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload);
if (response.status !== 200) throw opError('GetMessageThread', response);
return response.payload || {};
}
async resolveFreshBlockchainCursor(login) {
const cleanLogin = String(login || '').trim();
const user = await this.getUser(cleanLogin);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const freshNum = Number(user?.serverLastGlobalNumber);
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
return {
blockchainName,
cursor: {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash,
},
};
}
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage, ansTags }) {
const cleanLogin = String(login || '').trim();
const cleanBlockchainName = String(blockchainName || '').trim();
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName');
if (!(preimage instanceof Uint8Array) || preimage.length === 0) {
throw new Error('submitPreparedAddBlock: bad preimage');
}
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
if (blockchainPrivatePkcs8) {
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
const owner32 = base64ToBytes(await publicKeyB64FromPkcs8Ed25519(blockchainPrivatePkcs8));
const dataItemBytes = await createAns104DataItem({
owner32,
privateKey,
data: preimage,
tags: Array.isArray(ansTags) && ansTags.length ? ansTags : [{ name: 'App', value: 'test5590' }],
});
return this.ws.request('AddBlock', {
blockchainName: cleanBlockchainName,
blockNumber: Number(blockNumber),
prevBlockHash: cleanPrevBlockHash,
blockBytesB64: bytesToBase64(dataItemBytes),
});
}
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
if (!remoteSessionId) {
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
}
const signalRequestId = createSignalRequestId('remote-addblock');
const responseWait = this.waitForSignal({
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
signalRequestId,
timeoutMs: 20000,
});
const signalData = {
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
signalRequestId,
login: cleanLogin,
blockchainName: cleanBlockchainName,
blockNumber: Number(blockNumber),
prevBlockHash: cleanPrevBlockHash,
blockPreimageB64: bytesToBase64(preimage),
};
await this.sendSignal({
toLogin: cleanLogin,
targetMode: SIGNAL_TARGET_SINGLE,
targetSessionId: remoteSessionId,
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
signalRequestId,
data: JSON.stringify(signalData),
storagePwd,
includeClientSignature: true,
});
const signalPayload = await responseWait;
let result = {};
try {
result = JSON.parse(String(signalPayload?.data || '{}'));
} catch {
throw new Error('Некорректный ответ remote AddBlock от homeserver');
}
if (!result?.ok) {
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
}
return {
status: 200,
payload: {
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber),
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
remote: true,
},
};
}
async submitRemoteAddBlockBody({ login, storagePwd, blockchainName, blockBodyBytes }) {
const cleanLogin = String(login || '').trim();
const cleanBlockchainName = String(blockchainName || '').trim();
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitRemoteAddBlockBody: missing login/blockchainName');
if (!(blockBodyBytes instanceof Uint8Array) || blockBodyBytes.length < 6) {
throw new Error('submitRemoteAddBlockBody: bad blockBodyBytes');
}
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
if (!remoteSessionId) {
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
}
const signalRequestId = createSignalRequestId('remote-addblock');
const responseWait = this.waitForSignal({
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
signalRequestId,
timeoutMs: 20000,
});
const signalData = {
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
signalRequestId,
blockchainName: cleanBlockchainName,
blockBodyB64: bytesToBase64(blockBodyBytes),
};
await this.sendSignal({
toLogin: cleanLogin,
targetMode: SIGNAL_TARGET_SINGLE,
targetSessionId: remoteSessionId,
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
signalRequestId,
data: JSON.stringify(signalData),
storagePwd,
includeClientSignature: true,
});
const signalPayload = await responseWait;
let result = {};
try {
result = JSON.parse(String(signalPayload?.data || '{}'));
} catch {
throw new Error('Некорректный ответ remote AddBlock от homeserver');
}
if (!result?.ok) {
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
}
return {
status: 200,
payload: {
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? -1),
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
remote: true,
},
};
}
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage, ansTags }) {
let freshState = await resolveFreshState();
let blockchainName = String(freshState?.blockchainName || '').trim();
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
return this.submitPreparedAddBlock({
login,
storagePwd,
blockchainName,
blockNumber,
prevBlockHash,
preimage,
ansTags,
});
};
let cursor = freshState.cursor;
let response = await tryAdd(cursor);
if (response.status !== 200) {
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
response = await tryAdd(cursor);
} else {
freshState = await resolveFreshState();
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
cursor = freshState.cursor;
response = await tryAdd(cursor);
}
}
if (response.status !== 200) throw opError('AddBlock', response);
return {
response,
blockchainName,
};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '', _skipHeaderEnsure = false }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
const isHeader = Number(msgType) === MSG_TYPE_TECH && Number(msgSubType) === MSG_SUBTYPE_TECH_HEADER;
if (!_skipHeaderEnsure && !isHeader) {
await this.ensureBlockchainHeader(cleanLogin, storagePwd);
}
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
if (!blockchainPrivatePkcs8) {
const user = await this.getUser(cleanLogin);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
if (!blockchainName) throw new Error('Не удалось определить blockchainName для remote AddBlock');
const response = await this.submitRemoteAddBlockBody({
login: cleanLogin,
storagePwd,
blockchainName,
blockBodyBytes: buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion, bodyBytes }),
});
return response.payload || {};
}
const ansTags = [{ name: 'App', value: 'test5590' }];
const cleanChannelSlug = String(channelSlug || '').trim();
if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
const { response, blockchainName } = await this.runAddBlockWithRetry({
login: cleanLogin,
storagePwd,
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
ansTags,
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
prevBlockHashHex: prevBlockHash,
blockNumber,
msgType,
msgSubType,
msgVersion,
bodyBytes,
}),
});
const payload = response.payload || {};
const acceptedNum = Number(payload?.serverLastGlobalNumber);
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
this.headerHashCache.set(blockchainName, acceptedHash);
}
return payload;
}
async ensureBlockchainHeader(login, storagePwd) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login for HEADER bootstrap');
const current = await this.getUser(cleanLogin);
const lastNum = Number(current?.serverLastGlobalNumber);
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const privatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
let publicKeyB64 = '';
if (privatePkcs8) {
publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8);
} else {
publicKeyB64 = String(current?.blockchainKey || '').trim();
}
const initialBlockchainKey32 = base64ToBytes(publicKeyB64);
if (initialBlockchainKey32.length !== 32 || initialBlockchainKey32.every((value) => value === 0)) {
throw new Error('Не удалось определить initial blockchain key для HEADER');
}
await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_HEADER,
msgVersion: 1,
bodyBytes: makeHeaderBodyBytes({ login: cleanLogin, initialBlockchainKey32 }),
_skipHeaderEnsure: true,
});
return this.getUser(cleanLogin);
}
async ensureChainInitializedForLineOps(login, storagePwd) {
return this.ensureBlockchainHeader(login, storagePwd);
}
async addBlockLike({ login, message, storagePwd }) {
const cleanLogin = String(login || '').trim();
const target = normalizeMessageRefTarget(message, 'like');
const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_REACTION,
msgSubType: MSG_SUBTYPE_REACTION_LIKE,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockUnlike({ login, message, storagePwd }) {
const cleanLogin = String(login || '').trim();
const target = normalizeMessageRefTarget(message, 'unlike');
const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_REACTION,
msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockReply({ login, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'reply');
const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextReplyBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_REPLY,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockRating({ login, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'rating');
const key = `rating:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextRatingBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_RATING,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockStatusAction({ login, message, text = '', statusSubType, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const cleanSubType = Number(statusSubType);
const target = normalizeMessageRefTarget(message, 'status action');
const allowedStatusSubTypes = new Set([
MSG_SUBTYPE_STATUS_DONE_ONCE,
MSG_SUBTYPE_STATUS_LEARNED,
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
MSG_SUBTYPE_STATUS_CONFIRMED,
MSG_SUBTYPE_STATUS_INTERESTED,
MSG_SUBTYPE_STATUS_STARTED,
MSG_SUBTYPE_STATUS_IN_STUDY,
MSG_SUBTYPE_STATUS_ABANDONED,
MSG_SUBTYPE_STATUS_COMPLETED,
]);
if (!allowedStatusSubTypes.has(cleanSubType)) {
throw new Error('Unsupported status action subtype');
}
const key = `status-action:${cleanLogin}:${cleanSubType}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeStatusActionBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_STATUS_ACTION,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockRepost({ login, channel, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
if (!cleanText) throw new Error('Комментарий к репосту обязателен');
const target = normalizeMessageRefTarget(message, 'repost');
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
if (!rootChannel) throw new Error('Channel root not found');
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
let prevLineNumber = root;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName: owner,
channelRootBlockNumber: root,
channelRootBlockHash: rootHashHex,
}, 1, 'desc', cleanLogin);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
const latestLineStep = resolveLatestLineStep(latestMessage);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
thisLineNumber = Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: 1;
}
} catch {
// fallback to root anchor
}
const bodyBytes = makeTextRepostBodyBytes({
lineCode: root,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
msgVersion: 1,
bodyBytes,
channelSlug,
});
});
}
async addBlockEditMessage({
login,
message,
text,
storagePwd,
isChannelPost = false,
channel = null,
}) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'edit');
const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;
return this.runWriteLocked(lockKey, async () => {
if (isChannelPost) {
const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber);
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector for edit');
}
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (rootChannel) {
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
}
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 1;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
}, 1, 'desc', cleanLogin);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
const latestVersion = latestVersions[latestVersions.length - 1] || null;
const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
const latestLineStep = resolveLatestLineStep(latestMessage);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
thisLineNumber = Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep)
: 1;
}
} catch {
// fallback to root anchor
}
const bodyBytes = makeTextEditPostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
toLogin: target.login,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
msgVersion: 1,
bodyBytes,
channelSlug,
});
}
const bodyBytes = makeTextEditReplyBodyBytes({
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
if (!cleanTargetLogin) throw new Error('Target login is required');
const cleanLogin = String(login || '').trim();
const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
const targetUser = await this.getUser(cleanTargetLogin);
if (!targetUser?.exists) throw new Error('Target user not found');
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);
return this.addBlockFollowChannel({
login: cleanLogin,
storagePwd,
targetLogin: cleanTargetLogin,
targetBlockchainName: targetUser.blockchainName,
targetBlockNumber: 0,
targetBlockHashHex: targetHeaderHash,
unfollow,
});
});
}
async addBlockFollowChannel({
login,
storagePwd,
targetLogin = '',
targetBlockchainName,
targetBlockNumber,
targetBlockHashHex,
unfollow = false,
}) {
const cleanLogin = String(login || '').trim();
const cleanTargetBch = String(targetBlockchainName || '').trim();
const cleanTargetLogin = String(targetLogin || loginFromBlockchainNameValue(cleanTargetBch)).trim();
const cleanTargetBlockNumber = Number(targetBlockNumber);
if (!cleanTargetBch) throw new Error('Target blockchain is required');
if (!cleanTargetLogin) throw new Error('Target login is required');
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
throw new Error('Invalid target block number');
}
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
return this.runWriteLocked(key, async () => {
let targetHashHex = seedHash;
if (targetHashHex === ZERO64) {
targetHashHex = cleanTargetBlockNumber === 0
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
}
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO64,
thisLineNumber: -1,
toLogin: cleanTargetLogin,
toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch),
toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_CONNECTION,
msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
msgVersion: 1,
bodyBytes,
});
});
}
async getBlockHashByNumber(blockchainName, blockNumber) {
const cleanBlockNumber = Number(blockNumber);
try {
const payload = await this.getMessageThread(
{
blockchainName: String(blockchainName || '').trim(),
blockNumber: cleanBlockNumber,
blockHash: ZERO64,
},
0,
0,
1
);
const hash = payload?.focus?.messageRef?.blockHash;
return normalizeHex32(hash, ZERO64);
} catch (error) {
if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
return ZERO64;
}
throw error;
}
}
async resolveHeaderHashForBlockchain(blockchainName) {
const cleanBch = String(blockchainName || '').trim();
if (!cleanBch) throw new Error('Missing blockchainName');
if (this.headerHashCache.has(cleanBch)) {
const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
if (cached !== ZERO64) return cached;
this.headerHashCache.delete(cleanBch);
}
const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
if (headerHash !== ZERO64) {
this.headerHashCache.set(cleanBch, headerHash);
} else {
this.headerHashCache.delete(cleanBch);
}
return headerHash;
}
async listOwnChannelsForBlockchain(login, blockchainName) {
const feed = await this.listSubscriptionsFeed(login, 500);
const own = feed?.ownedChannels || [];
return own
.filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
.map((item) => ({
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
channelName: String(item?.channel?.channelName || ''),
channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
}))
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
}
async addBlockCreateChannel({
login,
channelName,
channelDescription = '',
channelProfileTitle = '',
channelAvatar = null,
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd,
}) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const typeCode = Number(channelType);
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
? validatePersonalChannelName(channelName)
: validateChannelDisplayName(channelName);
if (!nameCheck.ok) {
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
}
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
const cleanChannelDescription = typeCode === CHANNEL_TYPE_PUBLIC
? composeChannelMetaText({
title: channelProfileTitle,
description: channelDescription,
avatar: channelAvatar,
})
: normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const createdChannels = ownChannels
.filter((item) => item.rootBlockNumber > 0)
.sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);
let prevLineNumber = 0;
let prevLineHashHex = (
Number.isFinite(userLastGlobalNumber) &&
userLastGlobalNumber === 0 &&
userLastGlobalHash !== ZERO64
)
? userLastGlobalHash
: await this.resolveHeaderHashForBlockchain(blockchainName);
let thisLineNumber = 1;
if (createdChannels.length > 0) {
const last = createdChannels[createdChannels.length - 1];
prevLineNumber = last.rootBlockNumber;
prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
thisLineNumber = createdChannels.length + 1;
}
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
channelSlug,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
const selector = {
ownerBlockchainName: blockchainName,
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
};
return {
...payload,
channel: {
...selector,
},
};
});
}
async resolveChannelLineTail({ login, blockchainName, channel }) {
const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber);
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector');
}
if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels');
}
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
}, 1, 'desc', login);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
.slice()
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
? latestMeta
: latestMessage;
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
const latestLineStep = resolveLatestLineStep(latest);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
thisLineNumber = latest?.kind === 'created'
? 0
: Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: (latestBlockNumber === lineCode ? 0 : 1);
}
} catch {
// fallback to root anchor
}
return {
ownerBlockchainName,
lineCode,
rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelSlug,
};
}
async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
const cleanSubType = Number(msgSubType || MSG_SUBTYPE_TEXT_POST);
const allowedTextSubTypes = new Set([
MSG_SUBTYPE_TEXT_POST,
MSG_SUBTYPE_TEXT_ENTRYPOINT,
MSG_SUBTYPE_TEXT_EXERCISE,
MSG_SUBTYPE_TEXT_SERVICE,
MSG_SUBTYPE_TEXT_COURSE,
]);
if (!allowedTextSubTypes.has(cleanSubType)) {
throw new Error('Unsupported channel text subtype');
}
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextPostBodyBytes({
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: cleanText,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
channelSlug: tail.channelSlug,
});
return {
...payload,
channel: {
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: tail.rootHashHex,
},
};
});
}
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const metaText = composeChannelMetaText({ title, description, avatar });
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: metaText,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
msgVersion: 1,
bodyBytes,
channelSlug: tail.channelSlug,
});
return {
...payload,
channel: {
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: tail.rootHashHex,
},
};
});
}
onEvent(op, handler) {
if (!op || typeof handler !== 'function') return () => {};
if (!this.eventListeners.has(op)) {
this.eventListeners.set(op, new Set());
const unsubscribe = this.ws.onEvent(op, (data) => {
const handlers = this.eventListeners.get(op);
if (!handlers) return;
handlers.forEach((callback) => {
try { callback(data); } catch {}
});
});
this.wsEventUnsubscribers.set(op, unsubscribe);
}
const handlers = this.eventListeners.get(op);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (handlers.size) return;
this.eventListeners.delete(op);
const unsubscribe = this.wsEventUnsubscribers.get(op);
try { unsubscribe?.(); } catch {}
this.wsEventUnsubscribers.delete(op);
};
}
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
if (response.status !== 200) throw opError('UpsertPushToken', response);
return response.payload || {};
}
async sendTestWebPush({ login = '', sessionId = '', title = '', text = '' } = {}) {
const payload = {};
if (String(login || '').trim()) payload.login = String(login || '').trim();
if (String(sessionId || '').trim()) payload.sessionId = String(sessionId || '').trim();
if (String(title || '').trim()) payload.title = String(title || '').trim();
if (String(text || '').trim()) payload.text = String(text || '').trim();
const response = await this.ws.request('SendTestWebPush', payload);
if (response.status !== 200) throw opError('SendTestWebPush', response);
return response.payload || {};
}
normalizeDmLogin(login) {
return String(login || '').trim().toLowerCase();
}
async buildSignedDmBlock({
signerLogin,
fromLogin,
toLogin,
storagePwd,
timeMs,
nonce,
messageType,
revisionTimeMs = 0,
reencryptedAtMs = 0,
bodyBytes = new Uint8Array(0),
}) {
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
const cleanToLogin = this.normalizeDmLogin(toLogin);
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
}
const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd);
const clientPriv = secrets?.clientKey || secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
const preimage = concatBytes(
DM_PREFIX_V1,
uint8Bytes(DM_FORMAT_VERSION_MAJOR),
uint8Bytes(DM_FORMAT_VERSION_MINOR),
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(timeMs),
uint32Bytes(nonce),
uint8Bytes(messageType),
uint64Bytes(revisionTimeMs),
uint64Bytes(reencryptedAtMs),
uint32Bytes(bodyBytes.length),
bodyBytes,
);
const signature = await signBytes(privateKey, preimage);
return concatBytes(preimage, signature);
}
parseSignedMessageBlob(blobB64) {
const bytes = base64ToBytes(String(blobB64 || '').trim());
return parseSignedMessageBlockBytes(bytes);
}
parseReadReceiptPayload(payloadBytes) {
return parseReadReceiptBodyBytes(payloadBytes);
}
classifyDmDecryptFailure(error) {
return classifyDmDecryptFailure(error);
}
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
const messageType = Number(parsedBlock?.messageType || 0);
if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) {
return { text: '', plainBytes: new Uint8Array(0) };
}
if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки');
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Не передан login для расшифровки');
const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes);
if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) {
throw new Error('Неподдерживаемый метод шифрования DM');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8);
const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed);
const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64));
const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey);
const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub);
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv);
return {
text: new TextDecoder().decode(plainBytes),
plainBytes,
};
}
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
const body = { incomingBlobB64, outgoingBlobB64 };
const response = await this.ws.request('SendMessagePair', body);
if (response.status !== 200) throw opError('SendMessagePair', response);
return response.payload || {};
}
async sendDirectMessageRevision({
login,
toLogin,
text = '',
storagePwd,
timeMs,
nonce,
revisionTimeMs = 0,
reencryptedAtMs = 0,
}) {
const cleanFromLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanText = String(text || '');
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs');
const plainBytes = utf8Bytes(cleanText);
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) {
throw new Error('Не найден clientKey получателя');
}
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const senderPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя');
const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8);
const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
plainBytes,
recipientClientKeyB64: String(targetUser.clientKey || '').trim(),
});
const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
plainBytes,
recipientClientKeyB64: senderPublicKeyB64,
});
const incomingBlock = await this.buildSignedDmBlock({
signerLogin: cleanFromLogin,
fromLogin: cleanFromLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: DM_TYPE_INCOMING,
revisionTimeMs: normalizedRevisionTimeMs,
reencryptedAtMs: normalizedReencryptedAtMs,
bodyBytes: incomingEncryptedBodyBytes,
});
const outgoingBlock = await this.buildSignedDmBlock({
signerLogin: cleanFromLogin,
fromLogin: cleanFromLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: DM_TYPE_OUTGOING_COPY,
revisionTimeMs: normalizedRevisionTimeMs,
reencryptedAtMs: normalizedReencryptedAtMs,
bodyBytes: outgoingEncryptedBodyBytes,
});
const payload = await this.sendMessagePair({
incomingBlobB64: bytesToBase64(incomingBlock),
outgoingBlobB64: bytesToBase64(outgoingBlock),
});
return {
...payload,
localIncomingBlobB64: bytesToBase64(incomingBlock),
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
};
}
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
const cleanText = String(text || '');
if (!cleanText) throw new Error('Пустое сообщение');
return this.sendDirectMessageRevision({
login,
toLogin,
text: cleanText,
storagePwd,
timeMs: Date.now(),
nonce: Math.floor(Math.random() * 0x100000000),
revisionTimeMs: 0,
});
}
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs');
const block = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER,
revisionTimeMs: normalizedRevisionTimeMs,
reencryptedAtMs: 0,
bodyBytes: new Uint8Array(0),
});
const blobB64 = bytesToBase64(block);
const response = await this.ws.request('DeleteMessage', { blobB64 });
if (response.status !== 200) throw opError('DeleteMessage', response);
return {
...(response.payload || {}),
localBlobB64: blobB64,
};
}
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const payload = buildReadReceiptPayloadBytes({
refToLogin: cleanRefToLogin,
refFromLogin: cleanRefFromLogin,
refTimeMs,
refNonce,
});
const type3 = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
messageType: DM_TYPE_READ_INCOMING,
bodyBytes: payload,
});
const type4 = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
messageType: DM_TYPE_READ_OUTGOING_COPY,
bodyBytes: payload,
});
return this.sendMessagePair({
incomingBlobB64: bytesToBase64(type3),
outgoingBlobB64: bytesToBase64(type4),
});
}
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
const block = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER,
revisionTimeMs: 0,
reencryptedAtMs: 0,
bodyBytes: new Uint8Array(0),
});
const blobB64 = bytesToBase64(block);
const response = await this.ws.request('DeleteConversation', { blobB64 });
if (response.status !== 200) throw opError('DeleteConversation', response);
return {
...(response.payload || {}),
localBlobB64: blobB64,
};
}
async getDirectMessages({ peerLogin, limit = 50, beforeTimeMs = 0, beforeMessageKey = '' } = {}) {
const payload = {
peerLogin: String(peerLogin || '').trim(),
limit: Number(limit || 0),
};
if (!payload.peerLogin) throw new Error('Не передан peerLogin');
if (Number.isFinite(Number(beforeTimeMs)) && Number(beforeTimeMs) > 0) {
payload.beforeTimeMs = Math.trunc(Number(beforeTimeMs));
}
if (String(beforeMessageKey || '').trim()) {
payload.beforeMessageKey = String(beforeMessageKey || '').trim();
}
const response = await this.ws.request('GetDirectMessages', payload);
if (response.status !== 200) throw opError('GetDirectMessages', response);
return response.payload || {};
}
async ackSessionDelivery(messageKey) {
const response = await this.ws.request('AckSessionDelivery', { messageKey });
if (response.status !== 200) throw opError('AckSessionDelivery', response);
return response.payload || {};
}
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
return response.payload || {};
}
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data });
if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {};
}
async getCallIceConfig() {
const response = await this.ws.request('GetCallIceConfig', {});
if (response.status !== 200) throw opError('GetCallIceConfig', response);
return response.payload || {};
}
async sendCallDeliveryReport(payload = {}) {
const response = await this.ws.request('CallDeliveryReport', payload);
if (response.status !== 200) throw opError('CallDeliveryReport', response);
return response.payload || {};
}
async listContacts() {
const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response);
const payload = response.payload || {};
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
payload.contacts = extractContactsFromDialogs(payload.dialogs);
}
return payload;
}
async addCloseFriend(toLogin) {
const response = await this.ws.request('AddCloseFriend', { toLogin });
if (response.status !== 200) throw opError('AddCloseFriend', response);
return response.payload || {};
}
async getUserCounters() {
const response = await this.ws.request('GetUserCounters', {});
if (response.status !== 200) throw opError('GetUserCounters', response);
return response.payload || {};
}
async getNotifications(countsOnly = false) {
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
if (response.status !== 200) throw opError('GetNotifications', response);
return response.payload || {};
}
async setNotificationSeen({ login, category, seenAtMs, storagePwd }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanCategory = String(category || '').trim().toLowerCase();
const categoryCode = NTF_CATEGORY[cleanCategory];
if (!cleanLogin || !categoryCode) throw new Error('Некорректный login/category уведомлений');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи состояния уведомлений');
const normalizedSeenAtMs = Math.max(0, Math.trunc(Number(seenAtMs || 0)));
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPriv = secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
const loginBytes = ensureAsciiBytes(cleanLogin, 'login');
const preimage = concatBytes(
NTF_PREFIX_V1,
uint8Bytes(NTF_FORMAT_VERSION_MAJOR), uint8Bytes(NTF_FORMAT_VERSION_MINOR),
uint8Bytes(loginBytes.length), loginBytes,
uint64Bytes(timeMs), uint32Bytes(nonce),
uint8Bytes(NTF_STATE_SEEN_WATERMARK), uint8Bytes(categoryCode),
uint64Bytes(normalizedSeenAtMs),
);
const signature = await signBytes(privateKey, preimage);
const response = await this.ws.request('SetNotificationState', { blobB64: bytesToBase64(concatBytes(preimage, signature)) });
if (response.status !== 200) throw opError('SetNotificationState', response);
return response.payload || {};
}
async getUserConnectionsGraph(login) {
const response = await this.ws.request('GetUserConnectionsGraph', { login });
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
return response.payload || {};
}
async listUserProfileRelations(login, listType, limit = 100, offset = 0) {
const response = await this.ws.request('ListUserProfileRelations', { login, listType, limit, offset });
if (response.status !== 200) throw opError('ListUserProfileRelations', response);
return response.payload || {};
}
async listUserProfileChannels(login, mode, limit = 100, offset = 0) {
const response = await this.ws.request('ListUserProfileChannels', { login, mode, limit, offset });
if (response.status !== 200) throw opError('ListUserProfileChannels', response);
return response.payload || {};
}
async searchUsers(prefix, options = {}) {
const payload = { prefix };
if (typeof options?.isServer === 'boolean') {
payload.isServer = options.isServer;
}
const response = await this.ws.request('SearchUsers', payload);
if (response.status !== 200) throw opError('SearchUsers', response);
return response.payload?.logins || [];
}
async getUserParam(login, param) {
const cleanLogin = (login || '').trim();
const cleanParam = (param || '').trim();
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param');
const response = await this.ws.request('GetUserParam', { login: cleanLogin, param: cleanParam });
if (response.status === 200) return response.payload || {};
if (response.status === 404 || response.status === 204) return {};
throw opError('GetUserParam', response);
}
async listUserParams(login) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Не передан login');
const response = await this.ws.request('ListUserParams', { login: cleanLogin });
if (response.status !== 200) throw opError('ListUserParams', response);
return response.payload || {};
}
async listUserSettings(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Не передан login');
const response = await this.ws.request('ListUserSettings', { login: cleanLogin });
if (response.status !== 200) throw opError('ListUserSettings', response);
return response.payload || {};
}
async setChannelReadState({
login,
ownerBlockchainName,
channelName,
readCount,
timeMs,
storagePwd,
}) {
const cleanLogin = String(login || '').trim();
const cleanOwnerBch = String(ownerBlockchainName || '').trim();
const cleanChannelName = String(channelName || '').trim();
const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0)));
const cleanTimeMs = Math.trunc(Number(timeMs));
if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) {
throw new Error('Не переданы login/ownerBlockchainName/channelName');
}
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) {
throw new Error('Не передан корректный timeMs');
}
if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.');
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const preimage = 'SHiNe/ChannelReadState:' + [
escapeUserSettingPart(cleanLogin),
escapeUserSettingPart(cleanOwnerBch),
escapeUserSettingPart(cleanChannelName),
String(cleanTimeMs),
String(cleanReadCount),
].join('|');
const signature = await signBase64(privateKey, preimage);
const response = await this.ws.request('SetChannelReadState', {
login: cleanLogin,
owner_bch_name: cleanOwnerBch,
channel_name: cleanChannelName,
read_count: cleanReadCount,
time_ms: cleanTimeMs,
client_key: clientKey,
signature,
});
if (response.status !== 200) throw opError('SetChannelReadState', response);
return response.payload || {};
}
async upsertUserSetting({
login,
settingType,
settingKey,
timeMs,
valueText = '',
valueNum = 0,
storagePwd,
}) {
const cleanLogin = String(login || '').trim();
const cleanSettingKey = String(settingKey || '').trim();
const cleanValueText = String(valueText ?? '');
const cleanTimeMs = Number(timeMs);
const cleanSettingType = Number(settingType);
const cleanValueNum = Number(valueNum ?? 0);
if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey');
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs');
if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.');
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const preimage = 'SHiNe/UserSettings:' + [
escapeUserSettingPart(cleanLogin),
String(cleanSettingType),
escapeUserSettingPart(cleanSettingKey),
String(Math.trunc(cleanTimeMs)),
escapeUserSettingPart(cleanValueText),
String(Math.trunc(cleanValueNum)),
].join('|');
const signature = await signBase64(privateKey, preimage);
const response = await this.ws.request('UpsertUserSetting', {
login: cleanLogin,
setting_type: Math.trunc(cleanSettingType),
setting_key: cleanSettingKey,
time_ms: Math.trunc(cleanTimeMs),
value_text: cleanValueText,
value_num: Math.trunc(cleanValueNum),
client_key: clientKey,
signature,
});
if (response.status !== 200) throw opError('UpsertUserSetting', response);
return response.payload || {};
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind];
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
const subType = enabled ? kinds.on : kinds.off;
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
}
async addBlockUserParam({ login, param, value, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanParam = (param || '').trim();
const cleanValue = String(value ?? '').trim();
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
const bodyBytes = makeUserParamBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
key: cleanParam,
value: cleanValue,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 4,
msgSubType: 1,
msgVersion: 1,
bodyBytes,
});
}
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanToLogin = (toLogin || '').trim();
const cleanSubType = Number(subType);
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
throw new Error('Нельзя создать связь на самого себя.');
}
const user = await this.getUser(cleanLogin);
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const targetBlockchainName = String(targetUser.blockchainName || '').trim();
const targetForkNumber = forkNumberFromBlockchainNameValue(targetBlockchainName);
if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.');
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName);
if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.');
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
toLogin: cleanToLogin,
toForkNumber: targetForkNumber,
toBlockNumber: 0,
toBlockHashHex: targetHeaderHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 3,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
}
async reportClientDebug({ runId = '', level = 'info', message = '', details = '' } = {}) {
try {
const response = await this.ws.request('ClientDebugLog', { runId, level, message, details }, 3000);
return response?.status === 200;
} catch {
return false;
}
}
async reportClientError(details) {
try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
return response?.status === 200;
} catch {
return false;
}
}
async reportClientUiError(details = {}) {
try {
const payload = {
source: 'ui_error',
code: 'UI_RUNTIME_ERROR',
...details,
};
const response = await this.sendCallDeliveryReport({
type: 'ui_error',
value: JSON.stringify(payload),
});
return !!response;
} catch {
return false;
}
}
close() {
this.ws.close();
}
}