Добавить линии связей и параметров пользователя

This commit is contained in:
AidarKC
2026-10-02 00:33:13 +03:00
parent 8d22602761
commit e101e5c5f4
33 changed files with 1293 additions and 568 deletions
+373 -256
View File
@@ -37,7 +37,7 @@ import {
saveEncryptedUserSecrets,
saveSessionMaterial,
} from './key-vault.js';
import { createAns104DataItem } from './ans104-data-item.js';
import { createAns104DataItem, parseAns104DataItem } from './ans104-data-item.js';
import { defaultServerWs } from '../deploy-config.js';
const BCH_SUFFIX = '001';
@@ -48,10 +48,12 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_USER_PROFILE = 4;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_HEADER = 0;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TECH_FORK = 2;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
@@ -553,18 +555,19 @@ function makeHeaderBodyBytes({ login, initialBlockchainKey32 }) {
);
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
function makeUserParamBodyBytes({ prevProfileBlockNumber, prevProfileBlockHashHex, profileSequence, 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 байта');
const prevHashBytes = hexToBytes(prevProfileBlockHashHex);
if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока профиля');
if (!Number.isInteger(Number(prevProfileBlockNumber)) || Number(prevProfileBlockNumber) < 0) throw new Error('Некорректный previous profile block number');
if (!Number.isInteger(Number(profileSequence)) || Number(profileSequence) < 1) throw new Error('Некорректный profile sequence');
if (prevHashBytes.length !== 32) throw new Error('prevProfileBlockHash должен быть 32 байта');
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
int32Bytes(prevProfileBlockNumber),
prevHashBytes,
int32Bytes(thisLineNumber),
int32Bytes(profileSequence),
int16Bytes(keyBytes.length),
keyBytes,
int16Bytes(valueBytes.length),
@@ -653,15 +656,17 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
}
function makeConnectionBodyBytes({
lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1,
prevConnectionBlockNumber, prevConnectionBlockHashHex, connectionSequence,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex,
}) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'connection');
if (!Number.isInteger(Number(prevConnectionBlockNumber)) || Number(prevConnectionBlockNumber) < 0) throw new Error('Некорректный previous connection block number');
if (!Number.isInteger(Number(connectionSequence)) || Number(connectionSequence) < 1) throw new Error('Некорректный connection sequence');
return concatBytes(
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int32Bytes(prevConnectionBlockNumber), hexToBytes(normalizeHex32(prevConnectionBlockHashHex)), int32Bytes(connectionSequence),
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
);
}
@@ -674,7 +679,7 @@ function targetLoginBytes(value) {
function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
if (!Number.isInteger(n) || n < 0 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
return n;
}
@@ -884,6 +889,51 @@ function resolveLatestLineStep(message) {
return null;
}
function bytesToHexLower(bytes) {
return Array.from(bytes || [], (value) => Number(value).toString(16).padStart(2, '0')).join('');
}
function parseStoredLineMeta(blockBytesB64, { fixedLine = false } = {}) {
const raw = base64ToBytes(String(blockBytesB64 || ''));
const frame = parseAns104DataItem(raw).data;
const bodyOffset = 56;
const minLength = fixedLine ? bodyOffset + 40 : bodyOffset + 44;
if (!(frame instanceof Uint8Array) || frame.length < minLength) throw new Error('Некорректный line-блок');
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
if (fixedLine) {
return {
lineCode: 0,
prevLineNumber: view.getInt32(bodyOffset, false),
prevLineHashHex: bytesToHexLower(frame.slice(bodyOffset + 4, bodyOffset + 36)),
lineSequence: view.getInt32(bodyOffset + 36, false),
};
}
return {
lineCode: view.getInt32(bodyOffset, false),
prevLineNumber: view.getInt32(bodyOffset + 4, false),
prevLineHashHex: bytesToHexLower(frame.slice(bodyOffset + 8, bodyOffset + 40)),
lineSequence: view.getInt32(bodyOffset + 40, false),
};
}
function typeIndexTagValue(msgType) {
switch (Number(msgType)) {
case MSG_TYPE_TECH: return 'tech';
case MSG_TYPE_CONNECTION: return 'conn';
case MSG_TYPE_USER_PROFILE: return 'prof';
default: return '';
}
}
function isLineConflictError(error) {
return new Set([
'BAD_PREV_LINE_NUMBER',
'BAD_PREV_LINE_HASH',
'BAD_LINE_SEQUENCE',
'BAD_LINE_STATE',
]).has(String(error?.code || '').toUpperCase());
}
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
const body = bodyBytes || new Uint8Array(0);
@@ -919,6 +969,8 @@ export class AuthService {
this.eventListeners = new Map();
this.wsEventUnsubscribers = new Map();
this.headerHashCache = new Map();
this.lineTailCache = new Map();
this.lineWriteQueues = new Map();
this.writeLocks = new Map();
this.passwordKeyBundleCache = new Map();
this.passwordKeyBundleInFlight = new Map();
@@ -951,6 +1003,8 @@ export class AuthService {
this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map();
this.lineTailCache = new Map();
this.lineWriteQueues = new Map();
this.writeLocks.clear();
this.bindRegisteredEventsToCurrentWs();
if (clearSessionContext) this.clearActiveSessionContext();
@@ -1142,10 +1196,8 @@ export class AuthService {
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 clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет client key для входа');
const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
@@ -1221,7 +1273,7 @@ export class AuthService {
}
}
async persistSelectedKeys(login, storagePwd, keyBundle) {
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
let currentSecrets = {};
try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
@@ -1232,23 +1284,12 @@ export class AuthService {
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
}
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,
clientKey: keyBundle.clientPair.privatePkcs8B64,
};
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш
// нельзя переносить в новую identity: он будет лениво выведен заново.
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
delete secrets.arweaveWallet;
}
delete secrets.rootKey;
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
@@ -1773,6 +1814,8 @@ export class AuthService {
}
const ansTags = [{ name: 'App', value: 'test5590' }];
const typeIndexTag = typeIndexTagValue(msgType);
if (typeIndexTag) ansTags.push({ name: 't', value: typeIndexTag });
const cleanChannelSlug = String(channelSlug || '').trim();
if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
@@ -1990,53 +2033,17 @@ export class AuthService {
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}`;
const key = `channel-line:${cleanLogin}:${owner}:${root}`;
return this.runWriteLocked(key, async () => {
return this.runLineWriteLocked(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 tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextRepostBodyBytes({
lineCode: root,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
@@ -2044,15 +2051,17 @@ export class AuthService {
text: cleanText,
});
return this.addBlockSigned({
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
msgVersion: 1,
bodyBytes,
channelSlug,
channelSlug: tail.channelSlug,
});
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return payload;
});
}
@@ -2067,82 +2076,57 @@ export class AuthService {
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({
if (isChannelPost) {
const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber);
if (!ownerBlockchainName || !Number.isInteger(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector for edit');
}
const lockKey = `channel-line:${cleanLogin}:${ownerBlockchainName}:${lineCode}`;
return this.runLineWriteLocked(lockKey, 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,
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,
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
msgVersion: 1,
bodyBytes,
channelSlug,
channelSlug: tail.channelSlug,
});
}
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return payload;
});
}
const lockKey = `edit-reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
return this.runWriteLocked(lockKey, async () => {
const bodyBytes = makeTextEditReplyBodyBytes({
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
@@ -2196,35 +2180,21 @@ export class AuthService {
throw new Error('Invalid target block number');
}
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
let targetHashHex = seedHash;
if (targetHashHex === ZERO64) {
targetHashHex = cleanTargetBlockNumber === 0
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
}
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,
});
return this.addBlockConnectionTarget({
login: cleanLogin,
storagePwd,
subType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
toLogin: cleanTargetLogin,
toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch),
toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex,
});
}
@@ -2270,6 +2240,123 @@ export class AuthService {
return headerHash;
}
async getMyBlockchainPage({ beforeBlock = null, limit = 100, includeBlockBytes = true } = {}) {
const response = await this.ws.request('GetMyBlockchain', { beforeBlock, limit, includeBlockBytes });
if (response.status !== 200) throw opError('GetMyBlockchain', response);
return response.payload || {};
}
async findLatestOwnLineBlock({
blockchainName,
msgType,
subTypes = null,
lineCode = null,
fixedLine = false,
stopAtBlock = 0,
}) {
const cleanBch = String(blockchainName || '').trim();
if (!cleanBch) throw new Error('Missing blockchainName for line lookup');
const subtypeSet = subTypes == null ? null : new Set(Array.from(subTypes, (value) => Number(value)));
const minBlock = Math.max(0, Number(stopAtBlock) || 0);
let beforeBlock = null;
for (;;) {
const page = await this.getMyBlockchainPage({ beforeBlock, limit: 100, includeBlockBytes: true });
const pageBch = String(page?.blockchainName || '').trim();
if (pageBch && pageBch !== cleanBch) throw new Error('GetMyBlockchain returned another blockchain');
const blocks = Array.isArray(page?.blocks) ? page.blocks : [];
for (const block of blocks) {
const blockNumber = Number(block?.blockNumber);
if (!Number.isInteger(blockNumber) || blockNumber <= minBlock) continue;
if (Number(block?.msgType) !== Number(msgType)) continue;
if (subtypeSet && !subtypeSet.has(Number(block?.msgSubType))) continue;
if (!block?.blockBytesB64) continue;
const meta = parseStoredLineMeta(block.blockBytesB64, { fixedLine });
if (lineCode != null && Number(meta.lineCode) !== Number(lineCode)) continue;
return {
blockNumber,
blockHash: normalizeHex32(block?.blockHash, ZERO64),
lineSequence: Number(meta.lineSequence),
lineCode: Number(meta.lineCode),
};
}
const next = page?.nextBeforeBlock;
if (next == null || !Number.isInteger(Number(next)) || Number(next) <= minBlock) return null;
beforeBlock = Number(next);
}
}
async resolveFixedLineTail({ blockchainName, kind }) {
const cleanBch = String(blockchainName || '').trim();
const kindKey = String(kind || '').trim().toLowerCase();
const config = kindKey === 'profile'
? { msgType: MSG_TYPE_USER_PROFILE, tag: 'prof' }
: kindKey === 'connections'
? { msgType: MSG_TYPE_CONNECTION, tag: 'conn' }
: kindKey === 'tech'
? { msgType: MSG_TYPE_TECH, tag: 'tech', subTypes: [MSG_SUBTYPE_TECH_CREATE_CHANNEL, MSG_SUBTYPE_TECH_FORK] }
: null;
if (!config) throw new Error(`Unknown fixed line kind: ${kind}`);
const cacheKey = `${cleanBch}|${kindKey}`;
const cached = this.lineTailCache.get(cacheKey);
if (cached) return { ...cached };
const latest = await this.findLatestOwnLineBlock({
blockchainName: cleanBch,
msgType: config.msgType,
subTypes: config.subTypes || null,
lineCode: kindKey === 'tech' ? 0 : null,
fixedLine: kindKey !== 'tech',
stopAtBlock: 0,
});
if (latest) {
const result = {
prevLineNumber: latest.blockNumber,
prevLineHashHex: latest.blockHash,
thisLineNumber: latest.lineSequence + 1,
};
this.lineTailCache.set(cacheKey, result);
return { ...result };
}
const headerHash = await this.resolveHeaderHashForBlockchain(cleanBch);
if (headerHash === ZERO64) throw new Error('HEADER блокчейна не найден');
const result = { prevLineNumber: 0, prevLineHashHex: headerHash, thisLineNumber: 1 };
this.lineTailCache.set(cacheKey, result);
return { ...result };
}
rememberLineTail(blockchainName, kind, payload, currentSequence) {
const cleanBch = String(blockchainName || '').trim();
const acceptedNumber = Number(payload?.serverLastGlobalNumber);
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
if (!cleanBch || !Number.isInteger(acceptedNumber) || acceptedNumber < 0 || acceptedHash === ZERO64) return;
this.lineTailCache.set(`${cleanBch}|${String(kind || '').trim().toLowerCase()}`, {
prevLineNumber: acceptedNumber,
prevLineHashHex: acceptedHash,
thisLineNumber: Number(currentSequence) + 1,
});
}
async runLineWriteLocked(lockKey, buildAndSend) {
const key = String(lockKey || '').trim() || 'line-write';
const previous = this.lineWriteQueues.get(key) || Promise.resolve();
const task = previous.catch(() => {}).then(async () => {
try {
return await buildAndSend();
} catch (error) {
if (!isLineConflictError(error)) throw error;
this.lineTailCache.clear();
return buildAndSend();
}
});
this.lineWriteQueues.set(key, task);
return task.finally(() => {
if (this.lineWriteQueues.get(key) === task) this.lineWriteQueues.delete(key);
});
}
async listOwnChannelsForBlockchain(login, blockchainName) {
const feed = await this.listSubscriptionsFeed(login, 500);
const own = feed?.ownedChannels || [];
@@ -2314,34 +2401,11 @@ export class AuthService {
: 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 key = `tech-line:${cleanLogin}`;
return this.runLineWriteLocked(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 tail = await this.resolveFixedLineTail({ blockchainName, kind: 'tech' });
const payload = await this.addBlockSigned({
login: cleanLogin,
@@ -2352,15 +2416,16 @@ export class AuthService {
channelSlug,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
this.rememberLineTail(blockchainName, 'tech', payload, tail.thisLineNumber);
const selector = {
ownerBlockchainName: blockchainName,
@@ -2381,7 +2446,7 @@ export class AuthService {
const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber);
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
if (!ownerBlockchainName || !Number.isInteger(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector');
}
if (ownerBlockchainName !== blockchainName) {
@@ -2398,52 +2463,70 @@ export class AuthService {
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
if (rootHashHex === ZERO64) throw new Error('Cannot resolve channel root hash');
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
try {
const latestPayload = await this.getChannelMessages({
const cacheKey = `${blockchainName}|channel:${lineCode}`;
const cached = this.lineTailCache.get(cacheKey);
if (cached) {
return {
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
lineCode,
rootHashHex,
channelSlug,
...cached,
};
}
const latest = await this.findLatestOwnLineBlock({
blockchainName,
msgType: MSG_TYPE_TEXT,
subTypes: [
MSG_SUBTYPE_TEXT_POST,
MSG_SUBTYPE_TEXT_EDIT_POST,
MSG_SUBTYPE_TEXT_REPOST,
MSG_SUBTYPE_TEXT_CHANNEL_META,
MSG_SUBTYPE_TEXT_ENTRYPOINT,
MSG_SUBTYPE_TEXT_EXERCISE,
MSG_SUBTYPE_TEXT_SERVICE,
MSG_SUBTYPE_TEXT_COURSE,
],
lineCode,
fixedLine: false,
stopAtBlock: lineCode,
});
const lineTail = latest
? {
prevLineNumber: latest.blockNumber,
prevLineHashHex: latest.blockHash,
thisLineNumber: latest.lineSequence + 1,
}
: {
prevLineNumber: lineCode,
prevLineHashHex: rootHashHex,
thisLineNumber: 0,
};
this.lineTailCache.set(cacheKey, lineTail);
return {
ownerBlockchainName,
lineCode,
rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelSlug,
...lineTail,
};
}
rememberChannelLineTail(blockchainName, lineCode, payload, currentSequence) {
const acceptedNumber = Number(payload?.serverLastGlobalNumber);
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
if (!Number.isInteger(acceptedNumber) || acceptedNumber < 0 || acceptedHash === ZERO64) return;
this.lineTailCache.set(`${String(blockchainName || '').trim()}|channel:${Number(lineCode)}`, {
prevLineNumber: acceptedNumber,
prevLineHashHex: acceptedHash,
thisLineNumber: Number(currentSequence) + 1,
});
}
async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -2462,9 +2545,9 @@ export class AuthService {
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
const key = `channel-line:${cleanLogin}:${owner}:${root}`;
return this.runWriteLocked(key, async () => {
return this.runLineWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
@@ -2487,6 +2570,7 @@ export class AuthService {
bodyBytes,
channelSlug: tail.channelSlug,
});
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return {
...payload,
@@ -2506,9 +2590,9 @@ export class AuthService {
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}`;
const key = `channel-line:${cleanLogin}:${owner}:${root}`;
return this.runWriteLocked(key, async () => {
return this.runLineWriteLocked(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 });
@@ -2529,6 +2613,7 @@ export class AuthService {
bodyBytes,
channelSlug: tail.channelSlug,
});
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return {
...payload,
@@ -3161,21 +3246,65 @@ export class AuthService {
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.runLineWriteLocked(`profile-line:${cleanLogin}`, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'profile' });
const bodyBytes = makeUserParamBodyBytes({
prevProfileBlockNumber: tail.prevLineNumber,
prevProfileBlockHashHex: tail.prevLineHashHex,
profileSequence: tail.thisLineNumber,
key: cleanParam,
value: cleanValue,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_USER_PROFILE,
msgSubType: 1,
msgVersion: 1,
bodyBytes,
});
this.rememberLineTail(blockchainName, 'profile', payload, tail.thisLineNumber);
return payload;
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 4,
msgSubType: 1,
msgVersion: 1,
bodyBytes,
}
async addBlockConnectionTarget({
login,
storagePwd,
subType,
toLogin,
toForkNumber,
toBlockNumber,
toBlockHashHex,
}) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login for CONNECTION');
return this.runLineWriteLocked(`connections-line:${cleanLogin}`, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'connections' });
const bodyBytes = makeConnectionBodyBytes({
prevConnectionBlockNumber: tail.prevLineNumber,
prevConnectionBlockHashHex: tail.prevLineHashHex,
connectionSequence: tail.thisLineNumber,
toLogin,
toForkNumber,
toBlockNumber,
toBlockHashHex,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_CONNECTION,
msgSubType: Number(subType),
msgVersion: 1,
bodyBytes,
});
this.rememberLineTail(blockchainName, 'connections', payload, tail.thisLineNumber);
return payload;
});
}
@@ -3190,9 +3319,6 @@ export class AuthService {
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();
@@ -3200,24 +3326,15 @@ export class AuthService {
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,
return this.addBlockConnectionTarget({
login: cleanLogin,
storagePwd,
subType: cleanSubType,
toLogin: cleanToLogin,
toForkNumber: targetForkNumber,
toBlockNumber: 0,
toBlockHashHex: targetHeaderHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 3,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
}