Identity across Fronds
In one process a call is a function call: nothing crosses, nothing needs proving. Split a Frond out and the same call becomes a payload, and a payload can say anything. The question this page answers is what a receiver establishes rather than accepts.
Loopback or signed
A receiver refuses to start when it binds beyond loopback with no way to establish its caller. Not per call — at boot:
127.0.0.1 starts, nothing to configure
0.0.0.0 + FOUGERE_ROOT_KEY starts, verifies every call
0.0.0.0 with no root does not start, and says what to do
0.0.0.0 + allowUnsigned: true starts — something in front already authenticated
The address already carries the decision. Binding beyond loopback is a deliberate act —
hosts is where you write it down — and a receiver reachable from outside that establishes
nothing believes whatever state it is handed. Refusing at boot rather than per call is the
point: a receiver that starts and then rejects everything is discovered in production, one
that will not start is discovered at deployment.
What travels
Every call across a link carries an envelope, signed by the caller:
{ "method": "post.list",
"params": { "params": {}, "query": {}, "body": null,
"identity": "eyJhbGciOiJFZERTQSIs…" } }
The envelope carries the state — who is signed in, if anyone — and the receiver takes it from there rather than from the payload. Sending both would leave every reader downstream choosing between a proof and a claim about the same thing.
It binds the whole call: entity, op, params and query by value, the body by
digest. Signing the state alone would prove who without proving what, and a captured
envelope could be replayed against another operation for as long as it stayed valid —
post.list re-sent as post.delete, same signature, still good.
Two signatures, one public key
GRANT the root says "this key is blog" — issued once, at deployment
ENVELOPE blog says "here is my whole call" — signed per call
A receiver holds the root's public key and nothing else. It admits a Frond it has never seen, and a Frond granted tomorrow is admitted by a receiver deployed today without its configuration being touched. That is the difference between an authority and a list of known callers: the list has to be updated everywhere, and it goes stale.
Verification is offline — no service is joined, on any call or at any boot.
Issuing
fougere keys # once per system — writes .fougere/root.key, prints FOUGERE_ROOT_KEY
fougere grant blog # once per Frond that CALLS — prints FOUGERE_KEY and FOUGERE_GRANT
fougere keys is a command, not a service: it runs at deployment time and exits. Nothing
stays alive, nothing is joined at boot. The root's private key is written to
.fougere/root.key (mode 600, added to .gitignore) and never printed — it signs grants,
and a grant is the only thing that has to travel.
fougere grant prints and stores nothing. Re-running it issues a new key rather than
showing the old one, which is what rotation is: run it again, redeploy that Frond, and
nobody else's configuration moves.
What a deployment injects
Three variables, no configuration key — a private key does not belong in a committed file. PEM or base64, both are read.
| Where | What it is | |
|---|---|---|
FOUGERE_ROOT_KEY | every Frond that answers | public — safe in an image or a manifest |
FOUGERE_KEY | every Frond that calls | secret, shown once |
FOUGERE_GRANT | beside it | the root's word that this key is that Frond |
A Frond may hold either half or both: one that only answers has no key, one that only calls trusts no root, one in the middle does both. Trusting a root is asking to refuse — there is no second flag, because a deployment that names an authority and then accepts unsigned calls has said two things at once.
What a handler sees
Nothing new to write. The state arrives where it always did, so a
collector reading ctx.state.user is unchanged — in process,
and split. What is added is one field:
invocation.caller // 'blog' — the Frond that signed, as the root named it
It is top-level and not a key of state, and the reason decides how to read it: state
travels unverified when no verifier is wired, so a caller living inside it would be
forgeable exactly where nothing checks. Absent means not established, and there is no
spelling for a claim. A sender clears it on every hop, so it names the last one:
shop → catalog → billing has billing read catalog.
What this does not do
Authorization. A signature says who is calling, never what they may call.
if (user.role !== 'admin') throw is still yours to write.
A mesh's job. Under a service mesh the sidecar terminated mTLS before your process saw
the request, and asking for a second signature would redo what was just done a centimetre
away. That is what allowUnsigned: true is for — spelled separately from everything else,
so it can only ever mean "I thought about this".
Cross-language bodies. The body is bound by digest over its JSON text, which is the one place key order matters. A sender written in another language must emit the body the way it hashed it. Everything else is bound by value, which is what keeps a canonical-JSON dependency out of the wire.
The browser is not in this
A browser sits outside the topology and holds no key. It never signs, and nothing it sends
is taken as identity: the host resolves the session server-side from the request's own
cookie and the payload's state is dropped. That was already true and has not changed —
this page is about the link between two processes.