InfiniteOceanv2

Publishing sites

Turn a route into a public website on view2.infiniteocean.io by sharing it with @view, and register custom domains with /v2/domains.

A route becomes a public website when you share it with the principal @view. Every entity in that route that carries a _body field becomes one page, served without a key at https://view2.infiniteocean.io/{route}/{key}.

There is no /v2/publish endpoint — POST /v2/publish returns 404 NOT_FOUND. Publishing is an ACL operation: PUT /v2/access/{route}/shares/@view. Unpublishing is DELETE on the same path.

Quick start

Three calls: write a page, publish the route, fetch it. Pick a route name of your own — the first account to write to a route claims it, and a later write by a different account is refused. The responses below are the real output of this sequence on the route _docs_probe/qs1787594494; substitute your own name everywhere it appears.

# 1. Write the page. The HTML goes in payload._body
curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/qs1787594494","key":"index",
       "payload":{"_body":"<!doctype html><title>Hello</title><h1>Hello, world</h1>"}}'

# 2. Publish the route (the route is percent-encoded; so is the @ in @view)
curl -s -X PUT "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fqs1787594494/shares/%40view" \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'

# 3. Fetch it — no key
curl -s https://view2.infiniteocean.io/_docs_probe/qs1787594494/

Real responses from that exact sequence:

{"ok":true,"seq":1787577342970143,"offset":0,"len":143,"ts":1787594499534,
 "state":"accepted","rule_created":true,"drop_id":"1787577342970143",
 "route":"_docs_probe/qs1787594494","key":"index"}

{"ok":true,"route":"_docs_probe/qs1787594494","pid":"@view","perm":"read","changed":true}

<!doctype html><title>Hello</title><h1>Hello, world</h1>

"rule_created":true in the first response means that write claimed the route for your account. Writing to the same route again returns "rule_created":false.

How it works

One entity is one page. When you share a route with @view, the server copies every current entity in that route into a public object store, keyed by the URL path a browser would ask for. After that, every write to the route updates its published copy as part of the write — you do not re-publish.

Consequences worth knowing up front:

The URL your pages get

Base host is https://view2.infiniteocean.io. The path under it is your route, then the entity key.

RouteEntity keyURL
_docs_probe/sitedemoindex/_docs_probe/sitedemo/
_docs_probe/sitedemoabout/_docs_probe/sitedemo/about
_docs_probe/sitedemostyle.css/_docs_probe/sitedemo/style.css

The key index is the route's front page. It is served at the directory URL and also at /index.html.

The trailing slash matters for multi-segment routes

Measured, not inferred:

curl -o /dev/null -w '%{http_code}\n' https://view2.infiniteocean.io/_docs_probe/sitedemo/
200

curl -o /dev/null -w '%{http_code}\n' https://view2.infiniteocean.io/_docs_probe/sitedemo
404

A route with a / in it serves its index only at the URL with the trailing slash. Without the slash you get a miss. The same result was measured on a second multi-segment route (_docs_probe/hello): 200 on three tries with the slash, 404 on three tries without it.

A single-segment route (docsprobesingle) serves at /docsprobesingle, /docsprobesingle/ and /docsprobesingle/index.html — all three returned 200.

Always link to the trailing-slash form, and use relative links inside your HTML so they resolve under the directory.

Writing page content

Pages are normal drops. The only special field is _body, whose string value becomes the exact bytes served.

curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/sitedemo","key":"about",
       "payload":{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}}'
{"ok":true,"seq":1787577342970140,"offset":0,"len":136,"ts":1787594492258,
 "state":"durable","rule_created":false,"drop_id":"1787577342970140",
 "route":"_docs_probe/hello","key":"index"}

state is "accepted" or "durable" depending on how far the write has got when the response is written. Both mean the write succeeded; neither changes what gets published.

A 32 KB HTML body was written and served back byte-for-byte (32027 bytes downloaded). Nothing larger was tested — see the limits note at the end.

Content-Type comes from the entity key's extension

The served Content-Type is derived from the file extension in the URL, not from anything in the payload. Name your keys the way you want them served.

Entity keyURL requestedContent-Type returned
index/_docs_probe/sitedemo/text/html; charset=utf-8
about/_docs_probe/sitedemo/aboutapplication/octet-stream
about/_docs_probe/sitedemo/about.htmltext/html; charset=utf-8
style.css/_docs_probe/sitedemo/style.csstext/css; charset=utf-8
feed.json/_docs_probe/sitedemo/feed.jsonapplication/json

An extensionless key other than index is served as application/octet-stream, and every response carries x-content-type-options: nosniff, so a browser will download it rather than render it. Appending .html to the URL serves the same bytes as text/html — measured for about, feed and even style.css (/style.css.html returned 200 text/html). The reliable fix is to name the key about.html and link to that.

_content_type in the payload has no effect on what is served. A key named feed written with {"_body":"...","_content_type":"application/json"} was served as application/octet-stream. The same bytes written under the key feed.json were served as application/json. Use the key name.

Updating a page

Write the same route and key again. The published copy is updated as part of the write — there is no second call and no cache purge step. In testing the new bytes were already live on the first fetch after the write returned, and on every fetch for the next six seconds.

curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/qs1787594494","key":"index",
       "payload":{"_body":"<!doctype html><h1>Updated</h1>"}}'

curl -s https://view2.infiniteocean.io/_docs_probe/qs1787594494/
<!doctype html><h1>Updated</h1>

That was measured on an HTML page, whose responses carry cf-cache-status: DYNAMIC. A file with a static extension such as .css can be served from the Cloudflare edge cache (cf-cache-status: HIT), so an update to one of those may not be visible immediately. See the unpublish section.

What is not published

Two kinds of entity in a published route stay private. Both were written into a freshly published route and then fetched five times each on the public host; every fetch was a miss (504 or 404 — see "Behaviour of the public host" below):

Both are still readable through the API with your key:

curl -s "https://api2.infiniteocean.io/v2/entity/_docs_probe%2Fqs1787594494/_hidden" \
  -H "X-Ocean-Key: $OCEAN_KEY"

{"_body":"<p>secret</p>"}

Checking whether a route is published

GET /v2/access/{route} returns the route's rule. A @view entry in shares means published.

curl -s "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fsitedemo" \
  -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"route":"_docs_probe/sitedemo","owner":"9a92e0aed98f",
 "owner_handle":"ftft1787581023h","enforced":true,
 "shares":[{"pid":"@view","perm":"read","expires_at_ms":0,"expired":false}]}

An unpublished route returns the same shape with an empty shares array.

There is no endpoint that lists only your published routes. Use GET /v2/routes for your routes, then check each rule.

GET /v2/access/{route} takes no query parameters. An unrecognised one is a hard 400, not an ignored extra:

curl -s "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fsitedemo?expand=1" \
  -H "X-Ocean-Key: $OCEAN_KEY"

{"ok":false,"error":"unknown query parameter \"expand\" — nothing was applied","code":"UNKNOWN_PARAM"}

Unpublishing

curl -s -X DELETE \
  "https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
  -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"route":"_docs_probe/rwtest2","share_removed":true,"changed":true}

The route stops serving straight away: / and /a.html both became misses (504) within two seconds. Calling DELETE again is safe and returns "changed":false. Calling it on a route that has no access rule at all returns 404 NO_RULE.

Cloudflare's edge cache is not purged. In the same test, /_docs_probe/rwtest2/s.css still returned 200 after unpublishing, with cf-cache-status: HIT. HTML responses carry cf-cache-status: DYNAMIC and stop immediately; static extensions such as .css, .js and images can keep serving from the edge until the cache entry ages out. Do not rely on unpublish to make a cached asset unreachable to someone who already has the URL.

Errors and traps you will actually hit

The route must exist first

You cannot share a route nothing has been written to. The first POST /v2/drop to a route creates its access rule (the response shows "rule_created":true).

curl -s -X PUT \
  "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fnever-claimed-xyz2/shares/%40view" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'
404
{"ok":false,"error":"no access rule on this route — nothing was applied","code":"NO_RULE"}

Do not send perm "read-write" for @view — it unpublishes the site

@view accepts only "perm":"read". Sending "read-write" returns 400, and the error text says nothing was applied — but the published copies are deleted anyway, while the rule keeps showing @view. The site stops serving and the ACL says it is published.

curl -s -X PUT \
  "https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read-write"}'
400
{"ok":false,"error":"invalid rule mutation — nothing was applied","code":"INVALID_INPUT"}

Observed on a freshly published three-page route immediately after that call: / and /a.html both became misses (504 on 9 of 10 samples, 404 on the other), while GET /v2/access still listed the @view share with "perm":"read". Only /s.css kept returning 200, with cf-cache-status: HIT — an edge-cached copy, not evidence the object survived.

Recovery, and it is the same call that publishes: re-issue the share with "perm":"read". It re-copies every current entity. The response says "changed":false because the rule row did not move, but the pages come back.

curl -s -X PUT \
  "https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'

{"ok":true,"route":"_docs_probe/rwtest2","pid":"@view","perm":"read","changed":false}

All three URLs returned 200 again within three seconds. This makes a plain re-publish the general repair for a site whose pages are missing.

An expired @view share does not unpublish the site

The share body accepts expires_at_ms, and the ACL honours it in its own reporting — but the public host keeps serving.

curl -s -X PUT \
  "https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read","expires_at_ms":1787594892363}'

{"ok":true,"route":"_docs_probe/rwtest2","pid":"@view","perm":"read","changed":true}

Eight seconds after that timestamp passed, the rule reported the share as expired:

{"ok":true,"route":"_docs_probe/rwtest2",...,
 "shares":[{"pid":"@view","perm":"read","expires_at_ms":1787594892363,"expired":true}]}

and the site still returned 200 — four fetches of / and three of /a.html, all 200. Do not use expires_at_ms to schedule an unpublish. The only thing that takes a site down is DELETE .../shares/@view.

Body validation

The share body accepts perm and expires_at_ms. Any other field is rejected — note that the server's own error text says "only perm", which understates what it accepts. These fail before anything is touched, so a published site is not affected:

BodyStatusResponse
{}400{"ok":false,"error":"body must include perm (read|read-write) — nothing was applied","code":"MISSING_FIELD"}
{"perm":"read","public":true}400{"ok":false,"error":"unknown body field — body must include only perm (read|read-write); nothing was applied","code":"UNKNOWN_FIELD"}
no X-Ocean-Key401{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}

Path encoding

All four of these forms were accepted for the same route and principal, and all normalised to "pid":"@view":

/v2/access/_docs_probe%2Fhello/shares/%40view    # both encoded
/v2/access/_docs_probe%2Fhello/shares/@view      # literal @
/v2/access/_docs_probe/hello/shares/%40view      # literal /
/v2/access/_docs_probe%2Fhello/shares/view       # no @ at all

Use the fully encoded form.

Behaviour of the public host

Custom domains

/v2/domains is a registry that binds a hostname you own to one of your routes. Registration is bookkeeping — it does not configure DNS or TLS for you, and see the limitation at the end of this section before you plan around it.

GET /v2/domains

Lists domains registered by the presenting key. No parameters.

curl -s https://api2.infiniteocean.io/v2/domains -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"domains":[
  {"domain":"ft-ft1786397602.example.com","route":"ft1786397602/site"},
  {"domain":"ft-ft1786401042.example.com","route":"ft1786401042/site"}
]}

The array holds one object per registration, with exactly the two fields shown. There is no status, verification or certificate field.

POST /v2/domains

Required fields: domain and route, both strings. The convention is to bind example.com to the route sites/example.com.

curl -s -X POST https://api2.infiniteocean.io/v2/domains \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"domain":"docs-probe-1787594673.example.com",
       "route":"sites/docs-probe-1787594673.example.com"}'
{"ok":true,"domain":"docs-probe-1787594673.example.com",
 "route":"sites/docs-probe-1787594673.example.com"}

Re-posting the same pair with the same key returns the same 200 — registration is idempotent for the owner.

Validation errors:

RequestStatusResponse
{"domain":"foo.infiniteocean.io","route":"sites/foo"}400{"ok":false,"error":"domain is not a valid hostname — nothing was applied","code":"INVALID_DOMAIN"}
{"domain":"localhost","route":"sites/localhost"}400same INVALID_DOMAIN — at least two labels are required
{"domain":"-bad.example.com",...}400same INVALID_DOMAIN — a label may not start or end with a hyphen
{"domain":"x.example.com"}400{"ok":false,"error":"route required — nothing was applied","code":"MISSING_FIELD"}
no X-Ocean-Key401{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}

infiniteocean.io and any subdomain of it are refused.

The POST response echoes the casing you sent; the stored value is lowercased. Posting UPPER-1787594675.example.com returned "domain":"UPPER-1787594675.example.com", but GET /v2/domains then listed it as upper-1787594675.example.com. Compare domains case-insensitively; do not key your own records off the POST response verbatim. DELETE accepts either casing.

DELETE /v2/domains/{domain}

curl -s -X DELETE \
  "https://api2.infiniteocean.io/v2/domains/docs-probe-1787594673.example.com" \
  -H "X-Ocean-Key: $OCEAN_KEY"

{"ok":true,"removed":true}

A domain not registered to your key — including one you already deleted — returns 404:

{"ok":false,"error":"domain not registered to this account","code":"NOT_FOUND"}

Publishing the route behind a custom domain

Registering the domain does not publish anything. The route still needs its @view share, exactly as above:

curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"sites/docs-probe.example.com","key":"index",
       "payload":{"_body":"<h1>custom host root</h1>"}}'

curl -s -X PUT \
  "https://api2.infiniteocean.io/v2/access/sites%2Fdocs-probe.example.com/shares/%40view" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'
{"ok":true,"route":"sites/docs-probe.example.com","pid":"@view","perm":"read","changed":true}

Once published, the content is reachable on the shared host under the route path:

curl -s https://view2.infiniteocean.io/sites/docs-probe.example.com/
<h1>custom host root</h1>

What is not proven about custom domains

Serving over a real customer hostname could not be verified from outside, so treat everything past registration as unconfirmed:

Register the domain and publish the route by all means — those calls work. Confirm certificate issuance and hostname serving with InfiniteOcean before you point production DNS at it.

Reading your own pages back through the API

Publishing does not change the API view of the data. Both of these need your key, and both list entities the public host will not serve.

curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe%2Fsitedemo?limit=20" \
  -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[
  {"key":"_hidden","ts":1787593207998,"value":{"_body":"<p>secret</p>"}},
  {"key":"about","ts":1787592742098,"value":{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}},
  {"key":"feed","ts":1787592806926,"value":{"_body":"{\"items\":[1,2,3]}","_content_type":"application/json"}},
  {"key":"feed.json","ts":1787592832854,"value":{"_body":"{\"items\":[1,2,3]}"}},
  {"key":"index","ts":1787592794758,"value":{"_body":"<!doctype html><html><head>...</html>"}},
  {"key":"plaindata","ts":1787592776230,"value":{"title":"not web content","n":1}},
  {"key":"style.css","ts":1787592768686,"value":{"_body":"body{font-family:system-ui;max-width:40rem;margin:4rem auto}"}}
],"returned":7,"complete":true,"index":true,"next_cursor":null}

(The index value is abbreviated above; everything else is verbatim.)

curl -s "https://api2.infiniteocean.io/v2/entity/_docs_probe%2Fsitedemo/about" \
  -H "X-Ocean-Key: $OCEAN_KEY"

{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}

/v2/list/ rejects unknown query parameters the same way /v2/access/ does — ?published=true returns 400 UNKNOWN_PARAM. There is no query parameter that filters by published state.

Checklist for a working site

  1. Pick a route name of your own and write an index entity with payload._body. Check "rule_created":true on the first write.
  2. Name every other key with the extension you want served: about.html, style.css, data.json.
  3. Publish once with PUT /v2/access/{route}/shares/%40view and exactly {"perm":"read"}. Never send read-write.
  4. Link with relative URLs and always include the trailing slash on directory URLs.
  5. Keep non-page data in a different route — anything with a _body in the published route is public.
  6. To update, write the drop again. To repair, re-issue the same publish call.
  7. To take it down, DELETE .../shares/%40view — not an expires_at_ms, which does not stop serving. Expect edge-cached static assets to outlive the unpublish.

When a published page reaches every node

A page you publish is served from more than one machine, and the other machines do not necessarily have it the instant you write it. Two things are worth knowing before you rely on it.

The write reaching storage and every node serving it are different events. Storage is immediate. Nodes catch up afterwards, and while a node is behind, the request still succeeds: you receive HTTP 200 and a complete, valid page — the previous one. It is not a fallback, not a degraded mode, and not an error being suppressed. Nothing on the serving side considers anything to have gone wrong, so nothing reports it.

We are not publishing a catch-up time. Any figure we printed would be read as a promise, and the honest position is that we have not measured this often enough to make one. If a customer is about to look at something you have just published, verify it rather than assuming — the per-node check below takes seconds.

Checking it yourself

Requesting the public URL repeatedly tells you less than it appears to, and a cache-busting query string tells you least of all: it changes the URL, not which server answers. To see what is actually happening, ask each node directly:

curl -sk --resolve yourdomain.example:443:<node-ip> https://yourdomain.example/page/

Mind the trailing slash — it changes what you are asking for. /page and /page/ do not resolve to the same thing: one is a key on the parent route, the other addresses the sub-route. Two people comparing results, one with a trailing slash and one without, are measuring different objects and will disagree for reasons that have nothing to do with propagation.

Nodes disagreeing after a publish is expected and is not by itself a fault. What is worth reporting is a difference that does not resolve once the route sees traffic again, or a page that is out of date on every node.

Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.