Skip to content

Integrate it in your site

This manual is written so that another person —or an AI assistant— can integrate reply2social into any site without knowing this code.

Every step comes with how to verify it. That is not fussiness: in the real deployment of this very system, the process ended in ”✓ OK” three times in a row with something broken — an image that was not built, an npm ci that was failing, a screen that never asked for its data. The site answered 200 in all three cases.

What you are going to integrate

They are two separable pieces and it is worth being clear about that from the start:

mounts

HTTP

who is this?

your site

the host

@reply2social/svelte

the 8 screens

@reply2social/core

client + rules

the service

Rust + Postgres

Instagram · Mastodon

Telegram · RSS

PieceWhat it isMandatory?
The serviceA Rust binary + Postgres. Fetches material, archives it, publishes it.Yes
The panelTwo npm packages with the eight screens.No: you can talk to the service over HTTP from your own front end

This manual covers both. If you only want the service, stop at step 5.


Step 0 — Requirements and the code

The first thing is having the code. The repo is public:

Ventana de terminal
git clone https://gitlab.com/pineiden/reply2fb.git reply2social

The destination name matters: the compose.yml in step 3 points at ./reply2social/backend. The details —downloading it without git, pinning a version, what is in each directory and what the AGPL requires of you— are in Download the code.

On the machine where the service will run:

  • Postgres 14+ (tested on 16).
  • Podman or Docker, with compose.
  • Node 22+, only if you are going to build the panel.
  • Outbound internet access to the networks you are going to use.
Verify
Ventana de terminal
podman --version || docker --version
psql --version
node --version
# And that the clone is complete:
ls reply2social/backend/Cargo.toml reply2social/ui/core/package.json

The three version commands must answer, and the two files must exist. If psql is missing, you can use the Postgres from the compose file and skip the local installation.


Step 1 — The contract: what your site has to offer

This is the step that decides whether the integration is possible at all. The service has no users of its own: it asks your site who each person is.

1.1 Always mandatory: validating identity

Your site exposes one endpoint that the service calls on every request. You choose the path: the service calls whatever you put in IDENTIDAD_URL_VALIDAR. It can be /api/reply2fb/validar, /auth/verify or /rpc/who-is-this; the first one is used here as an example.

Request that reaches your site:

POST <the path you chose>
X-API-Key: <the shared secret>
Content-Type: application/json
{ "token": "<whatever the browser sent in Authorization: Bearer …>" }

Expected response, 200:

{ "id": "uuid-or-whatever-you-use", "roles": ["admin"] }

Response when the token is not valid, 401: any body.

Three rules your implementation has to follow, and all three come from a real incident:

  1. Compare the X-API-Key in constant time. A normal comparison leaks the secret character by character given enough attempts.
  2. Re-read the user from YOUR database on every call. Do not trust what the token says. That is what makes suspending someone take effect immediately instead of waiting for expiry.
  3. If you cannot decide, return 5xx, not 401. A 401 asserts “you are nobody”; if your database is down, the truth is “I could not ask”. The service translates that into a 503 and the panel says “could not be queried” instead of sending the user to log in again, which would fix nothing.

A minimal example, in three languages:

// Express / Node
app.post('/api/reply2fb/validar', async (req, res) => {
const sent = Buffer.from(req.get('X-API-Key') ?? '');
const ours = Buffer.from(process.env.REPLY2FB_IDENTIDAD_SECRETO);
if (sent.length !== ours.length || !crypto.timingSafeEqual(sent, ours)) {
return res.sendStatus(401);
}
try {
// Re-read from the database: the token says who they WERE, the DB who they ARE.
const u = await userFromToken(req.body.token);
if (!u || u.status !== 'active') return res.sendStatus(401);
res.json({ id: u.id, roles: [u.role] });
} catch (e) {
// I could not ask ≠ you are nobody.
res.sendStatus(503);
}
});
# FastAPI
@app.post("/api/reply2fb/validar")
async def validar(body: dict, x_api_key: str = Header(None)):
if not hmac.compare_digest(x_api_key or "", SECRET):
raise HTTPException(401)
try:
u = await user_from_token(body["token"])
except Exception:
raise HTTPException(503) # I could not ask
if not u or u.status != "active":
raise HTTPException(401) # you are nobody
return {"id": str(u.id), "roles": [u.role]}
// Laravel
Route::post('/api/reply2fb/validar', function (Request $r) {
if (!hash_equals(config('r2f.secret'), $r->header('X-API-Key', ''))) {
abort(401);
}
try { $u = userFromToken($r->input('token')); }
catch (\Throwable $e) { abort(503); }
if (!$u || $u->status !== 'active') abort(401);
return ['id' => (string) $u->id, 'roles' => [$u->role]];
});

1.2 Only if you are going to publish INTO your own site

The web platform —“your own site” as both source and destination— is the only part of reply2social that expects paths of a fixed shape. Everything else (Instagram, Mastodon, Telegram, RSS) talks to third-party APIs and asks nothing of you.

Path A — implement the routes. Five endpoints with these exact shapes:

GET {api_url}/api/perfil/me → 200 with the token's user
GET {api_url}/api/admin/usuarios → 200 [{id, username, estado, rol}]
POST {api_url}/api/posts
X-API-Key: <IDENTIDAD_SECRETO>
{ "cuerpo": "", "fuente_url": "https://…", "fuente_nombre": "Instagram" }
2xx { "id": "" }
POST {api_url}/api/posts/{id}/media
X-API-Key: <IDENTIDAD_SECRETO>
multipart/form-data, field «file» → 2xx
GET {api_url}/api/posts → 200 [ ] (to read FROM your site)

They are five thin routes; in most frameworks it is one controller translating into your model. fuente_url and fuente_nombre are the attribution: if your site displays them in a field of its own, do not also paste them into the body.

Path B — an adapter in front. A twenty-line proxy that receives those routes and calls yours. Useful when you do not want to touch your API (WordPress, Ghost, a CMS you do not control).

Path C — do not use web at all. This is the most common case: if you only want Instagram → Mastodon, or RSS → Telegram, skip this whole section. You need to implement none of it and the rest of the manual applies just the same.

Verify step 1

There is no service yet, so this is tested by hand against your site:

Ventana de terminal
# With the right secret and a valid token → 200 with id and roles
curl -s -X POST https://YOUR-SITE/api/reply2fb/validar \
-H "X-API-Key: $SECRET" -H 'Content-Type: application/json' \
-d '{"token":"A_VALID_TOKEN"}' -w '\n%{http_code}\n'
# With the WRONG secret → 401
curl -s -o /dev/null -X POST https://YOUR-SITE/api/reply2fb/validar \
-H "X-API-Key: wrong" -H 'Content-Type: application/json' \
-d '{"token":"x"}' -w '%{http_code}\n'

The first must return 200 and a JSON with id and roles. The second, 401. If the second returns 200, stop here: anybody could pass themselves off as an administrator.


Step 2 — The environment variables

They all go into a .env next to the compose file. The ones marked mandatory make the service fail to start if they are missing, on purpose: a half-configured service that starts is worse than one that does not.

VariableMandatoryWhat it is
DATABASE_URLYespostgres://user:password@host:5432/reply2fb
REPLY2FB_CIFRADO_KEYYes32 bytes in base64. Encrypts the network tokens
IDENTIDAD_URL_VALIDARYesThe URL from step 1.1, as the service sees it
IDENTIDAD_SECRETOYesThe secret shared with your site
IDENTIDAD_MAPA_ROLESNoadmin=administrar,editor=operar,lector=ver
REPLY2FB_EMISION_ACTIVANo1 turns publishing on. Without it, it only archives
REPLY2FB_UID_SERVICIOweb onlyUnder which user it publishes to your site
PORTNo8080 by default

Generating the secret and the key:

Ventana de terminal
# The encryption key: EXACTLY 32 bytes.
openssl rand -base64 32
# The secret shared with your site.
openssl rand -base64 36 | tr -d '/+=' | head -c 48

REPLY2FB_CIFRADO_KEY is not rotated lightly. Every network token was encrypted with it: if you change it, none of them can be opened and they have to be loaded again one by one. Keep it wherever you keep production keys, and take a backup (cauce exportar --respaldo) before touching it.

Verify step 2
Ventana de terminal
# The key must decode to exactly 32 bytes
echo -n "$REPLY2FB_CIFRADO_KEY" | base64 -d | wc -c # → 32

Any other number and the service will fail at startup saying so.


Step 3 — Bringing the service up

compose.yml
services:
reply2fb-db:
image: docker.io/postgres:16-alpine
environment:
POSTGRES_DB: reply2fb
POSTGRES_USER: reply2fb
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD missing}
volumes: ['reply2fb-datos:/var/lib/postgresql/data']
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U reply2fb']
interval: 10s
reply2fb:
build:
# The clone directory from step 0. If you cloned without naming the
# destination, this is `./reply2fb/backend`.
context: ./reply2social/backend
environment:
DATABASE_URL: postgres://reply2fb:${DB_PASSWORD}@reply2fb-db:5432/reply2fb
REPLY2FB_CIFRADO_KEY: ${REPLY2FB_CIFRADO_KEY:?missing}
IDENTIDAD_URL_VALIDAR: ${IDENTIDAD_URL_VALIDAR:?missing}
IDENTIDAD_SECRETO: ${IDENTIDAD_SECRETO:?missing}
IDENTIDAD_MAPA_ROLES: admin=administrar,editor=operar,lector=ver
REPLY2FB_EMISION_ACTIVA: '0'
depends_on:
reply2fb-db: { condition: service_healthy }
ports: ['127.0.0.1:8080:8080']
volumes:
reply2fb-datos:
Ventana de terminal
podman-compose up -d --build

The migrations run by themselves at startup.

Start with REPLY2FB_EMISION_ACTIVA=0. With that, the system archives and does not publish. You will be able to configure everything, watch what comes in, and only turn publishing on when you are sure. The other way round —turn it on first and configure afterwards— the first configuration mistake goes out published on somebody else’s account.

Verify step 3
Ventana de terminal
# 1. The container is up and NOT restarting
podman ps --format '{{.Names}} {{.Status}}' | grep reply2fb
# 2. It answers, and rejects whoever does not identify themselves
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/emisiones

The second must give 401.

  • If it gives 503: the service cannot talk to your site. Check IDENTIDAD_URL_VALIDAR — from inside the container, localhost is the container, not your machine.
  • If it gives 000 or does not connect: the service did not come up. podman logs reply2fb.
  • If it gives 200: something is very wrong — it should not let anyone in without a token.

Step 4 — The proxy

The browser has to be able to reach the service with your site’s session. You choose the prefix/reply2fb/ is used here— and the only thing that matters is that the panel’s transport uses the same one. The service neither knows nor cares under which path you published it.

location /reply2fb/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
# Videos take time: without this a large backfill is cut off at 60 s.
proxy_read_timeout 300s;
client_max_body_size 512M;
}

A third party’s account token never passes through your backend. The panel hits /reply2fb/* directly and nginx forwards it to the service: it does not go through your application, nor your database, nor your logs. If you route it through your backend “to keep things simple”, you take on the responsibility of holding other people’s credentials.

Verify step 4
Ventana de terminal
curl -s -o /dev/null -w '%{http_code}\n' https://YOUR-SITE/reply2fb/api/emisiones
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Authorization: Bearer fake-token' https://YOUR-SITE/reply2fb/api/emisiones

Both must give 401. A 502 means nginx cannot reach the service; a 404, that the prefix does not match.


Step 5 — The first account, still without a panel

Before mounting the interface, check that the service does its job. All of this can be done from the CLI:

Replace reply2fb with whatever you named the container.

Ventana de terminal
# See which platforms exist and which ones have a client
podman exec reply2fb reply2fb cuentas list
# Sign up an RSS source: it is the easiest to test, it needs no token
podman exec reply2fb reply2fb cuentas add \
--plataforma rss --rol lectura \
--handle prensa --id-remoto https://example.org/feed.xml

Signing up verifies before writing: if the feed does not answer or is not a feed, it does not create the account and says why. That is on purpose — a source that cannot be read would poll every six hours bringing back nothing.

Verify step 5
Ventana de terminal
podman exec reply2fb reply2fb poller --una-vez
podman exec reply2fb reply2fb cuentas list

If items came in, the service works. What is missing is the interface.


Step 6 — Mounting the panel

6.1 Getting the packages

They live in the same repo you cloned in step 0 —how to get it— under ui/. They are packaged with:

Ventana de terminal
# Inside the clone
node ui/empaquetar.mjs --destino /path/to/your-site/vendor

That runs both packages’ tests, compiles them, and leaves two .tgz files with the commit in the name, plus a PROCEDENCIA.json saying which version they came from.

In your site:

{
"dependencies": {
"@reply2social/core": "file:./vendor/reply2social-core-0.1.0-abc12345.tgz",
"@reply2social/svelte": "file:./vendor/reply2social-svelte-0.1.0-abc12345.tgz"
}
}

The name carries the sha on purpose. With a fixed name, npm serves the tarball it has in cache even if the contents changed: your site compiles green with the previous code and nobody finds out. If you update the packages, the name changes and so does the package.json.

6.2 The transport

The core does not know how your site authenticates: you hand it over. A Transporte is any function with fetch’s signature:

import { crearCliente } from '@reply2social/core';
// The path goes WITHOUT the prefix: the transport adds it.
const transporte = (ruta: string, opts: RequestInit = {}) =>
fetch(`/reply2fb${ruta}`, {
...opts,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${yourToken}`,
...(opts.headers ?? {}),
},
});
const cliente = crearCliente(transporte);

With session cookies instead of a token: credentials: 'include' and no Authorization.

6.3 Mounting the screens

<script lang="ts">
import { crearCliente, crearVistaPorAprobar } from '@reply2social/core';
import { PorAprobar, Confirmar, crearConfirmador } from '@reply2social/svelte';
import '@reply2social/svelte/estilos.css';
const confirmador = crearConfirmador();
const cliente = crearCliente(transporte);
// Once only: recreating it on every render loses the selection mid-batch.
const vista = crearVistaPorAprobar(cliente, confirmador.confirmar);
</script>
<!-- The dialog is mounted ONCE, at the very top -->
<Confirmar {confirmador} />
<PorAprobar {vista} />

The eight screens all follow the same pattern:

ComponentIts view
PorAprobarcrearVistaPorAprobar(cliente, confirmar)
HistorialcrearVistaHistorial(cliente, confirmar)
ArchivocrearVistaArchivo(cliente, confirmar)
FuentescrearVistaFuentes(cliente, confirmar)
AltacrearVistaAlta(cliente, confirmar)
CuentascrearVistaCuentas(cliente, confirmar)
ProgramacioncrearVistaProgramacion(cliente, confirmar)
RescatecrearVistaRescate(cliente)
AvisoscrearVistaAvisos(cliente)
Verify step 6
Ventana de terminal
npm run build
grep -rl "sin permiso registrado" dist/ | head -1

If the second finds nothing, your bundle does NOT contain the panel even though the build said “Complete”: check that the package.json points at the right tarball and that npm install actually installed it.

And open the screen. It has to show data or an explained error. If it says “Cargando…” forever, the component is not asking for anything: check that you are passing the view, not the client.


Step 7 — The colours

The package ships its own stylesheet (@reply2social/svelte/estilos.css) with a dark scheme and an automatic light variant. If your site already has a palette, do not import the stylesheet: define the same names from yours.

/* On the container where you mount the panel */
.my-panel {
--r2s-fondo: var(--my-surface);
--r2s-tinta: var(--my-text);
--r2s-borde: var(--my-control-border);
--r2s-ok: var(--my-green);
--r2s-fallo-texto: var(--my-readable-red);
--r2s-fallo-borde: var(--my-red);
--r2s-fallo-fondo: var(--my-red-background);
--r2s-atribucion: var(--my-amber);
--r2s-duda: var(--my-tinted-grey);
--r2s-peligro: var(--my-red);
}

Two rules when re-theming, and they are not about style:

  1. --r2s-duda cannot be the “disabled” grey. It marks what the panel does NOT know —a regime or a service state that is new to it— and in a muted grey it reads as “ignorable”, which is the opposite. Give it a tint of its own.
  2. --r2s-atribucion can be neither red nor grey. It marks what was published with attribution and without recorded consent: it is not an error —painting it as one teaches people to ignore errors— but it is not neutral either, because whoever approves has to see it. Amber.

What you cannot break even if you want to: the glyphs and the dotted border of “unknown” travel in the package’s markup. That is the half of the meaning that survives any theme, and the reason re-theming is safe.

Verify step 7

Open the Flows screen with a destination that has no permission and check four states at once: one in green (tested), one in red (no permission), one in amber (paused) and one in grey with ? (untested). If two of them look the same, the theme merged two families and they have to be separated.

Measure the contrast of the text against its background: 4.5:1 minimum. In the real deployment, the default green on a dark panel gave 2:1 —illegible— and nobody saw it until it was published.


Step 8 — Turning publishing on

Only now, and in this order:

  1. Record the permission from the source towards each destination, in the panel, with the name of whoever asserts it. There is no CLI shortcut, and that is on purpose.
  2. Test the flow without publishing: Flows → “Probar”, then “Ensayo en seco” (dry run).
  3. Publish ONE item by hand and look at it on the destination network: “Publicar uno”.
  4. Only then, REPLY2FB_EMISION_ACTIVA=1.

A flow without recorded consent does not emit, and the panel draws it broken. That is not bureaucratic friction: this system republishes other people’s work, and the permission is a personal, dated assertion by whoever granted it. There is no way to grant it in bulk, not even by importing a file.


Final verification

Run this whole thing. All six must give what is expected; if one fails, the integration is not complete even if everything else works.

Ventana de terminal
SITE=https://your-site
echo "1. no token → $(curl -so /dev/null -w %{http_code} $SITE/reply2fb/api/emisiones) expected 401"
echo "2. fake token → $(curl -so /dev/null -w %{http_code} -H 'Authorization: Bearer x' $SITE/reply2fb/api/emisiones) expected 401"
echo "3. wrong secret → $(curl -so /dev/null -w %{http_code} -X POST $SITE/api/reply2fb/validar -H 'X-API-Key: wrong' -H 'Content-Type: application/json' -d '{}') expected 401"
echo "4. panel in bundle → $(grep -rl 'sin permiso registrado' dist/ | wc -l) expected 1 or more"
echo "5. accounts exist → $(podman exec reply2fb reply2fb cuentas list | grep -c .) expected 1 or more"
echo "6. no credentials in the response:"
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $SITE/reply2fb/api/cuentas \
| grep -c 'credenciales_cifradas'

The sixth must give 0: no endpoint returns the encrypted column.


Frequent errors, and what they actually mean

All of these happened in the real deployment of this system.

What you seeWhat it usually is
“Cargando…” foreverYou passed the component the client instead of the view, or the view was never created. The screen mounts and asks for nothing
The panel looks “bare”, without coloursYou imported neither estilos.css nor defined the --r2s-*. The colours fall back to reserves designed for another scheme
Everything answers 503The service cannot reach your validation endpoint. From inside the container, localhost is the container
Everything answers 401 with a good tokenYour endpoint returns 401 when the database query fails. It has to return 5xx
The deploy says OK and you see the old codeThe image was not rebuilt, or npm served a cached tarball. Compare the image date, not the health check
A flow does not emit and does not say whyThe consent is missing. The panel draws it broken; on the CLI, rutas list shows it
Things get published without anyone approvingThat flow has auto_publica. It is switched on by hand and confirmed

Why this manual insists so much on verifying.

Integrating this is eight steps and each one has a way of failing silently: a build that does not run, an old tarball, a screen that asks for no data, a 2:1 colour. None of those show up in a health check, and all of them show up in thirty seconds if you know what to look at.

The site answering 200 proves nothing about reply2social.