# public pages (what a signed-out visitor can see and do)

> A public page, its public data, its public actions and its live updates are four separate declarations. `[AllowAnonymous]` on a component says a signed-out visitor may see the PAGE; whether they get its ROWS is decided by the entity's own `security { }` block; whether they may RUN an action is a grant on the function it calls; and whether a `topic` reaches them at all is `[AllowAnonymous]` on the topic. Grant only the page and it renders perfectly with nothing in it, its buttons do nothing and it never updates — the quietest failures in a public app.

<!-- id: security-public-reads · area: security · stability: preview · html: https://osysharp.com/reference/security/public-reads/ -->

## Summary        {#summary}
A shop's catalogue, a published article, a price list: some data is meant for people who have not signed in. Making
that work takes **two declarations, on two different things**, because the page and its data are gated separately:

- **`[AllowAnonymous]` on the component** — a signed-out visitor may load the PAGE. Without it, routing sends them to
  your login route and they never see it.
- **an anonymous read grant on the ENTITY** — a signed-out visitor may read its ROWS. Without it, the page renders
  and every query on it comes back refused.

If the page also lets a visitor *do* something, there is a **third**: `[AllowAnonymous]` on the function the action
calls. See *Writing is a THIRD grant*, below. And if the page shows LIVE data — a `topic` — there is a **fourth**,
on the topic itself. See *Live updates are a FOURTH grant*.

```osy title="both halves, and the page works signed out" test app=security-public-reads
[Principal] entity User { [MaxLength(200)] string Email; }   // who "signed in" means, for the grants below

entity Product {
  [MaxLength(80)] string Name;
  decimal Price;
  security {
    allow read when IsAnonymous || IsAuthenticated;   // ← the DATA half: anyone may read the catalogue
    allow create, update, delete when IsAuthenticated;
  }
}

[Route("/")]
[AllowAnonymous]                                      // ← the PAGE half: anyone may load this route
component Catalog() {
  live var products = Product.ToList();
  render { foreach (var p in products) { Text(p.Name); } }
}
```

The two do not inherit from each other, in either direction. That is deliberate — a public page composed of private
data is a real design, and so is a private page over public data — but it means **granting one and forgetting the
other is a thing you can do**, and the failure is silent.

## Signature      {#signature}
```osy syntax
security {
  allow read when IsAnonymous;                    // signed-out visitors only
  allow read when IsAnonymous || IsAuthenticated; // everyone, said out loud
}
```

## Description    {#description}

### The half that is easy to forget   {#empty-page}
Under [deny-all](https://osysharp.com/reference/security/secure-by-default/), an entity you have not granted is denied to everyone — including the
anonymous visitor. So a page marked `[AllowAnonymous]` over an ungranted entity **renders**: the chrome, the headings,
the empty list, the `0 items` count. No error, no redirect, no console message. It simply shows nothing, and it looks
exactly like a catalogue with no products in it.

This is worth stating plainly because it survives the checks you would expect to catch it. It compiles. Its tests pass
— a test runs under a principal you choose, and the ordinary anonymous read those tests exercise is honoured correctly.
The page renders in every screenshot taken while signed in. **Only opening the app signed out shows the gap.**

The compiler does look for it. An `[AllowAnonymous]` page reading an entity with no anonymous grant raises the
`security-anon-page-reads-ungranted-entity` maturity finding, which names the page (or the public page that renders the
component), the entity — the app's own or a used package's — and the line to add. You do not have to go looking for
it: `osy validate` prints it in full, with its fix, even when the source is otherwise valid (as
`ANON_PAGE_READ_REFUSED`). It stays a warning, not an error — a page that is public on purpose over data that is not is
a choice you are allowed to make.

### What "reaches an anonymous caller" means   {#grants}
An entity is anonymously readable when its `security { }` block grants a read to a caller who is not signed in:

| Grant | Anonymous visitor |
|---|---|
| `allow read when IsAnonymous;` | reads it |
| `allow read when IsAuthenticated \|\| IsAnonymous;` | reads it |
| `allow read when IsAuthenticated;` | **refused** — an anonymous caller is not authenticated |
| `allow read where Owner == user;` | **refused** — there is no `user` to match |
| `allow read where Status != ProductStatus.Draft;` | **refused** — a `where` says which rows, not who; it admits no signed-out caller |
| `allow read when IsAnonymous \|\| IsAuthenticated where Status != ProductStatus.Draft;` | reads the rows the filter passes |
| no `security { }` block at all | **refused** under deny-all |

⚠ **A row filter is not an audience.** `allow read where Status != ProductStatus.Draft;` reads like "anyone may see what is
not a draft", and it means "any SIGNED-IN caller may see what is not a draft". A signed-out visitor gets none of it,
on every path alike: the page's query is refused, an `[AllowAnonymous]` server function reading `Product.Where(…)`
gets no rows, and so does a `[Test]` body with no `runas` — one rule, one answer, whoever asks and from wherever.
Say both halves in ONE rule — who (`when`) and which rows (`where`):

```osy title="a public catalogue that still hides its drafts" test app=security-public-reads-filtered
[Principal] entity User { [MaxLength(200)] string Email; }

enum ProductStatus { Draft, Listed }

entity Product {
  [MaxLength(80)] string Name;
  ProductStatus Status;
  security {
    allow read when IsAnonymous || IsAuthenticated where Status != ProductStatus.Draft;   // who, then which rows
    allow create, update, delete when IsAuthenticated;
  }
}

[Route("/")]
[AllowAnonymous]
component Catalog() {
  live var products = Product.Where(p => p.Status == ProductStatus.Listed).ToList();
  render { foreach (var p in products) { Text(p.Name); } }
}
```

Do not add a separate `allow read when IsAnonymous;` beside the filtered rule instead: grants add up, so that is a
second, unfiltered grant, and a visitor would see every draft. `security-anon-page-reads-ungranted-entity` prints the
one-rule form for you, with your own filter in it — including when the entity is a package's, where the block lives in
the package (or in a `partial entity` of yours that restates it).

There is no separate switch and no attribute to add: the grant IS the declaration. Say it once on the entity, and
every surface — the page's query, a server function, the boot bundle, the server-side render — follows it.

Two kinds of caller are not visitors, and a `where` alone still serves them: the holder of a grant link
(`allow read by Preferences;` — the link is the audience, see [grants (a link that opens one row)](https://osysharp.com/reference/security/grants/)) and your own `[AuthMethod]`
while it signs someone in (it runs as the app's sign-in role, see [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/)).

### Only the rows you granted   {#scope}
Granting an anonymous read opens exactly what the grant says and nothing beside it. The row filter still applies the
predicate per row, field-level `deny read` masks still apply, and an entity you did not grant stays invisible — not
merely unreadable, but absent from the model an anonymous session is served at all. So a public catalogue does not
drag the customer table into public view because they happen to be related.

Prefer the narrowest grant that is true. `IsAnonymous` alone is right when a page is public and the signed-in view is
a different one; the `||` form is right when the answer really is "everyone" — see
[principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/).

### Writing is a THIRD grant   {#writes}
Reading needs two declarations. A visitor who *does* something — fills a basket, casts a vote, submits a form —
needs three, because an action reaches a server function and **who may call a function is its own grant**:

| | says | if missing |
|---|---|---|
| `[AllowAnonymous]` on the component | a stranger may SEE the page | they are redirected to login |
| an anonymous read/write grant on the entity | a stranger's rows may be read/written | the read is empty; the write is refused at commit |
| `[AllowAnonymous]` on the function | a stranger may CALL it | the click posts and the server refuses it — a **dead button** |
| `[AllowAnonymous]` on the topic | a stranger may SUBSCRIBE and PUBLISH | the page renders once and **never updates** |

```osy title="the write half" test app=security-public-reads-write
[Principal] entity User { [MaxLength(200)] string Email; }

entity Vote {
  [MaxLength(80)] string Choice;
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }   // the ROWS
}

[AllowAnonymous]                                   // the FUNCTION — a stranger may run it
void CastVote() {
  var v = new Vote { Choice = "yes" };
  UnitOfWork.Commit();
}

[Route("/vote")]
[AllowAnonymous]                                   // the PAGE
component VotePage() {
  action Cast() { CastVote(); }
  render { Button("Vote", onPress: Cast); }
}
```

The third one is the quietest of the three: the page renders, the button is there, it looks enabled, and pressing it
produces nothing at all — the refusal never reaches the screen. The compiler flags it as
`security-anon-page-calls-gated-function`, naming the page, the action and the function.

An `[AuthMethod]` function needs no such mark: it is an anonymous entry point by construction. And an action that
signs someone in and then calls a gated function is fine — by that line the caller is no longer a stranger.

### Live updates are a FOURTH grant   {#topics}
A public page that shows something *changing* — a queue display, a status board, a live scoreboard — reads its
updates from a [topic](https://osysharp.com/reference/realtime/topic/), and a topic refuses a caller with no principal unless it declares otherwise.

```osy title="a public status board that actually updates" test app=security-public-reads-topic
[Principal] entity User {
  [MaxLength(200)] string Email;
  bool IsStaff;
  security { allow read when IsAuthenticated; }
}

class Status { public string State; }

[AllowAnonymous]                 // the TOPIC — a stranger may subscribe to it
topic ServiceStatus() {
  Candidates = _ => true;
  Publishers = u => u.IsStaff;   // …and only staff may say what the state is
  Carries    = Status;
}

[Route("/status")]
[Render(CSR)]
[AllowAnonymous]                 // the PAGE
component StatusBoard() {
  live var updates = ServiceStatus.Listen();
  render { Text(updates.Count() + " updates"); }
}
```

This is the quietest of the four. The page loads, the layout is right, nothing is refused on screen — and the number
never moves, for the one reader who is not signed in. There is no error to see, because a subscription that was
refused is a room that is simply silent.

⚠ **`Candidates = _ => true` is not the declaration.** It means *every signed-in user*, and reading "public" out of
a rule that happens not to mention its principal is exactly what the platform refuses to do: a rule like
`_ => Room.Any(r => r == room)` restricts on the address and nothing about *who*, and it must not become
world-readable because of what it does not say. The attribute is the statement; the rule is still the gate.

### Look at it signed out   {#verify}
The habit worth forming: open the app **in a private window**, on the real page, and check the data is there. A public
app's most common defect is not a refusal — a refusal is loud. It is a page that renders beautifully and is empty,
and the only reader who ever sees it is the one who is not signed in.

## Examples       {#examples}
A storefront: a public catalogue, a basket keyed to the browser, and a customer record that stays private.

```osy title="public catalogue, private customers" test app=security-public-reads-shop
[Principal] entity Customer {
  [MaxLength(200)] string Email;
  security {
    allow read where Id == user.Id;         // you read your own profile, and nobody reads anyone else's
    allow update where Id == user.Id;

  }
}

entity Product {
  [MaxLength(80)] string Name;
  decimal Price;
  security {
    allow read when IsAnonymous || IsAuthenticated;   // a catalogue is public — that is what a shop is
    allow create, update, delete when IsAuthenticated;
  }
}

[Route("/")]
[AllowAnonymous]
component Catalog() {
  live var products = Product.ToList();
  render {
    Stack(gap: 2) {
      Text(products.Count() + " items");
      foreach (var p in products) { Text(p.Name + " " + p.Price.ToString("C")); }
    }
  }
}
```

Signed out, this page shows the products. `Customer` is granted to nobody anonymous, so it is not readable and not
even described to an anonymous session — the public page cannot leak it by accident.

## See also       {#see-also}
- [principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/) — `IsAnonymous` / `IsAuthenticated`, and why an open read must say so
- [security { }](https://osysharp.com/reference/security/entity-security/) — the `security { }` block the grant lives in
- [secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — the deny-all posture that makes an unmentioned grant a denial
- [topic](https://osysharp.com/reference/realtime/topic/) — `[AllowAnonymous]` on a topic, and why `Candidates = _ => true` is not it
- [routes and pages](https://osysharp.com/reference/ui/routing/) — `[AllowAnonymous]`, and protected-by-default routing
- [Visitor](https://osysharp.com/reference/ui/visitor/) — `Visitor.Id`, for work a person begins before they are anybody
