ident
All repositories: gitoria
19.0 KB
// project.hl — ident.worldapi.org: THE APP. Concept: CONCEPT.md (the creator's).// Piece 1 (#24): the login to ident itself, the account, its identities, its time zone —// page `/` (components/home.hl). Piece 2 (#25): apps (page `/apps`, components/apps.hl),// one identity id per app, and the LOGIN BUTTON flow (README "How apps use ident"):// GET /login?key=<api key>&return=<url> → error page, or → /signin/<rid>// /signin/<rid> sign in to ident, choose the identity,// → <return url>?ident_code=<one-time code>// POST /api/exchange { key, secret, code } → { identity } (the identity's short id only)// Piece 3 (#26): THE IDENTITY SELECTOR (selector.hl, selector.js; README "How apps use ident"):// GET /selector.js the script an app's page includes// GET /api/selector/identities?key= the signed-in user's identities (CORS)// POST /api/selector/choose?key= {identity} → { code } (the same one-time code)// Mission 010: THE CODE REQUEST is a function route, not a face, because a face gets no// request (no headers), and the per-IP limit needs the client's IP:// POST /api/code { email } → { email } | 400/429 { error }// ident#20 (mission 032): THE CODE PAGE — after "Send me a code" the browser goes to// GET /code, /signin/<rid>/code the code form for this session's pending// address, or → / resp. /signin/<rid>// Deploy (Byrodin): HL_HOST=127.0.0.1 binds loopback only (hl:web reads HL_HOST, hybriel#24),// IDENT_PORT the port.import WebFramework from 'hl:web'import { Response } from 'hl:http1'import { env } from 'hl:proc'import Styles from './styles.hl'import { dark, darker } from './shared/tokens.hl'import { reply, fail, redirect, strictBody, errorPage } from './api.hl'import { openRequest, exchange, migrateIds } from './apps.hl'import { selectorIdentities, selectorChoose } from './selector.hl'import { createInvite, listInvites, revokeInvite, ownInvite, inviteRow, openInvite, publicUrl } from './invites.hl'import { appOfSecret, registerKinds, sendNotification } from './notify.hl'import Mail from './mail.hl'import { startLogin, sessionHooks, pendingOf, accountOfSession, shortIdsBackfilled } from './store.hl'import { listDir, readFile, remove } from 'hl:fs'import { sendCode, sendInvite } from './mail.hl'// ident#23: statics run on first use — touching it here gives every older identity its short id at bootconsole.log('ident: ' + shortIdsBackfilled + ' identities got their short id')import Home from './components/home.hl'import Start from './components/start.hl'import Apps from './components/apps.hl'import Inbox from './components/inbox.hl'import AppSettings from './components/appsettings.hl'static siteName = "ident"appTitle = siteNamestyles = Styles// ---- THE INSTALLABLE APP (mission 046, as calendar#3): hl:web generates the web app manifest// (/__hl/manifest.webmanifest, linked in every head with the apple-touch-icon and theme-color)// from these, and the service worker (/__hl/sw.js) from `offline`. No JavaScript of ours.// Icons: icons/ (icon.svg is the source, README "PWA"). Theme colour = the header's colour (token// `darker`), background `dark` — the same in every app (mission 046); the icon carries the accent.appThemeColor = darker.valueappBackgroundColor = dark.valueappFavicon = '/favicon.ico'appTouchIcon = '/icons/apple-touch-icon.png'appIcons = [{ src = '/icons/icon-192.png' sizes = '192x192' purpose = 'any' }{ src = '/icons/icon-512.png' sizes = '512x512' purpose = 'any' }{ src = '/icons/icon-192.png' sizes = '192x192' purpose = 'maskable' }{ src = '/icons/icon-512.png' sizes = '512x512' purpose = 'maskable' }]// OFFLINE, WITHOUT PERSONAL DATA: the worker keeps only `/start` (components/start.hl, a page// with no data — never `/`, whose copy would hold the signed-in address and identities) and the// shell's assets. The installed app starts at `/start` (appManifest); online the shell's probe// (main.hl) sends it on to `/`, offline the header says it is offline. Every other page offline// is the worker's "Unavailable offline" (README "PWA").offline = [ Start ]appManifest = { start_url = '/start' }// ---- THE LOGIN BUTTON: GET /login?key=<api key>&return=<url> ---------------------------// An unknown key or a return URL outside the app's origins → an error page, NEVER a// redirect. Otherwise the request is parked under a random id (a page component cannot// read the query, hybriel #18) and the browser goes to /signin/<rid>.appLogin = (route, req) => {if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }let q = req.query != null ? req.query : {}let r = openRequest(q.key, q['return'])if (r.error != null) { return errorPage(400, 'Bad login request', r.error) }return redirect('/signin/' + r.rid)}// ---- POST /api/migrate-ids { key, secret, finish? } → { ids: { <old per-app id>: <short id> } } (ident#23)// the APP'S SERVER moves its stored users to the identities' short ids in one step; with// `finish: true` the old per-app ids are dropped afterwards (they are gone for good).apiMigrateIds = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } finish = { type = 'Boolean' required = false } })if (b.error != null) { return fail(400, b.error) }let a = appOfSecret(b.body.key.trim(), b.body.secret.trim())if (a == null) { return fail(401, 'unknown API key or wrong secret') }return reply(200, migrateIds(a, b.body.finish == true))}// ---- POST /api/exchange { key, secret, code } → { identity } ---------------------------// the APP'S SERVER trades the ident_code from its return URL for the app-specific// identity's short id. Nothing else is handed out (CONCEPT.md "More fields").apiExchange = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } code = { type = 'String' required = true } })if (b.error != null) { return fail(400, b.error) }for (k of ['key' 'secret' 'code']) {if (b.body[k].trim() == '') { return fail(400, 'missing field: ' + k) }}let r = exchange(b.body.key.trim(), b.body.secret.trim(), b.body.code.trim())if (r.error != null) { return fail(r.status, r.error) }return reply(200, { identity = r.identity })}// ---- THE SELECTOR's two calls (selector.hl): the ident user is the cookie's -------------// (hl:web hands a function route the cookie's session as req.session, hybriel#11)apiSelectorIdentities = (route, req) => { return selectorIdentities(req, req.session) }apiSelectorChoose = (route, req) => { return selectorChoose(req, req.session) }// ---- POST /api/code { email } → { email }: THE ONE WAY TO GET A LOGIN CODE MAILED --------// (the sign-in form of `/` and `/signin/<rid>` fetches it). Limits: per address (3 / 10 min)// and per client IP (store.hl ipBucket: 10 / 10 min, 30 / 24 h) → 429 with the reason.// THE CLIENT IP is the X-Client-IP header and nothing else: nginx on Byrodin sets it and// overwrites any the client sent (/CONTAINERS/web/nginx/conf.d/cloudflare-client-ip.conf).// CF-Connecting-IP / X-Forwarded-For / X-Real-IP are NEVER read (a client could forge them).// Without the header (dev, no nginx) every request shares ONE bucket ('direct'):// (hl:web's req.remoteAddress, hybriel#25, would be nginx's address behind the proxy.)apiCode = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let b = strictBody(req, { email = { type = 'String' required = true } })if (b.error != null) { return fail(400, b.error) }let r = startLogin(b.body.email, req.headers['x-client-ip'])if (r.error != null) { return fail(r.limited == true ? 429 : 400, r.error) }sendCode(r.email, r.code)return reply(200, { email = r.email })}// ---- NOTIFICATIONS (piece 4, notify.hl; README "How apps send notifications") -----------// The APP'S SERVER, with its key + secret. Strict JSON bodies; delivery is not built yet// (pieces 5/6) — ident stores the notification and its channels, and the user reads it// in the inbox (/inbox).// POST /api/kinds { key, secret, kinds: [{ name, push, email }] } → { kinds }// POST /api/notify { key, secret, identity, name, text, icon?, link?, urgent? } → { id }apiKinds = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } kinds = { type = 'List' required = true } })if (b.error != null) { return fail(400, b.error) }let a = appOfSecret(b.body.key, b.body.secret)if (a == null) { return fail(401, 'unknown API key or wrong secret') }let r = registerKinds(a, b.body.kinds)if (r.error != null) { return fail(r.status, r.error) }return reply(200, { kinds = r.kinds })}apiNotify = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } identity = { type = 'String' required = true } name = { type = 'String' required = true } text = { type = 'String' required = true } icon = { type = 'String' required = false } link = { type = 'String' required = false } urgent = { type = 'Boolean' required = false } })if (b.error != null) { return fail(400, b.error) }let r = sendNotification(b.body)if (r.error != null) { return fail(r.status, r.error) }return reply(200, { id = r.id })}// ---- INVITES (ident#22, invites.hl; README "Invites") ----------------------------------------// The APP'S SERVER, key + secret, strict JSON bodies:// POST /api/invites { key, secret, project, role, return, uses?, days?, email? } → { id, url, state, … }// POST /api/invites/list { key, secret, project? } → { invites: [...] }// POST /api/invites/get { key, secret, id } → { invite }// POST /api/invites/revoke { key, secret, id } → { invite }// and the PERSON: GET /invite/<token> → the login flow (or an error page: used, expired, withdrawn).static field = (n, t) => { return { type = t required = n } }inviteCaller = (req, spec) => {if (req.method != 'POST') { return { res = fail(405, 'POST only') } }let b = strictBody(req, spec)if (b.error != null) { return { res = fail(400, b.error) } }let a = appOfSecret(b.body.key, b.body.secret)if (a == null) { return { res = fail(401, 'unknown API key or wrong secret') } }return { app = a body = b.body }}// where the link points: IDENT_PUBLIC_URL, else the request's own hostbaseOf = (req) => {if (publicUrl != null) { return publicUrl }let proto = req.headers['x-forwarded-proto'] != null ? req.headers['x-forwarded-proto'] : 'http'return proto + '://' + req.headers['host']}apiInvites = (route, req) => {let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') project = field(true, 'String') role = field(true, 'String') 'return' = field(true, 'String') uses = field(false, 'Number') days = field(false, 'Number') email = field(false, 'String') })if (c.res != null) { return c.res }let r = createInvite(c.app, c.body, baseOf(req))if (r.error != null) { return fail(r.status, r.error) }let mailed = falseif (r.mail != null) {sendInvite(r.mail.to, r.url, c.app.name, r.invite.project, r.invite.role, r.mail.days)mailed = true}return reply(200, { id = r.invite.id url = r.url state = r.invite.state project = r.invite.project role = r.invite.role uses = r.invite.uses expires = r.invite.expires mailed = mailed })}apiInvitesList = (route, req) => {let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') project = field(false, 'String') })if (c.res != null) { return c.res }return reply(200, { invites = listInvites(c.app, c.body.project) })}apiInvitesGet = (route, req) => {let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })if (c.res != null) { return c.res }let r = ownInvite(c.app, c.body.id)if (r == null) { return fail(404, 'no such invite') }return reply(200, { invite = inviteRow(r) })}apiInvitesRevoke = (route, req) => {let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })if (c.res != null) { return c.res }let r = revokeInvite(c.app, c.body.id)if (r.error != null) { return fail(r.status, r.error) }return reply(200, { invite = r.invite })}inviteLink = (route, req) => {if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }let r = openInvite(route.params.token)if (r.error != null) { return errorPage(r.status, r.title, r.error) }return redirect('/signin/' + r.rid)}// ---- THE CODE PAGE: GET /code and /signin/<rid>/code (ident#20, mission 032) ------------// Creator: "just make a /code where it checks a pending code". The page asks the SESSION// (store.hl pendingOf) whether a code it asked for is still waiting — unused, unexpired,// not killed by wrong tries. Yes → Home renders its code step with that address (a reload// shows it again). No, or already signed in → 302 back to the email form (`/` or// `/signin/<rid>`), so a stale /code is never a dead end.// A FUNCTION route because a component route cannot answer a redirect: it takes the// cookie's session (req.session, hybriel#11) and renders Home through the framework's own page// render (`server.page`, the same call a component route makes) with `step = 'code'`.// Its signature since hybriel #105 (mission 048): page(match, session, host, headers) — the// old page(match, req, session) still compiles but renders with req as the session (empty page).codePage = (route, req) => {let rid = route.params.rid// a rid is 32 hex (apps.hl); anything else goes to `/` (it lands in a Location header)if (rid != null && !hexId(rid)) { return redirect('/') }let home = rid != null ? '/signin/' + rid : '/'if (req.method != 'GET') { return redirect(home) }let s = req.sessionif (s == null || accountOfSession(s) != null || pendingOf(s) == null) { return redirect(home) }let m = { route = { pattern = route.route.pattern component = Home } kind = 'component' params = { rid = rid step = 'code' pending = pendingOf(s) } path = route.path }return server.page(m, s, server.hostOf(req.headers['host']), server.requestHeaders(req.headers))}hexId = (v) => {if (v.length != 32) { return false }let i = 0while (i < v.length) {if (!'0123456789abcdef'.includes(v[i])) { return false }i = i + 1}return true}notFoundApi = (route, req) => { return fail(404, 'no such endpoint') }// GET /api/online → 204: the shell's network probe (main.hl). Never cached, carries nothing.apiOnline = (route, req) => { return new Response('', { status = 204 headers = { 'Cache-Control' = 'no-store' } }) }routes = [{ pattern = "/favicon.ico" file = "./icons/favicon.ico" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/icons/icon-192.png" file = "./icons/icon-192.png" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/icons/icon-512.png" file = "./icons/icon-512.png" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/icons/apple-touch-icon.png" file = "./icons/apple-touch-icon.png" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/icons/icon.svg" file = "./icons/icon.svg" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/login" function = appLogin }{ pattern = "/api/exchange" function = apiExchange }{ pattern = "/api/migrate-ids" function = apiMigrateIds }{ pattern = "/api/code" function = apiCode }{ pattern = "/api/selector/identities" function = apiSelectorIdentities }{ pattern = "/api/selector/choose" function = apiSelectorChoose }{ pattern = "/api/invites" function = apiInvites }{ pattern = "/api/invites/list" function = apiInvitesList }{ pattern = "/api/invites/get" function = apiInvitesGet }{ pattern = "/api/invites/revoke" function = apiInvitesRevoke }{ pattern = "/invite/:token" function = inviteLink }{ pattern = "/api/kinds" function = apiKinds }{ pattern = "/api/notify" function = apiNotify }{ pattern = "/selector.js" file = "./selector.js" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/avatar.js" file = "./avatar.js" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/api/online" function = apiOnline }{ pattern = "/api/*" function = notFoundApi }{ pattern = "/code" function = codePage }{ pattern = "/signin/:rid/code" function = codePage }{ pattern = "/" component = Home }{ pattern = "/start" component = Start }{ pattern = "/signin/:rid" component = Home }{ pattern = "/apps" component = Apps }{ pattern = "/inbox" component = Inbox }{ pattern = "/inbox/:cid" component = AppSettings }]// the mailer's result handlers live on an INSTANCE of mail.hlmail = new Mail()sessionDir = env('IDENT_SESSIONS') != null ? env('IDENT_SESSIONS') : nullport = env('IDENT_PORT') != null ? toNumber(env('IDENT_PORT')) : 8351// THE LISTENER's interface: hl:web reads HL_HOST (or HOST) itself (hybriel#24): 127.0.0.1 on// Byrodin behind nginx; unset = 0.0.0.0 (dev on Loreana).// IDENT_WATCH=0 turns the dev watcher off (the container: a deploy is an rsync + restart,// half-copied .hl files must not be re-analysed); unset = on, as before.watching = env('IDENT_WATCH') != '0'// WHO A PUSHED EVENT IS FOR: "signed out everywhere" reaches every open connection whose// session is the account's (checked before dropUserSessions clears the sessions).audience = {signedOutAll = (accountId, session) => { return session != null && session.user != null && session.user.id == accountId }}// ident's OWN COOKIE NAME. Cookies are per host, not per port: with the default `hlsid` an app// on the same host (tickets on :8350) and ident would overwrite each other's session cookie// → hl:web's `sessionCookie` setting (hybriel#10/#17).sessionCookie = 'identsid'server = new WebFramework(routes = routes, styles = styles, audience = audience, minify = true, port = port, watchMode = watching, sessionCookie = sessionCookie)// ---- SIGN OUT EVERYWHERE: DELETE EVERY SESSION OF AN ACCOUNT (ticket #19) ----------------// Resident ones (dropped from memory, their user cleared so a still-open socket's session// object cannot be written back signed in) and the files of the ones nobody has open.dropUserSessions = (userId) => {let sess = server.sessionslet gone = []for (k of sess.map.keys()) {let x = sess.map[k]if (x != null && x.user != null && x.user.id == userId) { gone.push(k) }}for (k of gone) {sess.map[k].user = nullsess.drop(k)sess.map[k] = null}if (sess.dir != null) {for (e of listDir(sess.dir)) {let raw = readFile(e.path)if (raw != null) {let rec = JSON.parse(raw)if (rec != null && rec.user != null && rec.user.id == userId) { remove(e.path) }}}}return null}sessionHooks.dropUser = dropUserSessions
Branches
- mainmain branch
Latest commits
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre