Releases: OpenReceive/openreceive
Release list
OpenReceive v0.4.5
Release workflow fixes for the first Composer publication: isolated deploy keys
for the PHP engine and Laravel split repositories, an explicit bootstrap mode
before Packagist registration, and reproducible immutable tags on retries.
Python and PHP publish from this new tag; existing public tags stay unchanged.
Complete the WooCommerce integration files referenced by the previous commit:
native classic/block checkout, host-owned orders with durable payment attempts,
HPOS support, the Docker demo, integration checks, plugin packaging and guides.
The WordPress.org listing remains unsubmitted; this release does not submit it.
The local release gate runs Ruby, Python, PHP, .NET and ordered artifact checks
in up to four concurrent lanes after shared builds, with separate logs,
durations and a failing exit status if any lane fails. All existing checks are
retained. The .NET runner builds and tests in Docker without a host SDK.
The custom element restores the selected swap asset when recovering an active
payment attempt, so the existing swap instructions remain visible.
Shared HTTP golden placeholders keep the JS, Ruby, Python and PHP fixtures in
sync. All packages are versioned together at 0.4.5; the release gate rebuilds
the Node/Express, Fastify, Laravel, FastAPI, Django, static HTML, Next.js and
Rails demo assets against those packages. Live-wallet verification is reported
separately in the release results; no manual payment is part of this release.
OpenReceive v0.4.3
Four embedding fixes found mounting the drop-in in a 560px card on a
1280px page, plus a quieter server log while a payer waits.
The checkout lays itself out by its own width
Every responsive rule in the checkout used to be a viewport media query
(sm: / md: / lg: in orClasses, and vw-based QR widths). The
drop-in is an embedded component: the host decides its box, and that box is
routinely far narrower than the viewport. In a 560px card on a 1280px page
the md: two-column wallet list fired and split 560px into two 234px cards,
whose icon-and-name column collapsed to 1.6px — the 28px provider icon was
crushed to a sliver and the name truncated to nothing. The same page at a
700px viewport was perfect.
The root (orClasses.root) is now a CSS size query container
(container-type: inline-size, named openreceive), and every internal
breakpoint is a container query — @sm: / @md: / @lg: /
@min-[390px]: — at the SAME rem values as before (40rem, 48rem, 64rem;
set in styles.source.css, since Tailwind's container defaults are much
smaller). The QR and its caption size in cqw. A checkout mounted in a
sidebar, modal or split view lays out like a screen of that width. Container
queries and cqw have shipped in every browser since 2023; there is no
fallback and none is needed for a payments page.
What this asks of the host: give the root a width. It is block-level, so any
normal container does; as a flex or grid item it needs flex: 1 or
width: 100%, because an inline-size container cannot size itself from its
contents. A host that had widened its page so the two-column layout had room
(twotensors' 760px container) can shrink it again.
The pre-create screens are themed
The React "Creating checkout…" spinner and "Could not start checkout." error
screens rendered without the resolved data-theme, so both painted the
light palette on a dark host — a white flash on every checkout open, and an
all-white error screen. They now resolve the theme exactly as the checkout
does (lock, ancestor ThemeScope, stored choice, system) and render as the
checkout's own root, surface and padding included. The custom element
already did this.
The root pads its own surface
daisyUI paints the theme's base-100 on the checkout root, and the root had
zero padding: a visible colour slab with the QR, status copy and caption
flush against its edges, plain to see on any host whose background is not
exactly base-100. The root now pads and rounds itself (p-4,
rounded-box) on every stack, including the creating and error screens. A
host that draws its own card and wants no second surface sets
--root-bg: transparent on [data-openreceive-root], and a host that wants
a different inset overrides padding there. Both work because every rule in
the shipped stylesheet is :where()-wrapped and carries zero specificity —
a host's own selector, however plain, wins on the same property. That is now
a documented compatibility promise (docs/guides/frontend-checkout.md,
"Layout and surface").
The caption under the QR is money
112,128 sats / $87.0 US is now 112,128 sats / $87.00 USD. Fiat values
render at exactly two decimals with thousands grouping through exact bigint
math (formatFiatValue in the browser package's checkout formatter), so a
Ruby BigDecimal echo of 87.0 and a Node 87.00 print alike, and the
dollar carries its ISO code. The same formatter feeds the transaction
details' fiat row and the swap limit hints, which already had two decimals.
One line per status poll
payment.reconcile.completed fires on every status poll while a payer
waits. On Node it printed ten lines: the console logger passed its fields as
a second console.info argument, and Node breaks any object past ~70
characters across lines. createConsoleLogger and createAppConsoleLogger
now print one line per event with fields inline as key=value (strings with
whitespace JSON-quoted, nested values in single-line inspect form), and the
reconcile event's message is the decision itself — 1 pending,
1 settled, 2 pending, 3 pending of 4 attempts when a walk was truncated —
with zero-valued extras dropped and the two walk timestamps folded into one
window=from..until field. attempt_count, settled_count and
pending_count stay as structured fields. The Rails engine's line is the
same shape ([openreceive] payment.reconcile.completed: 1 pending attempt_count=1 window=…). A host that also wants Rails' own request
logging quiet for the poll route uses Rails' knobs (config.log_level,
Rails.logger.silence), which this gem does not touch.
Tests: the e2e suite gained embedded-layout.spec.ts (a 560px host box on a
1280px viewport — provider icons ≥ 24px, names visible, root a query
container with ≥ 16px padding, QR in cqw, tutorial modal still covering
the viewport, on React and the custom element); React unit tests pin
data-theme on both pre-create screens; checkout-format.test.mjs pins the
money formatter and the caption.
OpenReceive v0.4.2
The stylesheet no longer touches host elements
Every shipped styles.css (@openreceive/react, @openreceive/elements,
@openreceive/browser, and the @import forwarders in vue, svelte,
angular) is now scoped: each rule applies only on or under an element
carrying data-openreceive-root, which the React <Checkout> and
<ThemeToggle> and the Vue/Svelte/Angular shell stamp on themselves. Loading
the sheet into a host page changes nothing the library did not render.
Before, the sheet was one global Tailwind + daisyUI compile: the preflight
zeroed every element's margin and padding, h1–h6 lost their size, img
went block-level, :root received the theme variables, and daisyUI's .btn,
.card, .badge, .input, .modal repainted any host element sharing the
name. The cascade made it worse than specificity suggests — the sheet's
@layer blocks are declared at import position, and a later layer beats an
earlier one outright — so a Mantine host imported first lost the padding on
every SegmentedControl label ("Pay with crypto" clipped to "Pay with crypt").
A host that was, accidentally, relying on the leaked preflight or theme
variables must now bring its own. The one visible change inside the checkout:
its root now carries the preflight's html rules (the Tailwind font stack,
line-height: 1.5, tab-size: 4), matching what the custom element always
did in its shadow root, so a React checkout that used to inherit the host's
body font renders in the system font stack. Set font-family: inherit on
the root (className) to keep the host font. daisyUI's page scroll lock while
a provider tutorial is open no longer reaches <html>.
Custom markup styled from orClasses needs the marker on its own container,
together with the resolved data-theme — the palette starts at that root, not
at the page's :root. OPENRECEIVE_STYLE_ROOT_ATTRIBUTE /
OPENRECEIVE_STYLE_ROOT_SELECTOR are exported from
@openreceive/browser/headless, and the shell binding's rootAttributes
carries the marker unconditionally (CheckoutShellRootAttributes). Under
ThemeScope the checkout still leaves data-openreceive-theme to the scope
but mirrors the resolved data-theme on its own root; the React ThemeToggle
stamps both too, so it paints correctly on its own in a page header.
The transform is a postcss pass (tools/package/scope-styles.mjs) over the one
compile, applied to the file copies only — src/generated/compiled-styles.ts,
injected into the custom elements' shadow roots, keeps the unscoped preflight.
Pinned by a unit test, the package smoke (every selector of every shipped
sheet carries the marker; the shadow compile does not), and an e2e host page
whose layered and Bootstrap-ish styles compute identically with and without
the sheet.
The gems carry no CSS and release in lockstep; the four Buy a Button examples
run 0.4.2 from the workspace; the live wallet smoke was skipped.
OpenReceive v0.4.1
Tooling-only release: no package or gem changes its public API. It exists to
exercise the new gem publishing path end to end. The four Buy a Button examples
resolve the workspace packages by path and run 0.4.1; the live wallet smoke was
skipped.
Gems publish from CI without an OTP
.github/workflows/publish-gems.yml pushes openreceive, openreceive-server
and openreceive-rails on every v* tag through RubyGems Trusted Publishing:
the job trades a GitHub OIDC token for a 15-minute push-only key, so no
RubyGems credential is stored and no TOTP code is typed (a trusted-publisher key
satisfies rubygems_mfa_required). The rubygems GitHub environment gates the
job behind a required approval and admits only v* tags. check:workflows now
allows gem push in that one workflow and pins its environment, permissions
and container; the placeholder publish.yml is gone. tools/release/push-gems.sh
remains the manual, OTP-per-gem fallback.
Publishing trusts green CI
npm run release:publish no longer re-runs npm run test:ci when the CI and
Release Dry Run workflows are both green on the exact commit being published.
The 0.4.0 release ran the suite twice back to back on the same commit — once in
the tag's dry run, once locally — for no extra evidence. To make the pair a
faithful stand-in, release.yml gained the four test:ci steps that only the
weekly Demos lane used to run (check:demo-containers, the Rails example's
catalog check, build:demo, scan:client-bundles), and check:workflows pins
them. Without a green pair (no gh, a missing, failed or in-progress run, a
dirty worktree) the suite runs locally as before; --skip-tests still skips it
unconditionally.
OpenReceive v0.4.0
Payment icons ship inside the JavaScript
The eleven payment-method icons (btc, crypto, eth, lightning, ltc,
sol, trx, usdc, usdt, xmr, xrp) are now compiled into
@openreceive/browser — about 7 KB of SVG text generated from
src/assets/icons/*.svg by npm run generate:payment-icons (checked in CI).
No host has to copy, serve, or resolve an icon file any more, under any
bundler: no import.meta.url games, no copy plugin, no loader, no
assetBaseUrl for these. The custom element draws them inline in its shadow
root (role="img" with the tile's label); everything that carries a URL —
@openreceive/react, the display models, getPaymentMethodIcon /
getNetworkIcon / getSwapOptionIcon — gets the same icons as
percent-encoded data:image/svg+xml URIs.
New on @openreceive/browser/headless: paymentIconSvgs (the markup),
PaymentIconId, the getPaymentMethodIconId / getNetworkIconId /
getSwapOptionIconId twins of the URL getters, and
WizardRouteAssetDisplay.iconId — the key a headless renderer needs to draw
an icon inline the way the element does.
Behaviour change: the VALUES of paymentIconUrls (and of every icon
getter called without a resolver) change from file URLs to data: URIs.
Anything string-matching them for assets/icons/… breaks. A host resolver
or assetBaseUrl still wins over the packaged value, exactly as before, and
dist/assets/icons/*.svg plus the ./assets/* export keep shipping, so an
existing copy-and-serve setup keeps working — it is now only needed as the
escape hatch for a CSP img-src without data:. Inline SVG is confined to
the element's own shadow-root renderer and to these build-gated first-party
strings (the generator refuses <script>, on*= handlers,
<foreignObject>, <style>, <image> and external hrefs).
@openreceive/provider-data's wallet logos and pay tutorials are unchanged:
still files, still served through assetBaseUrl / resolveAssetUrl. The
demos' copy-openreceive-payment-icons-plugin.ts is renamed
copy-openreceive-provider-assets-plugin.ts and copies only those.
Release notes
All four Buy a Button examples (examples/buttons/server/node-express,
static-html-small-api, nextjs-fullstack, rails) were rebuilt against the
0.4.0 workspace packages through npm run build:demo in the release gate. The
Vite and webpack examples no longer copy @openreceive/browser's icon
directory; only @openreceive/provider-data's images are copied.
The live wallet smoke (npm run test:live) was NOT run for this release: it
needs a funded NWC connection.
OpenReceive v0.3.3
OpenReceive ships as agent skills
Two skills in the open SKILL.md format now travel with the library:
integrate-openreceive (stack detection, the three server objects, the
authorize/amountFor/onPaid contract, 409 semantics, the secret boundary,
scaffolding, and testing against a fake wallet — with the full per-stack agent
directions as references) and debug-openreceive-payment (doctor-first
triage: boot failures, request-time 403/404/409, settlement timing, swap
refunds, each symptom with its fix). They install from the repo
(npx skills add OpenReceive/openreceive, or Claude Code
/plugin marketplace add OpenReceive/openreceive via the new
.claude-plugin/marketplace.json), are discovered from the .agents/skills/
mirror, and ship inside every npm package and gem, so an agent working in an
app that already installed OpenReceive finds them with no network.
npm run generate:skills keeps the mirrors byte-identical (checked in CI),
and the release checklist now requires the skills to describe the released
API.
The site contract moves to v3 for the same audience: openreceive.org now
serves /llms.txt (generated here from the docs manifest), the normative
OpenAPI file verbatim at /openapi.yaml, an /agents page, and llms.txt v2
discovery tags (rel="describedby", markdown alternates) — so a browsing
agent can find the whole documentation surface from any page.
Error messages state the fix and link the doc
Every integrator-facing setup error now says what to do about itself and
links the guide that owns the answer — the boot/mount "requires amountFor /
onPaid / authorize / host" family, the composed-options error, the
rate-limiting conflicts, and the reconcile-gate requirement, in both engines.
The 403 for a denied reference now names the authorize hook and links the
authorization guide. Ruby reaches parity with the JS NWC messages: missing,
invalid, and spend-capable NWC_URI failures carry the same receive-only
framing and the get-a-code URL (the long-dormant NWC_CODE_HELP_URL constant
finally earns its keep), and the spend override is spelled =true in both
engines.
A missing migration also stops surfacing as a raw driver error. When the
openreceive_meta probe finds the table absent, both engines now raise a
configuration error naming the exact fix — npx openreceive scaffold payments --orm <yours> on Node, bin/rails generate openreceive:install
then db:migrate on Rails — instead of leaking no such table from the
first query. Any other probe failure (connection refused, permissions) keeps
the old silent tolerance, and a healed database is retried, not remembered
as broken.
openreceive doctor proves the integration, not just the env
The doctor used to stop at parsing NWC_URI. It now probes the wallet over
the relay — the same preflight boot runs — and reports receive-only or the
exact refusal; --db <sqlite file | postgres:// | mysql://> confirms the
openreceive_payments + openreceive_meta migration actually ran, and
--url http://localhost:3000 confirms the routes answer on the running app
(recognized by the router's own JSON 404, which no framework fallback
produces). Every failing line states its own fix and links the guide that
owns it. The default run still touches no database, and --offline keeps the
old fully-offline behavior; debug-report stays exit-0 and redacted.
Checkout children compose instead of replacing the payment UI
Passing anything as <Checkout> children — even the one-line order summary
the docs recommend — silently replaced the QR, the method tiles, and the
whole payment flow, so a first store's checkout could ship as a description
with no way to pay. Children now render above the shipped payment UI, in the
same position as the custom element's order slot, exactly as the docs
always said they did. A host that wants its own checkout builds on
useCheckout or @openreceive/browser/headless, not on children.
The host can lock the checkout theme
<Checkout theme="dark" | "light" | "system"> locks the theme: it wins over
the payer's stored preference (localStorage["openreceive.theme"]) and any
ancestor ThemeScope, and hides the toggle, so a checkout embedded in a page
that is always dark can never come up as a white card. ThemeScope and
useTheme accept the same theme option. The custom element's theme
attribute already behaved this way; the element wrappers do not forward it
yet (docs/internal/wrapper-parity.md tracks the gap).
themeToggle={false} now hides the control but still stamps data-theme —
hiding the toggle no longer renders an unstyled checkout.
The theme toggle is labeled by the action ("switch to dark mode"), not the
current state: a checkout stuck light on a dark page used to announce
"light mode" at the payer.
Release notes
All four Buy a Button examples (examples/buttons/server/node-express,
static-html-small-api, nextjs-fullstack, rails) were rebuilt against the
0.3.3 workspace packages through npm run build:demo in the release gate.
The live wallet smoke (npm run test:live) was NOT run for this release: it
needs a funded NWC connection.
This is a PATCH bump: nothing left the public API. The doctor flags, the
DoctorWalletClient seam, and the skills shipped inside every package are
additive; the checkout-children change makes the component do what its docs
always said; error-message rewrites keep every pinned wire message and code.
OpenReceive v0.3.2
An overpaid swap deposit is a refund, not a support ticket
A payer who sent more than deposit_amount landed on attention — terminal,
no form, "This payment needs support review." The provider does not treat it
that way: lightning-swap opens refund_required on MORE and takes a full
choice=REFUND for it, exactly as it does for LESS and EXPIRED. The
client was the only thing calling it an incident, and the payer's money sat
behind a sentence with no next step.
MORE / OVER / OVERPAID now map to refund_required in both engines, so
the deposit comes back through the same two-step form as every other
emergency. The refund is the WHOLE deposit — the payout is a fixed-amount
bolt11, so there is nothing to exchange a surplus into and choice=EXCHANGE
is not a path this client takes. The order stays unpaid and the payer can pay
again afterwards.
refund_reason gains overpaid and overpaid_and_late beside the three that
were there, with payer-facing copy for both. LIMIT, which the provider pairs
with LESS/MORE when a deposit falls outside the pair's limits, names no
reason of its own — it says nothing the payer can act on beyond the amount.
attention still holds what genuinely needs a human: choice=EXCHANGE, a
provider status we do not recognize, and the states OpenReceive itself sets.
Attempts already sitting in attention heal on their next status read, which
is the next time the payer opens the checkout.
The attention screen says what to do
Both renderers printed the same sentence twice — once as the heading detail,
once as the warning banner underneath it — and then hid the ids behind a
"Payment details" caret. The banner now carries the next step, the amounts
sent and required sit under it, and the deposit transaction and provider order
render open: on a screen whose whole remaining job is quoting an id to a
human, a disclosure triangle is the wrong shape. The demo shops stop rendering
a payable QR under an attention status for the same reason.
A payer can get back to a deposit that is already in flight
A stablecoin deposit has no account behind it and sends no email. The order's
reference is the whole of the payer's claim on it, and POST /checkouts/prepare
answers with the amount and the pay-in catalog and NO attempts — so a checkout
rebuilt from a reference alone opened on the payment-method grid. That is the
wrong screen for someone who was told to bookmark a refund.
resumePaymentHash (attribute resume-payment-hash) is a create-mode prop on
the React, element, Vue, Svelte and Angular checkouts: pass the payment hash
your application stored beside the order and the deposit — or its refund form —
comes back as the payer left it. A hash the server will not serve is
ignored, because a stale note in host storage is not a reason to put an error
in front of a payer who can still start a fresh payment; they land on the method
grid exactly as they would have.
The hash and not the asset, deliberately: POST /swaps/status addresses one
attempt and applies no reuse test, so it still answers for a deposit that
stopped being payable hours ago, while re-selecting the asset re-serves the
committed attempt only until it expires and then mints a second address.
Four additive names on @openreceive/browser (and /headless) for hosts
driving their own UI: requestSwapStatus reads one attempt by hash and rejects
with a status-carrying error; resumeSwapAttempt is the forgiving wrapper the
renderers share, folding the attempt into a prepared snapshot and returning the
snapshot unchanged on any failure; currentCheckoutUrl is the one definition of
"its URL" that the two refund screens copy; formatMethodNetworkDetail joins
network labels for a method tile. SwapDisplayModel gains resumable, the
boolean refundReturnLabel was already chosen from, so a UI can render the copy
button that sentence names without string-matching the label.
The guides say less
Thirteen guides were rewritten against what the packages actually export, and
the set shrank by roughly 1,800 lines while gaining a page. swap-refunds.md
is new and is the one that was missing: when refund_required happens, which
refund_reason means what, and — the part every stack got wrong — that a refund
form is only a promise if the payer can reach it again after closing the tab.
headless-checkout.md, storage.md, security.md and checkout-ux.md lost
the most; three internal notes (architecture.md, checkout-design.md,
scope-lock.md) now hold the reasoning that was scattered through them.
Every demo stack runs with no wallet, and every order has a URL
The checkout lives at /checkout/:reference on all four Buy a Button stacks,
which serve the SPA there, put the reference in the address bar the moment the
order exists, and take a pasted one back in from the catalog. The two custom-UI
stacks reopen the attempt with resumePaymentHash; the two that mount the
packaged checkout let the payer re-pick their coin.
DEMO_WALLET=testkit now boots the Rails stack too —
examples/buttons/server/rails/lib/button_shop/testkit/ is a port of
packages/js/testkit down to the fixtures, so one Playwright suite drives all
four with the same assertions. It fakes the wallet, the swap provider and the
price feed, and nothing else: the engine, the hooks, the migrations and Postgres
are the production paths. The /__testkit route is declared unconditionally and
404s unconditionally without the variable, which its own test asserts in an
environment that does not set it, and which npm run check:demo-containers
enforces against every compose file.
All four stacks — node-express, static-html-small-api, nextjs-fullstack
and rails — were rebuilt against the 0.3.2 packages, and the Playwright suite
runs against node-express. Of the gems, only openreceive-server changed;
openreceive and openreceive-rails are byte-identical to 0.3.1 and ship to
keep the one-version-for-everything rule.
Two release gates that were lying are fixed. release:prepare now refreshes the
Rails demo's Gemfile.lock alongside package-lock.json — the v0.3.1 tag's CI
died at bundle install with exit 16 because the three VERSION constants moved
and a lockfile pinning them by path did not, and local test:ci never sees it
because nothing here sets frozen mode. And Release Dry Run, which had never
passed on either tag it ever ran for, now builds the packages before smoking
them: @openreceive/angular resolves @openreceive/elements/wrapper-shared to
a .d.ts that exists only once elements is built, and a fresh checkout with no
build step cannot. ci.yml hid it by running npm test — and so pretest:js —
first.
The live wallet smoke (npm run test:live) was NOT run for this release: it
needs a funded NWC connection.
This is a PATCH bump: nothing left the public API. resumePaymentHash and the
four new browser exports are additive, and the swap-status mapping change moves
attempts out of a terminal state into a recoverable one.
OpenReceive v0.3.1
Three changes where a component or an engine was guessing about the host, and
answering wrongly in the safe-looking direction: a swap panel telling payers to
bookmark a page that would not bring them back, a console warning that fired at
correct integrations, and a Rails app serving checkouts through the generated
allow-all placeholder without ever saying so. Around them, the demo is a new
shop on four stacks, and CI is running again.
The swap refund-return warning stops guessing
SwapDisplayModel.refundReturnLabel picks one of two warnings, and the wrong
one strands money: a payer who is told to come back to a page that has no route
to it cannot reach a swap deposit's refund. The component was inferring the
answer from syncUrl / routeReference, which only knows about URLs the
component itself writes.
resumable is now an explicit prop on <Checkout> and PaymentWizard — React,
Vue, Svelte, Angular, @openreceive/browser and the custom elements. Unset, it
is still inferred from syncUrl / routeReference; on the React wizard it
defaults to false, so the safe copy is what a host gets by saying nothing. Set
it when the host owns a per-order route the component cannot see, which is most
snapshot-mode apps.
Packaged asset URLs resolve when they are read
@openreceive/provider-data's icon and tutorial tables were built eagerly with
Object.fromEntries, so assetUrl — and its file:// warning — ran at import
time, before any host resolver could be consulted. A host doing exactly the
right thing, serving the packaged dist/assets trees itself and passing an
AssetUrlResolver everywhere, was told on the console that its icons "cannot
load" about icons that loaded fine. That inverts the warning: nobody reading
their own console could tell a correct integration from a broken one.
The new lazyAssetUrlTable export builds the same shape — own, enumerable,
frozen, undefined for a missing key — with getters, so a packaged URL resolves
when something actually reads it. The warning becomes evidence again.
Rails says out loud what the generated initializer left in place
config.authorize from openreceive:install allows every request, treating
possession of the reference as the authorization. That is a fine five-minute
demo and a bad production posture, and nothing distinguished the two.
OpenReceive::ALLOW_ALL_AUTHORIZEis a named constant, for the same reason
LOGGING_ON_PAIDis: the engine can detect it at boot by identity and warn
that anyone holding an order id can mint invoices, poll status and request
refunds for it.bin/rails openreceive:doctoris step 0 of the agent directions as one
command. It reports credential PRESENCE only — every line issetor
unset, no secret is printed or partially shown — plus whetherconfigure
ran, which hooks are missing or still placeholders, where the engine is
mounted, and a best-effort wallet preflight that reports rather than raises.
"Look forNWC_URIin this app's server environment" has a different answer
on every host shape, and in a containerised app grepping the repo finds the
name and proves nothing about the value. This asks the process.
CI is on again, and it had been off for three releases
Repository-level CI had been disabled since 2026-08-23, so 0.2.4, 0.3.0 and the
PR #1 merge all landed ungated. Switching it back on surfaced three failures,
none of them new: the Rails example's Gemfile.lock carried arm64-darwin
alone, so bundler exited 16 on x86_64-linux before a test ran; the ruby-engine
suite leaned on an ActiveRecord::Base.stub that minitest 6 moved out to a
separate gem; and one core test raced an 8s budget waiting out a real 1800ms
timer, which installFastTimers — exported since it was written and never
called — now clamps.
Buy a Button replaces Hello Fruit, on four stacks instead of one
The reference shop is examples/buttons/, and it exists in four hosts that sell
the same catalog: node-express (3000), static-html-small-api (3001),
nextjs-fullstack (3002) and rails (3003). Hello Fruit is deleted — 80 files
and its whole shared tree.
- The stacks share a shop, not a checkout.
shared/server-nodeis one
persistence layer behind all three Node hosts, and the interesting seam is
renderCheckout: the same order reaches a React mount, a vanilla
@openreceive/elementsmount, and the Next.js app router, and the shop code
around it does not change.client/andclient-vanilla/never import each
other, which is why the static-html workspace carries no@mantine/*or
mobx*. - Settlement arrives differently per host, on purpose. Rails pushes over
ActionCable; the Node stacks poll. The shared stores expose push seams they
know nothing about the implementation of, which is the point. - The Playwright suite runs against
node-express. All eight specs:
four framework tabs through a full Lightning checkout, both swap paths, the
remint regression, andpersistence.spec.tsin place ofresume.spec.ts. - Two stale references only surfaced when their target vanished. The Rails
demo's Dockerfile copied a Hello Fruit shared tree it never needed, and the
live NWC smoke readproduct.invoice_expiry_secondsfrom aproduct.json
that never carried that key, soexpirywent out undefined on every live run.
It now prices the cheapest button fromshop-catalog.jsonand passes the
engine's own 600s default. check-demo-containershad a rule that was wrong, not merely stale. It
forbade compose volumes outright. The intent — the engine must never carry a
datastore of its own — is enforced byforbidRuntimePersistence; the blanket
ban also forbade the HOST keeping its own database, without which a demo
stops demonstrating "survives a restart" on the firstup --build. Named
*-datavolumes are allowed; bind mounts are still rejected.
The release tool bumps the examples again
Deleting Hello Fruit took examples/hello-fruit/server out of
npm-release.mjs's workspace roots and put nothing back. The four buttons
examples are root workspaces, so release:prepare left their @openreceive/*
pins on the old version and the lockfile refresh went to the registry for
@openreceive/testkit — a package that is private by construction and has
never been published. Caught cutting this release.
Agent directions say how host tables sit next to the library
Both payloads told agents OpenReceive never owns orders, then pointed them at
cloning the repository. An agent working in an existing app does not have that
repo, and copying a demo's ShopOrder / signed-cookie visitor over the tables
already there is the failure the buttons demo exists to prevent. The directions
now spell the combination recipe: this app's order id is the reference,
products price the order, users authorize it, the order is unpaid or paid, and
the library's payment rows are not joined from host models. The inlined
quickstarts' onPaid / on_paid are the guarded UPDATE … WHERE state = awaiting_payment. Node's extra: pass this app's db handle, use the query
the settlement transaction hands you, and do not invent a PaymentRepository.
The docs the agent directions point at are fetchable now
The reading list at the end of both agent-directions payloads named page URLs —
https://openreceive.org/guides/authorization. openreceive.org renders guides in
the browser, so fetching one returned an application shell with an empty
<div id="root"> and none of the guide in it. Every link in that list was a
200 with no words behind it, and an agent cannot tell that from a blocked
network: the failure the site contract exists to prevent, arriving as a success.
Every page the site renders from a source here is now also published as raw
markdown at the same URL with .md appended, and the directions link that:
https://openreceive.org/guides/authorization.md
https://openreceive.org/guides.md
https://openreceive.org/api_docs.md
docs/site-contract.jsoniscontract_version: 2. Everypublish[]entry
rendered from a source here gainsmarkdown_pathbesidepath. The bump is
deliberate rather than an additive field: the payloads shipped alongside it
linkmarkdown_path, so a site that ignored it would 404 the whole reading
list.docs/internal/site-build.mdhas the obligations.- The inlined quickstart's sibling links point at
.mdtoo. They are
followed by whatever the payload was pasted into, which still has no browser. npm run check:docschecks the twins. A payload may link
/guides/<slug>.mdfor a public slug, and/guides.mdor/api_docs.md; a
.mdon any other site-owned page fails the gate, because nothing generates
one.
All four Buy a Button stacks — node-express, static-html-small-api,
nextjs-fullstack and rails — were rebuilt against the 0.3.1 packages, and
the Playwright suite runs against node-express. openreceive (the core gem)
is byte-identical to 0.3.0 and ships to keep the one-version-for-everything
rule; openreceive-rails carries the doctor task and the allow-all warning.
The live wallet smoke (npm run test:live) was NOT run for this release: it
needs a funded NWC connection. Note that the smoke script itself changed here —
it had been reading product.invoice_expiry_seconds from a product.json that
never carried that key, so expiry went out undefined on every live run, and it
now prices the cheapest button from shop-catalog.json. That correction is
therefore unexercised against a real wallet.
This is a PATCH bump: nothing left the public API. resumable and
lazyAssetUrlTable are additive, and both are optional.
OpenReceive v0.2.1
First test publish to NPM and RubyGems
Second audit sweep
A second full-repo audit fixed 24 correctness bugs, removed 22 trust-model
violations, closed 7 wrapper-parity gaps, and finished the order_id →
reference rename. The behavior changes worth knowing:
- Settlement. A notified
payment_receivednow checks pendingness BY HASH
(payments.findPendingAttempt) instead of membership in the oldest-200
batch, so a notified settlement no longer waits for a backlog to drain.
A reconcile result that reportssettledwith nopaidAtis reported as a
failure instead of skipped silently, and the request-path reconcile pass now
runs AFTER the body's cheap refusals so an anonymous garbage POST cannot
claim the gate. Rails exempts unauthenticatedGET /ratesfrom the pass,
matching the JS handler. - Wallet scans. A
list_transactionspage whose rows are all unusable now
fails the scan in both engines rather than reading as an empty wallet — an
empty-looking scan at expiry+grace closes unpaid attempts. Within a page, a
row whose PRESENT fields cannot be read (a non-hexpayment_hash, an
unparsable amount) is skipped and counted; absent fields still mean absent. - Invoice expiry. A wallet that clamps expiry no longer fails every
checkout: the ledger stores the wallet's ownexpires_atand logs
checkout.invoice_expiry.adjusted. A caller-suppliedexpirySeconds(only
the swap path sets one) stays a hard floor, and only when the wallet comes
in short. - Swaps. Provider transport failures on
/swaps,/swaps/statusand
/swaps/refundsmap to 502/503 instead of a generic 500. A provider order
without an expiry fails the create rather than inventing a 10-minute window,
and a provider amount that is present-but-unparsable throws instead of
vanishing. When every configured provider fails its catalog fetch, methods
reportprovider_unreachable, notprovider_unconfigured. A 429 backoff no
longer resets when the weight window rolls. - Checkout UI.
startSwapnow quotes before it starts, in the SHARED
session, so React and the custom element behave identically — an
out-of-range amount is an accepted-range panel in both, built from one
createSwapUnavailableModel. Expired-mid-swap keeps the wizard in both. The
refund review gate is explicitly client-side:refund_nonceand
refund_nonce_expires_atare gone from the browser wire types (the server
never sent them), andSwapDisplayModelcarriesrefundAllowed: boolean.
A swap deposit QR that cannot encode its amount now throws instead of
silently degrading to an amount-less payment URI. - Renames. Option types match their factories (
CreateHostOptions,
CreateStackOptions,CreateHttpHandlerOptions,DefineElementsOptions,
…).@openreceive/browserexportscreateLightningUri/createQrSvg/
createQrPngDataUrlunder those names on both entry points, and no longer
exports the internalreadJsonResponse. The adapter packages no longer
re-export the 14 generatedWire*body types (still on
@openreceive/http). Ad-hoc app-route console loggers are
createAppConsoleLogger/createAppBrowserConsoleLogger, soHostnames
only the persistence object. - New.
createStacktakesonBootFailure, and a failed boot answers
503 WALLET_UNAVAILABLEin the error contract instead of rethrowing the raw
cause.payInAssetNetworkin@openreceive/coreowns the
pay_in_asset→ network split that four call sites re-derived.
ORM handles wrap in one call: knexDb, prismaDb, typeOrmDb, sequelizeDb
@openreceive/httpships a namedSqlAdapterfactory per ORM whose handle
createSqlPaymentscannot accept directly. The parameter types are
structural (no ORM dependency);dialectstays a required argument. The
guide's copy-paste recipes are gone — and the shippedprismaDbfixes a bug
the Prisma recipe carried: only^selectstatements ran through
$queryRawUnsafe, so anUPDATE … RETURNINGfulfillment claim (the
guide's ownonPaidexample) lost its rows and never fulfilled.sequelizeDbcloses the last gap: Sequelize was a first-class scaffold flag
whose only documented wiring was "open a secondpgPool to the same
database, or hand-roll an adapter". It binds parameters through Sequelize's
bindoption and threads the managed transaction into every statement
inside it — Sequelize carries the transaction on the same instance, so a
hand-rolled adapter that missed that ran settlement outside the transaction.- The scaffold's wiring guide no longer prints hand-rolled
SqlAdapter
snippets for Prisma/Knex/TypeORM/Sequelize; each section is now the shipped
factory in one line. npm run test:ormsproves the factories against the real ORMs: knex,
typeorm, and prisma (7, via its better-sqlite3 driver adapter) each drive
the payments repository — commit, write-once settlement, reconcile-gate
CAS — on sqlite. Weeklyorm-adaptersjob indemos.yml; the ORMs are
devDependencies only. The no-database-driver gate now checks the root
workspace manifest on its runtime dependencies (package and example
manifests stay strict in full) so the lane's sqlite driver can exist at
dev time.
The browser's swap requests no longer throw Illegal invocation
- Every swap route call (
/swaps,/swaps/quote,/swaps/status,
/swaps/refunds) invoked the caller's fetch as a method of its options
object, rebindingthis— andwindow.fetch, the default, throws
Illegal invocationfor anythisbut the window. The swap flow was
broken in real browsers across all four wrappers; the e2e swap specs catch
it now. Checkout and status requests always bare-called a local and were
unaffected.
Cross-site requests are refused by the handler; the Rails engine inherits protect_from_forgery
- Every body-bearing route now answers
415for a body that is not
application/jsonand403for a request the browser labels
Sec-Fetch-Site: cross-site, in both engines and beforeauthorizeruns. A
cross-site form cannot set a JSON content type, a cross-originfetchthat
does is CORS-preflighted (never answered), and theSec-Fetch-Sitegate
covers the remainingno-corsforgery. Golden vectors11and12pin
both refusals; vectors may now declare extra requestheaders. OpenReceive::ApplicationControllerno longer calls
skip_forgery_protection: the host'sprotect_from_forgeryapplies to the
engine's routes as it does to the host's own, and a failed check is the
shared403instead of an opaque500. The browser client sends
X-CSRF-Tokenfrom<meta name="csrf-token">on every request when the
page renders one (csrf_meta_tags), from one sharedrequestHeaders
helper; a hostheadersvalue still wins. The Rails demo drops its
:null_sessionoverride and runs Rails' default.
reference, not order_id: the host's order is not part of the story
- The grouping key OpenReceive stores is now called
referenceeverywhere —
theopenreceive_paymentscolumn, every HTTP request and response body,
the browser snapshots, the<Checkout reference>prop (route-reference
for the element wrappers),AuthorizeResource.reference, and the
settlement passed toonPaid/config.on_paid(PaymentSettlement,
formerlyOrderSettlement). It is a string the host chooses — its order
id, one per payable thing and never reused — that OpenReceive groups
attempts under and fulfills at most once. Rails hosts recreate the engine tables
(bin/rails db:reseton a development database). loadOrder+amountForOrdercollapsed into one hook:amountFor(reference)
returns the trusted price ornullfor a 404 (Rails:config.amount_for,
nil). The host is consulted only where a price is minted or quoted; status
polls and refunds are answered from OpenReceive's own rows. This also fixes
the Rails engine selecting attempts by the loaded object's.idrather than
the id it was given, which broke hosts whose lookup key was not their
primary key.openreceive:installno longer takes--order-model; the generated
initializer names no model.authorizedefaults to possession of the
reference andconfig.amount_foris left for you to write (the engine
refuses to serve checkouts until it is set).- The fulfillment note rendered into every scaffolded migration, the Rails
initializer, and the wiring guide now says only what OpenReceive guarantees
aboutonPaidand what the host must guarantee. Its "optional foreign key"
recipe is gone, as is every "OpenReceive never reads, locks, or joins your
order table" paragraph in the docs and source — there is no relationship to
explain.npx openreceive scaffold paymentsrejects the long-removed
--order-model/--order-table/--order-id-type/--skip-foreign-keyflags
as plain unknown options.
One subpath under the UI: @openreceive/browser/headless
@openreceive/browser/internalis gone. It was public API with a
discouraging name — 130 values and 46 types that@openreceive/react,
@openreceive/elements, and the wrappers imported and nothing documented.
Those names now live on./headless, the one curated, documented surface
the renderers and headless integrations share; 15 names only tests used are
no longer exported.npm run check:example-importshad nothing left to
reject and is removed.docs/internal/display-boundary-findings.mdwas deleted. Its
display-boundary rule (formatters throw, display boundaries blank) is
superseded by the AGENTS.md trust model: our own server, the configured NWC
wallet, and the configured swap provider are trusted, and a throw in a
checkout panel is our own API surfacing, not a display-boundary bug class to
defend against.
The OpenReceive prefix is gone; the import path is the namespace
- 41...