gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9bfba36a9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre9bfba36a/README.md

51.7 KB

  1. # tickets.worldapi.org
  2. The World Ticket System: the only state store of AntColony (see byrodin
  3. `/CONTAINERS/projects/antcolony/README.md`). Projects, tickets, an append-only event
  4. history per ticket (created / comment / state change), a web UI with live push, a JSON
  5. API for the scheduler/CLI, and an idempotent import of the AntColony ticket files.
  6. Written in **Hybriel** on **hl:web** (SSR + WebSocket push; hybriel master since mission 036), modelled on the hybriel
  7. repo's `projects/demo-social-network`. **Login via ident** (ticket #7, `CONCEPT.md` "Login via
  8. ident"): reading is public; writing (tickets, comments, states) needs a login; the author is
  9. the logged-in user; since ticket #20 roles per project decide who may do what (the creator-only confirm is gone); machine clients write with API tokens.
  10. See section "Login (ident)". **Markdown** in text fields and a Markdown read view for LLMs
  11. (ticket #6), **editing** by the ticket's author (ticket #8): section "Markdown, editing".
  12. **Relations** (ticket #4): parent / child and blocked-by, across projects — section "Relations".
  13. **Markdown editor** (ticket #12): every Markdown field is an `<md-editor>` — section "Markdown editor".
  14. ## Run
  15. ```bash
  16. cd /media/STORAGE/projects/tickets.worldapi.org
  17. setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null & echo $! > server.pid
  18. # stop: kill $(cat server.pid)
  19. ```
  20. * Port **8350** on 0.0.0.0 (`TICKETS_PORT` moves it): http://192.168.178.75:8350 (LAN),
  21. http://100.77.141.84:8350 (tailnet), http://127.0.0.1:8350.
  22. * Data: `storage/mpackdb/{projects,tickets,events}.*` (hl:mpackdb, primary key = mpackdb
  23. UUID `@id` everywhere — creator's convention; `TICKETS_STORAGE=<dir>` moves them).
  24. Sessions: `.sessions/` (`TICKETS_SESSIONS`). The OLD global-numbered tables
  25. `storage/tickets-*` (before ticket #38) stay as a read-only backup — nothing reads them.
  26. * The framework's dev watcher is on: saving a `.hl` file re-analyses and reloads open tabs.
  27. A restart is needed after changing `styles.hl` root rules, `shared/tokens.hl`, env vars or the binary.
  28. * `HL_HOST` (or `HOST`): interface to bind, default 0.0.0.0; `127.0.0.1` on Byrodin. hl:web
  29. reads it itself (hybriel#24; until mission 036 `project.hl` built the listener). `TICKETS_WATCH=0` turns
  30. the dev watcher off (the container).
  31. * Login env (section "Login (ident)"): `IDENT_API_KEY`, `IDENT_API_SECRET`,
  32. `TICKETS_CREATOR_IDENTITY`, `IDENT_URL`, `IDENT_EXCHANGE_URL`, `TICKETS_PUBLIC_URL`. Without
  33. key/secret the app runs read-only for everyone (a login answers "login is not set up").
  34. ## Projects, members, roles, new states (ticket #20 — supersedes #19 and the creator-only workflow)
  35. - **Projects are records** (`projects` table): title, slug (generated from the title — letters, digits, `.`, `_`, `-`; an admin can change
  36. it, the old slug keeps resolving), description (Markdown, the shared `<md-editor>`), members. Tickets point at their project by its **id**
  37. (never by name); `path` = `<project id>/<number>`. Pages: `/new-project`, `/projects/<slug>` (the tickets), `/projects/<slug>/settings`
  38. (admin: title, slug, description, members, invite link).
  39. - **Roles** (`members` table, one row per project + user): `use` = comment + move a ticket between open and review; `edit` = use + create, edit,
  40. assign, any state, relations; `admin` = edit + settings and members. A project always keeps at least one admin. Everybody else only reads.
  41. Any logged-in user with a display name may open a project and becomes its admin.
  42. - **Tickets** carry `createdBy` and `assignee` (members). **States**: open, progress, pending, review, reopened, done, canceled. A ticket that
  43. goes to review / pending with nobody assigned is assigned to the project's oldest admin (a second, `assign` event). The **inbox**
  44. (`/inbox`, `GET /api/inbox` with a token) shows what is assigned to you, as two lists: pending and review.
  45. - **API aliases**: the old state names are accepted as input (`in progress`, `awaiting creator`, `awaiting-creator`, `confirmed`, `rejected`, `on hold`)
  46. and mean progress / review / done / reopened / pending. Output uses only the new names.
  47. New endpoints: `GET/POST /api/projects`, `GET/POST /api/projects/:slug`, `POST /api/projects/:slug/members` (`{user, role}`; user = display name,
  48. ident id or user id), `POST …/members/remove`, `POST …/invites` (`{role, uses?, days?, email?}` → an ident invite link, ident#22),
  49. `POST /api/tickets/:ref/assign` (`{assignee}`), `GET /api/inbox`. A refused role → 403.
  50. - **Invites**: the person opens ident's link, picks an identity, ident sends them to `/login/callback?ident_code=…&invite=<id>`; only when ident
  51. says that identity accepted the invite (`/api/invites/get`) does the person join with the invite's role.
  52. - **Migration** (`migrate.hl`, idempotent, runs at every start): old projects `{name}` → records, tickets re-linked by id, states renamed
  53. (awaiting creator → review, a "Question…" ticket → pending, confirmed → done, rejected → reopened, in progress → progress, on hold → pending;
  54. review / pending tickets go to the creator), `createdBy` from the created event; members: the creator (TICKETS_CREATOR_IDENTITY, read ONLY here)
  55. is admin, users named Architect / AntColonyScheduler that wrote in a project are edit, everybody else who wrote is use. It compacts the tables
  56. it changed (a second open of a table — the page realm — compacts on open and would rewrite the file under the first handle).
  57. - Short ids: `isIdentId` in `users.hl` accepts old 64-hex and new short ids (ident#23); `tools/migrate-short-ids.hl` + `tests/short-id-switch.mjs` kept.
  58. - Gate: `tests/browser.mjs` (233 checks). The pre-roles browser checks (creator-only confirm, author-only edit, relations by author, tokens page …)
  59. are PARKED in `tests/browser-pre20-parked.txt` — they need porting to the roles.
  60. ## Login (ident) — ticket #7
  61. * **How it works**: ident's identity selector top right (`<ident-selector key=IDENT_API_KEY>`
  62. from `<IDENT_URL>/selector.js`) plus a "Log in with ident" button
  63. (`<IDENT_URL>/login?key=…&return=<TICKETS_PUBLIC_URL>/login/callback`). Both hand tickets a
  64. one-time code; the SERVER exchanges it (`POST <IDENT_EXCHANGE_URL>/api/exchange` with key +
  65. secret) for the **per-app identity id**. Selector: `login.js` bridges the `ident-login` DOM
  66. event into the shell's face `identLogin` — no reload, the pages show their forms (`signedIn`
  67. push to this session's tabs). Button: `/login/callback` sets the session and redirects back to
  68. the page the login started from (ticket #10: at the click `login.js` adds
  69. `?next=<path + query>` to the button's return URL; the server follows only a same-origin
  70. path — one leading `/`, not `//`, no `\`, URL-safe chars, ≤ 500, not `/login/…` — else `/`).
  71. Logout (top right) = the shell's face `logOut`; the selector is reset (class `out` → `loggedIn = false`).
  72. * **`login.js` runs more than once per page** (ticket #9): hl:webex re-creates the header —
  73. the selector element AND the `<script>` elements — whenever the login state flips, and the
  74. browser executes a re-inserted script again. It therefore installs itself once per document
  75. (`window.__ticketsLogin`), listens for `ident-login` on `document`, always looks up the
  76. CURRENT `#selector`, and hands each one-time code to `#identcode` only once. (Before: one more
  77. listener per run → after a logout one selection = two exchanges of the same code → red banner
  78. "ident refused the login (400: unknown or already used code)", although the first logged in.)
  79. * **Users** (`storage/mpackdb/users.*`, `users.hl`): one per identity id `{identity, name,
  80. created}`. The first login asks once for a **display name** (a normal field — ident may fill it
  81. later, CONCEPT point 6); writing waits for it; it cannot be changed afterwards (no UI).
  82. The session holds only `user = { id = <users @id> }` (hl:webex ships `session.user` to the
  83. page); the identity id is never in a page except the user's own `/you`.
  84. * **Authors**: new events store `user` (+ the name as `author`); the history shows the user's
  85. display name. Old events keep their free-text `author`. The web forms and the API have no
  86. author field (API: a body with `author` → 400 naming it).
  87. * **Creator**: `TICKETS_CREATOR_IDENTITY` = the creator's per-app identity id. Only that user
  88. may set `confirmed` / `rejected` (web: message; API: **403** `{error, field:"state"}`).
  89. **Unset = nobody can.** To set it on Byrodin: the creator logs in to tickets, opens **`/you`**
  90. ("Your tickets identity id", 32 hex), the architect puts `TICKETS_CREATOR_IDENTITY=<id>`
  91. into `/CONTAINERS/projects/tickets.worldapi.org/.env` and restarts the container.
  92. `/you` then says "You are the creator".
  93. * **API tokens** (`storage/mpackdb/tokens.*`): on `/you` a logged-in user creates (optional
  94. label), lists and revokes tokens. Shown ONCE (`tkt_` + 48 hex), stored as sha256 only.
  95. `Authorization: Bearer <token>` acts as that user on every API write; no / wrong / revoked
  96. token → **401** (checked before the body). Reads need no token.
  97. * **Env**: `IDENT_API_KEY` / `IDENT_API_SECRET` (tickets' registration in ident, origin
  98. `https://tickets.worldapi.org`), `TICKETS_CREATOR_IDENTITY` — all in `.env` on Byrodin (the
  99. hybriel runtime loads `.env` beside project.hl; the real environment wins; never commit it).
  100. `IDENT_URL` (default `https://ident.worldapi.org`: selector script + button),
  101. `IDENT_EXCHANGE_URL` (server side; default = IDENT_URL; `docker-compose.yml` sets
  102. `http://127.0.0.1:45002` = ident's loopback port on Byrodin), `TICKETS_PUBLIC_URL` (default
  103. `https://tickets.worldapi.org`: the button's return URL).
  104. * **ident down**: a login answers "ident did not answer" (the global `on Error` in project.hl
  105. absorbs hl:fetch's failure — it logs `error absorbed: …` for every plugin error).
  106. ## Markdown, editing (tickets #6, #8 — mission 014)
  107. * **Markdown in text fields** (summary, comment text, state note): `markdown.hl` parses the text
  108. into plain data (blocks → spans), `components/markdown.hl` builds elements from it — every
  109. character ends up in a TEXT node, no HTML string exists, so raw HTML / `<script>` shows as text.
  110. Links only when `safeHref` accepts them: `http(s):`, `mailto:`, `/path` (not `//`), `#…`;
  111. `javascript:`, `data:`, `vbscript:` etc. stay text. Understood: `#`…`######` headings (shown as
  112. h3/h4/h5), `-`/`*`/`+` and `1.`/`1)` lists (one level; a list may interrupt a paragraph), fenced
  113. code (``` / ~~~), `code`, `[text](url)`, `<https://…>`, bare http(s) URLs, `*em*`, `**strong**`,
  114. `***both***` (also `_`), `\` escapes. Single line breaks inside a paragraph are KEPT (the page
  115. showed them before). Not: quotes, tables, images, nested lists. Only pages and pushes carry the
  116. parsed blocks (`md`, `summaryMd`, `oldSummaryMd` via `store.hl pageEventOf`); **API JSON unchanged**.
  117. * **Read view for LLMs**: `Accept: text/markdown` on `GET /api/tickets`, `/api/projects/:slug/tickets`,
  118. `/api/tickets/:ref`, `/api/projects/:slug/tickets/:number` → `text/markdown` (+ `Vary: Accept`),
  119. built by `mdview.hl`: one ticket = `# <project> #n: <subject>`, a meta line, URL, the summary as
  120. written, `## History` with one `### <seq> · <when> · <author> <what>` per event (texts below, an
  121. edit shows "Subject before:" / "Summary before:" quoted); a list = `# Tickets (<filters>): N` +
  122. one line per ticket. Markdown wins only if it is listed with q > 0 and preferred to
  123. `application/json` (higher q, or same q and first); `*/*` / none → JSON. Errors stay JSON.
  124. `curl -s -H 'Accept: text/markdown' http://127.0.0.1:8350/api/projects/tickets.worldapi.org/tickets/6`
  125. * **Editing**: the ticket's AUTHOR (the user of its `created` event) edits subject + summary —
  126. web: "Edit" on the ticket page (form, Save/Cancel), API: `POST …/edit { subject?, summary? }`.
  127. **A ticket from before the login** (created event without a user, only an author string):
  128. **only the creator** (`TICKETS_CREATOR_IDENTITY`) may edit it. Anyone else: web message, API
  129. **403**. The edit is a new history event `kind:"edit"` with `oldSubject`/`oldSummary` +
  130. `newSubject`/`newSummary` (+ `subjectChanged`/`summaryChanged`, `isEdit`) — earlier versions stay
  131. (append-only); pushed live like a comment (the page takes over subject + summary). The page
  132. shows "X edited the ticket", "Subject before" (struck through) and the previous summary folded.
  133. No migration: only NEW events have the edit fields; existing JSON is byte-identical (mission 014
  134. `.scratch/m014/compat.sh`).
  135. * **SECURITY (hl:webex, patched locally until mission 036; hl:web escapes it itself, hybriel#34)**: webex put the page's state (= user text) into an
  136. inline `<script>` without escaping `</script>` → a summary/comment with
  137. `</script><img src=x onerror=…>` RAN in every viewer's browser (stored XSS, reproduced on the
  138. pre-m014 code: `node .scratch/m014/xsscheck.mjs <url>` → `__pwned = 1`). Fixed in the vendored
  139. `plugins/webex/WebFramework.hl` (seed: every `<` written as `\u003c`); to be filed as a Hybriel issue (mission 014 report).
  140. The gate's `</script>` check (MD_RICH) still guards it.
  141. ## Markdown editor (ticket #12 — missions 024 + 025)
  142. * The new-ticket summary, the edit summary, the comment and the state note are `<md-editor>`s
  143. around their textareas (`mdEditor { textarea { … } }`): edited visually (formatted while typing,
  144. toolbar incl. phones, shortcuts), the textarea keeps the **plain Markdown** and fires `input` as
  145. before — members, faces, API and stored data are unchanged. The state note is now a textarea
  146. (rows 1): the editor writes line breaks. Ctrl/Cmd+Enter sends the form.
  147. * A "Markdown source" button in a footer row at the bottom of every editor (mission 025, creator's
  148. follow-up request) switches it to a plain textarea with the raw Markdown, to copy it out or edit
  149. it by hand; "Visual editor" switches back (re-parses the typed text). Same `input`/`change`
  150. events and Ctrl+Enter as the visual editor.
  151. * The component is **vendored**: `shared/md-editor.js` = verbatim copy of
  152. `/media/STORAGE/projects/worldapi-components/md-editor.js` (edit it THERE, test it there, `cp` it
  153. here; `cmp` them). Served at `/md-editor.js` (project.hl), loaded once in the shell (main.hl).
  154. Its README: attributes, keyboard, lossless rules, restyling. Colours: `mdEditor { '--md-…' = token }`
  155. in `styles.hl`.
  156. * Without JS the page shows the plain textareas (SSR); the forms themselves need JS as before
  157. (webex faces).
  158. * Only tickets' Markdown subset exists in the editor (its parser is a port of `markdown.hl`, checked
  159. against it by the component's test with `ORACLE_APP`); pasted rich text is reduced to it; an
  160. untouched text comes back byte for byte. **Keep `markdown.hl` and md-editor.js's parser in step.**
  161. ## Relations: parent / child, blocked by (ticket #4 — mission 017)
  162. * **Parent / child**: a ticket has at most one parent (any project). The parent's page lists its
  163. children with their states ("Children"); a child says "Part of <parent>". **Blocked by**: a ticket
  164. can wait on any number of tickets (any project); the blocked page lists "Blocked by", the other
  165. side "Blocks" — each with its state.
  166. * **Parent state** (smallest rule): `allChildrenConfirmed` = the ticket has children and every child
  167. is `confirmed`. The page shows "all children confirmed (n)" in green, else "k of n children
  168. confirmed". The parent's own state is NEVER changed by it — only the creator confirms.
  169. * **Who may set / remove a relation**: the AUTHOR of either of the two tickets (user of its created
  170. event) or the creator (`TICKETS_CREATOR_IDENTITY`). A ticket from before the login has no author:
  171. only the creator or the author of the other ticket. Replacing a parent needs the right for the old
  172. AND the new pair. Refused: a ticket as its own parent / blocker, loops (a parent below the ticket,
  173. a blocker that already waits on it, also transitively), duplicates, unknown tickets.
  174. * **Naming a ticket**: `<project>#<number>` (spaces around `#` allowed, e.g. `ident.worldapi.org#1`)
  175. or its UUID.
  176. * **Storage**: a NEW table `storage/mpackdb/links.*` (`{ kind: 'parent'|'blockedBy', ticket, other,
  177. user, created }`, indexes `@ticket`, `@other`), created empty at the first start — no migration. A
  178. removed relation is deleted there; the HISTORY keeps every change: an event `kind:"link"` on the
  179. child / the blocked ticket ("set the parent to X #1 (was Y #2)", "removed the parent X #1", "marked
  180. it blocked by X #5", "removed the blocker X #5"; extra JSON keys `isLink, linkKind ('parent' |
  181. 'blockedBy'), linkAction ('set' | 'removed'), otherRef, otherHref, previousRef` — only on link events).
  182. * **JSON**: every ticket row (list AND single GET, pushes) has `parent` (a ref or `null`), `children`,
  183. `blockedBy`, `blocks` (lists of refs, oldest link first) and `allChildrenConfirmed`. A ref =
  184. `{ id, project, number, ref ('#1'), key ('ident.worldapi.org#1'), subject, state, stateSlug, href,
  185. apiHref }`. All other keys unchanged (mission 017 `.scratch/m017/compat.sh`).
  186. * **Markdown view**: under the URL line `Parent: <p> #n [state] subject · url`, `Children (k of n
  187. confirmed):` / `Children (all n confirmed):`, `Blocked by:`, `Blocks:` (one `- <p> #n [state]
  188. subject · url` line each); a list line ends with ` · parent … · children n, k confirmed | all
  189. confirmed · blocked by <p> #n [state], … · blocks …` (only the parts that exist).
  190. * **Web** (logged in): under "Respond" the forms "Parent ticket (project#number)" → Set parent /
  191. Remove parent, "Blocked by (project#number)" → Add blocker; a "Remove" button per blocker row.
  192. Faces `ticketSetParent(id, key)` ('' = remove), `ticketAddBlocker`, `ticketRemoveBlocker`.
  193. * **Live**: every relation write, state change and edit pushes `ticketsRelated(views)` — the relation
  194. view of the ticket and of every ticket related to it (+ one just unlinked); an open page of any of
  195. them follows without a reload (a child confirmed → the parent's "k of n" moves).
  196. * **For the scheduler**: parent tickets = rows with `children.length > 0` (don't pick them as work);
  197. a ticket waits while any `blockedBy[].state` is not what the scheduler counts as done. A report's
  198. `issues[].blocks = true` → create the issue ticket, then `POST <original>/blocked-by {"add":
  199. "<project>#<n>"}` with the scheduler's token (it is the issue's author, so it may).
  200. ## PWA — installable app, icons, offline shell (mission 046)
  201. Done the way calendar.worldapi.org does it: hl:web generates everything from `project.hl` — no JavaScript of ours.
  202. - `appTitle` "tickets", `appIcons` (192/512, `any` + `maskable`, same PNGs), `appTouchIcon`, `appFavicon`,
  203. `appThemeColor` = the header's `darker` `rgb(15, 20, 25)` (as tracker), `appBackgroundColor` = dark `rgb(25, 30, 35)` (both from `shared/tokens.hl`).
  204. Served: `/__hl/manifest.webmanifest` (linked from every head with apple-touch-icon + theme-color), `/__hl/sw.js`.
  205. - `offline = [ TicketList ]`: `/` is the ticket list, so offline it is the list as this browser last loaded it (the worker
  206. precaches `/` at install; a visited `/projects/<slug>` is kept too). Every other page offline → hl:web's "Unavailable
  207. offline" (503). TicketList's emits are all value-form, so nothing is queued offline.
  208. - "You are offline" note in the shell (`components/main.hl` `offline`, `#offline`): hl:web has no connection state and no
  209. mount hook, so an invisible `<net-probe>` runs an endless 1 s CSS animation (`styles.hl` `tickets-net-tick`) and every
  210. `animationiteration` reads `navigator.onLine` (same trick as tracker).
  211. - Icons `icons/`: `icon.svg` is the SOURCE (a tilted ticket stub, purple on dark, inside the maskable safe circle r=205).
  212. Rebuild after editing it:
  213. ```bash
  214. cd icons && for s in 192 512; do rsvg-convert -w $s -h $s icon.svg -o icon-$s.png; done
  215. rsvg-convert -w 180 -h 180 icon.svg -o apple-touch-icon.png
  216. for s in 16 32 48; do rsvg-convert -w $s -h $s icon.svg -o /tmp/fav-$s.png; done
  217. magick /tmp/fav-16.png /tmp/fav-32.png /tmp/fav-48.png favicon.ico
  218. ```
  219. Each icon has its own `file` route in `project.hl` (`/favicon.ico` serves `icons/favicon.ico`).
  220. - Test: the gate's last part (browser C) — see "Test (the gate)".
  221. ## Deploy (Byrodin)
  222. Target: `/CONTAINERS/projects/tickets.worldapi.org` on Byrodin, container `tickets.worldapi.org`
  223. (`docker-compose.yml`: debian:12-slim, host network, `HL_HOST=127.0.0.1`, `TICKETS_PORT=45003`,
  224. `TICKETS_WATCH=0`, the folder mounted at `/home/tickets`, `./bin/hybriel project.hl`), public
  225. https://tickets.worldapi.org/ via nginx (TLS ends there; the vhost needs the WebSocket Upgrade
  226. headers — the live push rides `/__hl/socket`; no baseUrl/tls in the app, like notes).
  227. * **First deploy: done by the architect** (folder + data, nginx vhost, cert, DNS).
  228. * **Later: `./deploy.sh`** on Loreana, in this folder: runs the gate (refuses on a failure;
  229. `--skip-tests` skips it LOUDLY), backs up `storage/`/`.sessions/`/`.env` (what exists) to
  230. Loreana's `/media/SLOW1TB2/deploy-backups/<app>/` (newest 5 kept; an empty/failed backup stops
  231. the deploy), then rsyncs the code to
  232. `[email protected]:/CONTAINERS/projects/tickets.worldapi.org` (never `storage/`, `.sessions/`,
  233. `.env`, `.scratch/`, `server.*`, `testapp/`, logs — the preview is checked for them; no
  234. `--delete`), `docker compose up -d && docker compose restart` over `ssh -F /dev/null`, then
  235. waits for https://tickets.worldapi.org/ to answer 200. Every step is printed.
  236. * `./deploy.sh --dry-run` = gate + `rsync -n` + the commands it would run (no restart, no URL
  237. check). `--target DIR|HOST:DIR` / `--url URL` point it elsewhere (tested only against a local
  238. directory, ident STATUS "mission 010").
  239. * Import on Byrodin: `TICKETS_URL=http://127.0.0.1:45003 TICKETS_TOKEN=tkt_… ./bin/hybriel import.hl` (in the container
  240. or on the host with the vendored binary).
  241. * Session cookie `hlsid` (host-only; tickets has its own host on Byrodin). Login: `.env` needs
  242. `IDENT_API_KEY`, `IDENT_API_SECRET`, later `TICKETS_CREATOR_IDENTITY` (section "Login (ident)").
  243. No migration: `users.*` / `tokens.*` are created empty at the first start; old events keep
  244. their authors (checked mission 011: every GET of old vs new code on a copy of the store identical).
  245. ## Import the AntColony ticket files
  246. ```bash
  247. # on byrodin — refresh the copies:
  248. scp /CONTAINERS/projects/antcolony/tickets/*.md loreana:/media/STORAGE/projects/tickets.worldapi.org/import/tickets/
  249. # on loreana, with the server running (an API token of the user who should open them, /you):
  250. TICKETS_TOKEN=tkt_… ./bin/hybriel import.hl # TICKETS_IMPORT_DIR (default ./import/tickets), TICKETS_URL (default http://127.0.0.1:8350)
  251. ```
  252. Format: `project:` and `subject:` header lines, blank line, summary (`ticketfile.hl`). The
  253. summary is hard-wrapped text: its lines are joined into paragraphs (one space), a blank line
  254. starts a new paragraph; a Markdown list line (`- `, `* `, `1. ` after trimming) starts a new
  255. line (`\n`) and following ordinary lines join onto it (wrapped item; nesting indentation is
  256. dropped); stored as paragraphs separated by `\n\n`, shown with `white-space: pre-wrap`. CRLF files work. Each file is POSTed to
  257. the RUNNING server (`/api/tickets` with `source` = file name); a known source answers the
  258. existing ticket, so re-running prints `0 new, N already there`. New tickets are numbered
  259. per project in sorted file-name order (output: `NEW 0004-scheduler.md → antcolony #1`).
  260. ## Ticket numbers, URLs, the migration (ticket #38)
  261. * A ticket's key is the mpackdb UUID (`id`, e.g. `0mufbphip3w7`). People use **project +
  262. number**: numbers count per project from 1 (next = highest in the project + 1).
  263. * **Slug rule: the project slug IS the project name, as is.** A new project name may only hold
  264. letters, digits, `.`, `_`, `-` (all existing names do), so it goes into URLs unescaped.
  265. * Ticket page: **`/projects/<project>/<number>`** (e.g. `/projects/tickets.worldapi.org/5`).
  266. `/projects/<project>` = the project's list (same as `/project/<project>`).
  267. * **Old global numbers** (`#1`…`#39` in docs and comments) are kept on the migrated tickets
  268. as `oldNumber`; the ticket page shows "formerly #38"; **`/tickets/<old n>` → 301** to the new
  269. URL (also `/tickets/<uuid>`); `/api/tickets/<old n>` keeps answering. New tickets get no old number.
  270. * Ordering never uses key order: lists by stored `updated`, a history by (`created`, `seq`),
  271. projects by `created`.
  272. * Migration 2026-09-24 (`tools/migrate-008.hl`, idempotent, header explains it): old #N →
  273. `<project> #k` by creation order. Mapping printed in `.scratch/deploy-008-<ts>/migrate-run1.txt`;
  274. or ask the API: `curl -s localhost:8350/api/tickets/38 | jq .ticket.href`.
  275. ## Test (the gate)
  276. ```bash
  277. node tests/browser.mjs # 423 checks, ~2 min; own server on :8352, own ident on :8353,
  278. # exchange-counting proxy on :8357 (TICKETS_GATE_EXCHANGE_PORT),
  279. # own storage in .scratch/gate-store (ident's code copied WITHOUT
  280. # .env to .scratch/gate-store/ident, mail to a sink — never real mail)
  281. ```
  282. **Ports** (mission 017): `TICKETS_GATE_PORT`, `TICKETS_GATE_IDENT_PORT`, `TICKETS_GATE_EXCHANGE_PORT`,
  283. `TICKETS_GATE_CHROME_A` / `TICKETS_GATE_CHROME_B` (`"8810-8814"`; default 8620-8629 / 8630-8639) — a
  284. worker with a port range runs e.g. `bash .scratch/m017/gate.sh <out.txt>` (8800-8819).
  285. `TICKETS_GATE_CHROME_C` (default 8640-8649) is the PWA browser. Mission 046 ran it as
  286. `TICKETS_GATE_PORT=8750 TICKETS_GATE_IDENT_PORT=8751 TICKETS_GATE_EXCHANGE_PORT=8752 TICKETS_GATE_CHROME_A=8753-8754
  287. TICKETS_GATE_CHROME_B=8755-8756 TICKETS_GATE_CHROME_C=8757-8759 node tests/browser.mjs` → 245 passed (~30 s).
  288. Mission 046 part (**PWA**, at the very end, own fresh Chrome C): head links manifest / apple-touch-icon / favicon /
  289. theme-color; manifest fields; every icon a real PNG of its size; favicon.ico + icon.svg; the worker registered with
  290. scope `/` and controlling; `Page.getInstallabilityErrors` empty; then the SERVER IS STOPPED and the page set offline
  291. (CDP) → reload `/` shows header + last list + `#offline`; `/inbox` → "Unavailable offline"; online again → the note goes.
  292. Screenshots `.scratch/gate-pwa-phone-online.png` / `gate-pwa-phone-offline.png` (390 px) — LOOK at them.
  293. **Ident for the gate**: the gate runs ident's CODE from `../ident.worldapi.org` (or `TICKETS_GATE_IDENT_DIR`). On 2026-10-01 ident was
  294. being re-vendored to hybriel master and its own sign-in broke (`ident /code page` timeout at browser.mjs:552; ident's own gate fails
  295. the same way). Tickets' gate then ran against a snapshot of ident's code with ident's 837fe120 bin/plugins (what live ident runs):
  296. `.scratch/w048/ident-snap` (`TICKETS_GATE_IDENT_DIR=$PWD/.scratch/w048/ident-snap`). Remake it from ident (without .env/storage/.sessions/.scratch) if ident moves on.
  297. Mission 048 "**once:**" checks: after a comment, a state change and adding a member, the new row is in the list exactly once
  298. (hybriel 64527baa+ answers a session face with a sync of session-derived members — a handler appending the row too would double it).
  299. `connected(page)` in the gate waits until the server has READ the tab's `hello` (a ping on the same socket, its pong),
  300. not just an open socket: a push sent before that reaches no component (the inbox-live check failed ~1 in 5 without it).
  301. Mission 024 part (**#12 Markdown editor**, before the console check): bob on his ticket — SSR has the
  302. plain textareas inside `<md-editor>`, `/md-editor.js` served, editors upgraded; a comment by REAL typing
  303. (`**x**`, `` `x` ``, `- ` list) sent with Ctrl+Enter → stored Markdown + rendered + editor cleared; a
  304. state note with the toolbar "B"; a hostile rich paste (img onerror, script, javascript: link) reduced
  305. to the subset, sent, rendered safely; the edit form opens the stored summary formatted and byte for
  306. byte, typing at the end changes only that block, saved; at 390px the new-ticket form with toolbar
  307. taps (inline code) → stored. Screenshots `gate-{phone,desktop}-mdeditor.png`, `gate-phone-newticket-mdeditor.png`.
  308. The component's own test (72 checks): `worldapi-components/test/run.mjs`.
  309. Mission 017 part (**#4 relations**, after #8/#6): project `rel.example` — the gate user opens P (#1)
  310. and C1–C3 (#2–#4), bob X (#5). API: set parent 3 ways (per-project, UUID route + UUID, `rel.example # 1`),
  311. P's children + states, list rows carry the relations, row keys = old + exactly the 5 new, 14 refused
  312. parent writes (401, 403 ×2, itself, unknown, no `#`, loop, same, none to remove, `{}`, unknown field,
  313. non-string, author, 404) write nothing; who: bob (author of the child) sets/removes, a legacy ticket →
  314. 403 for bob, 201 for alice (creator); replace (label "(was …)"), a grandchild loop. Blocked-by: add,
  315. 13 refusals (401, 403, itself, twice, direct + 2-step loop, add+remove, `{}`, empty, unknown, not
  316. blocking, unknown field, 404), remove; non-link events keep the old keys. Markdown: child
  317. "Parent:" + "Blocked by:", parent "Children (0 of 3 confirmed):", blocker "Blocks:", list-line tails.
  318. Browsers: B (bob) on P sees 3 children + "0 of 3"; signed out: relations yes, forms no; A (alice) on C2
  319. "Part of …", "Remove parent" → B drops to 2 live, typed "Set parent" → 3 again, unknown parent →
  320. message; B on X "Blocks", A "Add blocker" → both live, bob's face refused, 3 forged faces refused, the
  321. row's "Remove"; alice confirms C1, C2 (API) → B "2 of 3", C3 (web) → "all children confirmed (3)" green,
  322. P stays open. Screenshots `gate-{phone,desktop}-{parent,blocked}.png` — look at them.
  323. Mission 014 parts: **#8** — the gate user opens gamma #3 with a Markdown summary, edits subject
  324. (per-project URL) and summary (`/api/tickets/<uuid>/edit`), history created/edit/edit with the old
  325. values; 9 refused edits (no token 401, bob / alice (creator, not author) 403, `{}`, empty subject,
  326. same values, unknown field, non-string, author → 400) write nothing; 404s; legacy alpha #1: gate /
  327. bob 403, alice (creator) 201. In Chrome: bob's ticket (gamma #4) — "Edit" only for bob (not
  328. alice, not signed out), the form (screenshots `gate-{phone,desktop}-edit.png`,
  329. `gate-desktop-editbutton.png`), Cancel writes nothing, an empty subject is refused, Save → both
  330. browsers show the new subject + rendered summary + "bob edited the ticket" with the previous
  331. values, no reload (`gate-{phone,desktop}-markdown.png`); faces: alice refused, forged session
  332. refused; legacy alpha #1: "Edit" for alice only; logout hides it, a selector login brings it back
  333. without a reload. **#6** — JSON unchanged (non-edit events keep exactly the old keys;
  334. `Accept: application/json` = no Accept); the read view (ticket, uuid form, project list, state
  335. list, 7 Accept variants, errors stay JSON); the parser table (`tests/markdown.hl`); the rendered
  336. summary in Chrome (headings, em/strong, code, lists, code block; ONLY the 3 safe links; no
  337. script/img/handler element; raw HTML incl. `</script><img …>` shown as text; `window.__xss` unset);
  338. a Markdown comment pushed live to both, a Markdown state note. **#11** — every page wait is
  339. `TICKETS_GATE_SLOW`× (default 3) its timeout; the console check runs BEFORE ident is stopped,
  340. both browsers park on `about:blank` first, then "nothing new in the console" after.
  341. Load proof (mission 014): `node .scratch/m014/load.mjs <tickets url> 4 6` (4 headless Chromes ×
  342. 6 tabs looping pages, ports 8700-8719, SIGTERM closes them) while the gate runs.
  343. Ticket #7 part (login via ident, `tests/identkit.mjs`): our own ident from
  344. `../ident.worldapi.org` (or `TICKETS_GATE_IDENT_DIR`), accounts alice / bob / gate, tickets
  345. registered with origin `http://127.0.0.1:8352`, alice's per-app id computed (selector code +
  346. exchange). Server #1 WITHOUT a creator: the machine user "gate" logs in through
  347. `/login/callback` (no / unknown / reused code → 400), the name prompt, the name rules (empty,
  348. 61 chars, asked once), a token over the face, `/you` shows its id, confirm / reject → 403.
  349. Server #2 with `TICKETS_CREATOR_IDENTITY` = alice: the session survives the restart; every API
  350. write with the gate token, `author` in a body → 400, 14 unauthenticated cases → 401 (auth before
  351. the body), reads without token. Browsers: signed out = history + hint, no forms, a face write
  352. refused, screenshots `gate-{phone,desktop}-signedout.png`; A = alice signs in to ident, then the
  353. SELECTOR (no reload, `logged-in`, name prompt `gate-*-name.png`, forms appear,
  354. `gate-*-loggedin.png`); B = bob via the LOGIN BUTTON (ident /signin → /login/callback → name);
  355. writes show alice / bob / gate as authors; bob's confirm / reject refused (web + API 403), alice
  356. confirms; bob's token on /you (shown once, `gate-*-tokens.png`, not after reload) → API write as
  357. bob → revoke → 401; alice cannot revoke bob's token; 8 faces with a forged session (#31) refused;
  358. old events keep "creator" / "worker"; logout resets the selector without a reload; ident stopped
  359. → "ident did not answer" (400, no 500). Ident's log: `.scratch/gate-ident.log`.
  360. Ticket #9: tickets reaches ident's `/api/exchange` through a proxy in the gate that COUNTS the
  361. exchanges. alice gets a 2nd identity "alice two"; on a ticket page: logged in → Log out →
  362. selector → "alice two": no `#loginerror`, exactly ONE exchange (200), no reload, `/you` shows
  363. alice two's per-app id; then Log out → in-app click to the list → selector → "Default": again
  364. one exchange, logged in as alice. Ticket #10: `/login/callback?next=` table (16 cases: paths
  365. kept, `//host`, `https://`, `/\host`, `javascript:`, CR/LF, quotes, `/login/…`, 501 chars → `/`);
  366. in Chrome bob logs out, clicks to a ticket (pushState URL), "Log in with ident" → ident → back
  367. on that ticket, logged in; same from `/state/open`.
  368. Manual repro of #9 with instrumentation (listeners, `change`s, socket frames, exchanges):
  369. `node .scratch/m012/repro.mjs [appDir]` (`SCEN=ticket-nav|list|ticket-direct`, `SECOND=<identity>`;
  370. ident :8363, proxy :8366, tickets :8362, Chrome 8640-8649; output `.scratch/m012/repro-run/out.txt`).
  371. Ticket #38 part (runs first): `tests/oldstore.hl` writes an OLD-format store (5 tickets,
  372. old #1–#5 in `alpha` / `beta.example.org`, a same-ms tie), `tools/migrate-008.hl` migrates a
  373. copy of it TWICE (second run must print `0 new`), the server runs on the migrated tables:
  374. old-number and per-project API (GET + POSTs), `/tickets/<n>` 301 (fetch and in Chrome),
  375. "formerly #n", a real click on a list row, new tickets continue the numbering (alpha #4,
  376. new project gamma #1), pushed rows carry `/projects/…` hrefs (list + inbox in browser B).
  377. Screenshots also `gate-{phone,desktop}-legacy.png`.
  378. It starts its own server, runs the import twice, drives the API, and opens TWO headless
  379. Chromes (debug ports 8620-8629 / 8630-8639, `--disable-gpu`, killed by PID at the end):
  380. filters by real clicks, a comment and state changes in browser A appear live in browser B
  381. (no reload — checked with a window marker), API writes appear live in both, the inbox and
  382. header count follow, the new-ticket form, and the layout at 390px and 1280px (no horizontal
  383. overflow; screenshots `.scratch/gate-{phone,desktop}-{list,ticket,inbox}.png`,
  384. `gate-phone-newticket.png`, `gate-phone-paragraphs.png` — look at them). Also: the strict-API
  385. table (26 refused bodies, nothing written), Vienna time against node's `Intl`
  386. (`bin/hybriel tests/localtime.hl` prints DST-edge samples), and a CRLF paragraph fixture
  387. imported from `.scratch/gate-import` (→ #7). Ticket #15 (author cases replaced by ticket #7's "no author field"), 13 invalid
  388. JSON bodies → 400 at the right character and no source path, valid escapes still accepted,
  389. no name field in either form, a list fixture `.scratch/gate-import-lists` (→ #8) rendered as
  390. separate lines (`gate-phone-lists.png`). Server log: `.scratch/gate-server.log`.
  391. Chrome: `HL_CHROME` (default `/opt/google/chrome/chrome`, google-chrome is not on PATH).
  392. Check for leaked Chromes afterwards: `ps -eo pid,args | grep [h]l-browser-tier` (must be empty).
  393. ## Pages
  394. | Route | Component | |
  395. |---|---|---|
  396. | `/` | `components/ticket_list.hl` | all tickets, newest update first; project + state filter chips; "New ticket" form |
  397. | `/project/:projectName`, `/state/:stateSlug`, `/project/:p/state/:s` | same | filtered (state slug: `in-progress`, `awaiting-creator`, `on-hold`, …) |
  398. | `/projects/:projectSlug/:ticketNumber` | `components/ticket.hl` | ticket, history, comment form, state change (select + optional note), "Edit" for the author (#8); texts rendered as Markdown (#6) |
  399. | `/projects/:projectName` | `components/ticket_list.hl` | = `/project/:projectName` |
  400. | `/tickets/:ref` | function route | 301 → `/projects/<p>/<n>` (`:ref` = old global number or UUID), else 404 |
  401. | `/inbox` | `components/inbox.hl` | every ticket in `awaiting creator`, across projects (the creator's comment on one sets it to `answered` and it leaves the inbox) |
  402. | `/you` | `components/you.hl` | the logged-in user: display name, own per-app identity id, creator or not; API tokens (create / list / revoke) |
  403. | `/login/callback` | function route | the login button's return: `?ident_code=` → exchange → session → 302 to `?next=` (same-origin path only, else `/`; 400 page on failure) |
  404. | `/login.js` | file | the selector bridge (`ident-login` → the shell's hidden `#identcode`, once per code) + `next=` on the login button |
  405. Shell: `components/main.hl` (header, Inbox link with live count, top right the login: selector +
  406. button, or name link to `/you` + Log out; the display-name prompt). All CSS: `styles.hl`
  407. (tokens imported from `shared/tokens.hl` — section "Design tokens"; accent `colorAccent = var(purple)`
  408. = #c586c0, `--color-danger: var(--red)` = #f44747; domain tags `<ticket-board>`, `<ticket-state>`, `<ticket-view>`,
  409. `<event-head>`, …; mobile first, one `min-width: 45rem` block).
  410. Live push (audience in `project.hl`): `ticketCreated(row, inboxCount)` and
  411. `ticketEvent(ticketId (UUID), event, row, inboxCount)` (public), raised by the web faces AND the
  412. API; `signedIn(tag, {name, named, creator})` / `signedOut(tag)` only to the tabs of the session
  413. whose login carries that random tag (`session.data.tag`).
  414. ## API (JSON)
  415. `:ref` = the OLD global number of a migrated ticket (`38`) or the ticket's UUID (`id`).
  416. `:slug` = project name, `:number` = per-project number. Both forms answer the same shapes.
  417. | Method + path | Body | Answer |
  418. |---|---|---|
  419. | `GET /api/tickets[?project=&state=]` | — | `{ tickets: [row] }` (state as `awaiting creator` or slug) |
  420. | `POST /api/tickets` | `{ project, subject, summary?, source? }` | 201 `{ ticket, existed:false }`; known `source` → 200 `{ ticket, existed:true }` |
  421. | `GET /api/tickets/:ref` | — | `{ ticket, events: [event] }` (oldest first); 404 |
  422. | `POST /api/tickets/:ref/comments` | `{ text }` | 201 `{ ticket, event }` |
  423. | `POST /api/tickets/:ref/state` | `{ state, text? }` | 201 `{ ticket, event }`; 400 unknown/same state; 403 confirmed/rejected by a non-creator |
  424. | `GET /api/projects/:slug/tickets[?state=]` | — | `{ tickets }`; 404 unknown project |
  425. | `POST /api/projects/:slug/tickets` | `{ subject, summary?, source? }` | as `POST /api/tickets` |
  426. | `GET /api/projects/:slug/tickets/:number` | — | `{ ticket, events }`; 404 |
  427. | `POST /api/projects/:slug/tickets/:number/comments` | `{ text }` | 201 `{ ticket, event }` |
  428. | `POST /api/projects/:slug/tickets/:number/state` | `{ state, text? }` | 201 `{ ticket, event }`; 403 as above |
  429. | `POST /api/tickets/:ref/edit`, `POST /api/projects/:slug/tickets/:number/edit` | `{ subject?, summary? }` (≥ 1) | 201 `{ ticket, event (kind edit) }`; 403 not the author; 400 empty subject / nothing changed |
  430. | `POST …/parent` (both forms) | `{ parent }` — `<project>#<n>` or UUID; `""` removes it | 201 `{ ticket, event (kind link) }`; 403 neither author nor creator; 400 `field:"parent"` (unknown, itself, loop, same, none to remove) |
  431. | `POST …/blocked-by` (both forms) | `{ add }` or `{ remove }` (exactly one) | 201 `{ ticket, event (kind link) }`; 403 as above; 400 `field:"add"`/`"remove"` (unknown, itself, loop, twice, not blocking) |
  432. Every ticket GET answers `Accept: text/markdown` with a Markdown document (section "Markdown, editing").
  433. **Every POST needs `Authorization: Bearer <token>`** (ticket #7; a token from `/you`): missing,
  434. malformed, unknown or revoked → **401** `{ error }` + `WWW-Authenticate: Bearer`, checked
  435. before the body. The author is the token's user. GETs need nothing.
  436. | `GET /api/projects` | — | `{ projects, states }` (projects in creation order) |
  437. Ticket row: `id` (UUID), `project`, `number`, `ref` (`#5`), `href` (`/projects/<p>/<n>`),
  438. `apiHref`, `oldNumber` (only if migrated) + `oldRef`/`hasOld`, `subject`, `summary`,
  439. `state`, `stateSlug`, `source`, `created`/`updated` (+`Ms`), `events`, `projectHref`.
  440. Event row: `id` (UUID), `ticket` (UUID), `project`, `number`, `seq`, `kind`, `author`,
  441. `text`, `from`, `to`, `when`, `createdMs`, …
  442. **Since #38 `ticket.id` is a UUID, not a number** — scripts that did `/api/tickets/${ticket.id}`
  443. keep working (UUIDs are accepted); for display use `project` + `ref`.
  444. **Strict** (ticket #10, `bodyError` in `api.hl`): a POST body must be a JSON object; an unknown
  445. field, a missing or empty (after trim) required field, or a non-string value (null included)
  446. → **400 `{ error, field }`** naming the field, and nothing is written. Semantic refusals
  447. (unknown/same state, bad project name) also carry `field`. `author` is gone (ticket #7): a body
  448. that has it → 400 `field:"author"` ("no field 'author' any more: the author is the user of your
  449. API token"). Invalid JSON → **400 `{ error: "invalid JSON at
  450. character N" }`** (no `field`, no source path): `jsoncheck.hl` checks the syntax BEFORE
  451. `JSON.parse` — a WORKAROUND for hybriel #12 (no soft parse); remove it (and `readBody`'s call
  452. in `api.hl`) once #12 is fixed. A LONE `\uD800`–`\uDFFF` escape is refused
  453. too, because hl's `JSON.parse` aborts on it; a valid pair (`\ud83d\ude00`, Python's default
  454. `ensure_ascii=True`) is accepted since hybriel#15 (mission 036). Other errors: `{ error }` with 404/405.
  455. **Times**: `created` / `updated` / `when` are Europe/Vienna wall time `YYYY-MM-DD HH:MM`
  456. (`localtime.hl`: EU DST rule, correct from 1996 on — hl:time has no time zones);
  457. `updatedMs` / `createdMs` and the stored values are epoch ms (UTC). Example:
  458. ```bash
  459. curl -s -XPOST -H "Authorization: Bearer $TICKETS_TOKEN" -d '{"state":"awaiting creator","text":"done"}' http://127.0.0.1:8350/api/projects/tickets.worldapi.org/tickets/5/state
  460. curl -s http://127.0.0.1:8350/api/tickets/38 | jq '.ticket | {project, number, href, oldNumber}' # old number still works
  461. ```
  462. Query the store directly: `curl -s http://127.0.0.1:8350/api/tickets | jq` (the mpackdb files
  463. are binary; the API is the query tool).
  464. ## Connecting an app to a project (ticket #21)
  465. Another app (first: gitoria) is connected to ONE project; it then gets a key that reaches that project only. Code: `connections.hl`,
  466. `components/connect.hl`, the connections line on the project page (`components/ticket_list.hl`), routes in `project.hl`. Data:
  467. `storage/mpackdb/connections.db` (created empty at the first start; no migration).
  468. 1. The app sends the browser to `GET /connect?app=<name>&label=<owner/repo>&return=<url>&state=<opaque>` (`label` and `state` optional; the state
  469. comes back unchanged). The return URL must be https on `worldapi.org` or a subdomain — or start with an origin listed in the env
  470. `TICKETS_CONNECT_ORIGINS` (comma separated, for dev copies). Else 400. A valid request is stored (1 hour) and the browser goes to `/connect/<nonce>`.
  471. 2. On that page the person, logged in through ident with a display name, picks a project they are ADMIN of or makes a new one (they become its
  472. admin) and confirms. The page says which app and host ask, then links back: `<return>?code=<one-time code>&state=<state>` (code valid 5 minutes).
  473. A second connection of the same app + label to the same project replaces the first (its key dies).
  474. 3. The app's SERVER swaps the code: `POST /api/connect/exchange {"code": "…"}` → `200 {key, project (slug), title, api}`. The key is `tktc_` + 48 hex,
  475. shown once, only its sha256 is stored. Wrong / used / expired code → 400.
  476. 4. The key: `Authorization: Bearer tktc_…` plus, on every write, `X-Tickets-Identity: <ident public id of the person>`. It works only on
  477. `POST /api/projects/<slug>/tickets` and `POST /api/tickets` (create), `…/comments` and `…/state` of tickets of ITS project — the same
  478. endpoints a user token uses, so "mentioned in PR" / "fixed by commit" is a comment or a state change with a note. The named person must have
  479. logged in to tickets once and chosen a display name; **the project's roles apply to them** (use: comment, open ↔ review; edit / admin: more).
  480. The author of what is written is that person. A key on any other endpoint (settings, members, invites, edit, assign, relations, inbox,
  481. new project, disconnect) → 401; another project → 403; no / unknown identity → 403 naming the header. Reads need nothing.
  482. 5. The project page shows "Connected to <app>: <label>" to everybody; an admin's **Disconnect** deletes the connection — the key is dead at once.
  483. `GET /api/projects/<slug>/connections` lists them; `POST /api/projects/<slug>/connections/remove {"id"}` (user token, admin) disconnects.
  484. Short copy for app workers: `docs/connect-apps.md` (the architect copies it next to antcolony-docs).
  485. Gate: `node tests/connect.mjs` (ports 8700 / 8701 + Chrome 8703–8709; an ident stub, a real headless Chrome) → 60 checks.
  486. ## Design tokens (shared, ticket antcolony#3 — mission 021)
  487. * **`shared/tokens.hl`** declares the WorldAPI palette + semantic tokens as hl:web css variables,
  488. hand-written: `static dark = var('rgb(25, 30, 35)')`, `static colorText = var(light)`, …
  489. (`import { var } from 'hl:web/css'`). It is a copy of the one source
  490. `loreana:/media/STORAGE/projects/worldapi-tokens/tokens.hl` (vendored like `plugins/`, no generator):
  491. edit it THERE, then `cp /media/STORAGE/projects/worldapi-tokens/tokens.hl shared/tokens.hl` in every
  492. app and restart it. Check: `cmp /media/STORAGE/projects/worldapi-tokens/tokens.hl shared/tokens.hl`.
  493. (2026-09-26: the apps' copy = gitoria's/ident's; it differs from the source only in 3 COMMENT lines that
  494. still say webex — update the source's comments, then the check is byte-exact again.)
  495. * `styles.hl` IMPORTS the tokens it uses (`import { colorText, colorBorder, … } from './shared/tokens.hl'`)
  496. and writes them as members: `color = colorText`, `border = '1px solid ' + colorBorder`. It sets only
  497. its accent: `colorAccent = var(purple)` (#c586c0). A token it uses must be in the import list,
  498. a new token must be added to tokens.hl (static) first.
  499. * hl:web names each token after its member (`colorTextMuted` → `--color-text-muted`), writes EVERY
  500. token of tokens.hl into the one `:root` (declaration order), then the app's `colorAccent` (a second
  501. `--color-accent`, later wins), and writes each use as `var(--…)`. Components/JS may still use
  502. `var(--color-…)` strings — the custom property names are the same.
  503. * hl:web does this itself since hybriel#39 (the webex LOCAL PATCH of mission 021 is gone, mission 036).
  504. * A change of tokens.hl or of styles.hl root members needs a RESTART: the dev watcher re-analyses
  505. but the served sheet keeps the old values (measured, mission 021).
  506. * Deploy: `shared/` is part of the app folder; `deploy.sh`'s rsync sends it (proved in mission 021:
  507. deploy.sh excludes + debian:12-slim container → byte-identical `/__hl/app.css`).
  508. ## Vendored Hybriel
  509. **hybriel master 73267707** (mission 048, 2026-10-01; ≥ 4850d798 = #122 fix, includes #110 #111 #113 #115/#116 #118).
  510. `bin/hybriel` sha256 `9707e0cc56886111c7e5d6e69be2d52d6fa822214d32e1580686094a81be534f`, built read-only
  511. (`git -C /media/STORAGE/projects/hybriel archive master | tar -x -C ~/scratch-…/src`, then
  512. `cd native && /media/STORAGE/projects/termuplex/.tools/zig/zig build -Dtarget=x86_64-linux-gnu.2.39 -Doptimize=ReleaseFast`).
  513. `plugins/{core,crypto,data,fetch,fs,http,http1,mpackdb,proc,time,web}` = master's — **no local patch**.
  514. Re-vendor = copy the binary + these plugins, run the gate. The previous copy (837fe120, sha 9e5e95b3…) is in `.scratch/pre-048/`.
  515. Mission 048: `migrate.hl`'s `compactNow()` block (hybriel #110) REMOVED — proven: without it master's gate is 249/0, while
  516. 837fe120 without it fails (`roles: … "no such project: alpha"`, logs `.scratch/w048/gate2-master-no110.txt` / `gate2-837-no110.txt`;
  517. old file `.scratch/w048/migrate.hl.pre-110`). Lesson (hybriel #122, fixed in 4850d798): ff51cf46's post-face session sync re-mounted
  518. the page without its route params (settings → "Only an admin…"); repro `.scratch/w048/repro/` (`node repro.mjs <bin>` must keep "you may edit").
  519. The old webex generation (e565176b + LOCAL
  520. PATCHes #34 seed escape, #39 tokens) is backed up in `.scratch/pre-036/` (bin, plugins, sources, tests).
  521. * Framework pages use content-hashed URLs `/__hl/{hl-runtime.js,web/client.js,app.css}?v=…` (hashed =
  522. `immutable`, bare = `no-cache`). Check: `~/scratch-036/hashcheck.sh tickets.worldapi.org TICKETS /` (own server :8730).
  523. * Dropped with mission 036: plugins/webex + both LOCAL PATCHes, the NativeWebSocketServer listener
  524. (HL_HOST, #24), `sessions.resolve(cookie)` in /login/callback (→ `req.session`, #11), `realSession()`
  525. (→ `session == null`; hl:web refuses a forged trailing session itself, #16 — the gate's 12 forged checks
  526. accept that ack `ok:false` "the `session` parameter is filled by the server"), the hand URL encoder
  527. (→ `encodeURIComponent`, #14), import.hl's charCodeAt string order (→ `a > b`, #2), jsoncheck's refusal
  528. of VALID surrogate pairs (#15; lone surrogates still abort JSON.parse → still 400).
  529. * Kept on purpose: `jsoncheck.hl` (JSON.parse still aborts on bad input), `localtime.hl` (no time zones),
  530. hand insertion sorts (no list `sort()`), `login.js` once-per-document guard (harmless).
  531. The binary finds `plugins/` beside the app (it walks up from the script's directory).
  532. `tests/cdp.mjs` + `tests/ports.mjs` are copies of the hybriel repo's `tests/browser/`.
  533. ## Files
  534. | File | |
  535. |---|---|
  536. | `project.hl` | manifest: routes (pages + API function routes), audience |
  537. | `store.hl` | the tables (UUID keys, `storage/mpackdb/`), rows, reads, writes, per-project numbers; relations + `links` table (#4) |
  538. | `users.hl` | ticket #7: users + tokens tables, the ident exchange, sessions → users, the login env |
  539. | `login.js` | the selector bridge (plain JS, served at `/login.js`) |
  540. | `shared/md-editor.js` | ticket #12: `<md-editor>`, vendored copy of worldapi-components (served at `/md-editor.js`) |
  541. | `components/you.hl` | `/you`: account + API tokens |
  542. | `tests/identkit.mjs` | the gate's own ident (a copy without .env, mail sink) and its login helpers |
  543. | `tools/migrate-008.hl` | one-off (2026-09-24, ticket #38): old global tables → `storage/mpackdb/`, idempotent |
  544. | `tests/oldstore.hl` | the gate's old-format fixture for the migration |
  545. | `localtime.hl` | epoch ms → Vienna wall time (userland DST rule) |
  546. | `ticketfile.hl` | ticket file parser + `paragraphsOf` (shared by import and the fix tool) |
  547. | `tools/fix-import-summaries.hl` | one-off (2026-09-24): re-paragraph stored summaries of imported tickets |
  548. | `markdown.hl` | ticket #6: Markdown text → blocks/spans (safe links); `components/markdown.hl` renders them |
  549. | `mdview.hl` | ticket #6: the `Accept: text/markdown` read view (documents for one ticket / a list); relations (#4) |
  550. | `tests/markdown.hl` | prints markdown.hl's blocks for `MD_INPUT` (the gate's parser table) |
  551. | `api.hl` | JSON replies, query/body helpers (`readBody` = JSON check + strict fields) |
  552. | `jsoncheck.hl` | hand-written JSON syntax check (workaround for hybriel #12) |
  553. | `import.hl` | the import command (needs `TICKETS_TOKEN`) |
  554. | `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`, sets the accent) |
  555. | `shared/tokens.hl` | the WorldAPI tokens (webex `var()`), verbatim copy of worldapi-tokens/tokens.hl — README "Design tokens" |
  556. | `components/` | shell + three pages |
  557. | `icons/` | mission 046: `icon.svg` (source), `icon-192/512.png`, `apple-touch-icon.png`, `favicon.ico` — README "PWA" |
  558. | `tests/browser.mjs` | the gate |
  559. | `tests/localtime.hl` | prints Vienna renderings of DST edges (the gate compares with `Intl`) |
  560. | `docker-compose.yml`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") |
  561. | `import/tickets/` | copies of the AntColony ticket files (source: byrodin) |

Branches

Latest commits

  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre