Sharing a bundle
A bundle has one sharing state and at most one share link. Every bundle arrives private, and a push never changes that.
The three states
- private
- Nothing answers at the share link. A guest gets the same 404 as for any other miss, so the 404 does not even say the bundle exists. The owner still sees it in the catalog.
- link
- Anyone holding the share link opens the bundle's current revision.
- pin
- The share link shows the pin gate first. The right pin opens the bundle for 12 hours in that browser.
A bundle with no revision, and a share slug nobody knows, answer the same 404 as a private bundle.
The share link
The share link is /v/<share-slug>/ on the catalog's domain. The share slug is the bundle slug until a rotate replaces it, so squad-2026 is shared at https://hosti.example/v/squad-2026/. A share link does not expire.
Under that path, a guest reads the bundle's files:
/v/x 308 to /v/x/, unless a pin gates it
/v/x/ index.html
/v/x/athletes/ athletes/index.html
/v/x/athletes 308 to /v/x/athletes/
/v/x/reports/2026-q3 reports/2026-q3.html
/v/x/assets/chart.js the file
anything else the bundle's 404.html, else Hosti's own 404
Links inside a bundle must be relative. A link written /assets/chart.js asks for the root of the domain, which is the catalog, and it will 404.
Every response under /v/ carries Cache-Control: private, no-store and frame-ancestors 'none'. A shared cache in front of Hosti must never keep one of these responses.
Setting the state
The owner sets it on the bundle's page in the catalog, /b/<slug>. An agent sets it with the CLI, which needs a push token with the share scope:
hosti share squad-2026 --mode link
hosti share squad-2026 --mode pin --pin 4821
hosti share squad-2026 --mode private
Over HTTP it is PUT /api/v1/bundles/<slug>/sharing with a body of {"mode": "private" | "link" | "pin", "pin"?: "4821"}. A mode that is not one of the three comes back 400 bad_mode.
Pins
A pin is four to eight digits, and the owner types them. Hosti never invents one. It hashes what you send with scrypt and a fresh salt, so no page and no endpoint can show a pin again. To change a pin, send new digits. To drop the gate, set the state to link.
- Asking for
pinwith no pin stored and none sent is refused, 400pin_required. Once a pin is stored,pinon its own keeps it. - A pin sent with
privateorlinkis refused, 400pin_not_wanted. - Anything that is not four to eight digits is refused, 400
bad_pin. The message never quotes what you sent. - Setting a pin needs
HOSTI_SECRETon the server. Without it the call is refused with 503.
Going to private or link clears the stored pin hash. A pin that survived going private would meet you months later as a gate whose digits you no longer remember.
What a guest sees at a pin
browser asks for a page 200, the gate, at the same URL, no redirect
anything else asks 404, the same 404 as any other miss
POST .../unlock, right 303 onward, cookie set
POST .../unlock, wrong 303 back to the gate, one error line
The gate names nothing: not the bundle title, not the collection, not whether the slug is real. The unlock cookie is hosti_pin_<share-slug>, scoped to /v/<share-slug> and good for 12 hours. It opens that one link and never the catalog.
The gate takes ten wrong pins per caller per fifteen minutes. Then it shuts that link to that caller for an hour, and while it is shut the right pin is refused too. The count lives in memory, so a restart clears it.
Rotate
Rotating mints a fresh random share slug: twelve characters, no vowels. The old address stops answering at once. It is the only way to cut off somebody who already has the link.
hosti rotate squad-2026
A rotate keeps the sharing state and the pin as they were. Rotating a private bundle only changes the address it will use later. Every pin unlock cookie belongs to the old slug, so after a rotate the fresh link asks for the pin again.
What hosti does not record
Hosti keeps no record of who opened a link. There are no counters, no hit table, no last-opened stamp and no addresses.
Bundles run on the catalog's origin
A bundle opened at /v/ runs its own JavaScript on the same origin as the catalog. While you are logged in, a script in a bundle can read the catalog pages and act as you there, including starting and approving its own agent connection. It cannot forge a push on its own, because the push API takes a bearer token no browser holds.
The fix, serving /v/ from a second hostname, is not built yet. Until it is, do not host bundles someone else generated, and check /tokens for a push token you did not approve. The catalog's own previews are sandboxed and do not carry this risk.