gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain5fdbb6b2ident mission 009: report — scratch folder notemremain/lib/invites.hl

7.5 KB

  1. // lib/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 (lib/apps.hl openInvite / grantInvite) — email → code if signed out, the identity choice if
  5. // signed in — and 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. // This file holds the invites table and every write to it; the checks: lib/invites-helpers.hl.
  10. //
  11. // invitesTable pk @id index @app, !hash
  12. // { app (app @id), hash = sha256(token), project, role, returnUrl, uses, acceptedBy
  13. // ('a b c': the identities' short ids that accepted it (invites accepted before ident#23 hold the old per-app id), space separated),
  14. // expires, revoked (0 or the time), mailedAt (0 or the time), created }
  15. //
  16. // Single use by default (`uses`, up to 1000), 7 days by default (`days`, up to 90). The link
  17. // (token) is shown once, in the answer of the create call; only its sha256 is stored.
  18. // A state is one of: revoked, used (all uses taken), expired, open.
  19. import { MPackDB } from 'hl:mpackdb'
  20. import { now } from 'hl:time'
  21. import { randomBytes, sha256 } from 'hl:crypto'
  22. import { env } from 'hl:proc'
  23. import { dir, envNumber, countOf, first, merged, validEmail, normEmail, isId, oldestFirst } from './util.hl'
  24. import { checkReturn } from './apps-helpers.hl'
  25. import { isWhole, checkText, acceptedList, usedCount, problemOf } from './invites-helpers.hl'
  26. import { identitiesTable } from './identities.hl'
  27. static invitesTable = new MPackDB(file = dir + '/invites.db', primaryKey = '@id', indexes = ['@app' '!hash'])
  28. static dayMs = envNumber('IDENT_INVITE_DAY_MS', 86400000) // a day (the gate shortens it)
  29. static defaultUses = 1
  30. static maxUses = 1000
  31. static defaultDays = 7
  32. static maxDays = 90
  33. // mails one app may send through ident per 24 h (an app must not turn ident into a mail cannon)
  34. static mailLimit = envNumber('IDENT_INVITE_MAIL_LIMIT', 50)
  35. static publicUrl = env('IDENT_PUBLIC_URL') != null && env('IDENT_PUBLIC_URL') != '' ? env('IDENT_PUBLIC_URL') : null
  36. // ---- state ----------------------------------------------------------------------------
  37. static stateOf = (rec) => {
  38. if (rec.revoked != null && rec.revoked > 0) { return 'revoked' }
  39. if (usedCount(rec) >= rec.uses) { return 'used' }
  40. if (rec.expires < now()) { return 'expired' }
  41. return 'open'
  42. }
  43. // what the app sees of an invite (never the token)
  44. static inviteRow = (rec) => {
  45. 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 }
  46. }
  47. // ---- the app's calls -------------------------------------------------------------------
  48. static mailedLately = (appId) => {
  49. let n = 0
  50. t0 = now() - dayMs
  51. rows = invitesTable.find('app', appId)
  52. if (countOf(rows) > 0) { for (r of rows) { if (r.mailedAt != null && r.mailedAt > t0) { n = n + 1 } } }
  53. return n
  54. }
  55. // `base` is where the link points (the request's own origin when IDENT_PUBLIC_URL is unset).
  56. // answers { status, error } or { invite (row), url, mail (null | { to, days }) } — the caller mails
  57. static createInvite = (appRec, b, base) => {
  58. p = checkText(b.project, 'project')
  59. if (p.error != null) { return { status = 400 error = p.error } }
  60. r = checkText(b.role, 'role')
  61. if (r.error != null) { return { status = 400 error = r.error } }
  62. rt = checkReturn(b['return'], appRec)
  63. if (rt.error != null) { return { status = 400 error = rt.error } }
  64. let uses = b.uses == null ? defaultUses : b.uses
  65. if (!isWhole(uses) || uses < 1 || uses > maxUses) { return { status = 400 error = 'field uses must be a whole number from 1 to ' + maxUses } }
  66. let days = b.days == null ? defaultDays : b.days
  67. if (!isWhole(days) || days < 1 || days > maxDays) { return { status = 400 error = 'field days must be a whole number from 1 to ' + maxDays } }
  68. let to = null
  69. if (b.email != null) {
  70. to = normEmail(b.email)
  71. if (!validEmail(to)) { return { status = 400 error = 'field email is not an email address' } }
  72. if (mailedLately(appRec.id) >= mailLimit) { return { status = 429 error = 'too many invitation mails from this app in 24 hours' } }
  73. }
  74. token = randomBytes(16)
  75. t = now()
  76. 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 })
  77. return { invite = inviteRow(invitesTable.fetch(id)) url = base + '/invite/' + token mail = to != null ? { to = to days = days } : null }
  78. }
  79. // the invite `id` if the app owns it, else null
  80. static ownInvite = (appRec, id) => {
  81. if (!isId(id)) { return null }
  82. r = invitesTable.fetch(id)
  83. if (r == null || r.app != appRec.id) { return null }
  84. return r
  85. }
  86. // all of the app's invites, oldest first; `project` (optional) narrows it
  87. static listInvites = (appRec, project) => {
  88. out = []
  89. for (r of oldestFirst(invitesTable.find('app', appRec.id))) {
  90. if (project == null || r.project == project) { out.push(inviteRow(r)) }
  91. }
  92. return out
  93. }
  94. // answers { status, error } or { invite (row) }
  95. static revokeInvite = (appRec, id) => {
  96. r = ownInvite(appRec, id)
  97. if (r == null) { return { status = 404 error = 'no such invite' } }
  98. s = stateOf(r)
  99. if (s != 'open') { return { status = 409 error = 'the invite is not open (it is ' + s + ')' } }
  100. invitesTable.update(r.id, merged(r, { revoked = now() }))
  101. return { invite = inviteRow(invitesTable.fetch(r.id)) }
  102. }
  103. // DELETING AN APP (lib/apps.hl deleteApp) drops its invites with it
  104. static dropInvitesOf = (appId) => {
  105. invs = invitesTable.find('app', appId)
  106. if (countOf(invs) > 0) { for (v of invs) { invitesTable.delete(v.id) } }
  107. return null
  108. }
  109. // ---- the person's side (the login flow is lib/apps.hl openInvite / grantInvite) ---------------
  110. // GET /invite/<token>: answers { status, title, error } (an error page) or { invite (record) } — one that can
  111. // still be accepted
  112. static openableInvite = (token) => {
  113. rec = token == null || hlTypeName(token) != 'String' || token.length > 64 ? null : first(invitesTable.find('hash', sha256(token)))
  114. 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.' } }
  115. s = stateOf(rec)
  116. if (s != 'open') { return { status = 410 title = s == 'used' ? 'Invitation already used' : (s == 'expired' ? 'Invitation expired' : 'Invitation withdrawn') error = problemOf(s) } }
  117. return { invite = rec }
  118. }
  119. static inviteById = (id) => { return isId(id) ? invitesTable.fetch(id) : null }
  120. // THE IDENTITY ACCEPTS the invite `rec` (its connection `conn` to the app is made already). Answers { error } or
  121. // { ok }. An identity that already accepted this invite may pass again without taking another use.
  122. static acceptInvite = (rec, identityId, conn) => {
  123. list = acceptedList(rec)
  124. s = stateOf(rec)
  125. if (s == 'revoked') { return { error = problemOf(s) } }
  126. mine = identitiesTable.fetch(identityId).shortId
  127. if (!list.includes(mine) && !(conn.appIdentity != null && list.includes(conn.appIdentity))) {
  128. if (s != 'open') { return { error = problemOf(s) } }
  129. list.push(mine)
  130. invitesTable.update(rec.id, merged(rec, { acceptedBy = list.join(' ') }))
  131. }
  132. return { ok = true }
  133. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
  • fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
  • 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