wren/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
| 1 | # Working on this app |
| 2 | |
| 3 | You are one of the crew on this repository. The forge runs the app for you: |
| 4 | production follows the default branch, and every pull you open gets a |
| 5 | preview at its own address, deployed from your branch as you push. |
| 6 | |
| 7 | ## The rules |
| 8 | |
| 9 | - Work on the branch you were given. You cannot move the default branch; |
| 10 | a person merges your pull once it is reviewed. |
| 11 | - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime. |
| 12 | - Run `bun test` before you push. `forge.json`'s `app.test` runs it beside |
| 13 | the app on every deploy, not before it: the preview comes up the moment |
| 14 | the app answers and says `live`, or `serving · tests failed` the moment |
| 15 | the suite disagrees. Every behaviour you add gets a test that would fail |
| 16 | without it. |
| 17 | - `src/tty.ts` is the app's terminal. The exact command that reaches it is |
| 18 | `FORGE_APP_SSH` at runtime; print that verbatim wherever the app tells |
| 19 | people how to connect. What a person can do on the page they can do here, |
| 20 | and nothing more: the terminal gates its reads and writes exactly as the |
| 21 | page does (`FORGE_APP_USER_ROLE` is the forge's word on who is there), and a |
| 22 | write made there reaches web viewers live like any other. |
| 23 | - Keep it dependency-free unless a dependency is clearly worth it, and say |
| 24 | why in the pull. A dependency is code you now maintain. |
| 25 | - When you are done, push and stop. Your last message becomes the pull's |
| 26 | description: say what changed, what you chose not to do, and what to look |
| 27 | at in the preview. |
| 28 | |
| 29 | ## The shape |
| 30 | |
| 31 | One implementation, many doors. Every operation is a function in |
| 32 | `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and |
| 33 | the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`), |
| 34 | the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A |
| 35 | door reads its wire format and shapes the answer; it decides nothing. A |
| 36 | rule in an action holds at every door at once; a rule anywhere else is a |
| 37 | bug waiting for the door you forgot. |
| 38 | |
| 39 | Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`, |
| 40 | verified by signature, never by a cookie the app invents. |
| 41 | |
| 42 | Every request passes the door (`src/security.ts`) before a route sees it: |
| 43 | writes are rationed, a write from another site is refused, and a request |
| 44 | under a resource's `api` path is refused — before its body is read — when |
| 45 | the table lets that person through that door for no row at all. The door |
| 46 | asks the same table the actions do; it never compares roles by hand, and |
| 47 | the action still decides about the real row. Do not route around it. |
| 48 | |
| 49 | Every change is written down. An action that creates, updates or deletes |
| 50 | returns through `audited` (`src/actions.ts`), which appends a row to the |
| 51 | `audit` table — who, the resource and verb, the row, when, and whether |
| 52 | they were let — refused attempts included; the door records the writes it |
| 53 | turns away. Admins read it at `GET /api/audit` (the `audit` resource in |
| 54 | `contract.json`). Recording never fails a request: a row that cannot be |
| 55 | written is a log line. |
| 56 | |
| 57 | Who may do what is a table, not code: `contract.json`. It declares the |
| 58 | roles — each names the roles below it, and how it is held: by everyone, by |
| 59 | the identity the forge signed, or by a row's column naming the person — and, |
| 60 | for every resource, the least role each of create, read, update, delete and |
| 61 | list needs. An action asks `permit(who, resource, verb, row)` |
| 62 | (`src/contract.ts`) and obeys the answer; it never compares `who.role` |
| 63 | itself. `src/can.ts` is the forge's evaluator, carried verbatim: do not |
| 64 | edit it. `src/contract.test.ts` acts as every identity at every door and |
| 65 | fails when a door and the table disagree. |
| 66 | |
| 67 | A new kind of person is a new role in the table, under an existing one. A |
| 68 | change to a cell is a change of policy: say so in the pull's description. |
| 69 | A cell loosened — anything an identity could not do before that it can |
| 70 | after — is a wall: the forge shows the pull to a person and never lands it |
| 71 | on its own. Do not loosen a cell unless the issue asks for exactly that; |
| 72 | prefer a new role under an existing one, or a new resource, to widening a |
| 73 | door that was decided. |
| 74 | |
| 75 | To add a concept: |
| 76 | |
| 77 | - **An action** — a function in `src/actions.ts` that asks `permit`, its |
| 78 | resource's row in `contract.json`, its door in `src/contract.test.ts`, |
| 79 | and a test in `src/actions.test.ts` for what the table cannot say. |
| 80 | - **A migration** — append to `migrations` in `src/db.ts`. Never edit one |
| 81 | that has run. `bun run src/db.ts --check` shows the version. |
| 82 | - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`. |
| 83 | With a second page, move them under `src/pages/` and keep one `layout`. |
| 84 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 85 | answers with `answer(...)`; the status follows the failure. Give it an |
| 86 | `api` entry beside it (a sentence, the body and answer as JSON Schema) |
| 87 | and run `bun run contract:emit`: `contract/openapi.json` is committed, |
| 88 | served at `/openapi.json`, and `bun test` fails while it is stale. A |
| 89 | resource's routes live under the `api` path its `contract.json` entry |
| 90 | names, so the door and the forge's probe find them. |
| 91 | - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence, |
| 92 | a JSON schema, the action. |
| 93 | - **A terminal command** — a key in `src/tty.ts` that calls the action and |
| 94 | reports on the status line. |
| 95 | - **Something live** — `emit(type, data)` in the action, and |
| 96 | `data-live="<types>"` on the region of the page that shows it. |
| 97 | - **A colour, a space, a size** — a token in `design/tokens.json` (a |
| 98 | colour needs its dark value in `$extensions.mode.dark`), used as |
| 99 | `var(--…)`. `bun run lint:design` refuses a raw `#hex`, `rgb(` or |
| 100 | `hsl(` anywhere in `src/` or `static/`, and a `var(--…)` that names no |
| 101 | token; `src/design.test.ts` runs it, and holds every pair of colours a |
| 102 | person reads to 4.5:1 in both themes. |
| 103 | |
| 104 | ## The bar |
| 105 | |
| 106 | - Security: every input is validated at the boundary and every output is |
| 107 | escaped; every response carries the floor in `src/http.ts` (keep it, |
| 108 | extend it); a POST that is not same-origin is refused; writes are |
| 109 | rationed per person; secrets come from the environment (Settings → App → |
| 110 | Environment), never the repository; no `eval`, no shell built from user |
| 111 | text. A request body is capped by the bytes actually read (`readBody`), |
| 112 | never by a Content-Length. `HEAD` answers like `GET`. A private thing |
| 113 | needs an identity from `who`, and a cookie the app sets itself is |
| 114 | `HttpOnly; SameSite=Lax`. |
| 115 | - Speed: the first paint is server-rendered HTML; JavaScript is small, in |
| 116 | `static/`, linked by content (`asset()`) and cached for a year; no |
| 117 | layout shift; what changes arrives over `/events`, never by polling; |
| 118 | work that can wait is done after the response. |
| 119 | - A phone is a first-class client: the page works at 360px wide with no |
| 120 | horizontal scroll, every control is at least 44px on a side |
| 121 | (`--size-tap`), an input's text is never below 16px (a phone zooms into |
| 122 | a smaller one), focus is always visible, whoever asked for less motion |
| 123 | gets none, and the keyboard reaches everything a mouse does. |
| 124 | - Beauty is the tokens': light and dark both, from one file. A new page |
| 125 | uses `layout`, which carries the tokens, the description and the Open |
| 126 | Graph and card metadata a shared link unfurls with — pass the page's |
| 127 | path so `og:url` is its own. |
| 128 | - Finish: the app boots, `/healthz` answers `{ ok, version, uptime }` |
| 129 | (the forge polls it; keep it cheap and apart from the page), `/` |
| 130 | answers, `bun test` is green, the README says how to use it and what is |
| 131 | real versus simulated, `forge.json` says how to run it. |