# publishing a REST API (app.Apis)

> `app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you EXPOSE what already exists: `Expose` turns an entity into CRUD routes (`new Crud<Order>() { Operations = [...] }`), and `Endpoints` maps one of your functions to a route (`new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit" }`). Each `RestApi` has a `Route` prefix and an `Auth` that says who may call it — including `Anonymous = true` when the answer is everyone, which is a decision you write rather than one you reach by saying nothing. The entity in a `Crud<T>` and the function in an `Endpoint(...)` must exist — a name that doesn't resolve is a compile error that names what does.

<!-- id: api-rest · area: api · stability: stable · html: https://osysharp.com/reference/api/rest/ -->

## Summary        {#summary}
`app.Apis` publishes your application over HTTP for other systems to call. The guiding idea is that you **expose what
you already have** rather than write a web layer: you do not author controllers, routes, DTOs or handlers. Two things
can be published — an **entity**, as a set of CRUD routes, and a **function**, mapped to a single route. Each API has a
`Route` prefix and an `Auth` gate.

```osy syntax
app.Apis = [
  new RestApi("Orders") {
    Route   = "orders",
    Auth    = new ApiAuth { ApiKey = true },
    Expose  = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
    Endpoints = [ new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit" } ],
  },
];
```

## Signature      {#signature}
```osy syntax
app.Apis = [
  new RestApi("Name") {          // one entry per published API
    Route   = "orders",          // the URL prefix for every route below
    Version = "1.0",             // optional — omit and it defaults to v1
    Auth    = new ApiAuth { ApiKey = true, Bearer = true },   // who may call
    Expose  = [                  // entities → CRUD routes
      new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create, CrudOp.Update, CrudOp.Delete] },
    ],
    Endpoints = [                // functions → one route each
      new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
    ],
  },
];
```

`app.Apis` is a list — an app may publish several independent APIs, each with its own `Route` and `Auth`.

## Description    {#description}
A `RestApi` has:

- **`Route`** — the URL prefix under which its routes live.
- **`Version`**, **`Deprecated`**, **`Sunset`** *(optional)* — API lifecycle metadata. Every route lives under a
  `/v{major}/` prefix; an omitted `Version` defaults to **v1**, so the simple case needs no version at all. A
  multi-major API gives each `RestApi` its own `Version` (e.g. `"1.0"`, `"2.0"`).
- **`Auth`** *(REQUIRED)* — an `ApiAuth { ApiKey = true, Bearer = true, OAuth = true }`, or
  `ApiAuth { Anonymous = true }` to open the API to everyone deliberately. Those four flags are its
  WHOLE vocabulary; it names no secret and takes no other member. Absent, the API is unauthenticated. Who each of
  them makes the caller is the next section — read it before you write a `security {}` rule for an API.
- **`Expose`** — a list of `new Crud<Entity>() { Operations = [CrudOp.…] }`. Each entry turns one entity into REST
  routes; `Operations` chooses which of `Read` / `Create` / `Update` / `Delete` exist. The entity must be defined in
  your app — a `Crud<Unknown>` is a compile error that lists the entities you *do* define.
- **`Endpoints`** — a list of `new Endpoint(Function) { Method = HttpMethod.…, Path = "…" }`. Each maps one of your
  functions to a route. The function must be declared — an `Endpoint(NoSuchFn)` is a compile error that lists your
  functions.

You write no request parsing, no serialization, and no routing table: the shape of the entity and the signature of the
function are the contract.

### Where an API is served: `/api/rest/v{major}/{route}/…`   {#address}
Every API is served at ONE address shape, on the app's own host:

```text syntax
https://<app-host>/api/rest/v{major}/{Route}{Path}           an Endpoint
https://<app-host>/api/rest/v{major}/{Route}/entities/{Entity}   a Crud<T>
```

So `new RestApi("MailEvents") { Route = "mail-events", Endpoints = [ new Endpoint(Reported) { Path = "/resend" } ] }`
is called at `https://<app-host>/api/rest/v1/mail-events/resend` — that is the address to give a webhook provider.
`osy model` prints each API's address (`basePath`, and each endpoint's `url`), so copy it from there rather than
writing it by hand. An address you build in code for a provider to call back (a payment's status callback) is
`$"{App.BaseUrl}/api/rest/v1/{route}{path}"`.

Nothing else is an API's address. A request for one of your API's routes at a shorter spelling —
`/api/mail-events/resend`, `/api/v1/mail-events/resend`, `/api/rest/mail-events/resend`, or a `POST` to
`/mail-events/resend` — is answered `404` with a JSON body that names the address the API IS served at, so a
provider's delivery log tells you what to type. No page is ever served under `/api/`.

### What an endpoint binds its parameters from      {#binding}

Exactly two places, and the list is closed:

1. **A `{placeholder}` in that endpoint's own `Path`.** `new Endpoint(Settle) { Path = "/settle/{paymentId}" }`
   fills the function's `paymentId` parameter from the matched segment, coerced to its declared type.
2. **A property of the JSON request body**, matched by name (`camelCase` or `PascalCase`). On a name collision the
   body wins.

```osy title="both sources, on one endpoint" syntax
string Settle(Guid paymentId, string reference, string status) { … }

new Endpoint(Settle) { Method = HttpMethod.Post, Path = "/payments/{paymentId}/settle" }
// → POST /api/rest/v1/bank/payments/9f3…/settle   {"reference":"wire-9001","status":"settled"}
```

A parameter that is an **entity** takes a row. A string id binds the existing row, read as the caller, so a row they
may not read is a `404`. An object without an id builds a new row from its fields.

### An `[Action]` as an endpoint — listed, never automatic   {#action-endpoints}

An [[function-action|`[Action]`]] is the verb a macro, a rule or an agent runs on a record: its first parameter is
that record. It becomes part of your API only when you **list** it, like any other function. The path's `{id}` then
binds the record:

```osy title="a listed action, addressed by its record's id" syntax
[Action("Close")]
public string CloseJob(Job job, string note) { … }

new Endpoint(CloseJob) { Method = HttpMethod.Post, Path = "/jobs/{id}/close" }
// → POST /api/rest/v1/jobs/jobs/4b1…/close   {"note":"Tiles replaced"}   →   {"result":"Closed Fix the roof."}
```

The other parameters bind from the body as usual, and an entity among them takes its row's id
(`{"agent": "<id>"}`). An id that names no row the caller may read is a `404`; nothing is ever created from the
request. ⛔ **An action you do not list is not reachable over the API**, by any path. Being an action makes a verb
available to the app's own macros, rules and agents, never to a caller outside it.

⛔ **The query string is not bindable**, and `[Header]`/`[Query]` are not how you read one. Those two mark where an
**outgoing** argument travels, and an [outgoing client](https://osysharp.com/reference/http/client/) is a class that builds its own URL and header
map — so they bind nothing anywhere, and are refused on a function you declare.

The request AS RECEIVED — the raw bytes and named headers — is a separate surface, and it is **declared on the
endpoint** rather than bound to a parameter. See the next section.

### Reading the request as it arrived: `Raw` and `Headers`   {#raw-and-headers}

A signed webhook — Slack, Stripe, GitHub, Shopify, Twilio, DocuSign — signs its headers over the **body bytes**. A
body re-serialised from bound parameters is not those bytes, so verifying against the bound model is not awkward, it
is *wrong*: it fails for whitespace the sender chose and you did not.

So an endpoint may **ask for** what it needs, and the function reads it back through `Request`:

| On the endpoint | In the function | Answers |
|---|---|---|
| `Raw = true` | `Request.RawBody` | the body as text, before any JSON parse |
| `Headers = ["X-Sig", …]` | `Request.Header("X-Sig")` | that header's value, or `null` if the sender sent none |

Both are **declared, never ambient**, and the declaration is the grant: a header this endpoint did not name is not
readable from this function, so what an app can see of its callers is visible in its own source. Header names are
matched case-insensitively, because HTTP header names are.

⛔ **A credential header may not be declared at all** — `Authorization`, `Proxy-Authorization`, `Cookie`,
`Set-Cookie`, `X-API-Key`. Those are how the *caller* authenticates to the platform, and handing one to a handler
would let an app replay its callers' credentials elsewhere. The compiler refuses the declaration by name. A webhook
signature never travels in one of these anyway: the sender has no account here, which is why it signs.

⛔ **Reading one you did not declare does not compile.** `Request.RawBody` without `Raw = true`, or
`Request.Header("X")` where `X` is not in `Headers`, is refused by the compiler, and the refusal lists the names the
endpoint *does* declare — because the usual cause is two strings written minutes apart that differ by a character.
The declaration and the read are one contract held in two places and joined by a string literal, so the compiler
checks them against each other rather than leaving it to the first real request.

⚠ **The same mistake one call deeper is a fault at run time, not a compile error.** The compile-time check reads the
endpoint function's own body; a `Request.Header(…)` inside a helper it calls is beyond it, and so is a name that is
not a literal. Those still fail loudly, with the same message naming the one-line fix — a `null` was never on the
table. Only *"the sender sent no such header"* is a `null`, because that is the app's to refuse and it must not look
like a mistake in the app's own source. That fault also covers being reached some other way entirely (an MCP tool
call, an agent, a workflow step, a `[Test]` body): there is no request to read, and saying so beats answering `null`
to a verifier that would then "pass".

`Raw` is carried **alongside** the bound parameters, never instead of them — an endpoint can bind a typed payload
*and* verify the signature over what actually arrived. A `Raw = true` endpoint whose body is not JSON at all (a
form-encoded `payload=…`) is not refused; it simply binds nothing, and the handler decides.

```osy title="slack" test app=api-rest-signed-webhook
app.Secrets = [ new Secret("SlackSigningSecret") ];

class Receipt { public bool Verified; }

[AllowAnonymous]
Receipt SlackEvents() {
  var body = Request.RawBody ?? "";
  var ts   = Request.Header("X-Slack-Request-Timestamp") ?? "";
  var sig  = Request.Header("X-Slack-Signature") ?? "";
  // Slack's own scheme: v0={hmac of "v0:{timestamp}:{body}"}, over the bytes it sent.
  var expected = "v0=" + Crypto.HmacSha256Hex(Secret.SlackSigningSecret, "v0:" + ts + ":" + body);
  return new Receipt { Verified = Crypto.FixedTimeEquals(expected, sig) };
}

app.Apis = [
  new RestApi("Slack") {
    Route = "slack",
    Auth  = new ApiAuth { Anonymous = true },
    Endpoints = [
      new Endpoint(SlackEvents) {
        Method = HttpMethod.Post, Path = "/events",
        Raw = true, Headers = ["X-Slack-Signature", "X-Slack-Request-Timestamp"],
      },
    ],
  },
];
```

### Who is asking: `Request.ClientAddress`   {#client-address}

`Request.ClientAddress` is the **end user's** address — the first hop of `X-Forwarded-For` behind the gateway, else
the connection's. It is what an identity provider asks for (BankID's `endUserIp`), and the one mistake it exists to
prevent is sending the SERVER's own address, which works in development, where the two are one machine, and blinds the
provider's fraud checks in production.

Unlike a header it is **not declared**, and it is readable from **any** function a request reached — a page's action
as much as an endpoint — because an address is neither the caller's text nor a credential, and the function that needs
it is usually a checkout step, which no endpoint reaches. It is `""` when no request did: a workflow step on a timer,
a resumed continuation, a `[Test]` body.

```osy title="the address an identity provider is given" syntax
var order = new BankIdClient { BaseUrl = "https://appapi2.test.bankid.com/rp/v6.0" }.Auth(Request.ClientAddress);
```

⚠ **A label, never an authority.** Behind a proxy the header is the client's to write, so use the address for what a
provider asks it for and never to decide what someone may do. It runs on the server only: a browser does not know its
own public address.

**Refusals map to the right status.** When an endpoint's function *refuses* — it `throw`s a typed exception, or a
declared security policy denies the caller — the response carries the matching 4xx, not a blanket 500, and the client
sees the refusal's own message:

| The function… | Response |
|---|---|
| `throw new ValidationException("…")` | `400` — the request was invalid |
| `throw new NotFoundException("…")` | `404` — the target does not exist / is not visible |
| `throw new ConflictException("…")` | `409` — conflicts with current state |
| is denied by a security / authorization policy | `403` — the caller may not do this |
| hits a genuine, unexpected error | `500` — a generic message; the details stay in the server log, never the response |

So a business rule like *"a backup can only be downloaded once it is ready"* is a `throw new ValidationException(…)`
that reaches the caller as a `400` with your wording — not a server crash.

### What a value looks like on the wire  {#wire-form}
JSON has no literal for most of what your entities hold, so each type has one agreed spelling — the same one going
out as coming in, which is what makes a response postable straight back:

| Declared as | On the wire |
|---|---|
| `enum` | its **member name** — `"machine": "Washer"`, never the ordinal `0` |
| `enum` with `[Value("…")]` | that **value**, because you chose it — `"risk": "risk-high"` |
| `Guid`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan` | a string in the canonical form — `"2026-09-04"`, `"01:30:00"` |
| `int`, `decimal`, `bool` | the JSON number or boolean, unquoted |

**An enum also accepts its stored key inbound** — the ordinal, or the `[Value]` of a string-backed enum — so a client
written against an older payload keeps working. The name is what a read ANSWERS and what the published OpenAPI
advertises; the key is a compatibility affordance.

Anything else is a **`400` naming the field and what it accepts**, with an enum's whole closed set spelled out:

```json title="a value outside the vocabulary" syntax
{ "error": { "code": "VALIDATION_FAILED", "details": [
  { "field": "machine", "code": "INVALID_ENUM_VALUE",
    "message": "'machine' expects one of Machine: Washer, Dryer — received a string ('Toaster')." } ] } }
```

### …and what the response is WRAPPED in  {#envelope}
The table above is about one VALUE. This is about the **object it arrives inside**, which is not always the value
itself — and every key in it is **camelCase**, whatever the member is called in your source.

| The route | What comes back |
|---|---|
| an `Endpoint` whose function returns an **entity** | that row, **flat** — `{"id": "…", "accession": "ACC-1"}` |
| an `Endpoint` whose function returns a **`class`** | its fields, **flat** — `{"accession": "ACC-1", "species": "Rana"}` |
| an `Endpoint` whose function returns **`void`** | `{"status": "ok"}` |
| an `Endpoint` whose function returns **anything else** — a `List<T>`, a `Dictionary<K,V>`, an `int`, a `string` | **wrapped**: `{"result": …}` |
| `Crud<T>` **read-many** (`GET /entities/T`) | `{"items": [ … ], "total": 12, "top": 50, "skip": 0, "hasMore": false}` — plus `"included"` when you asked to expand |
| `Crud<T>` **read-one / create / update** | that row, **flat** |
| `Crud<T>` **delete** | `204`, no body |

⚠ **The `result` wrapper is the one that catches people, because the obvious DTO is the one that does not work.** A
route whose function returns `List<Receipt>` does **not** answer a bare JSON array, so
`JsonSerializer.Deserialize<List<Receipt>>(body)` fails — the body is an object. Declare the wrapper instead, and
read through it:

```osy syntax
class ReceiptPage { public List<Receipt> Result; }   // `Result` binds the body's `result`

var page = JsonSerializer.Deserialize<ReceiptPage>(r.Body);
Assert.Equal(2, page.Result.Count);
```

[Calling your own REST API from a test](https://osysharp.com/reference/testing/api-calls/) has this as a compiled example, and [JsonSerializer](https://osysharp.com/reference/json/serializer/) is the verb that reads it.

### Reading many: `$filter`, `$orderby`, `$top`, `$skip`, `$expand`   {#query-options}
`GET /entities/T` takes OData's query options. A mistake in any of them is a **400 that names it**, never a 500 and
never a silently different answer.

| Option | What it takes | When it is wrong |
|---|---|---|
| `$filter` | `eq` `ne` `gt` `ge` `lt` `le`, `and` / `or`, `eq null`, and `contains(p,'…')`, `startswith(p,'…')`, `endswith(p,'…')`. Those three are **literal**: `contains(name,'50%')` finds the text "50%", and `''` inside the quotes is one quote | `400 INVALID_FILTER`, quoting your filter and where it stopped parsing (`name eq` with no value, a trailing `and`, an unsupported function) |
| `$orderby` | `name`, `name desc`, comma-separated for more keys | `400`, naming the term |
| `$top` | rows per page; **50** when omitted, at most what fits about **2 MB** of this entity's rows ([How many rows a page is sent](https://osysharp.com/reference/ui/read-limits/)) | a larger number is served at that size, and the answer's `top` says so. Rows larger than estimated end the answer early, with `hasMore` |
| `$skip` | rows to skip — continue an answer from `$skip` plus the items it held | — |
| `$expand` | relations, comma-separated; `lines.product` follows one further | `400 UNSUPPORTED_EXPAND_OPTION`, naming the member and listing the relations you could have named. A value (`$expand=name`) is refused the same way |

## Who is `user`?  {#who-is-user}
Every rule you already wrote applies to an API call unchanged, because an authenticated API caller is **a user of your
app** — not a special anonymous "machine" identity. This is the one thing about `app.Apis` worth reading before you
design anything: guessing it the other way leads you to open your entities to anonymous callers so the API "works",
which opens them to the whole world on every other surface too.

**An API key is a PER-USER credential. There is no app-wide API key.** A key is minted against exactly one
`[Principal]` row, and a request carrying it runs **as that user**:

| `Auth` | the request carries | `user` | `IsAuthenticated` | `IsAnonymous` |
|---|---|---|---|---|
| `new ApiAuth { ApiKey = true }` | `X-API-Key: pk_…` | the key OWNER's principal row | `true` | `false` |
| `new ApiAuth { Bearer = true }` | `Authorization: Bearer …` | that token's principal row | `true` | `false` |
| `new ApiAuth { OAuth = true }` | an OAuth-issued bearer token | that token's principal row | `true` | `false` |
| `new ApiAuth { Anonymous = true }` | anything, or nothing | `null` | `false` | `true` |
| an authenticating `Auth` | *nothing* | — | — | — → the request is **`401`** and never reaches your data |

So an API key and a bearer token for the same person produce **the same context**: the same `user`, the same role
grants, the same row filters. A rule like `allow read where OwnerEmail == user.Email` returns that person's rows over
the API exactly as it does in the app's own UI, and `allow read, create when IsAuthenticated` admits them.

Three consequences, each of which is a mistake if you assume the opposite:

- **You do not need `IsAnonymous` to make an entity reachable over an authenticated API.** The caller is somebody.
  An `allow … when IsAnonymous` rule is in fact the one thing an API-key caller can *never* satisfy.
- **A missing credential is a `401`, not an anonymous request.** `ApiAuth { ApiKey = true }` never falls through to
  anonymous access; the request stops at the door.
- **Only `Anonymous = true` runs anonymously**, and there `user` is null — so a row filter correlating to `user`
  matches nothing and denies, which is the correct answer for a caller who is nobody.

⚑ **You can check every row of that table against your own app rather than take it from here.** [Calling your own REST API from a test](https://osysharp.com/reference/testing/api-calls/)
sends a real request to your published route from inside a `[Test]`, with or without a credential, and hands you the
status back — so "an API key caller is authenticated" is a claim you can make the app answer:

```osy syntax
Assert.Equal(401, Api.Get("/api/rest/v1/catalog/entities/Product").StatusCode);                   // nobody
Assert.Equal(200, Api.Get("/api/rest/v1/catalog/entities/Product", apiKey: key).StatusCode);      // somebody
```

### Where the key lives  {#api-key-storage}
The platform stores each user's key as a **hash on that user's own principal row**, in two columns your `[Principal]`
entity must declare by these exact names:

```osy syntax
[Principal] entity User {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(255)] string? ApiKeyHash;    // the key in force
  [MaxLength(255)] string? ApiKeyHash2;   // a second, so a key can be rotated without a gap
  security {
    allow read when IsAuthenticated;
    // ⛔ BOTH SLOTS. A stored key hash with no field-level `deny read` rides `Session.CurrentUser` to the
    //    browser — the platform ships the whole [Principal] minus its MASKED properties.
    deny read ApiKeyHash  when !IsAuthenticator;
    deny read ApiKeyHash2 when !IsAuthenticator;
  }
}
```

`IsAuthenticator` is a policy **you declare** over your own grant table — it is not built in. See
[auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) for the three lines that declare it, and [acting for another principal](https://osysharp.com/reference/security/acting-for-another-principal/) for
the whole shape worked through on an app whose API caller is a machine.

Declaring `Auth = new ApiAuth { ApiKey = true }` on an app whose principal lacks them is a **compile error** naming
both columns — without them no key can be minted and none can be verified, so the published route would be a `401`
forever.

### Every API says who may call it   {#auth-is-required}

`Auth` is **required**, and an API that omits it is a compile error listing the four things it could have said. The
reason is what silence used to mean: a `RestApi` with no `Auth` published an **open** route, reachable by anyone who
found the URL — and nothing in the source said so.

That is the inverse of how the rest of the platform reads silence, which is what made it dangerous:

| you write nothing about… | and you get |
|---|---|
| an entity's `security { }` | **denied** — nobody may read or write it |
| a routed component's access | **denied** — until it says `[AllowAnonymous]` |
| ~~a `RestApi`'s `Auth`~~ | ~~**open**~~ → now a compile error |

Anonymous is still perfectly legal, and for a public catalogue or a health check it is the right answer. What is
refused is arriving there by saying nothing:

```osy title="open, and it says so" syntax
new RestApi("PublicCatalogue") {
  Route = "catalogue",
  Auth  = new ApiAuth { Anonymous = true },   // deliberate, and visible in the diff that added it
  Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
}
```

⚠ `Anonymous = true` cannot be combined with `ApiKey`/`Bearer`/`OAuth` — that would say two opposite things about
one route — and it does **not** weaken your entities. Their `security { }` blocks still decide what an anonymous
caller may read or write, which for most entities is nothing.

You never write a key into source. The platform mints one for a named user and prints the plaintext `pk_…` **once**;
only its hash is stored, and the caller then presents it in the `X-API-Key` header:

```bash
osyrin app user apikey generate alice@example.com     # prints the pk_… once
osyrin app user apikey revoke   alice@example.com     # --secondary to drop only the rotation key
```

The app's own administrators can do the same from inside the app with [[security-people-kit#api-keys|the People
kit's API keys page]]: issue a key to a teammate (shown once), see when each key was last used, and revoke one. When
the principal also declares `DateTime? ApiKeyUsedAt;` and `DateTime? ApiKey2UsedAt;`, the platform records when each
slot's key was last presented, to the minute. A revoked key is refused from the next request.

Because there are two columns, rotation has no gap: generate a second key while the first still works, move the
callers over, then revoke the old one.

⚑ **In a test, ask for one by naming the principal** — [[testing-api-calls#credentials|`Api.KeyFor(P)`]]. "You never
write a key into source" is about your APP's source and stays true; a `[Test]` mints a real one against its own
throwaway branch, so the gated route above can actually be exercised rather than only refused.

⚠ **`app.Secrets` is a different thing and is not involved.** `app.Secrets` declares secrets your app *consumes* — an
LLM key, an OAuth client secret. `ApiAuth` has no member that names a `Secret`, so there is nothing to declare there
for an API key, and declaring one does not gate anything.

## Examples       {#examples}
A complete app that publishes one entity as read/create CRUD and one function as a POST route:

```osy title="basic" test app=api-rest-example
entity Order {
  [Required, MaxLength(200)] string CustomerEmail;
  decimal Total;
}

decimal SubmitOrder(decimal total, decimal taxRate) {
  return total + total * taxRate;
}

app.Apis = [
  new RestApi("Orders") {
    Route  = "orders",
    // Every API says who may call it. This one is open on purpose; the next example authenticates.
    Auth   = new ApiAuth { Anonymous = true },
    Expose = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
    Endpoints = [
      new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
    ],
  },
];
```

Require an API key on every route. The key belongs to a USER, so the principal declares the two columns that hold
it — and the entity's `security {}` then governs the API caller exactly as it governs that same person signed in:

```osy title="with-api-key" test app=api-rest-authed
[Role] enum AppRole { Authenticator, Member }
[RoleGrant] entity RoleGrant {
  [Required("Name the user this grant belongs to.")] User Grantee;
  [Required("Choose the role this grant confers.")] AppRole Level;
  security { allow read when IsAuthenticated; }
}
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);

[Principal] entity User {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(255)] string? ApiKeyHash;
  [MaxLength(255)] string? ApiKeyHash2;
  security {
    allow read when IsAuthenticated;
    // ⛔ BOTH SLOTS, or the stored key hash rides `Session.CurrentUser` to the browser.
    deny read ApiKeyHash  when !IsAuthenticator;
    deny read ApiKeyHash2 when !IsAuthenticator;
  }
}

entity Product {
  [Required, MaxLength(200)] string Name;
  decimal Price;
  security {
    allow read when IsAuthenticated;   // the API-key caller IS authenticated — no anonymous grant needed
  }
}

app.Apis = [
  new RestApi("Catalog") {
    Route  = "catalog",
    Auth   = new ApiAuth { ApiKey = true },
    Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
  },
];
```

## See also       {#see-also}
- [principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/) — `IsAuthenticated` / `IsAnonymous`, the predicates the table above resolves
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — signing a user in, for bearer-authenticated routes
- [function](https://osysharp.com/reference/function/declaration/) — the functions an `Endpoint` publishes
- [declaring secrets (app.Secrets)](https://osysharp.com/reference/config/secrets/) — `app.Secrets`, for the secrets your app CONSUMES; an API key is not one of them
- [Calling your own REST API from a test](https://osysharp.com/reference/testing/api-calls/) — `Api.*`: calling these routes from your own `.test.osy`, and asserting the status each refusal answers
