From aae6c0791b979d24d75dd7dfcc11d793df4db51d Mon Sep 17 00:00:00 2001 From: William Loo Date: Sat, 31 Oct 2026 10:52:41 -0700 Subject: [PATCH 1/6] feat(website): a Notes site on WebsiteEnvironment, one image that passes the conformance check (FD-4683) Per-user notes stamped by the universe clock, an admin-only route, and a Vite page that uses relative URLs so it works under the gateway's /website/notes/ route. The image builds the page and serves it with the API on PORT, and env.toml declares it as the env's server image. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/.gitignore | 2 + .../examples/website/notes/Dockerfile | 26 + .../website/notes/Dockerfile.dockerignore | 6 + .../examples/website/notes/README.md | 87 ++ .../examples/website/notes/data/seed.json | 15 + .../examples/website/notes/env.toml | 4 + .../examples/website/notes/site.py | 131 +++ .../examples/website/notes/web/index.html | 34 + .../website/notes/web/package-lock.json | 1020 +++++++++++++++++ .../examples/website/notes/web/package.json | 14 + .../examples/website/notes/web/src/main.js | 139 +++ .../examples/website/notes/web/src/style.css | 57 + .../examples/website/notes/web/vite.config.js | 10 + .../tests/test_website_notes_example.py | 233 ++++ 14 files changed, 1778 insertions(+) create mode 100644 packages/agentenv-protocol/examples/website/notes/.gitignore create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore create mode 100644 packages/agentenv-protocol/examples/website/notes/README.md create mode 100644 packages/agentenv-protocol/examples/website/notes/data/seed.json create mode 100644 packages/agentenv-protocol/examples/website/notes/env.toml create mode 100644 packages/agentenv-protocol/examples/website/notes/site.py create mode 100644 packages/agentenv-protocol/examples/website/notes/web/index.html create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package-lock.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/main.js create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/style.css create mode 100644 packages/agentenv-protocol/examples/website/notes/web/vite.config.js create mode 100644 packages/agentenv-protocol/tests/test_website_notes_example.py diff --git a/packages/agentenv-protocol/examples/website/notes/.gitignore b/packages/agentenv-protocol/examples/website/notes/.gitignore new file mode 100644 index 000000000..545a6e274 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/.gitignore @@ -0,0 +1,2 @@ +web/node_modules/ +web/dist/ diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile new file mode 100644 index 000000000..461e886b2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -0,0 +1,26 @@ +ARG NOTES=packages/agentenv-protocol/examples/website/notes + +FROM mirror.gcr.io/library/node:22-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS web +ARG NOTES +WORKDIR /web +COPY ${NOTES}/web/package.json ${NOTES}/web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY ${NOTES}/web/ ./ +RUN npm run build + +FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf +ARG NOTES +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY packages/agentenv-protocol /tmp/agentenv-protocol +RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol +COPY ${NOTES}/site.py /app/site.py +COPY ${NOTES}/data /app/data +COPY --from=web /web/dist /app/web/dist +ENV PORT=8000 +EXPOSE 8000 +RUN useradd --create-home --uid 10001 appuser +USER appuser +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)" +CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore new file mode 100644 index 000000000..10ae9270c --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/__pycache__ +**/.pytest_cache +**/.venv +.git diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md new file mode 100644 index 000000000..2cae1f817 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -0,0 +1,87 @@ +# Notes: a reference website/v1 site + +Notes is a small notes app built on `agentenv_protocol.website.WebsiteEnvironment`. It is the reference site for the +`urn:agentenv:website/v1` server contract. Each user keeps their own notes and can create, edit, mark done and delete +them. The page shows the universe's time. + +| File | What it is | +| --- | --- | +| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | +| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | +| `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. | +| `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. | +| `env.toml` | The website env as a bundle entry. | + +## What it exercises + +- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`. + The card advertises website/v1 with the domains, the health path and every route by name. +- **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, + `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. +- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only when the gateway + stamps `AgentEnv-Role: admin`; any other role gets 403. +- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the + store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded + user. A user's notes are invisible to every other user, and another user's note answers 404. +- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or + as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns + `{initial_state, current_state, state_diff}`. +- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names + it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. + It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the + SDK. +- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a + `data/reset` and before the next `data/add`. + +## Run it + +From this folder: + +```bash +cd web && npm ci && npm run build && cd .. +PYTHONPATH=/packages/agentenv-protocol/src python site.py +``` + +Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies +`/api` to port 8000. + +## Test it + +```bash +python -m pytest packages/agentenv-protocol/tests/test_website_notes_example.py +``` + +The test imports `site.py`, runs the conformance checker, and checks sign-in, per-user notes, the admin-only route, +clock-synced timestamps, and the data plane's reset and seed load. It serves a stand-in page, so it needs no Vite +build. + +## Build and verify the image + +The build context is the repo root, because the image installs the protocol package from this repo's source: + +```bash +docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site . +docker run -d --name notes-site -p 18080:8000 notes-site +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +curl -s http://localhost:18080/api/notes +``` + +Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart +the container before using it. + +## Register it + +```bash +agent-env env website put --id notes-site \ + --server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \ + --server-docker-context . --health-path /healthz +``` + +Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env +has a server image and no frontend image. Pass `--skip-validation` to register without deploying. + +`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and +`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this +folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a +published release instead, register the site with `put` as above. diff --git a/packages/agentenv-protocol/examples/website/notes/data/seed.json b/packages/agentenv-protocol/examples/website/notes/data/seed.json new file mode 100644 index 000000000..0a52ab7be --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/data/seed.json @@ -0,0 +1,15 @@ +{ + "current_user": "ann", + "users": [ + {"id": "ann", "name": "Ann Lee"}, + {"id": "ben", "name": "Ben Ortiz"} + ], + "notes": [ + {"id": 1, "owner": "ann", "title": "Buy milk", "body": "Oat, two cartons.", "done": false, + "created_at": "2026-01-05T09:00:00.000Z", "updated_at": "2026-01-05T09:00:00.000Z"}, + {"id": 2, "owner": "ann", "title": "Book dentist", "body": "", "done": true, + "created_at": "2026-01-04T15:30:00.000Z", "updated_at": "2026-01-06T08:10:00.000Z"}, + {"id": 3, "owner": "ben", "title": "Draft the quarterly report", "body": "Due Friday.", "done": false, + "created_at": "2026-01-03T11:00:00.000Z", "updated_at": "2026-01-03T11:00:00.000Z"} + ] +} diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml new file mode 100644 index 000000000..a1d019251 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -0,0 +1,4 @@ +type = "website" +environment_name = "notes" +health = "/healthz" +backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py new file mode 100644 index 000000000..5eaafda0e --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -0,0 +1,131 @@ +"""Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps +notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's +``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data +plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's +``AgentEnv-Role`` names them, can list every user's notes. + +Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000).""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + +from agentenv_protocol import environment_card +from agentenv_protocol.website import ( + ApiContext, + BadRequest, + NotFound, + Unauthorized, + WebsiteEnvironment, + api, + ready_check, + website, +) +from agentenv_protocol.website.clock import iso + +NOTES = "notes" +USERS = "users" + + +class NewNote(BaseModel): + title: str + body: str = "" + + +class NoteEdit(BaseModel): + title: Optional[str] = None + body: Optional[str] = None + done: Optional[bool] = None + + +class SignIn(BaseModel): + user: str + + +@environment_card(name="notes") +@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +class Notes(WebsiteEnvironment): + @ready_check + def seeded(self) -> bool: + """The site is usable once its store knows who may sign in.""" + return bool(self.store[USERS]) + + @api.get("/api/users", name="list_users") + def list_users(self) -> list: + """The users a browser can sign in as.""" + return self.store[USERS] + + @api.post("/api/sign-in", name="sign_in") + def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: + """Sign the browser in as one of the site's users.""" + if self.store.get(USERS, body.user) is None: + raise NotFound(f"no user {body.user!r}") + ctx.sign_in(body.user) + return {"user": ctx.user} + + @api.get("/api/notes", name="list_notes") + def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: + """The signed-in user's notes, newest first; with ``done``, only those done or not.""" + match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done} + return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True) + + @api.post("/api/notes", name="create_note", status=201) + def create_note(self, note: NewNote, ctx: ApiContext) -> dict: + """Add a note for the signed-in user, not done, created and updated now.""" + now = iso(self.now()) + return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body, + "done": False, "created_at": now, "updated_at": now}) + + @api.get("/api/notes/{note_id:int}", name="get_note") + def get_note(self, note_id: int, ctx: ApiContext) -> dict: + return self._note(note_id, ctx) + + @api.patch("/api/notes/{note_id:int}", name="edit_note") + def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict: + """Change a note's title, body or done, and its ``updated_at``.""" + note = self._note(note_id, ctx) + fields = edit.model_dump(exclude_none=True) + if "title" in fields: + fields["title"] = _title(fields["title"]) + note.update(fields, updated_at=iso(self.now())) + return note + + @api.post("/api/notes/{note_id:int}/toggle", name="toggle_note") + def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: + """Mark a note done, or not done when it was.""" + note = self._note(note_id, ctx) + note.update(done=not note["done"], updated_at=iso(self.now())) + return note + + @api.delete("/api/notes/{note_id:int}", name="delete_note") + def delete_note(self, note_id: int, ctx: ApiContext) -> dict: + self.store.delete(NOTES, self._note(note_id, ctx)["id"]) + return {"deleted": note_id} + + @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) + def all_notes(self) -> list: + """Every user's notes, for an ``admin`` caller only.""" + return self.store[NOTES] + + def _user(self, ctx: ApiContext) -> str: + if ctx.user is None: + raise Unauthorized("sign in first") + return ctx.user + + def _note(self, note_id: int, ctx: ApiContext) -> dict: + """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" + note = self.store.get(NOTES, note_id) + if note is None or note["owner"] != self._user(ctx): + raise NotFound(f"no note {note_id}") + return note + + +def _title(title: str) -> str: + if not title.strip(): + raise BadRequest("a note needs a title", code="empty_title") + return title.strip() + + +if __name__ == "__main__": + Notes().serve() diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html new file mode 100644 index 000000000..1e498d3c1 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -0,0 +1,34 @@ + + + + + + Notes + + +
+

Notes

+
+ + +
+
+
+
+ + + +
+ + +
    +
    + + + diff --git a/packages/agentenv-protocol/examples/website/notes/web/package-lock.json b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json new file mode 100644 index 000000000..938f9c2cd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json @@ -0,0 +1,1020 @@ +{ + "name": "notes-site-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notes-site-web", + "version": "0.0.0", + "devDependencies": { + "vite": "5.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.2.tgz", + "integrity": "sha512-qB7Gzl1QI45zP1zXgqLKzjFmOBGQw2s2MLtdw0eB4xdFGPNE8LhmOnItFgyYJXXNSW8Go9c2ovvXrUUzw5sAMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.2.tgz", + "integrity": "sha512-8+hXNO1J+GsYCGHwyJuBlnhyozivEvIEm/P6NkJeuoVUN+60TDvYYXk38UTm5bKRTLhIpqEQYx4d9CeN6kjh0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.2.tgz", + "integrity": "sha512-/uEZY+tcWDE8DXa/rajU3Bwv6IfvQavvzjmbkn39GgQenT+UYtAxfEMdB7VKr74y8aonncm0eQa1EbiNfg+Cjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.2.tgz", + "integrity": "sha512-3tCLEgakEvb5M3/KyYdhPjoteyWYziMTeznoXfcs/31eeFu+Vgs0VNRlb6Zc+YC07/sLa84OwTDF5zHOMFk92Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.2.tgz", + "integrity": "sha512-z31ODzkdkX7OOJCssvpz++hhtkQKWKw2L0eO2HAxFP7RMSXXedzkrTjesqCUxs4XLckRShjNfxsNxjGY5DQCGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.2.tgz", + "integrity": "sha512-CTvrb0KTAQqcrpcj7rg8FgAI0+jRWnCl+En6exqXDZztCo7vspxlRUX8z9Hp/J/ja2XRnQ1JDbexkBtRyD9jSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.2.tgz", + "integrity": "sha512-SJdblZpBkbOh1e4JwMM5pJJ2PdVmD3WWbMTXvncMSRLA3DdZuwU+P93RaSChOAXN4Mu53tBoFp1ADPzi3dIVAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.2.tgz", + "integrity": "sha512-pUBEd7uI9dAu3TpbPcvi9eU2aJslsjIomormeTaQHmzWNhyqNx3mmq+ekC6blGNZu8F3uTHngTibKp7tbblF0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.2.tgz", + "integrity": "sha512-wW6ARw2Ah0/teOBTgTeSJIpVdPKaxQmEgZ/irkUfr3F9sgimqpApCv/NbQzjm0Nfi5EFilHh1H8kz01uQLaq5Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.2.tgz", + "integrity": "sha512-xcFYMHNL42fRu2MFipvxznr+zd+DRqwL98bhNvDWbMPRcFtMYf34NAz4xVOlnEhzgpNRE5htSemcqAsAxf2eQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.2.tgz", + "integrity": "sha512-U9HrnJ9PFXqA//P/oEGaXBIJraEqzYMS9yuQOdOcPCvHZIC1FFOlnd8ocAaeDDPDpY8xOrhfxMRtr6KqKi3gyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.2.tgz", + "integrity": "sha512-2hZN8CFGfsbDw+DNtImkPRitpLNjVM2XEbi4ZQ5igLxMFD4gI/P6Zo9czGxuvQXtRyZtZyZp8Kr4ERsAipplBw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.2.tgz", + "integrity": "sha512-HZO7UfWEsReAnkdVByyQaFsNXJCtvdfun/LFb2Xd4qnhgF1ZfhikpbgqrTRtNMmB5OgNnSwFLiPobOT8oNT9iw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.2.tgz", + "integrity": "sha512-AdYIihuLkJ2TScy9BpSPiDex3zOMjkNJLsFlk9NeJ0U3WEtOx4W8z0nOkPEhDS2S4gwsRWBa6ZZexJ9e6OxyzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.2.tgz", + "integrity": "sha512-iZE0NXcAGqLoCWeHRPkBxJe93JT4Q981z1ZqR+y7qAiIksdRlWhw8hmqHFo1B4ZnU66He31NUb52Iyv+vwMEBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.2.tgz", + "integrity": "sha512-vC7EAtJpSqSrrbg8huMfgxtwlR8fUsFCe5rStDk1WA8POky/6EEp52dxENMmqLKjSfdM3TT1WWPcZ01hiYZcdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.2.tgz", + "integrity": "sha512-DdqG6x72MHpQOlkK/cqrru9b2QMJHLkNojCB3bteB5fmkE/7XEAkReJMANp8wgef8eiEC99SvdP/BH7Ho/BpLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.2.tgz", + "integrity": "sha512-F10mjdnRmGg9K+LGdqQVeUYFr08f3fVBOh7YNE5p1KIpTzhtKKOLqcQEsqZG6yEM2eouXaMo2AZzeDAcseVTrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.2.tgz", + "integrity": "sha512-ChzK9f3iE4N5GInBquiZtzWOqh+CYS5nlLlXGJxeG3d2T9rk8L1Hxg0s+v2Eh6vmAc/1T6IhOVkQU0ORP2eV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.2.tgz", + "integrity": "sha512-95WwzC+KH9hPHtbgwvwwW6MkKME6vMAdAQLS2diCvoi/iUiYdC0Y1RIPYVESx0iWqkN5ZaZq6qD1BflLcyTMEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.2.tgz", + "integrity": "sha512-eKbpwlolQmDKd/dbFjvqB1+2qdYQLrgxi3Mtbijp7/3yBBvO9OoHtQ3ecYIaCdW46ijSjy5/yydyrrxHI416/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.2.tgz", + "integrity": "sha512-is9lnlv6khapl+H9Hjp0gIVsUVtDxQga2nwMHpQbhDXMCMGcNmlel4/q9ihf/3FvvbL4GdGOPOGvWoei2RCggg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.2.tgz", + "integrity": "sha512-yTq+7YjsgbBcMdlk7Wq+VoJilGQtdtRc4PyTvN7vhO958atCO2nQeIjCF/YANSD/Z/fnrCr27fYsGgC6PFTndQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.2.tgz", + "integrity": "sha512-nAvZib4Xz1zx8OKhtRuyCsSmCmbfegv1AGbfpG+CowQKxCBW1pLlfYWjcr1+GI3BzRKQbBl7l9640GwWAwOF7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.2.tgz", + "integrity": "sha512-R3CVbEbNa1OXjpldD1tOLarb5rB08xjQiRMpe7PZKtT8eHCZhrV4ClSPfLLbSgRMM1RfLtmAfouBRf14f47lMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.2.tgz", + "integrity": "sha512-DeuVFilvaGtoS9TFIbTIQ0Kt4CBqP3xnjJ8SFqWWbXlFbrpmCdivEW7Rm38trRA8rZzQQ8lhs73nEetWotJpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.2.tgz", + "integrity": "sha512-sFZk5BeRUQzkjmuClrJI41BowIP4yv/40d5gM9vtjxVLEt+8JG9FGlBps6cliTP2KhFiw22oqNGo0G9pVpPZlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.2", + "@rollup/rollup-android-arm64": "4.67.2", + "@rollup/rollup-android-x64": "4.67.2", + "@rollup/rollup-darwin-arm64": "4.67.2", + "@rollup/rollup-darwin-x64": "4.67.2", + "@rollup/rollup-freebsd-arm64": "4.67.2", + "@rollup/rollup-freebsd-x64": "4.67.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.2", + "@rollup/rollup-linux-arm-musleabihf": "4.67.2", + "@rollup/rollup-linux-arm64-gnu": "4.67.2", + "@rollup/rollup-linux-arm64-musl": "4.67.2", + "@rollup/rollup-linux-loong64-gnu": "4.67.2", + "@rollup/rollup-linux-loong64-musl": "4.67.2", + "@rollup/rollup-linux-ppc64-gnu": "4.67.2", + "@rollup/rollup-linux-ppc64-musl": "4.67.2", + "@rollup/rollup-linux-riscv64-gnu": "4.67.2", + "@rollup/rollup-linux-riscv64-musl": "4.67.2", + "@rollup/rollup-linux-s390x-gnu": "4.67.2", + "@rollup/rollup-linux-x64-gnu": "4.67.2", + "@rollup/rollup-linux-x64-musl": "4.67.2", + "@rollup/rollup-openbsd-x64": "4.67.2", + "@rollup/rollup-openharmony-arm64": "4.67.2", + "@rollup/rollup-win32-arm64-msvc": "4.67.2", + "@rollup/rollup-win32-ia32-msvc": "4.67.2", + "@rollup/rollup-win32-x64-gnu": "4.67.2", + "@rollup/rollup-win32-x64-msvc": "4.67.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/package.json b/packages/agentenv-protocol/examples/website/notes/web/package.json new file mode 100644 index 000000000..c741095dd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "notes-site-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "5.4.10" + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js new file mode 100644 index 000000000..ef3e16a69 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -0,0 +1,139 @@ +import "./style.css"; + +let offsetMs = 0; +let filter = "all"; + +export function universeNow() { + return new Date(Date.now() + offsetMs); +} + +function followUniverse(response) { + const at = Date.parse(response.headers.get("X-Universe-Now") || ""); + if (!Number.isNaN(at)) offsetMs = at - Date.now(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(path, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + followUniverse(response); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`); + return data; +} + +const $ = (id) => document.getElementById(id); + +function showError(error) { + $("error").textContent = error ? error.message : ""; + $("error").hidden = !error; +} + +function formatTime(value) { + return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }); +} + +function tick() { + $("universe-now").textContent = `${formatTime(universeNow())} UTC`; + $("universe-now").dateTime = universeNow().toISOString(); +} + +function noteItem(note) { + const item = document.createElement("li"); + item.className = `note card${note.done ? " done" : ""}`; + item.dataset.id = note.id; + + const done = document.createElement("input"); + done.type = "checkbox"; + done.checked = note.done; + done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); + done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + + const title = document.createElement("input"); + title.className = "title"; + title.value = note.title; + title.setAttribute("aria-label", "Title"); + + const body = document.createElement("textarea"); + body.className = "body"; + body.rows = 2; + body.value = note.body; + body.placeholder = "Details"; + body.setAttribute("aria-label", "Details"); + + const save = () => { + if (title.value === note.title && body.value === note.body) return; + run(() => api(`api/notes/${note.id}`, { method: "PATCH", body: { title: title.value, body: body.value } })); + }; + title.addEventListener("change", save); + body.addEventListener("change", save); + + const meta = document.createElement("small"); + meta.textContent = `Created ${formatTime(note.created_at)} · updated ${formatTime(note.updated_at)}`; + + const remove = document.createElement("button"); + remove.className = "delete"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + + const text = document.createElement("div"); + text.className = "text"; + text.append(title, body, meta); + item.append(done, text, remove); + return item; +} + +async function render() { + const query = filter === "all" ? "" : `?done=${filter === "done"}`; + const notes = await api(`api/notes${query}`); + $("notes").replaceChildren(...notes.map(noteItem)); + if (!notes.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "No notes here yet."; + $("notes").append(empty); + } +} + +async function run(action) { + try { + await action(); + showError(null); + await render(); + } catch (error) { + showError(error); + } +} + +async function start() { + const [session, users] = await Promise.all([api("api/session"), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = session.user ?? ""; + $("user").addEventListener("change", (event) => + run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + + $("new-note").addEventListener("submit", (event) => { + event.preventDefault(); + const body = { title: $("new-title").value, body: $("new-body").value }; + run(async () => { + await api("api/notes", { method: "POST", body }); + $("new-note").reset(); + }); + }); + + for (const button of document.querySelectorAll("[data-filter]")) { + button.addEventListener("click", () => { + filter = button.dataset.filter; + document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === button)); + run(async () => {}); + }); + } + + tick(); + setInterval(tick, 1000); + await render(); +} + +start().catch(showError); diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/style.css b/packages/agentenv-protocol/examples/website/notes/web/src/style.css new file mode 100644 index 000000000..8ecd48b6a --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/style.css @@ -0,0 +1,57 @@ +:root { + --bg: #f6f6f3; + --card: #ffffff; + --text: #1d1d1b; + --muted: #6b6b66; + --line: #deded8; + --accent: #2f5bd3; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--card); +} +.bar h1 { margin: 0; font-size: 1.25rem; } +.who { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--muted); font-size: 0.9rem; } +.who time { font-variant-numeric: tabular-nums; } + +main { max-width: 720px; margin: 0 auto; padding: 16px; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } + +input, textarea, select, button { font: inherit; color: inherit; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: transparent; } +textarea { resize: vertical; } +button { border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; background: var(--card); cursor: pointer; } +button[type="submit"], .filters .active { background: var(--accent); border-color: var(--accent); color: #fff; } + +#new-note { display: grid; gap: 8px; } +#new-note button { justify-self: end; } + +.filters { display: flex; gap: 8px; margin: 16px 0; } + +#error { color: #b3261e; margin: 0 0 12px; } + +#notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.note { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start; } +.note .text { display: grid; gap: 6px; min-width: 0; } +.note .title { font-weight: 600; border-color: transparent; } +.note .body { border-color: transparent; color: var(--muted); } +.note .title:focus, .note .body:focus { border-color: var(--line); } +.note small { color: var(--muted); } +.note.done .title { text-decoration: line-through; color: var(--muted); } +.note input[type="checkbox"] { width: auto; margin-top: 10px; } +.delete { color: #b3261e; } +.empty { color: var(--muted); text-align: center; padding: 24px; } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js new file mode 100644 index 000000000..2fecfa631 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + server: { + proxy: { + "/api": "http://127.0.0.1:8000", + }, + }, +}); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py new file mode 100644 index 000000000..8e8b4cfaa --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -0,0 +1,233 @@ +"""The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, +and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the +admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the +data plane.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import pytest +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from agentenv_protocol.website import SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, conformance, website + +NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" +SEED = json.loads((NOTES_DIR / "data" / "seed.json").read_text(encoding="utf-8")) +RPC = "/agentenv" + + +def _load_site(): + """The example's ``site.py``, imported under a name of its own: ``site`` is the standard library's.""" + spec = importlib.util.spec_from_file_location("notes_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _load_site() + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: + """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" + (tmp_path / "index.html").write_text("Notes") + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) + return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + + +@pytest.fixture +def env(notes_class: type[WebsiteEnvironment]) -> WebsiteEnvironment: + return notes_class() + + +@pytest.fixture +def app(env: WebsiteEnvironment): + return env.create_app() + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) + + +class _Clock: + """A gateway-shaped /clock/time whose time a test sets.""" + + def __init__(self, virtual: str) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": outer.virtual}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_port}/clock/time" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def gateway_clock(): + clock = _Clock("2040-02-03T04:05:06Z") + yield clock + clock.close() + + +def test_the_example_passes_every_conformance_check(client: TestClient) -> None: + results = conformance.check("http://testserver", client=client) + assert {r.name: r.status for r in results} == dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + + +def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] + assert card["name"] == "notes" + assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", + "edit_note", "toggle_note", "delete_note", "all_notes"] + assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: + session = client.get("/api/session") + assert session.json() == {"user": "ann", "role": "default", "signed_in": True} + assert SESSION_COOKIE in session.cookies + assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] + assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} + assert client.get("/api/session").json()["user"] == "ben" + assert [n["title"] for n in client.get("/api/notes").json()] == ["Draft the quarterly report"] + refused = client.post("/api/sign-in", json={"user": "mallory"}) + assert refused.status_code == 404 and refused.json()["code"] == "not_found" + + +def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, client: TestClient) -> None: + created = client.post("/api/notes", json={"title": " Call Sam ", "body": "about the trip"}) + assert created.status_code == 201 + note = created.json() + assert (note["owner"], note["title"], note["done"]) == ("ann", "Call Sam", False) + assert note["created_at"] == note["updated_at"] + + ben = TestClient(app) + ben.post("/api/sign-in", json={"user": "ben"}) + assert "Call Sam" not in [n["title"] for n in ben.get("/api/notes").json()] + for response in (ben.get(f"/api/notes/{note['id']}"), ben.patch(f"/api/notes/{note['id']}", json={"done": True}), + ben.post(f"/api/notes/{note['id']}/toggle"), ben.delete(f"/api/notes/{note['id']}")): + assert response.status_code == 404, response.text + + edited = client.patch(f"/api/notes/{note['id']}", json={"body": "about the trip, before Friday"}).json() + assert (edited["title"], edited["body"]) == ("Call Sam", "about the trip, before Friday") + assert client.post(f"/api/notes/{note['id']}/toggle").json()["done"] is True + assert [n["title"] for n in client.get("/api/notes", params={"done": "true"}).json()] == ["Call Sam", "Book dentist"] + assert client.patch(f"/api/notes/{note['id']}", json={"title": " "}).json()["code"] == "empty_title" + assert client.post("/api/notes", json={"body": "no title"}).status_code == 400 + assert client.delete(f"/api/notes/{note['id']}").json() == {"deleted": note["id"]} + assert client.get(f"/api/notes/{note['id']}").status_code == 404 + + +def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient) -> None: + for headers in ({}, {"AgentEnv-Role": "agent"}): + refused = client.get("/api/admin/notes", headers=headers) + assert refused.status_code == 403, headers + every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin"}).json() + assert {n["owner"] for n in every} == {"ann", "ben"} + + +def test_timestamps_follow_the_universes_clock_once_synced(client: TestClient, gateway_clock: _Clock) -> None: + before = client.post("/api/notes", json={"title": "before the sync"}).json() + assert not before["created_at"].startswith("2040") + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + after = client.post("/api/notes", json={"title": "after the sync"}) + assert after.json()["created_at"].startswith("2040-02-03T04:05") + assert after.headers[UNIVERSE_NOW_HEADER].startswith("2040-02-03T04:05") + toggled = client.post(f"/api/notes/{before['id']}/toggle").json() + assert toggled["created_at"] == before["created_at"] and toggled["updated_at"].startswith("2040-02-03T04:05") + + +def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestClient) -> None: + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {} + assert client.get("/api/notes").status_code == 401 + assert client.get("/healthz").status_code == 503 + + seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), + "mimeType": "application/json"}} + rpc(client, "data/add", {"parts": [seed_file]}) + assert client.get("/healthz").status_code == 200 + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + + client.post("/api/notes/1/toggle") + state = client.get("/go").json() + assert state["initial_state"] == SEED + assert state["state_diff"]["notes"]["changed"][0]["after"]["done"] is True + + +GATEWAY_PREFIX = "/website/notes" + + +def _behind_gateway_prefix(app): + """The gateway's ``/website//`` route: a request under the prefix reaches the site with the prefix taken off, + and any other one answers the gateway's own 404.""" + + async def gateway(scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith(GATEWAY_PREFIX + "/"): + path = scope["path"][len(GATEWAY_PREFIX):] + return await app({**scope, "path": path, "raw_path": path.encode()}, receive, send) + if scope["type"] != "http": + return await app(scope, receive, send) + await PlainTextResponse("no gateway route", status_code=404)(scope, receive, send) + + return gateway + + +@pytest.mark.skipif(not (NOTES_DIR / "web" / "dist" / "index.html").is_file(), reason="web/dist needs a Vite build") +def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> None: + page_url = f"http://gateway.test{GATEWAY_PREFIX}/" + browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") + page = browser.get(page_url) + assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + + assets = [urljoin(page_url, ref) for ref in re.findall(r'(?:src|href)="([^"]+)"', page.text)] + assert assets and all(urlsplit(url).path.startswith(GATEWAY_PREFIX + "/") for url in assets), assets + scripts = [] + for url in assets: + fetched = browser.get(url) + assert fetched.status_code == 200, url + if url.endswith(".js"): + scripts.append(fetched.text) + + called = {path for script in scripts for path in re.findall(r'["`](/?api/[\w-]+)', script)} + assert {"api/session", "api/users", "api/notes", "api/sign-in"} <= called + assert not [path for path in called if path.startswith("/")], called + assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann" + assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"] From c0c6a5300745b6c2529495972e1d01f9b12bd65d Mon Sep 17 00:00:00 2001 From: William Loo Date: Mon, 2 Nov 2026 01:22:39 -0800 Subject: [PATCH 2/6] fix(website): Notes' admin route answers only a caller with the site's role key (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 6 ++++-- .../tests/test_website_notes_example.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 2cae1f817..3311112b0 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -18,8 +18,10 @@ them. The page shows the universe's time. The card advertises website/v1 with the domains, the health path and every route by name. - **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. -- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only when the gateway - stamps `AgentEnv-Role: admin`; any other role gets 403. +- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that + sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller + gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no + `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. - **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 8e8b4cfaa..e02147b10 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -18,7 +18,8 @@ from starlette.responses import PlainTextResponse from starlette.testclient import TestClient -from agentenv_protocol.website import SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, conformance, website +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, + conformance, website) NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" SEED = json.loads((NOTES_DIR / "data" / "seed.json").read_text(encoding="utf-8")) @@ -153,11 +154,14 @@ def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, c assert client.get(f"/api/notes/{note['id']}").status_code == 404 -def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient) -> None: - for headers in ({}, {"AgentEnv-Role": "agent"}): +def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for headers in ({}, {"AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}, {"AgentEnv-Role": "admin"}, + {"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "guess"}): refused = client.get("/api/admin/notes", headers=headers) assert refused.status_code == 403, headers - every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin"}).json() + every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}).json() assert {n["owner"] for n in every} == {"ann", "ben"} From 3fc49e8c0861bfbacbbab815d25461609af1b328 Mon Sep 17 00:00:00 2001 From: William Loo Date: Mon, 2 Nov 2026 15:34:11 -0800 Subject: [PATCH 3/6] fix(website): Notes keeps a note's unsaved text across a refresh, and its clock follows the universe's rate (FD-4683) A refresh after a save replaced the list, so text typed into another field before the save answered was lost. render() now keeps each field edited since it was drawn, or focused, and puts back its text, focus and selection; a field saves on blur too, once per text. The page's clock assumed real-time speed between answers; it now reads X-Universe-Now from the health path every 5 seconds, so a stopped or faster clock shows. Checked in Chromium against the site: with the title's save delayed, text typed into the body stays focused through the refresh and is saved; one save per edit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/web/src/main.js | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index ef3e16a69..30066ea01 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -2,6 +2,7 @@ import "./style.css"; let offsetMs = 0; let filter = "all"; +const CLOCK_REFRESH_MS = 5000; export function universeNow() { return new Date(Date.now() + offsetMs); @@ -53,22 +54,26 @@ function noteItem(note) { const title = document.createElement("input"); title.className = "title"; - title.value = note.title; + title.defaultValue = note.title; title.setAttribute("aria-label", "Title"); const body = document.createElement("textarea"); body.className = "body"; body.rows = 2; - body.value = note.body; + body.defaultValue = note.body; body.placeholder = "Details"; body.setAttribute("aria-label", "Details"); + let sent = { title: note.title, body: note.body }; const save = () => { - if (title.value === note.title && body.value === note.body) return; - run(() => api(`api/notes/${note.id}`, { method: "PATCH", body: { title: title.value, body: body.value } })); + if (title.value === sent.title && body.value === sent.body) return; + sent = { title: title.value, body: body.value }; + run(() => api(`api/notes/${note.id}`, { method: "PATCH", body: sent })); }; - title.addEventListener("change", save); - body.addEventListener("change", save); + for (const field of [title, body]) { + field.addEventListener("change", save); + field.addEventListener("blur", save); + } const meta = document.createElement("small"); meta.textContent = `Created ${formatTime(note.created_at)} · updated ${formatTime(note.updated_at)}`; @@ -85,10 +90,36 @@ function noteItem(note) { return item; } +function editing() { + const kept = []; + for (const field of document.querySelectorAll("#notes .title, #notes .body")) { + const focused = field === document.activeElement; + if (focused || field.value !== field.defaultValue) { + kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, + start: field.selectionStart, end: field.selectionEnd }); + } + } + return kept; +} + +function restore(kept) { + for (const { id, kind, value, focused, start, end } of kept) { + const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); + if (!field) continue; + field.value = value; + if (focused) { + field.focus(); + field.setSelectionRange(start, end); + } + } +} + async function render() { const query = filter === "all" ? "" : `?done=${filter === "done"}`; const notes = await api(`api/notes${query}`); + const kept = editing(); $("notes").replaceChildren(...notes.map(noteItem)); + restore(kept); if (!notes.length) { const empty = document.createElement("li"); empty.className = "empty"; @@ -133,6 +164,7 @@ async function start() { tick(); setInterval(tick, 1000); + setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); await render(); } From e6f5c657900bfed0a9253befbf9151350fc70ae7 Mon Sep 17 00:00:00 2001 From: William Loo Date: Mon, 2 Nov 2026 16:41:38 -0800 Subject: [PATCH 4/6] fix(website): Notes clears a new note's fields after the save only if they still hold what was sent (FD-4683) Text typed into the Add note form while the earlier save was pending was wiped when that save answered, since the form was reset. Each field is now cleared only when it still holds the text that was submitted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/examples/website/notes/web/src/main.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index 30066ea01..2868e8124 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -150,7 +150,8 @@ async function start() { const body = { title: $("new-title").value, body: $("new-body").value }; run(async () => { await api("api/notes", { method: "POST", body }); - $("new-note").reset(); + if ($("new-title").value === body.title) $("new-title").value = ""; + if ($("new-body").value === body.body) $("new-body").value = ""; }); }); From 928808c643f88ad92eef42bd63a141065b9dd924 Mon Sep 17 00:00:00 2001 From: William Loo Date: Mon, 2 Nov 2026 16:43:10 -0800 Subject: [PATCH 5/6] fix(website): Notes' Vite dev server forwards the health path its clock reads (FD-4683) The page re-reads X-Universe-Now from healthz every 5 seconds, but npm run dev forwarded only /api, so an idle page under Vite never saw the site's clock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/examples/website/notes/web/vite.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js index 2fecfa631..d07ecd2c2 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -5,6 +5,7 @@ export default defineConfig({ server: { proxy: { "/api": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", }, }, }); From c287ec173678d5676201cf87d14f8be041954129 Mon Sep 17 00:00:00 2001 From: William Loo Date: Mon, 2 Nov 2026 17:49:17 -0800 Subject: [PATCH 6/6] fix(website): notes saves reach the server in edit order and the list shows the newest filter (FD-4683) Each note keeps one PATCH in flight and sends its latest text next, so an earlier save can no longer land after a newer one. render() drops a list answer when a newer list request has started since. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/web/src/main.js | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index 2868e8124..b49ec3efd 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -2,6 +2,8 @@ import "./style.css"; let offsetMs = 0; let filter = "all"; +let renders = 0; +const saving = new Map(); const CLOCK_REFRESH_MS = 5000; export function universeNow() { @@ -27,6 +29,27 @@ async function api(path, { method = "GET", body } = {}) { const $ = (id) => document.getElementById(id); +function queueSave(id, fields) { + const queued = saving.get(id); + if (queued) { + queued.next = fields; + return; + } + const state = { next: fields }; + saving.set(id, state); + run(async () => { + try { + while (state.next) { + const next = state.next; + state.next = null; + await api(`api/notes/${id}`, { method: "PATCH", body: next }); + } + } finally { + saving.delete(id); + } + }); +} + function showError(error) { $("error").textContent = error ? error.message : ""; $("error").hidden = !error; @@ -68,7 +91,7 @@ function noteItem(note) { const save = () => { if (title.value === sent.title && body.value === sent.body) return; sent = { title: title.value, body: body.value }; - run(() => api(`api/notes/${note.id}`, { method: "PATCH", body: sent })); + queueSave(note.id, sent); }; for (const field of [title, body]) { field.addEventListener("change", save); @@ -116,7 +139,9 @@ function restore(kept) { async function render() { const query = filter === "all" ? "" : `?done=${filter === "done"}`; + const request = ++renders; const notes = await api(`api/notes${query}`); + if (request !== renders) return; const kept = editing(); $("notes").replaceChildren(...notes.map(noteItem)); restore(kept);