gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Address
https://tickets.gitoria.worldapi.org/
Owner
Caramboleyo
Created

tickets.worldapi.org

The World Ticket System: the only state store of AntColony (see byrodin /CONTAINERS/projects/antcolony/README.md). Projects, tickets, an append-only event history per ticket (created / comment / state change), a web UI with live push, a JSON API for the scheduler/CLI, and an idempotent import of the AntColony ticket files.

Written in Hybriel on hl:web (SSR + WebSocket push; hybriel master since antcolony mission 036), modelled on the hybriel repo's projects/demo-social-network. Login via ident (ticket #7, CONCEPT.md "Login via ident"): reading is public; writing (tickets, comments, states) needs a login; the author is 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. See section "Login (ident)". Markdown in text fields and a Markdown read view for LLMs (ticket #6), editing by the ticket's author (ticket #8): section "Markdown, editing". Relations (ticket #4): parent / child and blocked-by, across projects — section "Relations". Markdown editor (ticket #12): every Markdown field is an <md-editor> — section "Markdown editor".

Run

cd /media/STORAGE/projects/tickets.worldapi.org
setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null &  echo $! > server.pid
# stop: kill $(cat server.pid)
  • Port 8350 on 0.0.0.0 (TICKETS_PORT moves it): http://192.168.178.75:8350 (LAN), http://100.77.141.84:8350 (tailnet), http://127.0.0.1:8350.
  • Data: storage/mpackdb/{projects,tickets,events}.* (hl:mpackdb, primary key = mpackdb UUID @id everywhere — creator's convention; TICKETS_STORAGE=<dir> moves them). Sessions: .sessions/ (TICKETS_SESSIONS). The OLD global-numbered tables storage/tickets-* (before ticket #38) stay as a read-only backup — nothing reads them.
  • The framework's dev watcher is on: saving a .hl file re-analyses and reloads open tabs. A restart is needed after changing components/styles.hl root rules, shared/tokens.hl, env vars or the binary.
  • HL_HOST (or HOST): interface to bind, default 0.0.0.0; 127.0.0.1 on Byrodin. hl:web reads it itself (hybriel#24; until antcolony mission 036 project.hl built the listener). TICKETS_WATCH=0 turns the dev watcher off (the container).
  • Login env (section "Login (ident)"): IDENT_API_KEY, IDENT_API_SECRET, TICKETS_CREATOR_IDENTITY, IDENT_URL, IDENT_EXCHANGE_URL, TICKETS_PUBLIC_URL. Without key/secret the app runs read-only for everyone (a login answers "login is not set up").

Projects, members, roles, new states (ticket #20 — supersedes #19 and the creator-only workflow)

  • Projects are records (projects table): title, slug (generated from the title — letters, digits, ., _, -; an admin can change it, the old slug keeps resolving), description (Markdown, the shared <md-editor>), members. Tickets point at their project by its id (never by name); path = <project id>/<number>. Pages: /new-project, /<slug> (the tickets; ticket #25 — was /projects/<slug>), /projects/<slug>/settings (admin: title, slug, description, members, invite link).
  • Roles (members table, one row per project + user): use = comment + move a ticket between open and review; edit = use + create, edit, assign, any state, relations; admin = edit + settings and members. A project always keeps at least one admin. Everybody else only reads. Any logged-in user with a display name may open a project and becomes its admin.
  • Tickets carry createdBy and assignee (members). States: open, progress, pending, review, reopened, done, canceled. A ticket that goes to review / pending with nobody assigned is assigned to the project's oldest admin (a second, assign event). The inbox (/inbox, GET /api/inbox with a token) shows what is assigned to you, as two lists: pending and review.
  • API aliases: the old state names are accepted as input (in progress, awaiting creator, awaiting-creator, confirmed, rejected, on hold) and mean progress / review / done / reopened / pending. Output uses only the new names. New endpoints: GET/POST /api/projects, GET/POST /api/projects/:slug, POST /api/projects/:slug/members ({user, role}; user = display name, ident id or user id), POST …/members/remove, POST …/invites ({role, uses?, days?, email?} → an ident invite link, ident#22), POST /api/tickets/:ref/assign ({assignee}), GET /api/inbox. A refused role → 403.
  • Invites: the person opens ident's link, picks an identity, ident sends them to /login/callback?ident_code=…&invite=<id>; only when ident says that identity accepted the invite (/api/invites/get) does the person join with the invite's role.
  • Migration (lib/migrate.hl, idempotent, runs at every start): old projects {name} → records, tickets re-linked by id, states renamed (awaiting creator → review, a "Question…" ticket → pending, confirmed → done, rejected → reopened, in progress → progress, on hold → pending; review / pending tickets go to the creator), createdBy from the created event; members: the creator (TICKETS_CREATOR_IDENTITY, read ONLY here) is admin, users named Architect / AntColonyScheduler that wrote in a project are edit, everybody else who wrote is use. It compacts the tables it changed (a second open of a table — the page realm — compacts on open and would rewrite the file under the first handle).
  • 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.
  • Gate: tests/browser.mjs (233 checks). The pre-roles browser checks (creator-only confirm, author-only edit, relations by author, tokens page …) are PARKED in tests/browser-pre20-parked.txt — they need porting to the roles.

Login (ident) — ticket #7

  • How it works: ident's identity selector top right (<ident-selector key=IDENT_API_KEY> from <IDENT_URL>/selector.js) plus a "Log in with ident" button (<IDENT_URL>/login?key=…&return=<TICKETS_PUBLIC_URL>/login/callback). Both hand tickets a one-time code; the SERVER exchanges it (POST <IDENT_EXCHANGE_URL>/api/exchange with key + secret) for the per-app identity id. Selector: login.js bridges the ident-login DOM event into the shell's face identLogin — no reload, the pages show their forms (signedIn push to this session's tabs). Button: /login/callback sets the session and redirects back to the page the login started from (ticket #10: at the click login.js adds ?next=<path + query> to the button's return URL; the server follows only a same-origin path — one leading /, not //, no \, URL-safe chars, ≤ 500, not /login/… — else /). Logout (top right) = the shell's face logOut; the selector is reset (class out → loggedIn = false).
  • login.js runs more than once per page (ticket #9): hl:webex re-creates the header — the selector element AND the <script> elements — whenever the login state flips, and the browser executes a re-inserted script again. It therefore installs itself once per document (window.__ticketsLogin), listens for ident-login on document, always looks up the CURRENT #selector, and hands each one-time code to #identcode only once. (Before: one more listener per run → after a logout one selection = two exchanges of the same code → red banner "ident refused the login (400: unknown or already used code)", although the first logged in.)
  • Users (storage/mpackdb/users.*, lib/users.hl): one per identity id {identity, name, created}. The first login asks once for a display name (a normal field — ident may fill it later, CONCEPT point 6); writing waits for it; it cannot be changed afterwards (no UI). The session holds only user = { id = <users @id> } (hl:webex ships session.user to the page); the identity id is never in a page except the user's own /you.
  • Authors: new events store user (+ the name as author); the history shows the user's display name. Old events keep their free-text author. The web forms and the API have no author field (API: a body with author → 400 naming it).
  • Creator: TICKETS_CREATOR_IDENTITY = the creator's per-app identity id. Only that user may set confirmed / rejected (web: message; API: 403 {error, field:"state"}). Unset = nobody can. To set it on Byrodin: the creator logs in to tickets, opens /you ("Your tickets identity id", 32 hex), the architect puts TICKETS_CREATOR_IDENTITY=<id> into /CONTAINERS/projects/tickets.worldapi.org/.env and restarts the container. /you then says "You are the creator".
  • API tokens (storage/mpackdb/tokens.*): on /you a logged-in user creates (optional label), lists and revokes tokens. Shown ONCE (tkt_ + 48 hex), stored as sha256 only. Authorization: Bearer <token> acts as that user on every API write; no / wrong / revoked token → 401 (checked before the body). Reads need no token.
  • Env: IDENT_API_KEY / IDENT_API_SECRET (tickets' registration in ident, origin https://tickets.worldapi.org), TICKETS_CREATOR_IDENTITY — all in .env on Byrodin (the hybriel runtime loads .env beside project.hl; the real environment wins; never commit it). IDENT_URL (default https://ident.worldapi.org: selector script + button), IDENT_EXCHANGE_URL (server side; default = IDENT_URL; docker-compose.yml sets http://127.0.0.1:45002 = ident's loopback port on Byrodin), TICKETS_PUBLIC_URL (default https://tickets.worldapi.org: the button's return URL).
  • ident down: a login answers "ident did not answer" (the global on Error in project.hl absorbs hl:fetch's failure — it logs error absorbed: … for every plugin error).

Markdown, editing (tickets #6, #8 — mission 007 (old 014))

  • Markdown in text fields (summary, comment text, state note): lib/markdown.hl parses the text into plain data (blocks → spans), components/markdown.hl builds elements from it — every character ends up in a TEXT node, no HTML string exists, so raw HTML / <script> shows as text. Links only when safeHref accepts them: http(s):, mailto:, /path (not //), #…; javascript:, data:, vbscript: etc. stay text. Understood: #…###### headings (shown as h3/h4/h5), -/*/+ and 1./1) lists (one level; a list may interrupt a paragraph), fenced code (``` / ~~~), code, [text](url), <https://…>, bare http(s) URLs, *em*, **strong**, ***both*** (also _), \ escapes. Single line breaks inside a paragraph are KEPT (the page showed them before). Not: quotes, tables, images, nested lists. Only pages and pushes carry the parsed blocks (md, summaryMd, oldSummaryMd via lib/events.hl pageEventOf); API JSON unchanged.
  • Read view for LLMs: Accept: text/markdown on GET /api/tickets, /api/projects/:slug/tickets, /api/tickets/:ref, /api/projects/:slug/tickets/:number → text/markdown (+ Vary: Accept), built by lib/mdview.hl: one ticket = # <project> #n: <subject>, a meta line, URL, the summary as written, ## History with one ### <seq> · <when> · <author> <what> per event (texts below, an edit shows "Subject before:" / "Summary before:" quoted); a list = # Tickets (<filters>): N + one line per ticket. Markdown wins only if it is listed with q > 0 and preferred to application/json (higher q, or same q and first); */* / none → JSON. Errors stay JSON. curl -s -H 'Accept: text/markdown' http://127.0.0.1:8350/api/projects/tickets.worldapi.org/tickets/6
  • Editing: the ticket's AUTHOR (the user of its created event) edits subject + summary — web: "Edit" on the ticket page (form, Save/Cancel), API: POST …/edit { subject?, summary? }. A ticket from before the login (created event without a user, only an author string): only the creator (TICKETS_CREATOR_IDENTITY) may edit it. Anyone else: web message, API 403. The edit is a new history event kind:"edit" with oldSubject/oldSummary + newSubject/newSummary (+ subjectChanged/summaryChanged, isEdit) — earlier versions stay (append-only); pushed live like a comment (the page takes over subject + summary). The page shows "X edited the ticket", "Subject before" (struck through) and the previous summary folded. No migration: only NEW events have the edit fields; existing JSON is byte-identical (mission 007 (old 014) .scratch/m014/compat.sh).
  • SECURITY (hl:webex, patched locally until antcolony mission 036; hl:web escapes it itself, hybriel#34): webex put the page's state (= user text) into an inline <script> without escaping </script> → a summary/comment with </script><img src=x onerror=…> RAN in every viewer's browser (stored XSS, reproduced on the pre-m014 code: node .scratch/m014/xsscheck.mjs <url> → __pwned = 1). Fixed in the vendored plugins/webex/WebFramework.hl (seed: every < written as \u003c); to be filed as a Hybriel issue (mission 007 (old 014) report). The gate's </script> check (MD_RICH) still guards it.

Markdown editor (ticket #12 — missions 009 (old 024) + antcolony 025)

  • The new-ticket summary, the edit summary, the comment and the state note are <md-editor>s around their textareas (mdEditor { textarea { … } }): edited visually (formatted while typing, toolbar incl. phones, shortcuts), the textarea keeps the plain Markdown and fires input as before — members, faces, API and stored data are unchanged. The state note is now a textarea (rows 1): the editor writes line breaks. Ctrl/Cmd+Enter sends the form.
  • A "Markdown source" button in a footer row at the bottom of every editor (antcolony mission 025, creator's follow-up request) switches it to a plain textarea with the raw Markdown, to copy it out or edit it by hand; "Visual editor" switches back (re-parses the typed text). Same input/change events and Ctrl+Enter as the visual editor.
  • The component is vendored: shared/md-editor.js = verbatim copy of /media/STORAGE/projects/worldapi-components/md-editor.js (edit it THERE, test it there, cp it here; cmp them). Served at /md-editor.js (project.hl), loaded once in the shell (main.hl). Its README: attributes, keyboard, lossless rules, restyling. Colours: mdEditor { '--md-…' = token } in components/styles.hl.
  • Without JS the page shows the plain textareas (SSR); the forms themselves need JS as before (webex faces).
  • Only tickets' Markdown subset exists in the editor (its parser is a port of markdown.hl, checked against it by the component's test with ORACLE_APP); pasted rich text is reduced to it; an untouched text comes back byte for byte. Keep markdown.hl and md-editor.js's parser in step.

Relations: parent / child, blocked by (ticket #4 — mission 008 (old 017))

  • Parent / child: a ticket has at most one parent (any project). The parent's page lists its children with their states ("Children"); a child says "Part of <parent>". Blocked by: a ticket can wait on any number of tickets (any project); the blocked page lists "Blocked by", the other side "Blocks" — each with its state.
  • Parent state (smallest rule): allChildrenConfirmed = the ticket has children and every child is confirmed. The page shows "all children confirmed (n)" in green, else "k of n children confirmed". The parent's own state is NEVER changed by it — only the creator confirms.
  • Who may set / remove a relation: the AUTHOR of either of the two tickets (user of its created event) or the creator (TICKETS_CREATOR_IDENTITY). A ticket from before the login has no author: only the creator or the author of the other ticket. Replacing a parent needs the right for the old AND the new pair. Refused: a ticket as its own parent / blocker, loops (a parent below the ticket, a blocker that already waits on it, also transitively), duplicates, unknown tickets.
  • Naming a ticket: <project>#<number> (spaces around # allowed, e.g. ident.worldapi.org#1) or its UUID.
  • Storage: a NEW table storage/mpackdb/links.* ({ kind: 'parent'|'blockedBy', ticket, other, user, created }, indexes @ticket, @other), created empty at the first start — no migration. A removed relation is deleted there; the HISTORY keeps every change: an event kind:"link" on the child / the blocked ticket ("set the parent to X #1 (was Y #2)", "removed the parent X #1", "marked it blocked by X #5", "removed the blocker X #5"; extra JSON keys isLink, linkKind ('parent' | 'blockedBy'), linkAction ('set' | 'removed'), otherRef, otherHref, previousRef — only on link events).
  • JSON: every ticket row (list AND single GET, pushes) has parent (a ref or null), children, blockedBy, blocks (lists of refs, oldest link first) and allChildrenConfirmed. A ref = { id, project, number, ref ('#1'), key ('ident.worldapi.org#1'), subject, state, stateSlug, href, apiHref }. All other keys unchanged (mission 008 (old 017) .scratch/m017/compat.sh).
  • Markdown view: under the URL line Parent: <p> #n [state] subject · url, Children (k of n confirmed): / Children (all n confirmed):, Blocked by:, Blocks: (one - <p> #n [state] subject · url line each); a list line ends with · parent … · children n, k confirmed | all confirmed · blocked by <p> #n [state], … · blocks … (only the parts that exist).
  • Web (logged in): under "Respond" the forms "Parent ticket (project#number)" → Set parent / Remove parent, "Blocked by (project#number)" → Add blocker; a "Remove" button per blocker row. Faces ticketSetParent(id, key) ('' = remove), ticketAddBlocker, ticketRemoveBlocker.
  • Live: every relation write, state change and edit pushes ticketsRelated(views) — the relation view of the ticket and of every ticket related to it (+ one just unlinked); an open page of any of them follows without a reload (a child confirmed → the parent's "k of n" moves).
  • For the scheduler: parent tickets = rows with children.length > 0 (don't pick them as work); a ticket waits while any blockedBy[].state is not what the scheduler counts as done. A report's issues[].blocks = true → create the issue ticket, then POST <original>/blocked-by {"add": "<project>#<n>"} with the scheduler's token (it is the issue's author, so it may).

PWA — installable app, icons, offline shell (antcolony mission 046)

Done the way calendar.worldapi.org does it: hl:web generates everything from project.hl — no JavaScript of ours.

  • appTitle "tickets", appIcons (192/512, any + maskable, same PNGs), appTouchIcon, appFavicon, appThemeColor = the header's darker rgb(15, 20, 25) (as tracker), appBackgroundColor = dark rgb(25, 30, 35) (both from shared/tokens.hl). Served: /__hl/manifest.webmanifest (linked from every head with apple-touch-icon + theme-color), /__hl/sw.js.
  • offline = [ TicketList ]: / is the ticket list, so offline it is the list as this browser last loaded it (the worker precaches / at install; a visited /<slug> is kept too). Every other page offline → hl:web's "Unavailable offline" (503). TicketList's emits are all value-form, so nothing is queued offline.
  • "You are offline" note in the shell (components/main.hl offline, #offline): hl:web has no connection state and no mount hook, so an invisible <net-probe> runs an endless 1 s CSS animation (components/styles.hl tickets-net-tick) and every animationiteration reads navigator.onLine (same trick as tracker).
  • Icons icons/: icon.svg is the SOURCE (a tilted ticket stub, purple on dark, inside the maskable safe circle r=205). Rebuild after editing it:
  cd icons && for s in 192 512; do rsvg-convert -w $s -h $s icon.svg -o icon-$s.png; done
  rsvg-convert -w 180 -h 180 icon.svg -o apple-touch-icon.png
  for s in 16 32 48; do rsvg-convert -w $s -h $s icon.svg -o /tmp/fav-$s.png; done
  magick /tmp/fav-16.png /tmp/fav-32.png /tmp/fav-48.png favicon.ico

Each icon has its own file route in project.hl (/favicon.ico serves icons/favicon.ico).

  • Test: the gate's last part (browser C) — see "Test (the gate)".

Deploy (Byrodin)

Target: /CONTAINERS/projects/tickets.worldapi.org on Byrodin, container tickets.worldapi.org (docker-compose.yml: debian:12-slim, host network, HL_HOST=127.0.0.1, TICKETS_PORT=45003, TICKETS_WATCH=0, the folder mounted at /home/tickets, ./bin/hybriel project.hl), public https://tickets.worldapi.org/ via nginx (TLS ends there; the vhost needs the WebSocket Upgrade headers — the live push rides /__hl/socket; no baseUrl/tls in the app, like notes).

  • First deploy: done by the architect (folder + data, nginx vhost, cert, DNS).
  • Later: ./deploy.sh on Loreana, in this folder: runs the gate (refuses on a failure; --skip-tests skips it LOUDLY), backs up storage//.sessions//.env (what exists) to Loreana's /media/SLOW1TB2/deploy-backups/<app>/ (newest 5 kept; an empty/failed backup stops the deploy), then rsyncs the code to [email protected]:/CONTAINERS/projects/tickets.worldapi.org (never storage/, .sessions/, .env, .scratch/, server.*, testapp/, logs — the preview is checked for them; no --delete), docker compose up -d && docker compose restart over ssh -F /dev/null, then waits for https://tickets.worldapi.org/ to answer 200. Every step is printed.
  • ./deploy.sh --dry-run = gate + rsync -n + the commands it would run (no restart, no URL check). --target DIR|HOST:DIR / --url URL point it elsewhere (tested only against a local directory, ident STATUS "ident mission 006 (old 010)").
  • Import on Byrodin: TICKETS_URL=http://127.0.0.1:45003 TICKETS_TOKEN=tkt_… ./bin/hybriel tools/import.hl (in the container or on the host with the vendored binary).
  • Session cookie hlsid (host-only; tickets has its own host on Byrodin). Login: .env needs IDENT_API_KEY, IDENT_API_SECRET, later TICKETS_CREATOR_IDENTITY (section "Login (ident)"). No migration: users.* / tokens.* are created empty at the first start; old events keep their authors (checked mission 005 (old 011): every GET of old vs new code on a copy of the store identical).

Import the AntColony ticket files

# on byrodin — refresh the copies:
scp /CONTAINERS/projects/antcolony/tickets/*.md loreana:/media/STORAGE/projects/tickets.worldapi.org/import/tickets/
# on loreana, with the server running (an API token of the user who should open them, /you):
TICKETS_TOKEN=tkt_… ./bin/hybriel tools/import.hl   # TICKETS_IMPORT_DIR (default import/tickets of the app), TICKETS_URL (default http://127.0.0.1:8350)

Format: project: and subject: header lines, blank line, summary (lib/import.hl). The summary is hard-wrapped text: its lines are joined into paragraphs (one space), a blank line starts a new paragraph; a Markdown list line (- , * , 1. after trimming) starts a new line (\n) and following ordinary lines join onto it (wrapped item; nesting indentation is dropped); stored as paragraphs separated by \n\n, shown with white-space: pre-wrap. CRLF files work. Each file is POSTed to the RUNNING server (/api/tickets with source = file name); a known source answers the existing ticket, so re-running prints 0 new, N already there. New tickets are numbered per project in sorted file-name order (output: NEW 0004-scheduler.md → antcolony #1).

Ticket numbers, URLs, the migration (ticket #38)

  • A ticket's key is the mpackdb UUID (id, e.g. 0mufbphip3w7). People use project + number: numbers count per project from 1 (next = highest in the project + 1).
  • Slug rule: the project slug IS the project name, as is. A new project name may only hold letters, digits, ., _, - (all existing names do), so it goes into URLs unescaped.
  • Ticket page: /<project>/<number> (e.g. /tickets.worldapi.org/5; ticket #25 — before it was /projects/<project>/<number>, which now answers 301). /<project> = the project's list (same as /project/<project>).
  • Old global numbers (#1…#39 in docs and comments) are kept on the migrated tickets as oldNumber; the ticket page shows "formerly #38"; /tickets/<old n> → 301 to the new URL (also /tickets/<uuid>); /api/tickets/<old n> keeps answering. New tickets get no old number.
  • Ordering never uses key order: lists by stored updated, a history by (created, seq), projects by created.
  • Migration 2026-09-24 (tools/migrate-008.hl, idempotent, header explains it): old #N → <project> #k by creation order. Mapping printed in .scratch/deploy-008-<ts>/migrate-run1.txt; or ask the API: curl -s localhost:8350/api/tickets/38 | jq .ticket.href.

Short URLs (ticket #25)

  • Pages: project /<slug>, ticket /<slug>/<number> (lib/util.hl projectHrefOf / ticketHref — every page link and the href / projectHref of the API rows). Settings stay at /projects/<slug>/settings; the filter pages /project/<slug>[/state/<s>] and /state/<s> are unchanged.
  • Old URLs answer 301 (lib/api.hl, to the project's CURRENT slug, so an old slug lands on the new one): /projects/<slug> → /<slug>, /projects/<slug>/<n> and /projects/<slug>/tickets/<n> (the form the conductor sent) → /<slug>/<n>, /tickets/<old n | uuid> → /<slug>/<n>. What names no project / ticket → 404 text, no Location.
  • The API paths are unchanged (/api/projects/<slug>/…, /api/tickets/<ref>); only the page links inside its answers are the short ones (apiHref, settingsHref unchanged).
  • A project by its id (mission 012, ticket #25 comments 2 + 4): every /api/projects/<x>/… route takes the project's slug (also an old one) OR its id — the 12-character record id of the projects table ([0-9a-z]{12}, e.g. 0mufbw18nsuj = tickets.worldapi.org; id in GET /api/projects details and in GET /api/projects/<x> project). It never changes, also not when the slug does. lib/projects.hl slugOfRef turns an id into the CURRENT slug (a slug is tried first, so it wins should a slug ever equal an id); the routes then run exactly as with the slug — the answers are byte for byte the same. Tickets stay under their project (/api/projects/<x>/tickets/<n>, numbers count per project); there is NO new global /api/tickets/<x> (the old one — old global number or ticket UUID — stays as it was). Pages are not affected (/<slug>, /projects/<slug> take slugs only).
  • Route order (hl:web: the FIRST matching route answers, authored order): explicit routes first, the two slug pages last (lastPage = true in project.hl). hl:web APPENDS its own urls (/__hl/app.css, /__hl/emit, /__hl/sw.js, /__hl/manifest.webmanifest, /__hl/hl-runtime.js, the component modules /components/<file>.hl) to the table while it is constructed, i.e. AFTER the app's — at the end of our table /:projectSlug/:ticketNumber still answered them with a ticket page (measured; the gate fails 15 checks and stops). So project.hl moves the lastPage routes to the end of server.router.routes right after new WebFramework(…). They stay in the table itself because hl:web serves the module of a component only if a route mounts it at construction, and the service worker's page table reads it. Survives the dev watcher's re-analysis (checked). Upstream fix would be hl:web matching its own urls first.
  • Reserved slugs (lib/projects.hl reservedSlugs, lower-case compare): every first path segment a route answers (api, __hl, components, login, projects, inbox, you, …) plus a few for later (my, assets, sign-in, settings, search, …). A new project or a slug change to one → 400 field:"slug" "… is reserved"; a proposed slug from a title skips them (Inbox → inbox-2). The gate checks that every first segment of project.hl's routes is in the list — a new route with a new first segment must add it there. Live data 2026-10-03: no collision (11 slugs, no old slugs).

Test (the gate)

node tests/browser.mjs          # 315 checks (mission 012), ~35 s; own server on :8352, own ident on :8353,
                                # exchange-counting proxy on :8357 (TICKETS_GATE_EXCHANGE_PORT),
                                # own storage in .scratch/gate-store (ident's code copied WITHOUT
                                # .env to .scratch/gate-store/ident, mail to a sink — never real mail)

Ports (mission 008 (old 017)): TICKETS_GATE_PORT, TICKETS_GATE_IDENT_PORT, TICKETS_GATE_EXCHANGE_PORT, TICKETS_GATE_CHROME_A / TICKETS_GATE_CHROME_B ("8810-8814"; default 8620-8629 / 8630-8639) — a worker with a port range runs e.g. bash .scratch/m017/gate.sh <out.txt> (8800-8819). TICKETS_GATE_CHROME_C (default 8640-8649) is the PWA browser. antcolony Mission 046 ran it as TICKETS_GATE_PORT=8750 TICKETS_GATE_IDENT_PORT=8751 TICKETS_GATE_EXCHANGE_PORT=8752 TICKETS_GATE_CHROME_A=8753-8754 TICKETS_GATE_CHROME_B=8755-8756 TICKETS_GATE_CHROME_C=8757-8759 node tests/browser.mjs → 245 passed (~30 s).

antcolony Mission 046 part (PWA, at the very end, own fresh Chrome C): head links manifest / apple-touch-icon / favicon / theme-color; manifest fields; every icon a real PNG of its size; favicon.ico + icon.svg; the worker registered with scope / and controlling; Page.getInstallabilityErrors empty; then the SERVER IS STOPPED and the page set offline (CDP) → reload / shows header + last list + #offline; /inbox → "Unavailable offline"; online again → the note goes. Screenshots .scratch/gate-pwa-phone-online.png / gate-pwa-phone-offline.png (390 px) — LOOK at them. 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 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 the same way). Tickets' gate then ran against a snapshot of ident's code with ident's 837fe120 bin/plugins (what live ident runs): .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. antcolony Mission 048 "once:" checks: after a comment, a state change and adding a member, the new row is in the list exactly once (hybriel 64527baa+ answers a session face with a sync of session-derived members — a handler appending the row too would double it). connected(page) in the gate waits until the server has READ the tab's hello (a ping on the same socket, its pong), not just an open socket: a push sent before that reaches no component (the inbox-live check failed ~1 in 5 without it).

Mission 011 part (#25 short URLs, after "numbers"): /gamma, /gamma/1, /beta.example.org/2, /alpha/3 SSR 200; 6 old URLs → 301 to the short one (/projects/<s>, /projects/<s>/<n>, /projects/<s>/tickets/<n>, /tickets/<old n>), 6 that name nothing → 404 without Location (incl. a CR/LF number); /nope says no such project; hl:web's own urls keep their content types (/__hl/app.css, runtime, sw, manifest, client.js, /components/{ticket,ticket_list,main}.hl, POST /__hl/emit); in Chrome /projects/gamma/tickets/1 lands on /gamma/1, the project link → /gamma and a row → /gamma/2 without a reload; 10 reserved slugs refused at creation, a slug change to inbox refused; every first segment of project.hl's routes is in reservedSlugs; /projects/<old slug> → 301 /<current slug>. Control: without the reorder in project.hl the gate ends 35 passed, 15 failed.

Mission 009 (old 024) part (#12 Markdown editor, before the console check): bob on his ticket — SSR has the plain textareas inside <md-editor>, /md-editor.js served, editors upgraded; a comment by REAL typing (**x**, `x`, - list) sent with Ctrl+Enter → stored Markdown + rendered + editor cleared; a state note with the toolbar "B"; a hostile rich paste (img onerror, script, javascript: link) reduced to the subset, sent, rendered safely; the edit form opens the stored summary formatted and byte for byte, typing at the end changes only that block, saved; at 390px the new-ticket form with toolbar taps (inline code) → stored. Screenshots gate-{phone,desktop}-mdeditor.png, gate-phone-newticket-mdeditor.png. The component's own test (72 checks): worldapi-components/test/run.mjs.

Mission 008 (old 017) part (#4 relations, after #8/#6): project rel.example — the gate user opens P (#1) and C1–C3 (#2–#4), bob X (#5). API: set parent 3 ways (per-project, UUID route + UUID, rel.example # 1), P's children + states, list rows carry the relations, row keys = old + exactly the 5 new, 14 refused parent writes (401, 403 ×2, itself, unknown, no #, loop, same, none to remove, {}, unknown field, non-string, author, 404) write nothing; who: bob (author of the child) sets/removes, a legacy ticket → 403 for bob, 201 for alice (creator); replace (label "(was …)"), a grandchild loop. Blocked-by: add, 13 refusals (401, 403, itself, twice, direct + 2-step loop, add+remove, {}, empty, unknown, not blocking, unknown field, 404), remove; non-link events keep the old keys. Markdown: child "Parent:" + "Blocked by:", parent "Children (0 of 3 confirmed):", blocker "Blocks:", list-line tails. Browsers: B (bob) on P sees 3 children + "0 of 3"; signed out: relations yes, forms no; A (alice) on C2 "Part of …", "Remove parent" → B drops to 2 live, typed "Set parent" → 3 again, unknown parent → message; B on X "Blocks", A "Add blocker" → both live, bob's face refused, 3 forged faces refused, the row's "Remove"; alice confirms C1, C2 (API) → B "2 of 3", C3 (web) → "all children confirmed (3)" green, P stays open. Screenshots gate-{phone,desktop}-{parent,blocked}.png — look at them.

Mission 007 (old 014) parts: #8 — the gate user opens gamma #3 with a Markdown summary, edits subject (per-project URL) and summary (/api/tickets/<uuid>/edit), history created/edit/edit with the old values; 9 refused edits (no token 401, bob / alice (creator, not author) 403, {}, empty subject, same values, unknown field, non-string, author → 400) write nothing; 404s; legacy alpha #1: gate / bob 403, alice (creator) 201. In Chrome: bob's ticket (gamma #4) — "Edit" only for bob (not alice, not signed out), the form (screenshots gate-{phone,desktop}-edit.png, gate-desktop-editbutton.png), Cancel writes nothing, an empty subject is refused, Save → both browsers show the new subject + rendered summary + "bob edited the ticket" with the previous values, no reload (gate-{phone,desktop}-markdown.png); faces: alice refused, forged session refused; legacy alpha #1: "Edit" for alice only; logout hides it, a selector login brings it back without a reload. #6 — JSON unchanged (non-edit events keep exactly the old keys; Accept: application/json = no Accept); the read view (ticket, uuid form, project list, state list, 7 Accept variants, errors stay JSON); the parser table (tests/markdown.hl); the rendered summary in Chrome (headings, em/strong, code, lists, code block; ONLY the 3 safe links; no script/img/handler element; raw HTML incl. </script><img …> shown as text; window.__xss unset); a Markdown comment pushed live to both, a Markdown state note. #11 — every page wait is TICKETS_GATE_SLOW× (default 3) its timeout; the console check runs BEFORE ident is stopped, both browsers park on about:blank first, then "nothing new in the console" after. Load proof (mission 007 (old 014)): node .scratch/m014/load.mjs <tickets url> 4 6 (4 headless Chromes × 6 tabs looping pages, ports 8700-8719, SIGTERM closes them) while the gate runs.

Ticket #7 part (login via ident, tests/identkit.mjs): our own ident from ../ident.worldapi.org (or TICKETS_GATE_IDENT_DIR), accounts alice / bob / gate, tickets registered with origin http://127.0.0.1:8352, alice's per-app id computed (selector code + exchange). Server #1 WITHOUT a creator: the machine user "gate" logs in through /login/callback (no / unknown / reused code → 400), the name prompt, the name rules (empty, 61 chars, asked once), a token over the face, /you shows its id, confirm / reject → 403. Server #2 with TICKETS_CREATOR_IDENTITY = alice: the session survives the restart; every API write with the gate token, author in a body → 400, 14 unauthenticated cases → 401 (auth before the body), reads without token. Browsers: signed out = history + hint, no forms, a face write refused, screenshots gate-{phone,desktop}-signedout.png; A = alice signs in to ident, then the SELECTOR (no reload, logged-in, name prompt gate-*-name.png, forms appear, gate-*-loggedin.png); B = bob via the LOGIN BUTTON (ident /signin → /login/callback → name); writes show alice / bob / gate as authors; bob's confirm / reject refused (web + API 403), alice confirms; bob's token on /you (shown once, gate-*-tokens.png, not after reload) → API write as bob → revoke → 401; alice cannot revoke bob's token; 8 faces with a forged session (#31) refused; old events keep "creator" / "worker"; logout resets the selector without a reload; ident stopped → "ident did not answer" (400, no 500). Ident's log: .scratch/gate-ident.log. Ticket #9: tickets reaches ident's /api/exchange through a proxy in the gate that COUNTS the exchanges. alice gets a 2nd identity "alice two"; on a ticket page: logged in → Log out → selector → "alice two": no #loginerror, exactly ONE exchange (200), no reload, /you shows alice two's per-app id; then Log out → in-app click to the list → selector → "Default": again one exchange, logged in as alice. Ticket #10: /login/callback?next= table (16 cases: paths kept, //host, https://, /\host, javascript:, CR/LF, quotes, /login/…, 501 chars → /); in Chrome bob logs out, clicks to a ticket (pushState URL), "Log in with ident" → ident → back on that ticket, logged in; same from /state/open. Manual repro of #9 with instrumentation (listeners, changes, socket frames, exchanges): node .scratch/m012/repro.mjs [appDir] (SCEN=ticket-nav|list|ticket-direct, SECOND=<identity>; ident :8363, proxy :8366, tickets :8362, Chrome 8640-8649; output .scratch/m012/repro-run/out.txt).

Ticket #38 part (runs first): tests/oldstore.hl writes an OLD-format store (5 tickets, old #1–#5 in alpha / beta.example.org, a same-ms tie), tools/migrate-008.hl migrates a copy of it TWICE (second run must print 0 new), the server runs on the migrated tables: old-number and per-project API (GET + POSTs), /tickets/<n> 301 (fetch and in Chrome), "formerly #n", a real click on a list row, new tickets continue the numbering (alpha #4, new project gamma #1), pushed rows carry /projects/… hrefs (list + inbox in browser B). Screenshots also gate-{phone,desktop}-legacy.png.

It starts its own server, runs the import twice, drives the API, and opens TWO headless Chromes (debug ports 8620-8629 / 8630-8639, --disable-gpu, killed by PID at the end): filters by real clicks, a comment and state changes in browser A appear live in browser B (no reload — checked with a window marker), API writes appear live in both, the inbox and header count follow, the new-ticket form, and the layout at 390px and 1280px (no horizontal overflow; screenshots .scratch/gate-{phone,desktop}-{list,ticket,inbox}.png, gate-phone-newticket.png, gate-phone-paragraphs.png — look at them). Also: the strict-API table (26 refused bodies, nothing written), Vienna time against node's Intl (bin/hybriel tests/localtime.hl prints DST-edge samples), and a CRLF paragraph fixture imported from .scratch/gate-import (→ #7). Ticket #15 (author cases replaced by ticket #7's "no author field"), 13 invalid JSON bodies → 400 at the right character and no source path, valid escapes still accepted, no name field in either form, a list fixture .scratch/gate-import-lists (→ #8) rendered as separate lines (gate-phone-lists.png). Server log: .scratch/gate-server.log. Chrome: HL_CHROME (default /opt/google/chrome/chrome, google-chrome is not on PATH).

Check for leaked Chromes afterwards: ps -eo pid,args | grep [h]l-browser-tier (must be empty).

Short URLs on real data (ticket #25)

# a copy of the live tables (never run on the original), then a server on it (port of your range, watcher off)
tar -C /CONTAINERS/projects/tickets.worldapi.org -cf - storage/mpackdb | ssh loreana tar -C /media/STORAGE/projects/tickets.worldapi.org/.scratch/w083/realdata -xf -
mkdir -p .scratch/w083/run && cp -a .scratch/w083/realdata/storage .scratch/w083/run/   # a fresh copy per run
TICKETS_PORT=8763 TICKETS_STORAGE=$PWD/.scratch/w083/run/storage/mpackdb TICKETS_SESSIONS=$PWD/.scratch/w083/run/sess TICKETS_WATCH=0 ./bin/hybriel project.hl &
node tests/shorturls-realdata.mjs http://127.0.0.1:8763 20   # every project + 20 tickets: short URL 200 (the page), every old URL 301 → it → 200; collisions with reserved slugs

Mission 011: 187 passed, 0 failed (11 projects, 20 tickets). The API of the old and new code on the same copy (.scratch/w083/apicompare.py <old url> <new url>): 1,348 answers (JSON + Markdown), 17 identical, 1,331 differ only by the page links (href values, the Markdown URL lines), 0 different.

Project ids on real data (mission 012)

# a copy of the live tables (as above, into .scratch/<you>/realdata), the OLD tree (as in "Same output" step 2), each on a fresh copy:
TICKETS_PORT=8763 TICKETS_STORAGE=<old copy>/mpackdb TICKETS_SESSIONS=<dir> TICKETS_WATCH=0 ./bin/hybriel project.hl &   # in the old tree
TICKETS_PORT=8764 TICKETS_STORAGE=<new copy>/mpackdb TICKETS_SESSIONS=<dir> TICKETS_WATCH=0 ./bin/hybriel project.hl &   # here
python3 tests/projectids-realdata.py http://127.0.0.1:8763 http://127.0.0.1:8764   # must end "0 FAILED"; then kill both by PID

Old vs new: every API read (JSON + Markdown) byte for byte; on new: every /api/projects/<slug>/… read again with the id = the same bytes; unknown id and /api/tickets/<project id> → 404. Mission 012 (Byrodin copy 2026-10-04 ~05:00): 11 projects, 309 tickets, 1,484 old-vs-new answers + 772 slug-vs-id answers, 0 FAILED. The gate's part: "ids (#25)" (22 checks: reads by id = slug form byte for byte, every write route by id, unknown id 404, id after a slug change); on the old code exactly those fail (control run: 286 passed, 13 failed, then it stops).

Same output (a cleanup must not change behaviour — mission 010)

The gate proves the features; this proves a refactor answers byte for byte what the old code answered, on REAL data.

# 1. a copy of the live tables (Byrodin → Loreana; opening a table rewrites its index files: never run on the original)
tar -C /CONTAINERS/projects/tickets.worldapi.org -cf - storage/mpackdb | ssh loreana tar -C /media/STORAGE/projects/tickets.worldapi.org/.scratch/realdata -xf -
# 2. the old code as a tree of its own (bin + plugins included), e.g. before the change:
rsync -a --exclude /.scratch --exclude /storage --exclude /.sessions --exclude '/.env*' --exclude /.git --exclude '/server.*' --exclude /import ./ .scratch/old/
# 3. both trees on a fresh copy each: ~2,200 answers — every page signed in (as az5b2, users @id 0mufikk6hj1u, through a
#    session file) AND signed out, every API read as JSON and Markdown, refusals, then ~70 writes (API + faces) and the reads after
node tests/realdata-baseline.mjs .scratch/old 8750 .scratch/base-old
node tests/realdata-baseline.mjs . 8750 .scratch/base-new
python3 tests/realdata-compare.py .scratch/base-old .scratch/base-new          # reads: must say 0 DIFFERENT
python3 tests/realdata-compare-writes.py .scratch/base-old .scratch/base-new   # writes: must say 0 DIFFERENT
python3 tests/letcount.py .                                                     # code order: 1 root .hl, never-reassigned let 0

Masked (not app output): the session seed's at time, the View's source positions (site, Child@line:col mount keys) and the ?v= content hashes; in writes also the new ids, today's times and the token. Two runs of the SAME code compare clean with these masks (checked). Mission 010 numbers: 2,172 reads (1,460 byte-identical, 712 identical after masking), 72 write answers identical after masking.

Pages

RouteComponent
/components/ticket_list.hlall tickets, newest update first; project + state filter chips; "New ticket" form
/project/:projectName, /state/:stateSlug, /project/:p/state/:ssamefiltered (state slug: in-progress, awaiting-creator, on-hold, …)
/:projectSlug/:ticketNumber (last, ticket #25)components/ticket.hlticket, history, comment form, state change (select + optional note), "Edit" for the author (#8); texts rendered as Markdown (#6)
/:projectName (last, ticket #25)components/ticket_list.hlthe project's page (= /project/:projectName)
/projects/:slug, /projects/:slug/:number, /projects/:slug/tickets/:numberfunction routes301 → /<slug> / /<slug>/<n> (current slug), else 404 (ticket #25)
/tickets/:reffunction route301 → /<p>/<n> (:ref = old global number or UUID), else 404
/inboxcomponents/inbox.hlevery ticket in awaiting creator, across projects (the creator's comment on one sets it to answered and it leaves the inbox)
/youcomponents/you.hlthe logged-in user: display name, own per-app identity id, creator or not; API tokens (create / list / revoke)
/login/callbackfunction routethe login button's return: ?ident_code= → exchange → session → 302 to ?next= (same-origin path only, else /; 400 page on failure)
/login.jsfilethe selector bridge (ident-login → the shell's hidden #identcode, once per code) + next= on the login button

Shell: components/main.hl (header, Inbox link with live count, top right the login: selector + button, or name link to /you + Log out; the display-name prompt). All CSS: components/styles.hl (tokens imported from shared/tokens.hl — section "Design tokens"; accent colorAccent = var(purple) = #c586c0, --color-danger: var(--red) = #f44747; domain tags <ticket-board>, <ticket-state>, <ticket-view>, <event-head>, …; mobile first, one min-width: 45rem block).

Live push (audience in project.hl): ticketCreated(row, inboxCount) and ticketEvent(ticketId (UUID), event, row, inboxCount) (public), raised by the web faces AND the API; signedIn(tag, {name, named, creator}) / signedOut(tag) only to the tabs of the session whose login carries that random tag (session.data.tag).

API (JSON)

:ref = the OLD global number of a migrated ticket (38) or the ticket's UUID (id). :slug = the project's slug (also an old one) or its id (12 characters, mission 012 — every /api/projects/:slug/… route), :number = per-project number. Both forms answer the same shapes.

Method + pathBodyAnswer
GET /api/tickets[?project=&state=]—{ tickets: [row] } (state as awaiting creator or slug)
POST /api/tickets{ project, subject, summary?, source? }201 { ticket, existed:false }; known source → 200 { ticket, existed:true }
GET /api/tickets/:ref—{ ticket, events: [event] } (oldest first); 404
POST /api/tickets/:ref/comments{ text }201 { ticket, event }
POST /api/tickets/:ref/state{ state, text? }201 { ticket, event }; 400 unknown/same state; 403 confirmed/rejected by a non-creator
GET /api/projects/:slug/tickets[?state=]—{ tickets }; 404 unknown project
POST /api/projects/:slug/tickets{ subject, summary?, source? }as POST /api/tickets
GET /api/projects/:slug/tickets/:number—{ ticket, events }; 404
POST /api/projects/:slug/tickets/:number/comments{ text }201 { ticket, event }
POST /api/projects/:slug/tickets/:number/state{ state, text? }201 { ticket, event }; 403 as above
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
POST …/parent (both forms){ parent } — <project>#<n> or UUID; "" removes it201 { ticket, event (kind link) }; 403 neither author nor creator; 400 field:"parent" (unknown, itself, loop, same, none to remove)
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)

Every ticket GET answers Accept: text/markdown with a Markdown document (section "Markdown, editing").

Every POST needs Authorization: Bearer <token> (ticket #7; a token from /you): missing, malformed, unknown or revoked → 401 { error } + WWW-Authenticate: Bearer, checked before the body. The author is the token's user. GETs need nothing. | GET /api/projects | — | { projects, states } (projects in creation order) |

Ticket row: id (UUID), project, number, ref (#5), href (/<p>/<n>, ticket #25), apiHref, oldNumber (only if migrated) + oldRef/hasOld, subject, summary, state, stateSlug, source, created/updated (+Ms), events, projectHref. Event row: id (UUID), ticket (UUID), project, number, seq, kind, author, text, from, to, when, createdMs, … Since #38 ticket.id is a UUID, not a number — scripts that did /api/tickets/${ticket.id} keep working (UUIDs are accepted); for display use project + ref.

Strict (ticket #10, bodyError in lib/api-helpers.hl): a POST body must be a JSON object; an unknown field, a missing or empty (after trim) required field, or a non-string value (null included) → 400 { error, field } naming the field, and nothing is written. Semantic refusals (unknown/same state, bad project name) also carry field. author is gone (ticket #7): a body that has it → 400 field:"author" ("no field 'author' any more: the author is the user of your API token"). Invalid JSON → 400 { error: "invalid JSON at character N" } (no field, no source path): lib/jsoncheck.hl checks the syntax BEFORE JSON.parse — a WORKAROUND for hybriel #12 (no soft parse); remove it (and readBody's call in lib/api-helpers.hl) once #12 is fixed. A LONE \uD800–\uDFFF escape is refused too, because hl's JSON.parse aborts on it; a valid pair (\ud83d\ude00, Python's default ensure_ascii=True) is accepted since hybriel#15 (antcolony mission 036). Other errors: { error } with 404/405.

Times: created / updated / when are Europe/Vienna wall time YYYY-MM-DD HH:MM (lib/util.hl localStamp: EU DST rule, correct from 1996 on — hl:time has no time zones); updatedMs / createdMs and the stored values are epoch ms (UTC). Example:

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
curl -s http://127.0.0.1:8350/api/tickets/38 | jq '.ticket | {project, number, href, oldNumber}'   # old number still works

Query the store directly: curl -s http://127.0.0.1:8350/api/tickets | jq (the mpackdb files are binary; the API is the query tool).

Connecting an app to a project (ticket #21)

Another app (first: gitoria) is connected to ONE project; it then gets a key that reaches that project only. Code: lib/connections.hl, components/connect.hl, the connections line on the project page (components/ticket_list.hl), routes in project.hl. Data: storage/mpackdb/connections.db (created empty at the first start; no migration).

  1. The app sends the browser to GET /connect?app=<name>&label=<owner/repo>&return=<url>&state=<opaque> (label and state optional; the state comes back unchanged). The return URL must be https on worldapi.org or a subdomain — or start with an origin listed in the env TICKETS_CONNECT_ORIGINS (comma separated, for dev copies). Else 400. A valid request is stored (1 hour) and the browser goes to /connect/<nonce>.
  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 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). A second connection of the same app + label to the same project replaces the first (its key dies).
  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, shown once, only its sha256 is stored. Wrong / used / expired code → 400.
  4. The key: Authorization: Bearer tktc_… plus, on every write, X-Tickets-Identity: <ident public id of the person>. It works only on POST /api/projects/<slug>/tickets and POST /api/tickets (create), …/comments and …/state of tickets of ITS project — the same 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 logged in to tickets once and chosen a display name; the project's roles apply to them (use: comment, open ↔ review; edit / admin: more). The author of what is written is that person. A key on any other endpoint (settings, members, invites, edit, assign, relations, inbox, new project, disconnect) → 401; another project → 403; no / unknown identity → 403 naming the header. Reads need nothing.
  5. The project page shows "Connected to <app>: <label>" to everybody; an admin's Disconnect deletes the connection — the key is dead at once. GET /api/projects/<slug>/connections lists them; POST /api/projects/<slug>/connections/remove {"id"} (user token, admin) disconnects.

Short copy for app workers: docs/connect-apps.md (the architect copies it next to antcolony-docs).

Gate: node tests/connect.mjs (ports 8700 / 8701 + Chrome 8703–8709; an ident stub, a real headless Chrome) → 60 checks.

Design tokens (shared, ticket antcolony#3 — antcolony mission 021)

  • shared/tokens.hl declares the WorldAPI palette + semantic tokens as hl:web css variables, hand-written: static dark = var('rgb(25, 30, 35)'), static colorText = var(light), … (import { var } from 'hl:web/css'). It is a copy of the one source loreana:/media/STORAGE/projects/worldapi-tokens/tokens.hl (vendored like plugins/, no generator): edit it THERE, then cp /media/STORAGE/projects/worldapi-tokens/tokens.hl shared/tokens.hl in every app and restart it. Check: cmp /media/STORAGE/projects/worldapi-tokens/tokens.hl shared/tokens.hl. (2026-09-26: the apps' copy = gitoria's/ident's; it differs from the source only in 3 COMMENT lines that still say webex — update the source's comments, then the check is byte-exact again.)
  • components/styles.hl IMPORTS the tokens it uses (import { colorText, colorBorder, … } from '../shared/tokens.hl') and writes them as members: color = colorText, border = '1px solid ' + colorBorder. It sets only its accent: colorAccent = var(purple) (#c586c0). A token it uses must be in the import list, a new token must be added to tokens.hl (static) first.
  • hl:web names each token after its member (colorTextMuted → --color-text-muted), writes EVERY token of tokens.hl into the one :root (declaration order), then the app's colorAccent (a second --color-accent, later wins), and writes each use as var(--…). Components/JS may still use var(--color-…) strings — the custom property names are the same.
  • hl:web does this itself since hybriel#39 (the webex LOCAL PATCH of antcolony mission 021 is gone, antcolony mission 036).
  • A change of tokens.hl or of components/styles.hl root members needs a RESTART: the dev watcher re-analyses but the served sheet keeps the old values (measured, antcolony mission 021).
  • Deploy: shared/ is part of the app folder; deploy.sh's rsync sends it (proved in antcolony mission 021: deploy.sh excludes + debian:12-slim container → byte-identical /__hl/app.css).

Vendored Hybriel

hybriel master 06617221 (antcolony mission 074, 2026-10-03; adds the plugin allocators 3a781359 + 413f60e4 (#126: every plugin allocates with malloc via plugin_api.zig), mpackdb 2cb7ae5e (frees per-operation buffers), http1 773de63e (request owns its parse), f0ac2d2d (event order; plugin ABI field — bin and .so must match); no lambda semantics change). sha256 21059cc741459ded8a004d44ed17c071aa296609be9fd0359202383210d77bdb, built the same way from git archive 06617221 (~/scratch-074/src, removed). Old vendor (190aa11d) in .scratch/pre-074/. Gates on 8760–8769: TICKETS_GATE_PORT=8760 TICKETS_GATE_IDENT_PORT=8761 TICKETS_GATE_EXCHANGE_PORT=8762 TICKETS_GATE_CHROME_A=8763-8764 TICKETS_GATE_CHROME_B=8767-8768 TICKETS_GATE_CHROME_C=8769-8769 node tests/browser.mjs → 249/0; connect.mjs as a temp copy (app URL :8762, Chrome 8763–8769, TICKETS_CONNECT_PORT=8760 TICKETS_CONNECT_IDENT_PORT=8761) → 60/0. Memory: LONG=1 node .scratch/w074/mem.mjs <tag> <app tree> 8760 (live-storage copy in .scratch/w074/realdata/storage, deleted after the run; prints RSS + swap): new 315 → 335 (200) → 312 (1200) → 315 MiB (5700 pairs) — flat; old 190aa11d 319 → 358 → 451 → 743 MiB.

Before: hybriel master 190aa11d (antcolony mission 072, 2026-10-02; adds fc838894 GC correctness (string index / plugin error message read freed memory), 038d84b3 (#126 returned closure scopes collected), #127 (d98926c6, 04df4428); no lambda semantics change since 7eea0d32). bin/hybriel sha256 860f5e61878be75fe84a569eee03c95a25f2757940903b2ed0afee0871a23626, built read-only from git archive 190aa11d (~/scratch-072/src, removed) with /media/STORAGE/projects/hybriel/native/zig-toolchain/zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux-gnu.2.39 in native/; old copy (7eea0d32) in .scratch/pre-072/. Memory proof: node .scratch/w072/mem.mjs <tag> <app tree> <port> (needs a live-storage copy in .scratch/w072/realdata/storage, deleted after each run — re-tar it; env LONG=1 adds 3×1500): new 315 → 355 (200) → 418 (1200) → 706 MiB (5700), old 697 → 1365 → 1719 MiB. Before: hybriel master 7eea0d32 (antcolony mission 069, 2026-10-02; ≥ f685f240 = #126 collector also by bytes, includes #48 #112 #117 #119 #120). bin/hybriel sha256 f0d3019f46f83cbf119d0a426fab18394bfc6c02c58abaa725983fcc4bc734a0, built read-only (git -C /media/STORAGE/projects/hybriel archive master | tar -x -C ~/scratch-…/src, then cd native && /media/STORAGE/projects/termuplex/.tools/zig/zig build -Dtarget=x86_64-linux-gnu.2.39 -Doptimize=ReleaseFast). plugins/{core,crypto,data,fetch,fs,http,http1,mpackdb,proc,time,web} = master's — no local patch. Re-vendor = copy the binary + these plugins, run the gate. The previous copy (73267707, sha 9707e0cc…) is in .scratch/pre-069/ (the one before, 837fe120, in .scratch/pre-048/). Lambda parameters COPY their argument since hybriel #48 (like methods); &p makes it a reference. A lambda that fills a list/record it was given needs &: migrate.hl migrateProjects/Tickets/Events/Members = (&log, …) (without it the gate fails at "migration: at the first start the log says what was migrated"). Audit script .scratch/w069/audit.py <files> (param mutations + for (x of param) aliases). store.hl sortByUpdated(rows) sorts its copy and RETURNS it — callers use the return, fine. antcolony Mission 048: migrate.hl's compactNow() block (hybriel #110) removed (proof logs .scratch/w048/). Lesson (hybriel #122): a post-face session sync must keep the route params — repro .scratch/w048/repro/ (node repro.mjs <bin> must keep "you may edit"). The old webex generation (e565176b + LOCAL PATCHes #34 seed escape, #39 tokens) is backed up in .scratch/pre-036/ (bin, plugins, sources, tests).

  • Framework pages use content-hashed URLs /__hl/{hl-runtime.js,web/client.js,app.css}?v=… (hashed = immutable, bare = no-cache). Check: ~/scratch-036/hashcheck.sh tickets.worldapi.org TICKETS / (own server :8730).
  • Dropped with antcolony mission 036: plugins/webex + both LOCAL PATCHes, the NativeWebSocketServer listener (HL_HOST, #24), sessions.resolve(cookie) in /login/callback (→ req.session, #11), realSession() (→ session == null; hl:web refuses a forged trailing session itself, #16 — the gate's 12 forged checks accept that ack ok:false "the session parameter is filled by the server"), the hand URL encoder (→ encodeURIComponent, #14), import.hl's charCodeAt string order (→ a > b, #2), jsoncheck's refusal of VALID surrogate pairs (#15; lone surrogates still abort JSON.parse → still 400).
  • Kept on purpose: jsoncheck.hl (JSON.parse still aborts on bad input), localtime.hl (no time zones), hand insertion sorts (no list sort()), login.js once-per-document guard (harmless). The binary finds plugins/ beside the app (it walks up from the script's directory). tests/cdp.mjs + tests/ports.mjs are copies of the hybriel repo's tests/browser/.

Files

Code order (antcolony docs/code-order.md, mission 010): project.hl is the map, no other .hl in the root, one lib/ file per topic holding its central logic and every write to its tables; the API routes and the faces are thin wrappers. Imports only go downwards (Hybriel refuses an import cycle): util ← users ← projects ← tickets-helpers ← events ← tickets ← invites / connections ← api-helpers ← api ← project.hl.

File
project.hlTHE MAP: index comment (feature → file), config (PWA, port, env), routes, audience (who hears which push), migrate at start, the server
lib/tickets.hltickets + their relations: tickets and links tables, rows, finding (ticketAt, ticketByRef, ticketByKey), lists, inbox, create / comment / state / assign / edit, parent / blocked-by, removeMember (also unassigns), ticketRights
lib/tickets-helpers.hlstate names + old aliases (stateOf), a related ticket's ref, the relation view, the list's filter links
lib/events.hlthe history: events table (append only, putEvent), event rows + labels, page rows with Markdown blocks
lib/projects.hlprojects + members: projects / members tables, slugs (old ones keep resolving; reservedSlugs, ticket #25), roles use / edit / admin
lib/users.hlticket #7: users + tokens tables, the ident exchange, sessions → users, the login env, tagOf
lib/invites.hlident invites: mayInvite, createInvite, joinByInvite (the login callback's invite=)
lib/connections.hlticket #21: an app connected to one project (connections table, request → code → key)
lib/markdown.hlticket #6: Markdown text → blocks/spans (safe links); components/markdown.hl renders them
lib/mdview.hlticket #6: the Accept: text/markdown documents (one ticket / a list; relations #4)
lib/migrate.hlthe ticket #20 migration, idempotent, every start (the one file writing other topics' tables)
lib/import.hlthe AntColony ticket-file format + paragraphsOf (import tool and fix tool)
lib/api.hlthe function routes: JSON API, /login/callback, /connect, the old page URLs' 301s (/tickets/:ref, /projects/…) — thin wrappers
lib/api-helpers.hlreplies, query/body (readBody = JSON check + strict fields), token / app-key auth, list filters, the Markdown Accept check, the small HTML page
lib/jsoncheck.hlhand-written JSON syntax check (workaround for hybriel #12 — delete with its one call once JSON.parse can fail softly)
lib/util.hlshared small helpers: env, the storage dir, URLs, countOf / first / merged, insertion sorts, text checks, Vienna time
components/the shell (main.hl) and the pages; styles.hl = all CSS (imports the tokens from shared/tokens.hl, sets the accent)
components/you.hl/you: account + API tokens
login.jsthe selector bridge (plain JS, served at /login.js)
shared/md-editor.jsticket #12: <md-editor>, vendored copy of worldapi-components (served at /md-editor.js)
shared/tokens.hlthe WorldAPI tokens (webex var()), verbatim copy of worldapi-tokens/tokens.hl — README "Design tokens"
tools/import.hlthe import command (needs TICKETS_TOKEN; section "Import")
tools/fix-import-summaries.hl, tools/migrate-short-ids.hlone-offs (2026-09-24 summaries; ident#23 short ids)
tests/browser.mjsthe gate; tests/connect.mjs the connect gate; tests/identkit.mjs the gate's own ident
tests/oldstore.hlthe gate's old-format fixture for the migration
tests/localtime.hl, tests/markdown.hlprint Vienna renderings of DST edges / markdown.hl's blocks for MD_INPUT
tests/realdata-baseline.mjs, tests/realdata-compare*.pya cleanup answers the same: reads + a write sequence on a live-data copy (section "Test" → "Same output")
tests/shorturls-realdata.mjsticket #25 on a live-data copy: every project + N tickets on the short and the old URLs (section "Test" → "Short URLs on real data")
tests/projectids-realdata.pymission 012 on a live-data copy: old vs new API byte for byte, slug vs id byte for byte (section "Test" → "Project ids on real data")
tests/letcount.pycode-order counts: root .hl files, project.hl lines, let that should be plain
icons/antcolony mission 046: icon.svg (source), icon-192/512.png, apple-touch-icon.png, favicon.ico — README "PWA"
docker-compose.yml, deploy.shByrodin container; the deploy from Loreana (section "Deploy")
import/tickets/copies of the AntColony ticket files (source: byrodin)

History and worker briefs

  • LOG.md — append-only history, one dated line per step (moved here from the antcolony LOG on 2026-10-01).
  • missions/NNN-*.md — worker briefs for this app; reports/NNN-*.md — their reports (same name). Numbered per project since 2026-10-01 (antcolony#40); older text, code comments and commits use the old antcolony numbers → map: /media/STORAGE/projects/antcolony-docs/docs/mission-map.md (Byrodin: /CONTAINERS/projects/antcolony/docs/mission-map.md).