gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitbb64d57cbb64d57cmission 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 identicalmrebb64d57c/lib/api.hl

23.1 KB

  1. // lib/api.hl — THE FUNCTION ROUTES (project.hl `routes`): the JSON API for the scheduler, a CLI and connected apps,
  2. // and the three page routes that are not components (/login/callback, /connect, /tickets/:ref). Thin wrappers:
  3. // check the method, the token / key and the body (lib/api-helpers.hl), call the topic function, push what
  4. // changed (the same frames the web faces push, so an open browser follows an API write live), answer.
  5. // See README.md "API" for the shapes.
  6. //
  7. // Every POST body goes through readBody: invalid JSON, unknown field, missing/empty required field, non-string
  8. // value → 400. WRITES NEED A TOKEN (ticket #7): no / bad / revoked token → 401, checked BEFORE the body. The
  9. // author is the token's user; a body with `author` → 400 naming it. Reads stay public.
  10. // THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,
  11. // the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise, unchanged.
  12. // TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global
  13. // number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the
  14. // per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.
  15. import { Response } from 'hl:http1'
  16. import { randomBytes } from 'hl:crypto'
  17. import { reply, fail, queryOf, readBody, refuse, unauthorized, denied, apiUser, apiAuth, actorIn, stateFilter, filterLabel, wantsMarkdown, markdownReply, htmlPage, moved, missing } from './api-helpers.hl'
  18. import { exchangeCode, ensureUser } from './users.hl'
  19. import { projectNames, projectByAnySlug, projectByRef, slugOfRef, projectRecords, projectRowOf, projectSlugOf, createProject, updateProject, memberRows, setMemberRole, userByRef, roles } from './projects.hl'
  20. import { states } from './tickets-helpers.hl'
  21. import { pageEventOf } from './events.hl'
  22. import { ticketRows, ticketWithEvents, ticketByRef, ticketAt, createTicket, addComment, changeState, assignTicket, editTicket, setParent, changeBlocker, relatedViews, removeMember, inboxRows } from './tickets.hl'
  23. import { mayInvite, createInvite, joinByInvite } from './invites.hl'
  24. import { createRequest, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'
  25. import { listDocument, ticketDocument } from './mdview.hl'
  26. import { ticketHref, projectHrefOf } from './util.hl'
  27. // ---- tickets ------------------------------------------------------------------------------------
  28. static byRef = (route) => { return ticketByRef(route.params.ref) }
  29. // the project of an /api/projects/:slug/… route: its slug (also an old one) or its id (ticket #25) → the slug
  30. static slugOf = (route) => { return slugOfRef(route.params.slug) }
  31. static byNumber = (route) => { return ticketAt(slugOf(route), route.params.number) }
  32. static listTickets = (route, req) => {
  33. if (req.method != 'GET') { return fail(405, 'GET only') }
  34. // hl:http1 hands the parsed query string as `req.query`; the path carries none
  35. q = req.query != null ? req.query : queryOf(req.path)
  36. sf = stateFilter(q)
  37. if (sf.bad != null) { return sf.bad }
  38. pr = q.project != null && q.project != '' ? q.project : null
  39. rows = ticketRows(pr, sf.state)
  40. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }
  41. return { tickets = rows }
  42. }
  43. // POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }
  44. static createFromApi = (project, b, auth, req) => {
  45. pr = projectByRef(project)
  46. w = actorIn(auth, req, pr != null ? pr.id : null)
  47. if (w.response != null) { return w.response }
  48. user = w.user
  49. r = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)
  50. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  51. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  52. if (!r.existed) { emit client ticketCreated(r.ticket) }
  53. return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })
  54. }
  55. static postTicket = (route, req) => {
  56. if (req.method == 'GET') { return listTickets(route, req) }
  57. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  58. u = apiAuth(req)
  59. if (u == null) { return unauthorized() }
  60. rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])
  61. if (rb.bad != null) { return refuse(rb.bad) }
  62. return createFromApi(rb.body.project, rb.body, u, req)
  63. }
  64. static projectTickets = (route, req) => {
  65. slug = slugOf(route)
  66. if (req.method == 'GET') {
  67. if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }
  68. q = req.query != null ? req.query : queryOf(req.path)
  69. sf = stateFilter(q)
  70. if (sf.bad != null) { return sf.bad }
  71. rows = ticketRows(slug, sf.state)
  72. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }
  73. return { tickets = rows }
  74. }
  75. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  76. u = apiAuth(req)
  77. if (u == null) { return unauthorized() }
  78. rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])
  79. if (rb.bad != null) { return refuse(rb.bad) }
  80. return createFromApi(slug, rb.body, u, req)
  81. }
  82. static getOne = (t, req) => {
  83. if (req.method != 'GET') { return fail(405, 'GET only') }
  84. if (t == null) { return fail(404, 'no such ticket') }
  85. data = ticketWithEvents(t)
  86. if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }
  87. return data
  88. }
  89. static commentOnTicket = (t, req) => {
  90. if (req.method != 'POST') { return fail(405, 'POST only') }
  91. auth = apiAuth(req)
  92. if (auth == null) { return unauthorized() }
  93. rb = readBody(req, ['text'], [])
  94. if (rb.bad != null) { return refuse(rb.bad) }
  95. if (t == null) { return fail(404, 'no such ticket') }
  96. w = actorIn(auth, req, t.project)
  97. if (w.response != null) { return w.response }
  98. u = w.user
  99. b = rb.body
  100. r = addComment(t.id, u, b.text)
  101. if (r.error == 'no such ticket') { return fail(404, r.error) }
  102. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  103. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  104. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  105. return reply(201, r)
  106. }
  107. static stateOfTicket = (t, req) => {
  108. if (req.method != 'POST') { return fail(405, 'POST only') }
  109. auth = apiAuth(req)
  110. if (auth == null) { return unauthorized() }
  111. rb = readBody(req, ['state'], ['text'])
  112. if (rb.bad != null) { return refuse(rb.bad) }
  113. if (t == null) { return fail(404, 'no such ticket') }
  114. w = actorIn(auth, req, t.project)
  115. if (w.response != null) { return w.response }
  116. u = w.user
  117. b = rb.body
  118. r = changeState(t.id, b.state, u, b.text)
  119. if (r.error == 'no such ticket') { return fail(404, r.error) }
  120. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  121. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  122. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  123. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  124. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  125. return reply(201, r)
  126. }
  127. // POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.
  128. // Needs the role edit or admin → else 403.
  129. static assignOfTicket = (t, req) => {
  130. if (req.method != 'POST') { return fail(405, 'POST only') }
  131. u = apiUser(req)
  132. if (u == null) { return unauthorized() }
  133. rb = readBody(req, [], ['assignee'])
  134. if (rb.bad != null) { return refuse(rb.bad) }
  135. if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }
  136. if (t == null) { return fail(404, 'no such ticket') }
  137. let who = ''
  138. if (rb.body.assignee.trim() != '') {
  139. f = userByRef(rb.body.assignee, t.project)
  140. if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }
  141. who = f.user.id
  142. }
  143. r = assignTicket(t.id, u, who)
  144. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  145. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  146. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  147. return reply(201, r)
  148. }
  149. // POST …/edit { subject?, summary? } (ticket #8): a member with the role edit or admin → else 403;
  150. // the history keeps the previous values
  151. static editOfTicket = (t, req) => {
  152. if (req.method != 'POST') { return fail(405, 'POST only') }
  153. u = apiUser(req)
  154. if (u == null) { return unauthorized() }
  155. rb = readBody(req, [], ['subject' 'summary'])
  156. if (rb.bad != null) { return refuse(rb.bad) }
  157. if (t == null) { return fail(404, 'no such ticket') }
  158. b = rb.body
  159. r = editTicket(t.id, u, b.subject, b.summary)
  160. if (r.error == 'no such ticket') { return fail(404, r.error) }
  161. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  162. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  163. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  164. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  165. return reply(201, r)
  166. }
  167. // RELATIONS (ticket #4, mission 017; tickets.hl "relations"): a ticket is named as
  168. // `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.
  169. // POST …/parent { parent }: set the parent; `"parent": ""` removes it.
  170. // POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.
  171. // 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.
  172. static linkReply = (r) => {
  173. if (r.error == 'no such ticket') { return fail(404, r.error) }
  174. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  175. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  176. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  177. emit client ticketsRelated(r.related)
  178. return reply(201, { ticket = r.ticket event = r.event })
  179. }
  180. static parentOfTicket = (t, req) => {
  181. if (req.method != 'POST') { return fail(405, 'POST only') }
  182. u = apiUser(req)
  183. if (u == null) { return unauthorized() }
  184. rb = readBody(req, [], ['parent'])
  185. if (rb.bad != null) { return refuse(rb.bad) }
  186. if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }
  187. if (t == null) { return fail(404, 'no such ticket') }
  188. return linkReply(setParent(t.id, u, rb.body.parent))
  189. }
  190. static blockersOfTicket = (t, req) => {
  191. if (req.method != 'POST') { return fail(405, 'POST only') }
  192. u = apiUser(req)
  193. if (u == null) { return unauthorized() }
  194. rb = readBody(req, [], ['add' 'remove'])
  195. if (rb.bad != null) { return refuse(rb.bad) }
  196. b = rb.body
  197. if ((b.add == null) == (b.remove == null)) { return refuse({ error = "give exactly one of 'add' or 'remove' (<project>#<number>)" field = b.add == null ? 'add' : 'remove' }) }
  198. if (t == null) { return fail(404, 'no such ticket') }
  199. adding = b.add != null
  200. return linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))
  201. }
  202. static getTicket = (route, req) => { return getOne(byRef(route), req) }
  203. static postComment = (route, req) => { return commentOnTicket(byRef(route), req) }
  204. static postState = (route, req) => { return stateOfTicket(byRef(route), req) }
  205. static getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }
  206. static postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }
  207. static postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }
  208. static postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }
  209. static postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }
  210. static postEdit = (route, req) => { return editOfTicket(byRef(route), req) }
  211. static postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }
  212. static postParent = (route, req) => { return parentOfTicket(byRef(route), req) }
  213. static postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }
  214. static postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }
  215. static postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }
  216. // ---- projects and members (ticket #20; projects.hl) ----------------------------------------------
  217. // GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }
  218. // POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens
  219. // a project and is its first admin; the slug is generated from the title unless given
  220. // GET /api/projects/:slug → { project, members } (the slug may be an old one)
  221. // POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working
  222. // POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone
  223. // who logged in here; sets (adds / changes) the role: use | edit | admin
  224. // POST /api/projects/:slug/members/remove { user } — admin only
  225. // POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }
  226. // GET /api/inbox — the token's user: { pending, review }, the tickets assigned to them
  227. static getProjects = (route, req) => {
  228. if (req.method == 'POST') { return postNewProject(req) }
  229. if (req.method != 'GET') { return fail(405, 'GET or POST') }
  230. let details = []
  231. for (p of projectRecords()) { details.push(projectRowOf(p)) }
  232. return { projects = projectNames() details = details states = states roles = roles }
  233. }
  234. static postNewProject = (req) => {
  235. u = apiUser(req)
  236. if (u == null) { return unauthorized() }
  237. rb = readBody(req, ['title'], ['slug' 'description'])
  238. if (rb.bad != null) { return refuse(rb.bad) }
  239. r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)
  240. if (r.error != null) { return denied(r) }
  241. return reply(201, r)
  242. }
  243. static getProject = (route, req) => {
  244. p = projectByAnySlug(slugOf(route))
  245. if (req.method == 'GET') {
  246. if (p == null) { return fail(404, 'no such project') }
  247. return { project = projectRowOf(p) members = memberRows(p.id) }
  248. }
  249. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  250. u = apiUser(req)
  251. if (u == null) { return unauthorized() }
  252. rb = readBody(req, [], ['title' 'slug' 'description'])
  253. if (rb.bad != null) { return refuse(rb.bad) }
  254. if (p == null) { return fail(404, 'no such project') }
  255. r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)
  256. if (r.error != null) { return denied(r) }
  257. return reply(200, r)
  258. }
  259. static postMembers = (route, req) => {
  260. if (req.method != 'POST') { return fail(405, 'POST only') }
  261. p = projectByAnySlug(slugOf(route))
  262. u = apiUser(req)
  263. if (u == null) { return unauthorized() }
  264. rb = readBody(req, ['user' 'role'], [])
  265. if (rb.bad != null) { return refuse(rb.bad) }
  266. if (p == null) { return fail(404, 'no such project') }
  267. f = userByRef(rb.body.user, null)
  268. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  269. r = setMemberRole(p.id, u, f.user.id, rb.body.role)
  270. if (r.error != null) { return denied(r) }
  271. return reply(200, r)
  272. }
  273. static postMemberRemove = (route, req) => {
  274. if (req.method != 'POST') { return fail(405, 'POST only') }
  275. p = projectByAnySlug(slugOf(route))
  276. u = apiUser(req)
  277. if (u == null) { return unauthorized() }
  278. rb = readBody(req, ['user'], [])
  279. if (rb.bad != null) { return refuse(rb.bad) }
  280. if (p == null) { return fail(404, 'no such project') }
  281. f = userByRef(rb.body.user, p.id)
  282. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  283. r = removeMember(p.id, u, f.user.id)
  284. if (r.error != null) { return denied(r) }
  285. return reply(200, r)
  286. }
  287. // an invite is only for an admin (invites.hl mayInvite); ident makes the link
  288. static postInvites = (route, req) => {
  289. if (req.method != 'POST') { return fail(405, 'POST only') }
  290. p = projectByAnySlug(slugOf(route))
  291. u = apiUser(req)
  292. if (u == null) { return unauthorized() }
  293. rb = readBody(req, ['role'], ['uses' 'days' 'email'])
  294. if (rb.bad != null) { return refuse(rb.bad) }
  295. if (p == null) { return fail(404, 'no such project') }
  296. b = rb.body
  297. no = mayInvite(p, u, b.role)
  298. if (no != null) { return denied(no) }
  299. n = b.uses == null ? 1 : toNumber(b.uses)
  300. d = b.days == null ? 7 : toNumber(b.days)
  301. if (n == null || n < 1 || n > 1000) { return denied({ error = "'uses' must be a number from 1 to 1000" field = 'uses' }) }
  302. if (d == null || d < 1 || d > 90) { return denied({ error = "'days' must be a number from 1 to 90" field = 'days' }) }
  303. r = createInvite(p.id, b.role, n, d, b.email)
  304. if (r.error != null) { return denied(r) }
  305. return reply(201, r)
  306. }
  307. static getInbox = (route, req) => {
  308. if (req.method != 'GET') { return fail(405, 'GET only') }
  309. u = apiUser(req)
  310. if (u == null) { return unauthorized() }
  311. return inboxRows(u)
  312. }
  313. // ---- connecting an app (ticket #21; connections.hl) ------------------------------------------------
  314. // GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to
  315. // the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.
  316. // POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:
  317. // 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.
  318. // GET /api/projects/:slug/connections — who is connected (public, like the project page)
  319. // POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at once
  320. static connectStart = (route, req) => {
  321. if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }
  322. q = req.query != null ? req.query : {}
  323. r = createRequest(q.app, q.label, q['return'], q.state)
  324. if (r.error != null) { return htmlPage(400, 'Connect', r.error) }
  325. to = '/connect/' + r.nonce
  326. return new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  327. }
  328. static connectExchange = (route, req) => {
  329. if (req.method != 'POST') { return fail(405, 'POST only') }
  330. rb = readBody(req, ['code'], [])
  331. if (rb.bad != null) { return refuse(rb.bad) }
  332. r = exchangeConnectCode(rb.body.code)
  333. if (r.error != null) { return refuse({ error = r.error field = 'code' }) }
  334. emit client connectionsChanged(r.project)
  335. return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })
  336. }
  337. static projectConnections = (route, req) => {
  338. if (req.method != 'GET') { return fail(405, 'GET only') }
  339. p = projectByAnySlug(slugOf(route))
  340. if (p == null) { return fail(404, 'no such project') }
  341. return { connections = connectionRows(p.id) }
  342. }
  343. static projectDisconnect = (route, req) => {
  344. if (req.method != 'POST') { return fail(405, 'POST only') }
  345. p = projectByAnySlug(slugOf(route))
  346. u = apiUser(req)
  347. if (u == null) { return unauthorized() }
  348. rb = readBody(req, ['id'], [])
  349. if (rb.bad != null) { return refuse(rb.bad) }
  350. if (p == null) { return fail(404, 'no such project') }
  351. r = disconnect(p.id, u, rb.body.id)
  352. if (r.error != null) { return denied(r) }
  353. emit client connectionsChanged(p.slug)
  354. return reply(200, r)
  355. }
  356. // ---- old page URLs -----------------------------------------------------------------------------
  357. // They move for good (301) to the short page URLs of ticket #25, `/<slug>` and `/<slug>/<number>`, with the
  358. // project's CURRENT slug; what names nothing → 404.
  359. // `/tickets/<old global number>` (also `/tickets/<uuid>`)
  360. static oldTicketPage = (route, req) => {
  361. t = ticketByRef(route.params.ref)
  362. if (t == null) { return missing('no such ticket') }
  363. return moved(ticketHref(projectSlugOf(t), t.number))
  364. }
  365. // `/projects/<slug>` (ticket #25)
  366. static oldProjectPage = (route, req) => {
  367. p = projectByAnySlug(route.params.slug)
  368. if (p == null) { return missing('no such project') }
  369. return moved(projectHrefOf(p.slug))
  370. }
  371. // `/projects/<slug>/<number>` and `/projects/<slug>/tickets/<number>` (the form the conductor sent in messages)
  372. static oldProjectTicketPage = (route, req) => {
  373. t = ticketAt(route.params.slug, route.params.number)
  374. if (t == null) { return missing('no such ticket') }
  375. return moved(ticketHref(projectSlugOf(t), t.number))
  376. }
  377. // ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------
  378. // The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=
  379. // <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.
  380. // The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app
  381. // identity id; its tickets user (made at the first login) goes into THIS browser's hl:web
  382. // session (`req.session`, the cookie's — hybriel#11; minted from `sessions` when the browser brought none), then →
  383. // the page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks for a display name
  384. // if there is none yet. project.hl hands `req` AND the framework's session store BY REFERENCE: copied into this
  385. // second call, the session inside `req` would be a copy too and the login would never reach the session the
  386. // framework keeps (mission 010: the gate's logins failed that way).
  387. // (The identity selector logs in through the shell's face `identLogin` instead — no reload.)
  388. static loginCallback = (route, &req, &sessions) => {
  389. if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }
  390. q = req.query != null ? req.query : {}
  391. code = q.ident_code
  392. if (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }
  393. x = exchangeCode(code)
  394. if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }
  395. u = ensureUser(x.identity)
  396. if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }
  397. // an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the code
  398. let joined = null
  399. if (q.invite != null && q.invite != '') {
  400. j = joinByInvite(q.invite, x.identity, u.id)
  401. if (j.error != null) { return htmlPage(400, 'Invite failed', j.error) }
  402. joined = j.project
  403. }
  404. let s = req.session
  405. fresh = s == null
  406. if (fresh) { s = sessions.mint() }
  407. s.user = { id = u.id }
  408. s.data.tag = randomBytes(16)
  409. sessions.save(s)
  410. to = joined != null ? projectHrefOf(joined.slug) : safeNext(q.next)
  411. res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  412. if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }
  413. return res
  414. }
  415. // BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the
  416. // button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no
  417. // backslash — `/\host` is another host to some browsers), only URL-safe characters (no
  418. // scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.
  419. // Anything else → `/`.
  420. static nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  421. static safeNext = (want) => {
  422. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  423. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  424. let i = 0
  425. while (i < want.length) {
  426. if (!nextChars.includes(want[i])) { return '/' }
  427. i = i + 1
  428. }
  429. return want
  430. }

Branches

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