for integrators one script tag, two GETs, nothing to store
You keep your content. attest keeps identity and the signed statements. Your page needs no server, no database and no user table. Everything below is live at ORIGIN.
1. The widget
<script type="module" src="ORIGIN/attest.js"></script> <div data-attest data-target="https://example.com/article" data-comments></div>
| attribute | meaning |
|---|---|
data-attest | mount a widget here: an upvote button with a live count |
data-target | what is being attested. Omit it and the page's <link rel="canonical"> is used, else the page URL without its fragment |
data-comments | also show signed comments and a composer |
Several widgets on one page are fine; they share one socket and one read. The first click on any widget starts sign-in. Colour comes from the CSS variable --attest-accent on any ancestor; everything else inherits your page's font and colours.
Sign-in from your origin opens a small popup on ORIGIN. The popup returns a session to your page by postMessage, scoped to your origin. Your page never sees the passkey and never holds a password. If your page runs inside a sandboxed iframe or blocks popups, sign-in cannot complete.
2. The JavaScript API
The widget is built on a small module you can use directly for your own UI. The same module is the npm package orbital-attest (not yet published; it lives in packages/orbital-attest in the repo): import * as A from "orbital-attest" then A.configure({ server: "ORIGIN" }). The served copy below is pre-configured for this origin. The verifier alone is orbital-attest/verify, with no dependencies.
import * as A from "ORIGIN/attest-core.js";
A.session() // {id, root, handle, until, delegation} or null
await A.signIn() // popup; resolves with the session
await A.attest("upvote", url) // sign with this page's device key and submit
await A.attest("comment", url, { body: "…" })
await A.attest("retract", url, { ref: recordId })
await A.attest("vouch", "did:key:z…")
await A.attest("statement", url, { body: "…" })
await A.read([url1, url2]) // counts + recent comments, keyed by target
await A.by(did) // everything a key signed
await A.subscribe([url]); A.onCounts(({ target, counts }) => …) // live
A.makeRecord(kind, target, extra) builds and signs without submitting, if you want to hold records and submit later.
3. Reads over HTTP
All GET, all public, all with Access-Control-Allow-Origin: *. Cacheable by a CDN (s-maxage); browsers revalidate.
| endpoint | returns |
|---|---|
GET /read?targets=a,b,c | {targets: {a: {target, upvotes, vouches, comments:[{id, by, handle, at, body}]}}}. Up to 100 targets. Comma-separate; URL-encode each target. |
GET /by/:did | {did, handle, since, keys:[{id, jwk, created}], delegations:[{id, device, origin, from, until, revoked}], counts, vouchedBy:[{by, handle, at}], vouches:[{target, handle, at}], proofs:[{id, target, verifications:[{by, handle, evidence}]}], records:[…]}. keys are the passkey public keys, for checking delegations yourself. |
GET /handle/:handle | the same, looked up by handle. /@handle is the human page. |
GET /record/:id | the full signed envelope {record, del, sig, id, retracted} |
GET /log?since=N&limit=500 | the append-only log from sequence N: {entries:[{seq, type:"record"|"delegation", id, received, …envelope}]}. Mirror by paging since. |
GET /domain/:host | public standing of a site: totals, most-attested pages with counts, recent comments, verified domain claims. /site/:host is the human page. |
GET /stats | account, record and log counts |
4. Writes over the socket
The client library does this for you. For your own client: one socket.io connection to ORIGIN, then emit("req", {name, payload}, ack); the ack is {ok, data} or {ok:false, error}.
| name | payload → data |
|---|---|
passkey.register.start | {handle} → {nonce, options} (WebAuthn creation options) |
passkey.register.finish | {nonce, response} → {did, handle} |
lookup | {handle} → {did, handle} |
delegate.start | {delegation} → {id, options} (WebAuthn request options whose challenge is the delegation's id) |
delegate.finish | {delegation, credentialId, assertion} → {id, root, handle, until} |
attest | {record, del, sig} → {id, counts, duplicate?}. Refused with "delegation revoked" once the root has revoked that delegation. |
root.start, root.finish | actions only the passkey may sign, on the service origin: {v:1, type:"revoke", root, del, at}, {type:"add-key", credentialId}, {type:"remove-key", credentialId}. Same shape as sign-in: the action's id is the WebAuthn challenge. |
passkey.add.start/finish | register a second passkey for an account; it is adopted when an existing passkey signs add-key |
proof.instructions, proof.check | {claim} → where to put the token attest-proof:<claim id>; then the service fetches and, if found, signs a verify record. Website: the URL itself or /.well-known/attest.txt; DNS: TXT at _attest.<domain>; GitHub: a public gist; Bluesky: the bio. |
subscribe | {targets}; the server then emits counts events {target, counts} |
whois, read | as the GETs |
Limits: 60 writes a minute per address, 30 per key. A duplicate upvote returns the existing id with duplicate: true. Records dated more than 10 minutes from server time are refused.
5. The record
record {v:1, type:"record", kind, by:"did:key:z…", target, at:"ISO-8601", body?, ref?}
envelope {record, del:"<delegation id>", sig:"<base64url ES256 over canonical(record)>"}
id hex sha256 of canonical(record)
kind is one of upvote, comment, vouch, statement, claim, retract, and verify. comment and statement need a body (under 4000 characters). vouch targets a key (a did:key), never your own. claim targets a handle or URL you say you control (https://…, dns:example.com, github:user, bsky:handle). retract needs ref, the id of the author's own record on the same target. verify is issued by a verifier, not submitted: it carries ref (the claim) and body (the evidence URL). One live upvote, vouch and claim per key per target.
A record signed by the service itself (today only verify) has no delegation: its envelope is {record, sig, key} and record.by equals didFromJwk(key). GET /service gives the service key's DID. Trust it as far as you trust the service; any other key may also issue verifications by hand.
target is normalised before signing, and the server refuses a record whose target is not in normal form. Forms: a URL (scheme and host lowercased, fragment dropped, empty path becomes /); doi:10.… lowercased; isbn:… without hyphens; a did:key:…; a service handle such as github:anselm; sha256:<hex> for content.
canonical JSON: object keys sorted at every depth, arrays kept in order, no whitespace, undefined dropped. The shared module ORIGIN/lib/did.js exports canonical, idOf, verifyObject, didFromJwk and normalizeTarget, and runs unchanged in browsers and Node.
6. Verifying without us
- Fetch the envelope (
/record/:id) or take it from the log. Checksha256(canonical(record)) === id. - Fetch the delegation by
delfrom the log. Checkdelegation.root === record.by, thatrecord.atlies withinfrom…until, and thatdidFromJwk(delegation.devKey) === delegation.device. Arevokeentry in the log naming that delegation means records received after it were refused; the log's order tells you which. - Verify
sigas ECDSA P-256 / SHA-256, rawr||s, overcanonical(record), withdelegation.devKey. - To go one step further, verify the WebAuthn assertion stored with the delegation against the root's passkey public key (
/by/:did→keys): its challenge must equal the delegation's id as base64url bytes.
The end-to-end test in the repo does steps 1 to 3 from Node against the live service.
7. A static site, concretely
A page on a CDN adds the script tag and a widget. Sign-in opens the popup; the session is stored in the page's own localStorage, the device key in its own IndexedDB. Counts render from /read; the reader's own votes from /by. Your build does not change and your host learns nothing new. If attest is down, counts fall back to whatever your page last cached, and nothing else on the page is affected.
Source and issues: github.com/orbitalfoundation/attest (MIT). Machine-readable summary: /llms.txt. Design: technical.