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

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,
});
}
+51 -15
View File
@@ -37,12 +37,30 @@ function readFrameTimestampMs(frame) {
const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false);
return Number(seconds * 1000n);
}
function makeForkBody({ oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) {
function readFrameType(frame) {
if (!(frame instanceof Uint8Array) || frame.length < 56) throw new Error('Некорректный SHiNE Frame');
const view=new DataView(frame.buffer,frame.byteOffset,frame.byteLength);
return { type:view.getUint16(50,false), subType:view.getUint16(52,false) };
}
function readExplicitLinePrefix(frame) {
if (!(frame instanceof Uint8Array) || frame.length < 100) throw new Error('Некорректный line Frame');
const view=new DataView(frame.buffer,frame.byteOffset,frame.byteLength);
return {
lineCode:view.getInt32(56,false),
prevLineNumber:view.getInt32(60,false),
prevLineHash:bytesToHex(frame.slice(64,96)),
lineSequence:view.getInt32(96,false),
};
}
function makeForkBody({ lineCode=0, prevLineNumber, prevLineHash, lineSequence, oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) {
const oldKey=base64ToBytes(oldBlockchainKeyB64); if(oldKey.length!==32) throw new Error('Некорректный старый blockchain key');
const c=TE.encode(normalizeComment(comment)); if(c.length>1024) throw new Error('Комментарий больше 1024 UTF-8 байт');
const discarded=Number(parentTipBlock)-Number(forkPointBlock);
if(discarded<0) throw new Error('Некорректная точка fork');
return concat(oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c);
if(Number(lineCode)!==0 || !Number.isInteger(Number(prevLineNumber)) || Number(prevLineNumber)<0 || !Number.isInteger(Number(lineSequence)) || Number(lineSequence)<1) {
throw new Error('Некорректная TECH line для FORK');
}
return concat(be32(lineCode),be32(prevLineNumber),hexToBytes(prevLineHash),be32(lineSequence),oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c);
}
function makeFrame({ prevHash, blockNumber, type, subType, version=1, body, timestampMs=Date.now() }) {
const bodyBytes=body || new Uint8Array(0);
@@ -75,6 +93,24 @@ export class KeyRotationClient {
return assertOk(await this.auth.ws.request('KeyRotationAddBlock', { blockNumber, prevBlockHash, blockBytesB64 }, 30000), 'KeyRotationAddBlock');
}
async #resolveTechLineTail(source, cutoff) {
for(let n=Number(cutoff);n>0;n--) {
const block=await this.getBlock(source,n);
const parsed=parseAns104DataItem(base64ToBytes(block.blockBytesB64));
const kind=readFrameType(parsed.data);
if(kind.type!==0 || (kind.subType!==1 && kind.subType!==TECH_FORK_SUBTYPE)) continue;
const line=readExplicitLinePrefix(parsed.data);
if(line.lineCode!==0 || line.lineSequence<1) throw new Error(`Некорректная TECH line в блоке #${n}`);
return {
prevLineNumber:n,
prevLineHash:String(block.blockHash||''),
lineSequence:line.lineSequence+1,
};
}
const header=await this.getBlock(source,0);
return { prevLineNumber:0, prevLineHash:String(header.blockHash||''), lineSequence:1 };
}
async copyCandidateChain({ rotation, newBundle, onProgress=()=>{} }) {
const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||'');
if(!source || !Number.isInteger(cutoff) || cutoff<0) throw new Error('Некорректное состояние ротации');
@@ -100,7 +136,12 @@ export class KeyRotationClient {
const tipSource=tipBlock===cutoff ? forkSource : await this.getBlock(source,tipBlock);
const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64));
const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64));
const techTail=await this.#resolveTechLineTail(source,cutoff);
const forkBody=makeForkBody({
lineCode:0,
prevLineNumber:techTail.prevLineNumber,
prevLineHash:techTail.prevLineHash,
lineSequence:techTail.lineSequence,
oldBlockchainKeyB64:rotation.oldBlockchainKey,
forkPointBlock:cutoff,
forkPointHash:rotation.forkFromHash,
@@ -111,9 +152,9 @@ export class KeyRotationClient {
reasonCode:rotation.reasonCode,
comment:rotation.comment,
});
const techTimestampMs=Number(rotation.createdAtMs)||Number(parentTipTimestampMs)+1000;
const techTimestampMs=Number(rotation.createdAtMs)||readFrameTimestampMs(tipParsed.data)+1000;
const techFrame=makeFrame({ prevHash:rotation.forkFromHash, blockNumber:cutoff+1, type:0, subType:TECH_FORK_SUBTYPE, version:1, body:forkBody, timestampMs:techTimestampMs });
const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'}] });
const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'},{name:'t',value:'tech'}] });
await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) });
onProgress({ phase:'copy', current:total, total });
}
@@ -160,17 +201,12 @@ export class KeyRotationClient {
}
const state=await this.notifyPdaRotation(signature);
if(storagePwd) {
await updateEncryptedUserSecrets(login, storagePwd, (current)=>{
const next={
...(current||{}),
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
};
// Arweave SAWD-v1 привязан к blockchain key: после ротации старый JWK-cache недействителен.
delete next.arweaveWallet;
delete next.rootKey;
return next;
});
await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
...(current||{}),
rootKey:newBundle.rootPair.privatePkcs8B64,
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
}));
}
return state;
}