gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd2e7f91bd2e7f91bident 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 outputmred2e7f91b/lib/api.hl

12.7 KB

  1. // lib/api.hl — THE FUNCTION ROUTES (project.hl `routes`): the login button's GET /login, the app's server calls
  2. // (exchange, migrate-ids, kinds, notify, invites), the selector's two CORS calls, the code request, the invite
  3. // link, the code page, the offline probe. THIN WRAPPERS: check the method, the body (lib/api-helpers.hl strictBody)
  4. // and the app's key + secret, call the topic function, answer. Shapes: README "How apps use ident",
  5. // "How apps send notifications", "Invites". Statics only.
  6. import { Response } from 'hl:http1'
  7. import { reply, fail, redirect, strictBody, errorPage, field, inviteCaller, baseOf, hexId, refuse, corsHeaders, answer, caller } from './api-helpers.hl'
  8. import { openRequest, exchange, migrateIds, appOfSecret, openInvite } from './apps.hl'
  9. import { selectorList, codeOfPick } from './selector.hl'
  10. import { createInvite, listInvites, revokeInvite, ownInvite, inviteRow } from './invites.hl'
  11. import { registerKinds, sendNotification } from './notify.hl'
  12. import { startLogin, pendingOf } from './login.hl'
  13. import { accountOfSession } from './accounts.hl'
  14. import { sendCode, sendInvite } from './mail.hl'
  15. // ---- THE LOGIN BUTTON: GET /login?key=<api key>&return=<url> ---------------------------
  16. // An unknown key or a return URL outside the app's origins → an error page, NEVER a
  17. // redirect. Otherwise the request is parked under a random id (a page component cannot
  18. // read the query, hybriel #18) and the browser goes to /signin/<rid>.
  19. static appLogin = (route, req) => {
  20. if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }
  21. let q = req.query != null ? req.query : {}
  22. let r = openRequest(q.key, q['return'])
  23. if (r.error != null) { return errorPage(400, 'Bad login request', r.error) }
  24. return redirect('/signin/' + r.rid)
  25. }
  26. // ---- POST /api/migrate-ids { key, secret, finish? } → { ids: { <old per-app id>: <short id> } } (ident#23)
  27. // the APP'S SERVER moves its stored users to the identities' short ids in one step; with
  28. // `finish: true` the old per-app ids are dropped afterwards (they are gone for good).
  29. static apiMigrateIds = (route, req) => {
  30. if (req.method != 'POST') { return fail(405, 'POST only') }
  31. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } finish = { type = 'Boolean' required = false } })
  32. if (b.error != null) { return fail(400, b.error) }
  33. let a = appOfSecret(b.body.key.trim(), b.body.secret.trim())
  34. if (a == null) { return fail(401, 'unknown API key or wrong secret') }
  35. return reply(200, migrateIds(a, b.body.finish == true))
  36. }
  37. // ---- POST /api/exchange { key, secret, code } → { identity } ---------------------------
  38. // the APP'S SERVER trades the ident_code from its return URL for the app-specific
  39. // identity's short id. Nothing else is handed out (CONCEPT.md "More fields").
  40. static apiExchange = (route, req) => {
  41. if (req.method != 'POST') { return fail(405, 'POST only') }
  42. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } code = { type = 'String' required = true } })
  43. if (b.error != null) { return fail(400, b.error) }
  44. for (k of ['key' 'secret' 'code']) {
  45. if (b.body[k].trim() == '') { return fail(400, 'missing field: ' + k) }
  46. }
  47. let r = exchange(b.body.key.trim(), b.body.secret.trim(), b.body.code.trim())
  48. if (r.error != null) { return fail(r.status, r.error) }
  49. return reply(200, { identity = r.identity })
  50. }
  51. // ---- THE SELECTOR's two calls (lib/selector.hl): the ident user is the cookie's ---------
  52. // (hl:web hands a function route the cookie's session as req.session, hybriel#11)
  53. static apiSelectorIdentities = (route, req) => { return selectorIdentities(req, req.session) }
  54. static apiSelectorChoose = (route, req) => { return selectorChoose(req, req.session) }
  55. // GET /api/selector/identities?key=
  56. static selectorIdentities = (req, &session) => {
  57. let c = caller(req)
  58. if (c.error != null) { return refuse(c.status, c.error) }
  59. if (req.method != 'GET') { return answer(c.origin, 405, { error = 'GET only' }) }
  60. let me = accountOfSession(session)
  61. if (me == null) { return answer(c.origin, 200, { signedIn = false identities = [] }) }
  62. return answer(c.origin, 200, { signedIn = true identities = selectorList(c.app, me.id) })
  63. }
  64. // POST /api/selector/choose?key= { identity } (+ its OPTIONS preflight)
  65. static selectorChoose = (req, &session) => {
  66. let c = caller(req)
  67. if (c.error != null) { return refuse(c.status, c.error) }
  68. if (req.method == 'OPTIONS') {
  69. let h = corsHeaders(c.origin)
  70. h['Access-Control-Allow-Methods'] = 'POST'
  71. h['Access-Control-Allow-Headers'] = 'Content-Type'
  72. h['Access-Control-Max-Age'] = '600'
  73. return new Response('', { status = 204 headers = h })
  74. }
  75. if (req.method != 'POST') { return answer(c.origin, 405, { error = 'POST only' }) }
  76. let b = strictBody(req, { identity = { type = 'String' required = true } })
  77. if (b.error != null) { return answer(c.origin, 400, { error = b.error }) }
  78. let me = accountOfSession(session)
  79. if (me == null) { return answer(c.origin, 401, { error = 'you are not signed in to ident' }) }
  80. let code = codeOfPick(c.app, me.id, b.body.identity)
  81. if (code != null) { return answer(c.origin, 200, { code = code }) }
  82. return answer(c.origin, 400, { error = 'no such identity' })
  83. }
  84. // ---- POST /api/code { email } → { email }: THE ONE WAY TO GET A LOGIN CODE MAILED --------
  85. // (the sign-in form of `/` and `/signin/<rid>` fetches it). Limits: per address (3 / 10 min)
  86. // and per client IP (lib/login-helpers.hl ipBucket: 10 / 10 min, 30 / 24 h) → 429 with the reason.
  87. // THE CLIENT IP is the X-Client-IP header and nothing else: nginx on Byrodin sets it and
  88. // overwrites any the client sent (/CONTAINERS/web/nginx/conf.d/cloudflare-client-ip.conf).
  89. // CF-Connecting-IP / X-Forwarded-For / X-Real-IP are NEVER read (a client could forge them).
  90. // Without the header (dev, no nginx) every request shares ONE bucket ('direct'):
  91. // (hl:web's req.remoteAddress, hybriel#25, would be nginx's address behind the proxy.)
  92. // A function route, not a face: a face gets no request (no headers), and the per-IP limit needs the IP.
  93. static apiCode = (route, req) => {
  94. if (req.method != 'POST') { return fail(405, 'POST only') }
  95. let b = strictBody(req, { email = { type = 'String' required = true } })
  96. if (b.error != null) { return fail(400, b.error) }
  97. let r = startLogin(b.body.email, req.headers['x-client-ip'])
  98. if (r.error != null) { return fail(r.limited == true ? 429 : 400, r.error) }
  99. sendCode(r.email, r.code)
  100. return reply(200, { email = r.email })
  101. }
  102. // ---- NOTIFICATIONS (piece 4, lib/notify.hl; README "How apps send notifications") -------
  103. // The APP'S SERVER, with its key + secret. Strict JSON bodies; delivery is not built yet
  104. // (pieces 5/6) — ident stores the notification and its channels, and the user reads it
  105. // in the inbox (/inbox).
  106. // POST /api/kinds { key, secret, kinds: [{ name, push, email }] } → { kinds }
  107. // POST /api/notify { key, secret, identity, name, text, icon?, link?, urgent? } → { id }
  108. static apiKinds = (route, req) => {
  109. if (req.method != 'POST') { return fail(405, 'POST only') }
  110. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } kinds = { type = 'List' required = true } })
  111. if (b.error != null) { return fail(400, b.error) }
  112. let a = appOfSecret(b.body.key, b.body.secret)
  113. if (a == null) { return fail(401, 'unknown API key or wrong secret') }
  114. let r = registerKinds(a, b.body.kinds)
  115. if (r.error != null) { return fail(r.status, r.error) }
  116. return reply(200, { kinds = r.kinds })
  117. }
  118. static apiNotify = (route, req) => {
  119. if (req.method != 'POST') { return fail(405, 'POST only') }
  120. 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 } })
  121. if (b.error != null) { return fail(400, b.error) }
  122. let r = sendNotification(b.body)
  123. if (r.error != null) { return fail(r.status, r.error) }
  124. return reply(200, { id = r.id })
  125. }
  126. // ---- INVITES (ident#22, lib/invites.hl; README "Invites") ----------------------------------------
  127. // The APP'S SERVER, key + secret, strict JSON bodies:
  128. // POST /api/invites { key, secret, project, role, return, uses?, days?, email? } → { id, url, state, … }
  129. // POST /api/invites/list { key, secret, project? } → { invites: [...] }
  130. // POST /api/invites/get { key, secret, id } → { invite }
  131. // POST /api/invites/revoke { key, secret, id } → { invite }
  132. // and the PERSON: GET /invite/<token> → the login flow (or an error page: used, expired, withdrawn).
  133. static apiInvites = (route, req) => {
  134. 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') })
  135. if (c.res != null) { return c.res }
  136. let r = createInvite(c.app, c.body, baseOf(req))
  137. if (r.error != null) { return fail(r.status, r.error) }
  138. let mailed = false
  139. if (r.mail != null) {
  140. sendInvite(r.mail.to, r.url, c.app.name, r.invite.project, r.invite.role, r.mail.days)
  141. mailed = true
  142. }
  143. 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 })
  144. }
  145. static apiInvitesList = (route, req) => {
  146. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') project = field(false, 'String') })
  147. if (c.res != null) { return c.res }
  148. return reply(200, { invites = listInvites(c.app, c.body.project) })
  149. }
  150. static apiInvitesGet = (route, req) => {
  151. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })
  152. if (c.res != null) { return c.res }
  153. let r = ownInvite(c.app, c.body.id)
  154. if (r == null) { return fail(404, 'no such invite') }
  155. return reply(200, { invite = inviteRow(r) })
  156. }
  157. static apiInvitesRevoke = (route, req) => {
  158. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })
  159. if (c.res != null) { return c.res }
  160. let r = revokeInvite(c.app, c.body.id)
  161. if (r.error != null) { return fail(r.status, r.error) }
  162. return reply(200, { invite = r.invite })
  163. }
  164. static inviteLink = (route, req) => {
  165. if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }
  166. let r = openInvite(route.params.token)
  167. if (r.error != null) { return errorPage(r.status, r.title, r.error) }
  168. return redirect('/signin/' + r.rid)
  169. }
  170. // ---- THE CODE PAGE: GET /code and /signin/<rid>/code (ident#20, mission 008 (old 032)) ------------
  171. // Creator: "just make a /code where it checks a pending code". The page asks the SESSION
  172. // (lib/login.hl pendingOf) whether a code it asked for is still waiting — unused, unexpired,
  173. // not killed by wrong tries. Yes → Home renders its code step with that address (a reload
  174. // shows it again). No, or already signed in → 302 back to the email form (`/` or
  175. // `/signin/<rid>`), so a stale /code is never a dead end.
  176. // A FUNCTION route because a component route cannot answer a redirect: it takes the
  177. // cookie's session (req.session, hybriel#11) and renders `pageComponent` (Home) through the framework's own page
  178. // render (`srv.page`, the same call a component route makes) with `step = 'code'`; project.hl hands in
  179. // its server (by reference) and Home.
  180. // Its signature since hybriel #105 (mission 048): page(match, session, host, headers) — the
  181. // old page(match, req, session) still compiles but renders with req as the session (empty page).
  182. static codePage = (route, req, &srv, pageComponent) => {
  183. let rid = route.params.rid
  184. // a rid is 32 hex (lib/apps.hl); anything else goes to `/` (it lands in a Location header)
  185. if (rid != null && !hexId(rid)) { return redirect('/') }
  186. let home = rid != null ? '/signin/' + rid : '/'
  187. if (req.method != 'GET') { return redirect(home) }
  188. let s = req.session
  189. if (s == null || accountOfSession(s) != null || pendingOf(s) == null) { return redirect(home) }
  190. let m = { route = { pattern = route.route.pattern component = pageComponent } kind = 'component' params = { rid = rid step = 'code' pending = pendingOf(s) } path = route.path }
  191. return srv.page(m, s, srv.hostOf(req.headers['host']), srv.requestHeaders(req.headers))
  192. }
  193. static notFoundApi = (route, req) => { return fail(404, 'no such endpoint') }
  194. // GET /api/online → 204: the shell's network probe (components/main.hl). Never cached, carries nothing.
  195. static apiOnline = (route, req) => { return new Response('', { status = 204 headers = { 'Cache-Control' = 'no-store' } }) }

Branches

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