ident
All repositories: gitoria
6.4 KB
// lib/api-helpers.hl — the plumbing of the function routes (lib/api.hl): JSON answers, redirects, the HTML error// page, the STRICT JSON body, the app's key + secret check of a call, the selector's CORS answers. Statics only.//// STRICT: a body must be a JSON object; an unknown field, a missing field and a field of// the wrong type are each a 400 whose `error` names the field. hl:json's JSON.parse// aborts the request (500) on invalid input and the language has no catch (hybriel #12),// so the body's syntax is checked first by jsoncheck.hl (copied from tickets).import { Response } from 'hl:http1'import { jsonErrorAt } from './jsoncheck.hl'import { appByKey, appOfSecret } from './apps.hl'import { originsOf } from './apps-helpers.hl'import { publicUrl } from './invites.hl'static jsonHeaders = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' }static htmlHeaders = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' }static reply = (status, value) => { return new Response(JSON.stringify(value), { status = status headers = jsonHeaders }) }static fail = (status, message) => { return reply(status, { error = message }) }static redirect = (url) => { return new Response('', { status = 302 headers = { 'Location' = url 'Cache-Control' = 'no-store' } }) }// THE STRICT BODY: `spec` maps each allowed field to { type ('String' | 'Number' |// 'Boolean' | 'List'), required }. Answers { error } or { body }.static strictBody = (req, spec) => {raw = req.bodyif (raw == null || raw.trim() == '') { return { error = 'a JSON object body is required' } }at = jsonErrorAt(raw)if (at >= 0) { return { error = 'the body is not valid JSON (at character ' + at + ')' } }t = raw.trim()if (t[0] != '{') { return { error = 'the body must be a JSON object' } }b = JSON.parse(raw)for (k of b.keys()) {if (spec[k] == null) { return { error = 'unknown field: ' + k } }}for (k of spec.keys()) {let f = spec[k]let v = b[k]if (v == null) {if (f.required) { return { error = 'missing field: ' + k } }} else if (f.type == 'List') {if (hlTypeName(v) != 'Hybrid' || v.length == null) { return { error = 'field ' + k + ' must be a list' } }} else if (hlTypeName(v) != f.type) {return { error = 'field ' + k + ' must be a ' + f.type.toLowerCase() }}}return { body = b }}// ---- the HTML error page of the login button (never a redirect) ------------------------static escapeHtml = (s) => { return ('' + s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') }static errorPage = (status, title, message) => {html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ident | ' + escapeHtml(title) + '</title><style>:root{--dark:rgb(25, 30, 35);--darker:rgb(15, 20, 25);--light:rgb(195, 200, 205);--lighter:rgb(245, 250, 255);--red:#f44747;--color-background:var(--dark);--color-surface:rgb(35, 40, 45);--color-border:rgb(70, 75, 80);--color-text:var(--light);--color-danger:var(--red);--color-accent:#ce9178}html{color-scheme:dark}body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--color-text);background:var(--color-background)}application-header{display:block;padding:.6rem 1rem;background:var(--darker);border-bottom:1px solid var(--color-border)}application-header a{font-weight:800;font-size:1.15rem;color:var(--color-accent);text-decoration:none}main{padding:2rem 1rem}ident-error{display:block;max-width:34rem;margin:0 auto;padding:1rem;background:var(--color-surface);border:1px solid var(--color-border);border-radius:.5rem}h1{margin:0 0 .5rem;font-size:1.2rem;color:var(--color-danger)}p{margin:0;overflow-wrap:anywhere}</style></head><body><application-header><a href="/">ident</a></application-header><main><ident-error id="identerror"><h1>' + escapeHtml(title) + '</h1><p id="errormessage">' + escapeHtml(message) + '</p></ident-error></main></body></html>'return new Response(html, { status = status headers = htmlHeaders })}// ---- THE APP'S CALLS (key + secret, strict JSON body) ------------------------------------------static field = (n, t) => { return { type = t required = n } }// POST only, the strict body `spec`, then the app of its key + secret: answers { res } (the refusal) or { app, body }static inviteCaller = (req, spec) => {if (req.method != 'POST') { return { res = fail(405, 'POST only') } }b = strictBody(req, spec)if (b.error != null) { return { res = fail(400, b.error) } }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 an invite link points: IDENT_PUBLIC_URL, else the request's own hoststatic baseOf = (req) => {if (publicUrl != null) { return publicUrl }proto = req.headers['x-forwarded-proto'] != null ? req.headers['x-forwarded-proto'] : 'http'return proto + '://' + req.headers['host']}// a login request id is 32 hex (lib/apps.hl openRequest)static 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}// ---- THE SELECTOR's CORS answers (lib/selector.hl) ----------------------------------------------static jsonType = 'application/json; charset=utf-8'// the refusal: no CORS headers → the browser gives the page nothingstatic refuse = (status, message) => {return new Response(JSON.stringify({ error = message }), { status = status headers = { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' } })}static corsHeaders = (origin) => {return { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' 'Access-Control-Allow-Origin' = origin 'Access-Control-Allow-Credentials' = 'true' }}static answer = (origin, status, value) => {return new Response(JSON.stringify(value), { status = status headers = corsHeaders(origin) })}// WHO ASKS: answers { app (record), origin } or { status, error }static caller = (req) => {let origin = req.headers['origin']if (origin == null || origin == '') { return { status = 403 error = 'the selector answers browsers only (no Origin header)' } }q = req.query != null ? req.query : {}a = appByKey(q.key)if (a == null) { return { status = 403 error = 'no app has this API key' } }if (!originsOf(a).includes(origin)) { return { status = 403 error = 'this origin is not registered for the app' } }return { app = a origin = origin }}
Branches
- mainmain branch
Latest commits
- 5fdbb6b2ident mission 009: report — scratch folder notemre
- 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- 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