tickets
All repositories: gitoria
8.7 KB
// lib/api-helpers.hl — the plumbing of the function routes (lib/api.hl): query strings, bodies, answers, who is// calling (token or app key), the Markdown Accept check, the small HTML page. Statics only; no topic logic here.import { Response } from 'hl:http1'import { jsonErrorAt } from './jsoncheck.hl'import { userOfBearer } from './users.hl'import { connectionOfKey, personOf } from './connections.hl'import { states, stateOf } from './tickets-helpers.hl'static jsonHeaders = { 'Content-Type' = 'application/json; charset=utf-8' }static reply = (status, value) => {return new Response(JSON.stringify(value), { status = status headers = jsonHeaders })}static fail = (status, message) => { return reply(status, { error = message }) }// `?a=1&b=x` of a request path → { a = '1', b = 'x' } ('+' is a space; hl:http1// has already percent-decoded the path)static queryOf = (path) => {out = {}q = path.indexOf('?')if (q < 0) { return out }for (pair of path.slice(q + 1).split('&')) {if (pair != '') {let eq = pair.indexOf('=')if (eq < 0) { out[pair] = '' } else { out[pair.slice(0, eq)] = pair.slice(eq + 1).replaceAll('+', ' ') }}}return out}// the request body as JSON, or null when it is notstatic bodyOf = (req) => {if (req.body == null || req.body == '') { return null }return JSON.parse(req.body)}// THE API IS STRICT (ticket #10): a malformed report must not be filed half-read. A body// is a JSON object whose keys are all in `required` or `optional`, every value is a// String, and every required one is non-empty after trimming. Answers null (fine) or// { error, field } — the first offence, naming the field.// (a list has no concat() in this build — NotCallable, hybriel #6's family)static allowedOf = (required, optional) => {all = []for (k of required) { all.push(k) }for (k of optional) { all.push(k) }return all.join(', ')}static bodyError = (b, required, optional) => {if (b == null || hlTypeName(b) != 'Hybrid' || b.length != null) {return { error = 'the body must be a JSON object' field = '' }}for (k of b.keys()) {// ticket #7: the author is the token's user — a body that still names one is refused// (not silently ignored), so a client learns that its field does nothingif (k == 'author') {return { error = "no field 'author' any more: the author is the user of your API token" field = 'author' }}if (!required.includes(k) && !optional.includes(k)) {return { error = "unknown field '" + k + "' (allowed: " + allowedOf(required, optional) + ')' field = k }}if (hlTypeName(b[k]) != 'String') {return { error = "field '" + k + "' must be a string, not " + hlTypeName(b[k]) field = k }}}for (k of required) {if (b[k] == null) { return { error = "field '" + k + "' is required" field = k } }if (b[k].trim() == '') { return { error = "field '" + k + "' must not be empty" field = k } }}return null}static refuse = (e) => { return reply(400, e) }// ticket #7: an API write without a valid token (missing, malformed, unknown, revoked) → 401static unauthorized = () => {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' } })}// a POST body, checked: { body } or { bad } (bad = the 400 answer's value).// Invalid JSON is refused by jsoncheck.hl BEFORE JSON.parse ever sees it (ticket #15,// workaround for hybriel #12: an uncaught JSON.parse would answer 500 with source paths).static readBody = (req, required, optional) => {if (req.body != null && req.body != '') {at = jsonErrorAt(req.body)if (at >= 0) { return { bad = { error = 'invalid JSON at character ' + at } } }}b = bodyOf(req)return { body = b bad = bodyError(b, required, optional) }}// a refusal of a topic function: 403 when the role is missing, else 400static denied = (r) => {if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }return refuse({ error = r.error field = r.field })}// ---- who is calling --------------------------------------------------------------------------------// WRITES NEED A TOKEN (ticket #7): `Authorization: Bearer <token>` (users.hl; a logged-in user// makes tokens on /you). No / bad / revoked token → 401, checked BEFORE the body.// the token's user of an API write, or null (→ 401)static apiUser = (req) => { return userOfBearer(req.headers['authorization']) }// WHO WRITES, for the endpoints an app's KEY may also use (ticket #21: create a ticket, comment, change the state):// { user } for a user's token, { conn } for a project key (connections.hl), null = 401 (checked before the body)static apiAuth = (req) => {h = req.headers['authorization']u = userOfBearer(h)if (u != null) { return { user = u } }c = connectionOfKey(h)return c != null ? { conn = c } : null}// the acting user inside project `projectId`: { user } or { response }. A key reaches ITS project only and names the// person by `X-Tickets-Identity`; the project's roles then decide as for any user.static actorIn = (auth, req, projectId) => {if (auth.user != null) { return { user = auth.user } }if (projectId == null || auth.conn.project != projectId) { return { response = reply(403, { error = 'this key belongs to another project' field = '' }) } }a = personOf(req.headers['x-tickets-identity'])if (a.error != null) { return { response = reply(403, { error = a.error field = '' }) } }return { user = a.user }}// ---- list filters ----------------------------------------------------------------------------------static stateFilter = (q) => {if (q.state == null || q.state == '') { return { state = null } }st = stateOf(q.state)if (st == null) { return { bad = fail(400, 'unknown state — one of: ' + states.join(', ')) } }return { state = st }}// the filters of a list, for the Markdown heading ('' = none)static filterLabel = (project, state) => {parts = []if (project != null) { parts.push('project ' + project) }if (state != null) { parts.push('state ' + state) }return parts.join(', ')}// ---- THE MARKDOWN READ VIEW's request side (ticket #6; the documents: lib/mdview.hl) -----------------// does the request ask for Markdown? `Accept` names text/markdown with q > 0 and prefers it to// application/json (a higher q, or the same q and listed first). No Accept / */* → JSON.static qOf = (part) => {let q = 1for (p of part.split(';')) {let kv = p.trim()if (kv.startsWith('q=')) { q = toNumber(kv.slice(2)) }}return q == null ? 0 : q}static wantsMarkdown = (req) => {h = req.headers['accept']if (h == null || hlTypeName(h) != 'String') { return false }let md = -1let js = -1let mdAt = -1let jsAt = -1let i = 0for (part of h.toLowerCase().split(',')) {let type = part.split(';')[0].trim()if (type == 'text/markdown' && mdAt < 0) {md = qOf(part)mdAt = i}if (type == 'application/json' && jsAt < 0) {js = qOf(part)jsAt = i}i = i + 1}if (md <= 0) { return false }if (js < 0) { return true }return md > js || (md == js && mdAt < jsAt)}static markdownReply = (text) => {return new Response(text, { status = 200 headers = { 'Content-Type' = 'text/markdown; charset=utf-8' 'Vary' = 'Accept' } })}// ---- answers that are not JSON ---------------------------------------------------------------------// a small HTML page (the login callback, /connect): a title and one messagestatic htmlPage = (status, title, text) => {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('&', '&').replaceAll('<', '<').replaceAll('>', '>') + '</p><p><a id="home" href="/">back to the tickets</a></p></main></body></html>'return new Response(body, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })}// a page that moved for good (301) / a page that names nothing (404), as plain textstatic moved = (to) => {return new Response('moved to ' + to, { status = 301 headers = { 'Location' = to 'Content-Type' = 'text/plain; charset=utf-8' } })}static missing = (message) => { return new Response(message, { status = 404 headers = { 'Content-Type' = 'text/plain; charset=utf-8' } }) }
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