Visão geral
O onpostt é um relay para blocos JSON assinados. O cliente gera a chave, assina localmente e envia o bloco por HTTP ou WebSocket. O relay valida assinatura, id, formato, alvo e autoria antes de gravar.
O que o relay garante
- assinatura e id conferem com o conteúdo;
- regras de modo e autoria aplicadas;
- um id produz efeito no máximo uma vez;
- fan-out apenas para filtros compatíveis.
O que ele não faz
- não guarda chave privada;
- não lê mensagens cifradas;
- não oferece anonimato nem moderação;
- não garante entrega ou disponibilidade.
Bloco
Um bloco é um objeto JSON com os campos abaixo. Campos extras são permitidos (até 32 no total) e entram no id e na assinatura.
| Campo | Tipo | Regra |
|---|---|---|
id | string | SHA-256 hex de 64 caracteres |
pubkey | string | secp256k1 comprimida (66) ou não comprimida (130) |
created_at | inteiro | Unix em segundos; sem mais de 600 s no futuro |
mode | string | um dos modos do protocolo |
query | array | pares [chave, valor] |
content | string | objeto | array | obrigatório em post, profile, comment, message e recibos de leitura; proibido em notification; em post use { text?, image, topics? } e no profile use { name, bio, image? } |
app | string | 1–190 caracteres; obrigatório exceto em delete |
sig | string | DER secp256k1 em hexadecimal |
{
"pubkey": "03a1...66-hex...",
"created_at": 1760000000,
"mode": "post",
"query": [["topic", "onpostt"]],
"content": "primeiro post",
"app": "meu-app",
"id": "9f2c...64-hex...",
"sig": "3045...der..."
}
JSON.stringify(bloco sem sig). Não reordene o objeto depois
de assinar.
Id e assinatura
O id identifica o conteúdo. A assinatura prova a autoria. Ambos são calculados sobre a serialização exata do bloco.
const unsigned = { ...block };
delete unsigned.id;
delete unsigned.sig;
const id = sha256(JSON.stringify(unsigned));
const signed = { ...block };
delete signed.sig; // id permanece
const msgHash = sha256(JSON.stringify(signed));
const sig = secp256k1.sign(msgHash, privateKey).toDER('hex');
idnão entra no cálculo do id;sigtambém não.signão entra na mensagem assinada;identra.- O relay compara o id calculado com o recebido e verifica a assinatura.
- A ordem da biblioteca é
pubkey, created_at, mode, query, content, app, extras, id, sig.
Modos
| Modo | Transporte | Query | Conteúdo |
|---|---|---|---|
post | HTTP | livre | texto ou { text, image } |
profile | HTTP | livre | { name, bio, image? } |
follow | HTTP | follower, following | opcional |
block | HTTP | blocker, blocked | opcional |
like | HTTP | block | opcional |
reaction | HTTP | block, reaction | opcional |
comment | HTTP | block + parent opcional | obrigatório |
repost | HTTP | block | opcional |
notification | HTTP | to, block, interaction, kind | proibido |
notification_read | HTTP | — | { read: [ids] } |
delete | HTTP | block | opcional |
message | WebSocket | from, to | envelope cifrado |
message_read | HTTP | — | { read: [ids] } |
like, reaction, repost,
follow, block e delete podem
omitir content. Nos demais modos, o conteúdo é
obrigatório, exceto em notification, que não aceita
content.
like, reaction,
repost, follow e block alternam:
o primeiro envio cria, um novo bloco com a mesma query remove. Cada
envio precisa de um novo id e de uma nova assinatura. A biblioteca
adiciona um nonce aleatório a esses blocos para que duas
ações no mesmo segundo não colidam.
Fotos em posts
A foto é opcional e vai como base64 dentro do content
assinado. O relay não confia nas dimensões declaradas: decodifica os
bytes, confere os magic bytes de JPEG/PNG, lê a largura e a altura reais
do cabeçalho e rejeita imagens acima de 500px ou do limite de bytes.
{
"text": "legenda opcional",
"image": {
"mime": "image/jpeg",
"data": "<base64 sem prefixo data:>",
"width": 500,
"height": 375
}
}
mime:image/jpegouimage/png.data: base64 canônico, com padding, semdata:.widtheheight: inteiros positivos, iguais às dimensões reais e no máximo 500.- O JSON do conteúdo com imagem pode ter até 128 KiB; a imagem decodificada, até 72 KiB.
Redimensione sempre no cliente. onpostt.prepareImage usa
canvas e FileReader, mantém o maior lado em
500px e comprime em JPEG até o alvo:
const image = await onpostt.prepareImage(file, {
maxDimension: 500,
maxBytes: 64 * 1024,
mime: 'image/jpeg',
});
const block = await onpostt.signBlock(
{
mode: 'post',
query: [],
app: 'meu-app',
content: {
...(caption ? { text: caption } : {}),
image: {
mime: image.mime,
data: image.data,
width: image.width,
height: image.height,
},
},
},
privateKey
);
if (typeof block === 'string') throw new Error(block);
await onpostt.sendBlock(block);
Foto no perfil
O perfil usa o mesmo helper, mas prepara a foto para até
150px no maior lado e mostra a prévia ao importar.
Quando há image, o relay aceita apenas name,
bio e image. Salvar sem image
remove a foto anterior. O limite de protocolo continua sendo 500px.
{
"name": "Ana",
"bio": "Uma frase sobre mim",
"image": {
"mime": "image/jpeg",
"data": "<base64 sem prefixo data:>",
"width": 150,
"height": 150
}
}
const image = await onpostt.prepareImage(file, {
maxDimension: 150,
maxBytes: 64 * 1024,
mime: 'image/jpeg',
});
const block = await onpostt.signBlock(
{
mode: 'profile',
query: [],
app: 'meu-app',
content: {
name: 'Ana',
bio: 'Uma frase sobre mim',
image: {
mime: image.mime,
data: image.data,
width: image.width,
height: image.height,
},
},
},
privateKey
);
if (typeof block === 'string') throw new Error(block);
await onpostt.sendBlock(block);
Perfil público
/perfil.html?pubkey=<pubkey> abre a página pública de
um usuário, com foto, nome, bio, publicações e fotos, curtidas,
reações, comentários, seguidores e seguindo. A leitura usa blocos
públicos de todas as aplicações e não exige autenticação.
| Rota | Função |
|---|---|
/perfil.html?pubkey=... | perfil público selecionado |
/perfil, /profile, /profile.html | redirecionam para /perfil.html preservando a query string |
/demo/?view=feed | demo no feed |
/demo/?view=profiles | demo nos perfis |
/demo/?view=notifications | demo nas notificações |
/demo/?view=chat | demo nas conversas |
/demo/?view=about | demo na seção sobre |
Sem pubkey, a página abre o perfil da identidade local
quando houver uma. A demo guarda a identidade em sessionStorage
e a página de perfil também aceita localStorage. Os cartões
de perfil e a área do autor de cada publicação levam para o perfil
selecionado.
Notificações
Uma notificação é um bloco assinado que aponta para uma interação já
aceita. O ator é a pubkey do bloco; o destinatário é
to; o alvo é block; a interação é
interaction; e kind é like,
reaction, comment, repost ou
follow. O bloco não tem content.
const notification = await onpostt.signBlock(
{
mode: 'notification',
query: [
['to', ownerPubkey],
['block', postId],
['interaction', like.id],
['kind', 'like'],
],
app: 'meu-app',
},
actorPrivateKey
);
if (typeof notification === 'string') throw new Error(notification);
await onpostt.sendBlock(notification);
toprecisa ser o dono do bloco alvo e diferente do autor.- A interação precisa existir, ter o mesmo autor e apontar para o mesmo
block. - O modo da interação precisa ser igual a
kind. - Alvo e interação não podem ter tombstone.
- Só pode existir uma notificação por interação.
- Em
kind: "follow",blockeinteractionapontam para o próprio blocofollow;toprecisa ser igual afollowing. - O relay não cria a notificação automaticamente: o cliente envia depois que a curtida, reação, comentário, repost ou follow for aceito.
O bloco é público e imutável, mas some das consultas quando a interação
ou o alvo é removido. O estado de lida/limpa fica no cliente. Na demo,
o contador de não lidas, o texto contextual e as ações de marcar/limpar
são persistidos por destinatário em localStorage.
const notifications = await onpostt.list({
mode: 'notification',
app: 'meu-app',
query: [['to', ownerPubkey]],
limit: 50,
});
Bloqueio de usuário
O bloqueio é um toggle assinado pelo bloqueador. O primeiro envio cria
a relação; repetir a mesma query desbloqueia. O relay recusa
mensagens entre as duas pubkeys, nos dois sentidos, enquanto o
bloqueio existir. A leitura também é filtrada no servidor: com um
token reader assinado, o relay esconde conteúdo dos
envolvidos nas duas direções; sem token, esconde autores que
participam de qualquer bloqueio ativo.
const block = await onpostt.signBlock(
{
mode: 'block',
query: [
['blocker', myPubkey],
['blocked', otherPubkey],
],
app: 'meu-app',
},
myPrivateKey
);
if (typeof block === 'string') throw new Error(block);
const [result] = await onpostt.sendBlock(block);
// "created" bloqueia; "removed" desbloqueia
// A biblioteca assina o token de leitura automaticamente.
await onpostt.setReaderKey(myPrivateKey);
blockerprecisa ser a pubkey do autor; não há auto-bloqueio.- Mensagens entre o par são recusadas com
user_blocked. - O bloqueio e o envio de mensagem usam o mesmo lock transacional do par.
- O token
readerexpira emREADER_TOKEN_TTL_SECONDS(padrão: 30 dias) e só concede leitura. - O bloco
blockcontinua público para a interface detectar o estado. - Uma nova chave privada é uma nova identidade; o bloqueio não impede Sybil (ver Segurança).
Query e filtros
query é um array de pares. Chaves são únicas, têm 1–64
caracteres e valores são string, número finito ou booleano.
[
["topic", "onpostt"],
["lang", "pt-BR"]
]
Filtros de leitura usam os mesmos pares e exigem mode.
Campos aceitos: mode, pubkey,
query, since, until,
app, id, limit e
offset. O HTTP aceita ainda um campo irmão
reader; no WebSocket, reader é irmão de
filter. O token é assinado pelo cliente e usado para
aplicar bloqueios sem confiar em um viewer enviado sem prova.
const posts = await onpostt.list({
mode: 'post',
app: 'meu-app',
query: [['topic', 'onpostt']],
limit: 20,
});
const reactions = await onpostt.list({
mode: 'reaction',
query: [
['block', posts[0].id],
['reaction', '🔥'],
],
limit: 100,
});
const comments = await onpostt.list({
mode: 'comment',
query: [['block', posts[0].id]],
limit: 100,
});
Em message, o filtro precisa de pubkey, dos
pares from/to ou de id. Em
notification, o filtro precisa do par
["to", pubkey] ou de id; to não
diferencia maiúsculas de minúsculas e pubkey filtra pelo
ator. Em block, use os pares blocker e/ou
blocked; esse modo fica fora do filtro de visibilidade
para a interface detectar o estado. O relay nunca aceita
privatekey em filtros.
Com reader, o relay esconde conteúdo quando leitor e
autor se bloquearam em qualquer direção. Sem token, a consulta é
anônima e esconde autores que participam de qualquer bloqueio ativo.
A mensagem assinada é
onpostt-read-v1:<viewer>:<created_at>.
const notifications = await onpostt.list({
mode: 'notification',
app: 'meu-app',
query: [['to', myPubkey]],
limit: 50,
});
HTTP
| Rota | Método | Função |
|---|---|---|
/save_block | POST | grava um bloco assinado |
/get_blocks_list | POST | consulta blocos |
/health | GET | saúde do relay e do banco |
/api/status | GET | snapshot público |
/api/modes | GET | regras dos modos |
/perfil.html | GET | página pública de perfil com publicações e atividade |
/perfil, /profile, /profile.html | GET | redirecionam para o perfil preservando a query string |
curl -s http://localhost:3000/get_blocks_list \
-H 'Content-Type: application/json' \
-d '{"mode":"post","app":"meu-app","limit":20}'
{
"response": "blocks",
"blocks": [
{
"id": "9f2c...",
"pubkey": "03a1...",
"created_at": 1760000000,
"mode": "post",
"query": [["topic", "onpostt"]],
"content": "olá",
"app": "meu-app",
"sig": "3045..."
}
],
"requestId": "8f4c..."
}
/save_block devolve action: "created",
"removed" (toggle) ou "duplicate" (id já
processado). Apenas ações efetivas são transmitidas pelo WebSocket.
WebSocket
Use o mesmo host e porta. O Origin é validado no upgrade.
Apenas message pode ser gravada pelo socket; os demais
modos usam HTTP.
{
"response": "hello",
"name": "onpostt",
"relay": "onpostt relay",
"heartbeatIntervalMs": 30000
}
{
"request": "get_blocks_sub",
"subscription": "feed",
"filter": { "mode": "post", "app": "meu-app", "limit": 20 },
"reader": {
"viewer": "03a1...",
"created_at": 1760000000,
"sig": "3045..."
},
"requestId": "req-1"
}
reader é opcional e usa o mesmo formato do HTTP. A página
inicial e cada bloco ao vivo passam pelo filtro de bloqueio antes da
entrega. Sem token, valem as regras anônimas; blocos block
continuam sendo entregues para a interface detectar o estado.
{
"response": "blocks",
"subscription": "feed",
"blocks": [
{
"id": "9f2c...",
"pubkey": "03a1...",
"created_at": 1760000000,
"mode": "post",
"query": [],
"content": "olá",
"app": "meu-app",
"sig": "3045..."
}
],
"live": true
}
{
"request": "save_block",
"block": {
"id": "9f2c...",
"pubkey": "03a1...",
"created_at": 1760000000,
"mode": "message",
"query": [
["from", "03a1..."],
["to", "02b2..."]
],
"content": "{\"forRecipient\":{...},\"forSender\":{...}}",
"app": "meu-app",
"sig": "3045..."
},
"requestId": "req-2"
}
Envelopes ping, unsubscribe,
get_status e erros seguem o formato
{ request, requestId } / { response, status }.
O limite padrão é 25 assinaturas e 5 pubkeys por conexão.
Mensagens cifradas
O cliente gera uma chave secp256k1 efêmera por envio, faz ECDH com a pubkey do destinatário e com a do remetente, deriva AES-256-GCM e grava dois envelopes.
{
"forRecipient": {
"ciphertext": "hex; tag incluída",
"iv": "24 hex (12 bytes)",
"senderPubKey": "pubkey efêmera comprimida"
},
"forSender": {
"ciphertext": "hex; tag incluída",
"iv": "24 hex (12 bytes)",
"senderPubKey": "pubkey efêmera comprimida"
}
}
- ECDH entre a chave efêmera e a pubkey do participante.
- Converter o segredo para hexadecimal e aplicar SHA-256 sobre o UTF-8.
- Cifrar com AES-256-GCM e IV aleatório de 12 bytes.
- Publicar
ciphertext,ivesenderPubKey.
O relay não consegue ler o texto. O destinatário recebe uma cópia com
id <id>_rcp na caixa de entrada; o remetente lê a
mensagem original.
Biblioteca
Carregue /onpostt.min.js no navegador ou
public/onpostt.js no Node.js. No Node, defina
globalThis.elliptic antes de importar.
Funções principais
generateKeys(),sha256()eprepareImage().signBlock(block, privateKey)assina e devolve o bloco, ou uma string de erro.sendBlock(),connect()esub().setReaderKey(privateKey)assina o token de leitura;createReaderToken()devolve o token bruto.list()devolve o array e expõearray.truncated; assinaturas recebemevent.truncated.encryptMessage()edecryptMessages()para mensagens diretas.sanitizeFilter()remove/recusa campos reservados antes de enviar.
prepareImage é exclusivo de navegador: usa
canvas, FileReader e
createImageBitmap para devolver
{ mime, data, width, height, bytes }.
'use strict';
globalThis.elliptic = require('elliptic');
const onpostt = require('./public/onpostt.js');
const host = 'http://localhost:3000';
const app = 'meu-app';
async function main() {
const alice = onpostt.generateKeys();
const bob = onpostt.generateKeys();
onpostt.connect({ host: [host] });
await onpostt.setReaderKey(alice.privateKey);
const post = await onpostt.signBlock(
{
mode: 'post',
query: [['topic', 'onpostt']],
content: 'olá',
app,
},
alice.privateKey
);
if (typeof post === 'string') throw new Error(post);
await onpostt.sendBlock(post);
await onpostt.sendBlock(
await onpostt.signBlock(
{
mode: 'reaction',
query: [
['block', post.id],
['reaction', '🔥'],
],
app,
},
bob.privateKey
)
);
const comment = await onpostt.signBlock(
{
mode: 'comment',
query: [['block', post.id]],
content: 'primeiro comentário',
app,
},
bob.privateKey
);
await onpostt.sendBlock(comment);
await onpostt.sendBlock(
await onpostt.signBlock(
{
mode: 'comment',
query: [
['block', post.id],
['parent', comment.id],
],
content: 'resposta',
app,
},
bob.privateKey
)
);
console.log(await onpostt.list({ mode: 'post', app, limit: 10 }));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
<script src="/onpostt.min.js"></script>
<script>
onpostt.connect({ socket: [location.origin] });
const stop = onpostt.sub(
{ mode: 'post', app: 'meu-app', limit: 20 },
function (event) {
event.blocks.forEach(function (block) {
const article = document.createElement('article');
article.textContent = block.content;
document.querySelector('#feed').appendChild(article);
});
}
);
// stop();
</script>
content como texto e renderize com
textContent. A CSP do relay também bloqueia scripts
inline; o exemplo acima serve para uma página sua.
Segurança
- A chave privada fica no cliente. Filtros com
privatekeysão rejeitados. - Todo bloco é validado por assinatura, id, tamanho, query e regras do modo.
processed_blocksimpede replay de toggles.deletesó é aceito para o autor do alvo.- Notificações exigem interação real do mesmo autor e alvo; só existe uma por interação; follow notifications validam o próprio follow.
- Mensagens entre usuários com bloqueio ativo, em qualquer sentido, são recusadas no servidor.
- Leituras com
readerverificam assinatura, timestamp e TTL; bloqueios filtram HTTP e WebSocket ao vivo nas duas direções. - Sem token, autores que participam de bloqueio ativo ficam ocultos; o bloco
blockcontinua público para a interface detectar o estado. - Uma nova chave privada é uma nova identidade: o protocolo não elimina Sybil sem um provedor externo de unicidade.
- Respostas exigem pai do mesmo alvo e não excluído.
- CORS e WebSocket restringem
Origin. - Rate limit por IP e por conexão, além de limites de payload.
- Imagens têm magic bytes, dimensões reais e tamanho verificados no relay.
- Logs não registram chaves, senhas, corpos ou URL do banco.
HOST_ALLOWED com as origens reais e mantenha
ALLOW_NO_ORIGIN=false.
Limites
| Item | Padrão |
|---|---|
| Bloco | 256 KiB |
| Resposta de listagem/assinatura | 8 MiB; acima disso, truncated: true |
| Conteúdo comum | 64 KiB |
| JSON de conteúdo com imagem | 128 KiB |
| Imagem decodificada | 72 KiB |
| Maior lado da imagem (protocolo) | 500px |
| Foto de perfil (cliente) | 150px |
| Campos no bloco | 32 |
| Pares de query | 32 |
| Emoji de reação | 32 code points |
| Blocos por consulta | 1000 |
| Payload WebSocket | 1 MiB |
| Assinaturas por conexão | 25 |
| Pubkeys por conexão | 5 |
| Validade do token de leitura | 30 dias |
| Rate limit HTTP | 120 leitura / 60 escrita por minuto |
Todos os valores são configuráveis por ambiente. Veja
env.example. O relay aceita imagens até 500px; a demo
prepara fotos de publicação em 500px e fotos de perfil em 150px.
Erros
Respostas de erro têm status: "erro", um
code estável e requestId. Os códigos mais
comuns:
| Código | Causa |
|---|---|
invalid_block | formato ou regra do modo |
invalid_id | id não confere com o hash |
invalid_signature | assinatura inválida |
invalid_image | base64, mime ou magic bytes inválidos |
invalid_image_dimensions | dimensões divergentes ou acima de 500px |
image_too_large | imagem decodificada acima de 72 KiB |
invalid_filter | filtro inválido |
invalid_reader | token reader malformado |
invalid_reader_signature | assinatura do token reader inválida |
reader_expired | token reader expirou |
reader_not_yet_valid | reader.created_at muito no futuro |
target_not_found | alvo inexistente |
target_deleted | alvo com tombstone |
target_not_owned | delete por quem não é autor |
parent_not_found | comentário pai inexistente |
parent_mismatch | pai de outro alvo |
interaction_not_found | notificação aponta para interação inexistente |
interaction_author_mismatch | interação de outro autor |
interaction_target_mismatch | interação de outro bloco |
notification_recipient_mismatch | destinatário não é dono do alvo ou não é o following |
notification_kind_mismatch | kind não corresponde à interação |
notification_target_mismatch | follow notification não aponta para o próprio follow |
notification_follow_mismatch | autor do follow não é o seguidor |
notification_duplicate | já existe notificação para a interação |
self_notification | notificação para a própria pubkey |
user_blocked | mensagem entre usuários com bloqueio ativo |
private_key_forbidden | filtro com privatekey |
rate_limited | limite de taxa |
use_http | modo não-message no WebSocket |
internal_error | falha interna |
{
"status": "erro",
"code": "invalid_signature",
"message": "Assinatura inválida. Bloco rejeitado.",
"requestId": "8f4c..."
}
Próximos passos
Executar
Suba o relay, abra a demo, publique um post e veja o perfil público, as notificações de interação e o bloqueio de usuário.
Abrir demoExemplos
Scripts prontos para publicar, reagir, comentar, responder e enviar mensagens.
Ver na prática
Referência completa em docs/PROTOCOL.md,
docs/API.md e docs/SECURITY.md.
Reações e respostas
Curtir e reagir
A curtida é única por autor e alvo. A reação é única por autor, alvo e emoji, então a mesma pessoa pode usar vários emojis no mesmo bloco.
Comentário e resposta
O alvo continua sendo o post. A resposta acrescenta
["parent", idDoComentario]. O pai precisa ser um comentário existente do mesmo alvo e não pode estar excluído.Excluir
Só o autor do alvo pode criar o tombstone. O bloco não é apagado fisicamente; as consultas passam a ocultá-lo.