Skip to content

Remote public apps: any strut UI hosts another server's public app, one-tap payment in Peel - #625

Open
Evanfeenstra wants to merge 3 commits into
mainfrom
feature/remote-public-apps
Open

Evanfeenstra wants to merge 3 commits into
mainfrom
feature/remote-public-apps

Conversation

@Evanfeenstra

@Evanfeenstra Evanfeenstra commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Strut's half of "Peel opens strangers' public app pages", option A: the HOST page is the person's own strut UI (Peel's local strut), never the stranger's copy of it. Only the sandboxed frame is the stranger's. Peel's half follows in stakwork/peel, built against specs/WALLET.md §1.2.

The link (specs/API.md §17.9)

<your strut>/?app=<name>&public=1&from=<seller base>[&input=…][&theme=…]
  • from is read like a seller's (sellerOf): the origin and path, no trailing / nor /public, no credentials.
  • It must be https, or http to loopback (a development pair on one machine).
  • solo=1 is implied: the app shows alone, for anyone.
  • The seller must say it can be hosted this way: host: { remote: 1 } on its GET /public, new here. An older seller gets a page naming it ("… runs an older strut, whose public apps cannot be hosted from another strut yet"), never a bare "Failed to fetch". The store shows Open in your strut only for such a seller.
  • A from the UI does not open is a page that says why. It never shows this strut's app of the same name.
  • The app's info, document and POST /public/mcp all come from the seller, straight from the browser. Nothing is proxied through this strut.

What changed

The remote mode in AppHost and SoloApp.

  • The purse is per server. It is store-purse's, keyed by the seller, and never sent to another server.
  • Invoices are claimed at the seller until the invoice's DECODED end.
  • The page's links are read against the seller's UI. A link to another app there opens hosted HERE too (?app=<other>&public=1&from=<seller>), never by the seller's own UI.
  • Every page's ui/open-link opens http(s) and mailto alone, never a javascript: URL.

The frame is a stranger's. It runs on this strut's origin, so it gets:

  • scripts, a form's submit event, pointer lock, the clipboard, the gamepad and the wake lock;
  • no microphone, nothing its version declares, no download, no fullscreen;
  • the build's CSP written ahead of whatever the seller sent (strangerDocument).

It can still navigate itself away (a known limit, below).

The price is the sats. Every challenge is checked before it is shown or paid (web/src/challenge.ts):

  • the invoice is BOLT11, asks an amount and has not ended;
  • the macaroon is L402 and names the same payment hash;
  • strut_sats equals the challenge's sats, which equals the invoice's amount;
  • strut_credit equals the challenge's usd.

The seller writes both usd and strut_credit, so they agree with each other, not with the world. So:

  • the sheet headlines the invoice's sats;
  • it shows dollars only at the HOSTING strut's own rate, via a new GET /rate (any credential, a member's session included);
  • the seller's dollars appear only as the credit it says the payment buys, with a warning when that credit is worth under 90% of what is paid.

One tap in Peel (specs/WALLET.md §1.2, kit/src/wallet). This is a HOST-initiated purchase, distinct from an app's strut/wallet/* calls: no app identity, no connection, no budget.

  • host_capability is asked once per page, warmed when the relay connects. Ops it does not know are left out, never fatal. wallet is the name the sheet uses ("Pay with Peel").
  • buy_credit { purchase } → a Payment. Peel answers as soon as the person acts: pending once the payment is sent.
  • From then on the invoice is never shown again: a hostile seller holding the first payment could settle a second one.
  • The sheet waits on the claim and on buy_credit_status, polled every 3 s. A failed status brings the invoice back. After 60 s the sheet says the server may be holding the payment, and Close tells the page the credit is kept.
  • If nothing was sent (a decline, a refusal, a failure, no answer before the invoice ends): the QR with why, and Pay with ⟨wallet⟩. Its press is the fresh tap a native host that wants one is waiting for.
  • buy_credit_cancel takes Peel's sheet down when the page stops waiting. A late "sent" answer takes a QR code already shown down.
  • Peel never pays one payment hash twice.
  • A call pays at most once: a seller that asks again after a payment gets nothing more, and the page is told its payment is kept as credit.
  • New vectors: host_capabilities (8), credit_purchases (16), credit_replies (15).

Store: Open in your strut beside Open full page, for a seller that says host.remote.

Server fix. POST /public/mcp answers with a Response of its own, so the CORS headers the public middleware set never reached it. A host on another origin passed the preflight and then could not read the answer. The headers are now set again after the handler, and the preflight also allows mcp-method and mcp-name.

Tests

  • npm test: 4418 tests, 0 failures, 54 skipped (the live suites).
  • New pure tests:
    • the link's parse and build, and the page routing;
    • a remote page's app links staying in this host;
    • the challenge check, against src/l402.ts's minter, Go's macaroon fixtures and the BOLT11 encoder;
    • the purse keyed per server;
    • the price view: a large invoice made to look cheap is flagged;
    • one payment per call;
    • the native-wallet path: the per-page probe, paid, decline, RESTRICTED, failed, sent-then-unproven (no QR), status, the late answer, timeout with cancel, no bridge, an older host;
    • the relay loop end to end, counting the QR codes shown;
    • the stranger's frame;
    • the kit's new vectors and relay functions;
    • a regression test that /public/mcp's answer carries CORS.
  • Browser checks on two scratch struts (publisher :4411 with a priced public app and FakeWallet with BOLT11 invoices; host :4410):
    • QR path: claimed, the run succeeds.
    • One tap: no sheet left.
    • Decline, Cancel, then a phone payment: kept, and the next run spends it.
    • A hung wallet: Show the invoice sends buy_credit_cancel.
    • Reload, then pay: claimed.
    • A bad from: refused.
    • After the review fixes:
      • an older seller is named;
      • the sheet shows "500 sats ≈ $0.37 at mempool.space", the $0.50 only as credit;
      • a first RESTRICTED shows Pay with Peel, whose press goes to a paying stage with no QR, then buy_credit_status settles it and the run succeeds;
      • a capability with an extra unknown op still works.

Not tested: Peel itself, real sats, and the store's button in a browser (it needs an index with a live row; its link is unit-tested).

Known limit

A stranger's frame can navigate itself away, carrying whatever was typed into it. A browser cannot stop that; Peel's web view can, and WALLET.md §1.2 recommends it.

…nd pays with one tap in Peel

A public app's page is two layers: the host page (the purse, any native wallet
bridge) and the app in a sandboxed frame. Opened on the seller's own server,
the host is the seller's copy of strut's UI, which in Peel's web view would sit
beside the wallet bridge. Now any strut UI hosts it:

  <your strut>/?app=<name>&public=1&from=<seller base>

- web/src/remote-app.ts: the link (from read like a seller's: https, or http to
  loopback), where the page's links lead (its own server), and the stranger's
  document with the build's CSP written ahead of it.
- AppHost / SoloApp / page.ts: the remote mode, alone (solo implied), its info,
  document and /public/mcp on the seller, a stranger's frame (no microphone,
  nothing its version declares), a refused link saying why.
- app-host.ts: one purse per server (store-purse's, keyed by the seller),
  every challenge checked before it is shown or paid (web/src/challenge.ts:
  one payment hash in the invoice and the macaroon, sats agreeing three ways,
  credit equal to the dollars), and in a native host the wallet asked first.
- The host's own purchase (specs/WALLET.md §1.2, kit/src/wallet):
  host_capability, buy_credit, buy_credit_cancel; CreditPurchase,
  creditPurchaseProblems, readCreditReply, probeHostWallet,
  buyCreditNatively; credit_purchases and credit_replies vectors.
- PaymentSheet: the wallet stage (with Show the invoice), a paying stage with
  no QR to pay twice from, the QR with why on any fallback.
- Store: Open in your strut beside Open full page.
- src/public-routes.ts: POST /public/mcp answers with a Response of its own, so
  the CORS headers never reached it; set again after the handler. The preflight
  also allows mcp-method and mcp-name.

Docs: specs/API.md §17.6 and §17.9, specs/WALLET.md §1.2 and §7, AGENTS.md,
kit/README.md.
…yment after a tap, an older seller named

Money:
- The sheet's price is the invoice's SATS; dollars only at the hosting strut's
  own rate (new GET /rate, any credential, a member's session included); the
  seller's usd only as the credit it says the payment buys, flagged when worth
  under 90% of what is paid (visitor-pay priceView). Peel is specced the same.
- Once the native wallet SENT the payment (pending, or settled unproven), the
  invoice is never shown again: a seller holding the first HTLC could settle a
  second. Peel answers pending as soon as the person taps; buy_credit_status
  (new op, polled every 3 s) says where it stands, a failure brings the
  invoice back; after 60 s the sheet says a server may hold it, and Close tells
  the page the credit is kept. A late "sent" answer takes a shown QR down.
- A call pays at most once (paidAgainResult).

Normal use:
- GET /public says host: { remote: 1 }; an older seller is named instead of
  "Failed to fetch", and the store's Open in your strut shows only for a
  seller that says it.
- hostWalletCapabilityOf drops an op it does not know instead of reading none;
  host_capabilities vectors. host_capability is asked once per page, warmed at
  connect (createHostWalletProbe), so no sheet waits on it.
- A refusal (no fresh tap) leaves the QR and Pay with <wallet>, a fresh tap.
- A remote page's link to another app on its server is hosted here too.

Edges:
- The claim keeper watches until the invoice's decoded end.
- A stranger's frame: no download, no fullscreen (nor orientation lock).
- Every page's ui/open-link opens http(s) and mailto alone.
- remote-app's comment no longer claims typed text stays in the frame.
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