# App access control for agents

Make a deployed app private, admit the right people and programs, and open narrow public paths. This page is written for an AI agent. It extends the deployment protocol at `https://app.tokay.io/llms.txt`. Everything here is an escape hatch. The happy path needs none of it, so act on an explicit user request or a named readiness action, not on your own initiative.

## The access model

- New apps are public by default so the first result is shareable. Privacy is set per project and enforced by an access layer in front of every WEB service, with no auth code in the app.
- The audience is additive. The policy flags admit the owner, project members, and workspace members, and on top of that you can add individual visitors by email and whole email domains. Humans sign in through an emailed magic link on the access page. Programs use machine tokens.
- Public endpoints are exceptions declared per service. An exact path, or a prefix with a trailing wildcard, stays reachable without sign in while the rest of the app is private. Webhooks and health checks are the intended use.
- Access changes take a few seconds to apply, because the enforcement layer picks up new config on a short poll. Verify with a real request after a change instead of asserting immediately.
- The canonical `service.publicUrl` never changes with any of this. Access control decides who gets through, not where the app lives.

## What to call

| To | Use |
|---|---|
| Read the policy | `project.projectAccessPolicy` |
| Make private or public | `updateProjectAccessPolicy` |
| Admit one outside person | `addProjectVisitor` / `removeProjectVisitor` |
| Admit a whole email domain | `addProjectAccessEmailDomain` / `removeProjectAccessEmailDomain` |
| Open one path publicly | `addServicePublicEndpoint` / `removeServicePublicEndpoint` |
| Let a program call the app | `createMachineToken` / `revokeMachineToken` |
| See who got in or was denied | `projectAccessEvents` |

## Read and flip the policy

```
query Policy($projectId: ID!) {
  project(id: $projectId) {
    projectAccessPolicy {
      id isPublic allowOwner allowProjectMembers allowWorkspaceMembers
      accessPageLogoUrl accessPageMessage
    }
  }
}
```

```
mutation Private($id: ID!) {
  updateProjectAccessPolicy(input: { id: $id, patch: { isPublic: false } }) {
    projectAccessPolicy { isPublic }
  }
}
```

Flip the policy only on an explicit user request. Never make an app private, or public, unprompted. The patch takes any of the audience flags plus the bounded access page branding fields. After flipping, poll the public URL until it answers 401 (or 200 again when going public) before reporting success. Expect a few seconds of the old behavior.

## Admit people

```
mutation Visitor($project: ID!) {
  addProjectVisitor(input: { project: $project, email: "friend@example.com", displayName: "Friend" }) {
    projectAccessVisitor { id projectAccessStatus projectAccessSource }
  }
}
```

A new visitor starts as `projectAccessStatus: PENDING` and becomes active when they first sign in through the magic link. `addProjectAccessEmailDomain(input: { project: $project, domain: "example.com" })` admits everyone with a verified address on that domain. Project members are already admitted by the policy flag and never need a visitor row.

## Open a public endpoint

```
mutation Endpoint($service: ID!) {
  addServicePublicEndpoint(input: { service: $service, pathPattern: "/webhooks/stripe" }) {
    servicePublicEndpoint { id pathPattern }
  }
}
```

Patterns come in two forms. An exact path such as `/healthz` matches only itself. A trailing wildcard such as `/webhooks/*` matches everything under the prefix but not the bare prefix. `/get/*` does not match `/get`. When the whole surface under a path plus the path itself must be public, add both rules. A wildcard is valid only at the end of the pattern.

## Machine tokens

```
mutation Token($project: ID!) {
  createMachineToken(input: { project: $project, name: "ci-reporter" }) {
    machineTokenCreation { keyId name token }
  }
}
```

The `token` value is shown exactly once. Deliver it to the user or the consuming system immediately and never write it into source or logs. The caller presents it as a Bearer header against the app's own URL, `Authorization: Bearer <token>`, and the access layer authenticates before the request reaches the app. Give it a few seconds after minting before the first call. Revoke with `revokeMachineToken(input: { machineToken: $id })`, which cuts access on the next request.

## See who got in

```
query Events($first: Int = 20) {
  projectAccessEvents(first: $first) {
    nodes { projectAccessActor projectAccessSource projectAccessEvent path requestHost occurredAt }
  }
}
```

The access log records who signed in, who was denied, and which tokens called, with the actor kind and source for each. Use it to answer "who opened this" and to verify that a token or visitor you just configured actually worked.

## Error shapes

| You see | It means | Do this |
|---|---|---|
| App URL still answers 200 right after going private | Config propagation is in flight | Check again over a few seconds before judging |
| 401 from the app with a valid machine token | Token minted moments ago, or revoked | Wait briefly and retry once, then check the token still exists |
| 401 on a path you opened with `/x/*` | The wildcard does not match the bare prefix | Add the exact `/x` rule too |
| "Not authorized for PROJECT_MANAGE_ACCESS on PROJECT id" | Your credential cannot change access | Report the missing grant to the user |
