Skip to content

Commit 90499de

Browse files
feat(website): one spec declares a site, composes it and checks any server (FD-4683)
This PR's change, rebased as one commit onto #444. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0ef31b9 commit 90499de

23 files changed

Lines changed: 2789 additions & 55 deletions

File tree

‎packages/agentenv-protocol/docs/website-spec.md‎

Lines changed: 371 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/agentenv-protocol/examples/website/notes/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ WORKDIR /app
1414
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
1515
COPY packages/agentenv-protocol /tmp/agentenv-protocol
1616
RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol
17-
COPY ${NOTES}/site.py /app/site.py
17+
COPY ${NOTES}/site.py ${NOTES}/website.toml /app/
1818
COPY ${NOTES}/data /app/data
1919
COPY --from=web /web/dist /app/web/dist
2020
ENV PORT=8000

‎packages/agentenv-protocol/examples/website/notes/README.md‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,23 @@ them. The page shows the universe's time.
66

77
| File | What it is |
88
| --- | --- |
9-
| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). |
9+
| `website.toml` | The site's spec: its name, domains, health path, source, seed, collections, personas, roles, routes, state read and clock (see `docs/website-spec.md`). |
10+
| `site.py` | The server: the `Notes` WebsiteEnvironment, declared from `website.toml`, its `@api` routes, and `serve()` on `PORT` (8000). |
1011
| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. |
1112
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. |
1213
| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. |
13-
| `env.toml` | The website env as a bundle entry. |
14+
| `env.toml` | The website env as a bundle entry, pointing at `website.toml`. |
1415

1516
## What it exercises
1617

17-
- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`.
18-
The card advertises website/v1 with the domains, the health path and every route by name.
18+
- **Site declaration.** `@website(spec="website.toml")`. The spec gives the name, domains, static folder, health path
19+
and seed, and the class is refused when its `@api` routes aren't the spec's. The card's website/v1 params are the
20+
spec's `to_card_extension()`: the domains, the health path, every route by name, the session, the state read and
21+
the clock.
1922
- **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`,
2023
`edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`.
21-
- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that
24+
- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`, and the spec's `[roles]` grants
25+
it to `admin`. It answers only a caller that
2226
sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller
2327
gets 403. A deploy with `keyed_roles` gives the site its own role key as `WEBSITE_ROLE_KEY` and its gateway sends it
2428
on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets
@@ -72,9 +76,11 @@ python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-h
7276
curl -s http://localhost:18080/api/notes
7377
```
7478

75-
Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning
76-
too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks
77-
fail the run, and Notes passes every check either way. The clock check leaves the server following a
79+
Use `--clock-host` so the container can reach the checker's clock, and add
80+
`--spec packages/agentenv-protocol/examples/website/notes/website.toml` to check the server against its spec too.
81+
`--strict` fails on a recommended check's warning too (the card, `X-Universe-Now`, the clock, forwarded headers,
82+
cookies); without it only the contract's required checks, and the spec's when given, fail the run, and Notes passes
83+
every check either way. The clock check leaves the server following a
7884
clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart
7985
the container before using it.
8086

@@ -86,10 +92,10 @@ agent-env env website put --id notes-site \
8692
--server-docker-context . --health-path /healthz
8793
```
8894

89-
Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env
95+
Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env
9096
has a server image and no frontend image. Pass `--skip-validation` to register without deploying.
9197

92-
`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and
93-
`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this
98+
`env.toml` describes the same env for a bundle. Its keys are `type`, `spec` and `backend_image`; the env takes its
99+
`environment_name`, health path and domains from the spec. A bundle builds `backend_image` with this
94100
folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a
95101
published release instead, register the site with `put` as above.
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
type = "website"
2-
environment_name = "notes"
3-
health = "/healthz"
2+
spec = "website.toml"
43
backend_image = { dockerfile = "Dockerfile" }

‎packages/agentenv-protocol/examples/website/notes/site.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from pydantic import BaseModel
1313

14-
from agentenv_protocol import environment_card
1514
from agentenv_protocol.website import (
1615
ApiContext,
1716
BadRequest,
@@ -43,8 +42,7 @@ class SignIn(BaseModel):
4342
user: str
4443

4544

46-
@environment_card(name="notes")
47-
@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")
45+
@website(spec="website.toml")
4846
class Notes(WebsiteEnvironment):
4947
@ready_check
5048
def seeded(self) -> bool:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[site]
2+
name = "notes"
3+
domains = ["notes.test"]
4+
health = "/healthz"
5+
6+
[source]
7+
sdk = "site:Notes"
8+
assets = "web/dist"
9+
10+
[data]
11+
seed = ["data/seed.json"]
12+
13+
[data.collections.users]
14+
15+
[data.collections.notes]
16+
owner = "owner"
17+
18+
[users]
19+
personas = [{ id = "ann", name = "Ann Lee" }, { id = "ben", name = "Ben Ortiz" }]
20+
acting_user = "ann"
21+
sign_in = "cookie"
22+
23+
[roles]
24+
admin = ["all_notes"]
25+
26+
[routes.ops]
27+
list_users = "GET /api/users"
28+
sign_in = "POST /api/sign-in"
29+
list_notes = "GET /api/notes"
30+
create_note = "POST /api/notes"
31+
get_note = "GET /api/notes/{note_id}"
32+
edit_note = "PATCH /api/notes/{note_id}"
33+
toggle_note = "POST /api/notes/{note_id}/toggle"
34+
delete_note = "DELETE /api/notes/{note_id}"
35+
all_notes = "GET /api/admin/notes"
36+
37+
[state]
38+
read = "go"
39+
40+
[clock]
41+
follow = "header"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[site]
2+
name = "sso"
3+
4+
[source]
5+
sdk = "fake_sso:FakeSso"
6+
7+
[data.collections.accounts]
8+
9+
[data.collections.grants]
10+
owner = "user"
11+
12+
[users]
13+
sign_in = "cookie"
14+
15+
[routes.ops]
16+
authorize = "GET /authorize"
17+
login = "POST /login"
18+
token = "POST /token"
19+
userinfo = "GET /userinfo"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
extends = "intranet.toml"
2+
3+
[users]
4+
personas = ["ben", "cy"]
5+
acting_user = "ben"
6+
7+
[roles]
8+
editor = false
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[site]
2+
name = "intranet"
3+
domains = ["intranet.test", "*.intranet.test"]
4+
5+
[source.image]
6+
dockerfile = "Dockerfile.intranet"
7+
8+
[data.collections.users]
9+
10+
[data.collections.pages]
11+
owner = "author"
12+
13+
[users]
14+
personas = ["ann", "ben"]
15+
acting_user = "ann"
16+
17+
[roles]
18+
editor = ["publish_page"]
19+
20+
[routes.ops]
21+
list_pages = "GET /api/pages"
22+
publish_page = "POST /api/pages"
23+
24+
[[uses]]
25+
spec = "components/sso.toml"
26+
mount = "/sso"
27+
data = { accounts = "users" }
28+
29+
[state]
30+
read = "go"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[site]
2+
name = "slack"
3+
domains = ["slack.com", "*.slack.com"]
4+
health = "/api/api.test"
5+
6+
[source.live]
7+
allow_hosts = ["*.slack-edge.com", "*.slack-imgs.com"]
8+
credentials = { token = "secret:SLACK_USER_TOKEN" }
9+
10+
[users]
11+
sign_in = "live"
12+
13+
[routes.ops]
14+
list_conversations = "GET /api/conversations.list"
15+
history = "GET /api/conversations.history"
16+
post_message = "POST /api/chat.postMessage"
17+
add_reaction = "POST /api/reactions.add"
18+
19+
[state]
20+
read = "diff"
21+
22+
[clock]
23+
follow = "none"

0 commit comments

Comments
 (0)