gitoriaLog in with ident

ident

All repositories: gitoria

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

ident.worldapi.org

ident: the login and identities for all worldapi apps. CONCEPT.md (the creator's) is the source of truth — read it first; nothing is built that it does not describe. Part of AntColony (byrodin /CONTAINERS/projects/antcolony/README.md, ticket #2; built in 6 pieces, tickets #24–#29).

Built so far — piece 1, ticket #24: the login to ident itself (email one-time code), the account, its identities, its time zone (page /). Piece 2, ticket #25: apps (page /apps), one identity id per app, the login button flow and the code exchange (see "How apps use ident"). Piece 3, ticket #26: the identity selector (<ident-selector>, /selector.js, same section). Piece 4, ticket ident#6 (mission 007 (old 013)): notifications — kinds, the send API, the inbox (/inbox) and the per-app page (/inbox/<connection id>); see "How apps send notifications". Not yet: DELIVERY — the daily mail at 17:00 and urgent mail (piece 5, ident#7), push (piece 6, ident#8). Nothing is mailed or pushed yet; ident stores what delivery will need.

Written in Hybriel on hl:web (hybriel master, since antcolony mission 036), same stack and conventions as /media/STORAGE/projects/tickets.worldapi.org.

Run (dev, Loreana)

cd /media/STORAGE/projects/ident.worldapi.org
setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null &  echo $! > server.pid
# stop: kill $(cat server.pid)
  • Port 8351 on 0.0.0.0 — http://100.77.141.84:8351 (tailnet), http://192.168.178.75:8351 (LAN).
  • The dev watcher is on: saving a .hl file reloads; restart after .env/binary changes and after changing components/styles.hl root members or shared/tokens.hl (the served sheet is not rebuilt).
  • The dev server sends REAL mail (SMTP via mail.byrod.in, .env — do not read or print it, it holds the SMTP password). Never trigger an OTP on :8351 in a test; tests start their own servers with IDENT_MAIL_SINK.
  • Test app (testapp/, :8354): see "How apps use ident" → "Try it".
  • Config: .env in the app folder (read from the cwd; the real environment outranks it):
VariableDefault
IDENT_PORT8351
IDENT_MAIL_SINK—file that gets <address> <code> appended per code INSTEAD of mail. Dev: storage/mail-sink.txt
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM— / 587hl:smtp; used when IDENT_MAIL_SINK is empty. Neither set → the code is only logged as NOT SENT
IDENT_STORAGE./storage/mpackdbtable DIRECTORY (<dir>/<table>.*); absolute for tools
IDENT_SESSIONS.sessions/session store
IDENT_OTP_TTL_MS, IDENT_SEND_WINDOW_MS, IDENT_SEND_LIMIT600000, 600000, 3clocks/limits (the gate shortens the OTP TTL)
IDENT_IP_LIMIT, IDENT_IP_WINDOW_MS10, 600000codes per client IP per 10 min (mission 006 (old 010))
IDENT_IP_DAY_LIMIT, IDENT_IP_DAY_WINDOW_MS30, 86400000codes per client IP per 24 h
HL_HOST (or HOST)0.0.0.0interface to bind; 127.0.0.1 on Byrodin (hl:web reads it itself, hybriel#24)
IDENT_WATCHon0 = no dev watcher (the container)
IDENT_GRANT_TTL_MS60000life of a login button's one-time code (tests/apps.mjs shortens it)

What it does (piece 1)

  • Login: email → 6-digit code (10 min, single use, 5 wrong tries kill it, a new request replaces the old code, max 3 codes per address per 10 min, max 10 codes per client IP per 10 min and 30 per 24 h) → signed in.
  • Per-IP limit (mission 006 (old 010)): the code request is POST /api/code {email} (the sign-in form fetches it; it is NOT a face any more — a face sees no request headers). The client IP is the X-Client-IP header only (nginx on Byrodin sets it and overwrites a client's: /CONTAINERS/web/nginx/conf.d/cloudflare-client-ip.conf); CF-Connecting-IP, X-Forwarded-For, X-Real-IP are never read. IPv6 counts per /64, ::ffff:a.b.c.d as the IPv4. No header (dev, no nginx) = ONE shared bucket direct (hl:web's req.remoteAddress would only be nginx's). Refusal: 429 {error} shown under the form ("too many codes were requested from your network …"); per address: 429 "too many codes were sent to this address …". Table ipsends {ip (bucket), at}. Only safe while ident is reachable through nginx alone (on Byrodin it binds 127.0.0.1) — anyone reaching it directly could send any header. Session cookie identsid (manifest sessionCookie, not hl:web's default hlsid: cookies ignore ports, tickets on :8350 uses hlsid).
  • The code step is its own page (ident#20, mission 008 (old 032); creator: "just make a /code where it checks a pending code"): after POST /api/code succeeds the page calls the face rememberPending(email) (records session.data.pendingEmail; refused unless a code for that address is really waiting — lib/login.hl codeWaiting) and goes to /code (app login: /signin/<rid>/code). That route (lib/api.hl codePage, a function route because a component route cannot redirect) shows Home's code form for the session's pending address while its code is waiting (unused, unexpired, < 5 wrong tries), else 302 back to / resp. /signin/<rid> (also when signed in, or a rid that is not 32 hex). So a reload, a second tab, a re-seed keeps the code form. Cleared on: sign-in (verifyCode), "Other address" (face forgetPending, → the email page), expiry / killed code (read as none). A wrong code keeps it. After sign-in the address bar is set back to / resp. /signin/<rid> (history.replaceState).
  • No registration: the first right code for an address creates the account and its default identity (identity name Default), then shows that identity's fields with "all of these fields are optional" and Save / Skip.
  • Identities: fields identity name, nickname, first name, last name — all optional (trimmed, ≤ 60 chars, no control characters). List, new, edit, delete. The last identity cannot be deleted. The default identity is the oldest remaining one (badge in the list). The list shows the identity name; without one: nickname, else first + last name, else Identity <n>.
  • Avatar (ticket #15, reworked after the creator asked for an upload): an optional avatar field on an identity — a picture uploaded in the identity form (file chooser, "Remove picture" to clear). avatar.js (route /avatar.js, included by components/main.hl) cuts it to a centred square, scales it to 128×128 in the browser and writes it as a data URL (WebP; JPEG if the browser cannot make WebP and PNG is too big) into the form's hidden #favatar; an input event hands it to the page (a page script cannot emit, hybriel #31). The server (lib/identities-helpers.hl checkAvatarUrl) accepts only data:image/(png|jpeg|webp);base64, with the type's magic bytes at the start, base64 characters only, ≤ 60000 characters — no links, no SVG. Stored in the identity record's avatar. Shown as a small round image in the identity list and the "choose an identity" list, and as a preview in the form. Old avatars that were http(s) links (first version) are no longer shown and are dropped on the next save. Apps do not get it yet — handed over once ticket #11 (property hand-over at first handshake) is built; until then apps fall back to the name's first letter. Lesson (hybriel #32/#41): no if block next to the text inputs of the form (recreates the form, loses focus) — the preview and the remove button are always rendered and toggled with a hidden CSS class from a plain reactive field.
  • Time zone: the browser's IANA zone (Intl…resolvedOptions().timeZone) is stored at the first login; shown and changeable (text field + "Use this browser's"). The page refuses names the browser does not know; the server checks the shape only (hl:time has no zones, hybriel #11). A later login does NOT overwrite it.
  • Apps (/apps, piece 2): any signed-in account registers apps — name + the origins it runs on (http(s)://host[:port], 1–10). Each app gets a public API key (pk_ + 32 hex) and a secret (sk_ + 48 hex) shown once (after register / "New secret"; only its sha256 is stored). List, edit (name, origins; the key stays), new secret (old one dies at once), delete (its per-app ids go with it). Only the owner sees/changes an app.
  • One public short id per identity (ident#23, replaces "one id per app"): every identity has a shortId — 5 characters like a68sz, random, made when the identity is made, kept forever, the same in every app. Alphabet: the digits 2–9 and the letters a–z without i, l, o (31 characters, 28.6 million ids; no 0/O, 1/l/I to mix up on the phone), lower case, unique (checked against an index when made; longer only if it ever runs out). Case does not matter when it is typed (A68SZ finds a68sz). The identity page shows it (selectable). Only the id is public — names, email and avatar stay as the identity sets them. Identities that existed before get theirs at the first start (lib/identities.hl backfillShortIds, touched from project.hl). The first login of an identity in an app still creates a connection (its creation time = "when they registered in it", for the per-app page); it holds no id of its own any more. Connections made before ident#23 keep their old 32-hex appIdentity only until the app has migrated (below).
  • Migration for the apps (ident#23): POST /api/migrate-ids {"key","secret","finish"?} → 200 {"ids":{"<old per-app id>":"<short id>"},"count":n,"finished":bool} (every connection of that app that still has an old id; an identity deleted since is left out; 401 wrong key/secret). The app's server calls it once and rewrites its stored users. Until then /api/notify also accepts the old id. With "finish":true the answer is the same and the old ids are dropped afterwards — from then on they are unknown (404) and the map is {}. Gate: tests/migration.mjs.
  • Ids (mission 005 (old 009), creator's convention): every table's key is the mpackdb UUID (@id, 12 chars like 0mufcrs6sb3v); accounts, identities and apps are addressed by it (faces take it as a string; an old numeric id finds nothing). "Oldest first" (the default identity, the app list) sorts by the stored created, never by key order.
  • JSON endpoints: POST /api/code (the login code, above), POST /api/exchange, POST /api/kinds, POST /api/notify (piece 4), and the selector's GET /api/selector/identities, POST /api/selector/choose (CORS, piece 3); other /api/* answer a JSON 404. The pages' server faces (components/home.hl, components/apps.hl) do all other writes. Face arguments are checked strictly (unknown field / wrong type → error naming the field) and every face only trusts a real framework session (see Lessons in STATUS.md — a forged trailing argument is refused).
  • Removed (mission 002 (old 005)): the mission-003 app login (/login?app=&return=, /continue/:rid, the origin allow list IDENT_ALLOWED_ORIGINS, testclient/); old files in .scratch/removed-mission003/. Piece 2 rebuilt /login and /api/exchange per the concept.
  • Session hardening (ticket #2): a signed-in session gets its OWN expiry, stamped at sign-in (lib/accounts.hl beginSession) and checked on every use (accountOfSession) — independent of the hl:web session file's own rolling idle/maxAge. IDENT_SESSION_TTL_MS (default 14 days). An older session (no expiry stamped yet, e.g. from before this ticket or the mission-009 migration) is upgraded on its next use, not force-signed-out. "Sign out everywhere" (/, account bar) bumps the account's session epoch, which invalidates every session of that account — the caller's own included — on its next check, without touching any other session file. Ticket #19: it also DELETES every session of the account at once (resident ones and files; lib/accounts.hl dropUserSessions on project.hl's server, reached through lib/accounts.hl sessionHooks) and pushes the signedOutAll event (audience: connections whose session is the account's) — components/main.hl reloads every open page on every device into the signed-out state, no click. Gate: tests/signoutall.mjs (:8702, two Chromes). "Sign out of this app" (the per-app page, /inbox/<connection id>) forgets one app connection; a later login gets the same short id again. Gate: tests/hardening.mjs.

Invites (ticket ident#22)

An app invites people to one of ITS projects through ident. CONCEPT.md does not describe it; the ticket is the spec. Code: lib/invites.hl (the person's login flow: lib/apps.hl openInvite / grantInvite), routes in lib/api.hl, table invites (lib/invites.hl). All calls are POST with a strict JSON body and the app's key + secret (401 otherwise):

POST /api/invites        {key, secret, project, role, return, uses?, days?, email?}
  200 {id, url, state:"open", project, role, uses, expires, mailed}
POST /api/invites/list   {key, secret, project?}  → {invites:[{id, project, role, state, uses, used, identities, expires, created}]}
POST /api/invites/get    {key, secret, id}        → {invite}
POST /api/invites/revoke {key, secret, id}        → {invite}   (409 unless it is open)
  • project / role: the app's own words (1–60 chars). return: where the person lands — like the login button, absolute http(s) on one of the app's origins. uses (default 1, up to 1000), days (default 7, up to 90). state: open, used (all uses taken), expired, revoked.
  • The link (<ident>/invite/<token>) is in the create answer ONLY (ident keeps its sha256). The app shows it (copy / share) itself. With email, ident also mails it through its own mailer (lib/mail.hl sendInvite; sink: <IDENT_MAIL_SINK>.invites); max 50 invitation mails per app per 24 h (IDENT_INVITE_MAIL_LIMIT, 429). The address is not stored and not tied to the login: whoever holds the link joins.
  • The person: GET /invite/<token> — used / expired / withdrawn / unknown give an error page (410 / 404) with a plain message; an open one is the login button's own flow: a login request carrying the invite → /signin/<rid> ("You are invited to <app>"), email → code if signed out, the identity choice (one click) if signed in. Choosing an identity accepts the invite (takes a use; the same identity again takes none) and sends the browser to <return>?ident_code=<code>&invite=<invite id>.
  • The app's server exchanges the code (/api/exchange, gives the identity id) and asks /api/invites/get for the invite: identities lists who accepted — check that the exchanged id is in it. Two people racing for the last use: the second is told "already used".
  • IDENT_PUBLIC_URL (compose: https://ident.worldapi.org) = where links point; unset → the request's Host. IDENT_INVITE_DAY_MS (gate only) shortens a "day". Gate: tests/invites.mjs (:8700/:8701). The test app has /invites.

How apps use ident (login button, piece 2)

  1. Register the app in ident: /apps → "Register an app": name + origin(s). Keep the API key (public) and the secret (server only; shown once).
  2. The button sends the browser to <ident>/login?key=<API key>&return=<percent-encoded return URL>. The return URL must be absolute http(s), no #, ≤ 2000 chars, and its origin one of the app's origins — else ident shows an error page (HTTP 400) and never redirects.
  3. ident parks the request (30 min) and shows /signin/<rid>: signs the browser in to ident if needed (email code; a first login shows the optional names first), then "Choose an identity" (also with one identity: it is shown, one click).
  4. ident redirects to <return URL>?ident_code=<code> (or &ident_code= if it has a query). The code is 48 hex, single use, 60 s, for this app only.
  5. The app's server exchanges it:
   POST <ident>/api/exchange   {"key":"pk_…","secret":"sk_…","code":"…"}
   200 {"identity":"a68sz"}            ← the identity's public short id, NOTHING else
   401 unknown key or wrong secret     400 unknown/used/expired code, code of another app
   400 invalid JSON, unknown/missing/wrong-type field (named); 405 not POST

A code presented by the wrong app is spent. The same identity always gets the same id, in every app (ident#23); a person can say it aloud and others find them by it. The app asks the user itself for any further data it needs.

The identity selector (piece 3)

Same app registration (key + secret, origins). The app's page includes ident's script and the element, configured with the app's public API key:

<script src="https://<ident>/selector.js"></script>
<ident-selector key="pk_…"></ident-selector>             <!-- add logged-in when the site's session is logged in -->
<script>
  const sel = document.querySelector('ident-selector');
  sel.addEventListener('ident-login', async (e) => {       // the user chose an identity = the login
    await fetch('/my-login', { method: 'POST', body: JSON.stringify({ code: e.detail.code }) });
    // → YOUR SERVER: POST <ident>/api/exchange {key, secret, code} → {identity}, keep it server side
    sel.loggedIn = true;                                   // the host tells the selector (or the attribute)
  });
  // logout on the website = reset the selector:  sel.loggedIn = false  /  sel.reset()
</script>
  • The element (shadow DOM, ident's design) first says "choose ident". Opening it fetches GET <ident>/api/selector/identities?key= with credentials (the browser's ident session cookie; same site only — SameSite=Lax). ident answers only when the request's Origin is one of the app's registered origins and the key is that app's: then Access-Control-Allow-Origin: <that origin> + …-Allow-Credentials: true (never *) and {"signedIn":true,"identities":[{"id","name"}]} — the identity name only; id is an opaque per-app pick id (not ident's id, not the short id). Otherwise 403 without CORS headers (the page gets nothing). Signed out of ident: {"signedIn":false,"identities":[]} → the selector says so and links to ident (new tab); opening it again re-asks.
  • Choosing an identity: POST <ident>/api/selector/choose?key= {"identity":"<id>"} (preflighted, same CORS rule; strict body) → {"code"} — the same one-time code as the login button's (48 hex, single use, 60 s, this app only). The element fires ident-login (event.detail.code, bubbles, composed) and closes. The host's server exchanges the code exactly as in step 5 above.
  • logged-in attribute / loggedIn property: set by the HOST (it knows its own session): the element shows "✓ logged in with ident" (+ the identity's name if chosen on this page) instead of the button. Removing it (loggedIn = false, reset()) = back to "choose ident". The selector sets no cookie and keeps no login state of its own.
  • Restyle from the host page: CSS custom properties on the element — --ident-accent, --ident-accent-text, --ident-background, --ident-text, --ident-text-strong, --ident-text-muted, --ident-border, --ident-radius, --ident-font — and ::part(button | panel | identity | status | message | link), e.g. ident-selector { --ident-accent: #569bd4 } ident-selector::part(button) { text-transform: uppercase }.

Try it (test app, testapp/, a separate hybriel app): http://100.77.141.84:8354 — register an app in ident with origin http://100.77.141.84:8354, paste key + secret into the test app's form, press "Log in with ident" — or, on the same page, "choose ident" (the selector; you must be signed in to ident at http://100.77.141.84:8351 in the same browser). The test app shows the id it got (a real app never would) and "new user"/"welcome back"; the selector switches it to "Logged in" without a reload, "Log out" resets the selector. Its own session cookie: testapp<port>sid. Its data: testapp/storage/testapp.json.

cd testapp && TESTAPP_PORT=8354 TESTAPP_URL=http://100.77.141.84:8354 IDENT_URL=http://100.77.141.84:8351 \
  setsid nohup ../bin/hybriel project.hl > testapp.log 2>&1 < /dev/null &  echo $! > testapp.pid

How apps send notifications (piece 4)

CONCEPT.md "Notifications" / "Per-app page". The app's SERVER calls ident with its API key + secret (never from a page) and the app-specific identity id it got from the exchange. Apps never get an email address. Strict JSON bodies (unknown/missing/wrong-type field → 400 naming it; invalid JSON 400; not POST 405; wrong secret / unknown key 401).

  1. Register the kinds (by name) with their preset channels — optional, but a kind must be registered before it can be pushed. Upsert: a name already there gets the new preset. Answers every registered kind. kinds: [] just lists them. ≤ 50 per call, ≤ 200 per app; a refused call writes nothing.
   POST <ident>/api/kinds  {"key":"pk_…","secret":"sk_…","kinds":[{"name":"New comment","push":true,"email":false}]}
   200 {"kinds":[{"name":"New comment","push":true,"email":false}]}
  1. Send a notification: name (1–60 chars, one line — the kind), text (1–2000 chars, Markdown by the worldapi convention; the inbox shows it as plain text with its line breaks), optional icon and link (absolute http(s) URLs ≤ 2000), optional urgent (bool).
   POST <ident>/api/notify {"key":"pk_…","secret":"sk_…","identity":"a68sz","name":"New comment",
                            "text":"Bea commented …","icon":"https://…/i.png","link":"https://…/post/7","urgent":false}
   200 {"id":"<notification id>"}
   404 unknown identity for this app   (the identity never logged in to this app, an unknown id;
                                        until the app has migrated, its old per-app ids work too)

An unregistered name is allowed: it becomes an unregistered kind with the concept's default (daily mail, no push); push stays impossible for it until the app registers it.

Effective settings (per connection = identity in app, per kind; stored in settings): the app's preset, replaced by the user's switch where the user set one; the user's override for all, when on, replaces both for every kind; push is always off for an unregistered kind. Channels of a notification, fixed when it arrives (stored on it, with pushSent/mailSent = false — delivery is pieces 5/6): normal → push = effective push, mail = daily if effective email else none; urgent → push if effective push AND a push device exists (none before piece 6), else mail = now if push or email is on for it, else none. Every notification lands in the inbox.

The user: /inbox (nav "Inbox") = all notifications of all identities, newest first (max 200 shown): name, app · identity, text, icon (referrerpolicy=no-referrer), "Open" (the action link, new tab), time (UTC — hl:time has no zones), urgent badge, read/unread toggle. Below: every app connection (app, identity, since) → per-app page /inbox/<connection id>: app, identity, "registered in this app since" (the connection's created), All notifications (Override all / Push / Email switches) and every kind of the app with Push and Email switches (effective state; "app default: …" under the name; an unregistered kind shows "—" for push; locked while the override is on). Each flip is saved at once. Faces: inboxMark, inboxSwitch, inboxOverride (session-checked, #31).

Try it (dev): the test app shows the identity id it got; with the app's key + secret:

curl -s -X POST http://100.77.141.84:8351/api/notify -H 'content-type: application/json' \
  -d '{"key":"pk_…","secret":"sk_…","identity":"<id>","name":"Hello","text":"First notification"}'

then open http://100.77.141.84:8351/inbox.

PWA (installable app, antcolony mission 046)

ident installs as an app (Chrome "Install", Android/iOS home screen) through hl:web's own support, as calendar does: project.hl declares appIcons, appThemeColor (token darker rgb(15,20,25) = the header's colour, as in every app), appBackgroundColor (token dark), appFavicon (/favicon.ico), appTouchIcon, offline = [ Start ] and appManifest = { start_url = '/start' }. hl:web generates /__hl/manifest.webmanifest (linked in every head with apple-touch-icon + theme-color) and the service worker /__hl/sw.js. No JavaScript of ours.

  • Icons icons/: icon.svg (the source: a key in orange on rgb(25,30,35), glyph inside the maskable safe zone), icon-192.png / icon-512.png (manifest, any and maskable), apple-touch-icon.png (180), favicon.ico (16/32/48, from favicon.svg = the same key cropped tighter). Re-render after editing the SVG: rsvg-convert -w 512 -h 512 icons/icon.svg -o icons/icon-512.png (192, 180 likewise); favicon: rsvg-convert -w N -h N icons/favicon.svg for 16/32/48, then magick fav-16.png fav-32.png fav-48.png icons/favicon.ico.
  • Offline, with NO personal data (ident is the identity provider; phones get shared): the worker keeps only the shell's assets and /start (components/start.hl, a page without any data) — never /, whose copy would hold the signed-in address and identities (hl:web caches a kept page's document as the server rendered it for that user, and its state in IndexedDB). The installed app starts at /start: online the shell's probe sends it on to / at once; offline it shows the header, "Offline — ident needs the network to sign in and to save" and an "Open ident" button, and opens / by itself once the probe gets an answer again. Every other page offline (/ included, e.g. a tab reload) is hl:web's data-free "Unavailable offline" page (503).
  • How the shell knows it is offline (components/main.hl): a zero-size <net-probe> runs a CSS animation (components/styles.hl ident-net-probe, starts 0.5 s after the page is up, repeats every 20 s); its animationstart / animationiteration handlers fetch GET /api/online (204, no-store — never cached by the worker). No answer → noNetwork = true → <offline-note id="offline"> in the header; the next answer clears it (and on /start goes on to /). (A View has no "mounted" event; a probe fired before hydration would be lost, hence the delayed animation.)

Test

node tests/browser.mjs      # THE GATE: 152 checks, ~40 s (ident#20 /code: 38 of them; PWA: 19). Own servers :8356 (ident) and :8357
                            # (OTP TTL 3 s, reached as localhost); own storage .scratch/gate-store;
                            # two Chromes on debug ports 8640-8659 (--disable-gpu), time zones
                            # emulated (Pacific/Auckland, Asia/Tokyo). Covers avatar: save/edit,
                            # live preview, list display, URL validation negatives (ticket #15)
                            # other ports: IDENT_GATE_PORT=8732 IDENT_GATE_SHORT_PORT=8733 IDENT_GATE_CHROME=8734-8739
                            # THE OTHER NINE GATES ON OTHER PORTS: temporary copies with the ports remapped, one by one:
                            #   node .scratch/w074/runall.mjs [apps selector migration iplimit notify hardening signoutall shortid invites]
                            #   (ports 8760-8767; outputs .scratch/w074/gate-<name>.txt; w072/runall.mjs = same on 8720-8727,
                            #   w048/runall.mjs on 8750-8757). Last run (074, hybriel 06617221): browser 152/0 with
                            #   IDENT_GATE_PORT=8760 IDENT_GATE_SHORT_PORT=8761 IDENT_GATE_CHROME=8762-8769, the nine all green
                            # PWA (antcolony mission 046): head links, manifest (start_url /start, theme = token darker), icons
                            # real PNGs, favicon ICO, /api/online 204, worker registered, installable; browser A SIGNED IN,
                            # then OFFLINE (tab AND the worker's own CDP target — the tab's emulation alone leaves the
                            # worker online): reload of / = "Unavailable offline" without the email; /start = shell +
                            # offline note, no email; EVERY worker cache entry + hl:web's IndexedDB scanned: no email, no
                            # identity; /apps unavailable; back online /start → / by itself.
                            # Screenshots .scratch/gate-phone-pwa-{offline,online}.png
node tests/apps.mjs         # PIECE 2 GATE: 107 checks, ~15 s (ident#20 /signin/<rid>/code: 8). Own servers: ident :8370, ident :8371
                            # (code TTL 1.5 s), test apps :8372/:8373; storage .scratch/apps-gate;
                            # two Chromes on 8670-8679. Screenshots .scratch/apps-*.png
node tests/selector.mjs     # PIECE 3 GATE: 99 checks, ~10 s. Own servers: ident :8390, test apps
                            # :8391/:8392, a node page on :8393 (an unregistered origin);
                            # storage .scratch/selector-gate; Chromes on 8690-8699.
                            # Screenshots .scratch/selector-*.png
node tests/shortid.mjs      # SHORT ID GATE (ident#23): 19 checks, ~10 s, HTTP only. Own ident :8706 (storage
                            # .scratch/shortid-gate): every identity's id, unique, same in two apps, notify, migrate-ids
node tests/migration.mjs    # MIGRATION GATE (mission 005 (old 009) + ident#23): 33 checks, ~10 s. Old-format fixture
                            # (tests/oldstore-009.hl) → tools/migrate-009.hl twice on a copy →
                            # ident :8395 + test app :8396 on the result: old session cookies
                            # still signed in, stored per-app ids come out of the exchange again
                            # (Chrome, login button → "welcome back"), pre-migration code still
                            # works. Storage .scratch/migration-gate; Chrome on 8710-8719
node tests/notify.mjs       # PIECE 4 GATE (mission 007 (old 013)): 130 checks, ~20 s. Own ident :8410 +
                            # a node "app site" :8413 (icon + link target); storage
                            # .scratch/notify-gate (the stored state is read off COPIES with
                            # tools/dump-store.hl); Chromes on 8740-8749.
                            # Screenshots .scratch/notify-{inbox,inbox-read,appsettings,override}-*.png
node tests/iplimit.mjs      # PER-IP GATE (mission 006 (old 010)): 39 checks, ~15 s. Own servers: ident :8400
                            # (HL_HOST=127.0.0.1, IP limit 3), :8401 (IP window 2 s, day limit 5);
                            # storage .scratch/iplimit-gate; Chromes on 8720-8739, each with its
                            # own X-Client-IP (CDP). Screenshots .scratch/iplimit-refused-*.png
# the other gates run their servers with IDENT_IP_LIMIT/IDENT_IP_DAY_LIMIT=1000 (no header =
# one shared bucket, and they request many codes)
node tests/hardening.mjs    # ticket #2: 17 checks. node tests/signoutall.mjs  # ticket #19: 6 checks
# tests/dev-smoke.mjs: DO NOT RUN any more — the dev server sends real mail now
ps -eo pid,args | grep [h]l-browser-tier    # must print nothing afterwards

The gate covers, in real browsers: signed-out login page, first login → account + default identity + optional-names form, Skip (A) and names filled (B), reload keeps the session, edit, cancel, second and third (empty) identity, delete with a dismissed and an accepted confirm, deleting the default one, the last one cannot be deleted, time zone from the browser at first use / change / unknown zone refused / "Use this browser's" / not reset by a later login, sign out; OTP rules (lowercasing, wrong-code countdown, 5 tries, replaced code, rate limit, single use, expiry on the short-clock server); face negatives over POST /__hl/emit (no session, unknown/wrong-type/too-long/control-char fields, another account's identity, forged session argument); the removed routes are 404; palette; no console errors; 390px/1280px without horizontal overflow. Screenshots: .scratch/gate-*.png — look. ident#20 (mission 008 (old 032)), the code page /code: "Send me a code" lands on /code; GET /code without a pending code / without a cookie / signed in → 302 /; SSR of /code (plain GET with the cookie) = the code form + address; a real Page.reload keeps the code form (390/1280 px, .scratch/gate-*-code-reloaded.png); a second tab of the same browser shows it too; a wrong code

  • reload keeps it; one mail only; the code typed after the reloads signs in (welcome, URL /); signed out → /code lands on the email form; "Other address" → /, server forgot it, reload = email form; 5 wrong codes → reload lands on /; face rememberPending refuses an address without a waiting code, a number, a forged trailing session, accepts (normalised) an address with a waiting code — for that session only; forgetPending; /signin/xyz/code and a CR/LF rid → 302 /; /signin/<rid>/code with nothing pending → 302 /signin/<rid>; short-clock server: reload right after asking = code form, expired code → reload lands on /, expiry without a try → /. tests/apps.mjs adds (app login): "Send me a code" → /signin/<rid>/code, reload keeps the code form under the app banner (.scratch/apps-code-for-app-reloaded-*.png), the code signs in (URL back to /signin/<rid>) and the app flow completes; "Other address" → /signin/<rid> email form, a reload stays there, /signin/<rid>/code with nothing pending → /signin/<rid>. The other gates' login helpers wait for the /code page to hydrate before typing the code. (Ticket #37, mission 004 (old 007): its two checks of the removed mission-003 routes now check that the old shapes are refused by the piece-2 routes: POST /api/exchange {"code"} → 400 missing field: key; /login?app=… → 400 error page, no redirect. 81 passed.)

tests/apps.mjs covers, in real browsers: /apps signed out; register (bad origin refused, secret shown once, not after reload), list, edit; test app setup; login button signed in with one and with two identities (same identity + app → same id, other app → other id, other identity → other id, the answer is only {identity}); signed out → ident login → (first login: optional names, then) choice → back; login negatives (foreign origin, the other app's origin, user@host trick, javascript:, missing/unknown key, unknown request id); exchange negatives (wrong secret/unknown key 401, reused, other app's code, expired, unknown, invalid JSON, not an object, missing/unknown/wrong-type field, surrogate escape, GET); forged session argument on every new face (#31), strict app fields, another account cannot touch an app or use an identity; new secret (dismissed/accepted confirm, old secret 401, the test app with the old secret fails visibly), delete (its key → error page); no console errors; 390/1280 px without overflow.

tests/selector.mjs covers, in real browsers: signed in to ident on ident's origin → the test app's page (own origin) shows the selector ("choose ident", ident's colours), open → the identities by name, the answer holds only {id, name} (no email, opaque ids) → choose → ident-login code → host exchange → logged in WITHOUT reload (a window marker survives), logged-in set, status shows the identity, no cookie from the selector → reload: the host renders logged-in → host logout resets (no reload) → other identity → other id; the button flow gives the same id as the selector; app B → other ids and other pick ids; host restyle (custom properties and ::part) changes computed styles; negatives: unregistered origin (same site, so the cookie travels — the Origin check alone stops it; the page's own fetch is CORS-blocked, the preflight too), another app's key / an unknown key, signed out of ident (second browser, and after sign-out), 403 without CORS headers for bad/missing/null/prefix origins, no key, made-up cookie, preflight good/bad, strict choose body (unknown/missing/wrong-type field, invalid JSON, a forged session field), ident's own identity id or another app's pick refused, another account cannot choose alice's identity, code reuse / other app's code / spent code, 405s, forged face sessions on 8 faces (#31); console clean; 390/1280 px without overflow, closed and open.

tests/notify.mjs covers: the app registers kinds (strict body, 401s, refused call writes nothing, re-register changes presets), sends normal/urgent, with/without icon + link, a registered and an unregistered name, to two identities in two apps (+ another account); send negatives (wrong secret, unknown key, another app's id, ident's own ids, unknown/missing/ wrong-type fields, bad URLs, control characters, surrogate escape, GET); stored channels per notification (push/daily/now/none, not delivered); in Chrome: inbox (all identities, newest first, app · identity, text line breaks, icon loaded, link, urgent badge, time, unread count), mark read / unread (survives reload, stored); per-app page (since = connection created, all kinds, presets, unregistered = no push), flip switches → stored effective settings change, survive reload, a later notification follows them; override on (locked switches, all kinds follow, user switches kept) and off again; forged/no session on the 3 faces (#31), bob can't see/mark/switch alice's, alice can't switch bob's, unknown connection, signed out; bob's own Chrome sees only his; console clean; 390/1280 px without overflow.

tests/iplimit.mjs covers: two IPs via X-Client-IP (the limit hits one, not the other); X-Forwarded-For / CF-Connecting-IP / X-Real-IP / Forwarded beside it change nothing; lower case / blanks; no header = one shared bucket (forwarded headers do not split it), empty header = none; IPv6 /64 buckets, IPv4-mapped; per-address limit across IPs; refused codes are not mailed; the old requestCode face is gone; 405 / strict body / invalid JSON; the short window expires, the day limit holds; HL_HOST=127.0.0.1 not reachable on the LAN address, without it 0.0.0.0 as before; two real Chromes (one IP each): A's 4th code refused VISIBLY on the page (390/1280 px, no overflow), B signs in, reload keeps the session.

Same output (code-order cleanups, mission 009)

A cleanup must answer exactly as before. tests/realdata-baseline.mjs runs a CODE-ONLY copy of a tree (no .env) on a COPY of the live storage and saves every answer: 187 reads (every page signed in as the creator — session file for account 0mufd5afy0xm, identity az5b2 — and signed out, the browser modules, every live app's /login and selector variants, the refused API calls) and 104 writes (identities, time zone, an app + secret, the login button + exchange, the selector, kinds + notifications, inbox switches, invites, migrate-ids, the OTP login of a new and of the creator's address into a mail sink, sign out (everywhere), app delete, then the stored state). tests/realdata-compare.py masks only what differs per run (times, random ids / codes / keys, source positions, module hashes) and compares two outputs; two runs of the same tree compare clean (291/291).

# the live copy (from Byrodin; holds real addresses — keep it in .scratch, delete it afterwards)
tar -C /CONTAINERS/projects/ident.worldapi.org/storage -cf - mpackdb | ssh loreana tar -C <repo>/.scratch/m009/realdata -xf -
git archive <old commit> | tar -x -C .scratch/m009/old && cp -a bin .scratch/m009/old/    # the old tree, code only
rsync -a --exclude=/.env --exclude='/.env.*' --exclude=/.scratch/ --exclude=/storage/ --exclude=/.sessions/ --exclude=/.git/ ./ .scratch/m009/new/
node tests/realdata-baseline.mjs .scratch/m009/old 8750 .scratch/m009/rd-old     # ~5 s each (port 8750 only)
node tests/realdata-baseline.mjs .scratch/m009/new 8750 .scratch/m009/rd-new
python3 tests/realdata-compare.py .scratch/m009/rd-old .scratch/m009/rd-new [-v]   # exit 0 = same output
python3 tests/letcount.py .                                                        # root .hl files, project.hl lines, lets

All ten gates on ports 8750–8759 only (browser with IDENT_GATE_PORT=8750 IDENT_GATE_SHORT_PORT=8751 IDENT_GATE_CHROME=8752-8759, the nine others as port-remapped temporary copies, one by one): TAG=<step> .scratch/m009/gates.sh (outputs .scratch/m009/gate-<name>-<step>.txt); .scratch/m009/step.sh <step> = code copy + real-data run + compare + all gates.

Deploy (Byrodin)

Target: /CONTAINERS/projects/ident.worldapi.org on Byrodin, container ident.worldapi.org (docker-compose.yml: debian:12-slim, host network, HL_HOST=127.0.0.1, IDENT_PORT=45002, IDENT_WATCH=0, the folder mounted at /home/ident, ./bin/hybriel project.hl), public https://ident.worldapi.org/ via nginx (TLS ends there; no baseUrl/tls in the app, like notes).

  • First deploy: done by the architect (folder, .env with SMTP on Byrodin, nginx vhost with WebSocket Upgrade headers and proxy_set_header X-Client-IP $client_ip;, cert, DNS).
  • Later: ./deploy.sh on Loreana, in this folder: runs the six gates (refuses on a failure; --skip-tests skips them 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/ident.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://ident.worldapi.org/ to answer 200. Every step is printed.
  • ./deploy.sh --dry-run = gates + rsync -n + the commands it would run (no restart, no URL check). --target DIR|HOST:DIR and --url URL point it elsewhere (tested only against a local directory: see STATUS "mission 006 (old 010)").
  • Session cookie identsid: HttpOnly; SameSite=Lax, no Secure (hl:web sets it — checked 2026-09-26: identsid=…; Path=/; HttpOnly; SameSite=Lax; Max-Age=1209600; it works on https — checked behind a local TLS proxy). The selector's CORS is exact-origin: register apps with their https origin (https://tickets.worldapi.org); the http:// twin is refused.

Data

storage/mpackdb/{accounts,identities,otp,sends,ipsends,apps,connections,requests,grants,kinds,settings,notifications}.* (hl:mpackdb, pk @id UUID; no admin UI/API). Tables and fields: the headers of lib/accounts.hl, lib/identities.hl, lib/login.hl, lib/apps.hl, lib/invites.hl, lib/notify.hl (piece 4: kinds, settings, notifications — new tables, created empty at the first start; no migration). Opening a table rewrites its files (hybriel #40): only ever open a COPY. Everything as JSON (on a copy; the output holds apps' secret HASHES — keep it in .scratch):

rm -rf /tmp/identcopy && cp -a storage/mpackdb /tmp/identcopy
IDENT_STORAGE=/tmp/identcopy ./bin/hybriel tools/dump-store.hl | python3 -m json.tool | less

Quick look without opening: strings storage/mpackdb/accounts.mpack. Dev mail: storage/mail-sink.txt. Mission-003 data: .scratch/storage-backup-20260924-060627/.

Migration 009 (*id → UUID, done 2026-09-24)

The old tables storage/ident-<table>.* (pk *id, public ids = id + 1) stay in storage/ as a backup; nothing reads them. tools/migrate-009.hl (one-off, idempotent: rows carry oldId = the old public id; a second run writes nothing) copied every row of every table, rewrote the references to the new UUIDs, kept apiKey, secretHash, appIdentity, rid, grant hashes byte-identical, and rewrote the session files' "user":{"id":<n>} to the account's UUID (the creator stayed signed in). Run it only on a COPY of the old tables with the server stopped (header of the tool). A connection of an identity deleted before the migration keeps its per-app id with identity = 'gone:<old id>'. Selector pick ids (sha256(secret hash : identity id)) changed once with the identity ids — harmless, the selector re-fetches them on every open.

Files

Code order (antcolony docs/code-order.md, ident mission 009): project.hl is the MAP (an index comment, config, routes, wiring) and the only .hl file in the root; ONE lib/ file per topic holds its central logic and every write to its tables, its noise (checks, formatting) is in lib/<topic>-helpers.hl, shared small helpers in lib/util.hl; the function routes (lib/api.hl) and the faces (components/) are thin wrappers. let only where a variable is reassigned (or Hybriel needs it: loop bodies, a name that is also a member of the file, a name declared twice in one function — python3 tests/letcount.py .). Imports go one way: util ← identities-helpers ← identities ← accounts ← login-helpers ← login; util ← apps-helpers ← invites-helpers ← invites ← apps ← selector / notify ← api-helpers ← api ← project.hl.

File
CONCEPT.mdthe creator's concept — do not edit
project.hlTHE MAP: feature → file index, PWA settings (appIcons, offline = [ Start ], start_url /start, README "PWA"), routes (/, /signin/:rid, /apps, /inbox, /inbox/:cid, /start, the function routes of lib/api.hl, /code + /signin/:rid/code → codePage with the server by reference, /selector.js, /avatar.js, /favicon.ico + /icons/*), the mailer instance, cookie name, the listener (HL_HOST), the push audience, sessionHooks.dropUser
lib/login.hlthe login: tables otp / sends / ipsends, startLogin (limits per address and per client IP), checkCode (the first right code creates the account), signInWithCode (face verifyCode), the pending sign-in codeWaiting / recordPending / pendingOf / dropPending (ident#20)
lib/login-helpers.hlnewOtp, otpHash (codes stored as sha256 only), ipBucket (the per-IP limit's bucket)
lib/accounts.hltable accounts, createAccount (+ its default identity), sessions: beginSession, accountOfSession (own expiry, epoch), signOutEverywhere + sessionHooks, dropUserSessions (ticket #19), setTimeZone
lib/identities.hltable identities: short ids (freshShortId, identityByShortId, backfillShortIds, ident#23), identityRecords / identityRows, ownIdentity, create / update / delete (the last one stays), addDefaultIdentity
lib/identities-helpers.hlstrict identity fields checkFields, the avatar checkAvatarUrl / shownAvatar (ticket #15), labelOf, the short id's alphabet newShortId / normShortId
lib/apps.hltables apps / connections / requests / grants: apps (create, edit, new secret, delete → its connections + invites), appOfSecret (the app's key + secret), the login button (openRequest, requestOf, grantLogin, issueCode: the one-time code of both flows, exchange), an invite's login (openInvite, grantInvite, grantRequest for the face chooseIdentity), disconnectConnection, connectionByAnyId, migrateIds (ident#23)
lib/apps-helpers.hlthe app form checkAppInput, checkOrigin, checkReturn (a return URL against the app's origins), newKey / newSecret, originsOf, appRowOf
lib/selector.hlthe selector's pick ids (pickOf), the list (selectorList), the choice → code (codeOfPick); its two CORS endpoints are in lib/api.hl
selector.jsthe browser half: <ident-selector> custom element (shadow DOM), served at /selector.js
lib/invites.hltable invites (ident#22): state, create (+ mail limit), list, get, revoke, dropInvitesOf (app deleted), openableInvite, acceptInvite
lib/invites-helpers.hlcheckText, isWhole, acceptedList / usedCount, problemOf
lib/notify.hlpiece 4: tables kinds / settings / notifications, registerKinds, effective settings (effectiveOf, storeEffective), sendNotification, the inbox (inboxRows, markRead) and the per-app page (connectionRows, settingsPage, setSwitch, setOverride)
lib/notify-helpers.hlchecks of a kind name / text / URL, stamp, newestFirst, byName, switchValue, onOff, unreadCount
lib/mail.hlhl:smtp Mailer + IDENT_MAIL_SINK (codes; invitations → <sink>.invites)
lib/api.hlTHE FUNCTION ROUTES, thin: /login, /api/exchange, /api/migrate-ids, /api/code, /api/selector/* (CORS), /api/kinds, /api/notify, /api/invites*, /invite/:token, codePage, /api/online (204, the offline probe), /api/* 404
lib/api-helpers.hlJSON replies, redirect, strict JSON body, the login button's error page, inviteCaller (key + secret + body), baseOf, hexId, the selector's CORS answers and caller check
lib/jsoncheck.hlJSON syntax pre-check (copied from tickets, hybriel #12)
lib/util.hlshared small helpers: dir (IDENT_STORAGE), envNumber, countOf / first / merged / str, isId / isList / isObject, oldestFirst, hasControl, urlChars, normEmail / validEmail, checkTimeZone
components/home.hl/ and /signin/:rid (and, rendered by codePage with step = 'code' + pending (the address), /code + /signin/:rid/code): login, welcome, identities, time zone, the identity choice for an app
components/start.hl/start: the installed app's start page, data-free, the only page kept offline (antcolony mission 046)
components/apps.hl/apps: register / list / edit / new secret / delete apps
components/inbox.hl/inbox: the notifications of all identities, read/unread, the app connections
components/appsettings.hl/inbox/:cid: the per-app page (since, override, per-kind push/email switches)
components/main.hlshell (header, offline probe, live sign-out)
components/styles.hlall CSS (imports the tokens from shared/tokens.hl; accent colorAccent = var(orange) = #ce9178, danger colorDanger = var(--red) = #f44747)
shared/tokens.hlthe WorldAPI tokens (hl:web var()), the hl:web copy all apps share (see "Design tokens"); README "Design tokens"
avatar.jsthe avatar upload's browser half (crop, scale, data URL), served at /avatar.js
icons/app icons: icon.svg source, 192/512 PNG (any + maskable), apple-touch-icon.png, favicon.svg → favicon.ico
testapp/the minimal app using the login button and the selector (:8354)
tests/browser.mjs, tests/apps.mjs, tests/selector.mjsgates of pieces 1, 2, 3; dev-smoke.mjs retired (real mail); cdp.mjs/ports.mjs copied from tickets
tests/migration.mjs, tests/oldstore-009.hlthe migration gate and its old-format fixture (mission 005 (old 009); also the short-id migration, ident#23)
tests/shortid.mjsthe short id gate (ident#23)
tests/iplimit.mjsthe per-IP limit gate (mission 006 (old 010))
tests/notify.mjsthe piece-4 gate (mission 007 (old 013))
tests/invites.mjsthe invite gate (ident#22)
tests/signoutall.mjsticket #19 gate: sign out everywhere pushes to open pages on other devices, session files deleted (:8702, Chrome debug 8703-8709)
tests/hardening.mjsticket #2 gate: session own-expiry, sign out everywhere, sign out of an app (own servers :8700/:8701)
tests/realdata-baseline.mjs, tests/realdata-compare.pySAME OUTPUT on a live-data copy, two code trees compared (mission 009; README "Test" → "Same output")
tests/letcount.pythe code-order counts: root .hl files, project.hl lines, every let by kind
tools/migrate-009.hlone-off migration *id → UUID + sessions (done; kept for the record)
tools/dump-store.hlevery table as JSON — on a COPY only
docker-compose.yml, deploy.shByrodin container; the deploy from Loreana (section "Deploy")

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; 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(orange) (#ce9178). 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). bin/hybriel sha256 21059cc741459ded8a004d44ed17c071aa296609be9fd0359202383210d77bdb, built the same way from git archive 06617221 into ~/scratch-074/src (removed). Old vendor (190aa11d) in .scratch/pre-074/. Gates: browser 152/0 + the nine via node .scratch/w074/runall.mjs (8760–8767) all green. Memory: LOADS=2500 node .scratch/w074/memtest.mjs <appdir> <mpackdb copy> <label> 0mufd5afy0xm (port 8760, Chrome 8762–8769; marks = RSS every 500 loads): new 211 → 212 MB flat over 2500 loads, old 190aa11d 211 → 246 MB.

Before: hybriel master 190aa11d (antcolony mission 072, 2026-10-02; adds fc838894 GC correctness (string index t[k] and plugin error messages could read freed memory), 038d84b3 (#126 returned closure scopes collected), #127 big SSR pages (d98926c6, 04df4428); no lambda/parameter semantics change since 8efba065, so no new & audit). bin/hybriel sha256 860f5e61878be75fe84a569eee03c95a25f2757940903b2ed0afee0871a23626, built read-only from git archive 190aa11d into ~/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 vendor (8efba065) in .scratch/pre-072/. Gates: browser 152/0 + the nine others via node .scratch/w072/runall.mjs (ports 8720–8727). Memory, live-data copy (mpackdb = storage/mpackdb, NOT storage/ — the top-level ident-* files are pre-009 leftovers), account 0mufd5afy0xm (4 identities), 200 Chrome loads: RSS 211 → 216 MB, 35 ms avg (old 8efba065: 282 → 305 MB, 41 ms) — node .scratch/w072/memtest.mjs <appdir> <mpackdb copy> <label> <account id> (port 8720, Chrome 8722–8729).

Before: hybriel master 8efba065 (antcolony mission 069, 2026-10-02; adds #126 = f685f240 (interpreter GC also collects by bytes), #48 = 4371b7aa (a lambda parameter COPIES its argument), #112 #117 #119 #120 (opt-in, not used)). bin/hybriel sha256 ce4c7507f65a856f6088c7c120a0033c5d22647a47997d5ea7c3f923b2bdf4eb, built read-only (git -C /media/STORAGE/projects/hybriel archive master | tar -x -C ~/scratch-069-ident/src, then cd native && /media/STORAGE/projects/termuplex/.tools/zig/zig build -Dtarget=x86_64-linux-gnu.2.39 -Doptimize=ReleaseFast). plugins/ = master's core crypto data fetch fs http http1 mpackdb proc smtp time web — no local patch. Re-vendor = copy the binary + these plugins, run every gate. Previous vendor (73267707) in .scratch/pre-069/ (bin, plugins, project.hl, README, STATUS); older ones in .scratch/pre-048/ (837fe120), .scratch/pre-036/ (webex).

  • Lesson of the 069 re-vendor (hybriel #48): a lambda parameter is a COPY — the framework's Session instance too. Every lambda that writes the session it is handed takes &session: (since mission 009) lib/accounts.hl beginSession, accountOfSession; lib/login.hl recordPending, pendingOf, dropPending, signInWithCode; lib/api.hl selectorIdentities, selectorChoose (pass it on to accountOfSession); lib/accounts.hl dropUserSessions and lib/api.hl codePage take the server as &srv. Without &: sign-in never sticks (browser gate aborts at "code page"), the old-session upgrade is lost (hardening 16/1), /code shows a dropped address (browser 148/4). Audit tool: python3 .scratch/w069/lambdas.py (every lambda, flags params it writes) + lambdas2.py (params handed on / aliased). Memory (#126), live-data copy, 200 Chrome page loads: RSS boot 311 MB / after 354 MB (old binary 600 / 1287 MB) — .scratch/w069/memtest.mjs <appdir> <mpackdb copy> <label> <account id>.
  • Lessons of the 048 re-vendor (each broke a gate):
  • server.page(match, session, host, headers) since hybriel #105 — project.hl codePage called the old page(m, req, s), which still compiles and renders an EMPTY card (req taken as the session).
  • A member whose initializer reads session is re-derived on the server after EVERY face that takes the session and synced to the page (hybriel 64527baa). Home's pendingEmail read pendingOf(session): after the 5th wrong code it became null and the code form vanished (dead end). codePage now hands the address in as the param pending. Lists (identities, apps, inbox) are replaced whole by the faces' answers, never pushed to → no double rows (hybriel#121); gate checks "listed ONCE" in browser/apps/invites.
  • A fresh *id mpackdb table counts from 1 (hybriel #4, 5bb08628): tests/oldstore-009.hl writes explicit ids 0, 1, … so the fixture still looks like the real old store (public id = id + 1).
  • Framework pages use content-hashed URLs: /__hl/hl-runtime.js?v=…, /__hl/web/client.js?v=…, /__hl/app.css?v=… (Cache-Control: public, max-age=31536000, immutable; bare /__hl/app.css = no-cache). Check: ~/scratch-036/hashcheck.sh ident.worldapi.org IDENT / (own server :8730, temp storage).
  • A forged trailing session argument is refused by hl:web itself (hybriel#16): the ack is ok:false, "… the session parameter is filled by the server, never by the peer" — the face never runs. The gates' forged-session checks (20: browser 4, apps 5, selector 8, notify 3) accept that refusal (framework_refused) or the app's own {error}. realSession() is gone; faces check session == null.
  • Kept on purpose: lib/mail.hl placeholder host 127.0.0.1 (a Mailer must exist for deliver()/handlers), lib/jsoncheck.hl (JSON.parse still aborts on bad input), X-Client-IP for the IP limit (behind nginx req.remoteAddress is nginx), hand sorts (no list sort()), avatar.js guard.

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).