for builders 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>
attributemeaning
data-attestmount a widget here: an upvote button with a live count
data-targetwhat is being attested. Omit it and the page's <link rel="canonical"> is used, else the page URL without its fragment
data-commentsalso 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. Where a popup cannot work (it was blocked, a home-screen web app, an in-app browser), the page itself goes to ORIGIN instead and comes back to the same address with the session in the URL fragment, which the library consumes at load. Mobile Safari allows a popup only when it is opened synchronously in the tap: the widget does that; for your own UI, open a blank window in your tap handler and pass it as signIn({popup}), or pass {mode:"redirect"} to skip the popup. A page inside a sandboxed iframe cannot complete sign-in either way.

Existing accounts. People with a Bluesky or other AT Protocol account sign in with their handle through AT Protocol OAuth, from the same popup. attest is a confidential client (/client-metadata.json, /jwks.json) asking only for repo:monster.attest.*, so it can publish attest records into their repository and nothing else. They still create a passkey, and records still carry its inline signature.

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, origin, salt} or null
A.own("sessions")                 // the signed-in person's own sign-ins, with their sites (private to them)
await A.signIn()                  // popup, or a redirect that comes back here; resolves with the session (popup case)
await A.signIn({ popup: w })      // w = window.open("about:blank", …) from your tap handler (Safari); null → redirect
await A.signIn({ mode: "redirect" })
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: recordUri })   // or A.retract(recordUri)
await A.attest("vouch", "did:plc:…")
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.

endpointreturns
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, 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/:handlethe same, looked up by handle. /@handle is the human page.
GET /record?uri=at://… or GET /record/:cidthe indexed record: the repo record under repoRecord, its uri, cid, the delegation id, and whether it was retracted
GET /delegation/:id?origin=…{id, root, handle, device, from, until, revoked, origin: "match" | "mismatch"}: how a site's server confirms a sign-in. The id is unguessable, so only a holder of the session can ask, and the answer never names the site. /by lists only delegations that have signed something public.
GET /log?since=N&limit=500the service's own log: delegations, revocations and passkey changes with their passkey assertions, which are not repository records. Records themselves are read from repositories or the firehose.
GET /domain/:hostpublic standing of a site: totals, most-attested pages with counts, recent comments, verified domain claims. /site/:host is the human page.
GET /statsaccount, 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}.

namepayload → data
passkey.register.start{handle} → {nonce, options} (WebAuthn creation options)
passkey.register.finish{nonce, response} → {did, handle}
lookup{handle} → {did, handle}
delegate.start{delegation, proof: {origin, salt}} → {id, options} (WebAuthn request options whose challenge is the delegation's id)
delegate.finish{delegation, proof, credentialId, assertion} → {id, root, handle, until}
attest{collection, rkey?, record, del} → {uri, id (cid), counts, duplicate?}. Refused with "delegation revoked" once the root has revoked that delegation.
retract{uri, at, del, sig} → deletes the repo record; sig is the device key over {type:"retract", uri, at}
root.start, root.finishactions 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/finishregister 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, readas 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

Since the fold (see changes), a record is an AT Protocol record in the person's own repository, in one of our lexicons, carrying the person's inline signature. The service writes it into the repo and indexes it; anyone can read it back from the repo without us.

collection   monster.attest.vote | comment | statement | vouch | claim   (verification is issued by verifiers)
record       {$type: collection, subject|target, text?, createdAt, signatures: [inline]}
inline       {$type: "app.certified.signature.defs#inline", key: "did:key:z…#z…", signature: {$bytes}}
signed       the 36-byte CIDv1 (dag-cbor, sha-256) of the record with `signatures` removed and
             $sig = {$type, key, repository: <the repo did>} inserted; ECDSA P-256, raw r||s, low-S
uri, cid     at://did:plc:…/monster.attest.vote/<rkey> and the record's CID, both returned on write

subject (vote, comment, statement) is a normalised URL, a doi: URI or a sha256: URI. target (claim) is https://origin/, dns:domain, github:user or bsky:handle. A vouch's subject is an account DID, never your own. Vote, vouch and claim use a deterministic record key derived from the subject, so there is one live record per author per subject; comments and statements get a time-ordered key. Retraction deletes the repo record; the client signs {type:"retract", uri, at} with the device key to authorise it.

The lexicons are in the repository under lexicons/monster/attest/ and validate with the AT Protocol lexicon tooling. The shared inline-signature shape is app.certified.signature.defs#inline, the same one Hypercerts and badge.blue use, so their verifiers can check our records.

6. Verifying without us

  1. Fetch the record from the repo: com.atproto.repo.getRecord on the PDS named in the DID document of did:plc:…, or from any relay. Or take it from our index at /record?uri=at://….
  2. Take the inline entry's key. Rebuild $sig = {$type, key, repository: <repo did>}, remove signatures, encode as DAG-CBOR, compute the CIDv1 bytes.
  3. Verify the raw ECDSA P-256 signature over those 36 bytes with the public key inside the did:key. orbital-attest/verify exports inlineVerify(record, repoDid), which does exactly this in browsers and Node.
  4. A delegation (v2) is {v:2, type:"delegation", root, device, devKey, originHash, from, until}, where originHash = idOf({origin, salt}) (sha-256 of the canonical JSON). The session holds origin and salt, so your server can check the delegation is for your site without asking us; nobody without the salt can tell which site it is for.
  5. To bind that device key to the person: the delegation in our log names it and carries the passkey assertion over the delegation's hash; the passkey public key is at /by/<did> → keys.

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.