gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9bfba36a9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre9bfba36a/project.hl

33.0 KB

  1. // project.hl — tickets.worldapi.org: THE APP. Routes (pages + JSON API) and WHO HEARS WHAT.
  2. import WebFramework from 'hl:web'
  3. import { env } from 'hl:proc'
  4. import Styles from './styles.hl'
  5. import { dark, darker } from './shared/tokens.hl'
  6. import { ticketRows, ticketWithEvents, projectNames, projectByAnySlug, projectByRef, projectRecords, projectRowOf, createProject, updateProject, memberRows, setMemberRole, removeMember, userByRef, isAdminOf, roleOf, createTicket, addComment, changeState, assignTicket, editTicket, pageEventOf, stateOf, states, roles, inboxRows, ticketByRef, ticketAt, ticketHref, setParent, changeBlocker, relatedViews, addMember, projectSlugOf } from './store.hl'
  7. import { migrateAll } from './migrate.hl'
  8. import { createInvite, acceptedInvite } from './invites.hl'
  9. import { Response } from 'hl:http1'
  10. import { reply, fail, queryOf, readBody, refuse, unauthorized } from './api.hl'
  11. import { userOfBearer, exchangeCode, ensureUser, userRecord } from './users.hl'
  12. import { createRequest, connectionOfKey, personOf, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'
  13. import { randomBytes } from 'hl:crypto'
  14. import { wantsMarkdown, markdownReply, listDocument, ticketDocument } from './mdview.hl'
  15. import TicketList from './components/ticket_list.hl'
  16. import Ticket from './components/ticket.hl'
  17. import Inbox from './components/inbox.hl'
  18. import You from './components/you.hl'
  19. import NewProject from './components/new_project.hl'
  20. import Settings from './components/settings.hl'
  21. import Connect from './components/connect.hl'
  22. static siteName = "tickets"
  23. appTitle = siteName
  24. styles = Styles
  25. // ---- THE INSTALLABLE APP (mission 046), the same way calendar.worldapi.org does it: hl:web's own web app manifest
  26. // (/__hl/manifest.webmanifest, linked from every head with the apple-touch-icon and theme-color) and service worker
  27. // (/__hl/sw.js) from these settings — no JavaScript of ours. Icons in icons/ (icon.svg, a ticket stub, is the source;
  28. // the PNGs and favicon.ico are rendered from it with rsvg-convert / magick, README "PWA"). The theme colour is the
  29. // header's background (darker, as tracker), the splash background the page's (dark) — both off the tokens, not written out again.
  30. appThemeColor = darker.value
  31. appBackgroundColor = dark.value
  32. appIcons = [
  33. { src = '/icons/icon-192.png' sizes = '192x192' purpose = 'any' }
  34. { src = '/icons/icon-512.png' sizes = '512x512' purpose = 'any' }
  35. { src = '/icons/icon-192.png' sizes = '192x192' purpose = 'maskable' }
  36. { src = '/icons/icon-512.png' sizes = '512x512' purpose = 'maskable' }
  37. ]
  38. appTouchIcon = '/icons/apple-touch-icon.png'
  39. appFavicon = '/icons/icon.svg'
  40. // OFFLINE: the shell and `/` — `/` IS the ticket list, so offline it shows the list as this browser last loaded it,
  41. // and the shell (components/main.hl) says "You are offline". Its value-form emits are never queued. Other pages are
  42. // not kept, except a project list (/projects/<slug>, same component) this browser visited; the rest get hl:web's
  43. // "Unavailable offline" page.
  44. offline = [ TicketList ]
  45. // ---- the JSON API (for the scheduler and a CLI) ----------------------------------------
  46. // Every write pushes the same frames the web faces push, so an open browser follows an
  47. // API write live. See README.md "API" for the shapes. Every POST body goes through
  48. // readBody (api.hl): invalid JSON, unknown field, missing/empty required field, non-string
  49. // value → 400.
  50. // WRITES NEED A TOKEN (ticket #7): `Authorization: Bearer <token>` (users.hl; a logged-in user
  51. // makes tokens on /you). No / bad / revoked token → 401, checked BEFORE the body. The author is
  52. // the token's user; a body with `author` → 400 naming it. Reads stay public.
  53. //
  54. // THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,
  55. // the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise,
  56. // unchanged.
  57. // EDITING (ticket #8): POST …/edit { subject?, summary? } — a member with the role edit or admin
  58. // of the ticket's project → else 403; the history keeps the previous values.
  59. //
  60. // TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global
  61. // number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the
  62. // per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.
  63. byRef = (route) => { return ticketByRef(route.params.ref) }
  64. byNumber = (route) => { return ticketAt(route.params.slug, route.params.number) }
  65. stateFilter = (q) => {
  66. if (q.state == null || q.state == '') { return { state = null } }
  67. let st = stateOf(q.state)
  68. if (st == null) { return { bad = fail(400, 'unknown state — one of: ' + states.join(', ')) } }
  69. return { state = st }
  70. }
  71. listTickets = (route, req) => {
  72. if (req.method != 'GET') { return fail(405, 'GET only') }
  73. // hl:http1 hands the parsed query string as `req.query`; the path carries none
  74. let q = req.query != null ? req.query : queryOf(req.path)
  75. let sf = stateFilter(q)
  76. if (sf.bad != null) { return sf.bad }
  77. let pr = q.project != null && q.project != '' ? q.project : null
  78. let rows = ticketRows(pr, sf.state)
  79. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }
  80. return { tickets = rows }
  81. }
  82. // the filters of a list, for the Markdown heading ('' = none)
  83. filterLabel = (project, state) => {
  84. let parts = []
  85. if (project != null) { parts.push('project ' + project) }
  86. if (state != null) { parts.push('state ' + state) }
  87. return parts.join(', ')
  88. }
  89. // the token's user of an API write, or null (→ 401)
  90. apiUser = (req) => { return userOfBearer(req.headers['authorization']) }
  91. // WHO WRITES, for the endpoints an app's KEY may also use (ticket #21: create a ticket, comment, change the state):
  92. // { user } for a user's token, { conn } for a project key (connections.hl), null = 401 (checked before the body)
  93. apiAuth = (req) => {
  94. let h = req.headers['authorization']
  95. let u = userOfBearer(h)
  96. if (u != null) { return { user = u } }
  97. let c = connectionOfKey(h)
  98. return c != null ? { conn = c } : null
  99. }
  100. // the acting user inside project `projectId`: { user } or { response }. A key reaches ITS project only and names the
  101. // person by `X-Tickets-Identity`; the project's roles then decide as for any user.
  102. actorIn = (auth, req, projectId) => {
  103. if (auth.user != null) { return { user = auth.user } }
  104. if (projectId == null || auth.conn.project != projectId) { return { response = reply(403, { error = 'this key belongs to another project' field = '' }) } }
  105. let a = personOf(req.headers['x-tickets-identity'])
  106. if (a.error != null) { return { response = reply(403, { error = a.error field = '' }) } }
  107. return { user = a.user }
  108. }
  109. // POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }
  110. createFromApi = (project, b, auth, req) => {
  111. let pr = projectByRef(project)
  112. let w = actorIn(auth, req, pr != null ? pr.id : null)
  113. if (w.response != null) { return w.response }
  114. let user = w.user
  115. let r = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)
  116. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  117. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  118. if (!r.existed) { emit client ticketCreated(r.ticket) }
  119. return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })
  120. }
  121. postTicket = (route, req) => {
  122. if (req.method == 'GET') { return listTickets(route, req) }
  123. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  124. let u = apiAuth(req)
  125. if (u == null) { return unauthorized() }
  126. let rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])
  127. if (rb.bad != null) { return refuse(rb.bad) }
  128. return createFromApi(rb.body.project, rb.body, u, req)
  129. }
  130. projectTickets = (route, req) => {
  131. let slug = route.params.slug
  132. if (req.method == 'GET') {
  133. if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }
  134. let q = req.query != null ? req.query : queryOf(req.path)
  135. let sf = stateFilter(q)
  136. if (sf.bad != null) { return sf.bad }
  137. let rows = ticketRows(slug, sf.state)
  138. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }
  139. return { tickets = rows }
  140. }
  141. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  142. let u = apiAuth(req)
  143. if (u == null) { return unauthorized() }
  144. let rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])
  145. if (rb.bad != null) { return refuse(rb.bad) }
  146. return createFromApi(slug, rb.body, u, req)
  147. }
  148. getOne = (t, req) => {
  149. if (req.method != 'GET') { return fail(405, 'GET only') }
  150. if (t == null) { return fail(404, 'no such ticket') }
  151. let data = ticketWithEvents(t)
  152. if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }
  153. return data
  154. }
  155. commentOnTicket = (t, req) => {
  156. if (req.method != 'POST') { return fail(405, 'POST only') }
  157. let auth = apiAuth(req)
  158. if (auth == null) { return unauthorized() }
  159. let rb = readBody(req, ['text'], [])
  160. if (rb.bad != null) { return refuse(rb.bad) }
  161. if (t == null) { return fail(404, 'no such ticket') }
  162. let w = actorIn(auth, req, t.project)
  163. if (w.response != null) { return w.response }
  164. let u = w.user
  165. let b = rb.body
  166. let r = addComment(t.id, u, b.text)
  167. if (r.error == 'no such ticket') { return fail(404, r.error) }
  168. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  169. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  170. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  171. return reply(201, r)
  172. }
  173. stateOfTicket = (t, req) => {
  174. if (req.method != 'POST') { return fail(405, 'POST only') }
  175. let auth = apiAuth(req)
  176. if (auth == null) { return unauthorized() }
  177. let rb = readBody(req, ['state'], ['text'])
  178. if (rb.bad != null) { return refuse(rb.bad) }
  179. if (t == null) { return fail(404, 'no such ticket') }
  180. let w = actorIn(auth, req, t.project)
  181. if (w.response != null) { return w.response }
  182. let u = w.user
  183. let b = rb.body
  184. let r = changeState(t.id, b.state, u, b.text)
  185. if (r.error == 'no such ticket') { return fail(404, r.error) }
  186. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  187. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  188. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  189. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  190. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  191. return reply(201, r)
  192. }
  193. // POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.
  194. // Needs the role edit or admin → else 403.
  195. assignOfTicket = (t, req) => {
  196. if (req.method != 'POST') { return fail(405, 'POST only') }
  197. let u = apiUser(req)
  198. if (u == null) { return unauthorized() }
  199. let rb = readBody(req, [], ['assignee'])
  200. if (rb.bad != null) { return refuse(rb.bad) }
  201. if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }
  202. if (t == null) { return fail(404, 'no such ticket') }
  203. let who = ''
  204. if (rb.body.assignee.trim() != '') {
  205. let f = userByRef(rb.body.assignee, t.project)
  206. if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }
  207. who = f.user.id
  208. }
  209. let r = assignTicket(t.id, u, who)
  210. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  211. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  212. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  213. return reply(201, r)
  214. }
  215. // POST …/edit { subject?, summary? } (ticket #8): the token's user must be the ticket's author
  216. editOfTicket = (t, req) => {
  217. if (req.method != 'POST') { return fail(405, 'POST only') }
  218. let u = apiUser(req)
  219. if (u == null) { return unauthorized() }
  220. let rb = readBody(req, [], ['subject' 'summary'])
  221. if (rb.bad != null) { return refuse(rb.bad) }
  222. if (t == null) { return fail(404, 'no such ticket') }
  223. let b = rb.body
  224. let r = editTicket(t.id, u, b.subject, b.summary)
  225. if (r.error == 'no such ticket') { return fail(404, r.error) }
  226. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  227. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  228. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  229. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  230. return reply(201, r)
  231. }
  232. // RELATIONS (ticket #4, mission 017; store.hl "relations"): a ticket is named as
  233. // `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.
  234. // POST …/parent { parent }: set the parent; `"parent": ""` removes it.
  235. // POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.
  236. // 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.
  237. linkReply = (r) => {
  238. if (r.error == 'no such ticket') { return fail(404, r.error) }
  239. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  240. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  241. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  242. emit client ticketsRelated(r.related)
  243. return reply(201, { ticket = r.ticket event = r.event })
  244. }
  245. parentOfTicket = (t, req) => {
  246. if (req.method != 'POST') { return fail(405, 'POST only') }
  247. let u = apiUser(req)
  248. if (u == null) { return unauthorized() }
  249. let rb = readBody(req, [], ['parent'])
  250. if (rb.bad != null) { return refuse(rb.bad) }
  251. if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }
  252. if (t == null) { return fail(404, 'no such ticket') }
  253. return linkReply(setParent(t.id, u, rb.body.parent))
  254. }
  255. blockersOfTicket = (t, req) => {
  256. if (req.method != 'POST') { return fail(405, 'POST only') }
  257. let u = apiUser(req)
  258. if (u == null) { return unauthorized() }
  259. let rb = readBody(req, [], ['add' 'remove'])
  260. if (rb.bad != null) { return refuse(rb.bad) }
  261. let b = rb.body
  262. 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' }) }
  263. if (t == null) { return fail(404, 'no such ticket') }
  264. let adding = b.add != null
  265. return linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))
  266. }
  267. getTicket = (route, req) => { return getOne(byRef(route), req) }
  268. postComment = (route, req) => { return commentOnTicket(byRef(route), req) }
  269. postState = (route, req) => { return stateOfTicket(byRef(route), req) }
  270. getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }
  271. postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }
  272. postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }
  273. postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }
  274. postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }
  275. postEdit = (route, req) => { return editOfTicket(byRef(route), req) }
  276. postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }
  277. postParent = (route, req) => { return parentOfTicket(byRef(route), req) }
  278. postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }
  279. postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }
  280. postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }
  281. // ---- PROJECTS AND MEMBERS (ticket #20; store.hl) ---------------------------------------------------
  282. // GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }
  283. // POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens
  284. // a project and is its first admin; the slug is generated from the title unless given
  285. // GET /api/projects/:slug → { project, members } (the slug may be an old one)
  286. // POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working
  287. // POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone
  288. // who logged in here; sets (adds / changes) the role: use | edit | admin
  289. // POST /api/projects/:slug/members/remove { user } — admin only
  290. // POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }
  291. // GET /api/inbox — the token's user: { pending, review }, the tickets assigned to them
  292. getProjects = (route, req) => {
  293. if (req.method == 'POST') { return postNewProject(req) }
  294. if (req.method != 'GET') { return fail(405, 'GET or POST') }
  295. let details = []
  296. for (p of projectRecords()) { details.push(projectRowOf(p)) }
  297. return { projects = projectNames() details = details states = states roles = roles }
  298. }
  299. // a refusal of the store: 403 when the role is missing, else 400
  300. denied = (r) => {
  301. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  302. return refuse({ error = r.error field = r.field })
  303. }
  304. postNewProject = (req) => {
  305. let u = apiUser(req)
  306. if (u == null) { return unauthorized() }
  307. let rb = readBody(req, ['title'], ['slug' 'description'])
  308. if (rb.bad != null) { return refuse(rb.bad) }
  309. let r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)
  310. if (r.error != null) { return denied(r) }
  311. return reply(201, r)
  312. }
  313. getProject = (route, req) => {
  314. let p = projectByAnySlug(route.params.slug)
  315. if (req.method == 'GET') {
  316. if (p == null) { return fail(404, 'no such project') }
  317. return { project = projectRowOf(p) members = memberRows(p.id) }
  318. }
  319. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  320. let u = apiUser(req)
  321. if (u == null) { return unauthorized() }
  322. let rb = readBody(req, [], ['title' 'slug' 'description'])
  323. if (rb.bad != null) { return refuse(rb.bad) }
  324. if (p == null) { return fail(404, 'no such project') }
  325. let r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)
  326. if (r.error != null) { return denied(r) }
  327. return reply(200, r)
  328. }
  329. postMembers = (route, req) => {
  330. if (req.method != 'POST') { return fail(405, 'POST only') }
  331. let p = projectByAnySlug(route.params.slug)
  332. let u = apiUser(req)
  333. if (u == null) { return unauthorized() }
  334. let rb = readBody(req, ['user' 'role'], [])
  335. if (rb.bad != null) { return refuse(rb.bad) }
  336. if (p == null) { return fail(404, 'no such project') }
  337. let f = userByRef(rb.body.user, null)
  338. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  339. let r = setMemberRole(p.id, u, f.user.id, rb.body.role)
  340. if (r.error != null) { return denied(r) }
  341. return reply(200, r)
  342. }
  343. postMemberRemove = (route, req) => {
  344. if (req.method != 'POST') { return fail(405, 'POST only') }
  345. let p = projectByAnySlug(route.params.slug)
  346. let u = apiUser(req)
  347. if (u == null) { return unauthorized() }
  348. let rb = readBody(req, ['user'], [])
  349. if (rb.bad != null) { return refuse(rb.bad) }
  350. if (p == null) { return fail(404, 'no such project') }
  351. let f = userByRef(rb.body.user, p.id)
  352. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  353. let r = removeMember(p.id, u, f.user.id)
  354. if (r.error != null) { return denied(r) }
  355. return reply(200, r)
  356. }
  357. // an invite is only for an admin; ident makes the link (invites.hl)
  358. makeInvite = (p, u, role, uses, days, email) => {
  359. if (!isAdminOf(p.id, u)) { return { error = 'only an admin of the project can invite' field = '' forbidden = true } }
  360. if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }
  361. let n = uses == null ? 1 : toNumber(uses)
  362. let d = days == null ? 7 : toNumber(days)
  363. if (n == null || n < 1 || n > 1000) { return { error = "'uses' must be a number from 1 to 1000" field = 'uses' } }
  364. if (d == null || d < 1 || d > 90) { return { error = "'days' must be a number from 1 to 90" field = 'days' } }
  365. return createInvite(p.id, role, n, d, email)
  366. }
  367. postInvites = (route, req) => {
  368. if (req.method != 'POST') { return fail(405, 'POST only') }
  369. let p = projectByAnySlug(route.params.slug)
  370. let u = apiUser(req)
  371. if (u == null) { return unauthorized() }
  372. let rb = readBody(req, ['role'], ['uses' 'days' 'email'])
  373. if (rb.bad != null) { return refuse(rb.bad) }
  374. if (p == null) { return fail(404, 'no such project') }
  375. let b = rb.body
  376. let r = makeInvite(p, u, b.role, b.uses, b.days, b.email)
  377. if (r.error != null) { return denied(r) }
  378. return reply(201, r)
  379. }
  380. getInbox = (route, req) => {
  381. if (req.method != 'GET') { return fail(405, 'GET only') }
  382. let u = apiUser(req)
  383. if (u == null) { return unauthorized() }
  384. return inboxRows(u)
  385. }
  386. // ---- CONNECTING AN APP (ticket #21; connections.hl) --------------------------------------------------
  387. // GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to
  388. // the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.
  389. // POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:
  390. // 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.
  391. // GET /api/projects/:slug/connections — who is connected (public, like the project page)
  392. // POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at once
  393. connectStart = (route, req) => {
  394. if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }
  395. let q = req.query != null ? req.query : {}
  396. let r = createRequest(q.app, q.label, q['return'], q.state)
  397. if (r.error != null) { return htmlPage(400, 'Connect', r.error) }
  398. let to = '/connect/' + r.nonce
  399. return new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  400. }
  401. connectExchange = (route, req) => {
  402. if (req.method != 'POST') { return fail(405, 'POST only') }
  403. let rb = readBody(req, ['code'], [])
  404. if (rb.bad != null) { return refuse(rb.bad) }
  405. let r = exchangeConnectCode(rb.body.code)
  406. if (r.error != null) { return refuse({ error = r.error field = 'code' }) }
  407. emit client connectionsChanged(r.project)
  408. return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })
  409. }
  410. projectConnections = (route, req) => {
  411. if (req.method != 'GET') { return fail(405, 'GET only') }
  412. let p = projectByAnySlug(route.params.slug)
  413. if (p == null) { return fail(404, 'no such project') }
  414. return { connections = connectionRows(p.id) }
  415. }
  416. projectDisconnect = (route, req) => {
  417. if (req.method != 'POST') { return fail(405, 'POST only') }
  418. let p = projectByAnySlug(route.params.slug)
  419. let u = apiUser(req)
  420. if (u == null) { return unauthorized() }
  421. let rb = readBody(req, ['id'], [])
  422. if (rb.bad != null) { return refuse(rb.bad) }
  423. if (p == null) { return fail(404, 'no such project') }
  424. let r = disconnect(p.id, u, rb.body.id)
  425. if (r.error != null) { return denied(r) }
  426. emit client connectionsChanged(p.slug)
  427. return reply(200, r)
  428. }
  429. // OLD PAGE URLS (`/tickets/<old global number>`, also `/tickets/<uuid>`) move for good
  430. // to `/projects/<slug>/<number>`
  431. oldTicketPage = (route, req) => {
  432. let t = ticketByRef(route.params.ref)
  433. if (t == null) { return new Response('no such ticket', { status = 404 headers = { 'Content-Type' = 'text/plain; charset=utf-8' } }) }
  434. let to = ticketHref(projectSlugOf(t), t.number)
  435. return new Response('moved to ' + to, { status = 301 headers = { 'Location' = to 'Content-Type' = 'text/plain; charset=utf-8' } })
  436. }
  437. // ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------
  438. // The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=
  439. // <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.
  440. // The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app
  441. // identity id; its tickets user (made at the first login) goes into THIS browser's hl:web
  442. // session (`req.session`, the cookie's — hybriel#11; minted here when the browser brought none), then → the
  443. // page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks
  444. // for a display name if there is none yet.
  445. // (The identity selector logs in through the shell's face `identLogin` instead — no reload.)
  446. htmlPage = (status, title, text) => {
  447. let 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>'
  448. return new Response(body, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })
  449. }
  450. // BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the
  451. // button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no
  452. // backslash — `/\host` is another host to some browsers), only URL-safe characters (no
  453. // scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.
  454. // Anything else → `/`.
  455. nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  456. safeNext = (want) => {
  457. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  458. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  459. let i = 0
  460. while (i < want.length) {
  461. if (!nextChars.includes(want[i])) { return '/' }
  462. i = i + 1
  463. }
  464. return want
  465. }
  466. loginCallback = (route, req) => {
  467. if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }
  468. let q = req.query != null ? req.query : {}
  469. let code = q.ident_code
  470. if (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }
  471. let x = exchangeCode(code)
  472. if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }
  473. let u = ensureUser(x.identity)
  474. if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }
  475. // an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the code; only when ident says this
  476. // identity accepted it does the person join the project, with the invite's role
  477. let joined = null
  478. if (q.invite != null && q.invite != '') {
  479. let a = acceptedInvite(q.invite, x.identity)
  480. if (a.error != null) { return htmlPage(400, 'Invite failed', a.error) }
  481. let jp = projectByRef(a.project)
  482. if (jp == null || !roles.includes(a.role)) { return htmlPage(400, 'Invite failed', 'the invite is for a project or role that does not exist here') }
  483. addMember(jp.id, u.id, a.role)
  484. joined = jp
  485. }
  486. let s = req.session
  487. let fresh = s == null
  488. if (fresh) { s = server.sessions.mint() }
  489. s.user = { id = u.id }
  490. s.data.tag = randomBytes(16)
  491. server.sessions.save(s)
  492. let to = joined != null ? '/projects/' + joined.slug : safeNext(q.next)
  493. let res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  494. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  495. return res
  496. }
  497. routes = [
  498. { pattern = "/favicon.ico" file = "./icons/favicon.ico" headers = { 'Cache-Control' = 'no-cache' } }
  499. // the installable app (mission 046): the icons (manifest and service worker are hl:web's own, from appIcons / offline)
  500. { pattern = "/icons/icon-192.png" file = "./icons/icon-192.png" headers = { 'Cache-Control' = 'no-cache' } }
  501. { pattern = "/icons/icon-512.png" file = "./icons/icon-512.png" headers = { 'Cache-Control' = 'no-cache' } }
  502. { pattern = "/icons/apple-touch-icon.png" file = "./icons/apple-touch-icon.png" headers = { 'Cache-Control' = 'no-cache' } }
  503. { pattern = "/icons/icon.svg" file = "./icons/icon.svg" headers = { 'Cache-Control' = 'no-cache' } }
  504. { pattern = "/login/callback" function = loginCallback }
  505. { pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }
  506. // ticket #12 (mission 024): the Markdown editor <md-editor>, vendored from worldapi-components
  507. { pattern = "/md-editor.js" file = "./shared/md-editor.js" headers = { 'Cache-Control' = 'no-cache' } }
  508. { pattern = "/api/tickets" function = postTicket }
  509. { pattern = "/api/tickets/:ref" function = getTicket }
  510. { pattern = "/api/tickets/:ref/comments" function = postComment }
  511. { pattern = "/api/tickets/:ref/state" function = postState }
  512. { pattern = "/api/tickets/:ref/edit" function = postEdit }
  513. { pattern = "/api/tickets/:ref/assign" function = postAssign }
  514. { pattern = "/api/tickets/:ref/parent" function = postParent }
  515. { pattern = "/api/tickets/:ref/blocked-by" function = postBlockers }
  516. { pattern = "/api/projects" function = getProjects }
  517. { pattern = "/api/inbox" function = getInbox }
  518. { pattern = "/api/projects/:slug" function = getProject }
  519. { pattern = "/api/projects/:slug/members" function = postMembers }
  520. { pattern = "/api/projects/:slug/members/remove" function = postMemberRemove }
  521. { pattern = "/api/projects/:slug/invites" function = postInvites }
  522. { pattern = "/api/connect/exchange" function = connectExchange }
  523. { pattern = "/api/projects/:slug/connections" function = projectConnections }
  524. { pattern = "/api/projects/:slug/connections/remove" function = projectDisconnect }
  525. { pattern = "/connect" function = connectStart }
  526. { pattern = "/api/projects/:slug/tickets" function = projectTickets }
  527. { pattern = "/api/projects/:slug/tickets/:number" function = getProjectTicket }
  528. { pattern = "/api/projects/:slug/tickets/:number/comments" function = postProjectComment }
  529. { pattern = "/api/projects/:slug/tickets/:number/state" function = postProjectState }
  530. { pattern = "/api/projects/:slug/tickets/:number/edit" function = postProjectEdit }
  531. { pattern = "/api/projects/:slug/tickets/:number/assign" function = postProjectAssign }
  532. { pattern = "/api/projects/:slug/tickets/:number/parent" function = postProjectParent }
  533. { pattern = "/api/projects/:slug/tickets/:number/blocked-by" function = postProjectBlockers }
  534. { pattern = "/" component = TicketList }
  535. { pattern = "/project/:projectName" component = TicketList }
  536. { pattern = "/projects/:projectName" component = TicketList }
  537. { pattern = "/state/:stateSlug" component = TicketList }
  538. { pattern = "/project/:projectName/state/:stateSlug" component = TicketList }
  539. { pattern = "/inbox" component = Inbox }
  540. { pattern = "/new-project" component = NewProject }
  541. { pattern = "/connect/:nonce" component = Connect }
  542. { pattern = "/projects/:projectSlug/settings" component = Settings }
  543. { pattern = "/you" component = You }
  544. { pattern = "/projects/:projectSlug/:ticketNumber" component = Ticket }
  545. { pattern = "/tickets/:ref" function = oldTicketPage }
  546. ]
  547. // WHO GETS THE PUSH: tickets and events are public (reading needs no login). A frame
  548. // reaches a connection only if a mounted component listens for it.
  549. // `signedIn` / `signedOut` (ticket #7) go to the tabs of ONE session: the one whose login
  550. // carries that random tag (session.data.tag, set at login) — every page of it switches to
  551. // logged in / out without a reload.
  552. tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }
  553. audience = {
  554. ticketCreated = (row, session) => { return true }
  555. ticketEvent = (ticketId, ev, row, session) => { return true }
  556. // ticket #4: the relation views (store.hl relationViewOf) of a ticket and its related ones
  557. ticketsRelated = (views, session) => { return true }
  558. // ticket #21: an app was connected / disconnected — the project page refreshes its line
  559. connectionsChanged = (slug, session) => { return true }
  560. signedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }
  561. signedOut = (tag, session) => { return tag != null && tagOf(session) == tag }
  562. }
  563. sessionDir = env('TICKETS_SESSIONS') != null ? env('TICKETS_SESSIONS') : null
  564. port = env('TICKETS_PORT') != null ? toNumber(env('TICKETS_PORT')) : 8350
  565. // THE LISTENER's interface (mission 010, deploy to Byrodin): hl:web reads HL_HOST (or HOST)
  566. // itself (hybriel#24): 127.0.0.1 on Byrodin behind nginx; unset = 0.0.0.0 (dev on Loreana).
  567. // TICKETS_WATCH=0 turns the dev watcher off (the container: a deploy is an rsync + restart,
  568. // half-copied .hl files must not be re-analysed); unset = on, as before.
  569. watching = env('TICKETS_WATCH') != '0'
  570. // THE MIGRATION of ticket #20 (migrate.hl): idempotent, runs at every start
  571. for (line of migrateAll()) { console.log('migrated: ' + line) }
  572. server = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching)
  573. // THE GLOBAL ERROR HANDLER (ticket #7): a plugin's failure — above all hl:fetch's refused /
  574. // timed-out exchange with ident (users.hl exchangeCode) — arrives here instead of aborting the
  575. // request with a 500 that shows source paths; the failed call yields null and the caller answers
  576. // it ("ident did not answer"). Every absorbed error is logged.
  577. on Error(e) { console.log('error absorbed: ' + e.message) }

Branches

Latest commits

  • 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