Skip to content

Host-provided identity: show your app's users in the chat (0.2.0) - #8

Merged
macoeshka merged 15 commits into
developfrom
feature/host-identity-bridge
Aug 18, 2026
Merged

macoeshka merged 15 commits into
developfrom
feature/host-identity-bridge

Conversation

@macoeshka

@macoeshka macoeshka commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Base is main — this repo has no develop branch. Version bumped 0.1.6 → 0.2.0 (additive only); the CDN snippets in the README and integration guide now point at it too.

Lets a host app supply the display names and avatars for the wallets in its
chat, so the widget renders its own users instead of a .sol domain or a
shortened address. Visual and scoped to one running embed: Cherry persists none
of it, the wallet stays the author of every message, and the Cherry app is
unaffected.

Surface

The iframe pulls — only it knows which wallets are on screen:

const chat = new CherryEmbed({
  appId, container, roomId,

  resolveUsers: async (wallets) => { /* ≤50 at a time; null = "don't know" */ },
  searchUsers:  async ({ query, cursor, limit }) => { /* @mention autocomplete */ },
});

The host pushes what changes — an open chat would otherwise never notice a
rename, since the iframe only asks about wallets it hasn't resolved yet:

chat.setUserProfiles({ [wallet]: { displayName } });  // merged: avatar kept
chat.invalidateUserProfiles([wallet]);
chat.setIdentityToken(token);   // Authorization for the profile endpoint

Plus userProfiles in the config (right first paint, no round-trip) and
getUser, reserved for the profile view.

Test bench — example/host-identity/

One page, one small backend, and a checklist of what "working" looks like. It
covers both transports (page handlers vs. a profile endpoint the iframe calls
directly), a live rename, invalidateUserProfiles(), a null profile falling
back to the Cherry identity, @mention autocomplete over the app's own
directory, and a "Serve hostile profiles" button that answers with a bidi
override, an embedded newline, zero-width padding, 400 characters and a
javascript: avatar — so the sanitizer can be checked by eye.

Runs authenticated with no wallet extension: with APP_SECRET set it mints an
embedToken server-side, which is what makes it a one-command bench.

Verified end to end

Ran the bench against a full local stack (server + realtime + embed + traefik).
Every claim above was checked in a live chat, not just in unit tests: names and
avatars replaced, both transports answering, a live rename applying instantly,
the hostile payload rendering flattened and pictureless, and @mention
autocomplete finding directory users Cherry has never heard of — with the
picked wallet riding along invisibly, so mention routing still works.

Two bugs the live run caught, both fixed:

  • Bench: demo wallets were sliced out of a sha256 hex digest, so they
    weren't valid base58 — the invisible wallet tag on a picked mention went
    unrecognised and the address rendered as visible junk beside the name. A
    bench that lies about the feature is worse than none; the roster now
    base58-encodes the digest.
  • Runtime (cherrydotfun/messaging-server#313): pushing only a name wiped
    the avatar. users.update now merges field-wise — documented here in the
    method's JSDoc, the type, the README and the changelog.

Notes for review

  • Registering nothing keeps the previous behaviour exactly. Handlers are
    wired only when supplied, so an unregistered method gets METHOD_NOT_FOUND
    immediately — which the iframe reads as "this host cannot answer" and stops
    asking. A handler resolving empty instead would look like "nobody is known"
    and cache negatives for every wallet in the room.
  • A thrown handler surfaces as an error, not as an empty result — same reason.
  • setIdentityToken keeps the token in memory only; it is re-sent after an
    iframe reload alongside the profile map, exactly like the auth token.
  • Requires "Who your users appear as" on the embed at portal.cherry.fun
    (cherrydotfun/self-service-portal#270). Integrators who prefer answering from
    their backend configure a profile endpoint there and register no handlers at
    all — the better fit for mobile WebViews, where the host page is a thin shim.

Docs

  • integration_instructions.md: a section that applies to all three auth modes
    (auth decides who may post, this decides what their row says), both
    transports, push semantics, and the five ways it goes wrong in practice.
    Mode 1's "No host-side identity" bullet was flatly false and is fixed.
  • skills/cherry-embed-integration: the same ground as hard rules an agent can
    check, a Discovery question so the feature gets raised at all, and
    verification steps. Its jsDelivr pin was still 0.1.5 while every other file
    said 0.2.0.
  • docs/react-native.md: why mobile wants the profile endpoint rather than page
    handlers (resolveUsers is a function and the bridge config crosses as a JSON
    string, so it cannot travel), and which lines to add to the example host page
    if a rename has to be pushed from native.
  • README: links the reference page and the bench, and states the sanitizing
    rules, since a refused avatar or a truncated name reads as a bug otherwise.
  • Two doc comments no longer matched the shipped behaviour and were corrected:
    invalidateUserProfiles marks profiles stale rather than forgetting them (the
    name on screen stays while the fresh answer is in flight), and resolveUsers
    batching is a 16 ms window, not an animation frame.
  • Full reference on the portal: cherrydotfun/self-service-portal#272.

Checks

  • 260 tests green (14 new here, the rest from develop), tsc --noEmit clean, npm run build clean
  • README section + CHANGELOG entry for 0.2.0
  • CDN pins stay at 0.1.7, the published release. This branch had moved them to 0.2.0 before it exists; bumping them belongs in the commit that publishes to npm. package.json is 0.2.0 -- that is the package's own version, not a pin.

Merged develop (0.1.7: unread API, chatBubble launcher, painted iframe ground,
the bridge's sender-window check). Five of the six conflicts were two independent
additions in the same spot; the real one was Marketing.tsx, where develop
rewrote buildSnippet to return { code, shown, total } while this branch had
only touched the pin inside it — took develop's function and re-pinned in place.

Publish to npm only after the embed half is in production: cherrydotfun/messaging-server#313
and #316 are in develop, riding the release in cherrydotfun/messaging-server#318.
Until that ships, the new methods have nothing on the other side. The portal docs
(cherrydotfun/self-service-portal#272, merged) describe 0.2.0, so the npm release
is what closes that gap.

🤖 Generated with Claude Code

macoeshka and others added 2 commits August 14, 2026 13:01
Let a host app supply the display names and avatars for the wallets in its
chat, so the widget renders ITS users instead of a .sol domain or a
shortened address. Visual and scoped to one running embed: Cherry persists
none of it, the wallet stays the author of every message, and the Cherry
app is unaffected.

The iframe PULLS, because only it knows which wallets are on screen:

  resolveUsers — batch (<=50 wallets), null/omitted for anyone you don't know
  searchUsers  — @mention autocomplete over your own directory
  getUser      — reserved for the profile view

The host PUSHES what changes: setUserProfiles() (a rename is invisible to an
open chat otherwise — the iframe only asks about wallets it has not resolved
yet), invalidateUserProfiles(), and setIdentityToken() for the profile
endpoint's Authorization header (memory only, never persisted).
`userProfiles` in the config gets the first paint right with no round-trip.

Registering nothing keeps the previous behaviour exactly: the bridge answers
an unregistered method with METHOD_NOT_FOUND, which the iframe reads as "this
host cannot answer" and stops asking, instead of waiting out a timeout per
wallet.

Requires "Who your users appear as" on the embed at portal.cherry.fun.
Hosts that would rather answer from their backend configure a profile
endpoint there instead — no host-page handlers needed, which is the better
fit for mobile WebViews where the host page is a thin shim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A runnable bench for the feature: one page, one small backend, and a
checklist that says what "working" looks like.

It exercises both transports of the same contract — the page answering
resolveUsers/searchUsers over the bridge, and a profile endpoint the iframe
calls directly (the URL is configured per embed in the portal) — plus the
paths that are easy to get wrong and invisible in a unit test:

  - a live rename via setUserProfiles(), which is the only way an already
    open chat learns that a user changed their name;
  - invalidateUserProfiles(), and what a null profile does (that person
    keeps their Cherry identity and stops being asked about);
  - @mention autocomplete searching the app's own directory rather than the
    room's history;
  - a "hostile profiles" button that answers with a bidi override, an
    embedded newline, zero-width padding, 400 characters and a
    `javascript:` avatar — the chat must flatten, truncate and drop them.

The demo directory maps ANY wallet to a stable fake user, so the bench is
useful in a room full of strangers; avatars are SVGs served by the example
itself, so it works offline and doubles as proof that an ordinary
cross-origin image URL is all the embed needs. A call log shows what the
iframe asked for and which transport answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@macoeshka

Copy link
Copy Markdown
Contributor Author

Added example/host-identity/ — a runnable bench for this feature, plus the
checklist of what "working" looks like.

It covers both transports (page handlers vs. a profile endpoint the iframe calls
directly) and the paths a unit test can't show: a live rename through
setUserProfiles(), invalidateUserProfiles(), a null profile falling back to
the Cherry identity, and @mention autocomplete searching the app's own directory.

There is also a "Serve hostile profiles" button — it answers with a bidi
override, an embedded newline, zero-width padding, 400 characters and a
javascript: avatar, so the sanitizer can be checked by eye. The matching
assertion now lives on the runtime side in
cherrydotfun/messaging-server#313 (sanitize.test.ts), so the README's promise
and the code can't drift apart.

The demo directory maps any wallet to a stable fake user, so the bench stays
useful in a room full of strangers, and avatars are SVGs served by the example
itself — it works offline.

Verified locally: the page renders, /identity/resolve, /identity/search and
the avatar route all answer, and the call log fills in. The embed itself does not
mount yet against production — the runtime half of this feature isn't deployed —
so steps 3–6 of the README are what to run once messaging-server#313 is out.

macoeshka and others added 12 commits August 17, 2026 10:25
Follows the runtime fix in messaging-server: pushing a name no longer drops
the avatar. Says so where an integrator actually looks — the method's JSDoc,
the profile type, the README snippet and the changelog entry — including how
to clear a field (mention it empty) versus forget the wallet (push null).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bench is where this gets checked by hand, so the expectation belongs in
its checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bench previously only had the wallet-only path, which needs a browser
wallet extension — awkward for a bench whose whole point is to be runnable in
one command. With APP_SECRET set it now mints an embedToken server-side and
mounts with it, so the chat is authenticated with no wallet in the loop and
the identity overlay can be exercised immediately.

DEMO_VIEWER_WALLET says which wallet to sign in as. A real backend takes that
from its own session — the banner on /api/embed-token already spells this out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught by running the bench: picking a directory user from @mention
autocomplete rendered "@Maya_Duarte Demo2ea87…" — the full address next to the
name, in the message body.

Not a Cherry bug. The composer attaches the picked wallet as an invisible
`​<address>​` tag, and the readers only recognise it when the address
is valid base58 (no 0, O, I, l). The demo roster was minting wallets by
slicing a sha256 HEX digest, so the tag never matched its pattern and fell
through as visible text — a bench that lied about how the feature behaves.

The roster now base58-encodes the 32-byte digest: same shape and charset as a
Solana public key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
package.json moved to 0.2.0 with host-provided identity, but every copy-paste
snippet still pinned 0.1.5 — a version that predates even the last release, so
an integrator following the README would load a bundle without the new
handlers and wonder why nothing resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the bench: the name shown for a chat participant was also a row
in the directory list, which reads as "the overlay resolved the wrong user".

It was the demo generator, not the runtime: 16 first names × 8 surnames is 128
combinations, and across ~27 shown people a duplicate is ~95% likely. Members
and the searchable directory now draw from two disjoint pools (16 × 16 each),
directory names are de-duplicated within the list, and a directory wallet
resolves to its DIRECTORY identity — otherwise someone picked from @mention
autocomplete got renamed on the next resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting a name meant pasting a wallet into one field and a name into another,
with no way to see who is even in the room. The bench now lists every wallet the
chat has asked about, one row each, with the backend's answer as the placeholder
and editable name / avatar-URL fields over it.

  - Apply pushes the row through setUserProfiles(); clearing one field drops
    just that field, clearing both is the same as pushing null.
  - Reset forgets the edit and re-asks the backend.
  - Edits live in localStorage and win inside resolveUsers, so a re-resolve or a
    reload doesn't quietly undo them — a bench you have to re-fill after every
    reload doesn't get used.
  - Add lets you name a wallet that hasn't spoken yet.

The avatar field warns about data:/blob: URLs. The embed refuses those, and
without the note the field looks broken: the name updates while the picture
silently doesn't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pasted image URL stretched the grid column and pushed the inputs past the
card's edge. The body column needs an explicit min-width: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ver HTTP

Reported from the bench: "back to normal and invalidate don't work". Both were
firing correctly — the answer just never changed, because `resolveUsers` checked
hand-typed profiles BEFORE hostile mode. One edit applied earlier (a wallet
renamed to "1111") permanently swallowed the diagnostic for that row, so the
probe, its undo, and every invalidate looked dead.

Priority is now hostile → hand-typed → backend. A diagnostic must not be
silently overridden by something you set ten minutes ago.

Two more things this exposed:

  - Hostile mode and the edits lived only in the page, so in HTTP mode — where
    the iframe calls the profile endpoint directly and never asks the page —
    every control would silently do nothing. Both are mirrored to the backend
    now (POST /api/bench/state) and the resolver applies the same priority.
  - The "http endpoint: answering" status counted the PAGE's own fetches to
    /identity/*, so it claimed the HTTP transport was live while bridge mode was
    running. Only an iframe-originated hit (it carries X-Cherry-App-Id) flips it
    now, and the log labels the two apart.

The hostile buttons also show which mode is active — with no state on screen,
"is the probe still on?" was unanswerable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no way to see the fallback the whole feature is defined against. The
demo directory answers for ANY wallet, so in normal operation a host name always
exists and Cherry's own identity — the `.sol` domain or shortened address — never
renders. The only route to it was turning the switch off in the portal, which
also stops the iframe from asking at all, so it proves less than it looks.

The bench now has three explicit answer modes: normal, "I know nobody" (null for
every wallet) and the hostile probe. What used to be a hostile on/off pair is one
selector with the active mode visible — "is the probe still on?" had no answer on
screen before.

Per-person the same thing already existed and stays: clear both fields in a row
and Apply pushes null for that wallet alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t it

In HTTP mode the iframe calls the profile endpoint directly and never asks the
page, so `resolveUsers` never runs — and the "Chat users" panel sat empty with
nothing to edit, which is the wrong half of the bench to lose exactly when
you're testing that transport.

The endpoint now records which wallets it was asked about, along with what the
directory would answer, and the page folds those into its roster. Edits and
answer modes were already mirrored to the backend, so the panel is fully live
either way.

README gains the local-vs-stage split: locally both sides are http and a plain
`http://localhost:<port>/identity` endpoint works, while a stage embed is served
over https and needs the bench exposed through a tunnel — otherwise the call is
blocked as mixed content and the transport looks broken for a reason that has
nothing to do with the feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README covered the feature; the guides an integrator (or an agent) actually
follows did not.

- integration_instructions.md: a section that applies to all three auth modes
  (auth decides who may post, this decides what their row says), both
  transports, push semantics, and the five ways it goes wrong in practice.
  Mode 1's "No host-side identity" bullet was flatly false and is fixed.
- skills/cherry-embed-integration: the same ground as hard rules an agent can
  check, a Discovery question so the feature gets raised at all, and
  verification steps. Its jsDelivr pin was still 0.1.5 while every other file
  said 0.2.0.
- docs/react-native.md: why mobile wants the profile endpoint rather than page
  handlers -- resolveUsers is a function and the bridge config crosses as a
  JSON string, so it cannot travel -- and which lines to add to the example
  host page if a rename has to be pushed from native.
- README: link the reference page and the bench, and state the sanitizing
  rules, since a refused avatar or a truncated name reads as a bug otherwise.

Two statements no longer matched the shipped behaviour:
- invalidateUserProfiles was documented as forgetting profiles. Since the
  invalidate fix it marks them stale: the name on screen stays while the fresh
  answer is in flight, which is the whole point of the change.
- resolveUsers batching is a 16 ms window, not an animation frame.
@macoeshka
macoeshka changed the base branch from main to develop August 18, 2026 21:01
develop released 0.1.7 (unread API for host pages, the chatBubble launcher,
the painted iframe ground, the bridge's sender-window check). Six conflicts,
five of them two independent additions landing in the same spot: the identity
types next to UnreadState, the users.auth/users.update mount commands next to
setVisibility, both README sections, both CHANGELOG entries, and 0.2.0 as the
minor bump on top of 0.1.7.

Marketing.tsx was the one real conflict: develop rewrote buildSnippet to
return { code, shown, total }, while this branch had only touched the CDN pin
inside it. Took develop's function and re-pinned in place.

CDN pins go back to a version that exists. This branch had moved them to
0.2.0, which is not published; develop had reverted them to 0.1.5, which the
0.1.7 release then made stale. Everything now points at 0.1.7 -- README,
integration_instructions, the skill, and the wallet-only snippet -- and the
bump belongs in the commit that publishes 0.2.0 to npm, not before it.
package.json stays 0.2.0: that is the package's own version, not a pin.

typecheck clean, 260 tests green, build clean.
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.

1 participant