Skip to content

Identity, authentication & permissions system - #264

Open
spencerc99 wants to merge 18 commits into
mainfrom
claude/library-auth-permissions-design-nk5tcl
Open

Identity, authentication & permissions system#264
spencerc99 wants to merge 18 commits into
mainfrom
claude/library-auth-permissions-design-nk5tcl

Conversation

@spencerc99

Copy link
Copy Markdown
Owner

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

  • Stable identity for everyone. Anonymous users get a real ECDSA P-256 keypair (non-extractable private key in IndexedDB) whose public key is their pk_… pid — the same format the extension uses. Extension identities take precedence via the existing playhtml:configure-identity bridge.
  • Declarative permissions. A permissions init option (and /.well-known/playhtml.json) declares roles and rules gating element writes. Checks are synchronous via playhtml.can() / the useCan() React hook; usePlayerIdentity() reports verified and roles; denied writes fire a permissiondenied event.
  • Real server enforcement. When a domain publishes /.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/delete with a built-in creator role and server-stamped createdBy). 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). days is one of the built-in server-attested counters; { "sessions": N } works the same way. Totals are reported in playhtml.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

  • The village guestbook (website/guestbook.html) — a returning/regular/keeper ladder demonstrating earned standing as client-UX flavor over server-enforced owner-key + moderator powers.
  • Plus permission-gated example pages and an auth E2E smoke test (smoke:partykit:auth).

Testing

  • playhtml 346/346, partykit auth policy suite green, react auth tests green.
  • Node-based E2E smoke test exercises the full protocol against a live wrangler dev server: handshake, write-gating, entry ownership, backstop revert, session resume, and the earned-role ladder (time-compressed via AUTH_DAY_MS).

Notes

  • Existing anonymous users' pids change once when the legacy random-hex publicKey is upgraded to a real key (accepted in the design).
  • Docs: new Permissions page under Advanced.

claude and others added 11 commits June 9, 2026 21:19
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
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

bun add https://pkg.pr.new/@playhtml/common@264
bun add https://pkg.pr.new/playhtml@264
bun add https://pkg.pr.new/@playhtml/react@264

commit: f504ddf

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 3, 2026

Copy link
Copy Markdown

Deploying playhtml with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1a0deb2
Status: ✅  Deploy successful!
Preview URL: https://4ae7000e.playhtml.pages.dev
Branch Preview URL: https://claude-library-auth-permissi.playhtml.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying we-were-online-website with  Cloudflare Pages  Cloudflare Pages

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

View logs

@spencerc99

Copy link
Copy Markdown
Owner Author

Code review

  • P1 partykit/party.ts:2463 skips arming the raw-Yjs enforcement backstop when the well-known config comes from Durable Object storage. getPermissionsConfig() returns the fresh stored config directly at lines 2463-2465, but armGatedEnforcement(config) is only called on the fresh network-fetch path at lines 2473-2475. On a cold Durable Object instance with a still-fresh cached /.well-known/playhtml.json, the normal gated_write custom message path still checks permissions, but the play.observeDeep backstop that reverts direct Yjs writes is never attached. A client that bypasses the library and sends raw Yjs updates can therefore mutate gated elements until the cache expires and the fetch path happens to arm enforcement. Make getPermissionsConfig() arm enforcement before every non-null cached return, or centralize non-null config returns through an idempotent helper.

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

Copy link
Copy Markdown
Owner Author

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 playhtml-auth-v1 payload and binds payload.origin to sender.url, and the server verifies the same signed payload against the room domain. That lets the page get a session-specific signature without receiving the private key.

The part I think we should change before merging is the extension key storage/API boundary:

  • extension/src/entrypoints/background.ts generates the extension keypair as extractable, exports the private key as JWK, and stores it at playerIdentity.privateKey.
  • GET_PLAYER_IDENTITY returns the stored object wholesale.
  • extension/src/entrypoints/content.ts then dispatches that identity through playhtml:configure-identity, which can carry the raw JWK across the DOM bridge before the library sanitizes it.

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:

  1. Make the only shareable identity object playerIdentity.public / PlayerIdentity.
  2. Remove private key material from any runtime message, content-script state, popup/profile API, DOM event detail, presence payload, or synced/public identity object.
  3. Store extension private signing material separately, ideally as a non-extractable CryptoKey in extension IndexedDB rather than exported JWK in browser.storage.local.
  4. Keep background as the only signer via SIGN_PLAYHTML_CHALLENGE. That message should return only { signature } or { error }, after validating payload shape and origin.

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 privateKey as data; code should only be able to ask the background signer to sign a narrowly validated PlayHTML auth payload.

@spencerc99

Copy link
Copy Markdown
Owner Author

Code review

  • P1 partykit/auth.ts:494 misses brand-new gated data when a raw Yjs update changes a whole tag. collectChangedElementIds() returns null for a top-level tag-map change, but the null branch checks only Object.keys(snapshots). A malicious client can therefore add a new tag containing a rule-matched element that has no authoritative snapshot yet; its ID is absent from the candidate list, so the backstop neither removes nor restores it and the unauthorized value persists. When the changed set is unknown, enumerate current IDs under the changed/live tag maps as well as stored snapshots before applying the rule filter.

  • P1 partykit/party.ts:2775 stores only one authoritative tag snapshot per element ID. snapshots[elementId] is overwritten with a single { tag, data }, while the document legitimately stores the same ID under multiple capability tags (and now under the __page__ tag). After gated writes to two such tags, a raw write to the first tag causes the backstop to restore only the last tag snapshot, leaving the unauthorized first-tag value in place. Key snapshots and changed candidates by (tag, elementId), or store and restore every tag value for an element ID.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants