a login screen drawn over a LEAK
The parent lesson pitched the whole idea in one move: the file carries everything — hand it to anyone. Then your workbook grows a paid dataset, or a client's numbers, and the pitch flips into a threat. If the data rides in the file, anyone with the file has the data.
The industry's reflex is to draw a login screen over it. You have seen this artifact a hundred times: the "protected" HTML export, the password prompt in JavaScript, the dashboard that politely hides its numbers until you sign in. And you know how it ends — View Source defeats the lock, because the gate and the secret traveled in the same file. The attacker owns the client and the bytes; a guard that ships alongside the treasure guards nothing.
The parent page's FAQ names this failure and promises it never happens here. This deep dive is the how. The short version: security is not a property you bolt onto the file. It's a stance you declare — and the stance decides where the bytes are allowed to live at all.
a stance, not a FEATURE
1. a declared security stance on a workbook — public, gated_data, or gated_route — that decides which bytes may be baked into a static artifact and which may only ever exist behind the runtime, released per-request after auth.
The set is exactly three, and each answers one question — what is public: everything, the shell, or nothing?
public — fully public. The whole workbook may be rendered into a static, self-contained artifact: one HTML file, no secrets, openable anywhere forever. gated_data — the shell is public, but the data and capabilities are fetched from the runtime per-request, behind auth. The app behind the page is the runtime, not the file. gated_route — even the shell, even the workbook's existence, is private; the runtime serves it only to an authenticated caller.
The canon table, straight from the protocol doc:
| posture | shell | data / capabilities | static-host safe? |
|---|---|---|---|
| public | public | public — may be inlined | YES |
| gated_data | public | runtime-gated, per-request | NO |
| gated_route | runtime-gated | runtime-gated | NO |
Declaring one is a line of metadata: a posture key on the
workbook, or :PUBLISH_ACCESS: in publish.org.
The parser is forgiving where it's safe and strict where it matters —
public | gated-data | gated-route, underscores normalized to
dashes, case-insensitive, whitespace trimmed; an empty or missing value
parses to public; an unknown string is an error, not a guess.
A security stance is the one place a parser should refuse to be
creative.
the anti-leak RULE
Everything on this page hangs off one law, stated in the access module's own doc: a gated workbook must never be rendered into a public static artifact — because a static file ships its contents to anyone with the URL, so client-side gating is theater. The secret can't be guarded inside the artifact. It has to never enter the artifact.
The primitive that carries the law is one function,
static_safe?/1 — true for public, false for
both gated postures. Every build decision branches on it:
flowchart LR
p["the workbook's posture"] --> q{"static_safe?"}
q -- "public — true" --> bake["bake — the whole workbook
into one static html, no secrets to leak"]
q -- "gated_data · gated_route — false" --> stay["never inlined — bytes stay behind
the runtime, released per-request, after auth"]
style p fill:#ffffff,stroke:#121316
style q fill:#fbfaf6,stroke:#121316
style bake fill:#13d943,stroke:#121316
style stay fill:#a8d4f0,stroke:#121316,stroke-width:2.5px
Read the right side of that fork carefully — it's the whole design. The gated branch doesn't say "inlined behind a password". It says the bytes do not go into the file. There is no version of a gated workbook whose secrets sit in a static artifact waiting for a cleverer attacker; the artifact that exists just doesn't contain them.
One deliberate nuance: even the desktop app — running on a machine you trust — gets only a shell for gated workbooks, fetching protected content over the runtime connection. Not because your laptop is the enemy, but because a "desktop" artifact is still a file, and files get copied to web hosts. The invariant doesn't trust contexts. It trusts construction.
enforced twice, at BUILD time
An invariant that lives only in request handlers is a hope. This one is enforced where you can't route around it — in the pipeline that produces artifacts, at two separate guards.
Guard one: the publish validator. There are four publish
targets — cloudflare-pages, gh-pages,
self-hosted, desktop-app. The first two are
public static hosts, and the validator flatly rejects a gated posture
aimed at either. Here's a publish.org for a fictional gated
workbook, pointed at the wrong door:
:PROPERTIES: :PUBLISH_TARGET: cloudflare-pages :PUBLISH_PROJECT: acme-pricing :PUBLISH_ACCESS: gated-data :END:
wb publish validate fails with the error, verbatim from
the source:
PUBLISH_ACCESS: gated-data cannot publish to cloudflare-pages — a public static host ships the whole workbook to anyone (client-side gating is theater). Use self-hosted (the runtime gates per-request) or desktop-app (local shell).
Flip :PUBLISH_TARGET: to self-hosted — where
the runtime gates every request — and validation passes. The invariant is
a build error, not a runtime hope. You cannot ship the leak,
because the pipeline won't produce the file. The refusal isn't folklore,
either: it's pinned by the test suite, error string and all.
Guard two: the compiler. The desktop-app target
accepts gated workbooks — but its compiler branches on
static_safe?. Public posture: the rendered page is inlined,
plus a runtime-connect bootstrap. Gated posture: it emits a
gated shell — a generated public page with no workbook content
inside, whose entire body says the workbook is protected, please
sign in. After auth, the shell fetches the real content from the runtime
— by default from /api/w/<slug>/html. The source
comment gives the reason in one line: even a desktop artifact can be
copied to a web host.
flowchart TD
v["wb publish validate — target × posture"] --> t{"target?"}
t -- "cloudflare-pages · gh-pages" --> s{"posture?"}
s -- "public" --> ok["ship — static artifact, the whole workbook"]
s -- "gated-*" --> rej["REJECTED — build error,
the leaking file is never produced"]
t -- "self-hosted" --> ok2["ship — the runtime gates per-request"]
t -- "desktop-app" --> c{"static_safe?"}
c -- "true" --> inl["inline — rendered page
+ runtime bootstrap"]
c -- "false" --> sh["gated shell — zero payload,
auths, then fetches over the wire"]
style v fill:#ffffff,stroke:#121316
style t fill:#fbfaf6,stroke:#121316
style s fill:#fbfaf6,stroke:#121316
style c fill:#fbfaf6,stroke:#121316
style ok fill:#13d943,stroke:#121316
style ok2 fill:#13d943,stroke:#121316
style inl fill:#13d943,stroke:#121316
style rej fill:#f3c5a3,stroke:#121316,stroke-width:2.5px
style sh fill:#a8d4f0,stroke:#121316,stroke-width:2.5px
The gated shell is worth opening once, just to see the invariant made visible: a real HTML page, viewable by anyone, whose View Source contains a loading message, a sign-in prompt, and a fetch — and not one byte of the workbook. The thing the attacker downloads is honestly empty.
the twelve-line JUDGE
depth rung · skippable — the decision function, cell by cell
At request time, the posture meets a tiny total function:
Workbooks.Access.enforce(posture, demand, identity), which
returns :allow or {:deny, :auth_required}. The
demand vocabulary has three words: :shell — the app
and its structure; :data — the protected content and
capabilities; :full — both, inlined. Three postures, three
demands, one identity — the whole decision is a matrix:
| posture | :shell | :data | :full |
|---|---|---|---|
| public | anyone — even anonymous | anyone | anyone |
| gated_data | anyone | authenticated only | authenticated only |
| gated_route | authenticated only | authenticated only | authenticated only |
And "authenticated" is strict: the identity must be a map with a
binary user_id that is not nil, not empty, and
not "dev". The anonymous dev fallback — the identity
every local request gets when nothing stronger is presented — explicitly
does not count. These are the actual assertions from the test suite:
Access.enforce(:public, :full, nil) #=> :allow
Access.enforce(:gated_data, :shell, nil) #=> :allow
Access.enforce(:gated_data, :data, nil) #=> {:deny, :auth_required}
Access.enforce(:gated_data, :data, %{user_id: "u-1"}) #=> :allow
Access.enforce(:gated_data, :data, %{user_id: "dev"}) #=> {:deny, :auth_required} — dev never unlocks
Access.enforce(:gated_route, :shell, nil) #=> {:deny, :auth_required} — even the shell
The dev-identity line is the trap worth memorizing. Local development cannot fake its way past a gate — so "it worked on my machine" can never quietly mean "the gate was open on my machine". If gating appears to work in dev, it's because you presented a real identity, not because the judge went easy on you.
five rungs to an IDENTITY
The judge needs an identity to judge. Identities come out of the auth plug — a ladder of five rungs, checked top to bottom, first match wins, with the available rungs gated by the deploy's tenancy mode.
First — the public allowlist. /health and the
/.well-known discovery docs are always open, for a
bootstrap reason the source spells out: a client must be able to read
the capabilities document to learn which rung it needs before
it has any credential. The handshake at
/.well-known/workbooks-runtime advertises
auth.rung — oidc-jwt on a multi-tenant deploy,
trusted otherwise — plus the issuer and JWKS URL.
Second — the desktop per-boot token. Only when the runtime
boots in desktop mode: 24 random bytes, Base64url, minted fresh every
boot, compared in constant time, scoping to the local tenant. The token
reaches the desktop shell through a discovery file —
runtime.json, permissions 0600, holding port, token, pid,
and scheme. No accounts, no config: possession of the local file is the
credential, and it expires with the process.
Third — the shared-secret lock. Set
WB_PUBLIC_BEARER and the deploy is locked:
Authorization: Bearer with that exact secret maps to the
configured tenant, and any other or absent bearer is a 401 — with
no dev fallback. This is the lock for a personal cloud deploy:
one secret, one tenant, nothing anonymous.
Fourth — the JWT rung. Production multi-tenant: BetterAuth-issued
JWTs, verified RS256 against a JWKS URL in production or HS256 from a
shared secret in local testing. The claims carry the user, the
organization as tenant — falling back to the user for solo accounts —
and the session; tokens live an hour. On a multi-tenant deploy with no
bearer at all, the answer is 401 tenant_required, because
isolation can't rest on a spoofable header.
Fifth — the dev fallback. Only when the deploy is neither
locked nor multi-tenant: an x-tenant header, or just
"dev", becomes the tenant. Note the shape it produces — a
user_id of "dev" — which is exactly the identity the judge
refuses. The bottom rung gets you in the door for development;
it never gets you through a gate.
flowchart TD
req["request arrives"] --> al{"path on the public allowlist?
/health · /.well-known/…"}
al -- "yes" --> open["open — read the capabilities doc
before you hold any credential"]
al -- "no" --> dk{"desktop mode + bearer
= the per-boot token?"}
dk -- "yes" --> t1["identity — tenant local"]
dk -- "no" --> lk{"WB_PUBLIC_BEARER set?"}
lk -- "yes — bearer matches" --> t2["identity — tenant from WB_TENANT"]
lk -- "yes — anything else" --> e1["401 — locked, no dev fallback"]
lk -- "no" --> jw{"bearer is a JWT?"}
jw -- "valid" --> t3["identity — user + organization as tenant"]
jw -- "none, multi-tenant" --> e2["401 tenant_required"]
jw -- "none, single-tenant" --> dv["dev fallback — user_id dev
never satisfies a gate"]
style req fill:#ffffff,stroke:#121316
style al fill:#fbfaf6,stroke:#121316
style dk fill:#fbfaf6,stroke:#121316
style lk fill:#fbfaf6,stroke:#121316
style jw fill:#fbfaf6,stroke:#121316
style open fill:#13d943,stroke:#121316
style t1 fill:#13d943,stroke:#121316
style t2 fill:#13d943,stroke:#121316
style t3 fill:#13d943,stroke:#121316
style e1 fill:#f3c5a3,stroke:#121316
style e2 fill:#f3c5a3,stroke:#121316
style dv fill:#d9dbd3,stroke:#121316,stroke-width:2.5px
Every deny on the ladder speaks one envelope —
{"error": {"code", "message", "retryable"}} — from a closed
vocabulary of seven codes, with unauthorized and
tenant_required both mapping to HTTP 401. Uniform refusals
matter more than they look: they're what keeps a denied request from
becoming an information channel, which the next section makes
concrete.
watch the gate HOLD
depth rung · skippable — posture enforcement, live, end-to-end
The clearest place to watch posture, judge, and ladder work together is the sealed-bundle key release. The bundles lesson taught the cipher layer: a sealed entry ships as real AES-256-GCM ciphertext, one fresh key per entry, and the key never rides in the bundle — it sits in an escrow table at the runtime, keyed by workbook id and entry path. This page owns the other layer: who gets the key, and when.
The release endpoint is POST /rcp/key/:key_id. A sealed
entry is gated by definition, so the route asks the judge exactly one
question — enforce(:gated_data, :data, identity) — and
branches on the answer:
sequenceDiagram
participant C as client — holds the sealed bundle
participant R as the runtime
participant A as Access.enforce
participant E as the key escrow
C->>R: POST /rcp/key/wb-acme:c2VjcmV0Lmh0bWw
R->>A: enforce(:gated_data, :data, identity)
alt identity is real
A-->>R: :allow
R->>E: fetch the entry's key
E-->>R: 32-byte key
R-->>C: key_id · algo aes-256-gcm · key
else anonymous, or dev
A-->>R: deny — auth_required
R-->>C: 401 — the same envelope as any unauthorized call
end
Two curls against a runtime holding an escrowed key tell the whole story. Anonymous first — the gate holds:
$ curl -s -X POST https://rt.example.com/rcp/key/wb-acme:c2VjcmV0Lmh0bWw
{"error":{"code":"unauthorized","message":"unauthorized","retryable":false}}
# authenticated — here, a locked deploy's shared-secret rung
$ curl -s -X POST -H "Authorization: Bearer $WB_PUBLIC_BEARER" \
https://rt.example.com/rcp/key/wb-acme:c2VjcmV0Lmh0bWw
{"key_id":"wb-acme:c2VjcmV0Lmh0bWw","algo":"aes-256-gcm","key":"<base64, 32 bytes>"}
The key id shape is real — workbook id, a colon, the entry path base64url-encoded. And notice what the deny doesn't say: the anonymous refusal is byte-identical to any unauthorized call on the runtime, whether the key exists, doesn't exist, or never did. Fail closed, no oracle — a probing client learns nothing from being refused. Only after auth does a missing key become an honest 404, because at that point you've earned a real answer.
The escrow side completes the picture: one fresh key per sealed entry — so revoking one doesn't unlock the rest — and revocation is a delete from the table. The source calls the design a dead-man switch, and names what it is not: not obfuscation, not a forked format, not a client-side guard — all bypassable, because the attacker owns the client and the bytes. The real gate is a key the client lacks.
which posture is YOURS?
The rubric is shorter than the theory. Ask one question: what must stay private — nothing, the data, or the existence?
| your workbook | posture | where it can publish |
|---|---|---|
| a demo, a report, a brand book — share-with-anyone is the point | public | anywhere — static hosts included |
| a free shell over paid or private data — try the app, auth for the numbers | gated_data | self-hosted · desktop-app |
| an internal tool whose existence is private | gated_route | self-hosted · desktop-app |
And the price tag, stated plainly: a gated workbook needs a reachable runtime. The hand-it-to-anyone story still applies to the shell — a gated_data shell is a real public page — but the gated parts go inert without an engine answering per-request. That's not a limitation snuck in beside the feature; it is the feature. The only way the file can't leak the secret is if the file doesn't have the secret, and then something else must.
One contrast to keep the vocabulary clean: a posture answers who may read this. Provenance — who made it, and whether it's been touched — is a different question with a different mechanism, the signatures story. A workbook can be public and signed, or gated and unsigned; the two axes don't trade.
why the default is PUBLIC
depth rung · skippable — the fail-open objection, answered
Look up the posture of a workbook that never declared one and you get
public. Every security instinct you have just flinched —
fail-open is wrong, defaults should deny. The module's own doc makes the
counter-argument, and it's worth making rather than hiding: an
unregistered workbook carries no server-side secrets to leak. It
is its static self — everything it has is already in the file,
which is the parent lesson's whole deal. There's nothing behind the
runtime to protect, because nothing was ever put behind the runtime. A
workbook becomes gated the only way it can mean anything: by explicitly
declaring it, at which point the invariant takes over and the static
paths slam shut.
Fail-open would be wrong if the default could expose something. It can't — declaring the posture and possessing the secret arrive together.
Belt and braces, the publish validator also sniffs for the
mistake the default can't cover: a secret pasted into
publish.org itself. Property keys ending in
TOKEN, KEY, SECRET,
ACCOUNT, PASSWORD, or CREDENTIAL,
and values that look like long hex strings, draw a warning: use an env
var instead of storing it in publish.org. The posture system keeps
secrets out of artifacts; the warnings keep them out of the config that
travels with the project.
where the wiring ENDS
Honesty section. Four edges, stated plainly.
The per-route registry is a follow-on. The judge exists, is tested, and is live where it matters most — the key release and the publish pipeline both call it. But wiring every serving route to a per-workbook posture registry needs workbook metadata to carry the posture everywhere, and that wiring is still in progress. The contract and the primitive are real; universal coverage isn't yet.
gated_route is the least-wired stance. Its contract is exact — even the shell requires auth, and the judge enforces it in one line — but of the three postures it has the least end-to-end serving path behind it today. Treat it as a declared contract with an enforcement primitive, not a finished feature.
A locked deploy is a shared secret, not identities.
WB_PUBLIC_BEARER gives you one credential for one tenant —
everyone holding it is the same caller. As the auth cascade reads, a
locked deploy accepts the shared secret, not JWTs — lock-mode and
per-user JWT mode are different deploys, not layers of one. If you need
to know which user opened the gate, you need the JWT rung.
Gating binds you to a runtime. Said once more, because it's the honest cost: public workbooks are open anywhere forever; gated ones are alive exactly as long as a runtime answers for them. Real gates need a gatekeeper with a power supply.
questions people actually ASK
Can't I just hide the data with JavaScript?
You can write the code, but it isn't a gate — the data and the check ship in the same file, and View Source reads both. The system's word for this is theater, and it refuses to participate: aim a gated posture at a static host and the validator fails the build before the leaking artifact exists.
What does someone holding my gated bundle actually have?
Ciphertext and key references. The sealed entries are AES-256-GCM with the keys escrowed at the runtime; the manifest names which key unlocks which entry, never the key itself. The useful thing they hold is an invitation to authenticate.
Does x-tenant: alice in dev unlock gated data?
No. The dev fallback produces the one identity the judge explicitly rejects — gates require a real, authenticated user, and the test suite pins it. Development convenience gets you a tenant to work in, never a pass through a gate.
Can I change a workbook's posture later?
Yes — it's a declaration, one metadata line, and the guards re-read it on the next build and the next request. One arrow of time though: going public-to-gated protects what hasn't shipped yet. Bytes already published into static artifacts were public when they left, and no declaration recalls them.
What's the difference between a posture and a sealed entry?
Stance versus mechanism. A posture is the declared decision — who may read what, and where bytes may live. A sealed entry is one enforcement mechanism under it — ciphertext in the artifact, key in escrow, released only when the judge allows. The posture decides; the seal carries the decision into the bytes.
Why is there no per-file posture inside one workbook?
There is a finer grain, but it's the seal, not the posture: a mostly public bundle can carry specific entries as ciphertext, each with its own revocable key. The posture stays per-workbook because it answers a routing question — what may this artifact contain at all.
keep GOING
Postures are the decision layer. The layers above and below each have their own lesson.