Repository navigation
Market
An optional marketplace where one box sells spare storage or compute and another buys it. Payment goes through Stripe Connect; the edge keeps a 4% cut to cover its running costs.
It is not open yet. The code is finished end to end (the registrar's
/market/* routes, the Stripe gate, lososd's relay and the admin UI pane),
but a Stripe Connect platform needs a registered business behind it, and
there is none yet. Until there is, the admin UI shows the Market tab
greyed out with a "soon(TM)" badge: it cannot be clicked or reached by
address, and nothing on a box asks the market anything. The disk-sharing
switch (losos.sharingMyStorage) lives on this pane, so sharing the disk with
the mesh opens with the market and cannot be switched on from the UI before
then. The rest of this page describes how it works once it opens. Everything
below is also off by default, at three levels.
| Default | Switch | |
|---|---|---|
| The edge serves it | off | losos.edge.market.enable |
| A box may trade | off | losos.edge.tenants.<id>.market |
| A seller may list | closed | Stripe reports the connected account ready |
A box that never trades is unaffected. The market is a feature of the edge's
losos-registrar.
The market sells what you already share, and nothing else. It is a way to
be paid for the storage and compute a box contributes to the mesh, not a
separate product. A box can list storage only while its node is enrolled in the
mesh, and compute only while it is also sharing compute
(losos.cluster.shareCompute). Stop sharing and your listings leave the shelf
immediately (they return if you resume; orders already paid are unaffected).
The edge is the Stripe platform. Money never touches a losos box.
- A seller onboards: the edge creates a Stripe Express connected account for
it, tags it with the box's UUID (metadata
losos_box_uuid), and returns Stripe's hosted onboarding link. A box that was onboarded before this existed is tagged the next time its owner opens the page. - Stripe tells the edge (
account.updated) when the account can receive transfers. Only then can the seller list anything. - A buyer orders units of a listing. The edge reserves them and creates a
Stripe Checkout Session as a destination charge: the buyer pays the
platform,
transfer_data[destination]forwards the sale to the seller's account, andapplication_fee_amountstays with the platform. - Stripe tells the edge (
checkout.session.completed) that the payment cleared. The order becomespaid.
The fee is feeBps basis points of the gross amount, rounded half up. At the
default 400, a 20.00 EUR sale pays the platform 0.80 and the seller 19.20. The
configured value is capped at 2000 (20%).
| Kind | Unit | Meaning |
|---|---|---|
storage |
GiB-month | Capacity in the mesh's Longhorn pool |
compute |
vCPU-hour | Scheduling on the seller's node, inside its window |
Prices are in the minor unit (cents) of one currency per edge
(losos.edge.market.currency). A single order must total at least 50 minor
units, which is Stripe's floor.
A paid order is a 30 day entitlement (expires_at). Compute units return
to the listing when it lapses. Storage units return only once the claim is
gone, because the claim still holds Longhorn capacity after the month ends.
-
Storage. The edge creates the namespace
market-<buyer id>in the mesh cluster and aPersistentVolumeClaimnamed after the order (ord-...) of the purchased size, fromlosos.edge.market.storageClass(defaultlonghorn). It runs after every reconcile pass and straight after a paid webhook, is idempotent (anAlreadyExistsanswer counts as done), and retries every reconcile interval if the apiserver refuses. The order counts as fulfilled only once the claim isBound; a claim leftPending(no such storage class, or no capacity) is checked again on every pass. That assumes anImmediate-binding class, as Longhorn's default is: aWaitForFirstConsumerclass never binds, because nothing mounts the claim yet. The account view reports a bound claim asvolume: "<namespace>/<claim>". The edge writes the claim's name down before it asks for it, so a claim that never binds, or one created just before the edge stopped, still keeps its units sold after the month ends. -
Compute. A credit in vCPU-hours, listed under
entitlements. Nothing meters or schedules against it yet.
Limits to know about, none of which are enforced yet:
- The claim is not pinned to the seller's node: Longhorn places replicas across the pool, so the seller is paid for contributing to it, not for hosting that particular volume.
- The buyer has no access path to the claim; it exists in the cluster for a workload to mount.
- Nothing is ever deleted. A lapsed entitlement stops being reported, but the
volume holds the buyer's data and removing it is an operator decision. Until
the operator deletes the claim, its GiB stay sold, so the same capacity is
never sold twice. That holds for a claim still
Pendingtoo, since it may yet bind. The reconcile pass checks each lapsed claim and puts its units back on sale once the apiserver answers 404 for it. - The registrar's ServiceAccount gains cluster-wide
get/createon namespaces and PersistentVolumeClaims when the market is enabled. Kubernetes RBAC cannot scope either tomarket-*names.
Today the Settings → Market row is greyed out and labelled "soon(TM)": it
is a disabled button, skipped by the keyboard, and /settings/market opens
the default pane instead. Opening it is one flag (planned on the row in
admin-ui/app/src/screens/settings/panes.ts) plus the matching switch in
admin-ui/app/tests/app.browser.mjs, which holds the pane's browser checks
until then.
Once open, the pane starts with the disk-sharing switch (moved here from Storage: lending disk to other boxes and being paid for it are one decision), then shows what the owner has bought (with expiry and volume), the shelf to buy from, and, for selling, Stripe payout setup, a listing form and the owner's own listings and sales. The switch is shown whether or not the edge offers the market to this box; it is a setting of the box, not of the edge.
- The listing form only offers what is already shared: storage once the box has joined the mesh, compute once it also shares compute. Anything else is greyed with the reason. The edge enforces the same rule; the pane only explains it.
- Payment and Stripe onboarding open Stripe's own pages in a new tab. Nothing
in the admin UI sees a card, and only
https://checkout.stripe.comandhttps://connect.stripe.compages are opened, checked by lososd and again by the page, so a compromised edge cannot send the owner to a look-alike card form. Custom Checkout domains are not supported. - The pages cannot call the edge themselves (the admin UI's CSP is
connect-src 'self'), solososdrelays:GET /api/marketandPOST /api/market/{onboard,listings,listings/close,orders}. The relay uses the registrar's URL, the appliance id and the proxy token thatlosos.proxy.enablealready provides, requires https, and sends the token tocurlon stdin rather than on the command line. - Where the market is off (no proxy, the edge has it disabled, or this tenant
is not opted in)
GET /api/marketanswers{"available": false}with a 200 and the pane says so. It is deliberately not a 404: the admin UI treats a 404 as "this box does not serve the route" for the rest of the session.
All routes live on the registrar's public API (register.<publicDomain>).
Authenticated routes take the same appliance_id and token as /register.
Bodies are JSON.
| Route | Auth | Purpose |
|---|---|---|
GET /market/listings |
none | What can be bought now. Names no seller |
POST /market/account |
token | Your listings, purchases, sales, entitlements |
POST /market/seller/onboard |
token | Start or resume Stripe onboarding |
POST /market/listings |
token |
kind, unit_price, capacity
|
POST /market/listings/close |
token |
listing_id; paid orders keep their units |
POST /market/orders |
token |
listing_id, quantity; returns a Checkout URL |
POST /market/webhook |
signature | Stripe events |
Every route answers 503 when the market is off. On the token routes, a tenant
without the market bit gets 403; the public listing view and the webhook
belong to no tenant, so they never do. POST /market/account returns the 100
most recent purchases and sales on each side, live ones first. A closed
listing nobody ordered from is deleted; one with orders stays as long as they
do. Neither party learns the other's appliance id, and the public
listing view never shows one.
Capacity is reserved when the Checkout Session is created and held until
Stripe says how it ended: paid (checkout.session.completed) or abandoned
(checkout.session.expired, sent as the session's 31 minutes run out). An
expired event only counts if it names the session the order recorded. It is
released at once if Stripe cannot be reached to create the session, unless a
payment for it has already landed. If neither
event ever arrives, the hold lapses after Stripe's three-day retry window, so
a payment delayed by an edge outage still finds its units unsold; the next
reconcile pass then records the order as expired. A payment that arrives
even later is honoured if the units are still free, and otherwise logged with
its session id so the operator can refund it. Two buyers racing for the last
units cannot both get a session.
-
In the Stripe dashboard, enable Connect on the platform account.
-
Seal the secret key (a restricted key works) with
systemd-creds, reading it from stdin so the plaintext never touches the disk:systemd-creds encrypt --name=stripe-secret-key - \ /var/secrets/losos-stripe-secret-key.cred
The blob's path is
losos.edge.market.stripeSecretKeySealed, and only the gate unit ever decrypts it. The name must be exactlystripe-secret-key: a blob only decrypts under the name it was sealed with. -
Add two webhook endpoints, both at
https://register.<publicDomain>/market/webhook: one for events on your account (checkout.session.completed,checkout.session.expired) and one that listens to events on Connected accounts (account.updated). Stripe signs each with its own secret, so seal bothwhsec_...secrets, one per line, under the namestripe-webhook-secretintolosos.edge.market.webhookSecretSealed(default/var/secrets/losos-stripe-webhook-secret.cred); the edge accepts either. -
Set
losos.edge.market.enable = trueandlosos.edge.market.returnUrl, an absolute http(s) URL with no credentials and no#fragment(the order and status are appended as query parameters). -
Set
losos.edge.tenants.<id>.market = truefor each box allowed to trade.
Start in Stripe test mode. The edge has been tested against a stand-in for Stripe, which checks what it sends and how it reacts but cannot say whether Stripe accepts it; the first test-mode run settles that.
- The webhook is authenticated by Stripe's HMAC signature over the raw body, with a five minute replay window and a larger body cap than other routes.
- A payment only counts when the session id, amount and currency equal what the edge recorded for the order. A mismatch is logged and not fulfilled. If the edge stopped before it wrote the session id down, the signed event's id is adopted, since only this edge's gate can have named the order in it.
-
The registrar never holds the Stripe key. A separate unit,
losos-stripe-gate(losos-registrar stripe-gate), is the only process that has it. The registrar talks to the gate over a Unix socket (/run/losos-stripe-gate/gate.sock, 0600) and may ask only for: create an account, tag an account, check an account, make an onboarding link, start a Checkout Session, verify a webhook signature. The gate refuses a checkout whose destination is not anacct_...id, whose currency differs from the configured one, whose fee exceeds the 20% ceiling (or the whole amount), whose return URL is not a plain http(s) URL, or whose session lifetime is more than a day. It also refuses a non-httpsStripe endpoint. There is no "forward this to Stripe" operation. A compromised registrar therefore cannot read the key, cannot send money anywhere but a connected account at the gate's fee limits, and cannot issue refunds or payouts; it can still ask for checkouts, because that is its job. Both units run as processes on the same machine, and root on the edge can still reach both. The registrar also has the sealed blobs and the gate's credential directory marked inaccessible, as a second layer. - The Stripe secrets are never stored in plaintext on the edge. They live as
systemd-credsblobs (TPM2 where the edge has one, otherwise the host key) handed to the gate withLoadCredentialEncrypted=, so they are plaintext only in that unit's private credential tmpfs and a copy of/varcarries ciphertext only. Rotating a secret means sealing a new blob and restartinglosos-stripe-gate. A missing blob skips the gate, which turns the market off (503) and nothing else; the gate is its own unit, so the master proxy in the registrar is never affected. -
The Stripe account carries the box's UUID, not its recovery code. The
recovery code is a credential and stays on the box. What is sent is a
one-way value derived from it (
SHA-256("losos-box-id-v1:" + code), first 16 bytes, formatted as a UUID), which is stable for the life of the installation and reveals nothing from which the code could be recovered. The registrar checks it is a canonical UUID and the browser cannot choose it: lososd adds it itself. - Secret files are shape-checked (
sk_/rk_,whsec_) before use. -
market.jsonis 0600 and written atomically. A file that does not parse stops the edge from starting rather than being treated as empty, because empty would forget who has paid for what. - Stripe's error text and endpoints stay in the journal; callers get a fixed message.
-
Refunds and disputes. Handle them in the Stripe dashboard, using
reverse_transferandrefund_application_feeso the seller's share and the platform's cut are both returned. The market does not model either. - Enforcing the entitlement. Fulfilment creates the claim; it does not stop a buyer using more than they bought, meter compute, or revoke access at expiry.
- Tax, invoicing and seller verification beyond what Stripe's onboarding does. Running a marketplace has legal duties that depend on where you operate; this is an experiment, not advice.