gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmainbb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmremain/lib/connections.hl

10.8 KB

  1. // lib/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 (lib/api-helpers.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 { storageDir, first, countOf, isHex, plainError } from './util.hl'
  26. import { isIdentId, userOfIdentity, nameOfUser, publicUrl } from './users.hl'
  27. import { projectRecord, projectRecords, isAdminOf, createProject, userOk, notAdmin } from './projects.hl'
  28. static connectionsTable = new MPackDB(file = storageDir + '/connections.db', primaryKey = '@id', indexes = ['nonce', 'hash', 'codeHash', 'project'])
  29. static requestTtlMs = 3600000
  30. static codeTtlMs = 300000
  31. static extraOrigins = () => {
  32. v = env('TICKETS_CONNECT_ORIGINS')
  33. out = []
  34. if (v == null || v.trim() == '') { return out }
  35. for (o of v.split(',')) { if (o.trim() != '') { out.push(o.trim()) } }
  36. return out
  37. }
  38. // the characters a return URL may hold: URL-safe, no spaces, no quotes, no '#' (the code goes in the query)
  39. static urlChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;:@!$()*'
  40. // the host (with port) of an absolute URL: everything between '://' and the next '/' or '?'
  41. static authorityOf = (url) => {
  42. at = url.indexOf('://')
  43. if (at < 0) { return '' }
  44. rest = url.slice(at + 3)
  45. let end = rest.length
  46. s = rest.indexOf('/')
  47. if (s >= 0 && s < end) { end = s }
  48. q = rest.indexOf('?')
  49. if (q >= 0 && q < end) { end = q }
  50. return rest.slice(0, end)
  51. }
  52. // null when the URL may be an app's return address, else the reason
  53. static returnError = (url) => {
  54. if (url == null || hlTypeName(url) != 'String' || url == '') { return 'the app sent no return address' }
  55. if (url.length > 500) { return 'the return address is too long' }
  56. let i = 0
  57. while (i < url.length) {
  58. if (!urlChars.includes(url[i])) { return 'the return address holds a character that is not allowed' }
  59. i = i + 1
  60. }
  61. for (o of extraOrigins()) {
  62. if (url == o || url.startsWith(o + '/') || url.startsWith(o + '?')) { return null }
  63. }
  64. if (!url.startsWith('https://')) { return 'the return address must be https on worldapi.org' }
  65. host = authorityOf(url)
  66. if (host == '' || host.includes('@') || host.includes(':')) { return 'the return address has an unusual host' }
  67. if (host != 'worldapi.org' && !host.endsWith('.worldapi.org')) { return 'the return address must be on worldapi.org' }
  68. return null
  69. }
  70. static hostOf = (url) => { return authorityOf(url) }
  71. static sepOf = (url) => { return url.includes('?') ? '&' : '?' }
  72. // ---- 1. the request ---------------------------------------------------------------------------
  73. // answers { nonce } or { error }
  74. static createRequest = (app, label, returnUrl, state) => {
  75. a = app == null ? '' : ('' + app).trim()
  76. if (a == '') { return { error = 'the app sent no name' } }
  77. let bad = plainError(a, 60, 'the app name')
  78. if (bad == null) { bad = plainError(label == null ? '' : label, 100, 'the label') }
  79. if (bad == null) { bad = plainError(state == null ? '' : state, 200, 'the state') }
  80. if (bad == null) { bad = returnError(returnUrl) }
  81. if (bad != null) { return { error = bad } }
  82. let nonce = randomBytes(16)
  83. 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() })
  84. if (id == null) { return { error = 'could not store the request: ' + connectionsTable.lastError() } }
  85. return { nonce = nonce }
  86. }
  87. // the open request of a nonce, or null (unknown, used, expired)
  88. static requestOf = (nonce) => {
  89. if (!isHex(nonce, 64)) { return null }
  90. c = first(connectionsTable.find('nonce', nonce))
  91. if (c == null || c.status != 'request' || c.expires < now()) { return null }
  92. return c
  93. }
  94. // what the connect page shows of a request
  95. static requestView = (c) => {
  96. return { app = c.app label = c.label hasLabel = c.label != '' host = hostOf(c.returnUrl) }
  97. }
  98. // the projects a user is an ADMIN of: [{ id, title, slug }]
  99. static adminProjects = (user) => {
  100. out = []
  101. if (user == null) { return out }
  102. for (p of projectRecords()) { if (isAdminOf(p.id, user)) { out.push({ id = p.id title = p.title slug = p.slug }) } }
  103. return out
  104. }
  105. // ---- 2. the confirmation ----------------------------------------------------------------------
  106. // `projectId` '' = make a new project titled `title` (the person becomes its admin). Answers { error, forbidden? } or
  107. // { url (back to the app, with the one-time code), project (title), slug }
  108. static confirmRequest = (nonce, user, projectId, title) => {
  109. c = requestOf(nonce)
  110. if (c == null) { return { error = 'this request is used up or expired — start again from the app' } }
  111. if (!userOk(user)) { return { error = 'log in with ident and choose a display name first' } }
  112. let p = null
  113. if (projectId == null || projectId == '') {
  114. r = createProject(user, title, '', '')
  115. if (r.error != null) { return { error = r.error } }
  116. p = projectRecord(r.project.id)
  117. } else {
  118. p = projectRecord('' + projectId)
  119. if (p == null) { return { error = 'no such project' } }
  120. if (!isAdminOf(p.id, user)) { return notAdmin }
  121. }
  122. // a second connection of the same app and label to the same project replaces the first: its key dies
  123. for (o of connectionsOf(p.id, true)) { if (o.app == c.app && o.label == c.label && o.id != c.id) { connectionsTable.delete(o.id) } }
  124. code = randomBytes(24)
  125. c.status = 'waiting'
  126. c.nonce = ''
  127. c.project = p.id
  128. c.createdBy = user.id
  129. c.codeHash = sha256(code)
  130. c.expires = now() + codeTtlMs
  131. connectionsTable.update(c.id, c)
  132. let url = c.returnUrl + sepOf(c.returnUrl) + 'code=' + code
  133. if (c.state != '') { url = url + '&state=' + encodeURIComponent(c.state) }
  134. return { url = url project = p.title slug = p.slug }
  135. }
  136. // ---- 3. the exchange --------------------------------------------------------------------------
  137. // answers { key, project (slug), title, api } or { error }
  138. static exchangeConnectCode = (code) => {
  139. if (!isHex(code, 200)) { return { error = 'that is not a connection code' } }
  140. c = first(connectionsTable.find('codeHash', sha256(code)))
  141. if (c == null || c.status != 'waiting' || c.expires < now()) { return { error = 'unknown, used or expired code — connect again' } }
  142. p = projectRecord(c.project)
  143. if (p == null) { return { error = 'the project is gone' } }
  144. let key = 'tktc_' + randomBytes(24)
  145. c.status = 'active'
  146. c.hash = sha256(key)
  147. c.codeHash = ''
  148. c.expires = 0
  149. c.connected = now()
  150. connectionsTable.update(c.id, c)
  151. return { key = key project = p.slug title = p.title api = publicUrl + '/api/projects/' + p.slug }
  152. }
  153. // ---- 4. the key -------------------------------------------------------------------------------
  154. static isKeyHeader = (header) => {
  155. if (header == null || hlTypeName(header) != 'String') { return false }
  156. h = header.trim()
  157. return (h.startsWith('Bearer ') || h.startsWith('bearer ')) && h.slice(7).trim().startsWith('tktc_')
  158. }
  159. // `Authorization: Bearer tktc_…` → the ACTIVE connection, or null
  160. static connectionOfKey = (header) => {
  161. if (!isKeyHeader(header)) { return null }
  162. key = header.trim().slice(7).trim()
  163. if (key.length != 53) { return null }
  164. c = first(connectionsTable.find('hash', sha256(key)))
  165. if (c == null || c.status != 'active') { return null }
  166. return c
  167. }
  168. // the person an app write names (`X-Tickets-Identity`): { user } or { error }
  169. static personOf = (identity) => {
  170. if (identity == null || hlTypeName(identity) != 'String' || !isIdentId(identity.trim())) {
  171. return { error = 'a write through a connection names the person: header X-Tickets-Identity: <their ident id>' }
  172. }
  173. u = userOfIdentity(identity.trim())
  174. 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' } }
  175. return { user = u }
  176. }
  177. // ---- 5. the project page ----------------------------------------------------------------------
  178. // the connections of a project: [{ id, app, label, by, when }]; `all` = also the waiting ones
  179. static connectionsOf = (projectId, all) => {
  180. out = []
  181. rows = connectionsTable.find('project', projectId)
  182. if (countOf(rows) == 0) { return out }
  183. for (c of rows) { if (all || c.status == 'active') { out.push(c) } }
  184. return out
  185. }
  186. static connectionRows = (projectId) => {
  187. out = []
  188. for (c of connectionsOf(projectId, false)) {
  189. 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 : '') })
  190. }
  191. return out
  192. }
  193. // an admin ends a connection; the key is dead at once. answers { connections } or { error, forbidden? }
  194. static disconnect = (projectId, actor, connectionId) => {
  195. p = projectRecord(projectId)
  196. if (p == null) { return { error = 'no such project' } }
  197. if (!userOk(actor)) { return { error = 'log in with ident and choose a display name first' } }
  198. if (!isAdminOf(p.id, actor)) { return notAdmin }
  199. if (connectionId == null || hlTypeName(connectionId) != 'String' || connectionId == '') { return { error = 'no such connection' } }
  200. c = connectionsTable.fetch(connectionId)
  201. if (c == null || c.project != p.id) { return { error = 'no such connection' } }
  202. connectionsTable.delete(c.id)
  203. return { connections = connectionRows(p.id) }
  204. }

Branches

  • mainmain branch

Latest commits

  • bb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmre
  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 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