Skip to content

Publish broker: self-hosted Share to Bluesky via hosted-origin popup#38

Merged
jpdevries merged 7 commits into
mainfrom
feature/publish-broker
Jul 13, 2026
Merged

Publish broker: self-hosted Share to Bluesky via hosted-origin popup#38
jpdevries merged 7 commits into
mainfrom
feature/publish-broker

Conversation

@jpdevries

Copy link
Copy Markdown
Owner

Implements track C of #37. Self-hosted origins (LAN IP, Tailscale, plain HTTP) can't complete AT OAuth — the client_id must 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:

self-hosted page ──window.open──▶ my.pantryhost.app/publish-broker
     │◀───── ph-broker:ready ─────│   (replays across OAuth redirects)
     │────── ph-broker:request ──▶│   (record + photo Blobs, structured clone)
     │                            │   user reviews + CONFIRMS in the popup
     │                            │──▶ PDS  (browser-side putRecord)
     │◀───── ph-broker:result ────│   (receipts, targetOrigin-locked)

Security invariants (encoded in shared/atproto-broker.ts doc comments)

  • Tokens and DPoP keys never leave the broker origin. Only record payloads go in; only receipts come out.
  • One request per popup lifetime — the first ph-broker:request wins; later messages are ignored, so the payload can't be swapped after the confirm UI renders. (verified)
  • The confirm click happens inside the popup — a real window with a real URL bar (no clickjacking), with the requesting origin displayed prominently.
  • Replies use an explicit targetOrigin (the captured requester origin), never '*'. The ready ping carries no data.
  • No write without a user gesture in the trusted origin.

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 a Promise so 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 /uploads is same-origin — incidentally sidesteps the external-photo CORS gap for broker publishes when the requester can reach the image).
  • web /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 (recordExists is a public read). Deterministic rkeys (Cross-device publish state: deterministic rkeys + PDS reconcile #36) make broker re-publishes idempotent.

Verified

  • Browser-driven protocol test: direct-load explainer, request accepted (origin + title + dry badge + record JSON rendered), payload-swap ignored, cancel replies {ok:false,error:'cancelled'}, live region announces, focus lands on the heading
  • Both tiers typecheck at baseline (web 50 / app 119); web vite build passes

Needs a human before merge

  • A real signed-in publish through the popup (OAuth session can't be driven headlessly). Suggested test once this deploys to my.pantryhost.app: open the Mini over Tailscale, Share to Bluesky → popup → confirm → check the record and that the Mini's CTA flips to "View on Bluesky".
  • iOS Safari popup behavior is the usual wildcard — the popup opens synchronously on the click, but worth a phone test.

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

…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>
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
pantry-host-marketing 4906091 Jul 13 2026, 12:44 AM

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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>
@jpdevries

Copy link
Copy Markdown
Owner Author

Pushed a fix caught while walking through manual verification: collectPhotoBlobs now accepts the caller's fetchPhoto resolver, so the web tier's opfs:// photos survive broker publishes instead of being silently dropped (bare fetch() can't read OPFS URLs). Verified: custom resolver collects the Blob; without one the URL is skipped gracefully, never blocking the publish.

jpdevries and others added 4 commits July 12, 2026 16:25
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>
@jpdevries

Copy link
Copy Markdown
Owner Author

Rehearsal results — real signed-in publish through the broker ✅

Full dual-origin rehearsal completed (requester localhost:5174 / broker 127.0.0.1:5174, real Bluesky account, real PDS):

  • Fresh OAuth sign-in inside the broker popup — survives the round-trip. Rehearsal caught that bsky.social's COOP headers permanently sever window.opener when the broker navigates via signInRedirect; fixed in 2f880fa by switching the broker to signInPopup (nested popup + BroadcastChannel handoff — the broker page never navigates).
  • Real publish: record written at the deterministic rkey (local UUID), content + keywords + attribution.adaptedFrom intact, confirmed via public getRecord.
  • Idempotent re-publish: same URI, new CID, no duplicate (record count unchanged).
  • Unpublish: record 404s; unrelated records untouched.
  • Cancel + payload review + origin callout all behaved per the security invariants.

Also fixed along the way:

  • 3485e31 — shared Modal panel was transparent on the web tier (.bg-body utilities existed only in the app package).
  • de947a5 — leftover dry-run receipts rendered a published CTA in real mode; display state now obeys usableReceipt and stale dry receipts are purged.

Known gap (out of scope, follow-up queued): photos sourced from cdn.bsky.app publish photoless — the CDN sends no access-control-allow-origin, so both requester- and broker-side fetches fail CORS. Fix planned alongside adopt-own-records: derive did/cid from the CDN URL and fetch via the PDS's CORS-enabled com.atproto.sync.getBlob, or reuse the existing blob ref when publishing into the owning repo.

🤖 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>
@jpdevries jpdevries merged commit fb5d75a into main Jul 13, 2026
2 checks passed
jpdevries added a commit that referenced this pull request Jul 13, 2026
… 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>
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