tickets
All repositories: gitoria
- 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_PORTmoves 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@ideverywhere — creator's convention;TICKETS_STORAGE=<dir>moves them). Sessions:.sessions/(TICKETS_SESSIONS). The OLD global-numbered tablesstorage/tickets-*(before ticket #38) stay as a read-only backup — nothing reads them. - The framework's dev watcher is on: saving a
.hlfile re-analyses and reloads open tabs. A restart is needed after changingcomponents/styles.hlroot rules,shared/tokens.hl, env vars or the binary. HL_HOST(orHOST): interface to bind, default 0.0.0.0;127.0.0.1on Byrodin. hl:web reads it itself (hybriel#24; until antcolony mission 036project.hlbuilt the listener).TICKETS_WATCH=0turns 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 (
projectstable): 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 (
memberstable, 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
createdByandassignee(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,assignevent). The inbox (/inbox,GET /api/inboxwith 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),createdByfrom 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:
isIdentIdinusers.hlaccepts old 64-hex and new short ids (ident#23);tools/migrate-short-ids.hl+tests/short-id-switch.mjskept. - Gate:
tests/browser.mjs(233 checks). The pre-roles browser checks (creator-only confirm, author-only edit, relations by author, tokens page …) are PARKED intests/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/exchangewith key + secret) for the per-app identity id. Selector:login.jsbridges theident-loginDOM event into the shell's faceidentLogin— no reload, the pages show their forms (signedInpush to this session's tabs). Button:/login/callbacksets the session and redirects back to the page the login started from (ticket #10: at the clicklogin.jsadds?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 facelogOut; the selector is reset (classout→loggedIn = false). login.jsruns 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 forident-loginondocument, always looks up the CURRENT#selector, and hands each one-time code to#identcodeonly 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 onlyuser = { id = <users @id> }(hl:webex shipssession.userto the page); the identity id is never in a page except the user's own/you. - Authors: new events store
user(+ the name asauthor); the history shows the user's display name. Old events keep their free-textauthor. The web forms and the API have no author field (API: a body withauthor→ 400 naming it). - Creator:
TICKETS_CREATOR_IDENTITY= the creator's per-app identity id. Only that user may setconfirmed/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 putsTICKETS_CREATOR_IDENTITY=<id>into/CONTAINERS/projects/tickets.worldapi.org/.envand restarts the container./youthen says "You are the creator". - API tokens (
storage/mpackdb/tokens.*): on/youa 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, originhttps://tickets.worldapi.org),TICKETS_CREATOR_IDENTITY— all in.envon Byrodin (the hybriel runtime loads.envbeside project.hl; the real environment wins; never commit it).IDENT_URL(defaulthttps://ident.worldapi.org: selector script + button),IDENT_EXCHANGE_URL(server side; default = IDENT_URL;docker-compose.ymlsetshttp://127.0.0.1:45002= ident's loopback port on Byrodin),TICKETS_PUBLIC_URL(defaulthttps://tickets.worldapi.org: the button's return URL). - ident down: a login answers "ident did not answer" (the global
on Errorin project.hl absorbs hl:fetch's failure — it logserror 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.hlparses the text into plain data (blocks → spans),components/markdown.hlbuilds elements from it — every character ends up in a TEXT node, no HTML string exists, so raw HTML /<script>shows as text. Links only whensafeHrefaccepts them:http(s):,mailto:,/path(not//),#…;javascript:,data:,vbscript:etc. stay text. Understood:#…######headings (shown as h3/h4/h5),-/*/+and1./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,oldSummaryMdvialib/events.hl pageEventOf); API JSON unchanged. - Read view for LLMs:
Accept: text/markdownonGET /api/tickets,/api/projects/:slug/tickets,/api/tickets/:ref,/api/projects/:slug/tickets/:number→text/markdown(+Vary: Accept), built bylib/mdview.hl: one ticket =# <project> #n: <subject>, a meta line, URL, the summary as written,## Historywith 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 toapplication/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
createdevent) 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 eventkind:"edit"witholdSubject/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 vendoredplugins/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 firesinputas 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/changeevents 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,cpit here;cmpthem). 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 }incomponents/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 withORACLE_APP); pasted rich text is reduced to it; an untouched text comes back byte for byte. Keepmarkdown.hland 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 isconfirmed. 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 eventkind:"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 keysisLink, 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 ornull),children,blockedBy,blocks(lists of refs, oldest link first) andallChildrenConfirmed. 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 · urlline 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 anyblockedBy[].stateis not what the scheduler counts as done. A report'sissues[].blocks = true→ create the issue ticket, thenPOST <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'sdarkerrgb(15, 20, 25)(as tracker),appBackgroundColor= darkrgb(25, 30, 35)(both fromshared/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.hloffline,#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.hltickets-net-tick) and everyanimationiterationreadsnavigator.onLine(same trick as tracker). - Icons
icons/:icon.svgis 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.icoEach 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.shon Loreana, in this folder: runs the gate (refuses on a failure;--skip-testsskips it LOUDLY), backs upstorage//.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(neverstorage/,.sessions/,.env,.scratch/,server.*,testapp/, logs — the preview is checked for them; no--delete),docker compose up -d && docker compose restartoverssh -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 URLpoint 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:.envneedsIDENT_API_KEY,IDENT_API_SECRET, laterTICKETS_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…#39in docs and comments) are kept on the migrated tickets asoldNumber; 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 bycreated. - Migration 2026-09-24 (
tools/migrate-008.hl, idempotent, header explains it): old #N →<project> #kby 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.hlprojectHrefOf/ticketHref— every page link and thehref/projectHrefof 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,settingsHrefunchanged). - 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;idinGET /api/projectsdetailsand inGET /api/projects/<x>project). It never changes, also not when the slug does.lib/projects.hl slugOfRefturns 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 = trueinproject.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/:ticketNumberstill answered them with a ticket page (measured; the gate fails 15 checks and stops). So project.hl moves thelastPageroutes to the end ofserver.router.routesright afternew 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.hlreservedSlugs, 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 → 400field:"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 slugsMission 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 PIDOld 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 0Masked (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
| Route | Component | |
|---|---|---|
/ | components/ticket_list.hl | all tickets, newest update first; project + state filter chips; "New ticket" form |
/project/:projectName, /state/:stateSlug, /project/:p/state/:s | same | filtered (state slug: in-progress, awaiting-creator, on-hold, …) |
/:projectSlug/:ticketNumber (last, ticket #25) | components/ticket.hl | ticket, 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.hl | the project's page (= /project/:projectName) |
/projects/:slug, /projects/:slug/:number, /projects/:slug/tickets/:number | function routes | 301 → /<slug> / /<slug>/<n> (current slug), else 404 (ticket #25) |
/tickets/:ref | function route | 301 → /<p>/<n> (:ref = old global number or UUID), else 404 |
/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) |
/you | components/you.hl | the logged-in user: display name, own per-app identity id, creator or not; API tokens (create / list / revoke) |
/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) |
/login.js | file | the 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 + path | Body | Answer |
|---|---|---|
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 it | 201 { 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 worksQuery 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).
- The app sends the browser to
GET /connect?app=<name>&label=<owner/repo>&return=<url>&state=<opaque>(labelandstateoptional; the state comes back unchanged). The return URL must be https onworldapi.orgor a subdomain — or start with an origin listed in the envTICKETS_CONNECT_ORIGINS(comma separated, for dev copies). Else 400. A valid request is stored (1 hour) and the browser goes to/connect/<nonce>. - 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). - The app's SERVER swaps the code:
POST /api/connect/exchange {"code": "…"}→200 {key, project (slug), title, api}. The key istktc_+ 48 hex, shown once, only its sha256 is stored. Wrong / used / expired code → 400. - The key:
Authorization: Bearer tktc_…plus, on every write,X-Tickets-Identity: <ident public id of the person>. It works only onPOST /api/projects/<slug>/ticketsandPOST /api/tickets(create),…/commentsand…/stateof 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. - 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>/connectionslists 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.hldeclares 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 sourceloreana:/media/STORAGE/projects/worldapi-tokens/tokens.hl(vendored likeplugins/, no generator): edit it THERE, thencp /media/STORAGE/projects/worldapi-tokens/tokens.hl shared/tokens.hlin 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.hlIMPORTS 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'scolorAccent(a second--color-accent, later wins), and writes each use asvar(--…). Components/JS may still usevar(--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 ackok:false"thesessionparameter 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 listsort()),login.jsonce-per-document guard (harmless). The binary findsplugins/beside the app (it walks up from the script's directory).tests/cdp.mjs+tests/ports.mjsare copies of the hybriel repo'stests/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.hl | THE MAP: index comment (feature → file), config (PWA, port, env), routes, audience (who hears which push), migrate at start, the server |
lib/tickets.hl | tickets + 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.hl | state names + old aliases (stateOf), a related ticket's ref, the relation view, the list's filter links |
lib/events.hl | the history: events table (append only, putEvent), event rows + labels, page rows with Markdown blocks |
lib/projects.hl | projects + members: projects / members tables, slugs (old ones keep resolving; reservedSlugs, ticket #25), roles use / edit / admin |
lib/users.hl | ticket #7: users + tokens tables, the ident exchange, sessions → users, the login env, tagOf |
lib/invites.hl | ident invites: mayInvite, createInvite, joinByInvite (the login callback's invite=) |
lib/connections.hl | ticket #21: an app connected to one project (connections table, request → code → key) |
lib/markdown.hl | ticket #6: Markdown text → blocks/spans (safe links); components/markdown.hl renders them |
lib/mdview.hl | ticket #6: the Accept: text/markdown documents (one ticket / a list; relations #4) |
lib/migrate.hl | the ticket #20 migration, idempotent, every start (the one file writing other topics' tables) |
lib/import.hl | the AntColony ticket-file format + paragraphsOf (import tool and fix tool) |
lib/api.hl | the function routes: JSON API, /login/callback, /connect, the old page URLs' 301s (/tickets/:ref, /projects/…) — thin wrappers |
lib/api-helpers.hl | replies, query/body (readBody = JSON check + strict fields), token / app-key auth, list filters, the Markdown Accept check, the small HTML page |
lib/jsoncheck.hl | hand-written JSON syntax check (workaround for hybriel #12 — delete with its one call once JSON.parse can fail softly) |
lib/util.hl | shared 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.js | the selector bridge (plain JS, served at /login.js) |
shared/md-editor.js | ticket #12: <md-editor>, vendored copy of worldapi-components (served at /md-editor.js) |
shared/tokens.hl | the WorldAPI tokens (webex var()), verbatim copy of worldapi-tokens/tokens.hl — README "Design tokens" |
tools/import.hl | the import command (needs TICKETS_TOKEN; section "Import") |
tools/fix-import-summaries.hl, tools/migrate-short-ids.hl | one-offs (2026-09-24 summaries; ident#23 short ids) |
tests/browser.mjs | the gate; tests/connect.mjs the connect gate; tests/identkit.mjs the gate's own ident |
tests/oldstore.hl | the gate's old-format fixture for the migration |
tests/localtime.hl, tests/markdown.hl | print Vienna renderings of DST edges / markdown.hl's blocks for MD_INPUT |
tests/realdata-baseline.mjs, tests/realdata-compare*.py | a cleanup answers the same: reads + a write sequence on a live-data copy (section "Test" → "Same output") |
tests/shorturls-realdata.mjs | ticket #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.py | mission 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.py | code-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.sh | Byrodin 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).