Publish broker: self-hosted Share to Bluesky via hosted-origin popup#38
Conversation
…origin popup Self-hosted origins (LAN, Tailscale, plain HTTP) can't complete AT OAuth: the client_id must be a publicly-fetchable HTTPS doc and web redirect URIs must live on its origin (#37). Rather than hand tokens across origins (defeats DPoP) or hold sessions server-side (custody we promised never to take), route the PUBLISH to the SESSION: self-hosted page ──window.open──▶ my.pantryhost.app/publish-broker │◀── ready ──│──── request (record + photo Blobs) ────▶│ │ │ user reviews + confirms IN the popup │──▶ PDS │◀──────────────── receipts (targetOrigin-locked) ─────│ Security invariants (documented in shared/atproto-broker.ts): tokens/DPoP keys never leave the broker origin; one request per popup lifetime (no payload swap after the confirm renders); the requesting origin is displayed prominently and the confirm click happens inside the popup (real URL bar — no clickjacking); replies use an explicit targetOrigin; no write without a user gesture in the trusted origin. Records still travel browser → PDS — Pantry Host infrastructure stores nothing. - shared/atproto-broker.ts: protocol types, publishViaBroker (popup opens synchronously on the click; the payload may be a Promise so photo-Blob collection finishes while the popup loads; ready→request handshake replays across OAuth redirects), shouldUseBroker origin detection, collectPhotoBlobs (photos are fetched on the requester origin — its /uploads are unreachable from the broker — and structured-cloned across) - web /publish-broker: review UI (origin callout, exact record JSON, Reuse/Publish child plan for menus, dry-run badge), sign-in via the existing OAuth modal, publish/unpublish, sr-only live region + heading focus - PublishToBlueskyButton: on broker origins the Share/Re-publish/ Unpublish actions open the popup instead of local modals; returned receipts (incl. per-child for menus) feed the existing receipt store so the CTA flips and Reuse works; stale-receipt reconcile now also runs without a local session (recordExists is a public read) Verified in-browser: direct-load explainer, request accepted with origin + JSON rendered, payload-swap ignored, cancel replies {ok:false,'cancelled'}, live region announced, focus managed; both tiers typecheck at baseline; web build passes. A real signed-in publish through the popup needs a human OAuth session — noted in the PR for manual verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
pantry-host-marketing | 4906091 | Jul 13 2026, 12:44 AM |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
pantry-host-web | 4906091 | Commit Preview URL Branch Preview URL |
Jul 13 2026, 12:45 AM |
collectPhotoBlobs used bare fetch(), which can't read the web PWA's opfs:// photo URLs — broker publishes from the web tier would have silently dropped uploaded photos (the same gap fetchPhoto already plugs for direct publishes). The button now passes its fetchPhoto prop through, so the web pages' OPFS-aware resolver applies in broker mode too; the app tier keeps plain fetch for /uploads paths. Verified in-browser: an opfs:// URL resolves through a custom fetcher and is skipped gracefully without one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a fix caught while walking through manual verification: |
The defensive spelling import.meta?.env?.VITE_X defeats Vite's env replacement — Vite only recognizes the exact import.meta.env.VITE_X token sequence — so VITE_ATPROTO_PUBLISH_DRY_RUN, VITE_ATPROTO_CLIENT_ID, and VITE_ATPROTO_BROKER_URL silently never resolved on the web tier (they always fell through to defaults). The optional chaining was redundant armor anyway: every read site already sits in a try/catch that handles non-Vite bundlers, where import.meta.env being undefined throws and is caught. Found live: the broker-rehearsal env override was ignored until the token was fixed; verified resolving afterward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rehearsal finding: the PDS auth pages (bsky.social) send Cross-Origin-Opener-Policy headers, so navigating the broker popup through signInRedirect permanently severs window.opener. On return the broker thought it was orphaned, the in-memory request was gone, and the requester saw popup.closed and settled "cancelled". Fix: the broker page never navigates. BlueskyAuth grows signInPopup() (BrowserOAuthClient.signInPopup — OAuth in a nested popup, session handed back over BroadcastChannel, which is same-origin and COOP-proof), BlueskySignInModal grows mode="popup", and PublishBrokerPage uses it. The callback page's LOGIN_CONTINUED_IN_PARENT_WINDOW sentinel is treated as success-and-closing, not an init failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.bg-body / .bg-body-translucent are hand-rolled utilities that only existed in packages/app/styles/globals.css, but the shared Modal component uses them for its panel and overlay. On the web tier the panel rendered fully transparent. Mirror both utilities into the web globals (tier parity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rehearsal finding: after flipping dry-run off, the CTA still showed "View on Bluesky" (with the Dry badge) because the published-state render was gated on the raw receipt. The usableReceipt rule already protected reuse/rkey/unpublish — apply it to the display state too, and have the reconcile effect purge leftover dry receipts outright in real mode (their DRYRUN- records exist on no PDS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rehearsal results — real signed-in publish through the broker ✅Full dual-origin rehearsal completed (requester
Also fixed along the way:
Known gap (out of scope, follow-up queued): photos sourced from 🤖 Generated with Claude Code |
Vite defaults to [::1] only; the broker rehearsal needs the same server reachable at both localhost (requester origin) and 127.0.0.1 (broker origin, loopback OAuth), and phone testing over LAN benefits too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… was broken (#41) BrowserOAuthClient.load() fetches the client_id URL from the browser, which is cross-origin from my.pantryhost.app. pantryhost.app served the metadata without Access-Control-Allow-Origin, so hosted-origin Bluesky sign-in failed with "Failed to fetch" before OAuth even started — every successful sign-in to date happened on loopback, where the client is constructed locally. Exposed by the publish-broker rollout (#38), which makes my.pantryhost.app the sign-in origin for self-hosted publishers. Workers static assets honor public/_headers (verified via wrangler dev: ACAO * present on the response). Public document, no credentials. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Implements track C of #37. Self-hosted origins (LAN IP, Tailscale, plain HTTP) can't complete AT OAuth — the
client_idmust be a publicly-fetchable HTTPS document and web-client redirect URIs must live on its origin. Instead of moving the session to the self-hosted origin (token hand-off defeats DPoP; server-side relay makes us a credential custodian, which SOVEREIGNTY.md forbids), this moves the publish request to the session:Security invariants (encoded in
shared/atproto-broker.tsdoc comments)ph-broker:requestwins; later messages are ignored, so the payload can't be swapped after the confirm UI renders. (verified)targetOrigin(the captured requester origin), never'*'. The ready ping carries no data.Records still travel browser → PDS; Pantry Host infrastructure stores nothing. The honest trade-off: self-hosted publishing gains an availability/trust dependency on the hosted origin — the fully-sovereign paths (own public domain, app passwords) remain #37 tracks A/B.
What's in it
shared/atproto-broker.ts— protocol +publishViaBroker()(popup opens synchronously on the click tick; the payload may be aPromiseso photo-Blob collection finishes while the popup loads; the ready→request handshake replays if the popup bounces through OAuth sign-in),shouldUseBroker()origin detection,collectPhotoBlobs()(photos fetched on the requester origin, where/uploadsis same-origin — incidentally sidesteps the external-photo CORS gap for broker publishes when the requester can reach the image)./publish-broker— review UI: origin callout, exact record JSON (same honesty rule as the preview modal), Reuse/Publish child plan for menus, dry-run badge, sign-in via the existing OAuth modal, publish + unpublish, persistent sr-only live region + heading focus (WCAG 4.1.3).PublishToBlueskyButton— on broker origins, Share/Re-publish/Unpublish open the popup instead of local modals; returned receipts (including per-child receipts for menu publishes) feed the existing receipt store so the CTA flips and future menus Reuse; the Cross-device publish state: deterministic rkeys + PDS reconcile #36 stale-receipt reconcile now also runs without a local session (recordExistsis a public read). Deterministic rkeys (Cross-device publish state: deterministic rkeys + PDS reconcile #36) make broker re-publishes idempotent.Verified
{ok:false,error:'cancelled'}, live region announces, focus lands on the headingvite buildpassesNeeds a human before merge
Closes the practical gap in #37 for most self-hosters; tracks A (own-domain OAuth) and B (app passwords) stay open there for full sovereignty.
🤖 Generated with Claude Code