gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit38f9d10f38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre38f9d10f/connections.hl

10.9 KB

  1. // connections.hl — AN APP IS CONNECTED TO ONE PROJECT (ticket tickets.worldapi.org#21; first app: gitoria).
  2. // Statics only. One hl:mpackdb table beside the others (storage/mpackdb/connections.db, UUID keys):
  3. //
  4. // connectionsTable pk @id index nonce, hash, codeHash, project
  5. // { status, nonce, app, label, returnUrl, state, project, createdBy, codeHash, expires, hash, created }
  6. //
  7. // THE FLOW (README "Connecting an app"):
  8. // 1. the app sends the browser to <tickets>/connect?app=gitoria&label=<owner/repo>&return=<url>&state=<opaque>
  9. // → a REQUEST (status 'request', an unguessable nonce) and a redirect to /connect/<nonce> (components/connect.hl).
  10. // 2. the person logs in (ident), picks a project they are ADMIN of — or makes a new one — and confirms.
  11. // The request becomes a connection (status 'waiting') with a ONE-TIME CODE (5 minutes); the page links back to
  12. // `return?code=<code>&state=<state>`.
  13. // 3. the app exchanges the code on the server: POST /api/connect/exchange { code } → { key, project }.
  14. // The connection is 'active'; only the sha256 of the key is stored (`tktc_` + 48 hex, shown once).
  15. // 4. the key acts inside THAT ONE project only (project.hl apiAuth / actorIn): create tickets, comment, change state.
  16. // Every write names the person: header `X-Tickets-Identity: <ident public id>`; the person is a tickets user
  17. // (they logged in here once and chose a name) and the project's ROLES apply to them as to a token.
  18. // 5. the project page shows "connected to <app>: <label>"; an admin's "Disconnect" deletes the row — the key is dead at once.
  19. // The return URL is the app's: only https on worldapi.org (and its subdomains) — or an origin listed in
  20. // TICKETS_CONNECT_ORIGINS (comma separated, e.g. http://127.0.0.1:8700 for a dev copy). The page shows the host.
  21. import { MPackDB } from 'hl:mpackdb'
  22. import { now } from 'hl:time'
  23. import { randomBytes, sha256 } from 'hl:crypto'
  24. import { env } from 'hl:proc'
  25. import { userDir, firstOf, countOfList, isHex, isIdentId, plainError, usersTable, nameOfUser, publicUrl } from './users.hl'
  26. import { projectRecord, projectRecords, isAdminOf, createProject, userOk, notAdmin } from './store.hl'
  27. static connectionsTable = new MPackDB(file = userDir + '/connections.db', primaryKey = '@id', indexes = ['nonce', 'hash', 'codeHash', 'project'])
  28. static requestTtlMs = 3600000
  29. static codeTtlMs = 300000
  30. static extraOrigins = () => {
  31. let v = env('TICKETS_CONNECT_ORIGINS')
  32. let out = []
  33. if (v == null || v.trim() == '') { return out }
  34. for (o of v.split(',')) { if (o.trim() != '') { out.push(o.trim()) } }
  35. return out
  36. }
  37. // the characters a return URL may hold: URL-safe, no spaces, no quotes, no '#' (the code goes in the query)
  38. static urlChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;:@!$()*'
  39. // the host (with port) of an absolute URL: everything between '://' and the next '/' or '?'
  40. static authorityOf = (url) => {
  41. let at = url.indexOf('://')
  42. if (at < 0) { return '' }
  43. let rest = url.slice(at + 3)
  44. let end = rest.length
  45. let s = rest.indexOf('/')
  46. if (s >= 0 && s < end) { end = s }
  47. let q = rest.indexOf('?')
  48. if (q >= 0 && q < end) { end = q }
  49. return rest.slice(0, end)
  50. }
  51. // null when the URL may be an app's return address, else the reason
  52. static returnError = (url) => {
  53. if (url == null || hlTypeName(url) != 'String' || url == '') { return 'the app sent no return address' }
  54. if (url.length > 500) { return 'the return address is too long' }
  55. let i = 0
  56. while (i < url.length) {
  57. if (!urlChars.includes(url[i])) { return 'the return address holds a character that is not allowed' }
  58. i = i + 1
  59. }
  60. for (o of extraOrigins()) {
  61. if (url == o || url.startsWith(o + '/') || url.startsWith(o + '?')) { return null }
  62. }
  63. if (!url.startsWith('https://')) { return 'the return address must be https on worldapi.org' }
  64. let host = authorityOf(url)
  65. if (host == '' || host.includes('@') || host.includes(':')) { return 'the return address has an unusual host' }
  66. if (host != 'worldapi.org' && !host.endsWith('.worldapi.org')) { return 'the return address must be on worldapi.org' }
  67. return null
  68. }
  69. static hostOf = (url) => { return authorityOf(url) }
  70. static sepOf = (url) => { return url.includes('?') ? '&' : '?' }
  71. // ---- 1. the request ---------------------------------------------------------------------------
  72. // answers { nonce } or { error }
  73. static createRequest = (app, label, returnUrl, state) => {
  74. let a = app == null ? '' : ('' + app).trim()
  75. if (a == '') { return { error = 'the app sent no name' } }
  76. let bad = plainError(a, 60, 'the app name')
  77. if (bad == null) { bad = plainError(label == null ? '' : label, 100, 'the label') }
  78. if (bad == null) { bad = plainError(state == null ? '' : state, 200, 'the state') }
  79. if (bad == null) { bad = returnError(returnUrl) }
  80. if (bad != null) { return { error = bad } }
  81. let nonce = randomBytes(16)
  82. let id = connectionsTable.put({ status = 'request' nonce = nonce app = a label = label == null ? '' : label.trim() returnUrl = returnUrl state = state == null ? '' : state project = '' createdBy = '' codeHash = '' hash = '' expires = now() + requestTtlMs created = now() })
  83. if (id == null) { return { error = 'could not store the request: ' + connectionsTable.lastError() } }
  84. return { nonce = nonce }
  85. }
  86. // the open request of a nonce, or null (unknown, used, expired)
  87. static requestOf = (nonce) => {
  88. if (!isHex(nonce, 64)) { return null }
  89. let c = firstOf(connectionsTable.find('nonce', nonce))
  90. if (c == null || c.status != 'request' || c.expires < now()) { return null }
  91. return c
  92. }
  93. // what the connect page shows of a request
  94. static requestView = (c) => {
  95. return { app = c.app label = c.label hasLabel = c.label != '' host = hostOf(c.returnUrl) }
  96. }
  97. // the projects a user is an ADMIN of: [{ id, title, slug }]
  98. static adminProjects = (user) => {
  99. let out = []
  100. if (user == null) { return out }
  101. for (p of projectRecords()) { if (isAdminOf(p.id, user)) { out.push({ id = p.id title = p.title slug = p.slug }) } }
  102. return out
  103. }
  104. // ---- 2. the confirmation ----------------------------------------------------------------------
  105. // `projectId` '' = make a new project titled `title` (the person becomes its admin). Answers { error, forbidden? } or
  106. // { url (back to the app, with the one-time code), project (title), slug }
  107. static confirmRequest = (nonce, user, projectId, title) => {
  108. let c = requestOf(nonce)
  109. if (c == null) { return { error = 'this request is used up or expired — start again from the app' } }
  110. if (!userOk(user)) { return { error = 'log in with ident and choose a display name first' } }
  111. let p = null
  112. if (projectId == null || projectId == '') {
  113. let r = createProject(user, title, '', '')
  114. if (r.error != null) { return { error = r.error } }
  115. p = projectRecord(r.project.id)
  116. } else {
  117. p = projectRecord('' + projectId)
  118. if (p == null) { return { error = 'no such project' } }
  119. if (!isAdminOf(p.id, user)) { return notAdmin }
  120. }
  121. // a second connection of the same app and label to the same project replaces the first: its key dies
  122. for (o of connectionsOf(p.id, true)) { if (o.app == c.app && o.label == c.label && o.id != c.id) { connectionsTable.delete(o.id) } }
  123. let code = randomBytes(24)
  124. c.status = 'waiting'
  125. c.nonce = ''
  126. c.project = p.id
  127. c.createdBy = user.id
  128. c.codeHash = sha256(code)
  129. c.expires = now() + codeTtlMs
  130. connectionsTable.update(c.id, c)
  131. let url = c.returnUrl + sepOf(c.returnUrl) + 'code=' + code
  132. if (c.state != '') { url = url + '&state=' + encodeURIComponent(c.state) }
  133. return { url = url project = p.title slug = p.slug }
  134. }
  135. // ---- 3. the exchange --------------------------------------------------------------------------
  136. // answers { key, project (slug), title, api } or { error }
  137. static exchangeConnectCode = (code) => {
  138. if (!isHex(code, 200)) { return { error = 'that is not a connection code' } }
  139. let c = firstOf(connectionsTable.find('codeHash', sha256(code)))
  140. if (c == null || c.status != 'waiting' || c.expires < now()) { return { error = 'unknown, used or expired code — connect again' } }
  141. let p = projectRecord(c.project)
  142. if (p == null) { return { error = 'the project is gone' } }
  143. let key = 'tktc_' + randomBytes(24)
  144. c.status = 'active'
  145. c.hash = sha256(key)
  146. c.codeHash = ''
  147. c.expires = 0
  148. c.connected = now()
  149. connectionsTable.update(c.id, c)
  150. return { key = key project = p.slug title = p.title api = publicUrl + '/api/projects/' + p.slug }
  151. }
  152. // ---- 4. the key -------------------------------------------------------------------------------
  153. static isKeyHeader = (header) => {
  154. if (header == null || hlTypeName(header) != 'String') { return false }
  155. let h = header.trim()
  156. return (h.startsWith('Bearer ') || h.startsWith('bearer ')) && h.slice(7).trim().startsWith('tktc_')
  157. }
  158. // `Authorization: Bearer tktc_…` → the ACTIVE connection, or null
  159. static connectionOfKey = (header) => {
  160. if (!isKeyHeader(header)) { return null }
  161. let key = header.trim().slice(7).trim()
  162. if (key.length != 53) { return null }
  163. let c = firstOf(connectionsTable.find('hash', sha256(key)))
  164. if (c == null || c.status != 'active') { return null }
  165. return c
  166. }
  167. // the person an app write names (`X-Tickets-Identity`): { user } or { error }
  168. static personOf = (identity) => {
  169. if (identity == null || hlTypeName(identity) != 'String' || !isIdentId(identity.trim())) {
  170. return { error = 'a write through a connection names the person: header X-Tickets-Identity: <their ident id>' }
  171. }
  172. let u = firstOf(usersTable.find('identity', identity.trim()))
  173. if (u == null || u.name == '') { return { error = 'that person has not logged in to tickets yet — they log in once and choose a display name' } }
  174. return { user = u }
  175. }
  176. // ---- 5. the project page ----------------------------------------------------------------------
  177. // the connections of a project: [{ id, app, label, by, when }]; `all` = also the waiting ones
  178. static connectionsOf = (projectId, all) => {
  179. let out = []
  180. let rows = connectionsTable.find('project', projectId)
  181. if (countOfList(rows) == 0) { return out }
  182. for (c of rows) { if (all || c.status == 'active') { out.push(c) } }
  183. return out
  184. }
  185. static connectionRows = (projectId) => {
  186. let out = []
  187. for (c of connectionsOf(projectId, false)) {
  188. out.push({ id = c.id app = c.app label = c.label hasLabel = c.label != '' by = nameOfUser(c.createdBy) text = c.app + (c.label != '' ? ': ' + c.label : '') })
  189. }
  190. return out
  191. }
  192. // an admin ends a connection; the key is dead at once. answers { connections } or { error, forbidden? }
  193. static disconnect = (projectId, actor, connectionId) => {
  194. let p = projectRecord(projectId)
  195. if (p == null) { return { error = 'no such project' } }
  196. if (!userOk(actor)) { return { error = 'log in with ident and choose a display name first' } }
  197. if (!isAdminOf(p.id, actor)) { return notAdmin }
  198. if (connectionId == null || hlTypeName(connectionId) != 'String' || connectionId == '') { return { error = 'no such connection' } }
  199. let c = connectionsTable.fetch(connectionId)
  200. if (c == null || c.project != p.id) { return { error = 'no such connection' } }
  201. connectionsTable.delete(c.id)
  202. return { connections = connectionRows(p.id) }
  203. }

Branches

Latest commits

  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 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