tickets
All repositories: gitoria
23.0 KB
// lib/api.hl — THE FUNCTION ROUTES (project.hl `routes`): the JSON API for the scheduler, a CLI and connected apps,// and the three page routes that are not components (/login/callback, /connect, /tickets/:ref). Thin wrappers:// check the method, the token / key and the body (lib/api-helpers.hl), call the topic function, push what// changed (the same frames the web faces push, so an open browser follows an API write live), answer.// See README.md "API" for the shapes.//// Every POST body goes through readBody: invalid JSON, unknown field, missing/empty required field, non-string// value → 400. WRITES NEED A TOKEN (ticket #7): no / bad / revoked token → 401, checked BEFORE the body. The// author is the token's user; a body with `author` → 400 naming it. Reads stay public.// THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,// the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise, unchanged.// TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global// number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the// per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.import { Response } from 'hl:http1'import { randomBytes } from 'hl:crypto'import { reply, fail, queryOf, readBody, refuse, unauthorized, denied, apiUser, apiAuth, actorIn, stateFilter, filterLabel, wantsMarkdown, markdownReply, htmlPage, moved, missing } from './api-helpers.hl'import { exchangeCode, ensureUser } from './users.hl'import { projectNames, projectByAnySlug, projectByRef, projectRecords, projectRowOf, projectSlugOf, createProject, updateProject, memberRows, setMemberRole, userByRef, roles } from './projects.hl'import { states } from './tickets-helpers.hl'import { pageEventOf } from './events.hl'import { ticketRows, ticketWithEvents, ticketByRef, ticketAt, createTicket, addComment, changeState, assignTicket, editTicket, setParent, changeBlocker, relatedViews, removeMember, inboxRows } from './tickets.hl'import { mayInvite, createInvite, joinByInvite } from './invites.hl'import { createRequest, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'import { listDocument, ticketDocument } from './mdview.hl'import { ticketHref, projectHrefOf } from './util.hl'// ---- tickets ------------------------------------------------------------------------------------static byRef = (route) => { return ticketByRef(route.params.ref) }static byNumber = (route) => { return ticketAt(route.params.slug, route.params.number) }static listTickets = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }// hl:http1 hands the parsed query string as `req.query`; the path carries noneq = req.query != null ? req.query : queryOf(req.path)sf = stateFilter(q)if (sf.bad != null) { return sf.bad }pr = q.project != null && q.project != '' ? q.project : nullrows = ticketRows(pr, sf.state)if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }return { tickets = rows }}// POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }static createFromApi = (project, b, auth, req) => {pr = projectByRef(project)w = actorIn(auth, req, pr != null ? pr.id : null)if (w.response != null) { return w.response }user = w.userr = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }if (!r.existed) { emit client ticketCreated(r.ticket) }return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })}static postTicket = (route, req) => {if (req.method == 'GET') { return listTickets(route, req) }if (req.method != 'POST') { return fail(405, 'GET or POST') }u = apiAuth(req)if (u == null) { return unauthorized() }rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])if (rb.bad != null) { return refuse(rb.bad) }return createFromApi(rb.body.project, rb.body, u, req)}static projectTickets = (route, req) => {slug = route.params.slugif (req.method == 'GET') {if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }q = req.query != null ? req.query : queryOf(req.path)sf = stateFilter(q)if (sf.bad != null) { return sf.bad }rows = ticketRows(slug, sf.state)if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }return { tickets = rows }}if (req.method != 'POST') { return fail(405, 'GET or POST') }u = apiAuth(req)if (u == null) { return unauthorized() }rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])if (rb.bad != null) { return refuse(rb.bad) }return createFromApi(slug, rb.body, u, req)}static getOne = (t, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }if (t == null) { return fail(404, 'no such ticket') }data = ticketWithEvents(t)if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }return data}static commentOnTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }auth = apiAuth(req)if (auth == null) { return unauthorized() }rb = readBody(req, ['text'], [])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }w = actorIn(auth, req, t.project)if (w.response != null) { return w.response }u = w.userb = rb.bodyr = addComment(t.id, u, b.text)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return reply(201, r)}static stateOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }auth = apiAuth(req)if (auth == null) { return unauthorized() }rb = readBody(req, ['state'], ['text'])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }w = actorIn(auth, req, t.project)if (w.response != null) { return w.response }u = w.userb = rb.bodyr = changeState(t.id, b.state, u, b.text)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }emit client ticketsRelated(relatedViews(r.ticket.id, []))return reply(201, r)}// POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.// Needs the role edit or admin → else 403.static assignOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, [], ['assignee'])if (rb.bad != null) { return refuse(rb.bad) }if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }if (t == null) { return fail(404, 'no such ticket') }let who = ''if (rb.body.assignee.trim() != '') {f = userByRef(rb.body.assignee, t.project)if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }who = f.user.id}r = assignTicket(t.id, u, who)if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return reply(201, r)}// POST …/edit { subject?, summary? } (ticket #8): a member with the role edit or admin → else 403;// the history keeps the previous valuesstatic editOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, [], ['subject' 'summary'])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }b = rb.bodyr = editTicket(t.id, u, b.subject, b.summary)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(relatedViews(r.ticket.id, []))return reply(201, r)}// RELATIONS (ticket #4, mission 017; tickets.hl "relations"): a ticket is named as// `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.// POST …/parent { parent }: set the parent; `"parent": ""` removes it.// POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.// 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.static linkReply = (r) => {if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(r.related)return reply(201, { ticket = r.ticket event = r.event })}static parentOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, [], ['parent'])if (rb.bad != null) { return refuse(rb.bad) }if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }if (t == null) { return fail(404, 'no such ticket') }return linkReply(setParent(t.id, u, rb.body.parent))}static blockersOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, [], ['add' 'remove'])if (rb.bad != null) { return refuse(rb.bad) }b = rb.bodyif ((b.add == null) == (b.remove == null)) { return refuse({ error = "give exactly one of 'add' or 'remove' (<project>#<number>)" field = b.add == null ? 'add' : 'remove' }) }if (t == null) { return fail(404, 'no such ticket') }adding = b.add != nullreturn linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))}static getTicket = (route, req) => { return getOne(byRef(route), req) }static postComment = (route, req) => { return commentOnTicket(byRef(route), req) }static postState = (route, req) => { return stateOfTicket(byRef(route), req) }static getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }static postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }static postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }static postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }static postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }static postEdit = (route, req) => { return editOfTicket(byRef(route), req) }static postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }static postParent = (route, req) => { return parentOfTicket(byRef(route), req) }static postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }static postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }static postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }// ---- projects and members (ticket #20; projects.hl) ----------------------------------------------// GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }// POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens// a project and is its first admin; the slug is generated from the title unless given// GET /api/projects/:slug → { project, members } (the slug may be an old one)// POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working// POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone// who logged in here; sets (adds / changes) the role: use | edit | admin// POST /api/projects/:slug/members/remove { user } — admin only// POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }// GET /api/inbox — the token's user: { pending, review }, the tickets assigned to themstatic getProjects = (route, req) => {if (req.method == 'POST') { return postNewProject(req) }if (req.method != 'GET') { return fail(405, 'GET or POST') }let details = []for (p of projectRecords()) { details.push(projectRowOf(p)) }return { projects = projectNames() details = details states = states roles = roles }}static postNewProject = (req) => {u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, ['title'], ['slug' 'description'])if (rb.bad != null) { return refuse(rb.bad) }r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)if (r.error != null) { return denied(r) }return reply(201, r)}static getProject = (route, req) => {p = projectByAnySlug(route.params.slug)if (req.method == 'GET') {if (p == null) { return fail(404, 'no such project') }return { project = projectRowOf(p) members = memberRows(p.id) }}if (req.method != 'POST') { return fail(405, 'GET or POST') }u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, [], ['title' 'slug' 'description'])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)if (r.error != null) { return denied(r) }return reply(200, r)}static postMembers = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }p = projectByAnySlug(route.params.slug)u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, ['user' 'role'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }f = userByRef(rb.body.user, null)if (f.error != null) { return refuse({ error = f.error field = 'user' }) }r = setMemberRole(p.id, u, f.user.id, rb.body.role)if (r.error != null) { return denied(r) }return reply(200, r)}static postMemberRemove = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }p = projectByAnySlug(route.params.slug)u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, ['user'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }f = userByRef(rb.body.user, p.id)if (f.error != null) { return refuse({ error = f.error field = 'user' }) }r = removeMember(p.id, u, f.user.id)if (r.error != null) { return denied(r) }return reply(200, r)}// an invite is only for an admin (invites.hl mayInvite); ident makes the linkstatic postInvites = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }p = projectByAnySlug(route.params.slug)u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, ['role'], ['uses' 'days' 'email'])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }b = rb.bodyno = mayInvite(p, u, b.role)if (no != null) { return denied(no) }n = b.uses == null ? 1 : toNumber(b.uses)d = b.days == null ? 7 : toNumber(b.days)if (n == null || n < 1 || n > 1000) { return denied({ error = "'uses' must be a number from 1 to 1000" field = 'uses' }) }if (d == null || d < 1 || d > 90) { return denied({ error = "'days' must be a number from 1 to 90" field = 'days' }) }r = createInvite(p.id, b.role, n, d, b.email)if (r.error != null) { return denied(r) }return reply(201, r)}static getInbox = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }u = apiUser(req)if (u == null) { return unauthorized() }return inboxRows(u)}// ---- connecting an app (ticket #21; connections.hl) ------------------------------------------------// GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to// the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.// POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:// 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.// GET /api/projects/:slug/connections — who is connected (public, like the project page)// POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at oncestatic connectStart = (route, req) => {if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }q = req.query != null ? req.query : {}r = createRequest(q.app, q.label, q['return'], q.state)if (r.error != null) { return htmlPage(400, 'Connect', r.error) }to = '/connect/' + r.noncereturn new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })}static connectExchange = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }rb = readBody(req, ['code'], [])if (rb.bad != null) { return refuse(rb.bad) }r = exchangeConnectCode(rb.body.code)if (r.error != null) { return refuse({ error = r.error field = 'code' }) }emit client connectionsChanged(r.project)return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })}static projectConnections = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }p = projectByAnySlug(route.params.slug)if (p == null) { return fail(404, 'no such project') }return { connections = connectionRows(p.id) }}static projectDisconnect = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }p = projectByAnySlug(route.params.slug)u = apiUser(req)if (u == null) { return unauthorized() }rb = readBody(req, ['id'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }r = disconnect(p.id, u, rb.body.id)if (r.error != null) { return denied(r) }emit client connectionsChanged(p.slug)return reply(200, r)}// ---- old page URLs -----------------------------------------------------------------------------// They move for good (301) to the short page URLs of ticket #25, `/<slug>` and `/<slug>/<number>`, with the// project's CURRENT slug; what names nothing → 404.// `/tickets/<old global number>` (also `/tickets/<uuid>`)static oldTicketPage = (route, req) => {t = ticketByRef(route.params.ref)if (t == null) { return missing('no such ticket') }return moved(ticketHref(projectSlugOf(t), t.number))}// `/projects/<slug>` (ticket #25)static oldProjectPage = (route, req) => {p = projectByAnySlug(route.params.slug)if (p == null) { return missing('no such project') }return moved(projectHrefOf(p.slug))}// `/projects/<slug>/<number>` and `/projects/<slug>/tickets/<number>` (the form the conductor sent in messages)static oldProjectTicketPage = (route, req) => {t = ticketAt(route.params.slug, route.params.number)if (t == null) { return missing('no such ticket') }return moved(ticketHref(projectSlugOf(t), t.number))}// ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------// The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=// <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.// The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app// identity id; its tickets user (made at the first login) goes into THIS browser's hl:web// session (`req.session`, the cookie's — hybriel#11; minted from `sessions` when the browser brought none), then →// the page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks for a display name// if there is none yet. project.hl hands `req` AND the framework's session store BY REFERENCE: copied into this// second call, the session inside `req` would be a copy too and the login would never reach the session the// framework keeps (mission 010: the gate's logins failed that way).// (The identity selector logs in through the shell's face `identLogin` instead — no reload.)static loginCallback = (route, &req, &sessions) => {if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }q = req.query != null ? req.query : {}code = q.ident_codeif (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }x = exchangeCode(code)if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }u = ensureUser(x.identity)if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }// an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the codelet joined = nullif (q.invite != null && q.invite != '') {j = joinByInvite(q.invite, x.identity, u.id)if (j.error != null) { return htmlPage(400, 'Invite failed', j.error) }joined = j.project}let s = req.sessionfresh = s == nullif (fresh) { s = sessions.mint() }s.user = { id = u.id }s.data.tag = randomBytes(16)sessions.save(s)to = joined != null ? projectHrefOf(joined.slug) : safeNext(q.next)res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }return res}// BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the// button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no// backslash — `/\host` is another host to some browsers), only URL-safe characters (no// scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.// Anything else → `/`.static nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'static safeNext = (want) => {if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }let i = 0while (i < want.length) {if (!nextChars.includes(want[i])) { return '/' }i = i + 1}return want}
Branches
- mainmain branch
Latest commits
- 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