gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc7bd2645c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mrec7bd2645/users.hl

11.9 KB

  1. // users.hl — WHO WRITES (ticket tickets.worldapi.org#7, mission 011; CONCEPT.md "Login via
  2. // ident"). Reading stays public; writing needs a login through ident. Two hl:mpackdb tables
  3. // beside the ticket tables (storage/mpackdb/, UUID keys — creator's convention):
  4. //
  5. // usersTable pk @id index !identity { identity, name, created }
  6. // identity = ident's PER-APP identity id (32 hex) — the answer of POST <ident>/api/exchange.
  7. // It stays SERVER SIDE (ident CONCEPT: "the app never exposes the identity id");
  8. // the one exception is the user's own /you page (mission 011: the architect
  9. // needs the creator's id for TICKETS_CREATOR_IDENTITY).
  10. // name = the DISPLAY NAME, asked once at the first login (CONCEPT point 6). An ordinary
  11. // field ('' until chosen) so ident can fill it later (the "handshake" properties).
  12. // tokensTable pk @id index !hash user { user (users @id), hash = sha256(token), label, created }
  13. // API TOKENS for machine clients (CONCEPT point 5): `tkt_` + 48 hex, shown ONCE; only the
  14. // sha256 is stored. `Authorization: Bearer <token>` acts as that user. Revoke = the row goes.
  15. //
  16. // THE SESSION (hl:web) carries `user = { id = <users @id> }` only — hl:web ships
  17. // `session.user` to the page, so never the identity id. `session.data.tag` is a random tag of
  18. // this login: the audience addresses `signedIn` / `signedOut` to THIS session's tabs by it.
  19. //
  20. // Config (environment, or `.env` beside project.hl — the runtime loads it; never commit it):
  21. // IDENT_URL ident's public origin (selector script, login button). Default https://ident.worldapi.org
  22. // IDENT_EXCHANGE_URL where the SERVER posts /api/exchange. Default = IDENT_URL
  23. // IDENT_API_KEY / IDENT_API_SECRET this app's registration in ident (pk_… public, sk_… secret)
  24. // TICKETS_PUBLIC_URL this app's public origin (the login button's return URL). Default https://tickets.worldapi.org
  25. // TICKETS_CREATOR_IDENTITY the creator's ident id (a short id like a68sz; before ident#23 32 hex). Since ticket #20 there is
  26. // no creator-only workflow: it is read ONLY by migrate.hl, which makes this person the admin of
  27. // the projects that existed before roles. Unset = those projects get no admin.
  28. import { MPackDB } from 'hl:mpackdb'
  29. import { env } from 'hl:proc'
  30. import { now } from 'hl:time'
  31. import { randomBytes, sha256 } from 'hl:crypto'
  32. import { fetch } from 'hl:fetch'
  33. import { localStamp } from './localtime.hl'
  34. static envOr = (name, fallback) => {
  35. let v = env(name)
  36. return v != null && v.trim() != '' ? v.trim() : fallback
  37. }
  38. static identUrl = envOr('IDENT_URL', 'https://ident.worldapi.org')
  39. static identExchangeUrl = envOr('IDENT_EXCHANGE_URL', identUrl)
  40. static identKey = envOr('IDENT_API_KEY', '')
  41. static identSecret = envOr('IDENT_API_SECRET', '')
  42. static publicUrl = envOr('TICKETS_PUBLIC_URL', 'https://tickets.worldapi.org')
  43. static creatorIdentity = envOr('TICKETS_CREATOR_IDENTITY', '')
  44. static userDir = env('TICKETS_STORAGE') != null ? env('TICKETS_STORAGE') : './storage/mpackdb'
  45. static usersTable = new MPackDB(file = userDir + '/users.db', primaryKey = '@id', indexes = ['!identity'])
  46. static tokensTable = new MPackDB(file = userDir + '/tokens.db', primaryKey = '@id', indexes = ['!hash', 'user'])
  47. static countOfList = (list) => {
  48. if (list == null) { return 0 }
  49. let n = list.length
  50. return n == null ? 0 : n
  51. }
  52. static firstOf = (list) => { return countOfList(list) > 0 ? list[0] : null }
  53. // a query value, percent-encoded (encodeURIComponent is a global, hybriel#14)
  54. static encode = (s) => { return encodeURIComponent('' + s) }
  55. // ---- the two ways in (ident README "How apps use ident") ---------------------------------
  56. static selectorScript = identUrl + '/selector.js'
  57. static callbackUrl = publicUrl + '/login/callback'
  58. static loginHref = identUrl + '/login?key=' + identKey + '&return=' + encode(callbackUrl)
  59. // an identity id as ident answers it: its public SHORT ID since ident#23 (5 characters like `a68sz`: 2-9 and a-z),
  60. // before that the old per-app id (32 hex) — lower case letters and digits, at most 64 (as gitoria's users.hl)
  61. static isIdentId = (s) => {
  62. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > 64) { return false }
  63. let i = 0
  64. while (i < s.length) {
  65. let c = s.charCodeAt(i)
  66. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122))) { return false }
  67. i = i + 1
  68. }
  69. return true
  70. }
  71. // only lowercase hex (ident's one-time codes are 48 hex)
  72. static isHex = (s, max) => {
  73. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > max) { return false }
  74. let i = 0
  75. while (i < s.length) {
  76. let c = s.charCodeAt(i)
  77. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102))) { return false }
  78. i = i + 1
  79. }
  80. return true
  81. }
  82. // A FAILED FETCH (refused connection, timeout, TLS) is an `Error` event, not an answer: the
  83. // global `on Error` in project.hl absorbs it, the fetch then yields null → "ident did not answer"
  84. // (a 400 page / a message) instead of a 500 with source paths. (A handler in THIS file does not
  85. // catch it — mission 011.)
  86. // THE EXCHANGE: POST <ident>/api/exchange { key, secret, code } → { identity } | { error }
  87. static exchangeCode = (code) => {
  88. if (identKey == '' || identSecret == '') { return { error = 'login is not set up on this server (IDENT_API_KEY / IDENT_API_SECRET missing)' } }
  89. if (!isHex(code, 200)) { return { error = 'that is not an ident login code' } }
  90. let r = fetch(identExchangeUrl + '/api/exchange', { method = 'POST' json = { key = identKey secret = identSecret code = code } headers = { 'user-agent' = 'tickets.worldapi.org (ident exchange)' } timeoutMs = 10000 })
  91. if (r == null || r.status == null || r.status == 0) { return { error = 'ident did not answer' } }
  92. let j = r.status == 200 ? r.json() : null
  93. if (j == null || j.identity == null || !isIdentId(j.identity)) {
  94. let why = ''
  95. if (r.status != 200) {
  96. let e = r.json()
  97. why = e != null && e.error != null ? ': ' + e.error : ''
  98. }
  99. return { error = 'ident refused the login (' + r.status + why + ')' }
  100. }
  101. return { identity = j.identity }
  102. }
  103. // ---- users ------------------------------------------------------------------------------
  104. static userRecord = (userId) => {
  105. if (userId == null || hlTypeName(userId) != 'String' || userId == '') { return null }
  106. return usersTable.fetch(userId)
  107. }
  108. // the user of an identity id, made at its first login (name '' = not chosen yet)
  109. static ensureUser = (identity) => {
  110. let u = firstOf(usersTable.find('identity', identity))
  111. if (u != null) { return u }
  112. let id = usersTable.put({ identity = identity name = '' created = now() })
  113. if (id == null) { return null }
  114. return usersTable.fetch(id)
  115. }
  116. // THE SITE CREATOR'S user record (TICKETS_CREATOR_IDENTITY), or null while they never logged in
  117. static creatorUser = () => {
  118. if (creatorIdentity == '') { return null }
  119. return firstOf(usersTable.find('identity', creatorIdentity))
  120. }
  121. // the same, made if missing (migrate.hl: the creator is admin before their first login here)
  122. static ensureCreatorUser = () => {
  123. if (creatorIdentity == '') { return null }
  124. return ensureUser(creatorIdentity)
  125. }
  126. // what a page may know about a user: NEVER the identity id
  127. static userInfo = (u) => {
  128. return { name = u.name named = u.name != '' }
  129. }
  130. static userOfSession = (session) => {
  131. if (session == null || session.user == null) { return null }
  132. return userRecord(session.user.id)
  133. }
  134. static infoOfSession = (session) => {
  135. let u = userOfSession(session)
  136. return u == null ? null : userInfo(u)
  137. }
  138. // may this session write (logged in AND a display name chosen)?
  139. static canWriteSession = (session) => {
  140. let u = userOfSession(session)
  141. return u != null && u.name != ''
  142. }
  143. // THE WRITER of a web write: { user } or { error } (the message the page shows)
  144. static writerOfSession = (session) => {
  145. let u = userOfSession(session)
  146. if (u == null) { return { error = 'log in with ident (top right) to write' } }
  147. if (u.name == '') { return { error = 'choose a display name first (top of the page)' } }
  148. return { user = u }
  149. }
  150. // the author a history shows for an event written by a user: the CURRENT display name
  151. static nameOfUser = (userId) => {
  152. let u = userRecord(userId)
  153. if (u == null || u.name == '') { return 'someone' }
  154. return u.name
  155. }
  156. // a short one-line text: trimmed, at most `max` characters, no control characters
  157. static plainError = (s, max, what) => {
  158. if (s == null || hlTypeName(s) != 'String') { return what + ' must be text' }
  159. let t = s.trim()
  160. if (t.length > max) { return what + ' is too long (at most ' + max + ' characters)' }
  161. let i = 0
  162. while (i < t.length) {
  163. let c = t.charCodeAt(i)
  164. if (c < 32 || c == 127) { return what + ' must be one line without control characters' }
  165. i = i + 1
  166. }
  167. return null
  168. }
  169. // the display name: 1–60 characters, one line. Asked ONCE (CONCEPT point 6): a name that is
  170. // set is not changed here (no rename UI — an open question for the creator)
  171. static setUserName = (userId, name) => {
  172. let u = userRecord(userId)
  173. if (u == null) { return { error = 'not logged in' } }
  174. if (u.name != '') { return { error = 'your display name is already set' } }
  175. let bad = plainError(name, 60, 'the display name')
  176. if (bad != null) { return { error = bad } }
  177. let n = name.trim()
  178. if (n == '') { return { error = 'the display name must not be empty' } }
  179. // the whole record, its `id` included (hl:mpackdb refuses an @id record without it: CorruptRecord)
  180. usersTable.update(u.id, { id = u.id identity = u.identity name = n created = u.created })
  181. let after = usersTable.fetch(u.id)
  182. if (after == null || after.name != n) { return { error = 'could not store the name: ' + usersTable.lastError() } }
  183. return { user = after }
  184. }
  185. // ---- API tokens ---------------------------------------------------------------------------
  186. static tokenRowOf = (t) => {
  187. return { id = t.id label = t.label != null && t.label != '' ? t.label : '(no label)' created = localStamp(t.created) createdMs = t.created }
  188. }
  189. // the user's tokens, newest first
  190. static tokenRows = (userId) => {
  191. let out = []
  192. let all = tokensTable.find('user', userId)
  193. if (countOfList(all) == 0) { return out }
  194. for (t of all) { out.push(tokenRowOf(t)) }
  195. let i = 1
  196. while (i < out.length) {
  197. let cur = out[i]
  198. let j = i - 1
  199. while (j >= 0 && out[j].createdMs < cur.createdMs) {
  200. out[j + 1] = out[j]
  201. j = j - 1
  202. }
  203. out[j + 1] = cur
  204. i = i + 1
  205. }
  206. return out
  207. }
  208. // answers { token (shown ONCE), tokens } or { error }
  209. static createToken = (userId, label) => {
  210. let bad = plainError(label == null ? '' : label, 60, 'the label')
  211. if (bad != null) { return { error = bad } }
  212. let token = 'tkt_' + randomBytes(24)
  213. let id = tokensTable.put({ user = userId hash = sha256(token) label = (label == null ? '' : label.trim()) created = now() })
  214. if (id == null) { return { error = 'could not store the token: ' + tokensTable.lastError() } }
  215. return { token = token tokens = tokenRows(userId) }
  216. }
  217. // only the owner revokes; the row goes, the token is dead at once
  218. static revokeToken = (userId, tokenId) => {
  219. if (tokenId == null || hlTypeName(tokenId) != 'String' || tokenId == '') { return { error = 'no such token' } }
  220. let t = tokensTable.fetch(tokenId)
  221. if (t == null || t.user != userId) { return { error = 'no such token' } }
  222. tokensTable.delete(t.id)
  223. return { tokens = tokenRows(userId) }
  224. }
  225. // `Authorization: Bearer <token>` → the user, or null (missing, malformed, unknown, revoked)
  226. static userOfBearer = (header) => {
  227. if (header == null || hlTypeName(header) != 'String') { return null }
  228. let h = header.trim()
  229. if (!h.startsWith('Bearer ') && !h.startsWith('bearer ')) { return null }
  230. let token = h.slice(7).trim()
  231. if (!token.startsWith('tkt_') || token.length != 52) { return null }
  232. let t = firstOf(tokensTable.find('hash', sha256(token)))
  233. if (t == null) { return null }
  234. return userRecord(t.user)
  235. }
  236. // ---- the /you page: the user's OWN data (the only place the identity id is shown) -----------
  237. static youOf = (session) => {
  238. let u = userOfSession(session)
  239. if (u == null) { return null }
  240. return { name = u.name named = u.name != '' identity = u.identity tokens = tokenRows(u.id) }
  241. }

Branches

Latest commits

  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre