gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita3a7d21aa3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmrea3a7d21a/selector.hl

5.1 KB

  1. // selector.hl — THE IDENTITY SELECTOR's server side (piece 3 of 6, ticket #26; CONCEPT.md
  2. // "Flow 2: identity selector" and "Decided 2026-09-24"). Statics only; the routes are in
  3. // project.hl, the browser half is selector.js (served at /selector.js).
  4. //
  5. // GET /api/selector/identities?key=<api key> → { signedIn, identities: [{ id, name }] }
  6. // POST /api/selector/choose?key=<api key> { identity: <id from the list> } → { code }
  7. // OPTIONS /api/selector/choose?key=… the CORS preflight of the POST
  8. //
  9. // CORS: ident answers ONLY a request whose Origin header is one of the app's registered
  10. // origins AND whose key is that app's API key. Then (and only then) the answer carries
  11. // `Access-Control-Allow-Origin: <that exact origin>` and `…-Allow-Credentials: true`
  12. // (never `*`). Anything else is a 403 without those headers: the browser hands the page
  13. // nothing, and nothing is written.
  14. // The ident user is the one of the `identsid` cookie the browser sends along (credentials;
  15. // SameSite=Lax, so it only travels from the SAME SITE — our apps; other ports of the same
  16. // host count). Function routes get no session: it is resolved from the cookie (hybriel #18).
  17. //
  18. // THE LIST'S ids are not ident's identity ids: each is sha256(app's secret hash : identity)
  19. // cut to 32 hex — stable for one app, different for every app, not guessable, and it
  20. // means nothing outside this selector. The CODE of a choice follows the login button's
  21. // rules exactly (apps.hl issueCode: single use, 60 s, this app only); the host's server
  22. // trades it with POST /api/exchange (key + secret) for the app-specific identity id.
  23. import { Response } from 'hl:http1'
  24. import { sha256 } from 'hl:crypto'
  25. import { appByKey, originsOf, issueCode } from './apps.hl'
  26. import { identityRecords, labelOf, accountOfSession } from './store.hl'
  27. import { strictBody } from './api.hl'
  28. static jsonType = 'application/json; charset=utf-8'
  29. // the refusal: no CORS headers → the browser gives the page nothing
  30. static refuse = (status, message) => {
  31. return new Response(JSON.stringify({ error = message }), { status = status headers = { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' } })
  32. }
  33. static corsHeaders = (origin) => {
  34. return { 'Content-Type' = jsonType 'Cache-Control' = 'no-store' 'Vary' = 'Origin' 'Access-Control-Allow-Origin' = origin 'Access-Control-Allow-Credentials' = 'true' }
  35. }
  36. static answer = (origin, status, value) => {
  37. return new Response(JSON.stringify(value), { status = status headers = corsHeaders(origin) })
  38. }
  39. // WHO ASKS: answers { app (record), origin } or { status, error }
  40. static caller = (req) => {
  41. let origin = req.headers['origin']
  42. if (origin == null || origin == '') { return { status = 403 error = 'the selector answers browsers only (no Origin header)' } }
  43. let q = req.query != null ? req.query : {}
  44. let a = appByKey(q.key)
  45. if (a == null) { return { status = 403 error = 'no app has this API key' } }
  46. if (!originsOf(a).includes(origin)) { return { status = 403 error = 'this origin is not registered for the app' } }
  47. return { app = a origin = origin }
  48. }
  49. // (since mission 009 the identity's id is its UUID, so every pick id changed once then)
  50. static pickOf = (appRec, identityRec) => { return sha256(appRec.secretHash + ':' + identityRec.id).slice(0, 32) }
  51. // the account's identities for the app's page: the id to pick it by and the identity
  52. // name (CONCEPT.md: "that one is shown in the selector"; blank → the list's fallback)
  53. static selectorList = (appRec, accountId) => {
  54. let out = []
  55. let n = 1
  56. for (r of identityRecords(accountId)) {
  57. out.push({ id = pickOf(appRec, r) name = labelOf(r, n) })
  58. n = n + 1
  59. }
  60. return out
  61. }
  62. // GET /api/selector/identities?key=
  63. static selectorIdentities = (req, &session) => {
  64. let c = caller(req)
  65. if (c.error != null) { return refuse(c.status, c.error) }
  66. if (req.method != 'GET') { return answer(c.origin, 405, { error = 'GET only' }) }
  67. let me = accountOfSession(session)
  68. if (me == null) { return answer(c.origin, 200, { signedIn = false identities = [] }) }
  69. return answer(c.origin, 200, { signedIn = true identities = selectorList(c.app, me.id) })
  70. }
  71. // POST /api/selector/choose?key= { identity } (+ its OPTIONS preflight)
  72. static selectorChoose = (req, &session) => {
  73. let c = caller(req)
  74. if (c.error != null) { return refuse(c.status, c.error) }
  75. if (req.method == 'OPTIONS') {
  76. let h = corsHeaders(c.origin)
  77. h['Access-Control-Allow-Methods'] = 'POST'
  78. h['Access-Control-Allow-Headers'] = 'Content-Type'
  79. h['Access-Control-Max-Age'] = '600'
  80. return new Response('', { status = 204 headers = h })
  81. }
  82. if (req.method != 'POST') { return answer(c.origin, 405, { error = 'POST only' }) }
  83. let b = strictBody(req, { identity = { type = 'String' required = true } })
  84. if (b.error != null) { return answer(c.origin, 400, { error = b.error }) }
  85. let me = accountOfSession(session)
  86. if (me == null) { return answer(c.origin, 401, { error = 'you are not signed in to ident' }) }
  87. for (r of identityRecords(me.id)) {
  88. if (pickOf(c.app, r) == b.body.identity) {
  89. return answer(c.origin, 200, { code = issueCode(c.app.id, r.id) })
  90. }
  91. }
  92. return answer(c.origin, 400, { error = 'no such identity' })
  93. }

Branches

Latest commits

  • 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