Cells/Access Control (Gate)

Access Control (Gate)

By default every Cell is public: anyone who knows the URL can call it. A gate puts an access policy in front of a Cell's public URL, enforced at the edge before your code runs. Visitors who don't satisfy the policy get a hosted sign-in or denial page; your Cell never sees the request.

A gate needs no code changes. You declare who may enter; the platform handles login pages, magic links, sessions, and revocation.


Audiences

The policy's audience says who gets in:

Audience Who may access the Cell
link Only holders of a capability link you mint (tal_ token, shared as a URL)
private Only you — sign in with your Tissue account
account Any member of the Cell's account
emails Listed email addresses (allow), verified by emailed one-time code
domain Anyone with an email under the listed domains (domains), e.g. yourco.com
public No restriction (gate configured but open — useful as a staging state)

Two extra knobs apply to every audience:

  • allow — extra email addresses admitted alongside the main audience.
  • links = true|false — whether capability links are accepted (default true).

Gating a Cell from ribo.toml

Add a [gate] block and deploy:

[cell]
name = "internal-dash"
js   = "./cell.js"

[gate]
audience = "domain"
domains  = ["yourco.com"]
allow    = ["contractor@example.com"]   # extra individual allowance

[gate.session]
ttl = "7d"        # session lifetime, 1h–90d (default 30d)

The policy rides along with ribo deploy and is validated before anything is written — a typo in the gate config fails the deploy rather than deploying the Cell open.

Simplest possible gate — share with specific people via links only:

[gate]
audience = "link"

Deploys are the source of truth for ribo.toml gates. A deploy whose ribo.toml has a [gate] block replaces the Cell's stored policy; a deploy without one removes a previously deployed gate. A policy set via the API or dashboard (source api) is different: deploys without a [gate] block leave it alone.


For audience = "link" (or any gate with links enabled), mint a shareable URL:

$ ribo gate link internal-dash
✓ Created link ab12cd34ef56 for internal-dash

  token:  tal_ab12cd34ef56.…
  url:    https://internal-dash.strand-9c.tissue.dev/?k=tal_ab12cd34ef56.…

  expires:  in 30d
  This is the only time the token is shown — anyone with the URL gets in.

The token is shown once and never stored in recoverable form — copy it then, or revoke and mint a new one. Opening the URL exchanges the token for a browser session; the ?k= disappears from the address bar and the visitor browses normally.

Links can be scoped:

$ ribo gate link internal-dash --ttl 12h            # short-lived
$ ribo gate link internal-dash --ttl 0              # never expires
$ ribo gate link internal-dash --path /reports      # only paths under /reports
$ ribo gate link internal-dash --read-only          # GET/HEAD only

List and revoke:

$ ribo gate links internal-dash
$ ribo gate link internal-dash --revoke ab12cd34ef56

Revoking a link stops new sign-ins through it; sessions it already minted stay valid until they expire or you revoke them (below).


Sessions and revocation

Every admitted visitor gets a session cookie (HttpOnly, Secure; lifetime from [gate.session] ttl, default 30 days). Inspect and revoke them:

$ ribo gate status internal-dash        # policy + active session/link counts
$ ribo gate sessions internal-dash      # who is signed in, from where, until when
$ ribo gate sessions internal-dash --revoke 9f3a1b2c…    # kick one viewer (sid from the list)
$ ribo gate sessions internal-dash --revoke-all          # everyone signs in again

--revoke-all is immediate and platform-enforced: the edge rejects any session issued before the revocation, so it works even if a cookie was copied somewhere you can't reach.

Manage the allow list without redeploying:

$ ribo gate allow internal-dash sam@example.com
$ ribo gate allow internal-dash sam@example.com --remove

Reading the visitor in your Cell

A gate needs no code from you, but once it admits someone your Cell can find out who. Every request that passes the gate arrives with the verified identity in headers:

Header Value
x-tissue-gate-sub Stable subject id: email:sam@example.com, the signed-in Tissue user for account and private, or link:<id> for a capability link
x-tissue-gate-email The verified address. Absent when there isn't one: a capability link identifies a holder, not a person
x-tissue-gate-source How they got in: email, account, or link:<id>
x-tissue-gate-jwt The session token, for passing the identity to a service of your own. Omitted for requests that authenticated with a link token directly

You can trust these without checking anything. The platform deletes every inbound x-tissue-* header from public traffic before it injects its own, so a visitor cannot forge one. The gate's session cookie is stripped from the request as well: your Cell reads the identity but can never replay the session.

An ungated Cell gets none of these headers, so their absence means the request is public.

Rust

Header names arrive lowercased. Fields you leave out of the struct are ignored, so declare only what you read.

use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use wasm_bindgen::prelude::*;

#[derive(Deserialize)]
struct IncomingRequest {
    method: String,
    url: String,
    headers: HashMap<String, String>,
}

#[derive(Serialize)]
struct WasmResponse {
    status: u16,
    headers: HashMap<String, String>,
    body: String,
}

/// The gate's verified viewer, or `None` on a Cell with no gate.
struct Viewer {
    sub: String,
    email: Option<String>,
    source: String,
}

fn viewer(headers: &HashMap<String, String>) -> Option<Viewer> {
    Some(Viewer {
        sub: headers.get("x-tissue-gate-sub")?.clone(),
        email: headers.get("x-tissue-gate-email").cloned(),
        source: headers.get("x-tissue-gate-source").cloned().unwrap_or_default(),
    })
}

fn json_resp(status: u16, body: Value) -> String {
    let resp = WasmResponse {
        status,
        headers: [("content-type".into(), "application/json".into())].into(),
        body: body.to_string(),
    };
    serde_json::to_string(&resp).unwrap_or_default()
}

#[wasm_bindgen]
pub fn fetch(req_json: String) -> String {
    let req: IncomingRequest = match serde_json::from_str(&req_json) {
        Ok(r) => r,
        Err(e) => return json_resp(400, json!({ "error": e.to_string() })),
    };

    match viewer(&req.headers) {
        // Signed in: audiences `emails`, `domain`, `account` and `private`.
        Some(v) if v.email.is_some() => json_resp(200, json!({
            "hello":   v.email,
            "subject": v.sub,
            "source":  v.source,
            "path":    req.url,
            "method":  req.method,
        })),
        // A capability-link holder — authenticated, but no address to show.
        Some(v) => json_resp(200, json!({
            "hello":   "link holder",
            "subject": v.sub,
            "source":  v.source,
        })),
        // No gate on this Cell, so this is an ordinary public request.
        None => json_resp(200, json!({ "hello": "anonymous" })),
    }
}

Build and deploy it like any other Rust Cell; the [gate] block goes in the same ribo.toml:

[cell]
name         = "internal-dash"
wasm         = "./pkg/internal_dash_bg.wasm"
build        = "wasm-pack build --target web --out-dir pkg"
bindgen_glue = "./pkg/internal_dash.js"

[gate]
audience = "domain"
domains  = ["yourco.com"]

JavaScript

The same headers, off the standard Request:

export default {
  async fetch(request) {
    const email = request.headers.get("x-tissue-gate-email");
    const source = request.headers.get("x-tissue-gate-source");
    return Response.json({ hello: email ?? "link holder", source });
  },
};

Calling a gated Cell from a program

A capability-link token works as a Bearer credential, so scripts and services reach a gated Cell without a browser session. Mint one with ribo gate link, keep it out of the source, and send it as Authorization: Bearer tal_…:

let token = std::env::var("TISSUE_LINK_TOKEN")?;   // tal_…
let res = reqwest::Client::new()
    .get("https://internal-dash.strand-9c.tissue.dev/reports.json")
    .bearer_auth(token)
    .send()
    .await?;

The gate answers a program the way a program expects. With no credential, or one it cannot verify, the response is 401 with WWW-Authenticate: Bearer and a body of {"error": "gate_auth_required", "login": "…"}. That login value is a URL to hand a human; the program itself is never redirected into a sign-in page. A token that is real but used outside its scope (--path, --read-only) gets 403 {"error": "gate_forbidden"}, so the two cases stay distinguishable.

Browsers are the exception: a request that accepts text/html is redirected to the sign-in page instead.


Managing gates via the REST API

Everything above is also available under /v1/cells/{address}/gate for tokens with the cells:read / cells:write scopes — see the REST API reference (Gate section). The policy body is JSON in the same shape as ribo.toml:

$ curl -X PUT https://api.tissue.systems/v1/cells/$ADDR/gate \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"policy": {"audience": "emails", "allow": ["sam@example.com"]}}'

DELETE /v1/cells/{address}/gate removes the gate entirely — the Cell is public again and all its links and sessions are deleted.

AI agents can do the same through the MCP server (gate_status, gate_set, gate_link_create, …).


What the gate does and doesn't cover

  • The gate protects the Cell's public URL (its *.tissue.dev address and any custom domains). All paths are covered; there is no way to leave a path open.
  • Pulse scheduled invocations are platform-internal and keep firing on a gated Cell.
  • Gate checks add no meaningful latency: sessions are verified at the edge with a public key, without a database lookup per request.
  • API clients authenticate with a capability-link token as a Bearer credential: see calling a gated Cell from a program.