gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita75e0279a75e0279mission 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 codemrea75e0279/lib/api-helpers.hl

8.3 KB

  1. // lib/api-helpers.hl — the plumbing of the function routes (lib/api.hl): query strings, bodies, answers, who is
  2. // calling (token or app key), the Markdown Accept check, the small HTML page. Statics only; no topic logic here.
  3. import { Response } from 'hl:http1'
  4. import { jsonErrorAt } from './jsoncheck.hl'
  5. import { userOfBearer } from './users.hl'
  6. import { connectionOfKey, personOf } from './connections.hl'
  7. import { states, stateOf } from './tickets-helpers.hl'
  8. static jsonHeaders = { 'Content-Type' = 'application/json; charset=utf-8' }
  9. static reply = (status, value) => {
  10. return new Response(JSON.stringify(value), { status = status headers = jsonHeaders })
  11. }
  12. static fail = (status, message) => { return reply(status, { error = message }) }
  13. // `?a=1&b=x` of a request path → { a = '1', b = 'x' } ('+' is a space; hl:http1
  14. // has already percent-decoded the path)
  15. static queryOf = (path) => {
  16. out = {}
  17. q = path.indexOf('?')
  18. if (q < 0) { return out }
  19. for (pair of path.slice(q + 1).split('&')) {
  20. if (pair != '') {
  21. let eq = pair.indexOf('=')
  22. if (eq < 0) { out[pair] = '' } else { out[pair.slice(0, eq)] = pair.slice(eq + 1).replaceAll('+', ' ') }
  23. }
  24. }
  25. return out
  26. }
  27. // the request body as JSON, or null when it is not
  28. static bodyOf = (req) => {
  29. if (req.body == null || req.body == '') { return null }
  30. return JSON.parse(req.body)
  31. }
  32. // THE API IS STRICT (ticket #10): a malformed report must not be filed half-read. A body
  33. // is a JSON object whose keys are all in `required` or `optional`, every value is a
  34. // String, and every required one is non-empty after trimming. Answers null (fine) or
  35. // { error, field } — the first offence, naming the field.
  36. // (a list has no concat() in this build — NotCallable, hybriel #6's family)
  37. static allowedOf = (required, optional) => {
  38. all = []
  39. for (k of required) { all.push(k) }
  40. for (k of optional) { all.push(k) }
  41. return all.join(', ')
  42. }
  43. static bodyError = (b, required, optional) => {
  44. if (b == null || hlTypeName(b) != 'Hybrid' || b.length != null) {
  45. return { error = 'the body must be a JSON object' field = '' }
  46. }
  47. for (k of b.keys()) {
  48. // ticket #7: the author is the token's user — a body that still names one is refused
  49. // (not silently ignored), so a client learns that its field does nothing
  50. if (k == 'author') {
  51. return { error = "no field 'author' any more: the author is the user of your API token" field = 'author' }
  52. }
  53. if (!required.includes(k) && !optional.includes(k)) {
  54. return { error = "unknown field '" + k + "' (allowed: " + allowedOf(required, optional) + ')' field = k }
  55. }
  56. if (hlTypeName(b[k]) != 'String') {
  57. return { error = "field '" + k + "' must be a string, not " + hlTypeName(b[k]) field = k }
  58. }
  59. }
  60. for (k of required) {
  61. if (b[k] == null) { return { error = "field '" + k + "' is required" field = k } }
  62. if (b[k].trim() == '') { return { error = "field '" + k + "' must not be empty" field = k } }
  63. }
  64. return null
  65. }
  66. static refuse = (e) => { return reply(400, e) }
  67. // ticket #7: an API write without a valid token (missing, malformed, unknown, revoked) → 401
  68. static unauthorized = () => {
  69. return new Response(JSON.stringify({ error = 'an API write needs Authorization: Bearer <token> — log in with ident and create a token on /you' }), { status = 401 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'WWW-Authenticate' = 'Bearer' } })
  70. }
  71. // a POST body, checked: { body } or { bad } (bad = the 400 answer's value).
  72. // Invalid JSON is refused by jsoncheck.hl BEFORE JSON.parse ever sees it (ticket #15,
  73. // workaround for hybriel #12: an uncaught JSON.parse would answer 500 with source paths).
  74. static readBody = (req, required, optional) => {
  75. if (req.body != null && req.body != '') {
  76. at = jsonErrorAt(req.body)
  77. if (at >= 0) { return { bad = { error = 'invalid JSON at character ' + at } } }
  78. }
  79. b = bodyOf(req)
  80. return { body = b bad = bodyError(b, required, optional) }
  81. }
  82. // a refusal of a topic function: 403 when the role is missing, else 400
  83. static denied = (r) => {
  84. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  85. return refuse({ error = r.error field = r.field })
  86. }
  87. // ---- who is calling --------------------------------------------------------------------------------
  88. // WRITES NEED A TOKEN (ticket #7): `Authorization: Bearer <token>` (users.hl; a logged-in user
  89. // makes tokens on /you). No / bad / revoked token → 401, checked BEFORE the body.
  90. // the token's user of an API write, or null (→ 401)
  91. static apiUser = (req) => { return userOfBearer(req.headers['authorization']) }
  92. // WHO WRITES, for the endpoints an app's KEY may also use (ticket #21: create a ticket, comment, change the state):
  93. // { user } for a user's token, { conn } for a project key (connections.hl), null = 401 (checked before the body)
  94. static apiAuth = (req) => {
  95. h = req.headers['authorization']
  96. u = userOfBearer(h)
  97. if (u != null) { return { user = u } }
  98. c = connectionOfKey(h)
  99. return c != null ? { conn = c } : null
  100. }
  101. // the acting user inside project `projectId`: { user } or { response }. A key reaches ITS project only and names the
  102. // person by `X-Tickets-Identity`; the project's roles then decide as for any user.
  103. static actorIn = (auth, req, projectId) => {
  104. if (auth.user != null) { return { user = auth.user } }
  105. if (projectId == null || auth.conn.project != projectId) { return { response = reply(403, { error = 'this key belongs to another project' field = '' }) } }
  106. a = personOf(req.headers['x-tickets-identity'])
  107. if (a.error != null) { return { response = reply(403, { error = a.error field = '' }) } }
  108. return { user = a.user }
  109. }
  110. // ---- list filters ----------------------------------------------------------------------------------
  111. static stateFilter = (q) => {
  112. if (q.state == null || q.state == '') { return { state = null } }
  113. st = stateOf(q.state)
  114. if (st == null) { return { bad = fail(400, 'unknown state — one of: ' + states.join(', ')) } }
  115. return { state = st }
  116. }
  117. // the filters of a list, for the Markdown heading ('' = none)
  118. static filterLabel = (project, state) => {
  119. parts = []
  120. if (project != null) { parts.push('project ' + project) }
  121. if (state != null) { parts.push('state ' + state) }
  122. return parts.join(', ')
  123. }
  124. // ---- THE MARKDOWN READ VIEW's request side (ticket #6; the documents: lib/mdview.hl) -----------------
  125. // does the request ask for Markdown? `Accept` names text/markdown with q > 0 and prefers it to
  126. // application/json (a higher q, or the same q and listed first). No Accept / */* → JSON.
  127. static qOf = (part) => {
  128. let q = 1
  129. for (p of part.split(';')) {
  130. let kv = p.trim()
  131. if (kv.startsWith('q=')) { q = toNumber(kv.slice(2)) }
  132. }
  133. return q == null ? 0 : q
  134. }
  135. static wantsMarkdown = (req) => {
  136. h = req.headers['accept']
  137. if (h == null || hlTypeName(h) != 'String') { return false }
  138. let md = -1
  139. let js = -1
  140. let mdAt = -1
  141. let jsAt = -1
  142. let i = 0
  143. for (part of h.toLowerCase().split(',')) {
  144. let type = part.split(';')[0].trim()
  145. if (type == 'text/markdown' && mdAt < 0) {
  146. md = qOf(part)
  147. mdAt = i
  148. }
  149. if (type == 'application/json' && jsAt < 0) {
  150. js = qOf(part)
  151. jsAt = i
  152. }
  153. i = i + 1
  154. }
  155. if (md <= 0) { return false }
  156. if (js < 0) { return true }
  157. return md > js || (md == js && mdAt < jsAt)
  158. }
  159. static markdownReply = (text) => {
  160. return new Response(text, { status = 200 headers = { 'Content-Type' = 'text/markdown; charset=utf-8' 'Vary' = 'Accept' } })
  161. }
  162. // ---- answers that are not JSON ---------------------------------------------------------------------
  163. // a small HTML page (the login callback, /connect): a title and one message
  164. static htmlPage = (status, title, text) => {
  165. body = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>tickets | ' + title + '</title><style>body{margin:0;padding:1.5rem 1rem;font:16px/1.5 system-ui,sans-serif;color:rgb(195, 200, 205);background:rgb(25, 30, 35)}main{max-width:34rem;margin:0 auto;display:grid;gap:1rem}h1{margin:0;font-size:1.3rem;color:rgb(245, 250, 255)}p{margin:0}a{color:#c586c0}.error{color:#f44747}</style></head><body><main><h1>' + title + '</h1><p id="error" class="error">' + text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;') + '</p><p><a id="home" href="/">back to the tickets</a></p></main></body></html>'
  166. return new Response(body, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })
  167. }

Branches

Latest commits

  • 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