Identity, authentication & permissions system - #264
Conversation
Design doc for library-wide identity, key-exchange verification, and permission-gated behaviors, integrating the extension's ECDSA identity and anonymous local keypairs. Force-added from gitignored internal-docs/ so it survives this review branch; not intended to merge into main. https://claude.ai/code/session_0142ckc16naGoBJxK3KbfNyo
Implements the auth/permissions design spec across the stack: - common: shared auth contract — pk_ key format, ECDSA P-256 sign/verify helpers, challenge payload binding (nonce|room|origin|ts), permission rule/role types and pure evaluation, well-known config sanitizer, and wire message types. - playhtml: real local keypair for anonymous users (non-extractable CryptoKey in IndexedDB) upgrading the legacy random-hex identity; permissions init config + permissions attribute; synchronous can(); playhtml.me / verify(); permissiondenied events; identitychange / permissionschange events; client handshake with session-token resume; gated writes routed through the server (wait-for-server). - partykit: domain-bound config fetch from /.well-known/playhtml.json (cached in DO storage, 5 min TTL), challenge-response verification bound to room domain, sessions in DO storage, server-mediated gated writes with keyed-map entry rules (createdBy stamped and pinned), and an observe-and-correct backstop reverting unauthorized direct writes to gated elements. - extension: signs playhtml auth challenges via a content-script bridge; the background validates payload shape and origin against the sender tab before signing, so pages can only obtain origin-bound signatures. - react: useCan() hook; usePlayerIdentity() now reports verified+roles. - docs: advanced/permissions page; changeset (minor for playhtml, common, react). Note: anonymous users' pids change once when the fake random-hex publicKey is replaced by a real key (accepted in the design). Tests: playhtml 194 passed (incl. new permissions suite), partykit 97 passed (incl. new authPolicy suite), react 26 passed, extension 218 passed. Wrangler dry-run bundles cleanly. Root lint shows only the pre-existing website/ errors. https://claude.ai/code/session_0142ckc16naGoBJxK3KbfNyo
- elements map config: rules declared as { "site-title": "write:admin" }
using the permissions attribute's mini-language, accepted in init()
and .well-known/playhtml.json alike (rules array remains the
normalized low-level form).
- Raw pk_… keys work anywhere a role name does, so single-owner sites
need no roles config at all.
- Zero-config client path: server-pushed rules already drive can() and
setData gating; added dev warnings when client rules can't be
enforced server-side (CSS selectors) or have drifted from the
well-known file. CSS-selector patterns are dropped from well-known
configs with a warning instead of silently never matching.
- withSharedState/CanPlayElement accept a permissions prop (string or
object spec, rendered as the attribute) and pass an element-scoped
can() to the render callback; components re-render on identity/
permissions changes.
- can() accepts { entry } (reads createdBy); playhtml.me.owns(entry);
useCan() accepts an element or ref.
- playhtml.verify() returns Promise<boolean> (auth_ok/auth_error/
timeout); playhtml.onIdentityChange(cb) returns an unsubscribe.
- me.enforced moved to playhtml.permissionsEnforced (room property,
not a person property).
- Docs flipped to lead with the one-file well-known setup; changeset
updated.
Tests: playhtml 204, react 26, partykit 77 — all passing; worker
dry-run bundles cleanly.
https://claude.ai/code/session_0142ckc16naGoBJxK3KbfNyo
Testing: - smoke-tests/partykit/auth-permissions-smoke.mjs: full protocol E2E against a local wrangler dev server — serves a .well-known config, then asserts enforcement announcement, unverified rejection, challenge-response for two identities, write-gated elements, entry rules (createdBy stamping/pinning, creator-only update, admin delete), backstop reverting direct CRDT writes, non-gated elements untouched, and session-token resume (good + bad token). 19/19 checks pass locally. Run via `bun run smoke:partykit:auth`. - sanitizer now keeps roles-only well-known configs (UI role gating without gated elements), with test. Examples (rules in website/public/.well-known/playhtml.json, served by vite in dev and shipped with the site; admin pk is a placeholder until Spencer adds his pid): - /permissions — 'the locked room' playground: identity panel, verify() button, admin-gated title, creator-owned notes, live event log. Doubles as the manual test page. - /garden — community garden: one plot per pid (create:verified), water only your own plant (update:creator), uproot creator|admin. - /shop — the corner shop: single owner key gates the can-toggle sign and marquee via permissions attributes; the doorbell stays open. - walking-together: isAdmin() now accepts roles from usePlayerIdentity() and honors the key-based 'admin' role, keeping the legacy name+color match as a fallback until the pk lands in the well-known file. Docs: 'Trying it locally' section with the dev-server + well-known flow and the smoke command. Website type-error count unchanged (13 pre-existing); all suites pass (playhtml 204, react 26, partykit 78, walking-together 26+2). https://claude.ai/code/session_0142ckc16naGoBJxK3KbfNyo
Roles can now be earned by showing up: a role defined as { "visits": N }
in the well-known config is held by any verified identity the server has
seen in the room on N distinct days. Visits are counted server-side
against the key (once per day bucket, idempotent within a day, persisted
in DO storage with LRU pruning), so the ladder can't be climbed by
clearing storage or hammering reconnects. The count is reported back in
auth_ok as stats.visitDays, exposed as playhtml.me.visitDays and via
usePlayerIdentity(), and feeds the same satisfiesRole() evaluation on
both client and server. Day-bucket size is configurable via
AUTH_VISIT_DAY_MS for tests.
The canonical example, /guestbook — 'the village guestbook' — uses the
full ladder: visitors (day 1) read, returning visitors (2 days) sign,
regulars (5 days) sweep up after others, and the keeper (owner pk) can
do anything including changing the rules, which is just editing
/.well-known/playhtml.json ('the deed'). The page renders your rung,
your server-attested visit count, and what unlocks next.
E2E smoke extended with the accrual scenario (compressed day buckets):
day-1 create rejected, day-2 create accepted, day-3 regular sweeps a
stranger's entry, returning stranger cannot sweep others'. All 26
checks pass against a local wrangler dev server. Unit coverage:
recordVisit/pruneVisitLog, sanitizer {visits:N} parsing, earned-role
satisfiesRole, visit-gated evaluateGatedWrite, and client-side ladder
resolution via setVisitDays.
https://claude.ai/code/session_0142ckc16naGoBJxK3KbfNyo
…days:N}
Replace the hardcoded { visits: N } earned-role condition with a generic
counter primitive: a role condition names a server-attested counter and a
minimum threshold, e.g. { days: 2 }. Two counters are built in — days
(distinct-day appearances, what visits was) and sessions (total verified
handshakes) — and the server reports totals in auth_ok as me.counters.
This keeps the earned-standing mechanic (returning/regular ladders) without
baking one metric into the wire format or persisted DO storage, so new
counters slot in later with no migration.
…ermissions-design-nk5tcl # Conflicts: # extension/src/entrypoints/content.ts # package.json # packages/playhtml/src/index.ts # packages/react/src/hooks.ts # packages/react/src/index.tsx # packages/react/src/utils.tsx # partykit/party.ts # smoke-tests/package.json
commit: |
Deploying playhtml with
|
| Latest commit: |
1a0deb2
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4ae7000e.playhtml.pages.dev |
| Branch Preview URL: | https://claude-library-auth-permissi.playhtml.pages.dev |
Deploying we-were-online-website with
|
| Latest commit: |
c06a6ff
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://74a151b3.we-were-online-website.pages.dev |
| Branch Preview URL: | https://claude-library-auth-permissi.we-were-online-website.pages.dev |
Code review
|
The `elements` map may now be keyed by room path so different pages get
different rules: `{ "/*": {...}, "/wall": {...}, "/blog/*": {...} }`. Path
keys match exactly or via a trailing-* prefix glob; "/*" (and path-less
rules) apply everywhere. When multiple paths match an element, the
most-specific path wins per element id (exact > glob > /*), ties break
first-in-array.
The flat form (`{ elements: { guestbook: "..." } }`) still works and means
"/*" — path-keying is opt-in, detected by any top-level key starting with
"/". A mixed map warns and falls back to flat. The low-level `rules` array
gains the same glob paths + ranking via the shared resolver.
Correctness fixes from code review: - Arm the gated-write backstop on every config path, not just fetch, so enforcement survives a Durable Object restart within the cache TTL. - Clear the stored session token on re-verification and reject auth_ok whose pid doesn't match the current identity, so an identity swap can't leave the client "verified" as a stale pid. - Revert (not adopt) direct writes to gated elements that have no authoritative snapshot yet, so an unverified client can't seed the baseline. - Normalize the client room path the same way room ids strip file extensions, so path-scoped rules match on .html pages. - Time out unacknowledged gated writes and surface a denial instead of dropping the edit silently when the socket is mid-reconnect. - Queue writes issued before permissions_status arrives and replay them once enforcement state is known, instead of racing the server backstop. - Bind the extension's challenge signature to the sending frame's URL. - Share an AUTH parser (parseAuthChallengePayload) so the extension no longer parses the challenge payload by hardcoded field index. Also stop tracking the internal-docs spec (kept on disk, gitignored per repo conventions).
- Gated writes to keyed-collection elements now carry explicit per-key ops (create/update/delete/replace) instead of a full snapshot, and the server applies each as a real Yjs Map mutation under GATED_WRITE_ORIGIN. Concurrent writes to different keys merge via the CRDT instead of clobbering — a stale client snapshot can no longer be read as deleting another client's entry. Batches are all-or-nothing: if any op is denied the whole write is rejected. - Permission-rule resolution now ranks by path specificity, then match specificity, so an exact element id beats a trailing-* glob at the same path (no longer key-order dependent). - Page-data channels are gated the same as elements: writes run can(), route through the gated-write path when server-enforced, and share the element-id rule namespace (a rule on "my-channel" gates the page-data channel).
Earned standing (server-attested { days: N } / { sessions: N } counters) is
pulled from the permissions beta — the storage model doesn't scale (a per-room
counter blob hits Cloudflare's 128 KiB value limit and would fail handshakes in
popular rooms) and the mechanism is too closed to commit to. Design notes for a
future doc-stored, unforgeable version are captured separately. Removes
CounterName/Counters/EarnedRoleCondition and the earned-role paths from the
shared contract, server visit-counting and DO storage, the client me.counters
surface, the React field, docs, changeset, and the guestbook's returning/regular
ladder (the guestbook still works via key-list + creator roles).
Also extracts the observe-and-revert backstop decision into a pure
planGatedReverts() and adds tests for it: a raw (non-server) write to a gated
element is reverted to its snapshot, or removed when no snapshot exists (never
adopted), with non-gated and path-scoped elements left untouched.
|
I checked the signing path here because of the private/public identity boundary issue from #286. The high-level shape is right: page code asks the content script for a signature, the content script asks background, background validates the The part I think we should change before merging is the extension key storage/API boundary:
That means the signing bridge is mostly doing the right thing, but the identity retrieval path still makes the raw private key grab-able/passable as normal data. I think the fix should be structural rather than relying on every caller to remember to sanitize:
So I would not remove browser signing. The extension still needs to sign secure messages. But we should remove the ability to fetch or pass around |
Code review
|
Identity, authentication & permissions for playhtml
Adds a full identity + auth + permissions system to the library, integrated with the "we were online" extension.
What it does
pk_…pid — the same format the extension uses. Extension identities take precedence via the existingplayhtml:configure-identitybridge.permissionsinit option (and/.well-known/playhtml.json) declares roles and rules gating element writes. Checks are synchronous viaplayhtml.can()/ theuseCan()React hook;usePlayerIdentity()reportsverifiedandroles; denied writes fire apermissiondeniedevent./.well-known/playhtml.json, the partykit server enforces the rules: connections prove key ownership through a challenge–response handshake over the existing WebSocket (one signature per connection, session-token resume, zero per-message crypto), and writes to gated elements are server-mediated — including keyed-collection entry rules (create/update/deletewith a built-increatorrole and server-stampedcreatedBy). Sites that don't opt in pay no cost.Earned roles (experimental)
Roles can also be earned by showing up:
{ "days": N }grants a role to any verified identity the server has seen on N distinct days in the room (counted server-side, once per day — uninflatable by reconnecting or clearing storage).daysis one of the built-in server-attested counters;{ "sessions": N }works the same way. Totals are reported inplayhtml.me.counters.This started as a hardcoded
{ visits: N }and was generalized into a counter primitive so the earned-standing mechanic doesn't bake one metric into the wire format or persisted server storage — new counters can slot in later with no migration. Flagged experimental in the docs; the counter vocabulary may still change. Explicit key-list roles are stable.Examples
website/guestbook.html) — a returning/regular/keeper ladder demonstrating earned standing as client-UX flavor over server-enforced owner-key + moderator powers.smoke:partykit:auth).Testing
AUTH_DAY_MS).Notes