ident
All repositories: gitoria
43.9 KB
# ident.worldapi.orgident: the login and identities for all worldapi apps. **`CONCEPT.md` (the creator's) isthe 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 in6 pieces, tickets #24–#29).Built so far — **piece 1, ticket #24**: the login to ident itself (email one-time code), theaccount, 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 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 mission 036), same stack and conventions as`/media/STORAGE/projects/tickets.worldapi.org`.## Run (dev, Loreana)```bashcd /media/STORAGE/projects/ident.worldapi.orgsetsid 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 afterchanging `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 theirown 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):| Variable | Default | ||---|---|---|| `IDENT_PORT` | 8351 | || `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` | — / 587 | hl:smtp; used when `IDENT_MAIL_SINK` is empty. Neither set → the code is only logged as NOT SENT || `IDENT_STORAGE` | `./storage/mpackdb` | table DIRECTORY (`<dir>/<table>.*`); absolute for tools || `IDENT_SESSIONS` | `.sessions/` | session store || `IDENT_OTP_TTL_MS`, `IDENT_SEND_WINDOW_MS`, `IDENT_SEND_LIMIT` | 600000, 600000, 3 | clocks/limits (the gate shortens the OTP TTL) || `IDENT_IP_LIMIT`, `IDENT_IP_WINDOW_MS` | 10, 600000 | codes per client IP per 10 min (mission 010) || `IDENT_IP_DAY_LIMIT`, `IDENT_IP_DAY_WINDOW_MS` | 30, 86400000 | codes per client IP per 24 h || `HL_HOST` (or `HOST`) | 0.0.0.0 | interface to bind; `127.0.0.1` on Byrodin (hl:web reads it itself, hybriel#24) || `IDENT_WATCH` | on | `0` = no dev watcher (the container) || `IDENT_GRANT_TTL_MS` | 60000 | life 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 requestreplaces the old code, max 3 codes per address per 10 min, **max 10 codes per client IPper 10 min and 30 per 24 h**) → signed in.* **Per-IP limit** (mission 010): the code request is `POST /api/code {email}` (the sign-inform fetches it; it is NOT a face any more — a face sees no request headers). The client IPis 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 wererequested 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 nginxalone** (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 032; creator: "just make a /code whereit 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 thataddress is really waiting — `store.hl codeWaiting`) and goes to **`/code`** (app login:**`/signin/<rid>/code`**). That route (`project.hl codePage`, a function route because acomponent route cannot redirect) shows Home's code form for the session's pending addresswhile 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 itsdefault 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 identitycannot 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` fieldon an identity — a picture **uploaded** in the identity form (file chooser, "Remove picture" toclear). `avatar.js` (route `/avatar.js`, included by `components/main.hl`) cuts it to a centredsquare, scales it to 128×128 in the browser and writes it as a data URL (WebP; JPEG if the browsercannot make WebP and PNG is too big) into the form's hidden `#favatar`; an `input` event hands it tothe page (a page script cannot emit, hybriel #31). The server (`store.hl checkAvatarUrl`) acceptsonly `data:image/(png|jpeg|webp);base64,` with the type's magic bytes at the start, base64characters 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 apreview in the form. Old avatars that were http(s) links (first version) are no longer shown andare dropped on the next save. Apps do not get it yet — handed over once ticket #11 (propertyhand-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 thefirst login; shown and changeable (text field + "Use this browser's"). The page refuses namesthe 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 itruns 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 itssha256 is stored). List, edit (name, origins; the key stays), new secret (old one dies atonce), 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 hasa `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 (31characters, 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 whenit is typed (`A68SZ` finds `a68sz`). The identity page shows it (selectable). Only the id ispublic — names, email and avatar stay as the identity sets them. Identities that existedbefore get theirs at the first start (`store.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 hasmigrated (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 ofthat 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` alsoaccepts the old id. With `"finish":true` the answer is the same and the old ids are droppedafterwards — from then on they are unknown (404) and the map is `{}`. Gate: `tests/migration.mjs`.* **Ids** (mission 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 defaultidentity, 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 Lessonsin STATUS.md — a forged trailing argument is refused).* **Removed (mission 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 atsign-in (`store.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 ticketor 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, whichinvalidates 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 theaccount at once (resident ones and files; `project.hl` `dropUserSessions`, reached through`store.hl` `sessionHooks`) and pushes the `signedOutAll` event (audience: connections whosesession is the account's) — `components/main.hl` reloads every open page on every device intothe 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 idagain. 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; theticket is the spec. Code: `invites.hl`, routes in `project.hl`, table `invites` (`apps.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 thelogin 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). Theapp shows it (copy / share) itself. With `email`, ident also **mails it through its own mailer**(`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 requestcarrying 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 APIkey (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 theapp'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 identif needed (email code; a first login shows the optional names first), then **"Choose anidentity"** (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 else401 unknown key or wrong secret 400 unknown/used/expired code, code of another app400 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, inevery app (ident#23); a person can say it aloud and others find them by it. The app asks theuser 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 andthe element, configured with the app's **public API key**:```html<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 loginawait 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 sidesel.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 identsession cookie; same site only — `SameSite=Lax`). ident answers **only** when therequest'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 thelogin 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 serverexchanges the code exactly as in step 5 above.* **`logged-in`** attribute / `loggedIn` property: set by the HOST (it knows its ownsession): the element shows "✓ logged in with ident" (+ the identity's name if chosen onthis 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 intothe test app's form, press "Log in with ident" — or, on the same page, **"choose ident"** (the selector; youmust be signed in to ident at http://100.77.141.84:8351 in the same browser). The test appshows the id it got (a real app never would) and "new user"/"welcome back"; the selectorswitches it to "Logged in" without a reload, "Log out" resets the selector. Its own sessioncookie: `testapp<port>sid`. Its data: `testapp/storage/testapp.json`.```bashcd 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 → 400naming 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 mustbe registered before it can be **pushed**. Upsert: a name already there gets the newpreset. 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}]}```2. **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'sdefault (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 **overridefor 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` ifeffective email else `none`; urgent → push if effective push AND a push device exists (nonebefore piece 6), else `mail = now` if push or email is on for it, else `none`. Everynotification 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/unreadtoggle. 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 theapp with Push and Email switches (effective state; "app default: …" under the name; anunregistered kind shows "—" for push; locked while the override is on). Each flip is saved atonce. 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:```bashcurl -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.## Test```bashnode tests/browser.mjs # THE GATE: 132 checks, ~35 s (ident#20 /code: 38 of them). 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)node tests/apps.mjs # PIECE 2 GATE: 105 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-*.pngnode 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-*.pngnode 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-idsnode tests/migration.mjs # MIGRATION GATE (mission 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-8719node tests/notify.mjs # PIECE 4 GATE (mission 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}-*.pngnode tests/iplimit.mjs # PER-IP GATE (mission 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 nowps -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 + defaultidentity + optional-names form, **Skip** (A) and **names filled** (B), reload keeps thesession, edit, cancel, second and third (empty) identity, delete with a dismissed and anaccepted confirm, deleting the default one, the last one cannot be deleted, time zone fromthe browser at first use / change / unknown zone refused / "Use this browser's" / not reset bya 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, anotheraccount's identity, forged session argument); the removed routes are 404; palette; no consoleerrors; 390px/1280px without horizontal overflow. Screenshots: `.scratch/gate-*.png` — look.**ident#20 (mission 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 withthe 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 withouta waiting code, a number, a forged trailing session, accepts (normalised) an address with a waitingcode — 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 rightafter 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 codeform under the app banner (`.scratch/apps-code-for-app-reloaded-*.png`), the code signs in (URL backto `/signin/<rid>`) and the app flow completes; "Other address" → `/signin/<rid>` email form, areload 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 007: its two checks of the removed mission-003 routes now check thatthe 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 inwith 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, theother app's origin, `user@host` trick, `javascript:`, missing/unknown key, unknown requestid); 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 accountcannot touch an app or use an identity; new secret (dismissed/accepted confirm, old secret401, 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 → thetest 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: thehost renders `logged-in` → host logout resets (no reload) → other identity → other id; thebutton 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; thepage'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 forbad/missing/`null`/prefix origins, no key, made-up cookie, preflight good/bad, strict choosebody (unknown/missing/wrong-type field, invalid JSON, a forged `session` field), ident's ownidentity 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 writesnothing, re-register changes presets), sends normal/urgent, with/without icon + link, aregistered 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 pernotification (push/daily/now/none, not delivered); in Chrome: inbox (all identities, newestfirst, 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, allkinds, presets, unregistered = no push), flip switches → stored effective settings change,survive reload, a later notification follows them; override on (locked switches, all kindsfollow, user switches kept) and off again; forged/no session on the 3 faces (#31), bob can'tsee/mark/switch alice's, alice can't switch bob's, unknown connection, signed out; bob's ownChrome 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), emptyheader = none; IPv6 /64 buckets, IPv4-mapped; per-address limit across IPs; refused codesare not mailed; the old `requestCode` face is gone; 405 / strict body / invalid JSON; theshort window expires, the day limit holds; `HL_HOST=127.0.0.1` not reachable on the LANaddress, without it 0.0.0.0 as before; two real Chromes (one IP each): A's 4th code refusedVISIBLY on the page (390/1280 px, no overflow), B signs in, reload keeps the session.## 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`), publichttps://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 withWebSocket 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 afailure; `--skip-tests` skips them LOUDLY), 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`, thenwaits 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 URLcheck). `--target DIR|HOST:DIR` and `--url URL` point it elsewhere (tested only against alocal directory: see STATUS "mission 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 workson https — checked behind a local TLS proxy). The selector's CORS is exact-origin: registerapps 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 `store.hl`and `apps.hl`, `notify.hl` (piece 4: `kinds`, `settings`, `notifications` — new tables, createdempty 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):```bashrm -rf /tmp/identcopy && cp -a storage/mpackdb /tmp/identcopyIDENT_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 theaccount's UUID (the creator stayed signed in). Run it only on a COPY of the old tables withthe server stopped (header of the tool). A connection of an identity deleted before themigration 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| File | ||---|---|| `CONCEPT.md` | the creator's concept — do not edit || `project.hl` | manifest: routes (`/`, `/signin/:rid`, `/apps`, `/inbox`, `/inbox/:cid`, `/login`, `/api/code`, `/api/exchange`, `/api/kinds`, `/api/notify`, `/api/selector/*`, `/selector.js`, `/api/*` 404, `/code` + `/signin/:rid/code` → `codePage`), cookie name, the listener (`HL_HOST`) || `apps.hl` | tables apps / connections / requests / grants and the login button + exchange rules (`issueCode`: the one-time code of both flows) || `selector.hl` | the selector's two CORS endpoints (origin + key check, pick ids, choose → code) || `selector.js` | the browser half: `<ident-selector>` custom element (shadow DOM), served at `/selector.js` || `store.hl` | tables accounts / identities / otp / sends / ipsends and all rules (codes stored as sha256 only; `ipBucket`, the per-IP limit; the pending sign-in `codeWaiting`/`recordPending`/`pendingOf`/`dropPending`, ident#20) || `components/home.hl` | `/` and `/signin/:rid` (and, rendered by `codePage` with `step = 'code'`, `/code` + `/signin/:rid/code`): login, welcome, identities, time zone, the identity choice for an app || `components/apps.hl` | `/apps`: register / list / edit / new secret / delete apps || `components/main.hl` | shell (header) || `api.hl` | JSON replies, redirect, strict JSON body, the login button's error page || `jsoncheck.hl` | JSON syntax pre-check (copied from tickets, hybriel #12) || `testapp/` | the minimal app using the login button and the selector (:8354) || `mail.hl` | hl:smtp Mailer + `IDENT_MAIL_SINK` || `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`; accent `colorAccent = var(orange)` = #ce9178, danger `colorDanger` = `var(--red)` = #f44747) || `shared/tokens.hl` | the WorldAPI tokens (hl:web `var()`), the hl:web copy all apps share (see "Design tokens"); README "Design tokens" || `tests/browser.mjs`, `tests/apps.mjs`, `tests/selector.mjs` | gates of pieces 1, 2, 3; `dev-smoke.mjs` retired (real mail); `cdp.mjs`/`ports.mjs` copied from tickets || `tests/migration.mjs`, `tests/oldstore-009.hl` | the migration gate and its old-format fixture (mission 009; also the short-id migration, ident#23) || `tests/shortid.mjs` | the short id gate (ident#23) || `tools/migrate-009.hl` | one-off migration `*id` → UUID + sessions (done; kept for the record) || `tools/dump-store.hl` | every table as JSON — on a COPY only || `tests/iplimit.mjs` | the per-IP limit gate (mission 010) || `notify.hl` | piece 4: tables kinds / settings / notifications, the send + kinds rules, effective settings, inbox and per-app page data || `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) || `tests/notify.mjs` | the piece-4 gate (mission 013) || `tests/signoutall.mjs` | ticket #19 gate: sign out everywhere pushes to open pages on other devices, session files deleted (:8702, Chrome debug 8703-8709) || `tests/hardening.mjs` | ticket #2 gate: session own-expiry, sign out everywhere, sign out of an app (own servers :8700/:8701) || `docker-compose.yml`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") |## Design tokens (shared, ticket antcolony#3 — 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 everyapp 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 thatstill say webex — update the source's comments, then the check is byte-exact again.)* `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 onlyits 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 EVERYtoken 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 mission 021 is gone, mission 036).* A change of tokens.hl or of styles.hl root members needs a RESTART: the dev watcher re-analysesbut the served sheet keeps the old values (measured, mission 021).* Deploy: `shared/` is part of the app folder; `deploy.sh`'s rsync sends it (proved in mission 021:deploy.sh excludes + debian:12-slim container → byte-identical `/__hl/app.css`).## Vendored Hybriel**hybriel master 837fe120** (mission 036, 2026-09-26; includes #103/#104 = d9aa12e7..0c285350).`bin/hybriel` sha256 `9e5e95b33680eb68a016e9e48a76c9f92193fd2ffdbdf437c1aab1bda2731a0d`, built read-only(`git -C /media/STORAGE/projects/hybriel archive master | tar -x -C ~/scratch-036/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. Old webex generation (e565176b + LOCAL PATCHes#34 seed escape, #39 tokens) backed up in `.scratch/pre-036/` (bin, plugins, sources, tests).* 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 neverruns. The gates' forged-session checks (20: browser 4, apps 5, selector 8, notify 3) accept thatrefusal (`framework_refused`) or the app's own `{error}`. `realSession()` is gone; faces check `session == null`.* Kept on purpose: `mail.hl` placeholder host 127.0.0.1 (a Mailer must exist for `deliver()`/handlers),`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.
Branches
- mainmain branch
Latest commits
- 81b15b7bState of 2026-09-27, before the move to gitoriamre