gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit98226b4198226b41antcolony#40: mission references point to the moved missionsmre98226b41/project.hl

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

Branches

Latest commits

  • 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