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>
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. 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.

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, 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/:handlethe same, looked up by handle. /@handle is the human page.
GET /record/:idthe full signed envelope {record, del, sig, id, retracted}
GET /log?since=N&limit=500the append-only log from sequence N: {entries:[{seq, type:"record"|"delegation", id, received, …envelope}]}. Mirror by paging since.
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}{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.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

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

  1. Fetch the envelope (/record/:id) or take it from the log. Check sha256(canonical(record)) === id.
  2. Fetch the delegation by del from the log. Check delegation.root === record.by, that record.at lies within fromuntil, and that didFromJwk(delegation.devKey) === delegation.device. A revoke entry in the log naming that delegation means records received after it were refused; the log's order tells you which.
  3. Verify sig as ECDSA P-256 / SHA-256, raw r||s, over canonical(record), with delegation.devKey.
  4. To go one step further, verify the WebAuthn assertion stored with the delegation against the root's passkey public key (/by/:didkeys): 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.