تخطَّ إلى المحتوى
Sideverse
الأقسام

الوثائق

مرجع واجهة البرمجة

HTTP endpoints exposed by this registry.

هذه المقالة متاحة باللغة الإنجليزية حاليًا.


All endpoints below live at the registry's root domain. Request bodies are CBOR; responses are CBOR for protocol endpoints and JSON for everything else.

Resolve a handle

GET /.well-known/tpsocial/resolve/{handle}
Accept: application/cbor

Returns the public key and attestation wire for a claimed handle.

Response — application/cbor:

{
  "handle": "alice",
  "pubkey": h'…32 bytes…',
  "attestation_wire": h'…64 bytes…',
  "attested_at": 1700000000
}

Headers

  • ETag: "<blake3-of-attestation>" — cache validator.
  • Cache-Control: public, max-age=300 — short cache so handle rotations propagate within five minutes.

A 404 means the handle is not in the registry. A 410 Gone means it was claimed and later revoked.

Claim a handle

POST /handles/claim?category=person
Content-Type: application/cbor

Body is a signed HandleAttest (canonical CBOR) binding handle_local and this registry's domain to the side that will own the name. The registry verifies the signature, that the domain inside the signed bytes is its own (an attestation minted for another registry is refused), the name's shape, the reserved list, and that issued_at is within 300 seconds of its clock.

Query: category — person (default), business, directory or institution; dial_addr — an optional hint for reaching the side.

Response — 201 Created:

{
  "handle": "alice@tpsocial.net",
  "handle_local": "alice",
  "domain": "tpsocial.net",
  "side": "tp1…",
  "category": "person",
  "attested_at": 1790000000
}

Errors:

  • 409 Conflict — the name is already claimed here.
  • 422 Unprocessable Entity — a bad or tampered attestation, another registry's domain, a name that is not a TP Social handle or is reserved, an unknown category, or a skewed issued_at.

Rate-limited per IP by the tpsocial-claim limiter (AppServiceProvider).

A browser account claims at /app/claim instead: it has no device to sign on, so this registry signs the same attestation with the key it holds for the account, under the same rules. Each browser Side may hold three names.

Publish a page

POST /pages
Content-Type: application/cbor

Body is a canonical CBOR page wire signed by the handle's keypair.

Response — 200 OK (JSON):

{
  "handle": "alice",
  "slug": "hello",
  "url": "https://tpsocial.net/alice/hello",
  "wire_blake3": "…hex…"
}

Re-publishing under the same (handle, slug) overwrites the previous version. The registry keeps only the latest.

Errors:

  • 403 Forbidden — signature did not match the claimed handle's pubkey.
  • 404 Not Found — handle has not been claimed.
  • 413 Payload Too Large — wire exceeded the registry's per-page cap (currently 256 KiB; configurable per operator).

Render a page

GET /{handle}/{slug}

Returns the rendered HTML for the page — markdown sanitised inline, HTML iframed against the sandbox origin. ETag is the BLAKE3 of the signed wire, so 304s are cheap.

GET /{handle}/{slug}.cbor

Returns the raw signed wire bytes for clients that want to verify locally.

Health check

GET /up

A Laravel-default health endpoint that returns 200 when the app boots and the database is reachable. Useful behind a load balancer.

GET /__health/ffi

Verifies a committed HandleAttest fixture through libtpsocial and reports how this node signs: runtime.transport is library when libtpsocial is loaded in-process, or process when the runtime has no FFI and the same library runs as the tpsocial-ffi-proc program — Laravel Cloud's case. 503 with a remediation when neither works.

Names on the trust web

The registry is the namespace authority for sa (protocol §11.12). Two read-only endpoints let resolvers and clients verify names without trusting the transport:

GET /.well-known/trustplane/authority

JSON: namespace, domain, authority (the authority's tp1… side address), authority_pk_hex, uri (trust://sa/<tp1…>), name_ttl_seconds. Pin authority in a resolver's or client's names table (sa=<domain>=<base url>=<authority>).

GET /.well-known/trustplane/names/{handle}

application/cbor: a NameRecord — the authority's signature over the DirectoryEntry served at /.well-known/tpsocial/resolve/{handle}, with issued_at and expires_at. Two signatures: the side's own attestation inside the entry says the side claims the name; the authority's says it assigned it. 404 for an unknown, hidden or foreign-domain handle; 501 when the registry has no authority key configured.

The app: pairing and what a side sees

Everything below is what the TrustPlane app uses (protocol §11.12). A Side key authenticates one identity. The private owner account may hold several Sides; a Side credential never authenticates that owner account.

Pair a device. POST /api/v1/auth/challenge → {nonce, domain, label, expires_at}. The device signs blake3(label ‖ domain ‖ nonce) with its side key and posts POST /api/v1/auth/pair {nonce, side_pk, signature, device_name} (base64url) → 201 {token, token_id, address, side_hex, user_id, created}. The token carries the read and publish abilities. 404 unknown nonce, 409 used, 410 expired, 401 bad signature. DELETE /api/v1/auth/token revokes the calling token.

Independent Sides: version 2 pairing. Use POST /api/v2/auth/challenge for {nonce, domain, label, expires_at}. The label is tpsocial/v2/api-pair; sign blake3(label ‖ domain ‖ nonce) and send POST /api/v2/auth/pair {nonce, side_pk, signature, device_name?}. Nonce, key and signature are base64url. Only an existing, active Side may pair; this endpoint does not create accounts or identities. The response is 201 {token, token_id, address, side_hex, scope: "side"} with read and publish abilities. It contains no owner ID, email or sibling identities. DELETE /api/v2/auth/token revokes the calling token.

Version 1 remains compatible with the original Side. Attempting to pair an independent Side through version 1 returns 409; use version 2 instead. Existing tokens remain restricted to their original Side. A side query parameter cannot move a token to another identity. /api/user now returns the same Side-only representation as /api/v1/me, rather than an account record. Browser QR authentication likewise grants a Side-only session, bound to the browser that created its challenge. Account settings require owner authentication by password or passkey.

Who am I. GET /api/v1/me → {address, side_hex, has_side, handles[], token{name, abilities}}.

Groups. GET /api/v1/verses (mine), GET /api/v1/verses/{slug} (adds ranks, contract, owner handle), POST /api/v1/verses {title, description, owner_handle}. A verse you are not an active member of is a 404, never a 403.

Invites and joining. POST /api/v1/verses/{slug}/invites {max_uses?, expires_in_days?, note?, grants_rank?} → 201 {code, prefix, join_url, expires_at, max_uses} (the code is shown once; 403 without the invite grant). POST /api/v1/join/{code} {display_name} → the verse as you now see it; expired, revoked, exhausted and never-existed codes are all 404.

Members. GET /api/v1/verses/{slug}/members → the side and name each member chose there, their rank, who invited them, and a public handle if that side holds one.

Sides graph. GET /api/v1/graph → {me, verses, people}: for each person, exactly what they let you see — the side and display name they used in each verse you share, their rank there, a public handle, the places they verified presence at in rooms you can read, and who invited whom. The same person with two sides in two verses is two people; nothing is inferred.

Discovery. GET /api/v1/discover lists public, active groups for everyone (a token, if present, marks is_member). GET /api/v1/discover/{slug} previews a public or unlisted one: rooms open to newcomers, ranks, what it asks for and never asks for, the founder's handle. POST /api/v1/verses/{slug}/join {display_name} admits the caller to an open community; an invite-only one answers 403 with what to do instead; a hidden one is 404. Creating a verse takes visibility (public, unlisted, hidden) and join_mode (open, invite); an open door on a hidden space is corrected to invite.

Experiences, people, groups inside a group

The social layer (protocol §11.14). One rule shapes all of it: your peers are the active members of the active groups you are in, and nothing about a person reaches anyone who is not their peer. A profile, a post or a photo you may not see is a 404.

Post an experience. POST /api/v1/experiences (multipart, publish, 30/hour): body (≤ 2000), verse? (a slug: only that group sees it, and tags must be its members), presence[credential] + presence[manifest] (+ presence[issued_at] when the check-in is older than a minute — the registry verifies at that instant), with[] (side hex of peers, ≤ 10), photos[] (≤ 4, ≤ 8 MB each, JPEG/PNG/WebP; re-encoded to JPEG ≤ 1600 px with EXIF dropped, kept on the private disk). A presence credential must be the poster's own (its subject is the poster's side) or the post is 422. → 201 with the feed item.

The feed. GET /api/v1/feed?before=<id> → {data[], next_before}, 50 per page, newest first: posts by your peers (unscoped, or scoped to a group you share), your own, and those you are tagged in. Each item: {id, author{side_hex, name, handle}, body, verse{slug,title}|null, place{entity, name, level, verified_at, issued_at}|null, with[{side_hex, name, status, verified_together}], photos[{position, width, height, url}], created_at, mine, tagged_me, my_status}. Names are the ones you know — the post's group first, else the first one you share; you for yourself; someone when you share nothing (a tag on a post you may see).

A post and its photos. GET /api/v1/experiences/{id}; GET /api/v1/experiences/{id}/photos/{n} streams the JPEG to anyone who may see the post. DELETE /api/v1/experiences/{id}: the author deletes (files too), a member with the moderate grant in the post's group hides it, anyone else gets 404.

Tags. A tag is the poster's claim (tagged). The tagged person answers once: POST /api/v1/experiences/{id}/confirm {presence?} or POST …/decline (409 once answered, 404 for anyone else). Confirming with your own presence credential for the same place issued within two hours of the post's makes the tag verified_together; a credential for another place or too far apart is 422 and nothing is recorded. Confirming without a credential is "says so": confirmed, not verified.

People. GET /api/v1/people/{side_hex} (404 unless a peer or yourself) → {side, side_hex, self, names{slug: name}, handle, bio, shared_verses[{slug, title, their_rank, their_display_name}], places[{entity, name, level, first, last, count}], experiences[], next_before, brought_you_into[], you_brought_into[], member_since}. Places come from their verified posts you may see and their presence-attested messages in rooms you can read. PATCH /api/v1/me {bio} (≤ 300) edits your own.

Groups inside a group. POST /api/v1/verses/{slug}/verses {title, description?} → 201 the child, for a parent member whose rank grants create_room (trusted members and the founder; 403 otherwise, 409 on a child — one level only). A child is always hidden with join_mode: parent, inherits the parent's contract rules under its own title, and admits its creator and the parent's founder at the top rank. GET /api/v1/verses/{slug} adds parent{slug,title}, children[{slug, title, description, address, member_count, is_member}] and me.can_create_inside. Parent members preview it at GET /api/v1/discover/{slug} (parent and inside name the parent) and join with POST /api/v1/verses/{slug}/join — no display_name needed, the one they use in the parent carries over. To everyone else it does not exist. Leaving the parent leaves its children.

A group's look

Every group chooses how it looks, and the app wears that look whenever you are inside it or posting to it — like a server with its own colours. The look is presentation, not contract: the founder and moderators (the moderate grant) change it without anyone re-consenting.

Read. theme rides on every verse shape: GET /api/v1/verses and /verses/{slug} (with me.can_style), the children[] of a verse, GET /api/v1/discover cards and /discover/{slug}, the verse of a feed item, and the graph's verses[]. Shape: {accent, bg, panel, ink} as #rrggbb; font sans|serif|mono|rounded|display; corners sharp|soft|round; wallpaper none|dots|grid|lines|waves; bubbles round|square; density cozy|compact; icon (≤ 8 chars, an emoji or a letter or two); motto (≤ 120); rooms_word, people_word (≤ 20, what that group calls them); banner {width, height} or null; banner_position top|center|bottom; icon_image {width, height} or null (a square picture that replaces the emoji); backdrop flat|gradient|banner (what sits behind the page); glow none|soft|strong (how much the accent glows on what is active); title_font same|sans|serif|mono|rounded|display (names and headings); chat_style bubbles|rows (a group chat, or rows with avatars like a server); css (custom CSS, sanitised — see below); preset (the look it started from); inherited (a group inside another wears its parent's look until it has its own); light (whether the background is light). Defaults fill every missing value, so a client never sees a hole.

Write. PUT /api/v1/verses/{slug}/theme (publish, 403 without the moderate grant) with any subset of the fields above, or {preset: "coffee"} to start from a preset (trustplane, paper, coffee, forest, sunset, neon, slate), or {reset: true} to go back to the TrustPlane look. Fields not mentioned keep their value. Everything is normalised before it is stored: a colour that is not #rrggbb (#rgb and a missing # are accepted) is dropped, a value outside the vocabulary falls back to the default, words lose control characters and angle brackets and are cut to length. Nothing a founder types reaches a stylesheet as typed.

Custom CSS. css (≤ 8000 characters after sanitising, ≤ 200 rules) is for the founders who want every little detail. The registry keeps only what can change how things look: no at-rules (@import, @font-face, @media…), no url(), image-set() or src(), no expression(), javascript:, behavior, -moz-binding, no backslash escapes, no < or & — any of those refuses the whole thing with 422 and a reason. Of the rest, only known properties survive (colours, backgrounds without images, borders, radius, shadows, type, spacing, opacity, filters, transforms, transitions, display/flex, custom properties); every selector is kept as written and the app prefixes it with the group's page scope, so the CSS never reaches another group's page.

Pictures. POST /api/v1/verses/{slug}/banner (multipart banner, JPEG/PNG/WebP ≤ 8 MB; re-encoded to JPEG ≤ 2000 px, EXIF dropped, private disk) and POST …/icon (multipart icon; centre-cropped to a 512 px square) → 201 with the theme; DELETE …/banner / …/icon remove them; a colour change or a reset keeps them. GET /api/v1/verses/{slug}/banner and …/icon stream them to members and, for a public or unlisted group, to anyone; a hidden one's pictures are 404 to outsiders like everything else about it.

Events

An event belongs to the person who made it, is signed by no one, and can be answered by anyone with a name — the link that gets posted in a WhatsApp group. Pages, not API, for now.

  • GET /e/{id} — the page. Speaks the organiser's language unless the visitor picked one (?lang=en|ar). Carries Open Graph tags (the cover as og:image when there is one) and schema.org Event data. Unlisted events are noindex; listed ones that are still ahead are in the sitemap.
  • GET /e/{id}/calendar.ics — iCalendar (RFC 5545), times in UTC.
  • GET /e/{id}/cover.jpg — the cover, 404 without one.

An answer is going, maybe or declined, keyed by exactly one of: the account, a token in a guest's browser (stored hashed), or a WhatsApp voter reference. Who else is coming is shown to the organiser and to people who have answered, never to someone only holding the link.

The WhatsApp bridge

A separate process holds an organiser's linked WhatsApp session and speaks to this registry with two signed calls. It only ever calls out, so it runs behind NAT. Both answer 404 when no bridge secret is configured (TPSOCIAL_WHATSAPP_BRIDGE_SECRET).

Signing. Each request carries X-TpSocial-Bridge-Timestamp (unix seconds) and X-TpSocial-Bridge-Signature: hex HMAC-SHA256, keyed with the shared secret, over timestamp \n METHOD \n /path \n body. A timestamp more than five minutes off is refused, so a captured request cannot be replayed.

POST /api/v1/bridge/whatsapp/poll {limit?} → {data: [{id, account_id, type, payload, attempt}]}. Hands over waiting commands and marks them claimed. A claim with no report for five minutes is offered again — except members.invite, which never runs twice. Command types: link.start, link.logout, groups.sync, event.post {post_id, group_ref, text, poll: {name, options[3]}}, event.remind {post_id, group_ref, reply_to, text}, chat.send {group_ref, text}, mirror.announce {group_ref, mirror, text} (the bridge forwards messages only from groups last announced with mirror: true), members.invite {group_ref, text, pace_seconds: [min, max], daily_cap, skip_member_refs[]}.

POST /api/v1/bridge/whatsapp/reports {reports: [{type, account_id, data}]} (≤ 200) → {accepted, failed: [{index, error}]}. Each report is applied on its own and is safe to resend. Types: command.done {command_id, result} (for event.post, result.poll_message_id), command.failed {command_id, error}, command.progress {command_id, result}, link.qr {code, expires_in}, link.connected {push_name, number_hint}, link.logged_out, groups.synced {groups: [{ref, name, participants, is_admin}]}, poll.vote {poll_message_id, voter_ref, voter_name, selected: [option indexes]} (empty takes the vote back), chat.message {group_ref, sender_ref, sender_name, text, message_id, sent_at}. A report may only speak about commands and groups belonging to its account_id.

No phone numbers. Groups and people are named by references the bridge makes with its own HMAC key — group_ref, sender_ref, voter_ref, member_ref — never a JID, since older group JIDs embed the creator's number. The one exception is number_hint, of which only the last four digits are kept, so an organiser can tell which WhatsApp they linked.