08 — Hosting
A host is a carrier (06 §4) that many accounts pay to keep their spaces online. This part is the exchange between a device and a host: subscriptions, payment, a space's own fund, and bots a host runs. It is not part of the core: peers sync, store and judge every record the same way whether or not anyone uses a host, and an implementation that never talks to one needs none of it.
1. Hosts
A host is a carrier for many accounts, run as a service. Each account that uses it has a subscription: a key pair the account made, a date it is paid until, and, once a device hands it over, the account's carry space. Spaces several subscriptions name are held once.
The subscription key. When an account first uses a host, the node makes a 32-byte random seed, derives a
P-256 key pair from it, and keeps a sys.hosting record in the account
registry, key hosting:<hex of the first 20 bytes of SHA-256(utf8(url))>, body:
| Field | Meaning |
|---|---|
url | The host's origin: https://, or http:// on localhost/127.0.0.1. |
host | The host's DID, from its description when first used. |
seed | The subscription key's seed, base64url. |
since | ISO date. |
name | The host's name, as it described itself. |
peer | Optional: where the host takes peers, resolved from its description (below). |
receipt | The latest SignedStatus the host gave (below). |
Every device of the account signs as the same subscription. It is not the account's key: the host learns a subscription, not who pays.
Description. GET <url>/.well-known/minca-host, public:
| Field | Meaning |
|---|---|
minca | "host/1" |
did | The host's key: its identity to peers, and what signs its statuses. |
name | For people. |
free | Every subscription counts as paid. |
price | Optional, free text. |
terms | Optional, for people. |
peer | Optional: where it takes peers over WebSocket (04), relative to the host's address or absolute. Absent: devices cannot reach it by its description. |
remind | Optional: true when it sends reminders by email before paid time runs out (…/remind). |
bots | Optional: true when it runs bots for the spaces it carries (§3). |
fund | Optional: how money goes in here, and the price: into an account's balance (below) and a community's fund (§2). Absent: it takes no payments. |
connectors | Optional, with bots: the services its bots can be connected to (§3.1), each { id, name }. |
limits | Optional: what it keeps for free (§1.1). Absent: every subscription is paid for, or free with free. |
A device MUST refuse a description whose minca is not host/1 or whose did
is not a did:key, and MUST treat a host whose did changed since it was first
used as a different host. A host answers this, and every request below, at its
own address: a device does not follow a redirect, since an address a record
names would then choose where the device connects. A host that moved is used
at its new address.
Example:
{
"minca": "host/1",
"did": "did:key:zDnaeXL64…",
"name": "Minca host",
"free": false,
"price": "$0.10 a GB a month",
"peer": "/peer",
"remind": true,
"bots": true,
"fund": {
"perGb": "0.10",
"min": "5",
"methods": ["checkout", "request"],
"recurring": true,
"botDailyCap": "1.50"
}
}Signed requests. Every call about a subscription carries
Authorization: Minca did=<subscription DID>, at=<unix seconds>, sig=<base64url>where sig is the subscription key's signature over the UTF-8 bytes of
minca-host/v1\n<METHOD>\n<path and query>\n<at>\n<base64url(SHA-256(body))>(body is the exact request body, empty string when none). A host MUST refuse a
request whose header does not match
^Minca did=(did:key:z[1-9A-HJ-NP-Za-km-z]{1,120}), at=(\d{1,12}), sig=([A-Za-z0-9_-]{1,200})$,
whose at is more than 300 s from its clock, whose signature fails, or whose
signer is not the subscription in the path. Example:
Authorization: Minca did=did:key:zDnaet57TmtMH7vQJT8HzNLSZV5sc5JGJub2ZzpX3oHshR2k2, at=1790422901, sig=UD0ynCDYHqxRQ2N08X3On5ljQhEZwyczof1_Zuiv0jhN4rYpoh0pEZ88mPx7hit20d9A5T2oSgvK-UPTetIO_Q(The format only: the signature is illustrative, as the key is not given.)
Calls. <id> is the subscription DID, URL-encoded or not.
| Call | Body | Answer |
|---|---|---|
GET /host/subscriptions/<id> | — | SignedStatus |
PUT /host/subscriptions/<id>/carry | { "account": "<account DID>", "invite": "<carry invite, ≤ 16 000 chars>" } | SignedStatus. 402 when not paid (or lapsed); 403 when the host carries only named accounts and this is not one; 400 for a bad invite. |
DELETE /host/subscriptions/<id>/carry | — | SignedStatus. Stops carrying; the subscription stays. |
POST /host/subscriptions/<id>/pay | { "amount": "<dollars>", "method": "checkout" | "request", "monthly"?: true } | A pay answer (below) that adds amount to its balance. 400 for an amount below fund.min or not matching it as in §2, or a method it doesn't take; 404 when it takes no payments. |
POST /host/subscriptions/<id>/remind | { "email": "<address>" } | {}. 404 when it sends no reminders; 400 for an address it can't use. |
Errors are { "error": "<message>" } with 400, 401 (not signed by the
subscription), 402, 403, 404, 405. The reference host sends
Access-Control-Allow-Origin: * on these paths and answers OPTIONS.
Signed status. SignedStatus = { payload, sig }, where payload is the JSON
text of a HostStatus and sig is the host key's signature (base64url) over
utf8("minca-host-status/v1\n" + payload). HostStatus:
| Field | Meaning |
|---|---|
subscription | The subscription DID. |
host | The host's DID. |
state | active (paid, or a free host), grace (past paidUntil, within the grace period), lapsed, none (no such subscription). |
paidUntil | Unix seconds; 0 before any payment. |
renews | Paid through something that renews by itself. Always false for a balance: a card that adds every month is the payer's own (fund.manage). |
carrying | Whether it carries the account's spaces now. |
spaces | How many spaces the carry space's passes name. |
at | When the host said it, unix seconds. |
bytes | Optional: what the subscription's spaces take at the host, as it last measured. |
quota | Optional: what they may take. Past it the host takes no new space for the subscription; at a host with limits, the free room while its balance does not pay (Room, §1.1). |
balance | Optional, at a host that takes payments: what is in the subscription's balance, in millionths of a dollar; below zero by what its grace (and a bot's last call, §3) took. |
daily | Optional, with balance: what it spends a day as things go, in millionths of a dollar. |
plan | Optional, at a host with limits, for an account's subscription: "paid" while its balance pays for what is past its free room, "free" otherwise (§1.1). |
over | Optional, at a host with limits: the spaces this subscription names that are over the free line (§1.1), each an OverSpace. |
usage | Optional, at a host with limits, for a space's own subscription: how much the space is used (§1.1), a SpaceUsage. |
full | Optional, at a host with limits: { since, until }, unix seconds, from when its spaces took more than quota until a whole window after they fit again (§1.1, Room). |
A device MUST accept a status only if it verifies under the host's recorded DID,
its host equals that DID, and its subscription is the device's own. Example:
{
"payload": "{\"subscription\":\"did:key:zDnaet57…\",\"host\":\"did:key:zDnaeXL64…\",\"state\":\"active\",\"paidUntil\":1821536000,\"renews\":true,\"carrying\":true,\"spaces\":4,\"at\":1790422901}",
"sig": "cC4uQLCnF0tkMzhAuxHp58tjc2XWhdRpj9RpWgb-wYIhzKvhXhi97DoBn_hvcz9NLyv0ch2bqFMJfTIXgYxQhw"
}(The format only: the DIDs are shortened and the signature is illustrative.)
The balance. An account pays as a community does (§2): into a balance,
any amount, from which the host takes its fund.perGb rate a month for each
GB (10⁹ bytes) the subscription's spaces take past limits.accountBytes
(every GB, at a host without limits), by the byte and for the time they
do. Its state, its grace and paidUntil go as a fund's do (§2): active
while the balance is above zero, a grace lent as a fund's is, and
paidUntil the host's estimate. At a host with limits an account's
balance is lent no grace: its room's notice is its grace (§1.1). Its status says balance and daily. The
host learns how much was paid into which subscription, never by whom.
Paying. A device never handles a payment: it shows the host's price
(fund), and what the host answers when asked to take one. A pay answer
is one of:
{ "checkout": "https://checkout.stripe.com/c/pay/cs_live_a1b2c3" }{
"request": {
"uri": "ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer?address=0x1111111111111111111111111111111111111111&uint256=36004217",
"amount": "36.004217 USDC on Base",
"expires": 1791027701,
"evm": {
"chainId": 8453,
"chainName": "Base",
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"to": "0x1111111111111111111111111111111111111111",
"units": "36004217"
}
}
}checkoutis a page at a payment provider. A device MUST refuse one that is nothttps://(http://only onlocalhostor127.0.0.1), and opens it in a new browsing context with no opener, so the page cannot reach back into the device's.requestis a payment for a wallet to send:uria payment URI a wallet opens (ethereum:per EIP-681,lightning:orbitcoin:),amountfor people, andexpires(unix seconds) until when a payment counts. A device MUST refuse auriwith another scheme.evm, optional, spells out an ERC-20 transfer (transfer(to, units)ontoken, onchainId), so a wallet in the browser (EIP-1193) can be asked to send it. A device showsamountas it is: the host tells payments apart by it.
A host sees a request paid by itself, on the network, and adds it to the
balance; how is its business. A device asks for the status until it moves. Where a
checkout page sends people back to is the host's business too; a device
never follows a link the host gives, only opens checkout.
Reminders (…/remind) are the host's to send, and its business: the
reference host mails a link to confirm the address before anything else.
What a device does. A device asks each host the account uses for its
status. When the status says it is not carrying and it is paid (active,
grace, or not lapsed at a host that is free or has limits), the device hands over the carry space
with PUT …/carry, making the carrier (06 §4.2) for the host's DID first if the
account has none. It writes the returned receipt into the sys.hosting record
when the state, paidUntil, renews or name changed. To stop using a host it
sends DELETE …/carry, removes the carrier and deletes the sys.hosting
record. How often the reference asks is in
the node.
What a host does. Subscriptions are active while paidUntil ≥ now (or
always, when free; or, for an account's, always at a host with limits,
§1.1), grace for a grace period after (the host's policy), then
lapsed; a lapsed subscription is dropped, and its carry space with it unless
another subscription carries it. Paying again
in time carries again what the grace period kept. A host MAY carry only a
configured list of accounts, and then MUST refuse any other before keeping
anything. It runs the carrier of 06 §4.1–4.2 for every carry space.
Reaching a host. A device resolves peer against the host's address
(new URL(peer, url + "/")), reading https: as wss: and http: as ws:.
It MUST NOT use an address that is not wss://, except ws:// on
localhost, 127.0.0.1 or [::1], and pins it to the host's key
(04 §5.2). It keeps the resolved address in the
sys.hosting record, so the account's other devices have it without asking.
Every device of the account then holds a socket to that address for every
space it holds (04), as the host carries all of them through
the carry space. A device also holds one, for that space, to each host a space
it holds pays itself (§2), and to each node the invite that brought the space
here named (03 §7.4), until the space names keepers that
leave it out: so someone invited reaches the space through the inviter's
host with nobody else online. An invite names the account's hosts, and
those the space pays; never the nodes an invite named. Example: a host at https://host.example that
describes "peer": "/peer" is reached at wss://host.example/peer, and a
space b3kq7zp2f4mhx6ydwa5rtc9n1e at
wss://host.example/peer?space=b3kq7zp2f4mhx6ydwa5rtc9n1e#<host DID>.
A device MAY be built with hosts to look for its account registry at before it
knows which the account uses: that is how a new device with only the recovery
code finds its registry, and every space from it. The host learns the
registry's id and the device's session key, and serves it only if it carries
it. The reference reaches those hosts for the registry; its account home also
keeps each new account online with the first of them, when it keeps accounts
with nothing paid (packages/core/docs/sign-in.md).
Source: packages/core/src/session/hosting.ts (hostPeerAddress), packages/core/src/session/auth.ts (keepOnline), packages/core/src/node/host.ts, packages/core/src/node/node-hosting.ts (hosting, reachHosts), packages/core/src/node/space-peers.ts (useNodes), packages/cli/src/host.ts (startFund, watch), packages/cli/src/fund.ts, packages/cli/src/wallet.ts (request, scan), packages/cli/src/reminders.ts. Tests: packages/cli/tests/host.test.ts ("the host API": all; "pay answers": all; "reaching a host at the address it names": all; "a host’s room": all; "wallet payments": all), packages/cli/tests/reminders.test.ts, packages/core/tests/reach.test.ts ("follows no host’s redirect…").
1.1 Limits: free hosting, paid room, and the free line
A host may keep every account's spaces online without payment, within limits, and ask a community to pay for itself once it grows past them. It is blind, so it draws the line only on what it can see of a space: how many accounts write there, how many devices read, and how many bytes it holds. The numbers are each host's own; it says them in its description, so a person can compare hosts before choosing one.
Description. limits:
| Field | Meaning |
|---|---|
accountBytes | Bytes an account's spaces may take for free: its quota while its balance does not pay. Past them, a balance pays (§1). |
space | The free line for a space several accounts write in: { writers, bytes, windowDays, readers? }, below. |
graceDays | Days a space over the line is still carried for its members' subscriptions before only its own subscription (§2) carries it; and the notice for room (Room). |
keepDays | Optional: days the host keeps a space it stopped carrying for being over the line, or held back for room (Room), so a fund or room made in that time brings it back at once. |
idleDays | Optional: days a free subscription is kept with no signed call from any of its devices. Absent: kept while the host runs. |
{
"limits": {
"accountBytes": 250000000,
"space": { "writers": 10, "bytes": 500000000, "windowDays": 30, "readers": 50 },
"graceDays": 30,
"keepDays": 30,
"idleDays": 180
}
}Each number is a non-negative integer below 2⁵³, and space.windowDays at
least 1. A device MUST ignore a
limits that is not an object with every field it requires well formed,
and still read the rest of the description: limits are for showing, and a
host that describes them badly is still a host. free: true means what it
always did, every subscription paid and no limits, and a host that says it
MUST NOT also give limits.
Plans. At a host with limits, an account's subscription is active
without payment: plan is "paid" while its balance is above zero and
"free" otherwise. Paying, it has no quota: its balance pays for what is
past accountBytes. On the free plan its quota is accountBytes. So when
a balance runs out, its room is the free one again, and the notice (Room)
is its grace: nothing below zero is taken from it. A balance whose spaces fit
in the free room draws nothing, so it stays "paid" and is not dropped for
idleDays. It is never grace or lapsed for want of payment, and is
dropped only after idleDays with no signed call (§1) from any of its
devices. What a space's own subscription carries (§2), while active,
counts against no account's quota; its fund pays for its size instead (§2).
A quota of 0 is a quota: no room.
Room. At a host with limits a quota is kept, not only a bar to new
spaces. A subscription's room counts its carry space and every space it
names, carried or not, but those paying for themselves and those past the
free line. When they pass its quota, the host says so in its status
(full) and keeps carrying for graceDays; the notice is the account's, not
the subscription key's, and goes only after a whole windowDays of fitting,
so neither a new key nor a dip under starts it again. Past until, while
still over, it MUST NOT carry the largest of those spaces for that
subscription until the rest fit; another subscription with room that names
one still has it carried. It keeps what it held of them, unserved, for
keepDays from when each was first held back (none, without keepDays),
then deletes what its bucket keeps of one no subscription carries; the
space is still named, comes back once it fits, and goes to the bucket whole
again. An account over by its carry space alone is carried for no more,
under any key, until it pays (its balance then has no room to be over) or
the host's owner says. Members' devices keep
everything. A space's own fund pays for its size instead (§2).
The free line. Over the last windowDays, a host counts for each space
it carries for members' subscriptions, carry spaces among them:
- writers: distinct accounts with a version of the space that reached the
host and passed its gates (02 §3), the account being the issuer of the
version's note (
proof), or itsauthorwhen it has none, and that are members of the space (03 §4) when the host counts. Joining writes a version, so a member counts from when they join, and one removed counts no more. A version counts by when it arrived, never by itscreatedAt, which is its writer's to say: only while the host first catches up on a space it has just started carrying is a version that says it is older than the window taken as history, and not counted (the reference host: the first 10 minutes). Accounts the host runs, its bots (§3), are not counted. - readers: distinct session DIDs that a socket for the space
(
/peer?space=) proved (04 §5). The reference host keeps at most 10 000 for a space over its window, and a million over every space, so made-up keys cost it no more memory past that; one account with many spaces can fill the million, after which readers stop growing anywhere. It keeps at most 50 000 writers for a space a day. - bytes: what the space takes at the host.
A space is over the line when writers exceeds space.writers (and at
least 1), or when it has at least two writers and bytes exceeds
space.bytes. Readers are
only reported: they are cheap to make up, so they MUST NOT put a space over
the line. A space only one account writes in is never over the line,
however large: it counts against the quota of every account that names it,
as any space does.
When a space goes over, the host notes when (since) and keeps carrying it
for graceDays (until = since + graceDays × 86 400), saying so in the
status of every subscription that names it. Past until, if the space is
over the line then, or was at any look in the 7 days before until, it
MUST NOT carry it for members' subscriptions alone:
only its own subscription (§2), while active or grace, carries it then.
One back under more than 7 days before until, as when its members removed
those who pushed it over once told, keeps its line and is carried as before, until it is over again or
free again. Before until it MUST carry it as before. While the space's own subscription is active, the host still
counts and dates the line, but carrying does not depend on it. The grace is given once: paying for a while does not start it again,
so once the space's own subscription stops being active, members'
subscriptions carry it again only if until is still ahead or it is under
the line. Its members hear nothing of the line while the fund pays, so a
fund that lapses after until stops members' carrying it with no new
notice; the fund's own status says when it runs out. A space comes
back under the line only after windowDays with every count at or below 80%
of its limit. One the host stopped carrying sees no writes, so it comes back
only through its own subscription, or once the host forgets it after
keepDays, when it is counted again from nothing. A space whose grace is
spent, because it was stopped or because the grace ran out while it was
under, gets only a short notice should it go over again (the reference
host: 7 days), never a whole grace; one free again after a whole window
under has its grace back, and so does one stopped and forgotten, a window
after keepDays. Nobody naming a space for a while changes none of this:
its line stays until it ends by these rules. A carry space over the line past until stops
being that subscription's carry space, and the host refuses it as one until
its own subscription pays.
Stopping changes nothing a peer checks: members' devices keep their copies
and sync with each other directly, and a subscription that names the space
still names it. The host keeps what it held for keepDays, unserved, so
that once the space's own subscription is paid it carries it again from
where it was.
A carry space is the account's alone (06 §4.2): at a host with limits, one
the host already carries, or another subscription's passes name, is refused
as a carry space, as is one in which another account is a founder or holds a
role; and one that comes to be shared is let go, so that every member's own
pass carries it and every member is told of the line.
A host with limits takes payments for communities (§2 fund): a line
that stops carrying a community needs a way for it to pay for itself.
Status. For an account's subscription, over lists the spaces its
carry space names that are over the line, and those it no longer carries
for that reason, but none whose own subscription is active, and none back
under the line and still carried:
OverSpace field | Meaning |
|---|---|
space | The space id. |
since | When it went over, unix seconds. |
until | When carrying it for members' subscriptions stops, or stopped, unix seconds. |
reasons | Which limits it is over: a non-empty subset of "writers", "bytes". |
For a space's own subscription, and so at GET /host/spaces/<space id>,
usage says how the space is used, and over holds its one OverSpace
while it is over the line, whether or not anyone pays for it:
SpaceUsage field | Meaning |
|---|---|
writers | Distinct accounts that wrote, over the window. |
readers | Distinct session DIDs that read, over the window. |
bytes | What the space takes at the host, as it last measured. |
windowDays | The window counted over. |
Example payload, for an account on the free plan in a community over the line:
{
"subscription": "did:key:zDnaet57…",
"host": "did:key:zDnaeXL64…",
"state": "active",
"paidUntil": 0,
"renews": false,
"carrying": true,
"spaces": 6,
"bytes": 41200000,
"quota": 250000000,
"plan": "free",
"over": [
{
"space": "b3kq7zp2f4mhx6ydwa5rtc9n1e",
"since": 1791027701,
"until": 1793619701,
"reasons": ["writers"]
}
],
"at": 1791114101
}A device MUST ignore an over entry or a usage that is not shaped as
above, and SHOULD show a person each space in over with its until, and
how its members can start its fund (§2) before then.
Abuse. A host with limits carries for anyone who asks, so it limits how
fast subscriptions are made and how much one account may name. The
reference host makes at most 5 subscriptions a day for one address, an IPv6
/64 counting as one, by any call that makes one, and carries at most 200
spaces for one subscription, counted as it reads the carry space's passes;
one past that takes no new space, as past its quota. Behind a proxy it takes the address from a header only when
told which (on Fly, Fly's own), and then the last address in it, the one
the proxy added. These are its own
numbers, not the protocol's, and they slow a stranger rather than stop one:
an account is only a key.
Privacy. The counts are what the host sees already: every version's
note names its account in the clear (02 §3.1), and every socket's hello its
session DID. To count, the reference host keeps, for windowDays, the
accounts that wrote in each space on each day as salted hashes; the readers
it keeps in memory only. usage gives only counts, never who. It is open
to anyone who knows the space id, as its status is, so it tells them
roughly how busy the space is, and, asked again and again, when its members
are online.
Source: packages/core/src/session/hosting.ts (HostLimits, hostLimits, OverSpace, SpaceUsage, describeHost), packages/core/src/node/usage.ts (createUsageMeter), packages/core/src/node/host.ts (plan, evaluate, sweep, setFull, holdForRoom, refuse, fullUntil), packages/cli/src/fund.ts (settle, daily), packages/core/src/node/carrier.ts (skip, onArrived), packages/cli/src/host.ts (usageOf, quotaFor, statusOf), packages/cli/src/serve.ts (onPeer), packages/cli/src/host-setup.ts (limitsFromEnv). Tests: packages/core/tests/usage.test.ts, packages/core/tests/host-limits.test.ts, packages/cli/tests/host.test.ts ("a host with limits": all).
2. A space paying for itself: its fund
A space can also be a host's subscriber: its own subscription there, with a fund anyone adds to, so a community keeps itself online together. The host carries that one space as a carrier would (06 §4.1), from a pass (06 §4.2) any member's device hands over, and can read no more of it than any carrier.
The subscription. It is named space:<space id>. It has no key: anyone
may ask how it stands and add to its fund, and a pass proves itself, so
nothing about it is signed but the host's answers. A host MUST refuse a space
id that does not match ^[A-Za-z0-9_-]{1,120}$.
The fund. One balance, in millionths of a US dollar. Every payment into
it adds what was paid, whoever pays and however (once, or every month). The
host takes from it only what it says it will: keeping the space online at its
fund.perGb rate for each GB (10⁹ bytes) its space takes, and for one GB if
it takes less, for the time it does, and what the bots it runs in that space
spend (§3). The subscription is active while the balance is above zero,
then grace, then lapsed, as in §1. A grace is lent, never given, and
in money: after the balance reaches zero the host goes on taking its rate,
below zero, until it owes as much as the largest single payment ever made
into it, or for its grace period at most,
whichever comes first, and then takes nothing more, whatever the space
grows to. So a grace is what that payment would buy again at the size the
space is then, and no payment, whoever makes it, ever shortens one. A
payment that brings a fund back above zero after its grace was spent lends
the grace after it no more than it left above zero, and what is added
after, so whoever revives a fund pays for the grace they start. A fund
nothing was ever added to owes nothing. The rate follows the size as the
host last measured it, and time the host was down for more than an hour is
charged to nobody. A payment pays what is owed
first, so one that leaves the balance at or below zero turns nothing back
on, and each grace is paid for by the payment after it. So the least
payment into a large space buys a short grace. paidUntil is not a date
that was bought: it is the host's estimate of when the balance reaches zero
at what the fund spends now, and it MUST move as that changes (sooner as
bots spend more, later with each payment). Once it ran out, it is the date
the host's grace period counts from so that it ends where the fund stops
drawing, in time or in money: while its grace still runs, a payment that
leaves it owing moves it later by what that payment pays for, never past the
grace period from when it ran out; once the grace is spent, in time or in
money, such a payment moves nothing. A fund is never drawn on past it.
A host that takes payments says how in its description (§1), as fund, for
accounts' balances and communities' funds alike:
| Field | Meaning |
|---|---|
perGb | Dollars a month for each GB kept online: a fund pays for each its space takes, at least one; an account's balance for each past its free room (§1): "0.10". |
min | Dollars: the least one payment may add. |
methods | How money is added: checkout (a page at a payment provider) and/or request (a payment for a wallet). |
recurring | Whether a checkout payment may be every month. |
manage | Optional: the payment provider's page where someone stops paying every month. |
botDailyCap | Optional, when it runs bots: dollars a bot may take from the fund a day at most. |
botTypical | Optional, when it runs bots: dollars one usual bot reply takes from the fund, as the host reckons it, matching ^[0-9]{1,5}(\.[0-9]{1,4})?$: "0.038". |
lines | Optional: what it charges for, in words for people, each { for, name, price }. |
A host MUST give perGb, min, botDailyCap and botTypical as dollars
matching ^[0-9]{1,5}(\.[0-9]{1,4})?$. Every field but perGb, min,
methods and recurring is for people: a
device shows it and counts on none of it, and what the host takes is still
only what this section says. botTypical and lines are how a host says what
a person can't tell from the numbers, its model and its price per token, say,
without a new field for each. In a line, for says where it belongs:
hosting, beside keeping the space online; bots, beside each bot; any other,
on its own, and a hosting line goes beside the perGb rate, never in its
place. A device MUST treat a fund whose perGb or min does not match
^[0-9]{1,5}(\.[0-9]{1,4})?$ as absent, MUST leave out a botDailyCap or
botTypical that does not match it, a manage that is not an https: URL,
and a method it does not know, MUST show a host that is not free and has
no fund it keeps as taking no payments there, never as free, MUST
leave out a line whose for does not match ^[a-z0-9-]{1,40}$ or whose name
or price is not a string of 1 to 60 UTF-16 code units with no control or format
characters (Unicode Cc, Cf), MUST show at most the first
eight lines that remain, and MUST show them as text only.
{
"perGb": "0.10",
"min": "5",
"methods": ["checkout", "request"],
"recurring": true,
"botDailyCap": "1.50",
"botTypical": "0.038",
"lines": [{ "for": "bots", "name": "Claude Sonnet 5.5", "price": "$3 / $15 per million tokens" }]
}Calls. No Authorization.
| Call | Body | Answer |
|---|---|---|
GET /host/spaces/<space id> | — | SignedStatus (§1), subscription space:<space id>; state none before anyone paid or handed a pass |
PUT /host/spaces/<space id>/pass | { "pass": <SpacePass, 06 §4.2> } | SignedStatus. 402 when not paid (or lapsed); 403 when the host carries only named accounts; 400 when the pass does not open, or is for another space. A later pass replaces the one before. |
POST /host/spaces/<space id>/pay | { "amount": "<dollars>", "method": "checkout" | "request", "monthly"?: true } | A pay answer (§1) that adds amount to the fund. Anyone may. 400 for an amount below min, or a method it doesn't take; 403 when the host carries only named accounts. |
POST /host/spaces/<space id>/remind | { "email": "<address>" } | {}, as for an account (§1). |
amount matches ^\d{1,5}(\.\d{1,2})?$. A request for a wallet asks for
the amount plus a fraction of a cent that tells it apart (the reference host
adds 1 to 9 999 millionths); what arrives is added in full.
A host MUST check a pass as a carrier checks one from a carry space (06 §4.2):
its space must verify and hash to the id in the path, and a private space's
read key must be one the space's history names. It carries the space while
the subscription is active or grace, and drops it when it lapses unless an
account's carry space also names it. At a host with limits, an account's carry space naming it
keeps it only while it is under the free line, or before that line's until
(§1.1).
For a space's own subscription, HostStatus also has:
| Field | Meaning |
|---|---|
readKey | For a private space it carries: the read key it carries it with, as a DID. Absent otherwise. |
balance | What is in the fund, in millionths of a dollar; below zero by what its grace (and a bot's last call) took, which the next payment pays first. Absent on a free host. |
daily | What the fund spends a day as things go, in millionths of a dollar: the hosting rate and its bots'. |
bots | The bots it runs in the space (§3): [{ "bot": "<DID>", "name": "<name>", "running": true, "daily": 65000 }], daily what each took from the fund a day over the last week, in millionths of a dollar (absent on a free host). |
carrying is whether it carries the space now, and spaces is 1 when it
does. A device compares readKey with the space's current read key to know
whether the host needs a newer pass, after the space's key changed. Example
payload:
{
"subscription": "space:b3kq7zp2f4mhx6ydwa5rtc9n1e",
"host": "did:key:zDnaeXL64…",
"state": "active",
"paidUntil": 1801387701,
"renews": false,
"carrying": true,
"spaces": 1,
"readKey": "did:key:zDnaejutRdfJ47…",
"balance": 23400000,
"daily": 196000,
"bots": [{ "bot": "did:key:zDnaeYffVz7N…", "name": "Club Bot", "running": true, "daily": 62000 }],
"at": 1791027701
}Which host a space uses is not protocol: the library keeps it in a
std.host record that only those who may manage the space write, and every
member's device holding the space key hands that host the pass once the
space is paid for there, and again when its key changes. See
the node.
Anyone who knows a space's id can learn whether a host carries it, what is in its fund and until when, as its status is open. A pass lets its holder download the space's records sealed, as a carrier does; any member could hand one over already.
Source: packages/core/src/node/host.ts (carrySpace, spaceSubscription, setPaidUntil), packages/core/src/node/carrier.ts (addPass, removePass), packages/core/src/session/hosting.ts (createSpaceHostClient, readPayAnswer, FundOffer, fundLines, describeHost), packages/cli/src/host.ts (answerSpace, startFund, refreshFund), packages/cli/src/hosted-bots.ts (botFundWords), packages/cli/src/fund.ts, packages/cli/src/stripe.ts (fund). Tests: packages/cli/tests/host.test.ts ("a space paying for itself": all; "bots a host runs": "a bot runs from its community’s fund"), packages/cli/tests/fund.test.ts, packages/core/tests/fund-lines.test.ts.
3. Bots a host runs
A host MAY run bots for the spaces it carries: each an account of its own
(01) whose keys the host makes and holds, joined to a space
with an invite for the role it should hold (03), writing as
itself like any member. Every member's device checks what it writes against
that role, as for anyone. It says it is a bot the way any bot does (bot: true on its profile, the standard library).
This is not carrying: a host holding a bot's keys reads what the bot may read. A device MUST say so to the person before it asks a host for a bot.
Paid from the space's fund. A bot has no subscription of its own. It runs
while its space's fund (§2) has money in it, or always on a free host, and
what it spends is taken from that fund, at most fund.botDailyCap a day.
With the fund empty, it stays a member and does nothing. What a bot does is
the space's automations naming it (automations); how
the host thinks for it is the host's business.
Calls. No Authorization: an invite is all it takes to add a bot, as it
is to add anyone, and its role is all the bot may do.
| Call | Body | Answer |
|---|---|---|
POST /host/bots | { "name": "<≤ 60 chars>", "invite": "<≤ 16 000 chars>" } | { "bot": "<its DID>", "receipt": <SignedStatus> }, the receipt the invite's space's own (§2). 404 when it runs no bots; 400 for an invite it can't read; 409 when it does not carry the space, or the space has as many bots as the host allows. |
A device MUST accept the answer only if the receipt verifies under the
host's DID and its subscription is space: and the invite's space id. The
bots a host runs in a space, and whether each is running, are in that
space's status (bots, §2), so they are signed with it.
Stopping a bot is removing it from the space: it then holds no role there, and every peer refuses what it writes.
The reference host runs bots only in spaces it carries, each reaching the space through the host's own socket, so it needs no relay.
Source: packages/core/src/session/hosting.ts (createSpaceHostClient: startBot), packages/core/src/node/node-hosting.ts (hosting.startBot), packages/cli/src/hosted-bots.ts, packages/cli/src/host.ts (answerBot, fundOf), packages/cli/src/bot-runner.ts. Tests: packages/cli/tests/host.test.ts ("bots a host runs": all).
3.1 Connecting a bot to a service
A host may connect its bots to services outside Minca, each through the service's own consent screen: GitHub, a calendar, a drive. Which services, and everything between the host and each, is the host's: its pages, what it keeps, what the bot may do there. This part fixes only the exchange between the person's app and the host around them, so that any app can connect a bot at any host. No secret of a service enters the space.
What a host offers. Its description names the services as connectors:
{
"connectors": [
{ "id": "github", "name": "GitHub" },
{ "id": "calendar", "name": "Calendar" }
]
}id is 1 to 40 lowercase letters, digits and dashes, and names the service
in the addresses and records below; name is for people, 1 to 60
UTF-16 code units with no control or format characters (Unicode Cc, Cf), as a
fund's lines (§2). A device MUST ignore an entry that isn't both.
Who may. Someone who holds /std.automation/instruct in the space and
every role the bot holds there, by the host's view of the space's access
history, read as any peer reads it; never a bot, the one in question or
another this host runs, as no integration installs another. The request is a
std.* record, which a host otherwise has no reason to read: here it is the
exchange between the person's app and the host, so this part says how the
host reads it.
The request. The person's device makes a random secret of at least 32
bytes, keeps it, and writes a std.connect record (packages/core/docs/bots.md),
kind the service's id:
{
"bot": "did:key:zDnaeYffVz7N…",
"kind": "github",
"verifier": "<base64url(SHA-256(secret))>",
"expires": "2026-10-15T12:10:00Z"
}It then opens, in a window of its own:
GET <host>/host/bots/<bot DID>/connect/<id>?space=<space id>&request=<record key>&secret=<base64url(secret)>The host MUST answer with the service's consent screen only if all of these hold, and otherwise with a page that says why:
- It offers that service, runs the bot, and the bot holds the space.
- The record at
requestis in that space, verified, not deleted, its first version (seq0), signed by its creator under a note that may do more than write records (notrecordsOnly, as an agent's note is), instd.connect. - Its
botis the bot in the path, itskindtheidin the path, it has averifier, and nodisconnect. expiresis later than now and no more than 600 seconds from now.- SHA-256 of
secretisverifier, compared in constant time. - Its writer may configure the bot, as above.
- No earlier call used that record.
Once a request passes these checks the host MUST mark it used before it
answers, so that of two calls with it at once only one passes; a call it
refuses leaves it unused, since the record may not have reached it yet. It
MUST bind the consent screen's return to the bot, the space, the service and
the writer with a one-time state of at least 32 random bytes that lasts at
most 15 minutes. It MUST also bind that return to the browser: it sets a
cookie of at least 32 random bytes on the answer (HttpOnly, SameSite=Lax,
Secure over https, scoped to where the service returns), keeps its hash
with the state, and refuses a return without it. Otherwise someone could open
the page, then send the service's link to someone else, and finish with
their consent. When the browser returns, the host MUST check again that the
writer may configure the bot.
The secret travels in the page's address, so it can reach the host's logs and the browser's history. It counts once and for ten minutes at most. Whoever holds an unused link can finish it with an account at the service they control: the link is a bearer link, as most "connect your account" links are.
What the bot says. Once connected, the bot writes its own std.connector
naming the service, the account there and what it reaches, as people read
them, so the app shows whose account it is; an app MUST show one only when
the bot it names wrote it. When a host stops acting through a connection is
its own: no peer can check it (the reference host's is in
bots).
Disconnecting. The device writes a std.connect as above with
"disconnect": true, and calls:
| Call | Body | Answer |
|---|---|---|
POST /host/bots/:bot/disconnect/:id | { "space", "request", "secret" } | { "disconnected": true }; 400, 403 or 404 with { error }, as checked |
The host MUST check the request as for connecting, except that it must ask to
disconnect, and then MUST forget the connection; the bot deletes its
std.connector.
How the reference host connects GitHub, and what a bot does with a connection, are the library's (bots): no peer can check them.
Source: packages/cli/src/host-connect.ts (createBotConnect: start, finish, disconnect, check, usable), packages/cli/src/connectors/connector.ts (HostConnector), packages/core/src/schemas/connectors.ts (mayConfigure, connect, connector), packages/core/src/session/hosting.ts (connectorOffers), packages/cli/src/host.ts (CONNECT_PATH, CALLBACK_PATH, DISCONNECT_PATH). Tests: packages/cli/tests/host-connect.test.ts (all; "a host’s own connectors"), packages/cli/tests/host-github.test.ts ("connecting a bot to GitHub": all), packages/core/tests/host-connectors.test.ts.
4. Planned: hosts
Planned. Not normative.
Storage the person already has. When the account has connected its own storage (a Dropbox or Drive folder, 05 mirrors), the device seals the storage grant (for Dropbox, a refresh token) to the host's key and hands it over with a new signed call on the subscription. The host keeps it encrypted at rest under a key outside its database, never logs it, and mirrors the carried spaces into that folder as well as its own bucket, so moving to another host means handing it the same folder. A leaked grant exposes only ciphertext, since mirrors hold records as they travel. Depends on the mirror drivers for those services. Open: Google and OneDrive give browser apps no lasting refresh token, so for them consent has to finish on the host (or in the extension); and a grant shared by device and host disconnects both when revoked at the provider, unless the host gets its own consent.
Stopping a host a space pays. A space that stops using a host (§2) can only act on its own side: it removes the host's bots and changes its key, so the host cannot follow. Nothing tells the host, so the fund keeps paying and cards that add every month keep being charged. The space will say it in
sys.keepers(03 §10), which is in the clear, needs a grant of/space/keepers, and every peer already checks: a host carries a space for the space's own subscription only while the keepers name its DID, and once they no longer do it pauses the subscription, which means it stops carrying, stops its bots there, takes nothing more from the fund and cancels payments that add every month. The balance stays and counts again if the space names the host again. No refunds, and no new call: the subscription has no key, so a call would let any pass holder stop it. Open: keepers need/space/keeperswhile choosing a host needs only a grant to writestd.host; whether a host must be named a keeper before it carries at all; whether a paused fund expires. Tracked in #220.Metering beyond storage. A host that meters requests and bandwidth per subscription says so in
HostStatusas it saysbytes. Open: the fields, and an account with several subscriptions or refused for good (#289).A reminder before time runs out. Time paid up front does not renew itself (
renews: false), so the host reminds the person 14 and 3 days beforepaidUntiland once when the grace period starts. Email is the host's own business: the reference host takes an address with…/remind(§1,packages/cli/src/reminders.ts), and every device already shows the date it holds. The protocol route is Web Push through the carry space (06 §4.4, Planned): when the person allows it, the home writes a record in the carry space, apart from wakes (03 §15), asking for reminders about hosting, and the host pushes{ kind: "hosting", host, paidUntil }, signed like a status. No new call between home and host. Depends on Web Push.Paying without being linked to the account. Today the host can tie a payment to the carry space it is then handed. Privacy Pass tokens (RFC 9576) — pay once, receive anonymous tokens, spend them for time — would cut that link. Not designed.
07 — Inbox
A DID is a name, not an address: it is on everything an account signs, so knowing it must not be enough to reach the account. Two people who share a space can become contacts through it, with a…
09 — Contracts
A contract says what a version of a collection must be to count: conditions over its body, the version before it, and other versions it cites as evidence. "A proposal passes with yes votes from…