gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8bebbbf28bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre8bebbbf2/invites.hl

9.0 KB

  1. // invites.hl — THE INVITE SERVICE (ticket ident#22). Statics only, the server realm.
  2. // An app (key + secret) asks ident for an INVITE for its project and role and gets a link
  3. // `<ident>/invite/<token>`. Whoever opens the link joins with ident: the login button's
  4. // own flow (apps.hl) — email → code if signed out, the identity choice if signed in — and
  5. // ident sends the browser back to the app's `return` URL with `ident_code` (the identity)
  6. // AND `invite=<invite id>`. The app's server then asks POST /api/invites/get which identity
  7. // accepted the invite (compare it with the one the exchange gave). Concept: CONCEPT.md is
  8. // silent on invites; the ticket is the spec.
  9. //
  10. // invitesTable pk @id index @app, !hash (apps.hl)
  11. // { app (app @id), hash = sha256(token), project, role, returnUrl, uses, acceptedBy
  12. // ('a b c': the identities' short ids that accepted it (invites accepted before ident#23 hold the old per-app id), space separated),
  13. // expires, revoked (0 or the time), mailedAt (0 or the time), created }
  14. //
  15. // Single use by default (`uses`, up to 1000), 7 days by default (`days`, up to 90). The link
  16. // (token) is shown once, in the answer of the create call; only its sha256 is stored.
  17. // A state is one of: revoked, used (all uses taken), expired, open.
  18. import { now } from 'hl:time'
  19. import { randomBytes, sha256 } from 'hl:crypto'
  20. import { env } from 'hl:proc'
  21. import { invitesTable, requestsTable, requestOf, requestTtl, checkReturn, originsOf, issueCode, connectionOf } from './apps.hl'
  22. import { envNumber, countOf, first, merged, hasControl, validEmail, normEmail, isId, oldestFirst, ownIdentity, identitiesTable } from './store.hl'
  23. static dayMs = envNumber('IDENT_INVITE_DAY_MS', 86400000) // a day (the gate shortens it)
  24. static defaultUses = 1
  25. static maxUses = 1000
  26. static defaultDays = 7
  27. static maxDays = 90
  28. static maxText = 60
  29. // mails one app may send through ident per 24 h (an app must not turn ident into a mail cannon)
  30. static mailLimit = envNumber('IDENT_INVITE_MAIL_LIMIT', 50)
  31. static publicUrl = env('IDENT_PUBLIC_URL') != null && env('IDENT_PUBLIC_URL') != '' ? env('IDENT_PUBLIC_URL') : null
  32. static isWhole = (n) => { return hlTypeName(n) == 'Number' && !('' + n).includes('.') && !('' + n).includes('e') }
  33. // ---- state ----------------------------------------------------------------------------
  34. static acceptedList = (rec) => { return rec.acceptedBy == null || rec.acceptedBy == '' ? [] : rec.acceptedBy.split(' ') }
  35. static usedCount = (rec) => { return acceptedList(rec).length }
  36. static stateOf = (rec) => {
  37. if (rec.revoked != null && rec.revoked > 0) { return 'revoked' }
  38. if (usedCount(rec) >= rec.uses) { return 'used' }
  39. if (rec.expires < now()) { return 'expired' }
  40. return 'open'
  41. }
  42. // what the app sees of an invite (never the token)
  43. static inviteRow = (rec) => {
  44. return { id = rec.id project = rec.project role = rec.role state = stateOf(rec) uses = rec.uses used = usedCount(rec) identities = acceptedList(rec) expires = rec.expires created = rec.created }
  45. }
  46. // the message of a state that cannot be accepted
  47. static problemOf = (state) => {
  48. if (state == 'used') { return 'This invitation was already used.' }
  49. if (state == 'expired') { return 'This invitation has expired. Ask whoever invited you for a new one.' }
  50. if (state == 'revoked') { return 'This invitation was withdrawn. Ask whoever invited you for a new one.' }
  51. return ''
  52. }
  53. // ---- the app's calls -------------------------------------------------------------------
  54. static checkText = (v, name) => {
  55. if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + name + ' must be a string' } }
  56. let t = v.trim()
  57. if (t == '') { return { error = 'field ' + name + ' is empty' } }
  58. if (t.length > maxText) { return { error = 'field ' + name + ' is longer than ' + maxText + ' characters' } }
  59. if (hasControl(t)) { return { error = 'field ' + name + ' contains a control character' } }
  60. return { text = t }
  61. }
  62. static mailedLately = (appId) => {
  63. let n = 0
  64. let t0 = now() - dayMs
  65. let rows = invitesTable.find('app', appId)
  66. if (countOf(rows) > 0) { for (r of rows) { if (r.mailedAt != null && r.mailedAt > t0) { n = n + 1 } } }
  67. return n
  68. }
  69. // `base` is where the link points (the request's own origin when IDENT_PUBLIC_URL is unset).
  70. // answers { status, error } or { invite (row), url, mail (null | { to, days }) } — the caller mails
  71. static createInvite = (appRec, b, base) => {
  72. let p = checkText(b.project, 'project')
  73. if (p.error != null) { return { status = 400 error = p.error } }
  74. let r = checkText(b.role, 'role')
  75. if (r.error != null) { return { status = 400 error = r.error } }
  76. let rt = checkReturn(b['return'], appRec)
  77. if (rt.error != null) { return { status = 400 error = rt.error } }
  78. let uses = b.uses == null ? defaultUses : b.uses
  79. if (!isWhole(uses) || uses < 1 || uses > maxUses) { return { status = 400 error = 'field uses must be a whole number from 1 to ' + maxUses } }
  80. let days = b.days == null ? defaultDays : b.days
  81. if (!isWhole(days) || days < 1 || days > maxDays) { return { status = 400 error = 'field days must be a whole number from 1 to ' + maxDays } }
  82. let to = null
  83. if (b.email != null) {
  84. to = normEmail(b.email)
  85. if (!validEmail(to)) { return { status = 400 error = 'field email is not an email address' } }
  86. if (mailedLately(appRec.id) >= mailLimit) { return { status = 429 error = 'too many invitation mails from this app in 24 hours' } }
  87. }
  88. let token = randomBytes(16)
  89. let t = now()
  90. let id = invitesTable.put({ app = appRec.id hash = sha256(token) project = p.text role = r.text returnUrl = b['return'] uses = uses acceptedBy = '' expires = t + days * dayMs revoked = 0 mailedAt = to != null ? t : 0 created = t })
  91. return { invite = inviteRow(invitesTable.fetch(id)) url = base + '/invite/' + token mail = to != null ? { to = to days = days } : null }
  92. }
  93. // the invite `id` if the app owns it, else null
  94. static ownInvite = (appRec, id) => {
  95. if (!isId(id)) { return null }
  96. let r = invitesTable.fetch(id)
  97. if (r == null || r.app != appRec.id) { return null }
  98. return r
  99. }
  100. // all of the app's invites, oldest first; `project` (optional) narrows it
  101. static listInvites = (appRec, project) => {
  102. let out = []
  103. for (r of oldestFirst(invitesTable.find('app', appRec.id))) {
  104. if (project == null || r.project == project) { out.push(inviteRow(r)) }
  105. }
  106. return out
  107. }
  108. // answers { status, error } or { invite (row) }
  109. static revokeInvite = (appRec, id) => {
  110. let r = ownInvite(appRec, id)
  111. if (r == null) { return { status = 404 error = 'no such invite' } }
  112. let s = stateOf(r)
  113. if (s != 'open') { return { status = 409 error = 'the invite is not open (it is ' + s + ')' } }
  114. invitesTable.update(r.id, merged(r, { revoked = now() }))
  115. return { invite = inviteRow(invitesTable.fetch(r.id)) }
  116. }
  117. // ---- the person's side ------------------------------------------------------------------
  118. // GET /invite/<token>: answers { status, title, error } (an error page) or { rid } — a login
  119. // request of the app (apps.hl) that carries the invite, so the login flow does the rest.
  120. static openInvite = (token) => {
  121. let rec = token == null || hlTypeName(token) != 'String' || token.length > 64 ? null : first(invitesTable.find('hash', sha256(token)))
  122. if (rec == null) { return { status = 404 title = 'Unknown invitation' error = 'This invitation link is not valid. Check that you copied all of it, or ask whoever invited you for a new one.' } }
  123. let s = stateOf(rec)
  124. if (s != 'open') { return { status = 410 title = s == 'used' ? 'Invitation already used' : (s == 'expired' ? 'Invitation expired' : 'Invitation withdrawn') error = problemOf(s) } }
  125. let rid = randomBytes(16)
  126. requestsTable.put({ rid = rid app = rec.app returnUrl = rec.returnUrl invite = rec.id expires = now() + requestTtl })
  127. return { rid = rid }
  128. }
  129. // THE CHOICE for an invite's request: the identity accepts it. Answers { error } or { url }
  130. // (the app's return URL with ident_code and invite). An identity that already accepted this
  131. // invite may pass again without taking another use.
  132. static grantInvite = (accountId, rid, identityId) => {
  133. let rq = requestOf(rid)
  134. if (rq == null) { return { error = 'this login request is unknown or expired — open the invitation link again' } }
  135. let rec = isId(rq.invite) ? invitesTable.fetch(rq.invite) : null
  136. if (rec == null || rec.app != rq.app.id) { return { error = 'this invitation no longer exists' } }
  137. if (ownIdentity(accountId, identityId) == null) { return { error = 'no such identity' } }
  138. let conn = connectionOf(rq.app.id, identityId)
  139. let list = acceptedList(rec)
  140. let s = stateOf(rec)
  141. if (s == 'revoked') { return { error = problemOf(s) } }
  142. let mine = identitiesTable.fetch(identityId).shortId
  143. if (!list.includes(mine) && !(conn.appIdentity != null && list.includes(conn.appIdentity))) {
  144. if (s != 'open') { return { error = problemOf(s) } }
  145. list.push(mine)
  146. invitesTable.update(rec.id, merged(rec, { acceptedBy = list.join(' ') }))
  147. }
  148. let code = issueCode(rq.app.id, identityId)
  149. let r = first(requestsTable.find('rid', rid))
  150. if (r != null) { requestsTable.delete(r.id) }
  151. let sep = rq.returnUrl.includes('?') ? '&' : '?'
  152. return { url = rq.returnUrl + sep + 'ident_code=' + code + '&invite=' + rec.id }
  153. }

Branches

Latest commits

  • 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