Public Pages That Show Live Data
The sanctioned shape for “anonymous visitors open a link and see real data” — and why the obvious-looking shortcuts return 401 or 422.
The visibility model, in one minute
Every element has a visible_to list that governs who can see it. Frontends
(spa, ssr, view) and apps can be made public — that is how a live page
serves to a logged-out visitor. But actions can never be public: a python
or javascript element caps at collaborator/audience visibility, and trying to
set visible_to: [public] on one returns 422 VISIBILITY_NOT_ALLOWED.
That cap is deliberate. An action runs arbitrary code in a sandbox on the
owning circle’s billing — a directly-public action would be an unauthenticated
remote-code-execution-and-spend surface. For the same reason, an anonymous
browser calling an action’s ops/invoke directly gets 401 — invoke
requires an authenticated circle member.
So a public page can render, but its fetch() to a sibling action bounces.
Two locks, both intentional. The platform’s answer is a third element.
The pattern: an io/http gateway is the public seam
Put an io/http element between the public page and the action. Its receive
operation is the one sanctioned unauthenticated entry point on the platform
(auth: none — designed for webhooks and public endpoints), and it dispatches
to the flow you wire behind it:
anon browser
│ POST /api/{circle}/{gateway}/ops/receive {"n": 7} (no auth header)
▼
io/http "gateway" spec.auth.type: none | bearer | api_key
│ dispatches spec.target_ref
▼
actions/python "calc" visible_to stays collaborator — never public
▼
result flows back to the browser
Build steps:
- Create the action as usual; leave its visibility alone.
- Create the gateway:
element_type: httpwithspec: { auth: { type: "none" }, target_ref: "<action-slug>" }, then enable it. - The public page fetches
POST /api/{circle}/{gateway}/ops/receivewith the payload. Every element operation lives at exactly this shape —/api/{circle}/{element}/ops/{operation}. There is no un-prefixed form and no form without the/ops/segment. An earlier revision of this guide omitted both, publishing a path that is not a route at all — it falls through to the frontend-serve router and answers 405. - Make the frontend/app public (
visible_to), promote demo → live.
Why this is secure rather than a loophole: the action’s own surface stays 401
to the world — reach exists only through the seam you explicitly authored, and
you choose its posture. auth.type: none is fully open (rate-limited and
billed to the circle); for anything beyond a demo, use bearer/api_key (a
shared secret) or enable HMAC body verification so the gateway rejects
unsigned requests before the action ever runs.
Verify like an attacker would: an anonymous POST to the gateway returns the
real result; an anonymous POST to the action’s ops/invoke still returns
401. Both together prove the page works and the action is not exposed.
Reads too, not just writes
The same seam pattern serves “the page lists live records”: point the gateway
(or an automation step behind it) at the read path — for tabular data that is
the SQL element’s query op — instead of having the page call a data element
directly with no auth. See
Reading and writing data from action code for why the
flow, not sandboxed action code, should own database access.
What “done” looks like
A public data page is finished when a logged-out browser (fresh profile, no cookies) can load the live URL, submit or fetch through the gateway, and see real data come back — and the action still rejects direct anonymous invokes. If any of those checks was skipped, the page may still be bouncing strangers to login while looking perfectly healthy to its logged-in author.