Skip to content

Identity & permissions

playhtml gives every visitor a stable identity — a pk_… public key (their pid) backed by a real keypair — and lets you gate element writes behind rules. Identity works with zero setup; permissions take one static file.

  • Anonymous visitors get a keypair generated by the library on first visit: the private key lives in IndexedDB (non-extractable — scripts can use it, never copy it), the public key becomes their pid. It’s stable per browser until they clear site data.
  • “we were online” extension users carry one identity across every playhtml site. The extension injects its public key into the page; the private key never leaves the extension.

Read the local player anywhere:

playhtml.me.pid        // "pk_04a1…" — stable id
playhtml.me.verified   // completed the key handshake on this connection?
playhtml.me.roles      // resolved role names, e.g. ["admin"]
playhtml.me.owns(entry) // entry.createdBy === my pid?

const unsubscribe = playhtml.onIdentityChange((me) => { … });

React: usePlayerIdentity() returns { color, pid, name, verified, roles } and updates reactively.

Put your rules at https://your-domain/.well-known/playhtml.json:

{
  "roles": {
    "admin": ["pk_04a1…"]
  },
  "elements": {
    "/*": {
      "site-title": "write:admin"
    },
    "/wall": {
      "guestbook": "create:verified, update:creator, delete:creator|admin"
    },
    "/blog/*": {
      "comment-*": "create:verified, update:creator, delete:creator|admin"
    }
  }
}

That’s the whole setup. It’s just a static file — whoever controls the domain controls the rules, the same trust model as the site itself. The playhtml server reads it and:

  1. Verifies identities. On connect, the server issues a challenge; the client signs it with its key (the extension signs for extension identities). One signature per connection — no per-message crypto — and reconnects resume with a session token. Sync is never blocked.
  2. Mediates gated writes. Writes to rule-matched elements go through the server, which checks the verified pid and applies the data itself. Unauthorized direct writes to gated elements are reverted.
  3. Enforces entry ownership. For create/update/delete rules the element’s data must be a keyed map of object entries ({ [entryId]: { … } } — the shape playhtml recommends anyway). The server stamps each new entry’s createdBy with the verified pid and pins it on updates, so ownership can’t be forged.

The same rules arrive in the browser automatically, so can(), disabled buttons, and permissiondenied events all work without any init config.

elements is keyed by page path. Use:

  • "/wall" for one exact page.
  • "/blog/*" for a trailing-prefix glob; it matches /blog, /blog/post-1, and deeper paths.
  • "/*" for a site-wide default. If you leave it out, unlisted pages are open unless another rule matches them.

Element ids and page-data channel names share the same rule namespace. For example, { "elements": { "my-channel": "write:admin" } } gates writes to playhtml.createPageData("my-channel", …) the same way it gates an element with id="my-channel".

When multiple paths match the same element id, the most specific path wins and replaces the less specific rule for that element. Exact paths beat globs, longer globs beat shorter globs, and "/*" is the fallback. The replacement is per element, not per action: a /blog/post-1 rule for comment-* does not inherit delete from a broader /blog/* rule.

For small sites, the older flat form still works and means "/*":

{
  "elements": {
    "site-title": "write:admin",
    "guestbook": "create:verified, update:creator"
  }
}

action:role pairs, comma-separated; alternatives join with |.

  • Actions: write (replace the element’s data), and for keyed-map collections create / update / delete (per entry). Entry actions fall back to the write requirement when unspecified.
  • Roles: anyone (default), verified (proved key ownership), creator (the pid stamped on an entry at create time), any name you define under roles, or a raw pk_… key — so single-owner sites need no role definitions at all.
  • Targets: an element id (leading # optional) or a trailing-* glob (note-*).

The low-level rules array is still available as an escape hatch when you want to write normalized rule objects directly:

{
  "rules": [
    { "path": "/wall", "match": "site-title", "write": "admin" },
    { "path": "/blog/*", "match": "comment-*", "update": "creator" }
  ]
}

Named roles, when you want them, are explicit key lists:

{
  "roles": {
    "admin": ["pk_04a1…", "pk_9bb2…"]
  },
  "elements": {
    "/*": {
      "site-title": "write:admin"
    },
    "/guestbook": {
      "guestbook": "create:verified, update:creator, delete:creator|admin"
    }
  }
}

The same spec works per element in HTML, and in init() for client-side-only gating:

<h1 id="site-title" can-move permissions="write:pk_04a1…">…</h1>
playhtml.init({
  permissions: {
    roles: { regular: ({ name }) => name !== undefined },  // condition fn — client-only
    elements: {
      "#site-title":  "write:pk_04a1…",
      "[data-note]":  "write:regular",   // CSS selectors — client-only
    },
  },
});

Init-config rules and condition roles run in the browser only — they shape honest users’ experience but can’t stop someone driving the console. When a room has server enforcement, playhtml warns in the console about any client rule the server doesn’t know (CSS selectors, drifted config) so the gap is never silent. playhtml.permissionsEnforced tells you which mode the room is in.

Synchronous and cheap, everywhere:

playhtml.can("write", "#site-title");                 // boolean
playhtml.can("update", "#guestbook", { entry });      // reads entry.createdBy
await playhtml.verify();                              // re-run the handshake; resolves with outcome

React — a hook, plus can scoped to the element inside withSharedState:

const canEdit = useCan("write", "#site-title");       // also accepts an element or ref

const Guestbook = withSharedState(
  { defaultData: { }, permissions: "create:verified, update:creator" },
  ({ data, setData, can }) => (
    <div id="guestbook">
      {Object.entries(data).map(([id, entry]) => (
        <Note key={id} entry={entry} editable={can("update", { entry })} />
      ))}
      <button disabled={!can("create")} onClick={…}>add</button>
    </div>
  ),
);

When a write is denied, setData becomes a no-op and the element fires a permissiondenied CustomEvent (detail: { action, elementId, reason }) — listen to it to shake a lock icon or show a toast.

Four live examples on this site exercise the whole system (their rules live in /.well-known/playhtml.json):

  • /permissionsthe locked room: identity panel, an admin-gated title, creator-owned notes, and a live event log. The best place to watch the handshake and denials happen.
  • /guestbookthe village guestbook: visitors read, verified visitors sign, creators amend or remove their own entries, and the keeper’s key can moderate the room.
  • /gardencommunity garden: claim a plot (one per pid), water only your own plant (update:creator).
  • /shopthe corner shop: a single owner key gates the sign and marquee; the doorbell stays open to everyone.

To run them with real enforcement locally:

bun dev          # vite — serves the pages AND /.well-known/playhtml.json
bun dev-server   # local partykit — fetches the well-known file from vite

Then open two browsers on a page and put one browser’s pid (playhtml.me.pid) into the admin role of website/public/.well-known/playhtml.json.

The end-to-end protocol suite (handshake, gated writes, entry ownership, backstop, session resume) runs headlessly against a local server:

SUPABASE_URL=http://127.0.0.1:9 SUPABASE_KEY=bad ADMIN_TOKEN=dev \
  bunx wrangler dev --config partykit/wrangler.jsonc --port 1999 \
  --var SUPABASE_LOAD_TIMEOUT_MS:200 &
bun run smoke:partykit:auth
  • Writes are gated; reads are not. Everyone in a room can still read all of its data.
  • verified ≠ trusted. Anyone can mint a fresh key. Verification means attributable (“the same keyholder as before”), not scarce — use explicit keys or key-list roles for privileged access.
  • Gated writes wait for the server (one round trip) instead of applying optimistically; gate sparingly — non-gated elements are completely unaffected.
  • An entry-only rule (update:creator) doesn’t restrict element-level can("write") client-side: write gates replacing the element’s data, entry actions gate individual entries, and entry rules don’t imply element rules. The server still checks every entry change.
  • Clearing browser storage discards an anonymous identity; there is no recovery. Extension identities survive (and can be backed up by the extension).