gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit81b15b7b81b15b7bState of 2026-09-27, before the move to gitoriamre81b15b7b/project.hl

16.4 KB

  1. // project.hl — ident.worldapi.org: THE APP. Concept: CONCEPT.md (the creator's).
  2. // Piece 1 (#24): the login to ident itself, the account, its identities, its time zone —
  3. // page `/` (components/home.hl). Piece 2 (#25): apps (page `/apps`, components/apps.hl),
  4. // one identity id per app, and the LOGIN BUTTON flow (README "How apps use ident"):
  5. // GET /login?key=<api key>&return=<url> → error page, or → /signin/<rid>
  6. // /signin/<rid> sign in to ident, choose the identity,
  7. // → <return url>?ident_code=<one-time code>
  8. // POST /api/exchange { key, secret, code } → { identity } (the identity's short id only)
  9. // Piece 3 (#26): THE IDENTITY SELECTOR (selector.hl, selector.js; README "How apps use ident"):
  10. // GET /selector.js the script an app's page includes
  11. // GET /api/selector/identities?key= the signed-in user's identities (CORS)
  12. // POST /api/selector/choose?key= {identity} → { code } (the same one-time code)
  13. // Mission 010: THE CODE REQUEST is a function route, not a face, because a face gets no
  14. // request (no headers), and the per-IP limit needs the client's IP:
  15. // POST /api/code { email } → { email } | 400/429 { error }
  16. // ident#20 (mission 032): THE CODE PAGE — after "Send me a code" the browser goes to
  17. // GET /code, /signin/<rid>/code the code form for this session's pending
  18. // address, or → / resp. /signin/<rid>
  19. // Deploy (Byrodin): HL_HOST=127.0.0.1 binds loopback only (hl:web reads HL_HOST, hybriel#24),
  20. // IDENT_PORT the port.
  21. import WebFramework from 'hl:web'
  22. import { env } from 'hl:proc'
  23. import Styles from './styles.hl'
  24. import { reply, fail, redirect, strictBody, errorPage } from './api.hl'
  25. import { openRequest, exchange, migrateIds } from './apps.hl'
  26. import { selectorIdentities, selectorChoose } from './selector.hl'
  27. import { createInvite, listInvites, revokeInvite, ownInvite, inviteRow, openInvite, publicUrl } from './invites.hl'
  28. import { appOfSecret, registerKinds, sendNotification } from './notify.hl'
  29. import Mail from './mail.hl'
  30. import { startLogin, sessionHooks, pendingOf, accountOfSession, shortIdsBackfilled } from './store.hl'
  31. import { listDir, readFile, remove } from 'hl:fs'
  32. import { sendCode, sendInvite } from './mail.hl'
  33. // ident#23: statics run on first use — touching it here gives every older identity its short id at boot
  34. console.log('ident: ' + shortIdsBackfilled + ' identities got their short id')
  35. import Home from './components/home.hl'
  36. import Apps from './components/apps.hl'
  37. import Inbox from './components/inbox.hl'
  38. import AppSettings from './components/appsettings.hl'
  39. static siteName = "ident"
  40. appTitle = siteName
  41. styles = Styles
  42. // ---- THE LOGIN BUTTON: GET /login?key=<api key>&return=<url> ---------------------------
  43. // An unknown key or a return URL outside the app's origins → an error page, NEVER a
  44. // redirect. Otherwise the request is parked under a random id (a page component cannot
  45. // read the query, hybriel #18) and the browser goes to /signin/<rid>.
  46. appLogin = (route, req) => {
  47. if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }
  48. let q = req.query != null ? req.query : {}
  49. let r = openRequest(q.key, q['return'])
  50. if (r.error != null) { return errorPage(400, 'Bad login request', r.error) }
  51. return redirect('/signin/' + r.rid)
  52. }
  53. // ---- POST /api/migrate-ids { key, secret, finish? } → { ids: { <old per-app id>: <short id> } } (ident#23)
  54. // the APP'S SERVER moves its stored users to the identities' short ids in one step; with
  55. // `finish: true` the old per-app ids are dropped afterwards (they are gone for good).
  56. apiMigrateIds = (route, req) => {
  57. if (req.method != 'POST') { return fail(405, 'POST only') }
  58. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } finish = { type = 'Boolean' required = false } })
  59. if (b.error != null) { return fail(400, b.error) }
  60. let a = appOfSecret(b.body.key.trim(), b.body.secret.trim())
  61. if (a == null) { return fail(401, 'unknown API key or wrong secret') }
  62. return reply(200, migrateIds(a, b.body.finish == true))
  63. }
  64. // ---- POST /api/exchange { key, secret, code } → { identity } ---------------------------
  65. // the APP'S SERVER trades the ident_code from its return URL for the app-specific
  66. // identity's short id. Nothing else is handed out (CONCEPT.md "More fields").
  67. apiExchange = (route, req) => {
  68. if (req.method != 'POST') { return fail(405, 'POST only') }
  69. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } code = { type = 'String' required = true } })
  70. if (b.error != null) { return fail(400, b.error) }
  71. for (k of ['key' 'secret' 'code']) {
  72. if (b.body[k].trim() == '') { return fail(400, 'missing field: ' + k) }
  73. }
  74. let r = exchange(b.body.key.trim(), b.body.secret.trim(), b.body.code.trim())
  75. if (r.error != null) { return fail(r.status, r.error) }
  76. return reply(200, { identity = r.identity })
  77. }
  78. // ---- THE SELECTOR's two calls (selector.hl): the ident user is the cookie's -------------
  79. // (hl:web hands a function route the cookie's session as req.session, hybriel#11)
  80. apiSelectorIdentities = (route, req) => { return selectorIdentities(req, req.session) }
  81. apiSelectorChoose = (route, req) => { return selectorChoose(req, req.session) }
  82. // ---- POST /api/code { email } → { email }: THE ONE WAY TO GET A LOGIN CODE MAILED --------
  83. // (the sign-in form of `/` and `/signin/<rid>` fetches it). Limits: per address (3 / 10 min)
  84. // and per client IP (store.hl ipBucket: 10 / 10 min, 30 / 24 h) → 429 with the reason.
  85. // THE CLIENT IP is the X-Client-IP header and nothing else: nginx on Byrodin sets it and
  86. // overwrites any the client sent (/CONTAINERS/web/nginx/conf.d/cloudflare-client-ip.conf).
  87. // CF-Connecting-IP / X-Forwarded-For / X-Real-IP are NEVER read (a client could forge them).
  88. // Without the header (dev, no nginx) every request shares ONE bucket ('direct'):
  89. // (hl:web's req.remoteAddress, hybriel#25, would be nginx's address behind the proxy.)
  90. apiCode = (route, req) => {
  91. if (req.method != 'POST') { return fail(405, 'POST only') }
  92. let b = strictBody(req, { email = { type = 'String' required = true } })
  93. if (b.error != null) { return fail(400, b.error) }
  94. let r = startLogin(b.body.email, req.headers['x-client-ip'])
  95. if (r.error != null) { return fail(r.limited == true ? 429 : 400, r.error) }
  96. sendCode(r.email, r.code)
  97. return reply(200, { email = r.email })
  98. }
  99. // ---- NOTIFICATIONS (piece 4, notify.hl; README "How apps send notifications") -----------
  100. // The APP'S SERVER, with its key + secret. Strict JSON bodies; delivery is not built yet
  101. // (pieces 5/6) — ident stores the notification and its channels, and the user reads it
  102. // in the inbox (/inbox).
  103. // POST /api/kinds { key, secret, kinds: [{ name, push, email }] } → { kinds }
  104. // POST /api/notify { key, secret, identity, name, text, icon?, link?, urgent? } → { id }
  105. apiKinds = (route, req) => {
  106. if (req.method != 'POST') { return fail(405, 'POST only') }
  107. let b = strictBody(req, { key = { type = 'String' required = true } secret = { type = 'String' required = true } kinds = { type = 'List' required = true } })
  108. if (b.error != null) { return fail(400, b.error) }
  109. let a = appOfSecret(b.body.key, b.body.secret)
  110. if (a == null) { return fail(401, 'unknown API key or wrong secret') }
  111. let r = registerKinds(a, b.body.kinds)
  112. if (r.error != null) { return fail(r.status, r.error) }
  113. return reply(200, { kinds = r.kinds })
  114. }
  115. apiNotify = (route, req) => {
  116. if (req.method != 'POST') { return fail(405, 'POST only') }
  117. 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 } })
  118. if (b.error != null) { return fail(400, b.error) }
  119. let r = sendNotification(b.body)
  120. if (r.error != null) { return fail(r.status, r.error) }
  121. return reply(200, { id = r.id })
  122. }
  123. // ---- INVITES (ident#22, invites.hl; README "Invites") ----------------------------------------
  124. // The APP'S SERVER, key + secret, strict JSON bodies:
  125. // POST /api/invites { key, secret, project, role, return, uses?, days?, email? } → { id, url, state, … }
  126. // POST /api/invites/list { key, secret, project? } → { invites: [...] }
  127. // POST /api/invites/get { key, secret, id } → { invite }
  128. // POST /api/invites/revoke { key, secret, id } → { invite }
  129. // and the PERSON: GET /invite/<token> → the login flow (or an error page: used, expired, withdrawn).
  130. static field = (n, t) => { return { type = t required = n } }
  131. inviteCaller = (req, spec) => {
  132. if (req.method != 'POST') { return { res = fail(405, 'POST only') } }
  133. let b = strictBody(req, spec)
  134. if (b.error != null) { return { res = fail(400, b.error) } }
  135. let a = appOfSecret(b.body.key, b.body.secret)
  136. if (a == null) { return { res = fail(401, 'unknown API key or wrong secret') } }
  137. return { app = a body = b.body }
  138. }
  139. // where the link points: IDENT_PUBLIC_URL, else the request's own host
  140. baseOf = (req) => {
  141. if (publicUrl != null) { return publicUrl }
  142. let proto = req.headers['x-forwarded-proto'] != null ? req.headers['x-forwarded-proto'] : 'http'
  143. return proto + '://' + req.headers['host']
  144. }
  145. apiInvites = (route, req) => {
  146. 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') })
  147. if (c.res != null) { return c.res }
  148. let r = createInvite(c.app, c.body, baseOf(req))
  149. if (r.error != null) { return fail(r.status, r.error) }
  150. let mailed = false
  151. if (r.mail != null) {
  152. sendInvite(r.mail.to, r.url, c.app.name, r.invite.project, r.invite.role, r.mail.days)
  153. mailed = true
  154. }
  155. 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 })
  156. }
  157. apiInvitesList = (route, req) => {
  158. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') project = field(false, 'String') })
  159. if (c.res != null) { return c.res }
  160. return reply(200, { invites = listInvites(c.app, c.body.project) })
  161. }
  162. apiInvitesGet = (route, req) => {
  163. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })
  164. if (c.res != null) { return c.res }
  165. let r = ownInvite(c.app, c.body.id)
  166. if (r == null) { return fail(404, 'no such invite') }
  167. return reply(200, { invite = inviteRow(r) })
  168. }
  169. apiInvitesRevoke = (route, req) => {
  170. let c = inviteCaller(req, { key = field(true, 'String') secret = field(true, 'String') id = field(true, 'String') })
  171. if (c.res != null) { return c.res }
  172. let r = revokeInvite(c.app, c.body.id)
  173. if (r.error != null) { return fail(r.status, r.error) }
  174. return reply(200, { invite = r.invite })
  175. }
  176. inviteLink = (route, req) => {
  177. if (req.method != 'GET') { return errorPage(405, 'Method not allowed', 'Use a GET request.') }
  178. let r = openInvite(route.params.token)
  179. if (r.error != null) { return errorPage(r.status, r.title, r.error) }
  180. return redirect('/signin/' + r.rid)
  181. }
  182. // ---- THE CODE PAGE: GET /code and /signin/<rid>/code (ident#20, mission 032) ------------
  183. // Creator: "just make a /code where it checks a pending code". The page asks the SESSION
  184. // (store.hl pendingOf) whether a code it asked for is still waiting — unused, unexpired,
  185. // not killed by wrong tries. Yes → Home renders its code step with that address (a reload
  186. // shows it again). No, or already signed in → 302 back to the email form (`/` or
  187. // `/signin/<rid>`), so a stale /code is never a dead end.
  188. // A FUNCTION route because a component route cannot answer a redirect: it takes the
  189. // cookie's session (req.session, hybriel#11) and renders Home through the framework's own page
  190. // render (`server.page`, the same call a component route makes) with `step = 'code'`.
  191. codePage = (route, req) => {
  192. let rid = route.params.rid
  193. // a rid is 32 hex (apps.hl); anything else goes to `/` (it lands in a Location header)
  194. if (rid != null && !hexId(rid)) { return redirect('/') }
  195. let home = rid != null ? '/signin/' + rid : '/'
  196. if (req.method != 'GET') { return redirect(home) }
  197. let s = req.session
  198. if (s == null || accountOfSession(s) != null || pendingOf(s) == null) { return redirect(home) }
  199. let m = { route = { pattern = route.route.pattern component = Home } kind = 'component' params = { rid = rid step = 'code' } path = route.path }
  200. return server.page(m, req, s)
  201. }
  202. hexId = (v) => {
  203. if (v.length != 32) { return false }
  204. let i = 0
  205. while (i < v.length) {
  206. if (!'0123456789abcdef'.includes(v[i])) { return false }
  207. i = i + 1
  208. }
  209. return true
  210. }
  211. notFoundApi = (route, req) => { return fail(404, 'no such endpoint') }
  212. routes = [
  213. { pattern = "/favicon.ico" direct = "" }
  214. { pattern = "/login" function = appLogin }
  215. { pattern = "/api/exchange" function = apiExchange }
  216. { pattern = "/api/migrate-ids" function = apiMigrateIds }
  217. { pattern = "/api/code" function = apiCode }
  218. { pattern = "/api/selector/identities" function = apiSelectorIdentities }
  219. { pattern = "/api/selector/choose" function = apiSelectorChoose }
  220. { pattern = "/api/invites" function = apiInvites }
  221. { pattern = "/api/invites/list" function = apiInvitesList }
  222. { pattern = "/api/invites/get" function = apiInvitesGet }
  223. { pattern = "/api/invites/revoke" function = apiInvitesRevoke }
  224. { pattern = "/invite/:token" function = inviteLink }
  225. { pattern = "/api/kinds" function = apiKinds }
  226. { pattern = "/api/notify" function = apiNotify }
  227. { pattern = "/selector.js" file = "./selector.js" headers = { 'Cache-Control' = 'no-cache' } }
  228. { pattern = "/avatar.js" file = "./avatar.js" headers = { 'Cache-Control' = 'no-cache' } }
  229. { pattern = "/api/*" function = notFoundApi }
  230. { pattern = "/code" function = codePage }
  231. { pattern = "/signin/:rid/code" function = codePage }
  232. { pattern = "/" component = Home }
  233. { pattern = "/signin/:rid" component = Home }
  234. { pattern = "/apps" component = Apps }
  235. { pattern = "/inbox" component = Inbox }
  236. { pattern = "/inbox/:cid" component = AppSettings }
  237. ]
  238. // the mailer's result handlers live on an INSTANCE of mail.hl
  239. mail = new Mail()
  240. sessionDir = env('IDENT_SESSIONS') != null ? env('IDENT_SESSIONS') : null
  241. port = env('IDENT_PORT') != null ? toNumber(env('IDENT_PORT')) : 8351
  242. // THE LISTENER's interface: hl:web reads HL_HOST (or HOST) itself (hybriel#24): 127.0.0.1 on
  243. // Byrodin behind nginx; unset = 0.0.0.0 (dev on Loreana).
  244. // IDENT_WATCH=0 turns the dev watcher off (the container: a deploy is an rsync + restart,
  245. // half-copied .hl files must not be re-analysed); unset = on, as before.
  246. watching = env('IDENT_WATCH') != '0'
  247. // WHO A PUSHED EVENT IS FOR: "signed out everywhere" reaches every open connection whose
  248. // session is the account's (checked before dropUserSessions clears the sessions).
  249. audience = {
  250. signedOutAll = (accountId, session) => { return session != null && session.user != null && session.user.id == accountId }
  251. }
  252. // ident's OWN COOKIE NAME. Cookies are per host, not per port: with the default `hlsid` an app
  253. // on the same host (tickets on :8350) and ident would overwrite each other's session cookie
  254. // → hl:web's `sessionCookie` setting (hybriel#10/#17).
  255. sessionCookie = 'identsid'
  256. server = new WebFramework(routes = routes, styles = styles, audience = audience, minify = true, port = port, watchMode = watching, sessionCookie = sessionCookie)
  257. // ---- SIGN OUT EVERYWHERE: DELETE EVERY SESSION OF AN ACCOUNT (ticket #19) ----------------
  258. // Resident ones (dropped from memory, their user cleared so a still-open socket's session
  259. // object cannot be written back signed in) and the files of the ones nobody has open.
  260. dropUserSessions = (userId) => {
  261. let sess = server.sessions
  262. let gone = []
  263. for (k of sess.map.keys()) {
  264. let x = sess.map[k]
  265. if (x != null && x.user != null && x.user.id == userId) { gone.push(k) }
  266. }
  267. for (k of gone) {
  268. sess.map[k].user = null
  269. sess.drop(k)
  270. sess.map[k] = null
  271. }
  272. if (sess.dir != null) {
  273. for (e of listDir(sess.dir)) {
  274. let raw = readFile(e.path)
  275. if (raw != null) {
  276. let rec = JSON.parse(raw)
  277. if (rec != null && rec.user != null && rec.user.id == userId) { remove(e.path) }
  278. }
  279. }
  280. }
  281. return null
  282. }
  283. sessionHooks.dropUser = dropUserSessions

Branches

Latest commits

  • 81b15b7bState of 2026-09-27, before the move to gitoriamre