ident
All repositories: gitoria
12.7 KB
// lib/api.hl — THE FUNCTION ROUTES (project.hl `routes`): the login button's GET /login, the app's server calls// (exchange, migrate-ids, kinds, notify, invites), the selector's two CORS calls, the code request, the invite// link, the code page, the offline probe. THIN WRAPPERS: check the method, the body (lib/api-helpers.hl strictBody)// and the app's key + secret, call the topic function, answer. Shapes: README "How apps use ident",// "How apps send notifications", "Invites". Statics only.import { Response } from 'hl:http1'import { reply, fail, redirect, strictBody, errorPage, field, inviteCaller, baseOf, hexId, refuse, corsHeaders, answer, caller } from './api-helpers.hl'import { openRequest, exchange, migrateIds, appOfSecret, openInvite } from './apps.hl'import { selectorList, codeOfPick } from './selector.hl'import { createInvite, listInvites, revokeInvite, ownInvite, inviteRow } from './invites.hl'import { registerKinds, sendNotification } from './notify.hl'import { startLogin, pendingOf } from './login.hl'import { accountOfSession } from './accounts.hl'import { sendCode, sendInvite } from './mail.hl'// ---- 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>.static 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).static 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").static 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 (lib/selector.hl): the ident user is the cookie's ---------// (hl:web hands a function route the cookie's session as req.session, hybriel#11)static apiSelectorIdentities = (route, req) => { return selectorIdentities(req, req.session) }static apiSelectorChoose = (route, req) => { return selectorChoose(req, req.session) }// GET /api/selector/identities?key=static selectorIdentities = (req, &session) => {let c = caller(req)if (c.error != null) { return refuse(c.status, c.error) }if (req.method != 'GET') { return answer(c.origin, 405, { error = 'GET only' }) }let me = accountOfSession(session)if (me == null) { return answer(c.origin, 200, { signedIn = false identities = [] }) }return answer(c.origin, 200, { signedIn = true identities = selectorList(c.app, me.id) })}// POST /api/selector/choose?key= { identity } (+ its OPTIONS preflight)static selectorChoose = (req, &session) => {let c = caller(req)if (c.error != null) { return refuse(c.status, c.error) }if (req.method == 'OPTIONS') {let h = corsHeaders(c.origin)h['Access-Control-Allow-Methods'] = 'POST'h['Access-Control-Allow-Headers'] = 'Content-Type'h['Access-Control-Max-Age'] = '600'return new Response('', { status = 204 headers = h })}if (req.method != 'POST') { return answer(c.origin, 405, { error = 'POST only' }) }let b = strictBody(req, { identity = { type = 'String' required = true } })if (b.error != null) { return answer(c.origin, 400, { error = b.error }) }let me = accountOfSession(session)if (me == null) { return answer(c.origin, 401, { error = 'you are not signed in to ident' }) }let code = codeOfPick(c.app, me.id, b.body.identity)if (code != null) { return answer(c.origin, 200, { code = code }) }return answer(c.origin, 400, { error = 'no such identity' })}// ---- 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 (lib/login-helpers.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.)// A function route, not a face: a face gets no request (no headers), and the per-IP limit needs the IP.static 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, lib/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 }static 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 })}static 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, lib/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 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 })}static 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) })}static 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) })}static 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 })}static 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 008 (old 032)) ------------// Creator: "just make a /code where it checks a pending code". The page asks the SESSION// (lib/login.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 `pageComponent` (Home) through the framework's own page// render (`srv.page`, the same call a component route makes) with `step = 'code'`; project.hl hands in// its server (by reference) and Home.// 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).static codePage = (route, req, &srv, pageComponent) => {let rid = route.params.rid// a rid is 32 hex (lib/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 = pageComponent } kind = 'component' params = { rid = rid step = 'code' pending = pendingOf(s) } path = route.path }return srv.page(m, s, srv.hostOf(req.headers['host']), srv.requestHeaders(req.headers))}static notFoundApi = (route, req) => { return fail(404, 'no such endpoint') }// GET /api/online → 204: the shell's network probe (components/main.hl). Never cached, carries nothing.static apiOnline = (route, req) => { return new Response('', { status = 204 headers = { 'Cache-Control' = 'no-store' } }) }
Branches
- mainmain branch
Latest commits
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 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