gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain5fdbb6b2ident mission 009: report — scratch folder notemremain/lib/api-helpers.hl

6.4 KB

  1. // lib/api-helpers.hl — the plumbing of the function routes (lib/api.hl): JSON answers, redirects, the HTML error
  2. // page, the STRICT JSON body, the app's key + secret check of a call, the selector's CORS answers. Statics only.
  3. //
  4. // STRICT: a body must be a JSON object; an unknown field, a missing field and a field of
  5. // the wrong type are each a 400 whose `error` names the field. hl:json's JSON.parse
  6. // aborts the request (500) on invalid input and the language has no catch (hybriel #12),
  7. // so the body's syntax is checked first by jsoncheck.hl (copied from tickets).
  8. import { Response } from 'hl:http1'
  9. import { jsonErrorAt } from './jsoncheck.hl'
  10. import { appByKey, appOfSecret } from './apps.hl'
  11. import { originsOf } from './apps-helpers.hl'
  12. import { publicUrl } from './invites.hl'
  13. static jsonHeaders = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' }
  14. static htmlHeaders = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' }
  15. static reply = (status, value) => { return new Response(JSON.stringify(value), { status = status headers = jsonHeaders }) }
  16. static fail = (status, message) => { return reply(status, { error = message }) }
  17. static redirect = (url) => { return new Response('', { status = 302 headers = { 'Location' = url 'Cache-Control' = 'no-store' } }) }
  18. // THE STRICT BODY: `spec` maps each allowed field to { type ('String' | 'Number' |
  19. // 'Boolean' | 'List'), required }. Answers { error } or { body }.
  20. static strictBody = (req, spec) => {
  21. raw = req.body
  22. if (raw == null || raw.trim() == '') { return { error = 'a JSON object body is required' } }
  23. at = jsonErrorAt(raw)
  24. if (at >= 0) { return { error = 'the body is not valid JSON (at character ' + at + ')' } }
  25. t = raw.trim()
  26. if (t[0] != '{') { return { error = 'the body must be a JSON object' } }
  27. b = JSON.parse(raw)
  28. for (k of b.keys()) {
  29. if (spec[k] == null) { return { error = 'unknown field: ' + k } }
  30. }
  31. for (k of spec.keys()) {
  32. let f = spec[k]
  33. let v = b[k]
  34. if (v == null) {
  35. if (f.required) { return { error = 'missing field: ' + k } }
  36. } else if (f.type == 'List') {
  37. if (hlTypeName(v) != 'Hybrid' || v.length == null) { return { error = 'field ' + k + ' must be a list' } }
  38. } else if (hlTypeName(v) != f.type) {
  39. return { error = 'field ' + k + ' must be a ' + f.type.toLowerCase() }
  40. }
  41. }
  42. return { body = b }
  43. }
  44. // ---- the HTML error page of the login button (never a redirect) ------------------------
  45. static escapeHtml = (s) => { return ('' + s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;') }
  46. static errorPage = (status, title, message) => {
  47. 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>'
  48. return new Response(html, { status = status headers = htmlHeaders })
  49. }
  50. // ---- THE APP'S CALLS (key + secret, strict JSON body) ------------------------------------------
  51. static field = (n, t) => { return { type = t required = n } }
  52. // POST only, the strict body `spec`, then the app of its key + secret: answers { res } (the refusal) or { app, body }
  53. static inviteCaller = (req, spec) => {
  54. if (req.method != 'POST') { return { res = fail(405, 'POST only') } }
  55. b = strictBody(req, spec)
  56. if (b.error != null) { return { res = fail(400, b.error) } }
  57. a = appOfSecret(b.body.key, b.body.secret)
  58. if (a == null) { return { res = fail(401, 'unknown API key or wrong secret') } }
  59. return { app = a body = b.body }
  60. }
  61. // where an invite link points: IDENT_PUBLIC_URL, else the request's own host
  62. static baseOf = (req) => {
  63. if (publicUrl != null) { return publicUrl }
  64. proto = req.headers['x-forwarded-proto'] != null ? req.headers['x-forwarded-proto'] : 'http'
  65. return proto + '://' + req.headers['host']
  66. }
  67. // a login request id is 32 hex (lib/apps.hl openRequest)
  68. static hexId = (v) => {
  69. if (v.length != 32) { return false }
  70. let i = 0
  71. while (i < v.length) {
  72. if (!'0123456789abcdef'.includes(v[i])) { return false }
  73. i = i + 1
  74. }
  75. return true
  76. }
  77. // ---- THE SELECTOR's CORS answers (lib/selector.hl) ----------------------------------------------
  78. static jsonType = 'application/json; charset=utf-8'
  79. // the refusal: no CORS headers → the browser gives the page nothing
  80. static refuse = (status, message) => {
  81. return new Response(JSON.stringify({ error = message }), { status = status headers = { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' } })
  82. }
  83. static corsHeaders = (origin) => {
  84. return { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' 'Access-Control-Allow-Origin' = origin 'Access-Control-Allow-Credentials' = 'true' }
  85. }
  86. static answer = (origin, status, value) => {
  87. return new Response(JSON.stringify(value), { status = status headers = corsHeaders(origin) })
  88. }
  89. // WHO ASKS: answers { app (record), origin } or { status, error }
  90. static caller = (req) => {
  91. let origin = req.headers['origin']
  92. if (origin == null || origin == '') { return { status = 403 error = 'the selector answers browsers only (no Origin header)' } }
  93. q = req.query != null ? req.query : {}
  94. a = appByKey(q.key)
  95. if (a == null) { return { status = 403 error = 'no app has this API key' } }
  96. if (!originsOf(a).includes(origin)) { return { status = 403 error = 'this origin is not registered for the app' } }
  97. return { app = a origin = origin }
  98. }

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