gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmainbb64d57cmission 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 identicalmremain/tests/connect.mjs

19.4 KB

  1. // tests/connect.mjs — THE CONNECT GATE (ticket #21): an app (gitoria) is connected to ONE project. A small ident STUB (only POST /api/exchange: one-time code → short ident id, like ident#23) and its own tickets server on a fresh store; alice (admin of her projects), bob (a member)
  2. // and carol (logged in, no role) log in; then over HTTP: the request refusals (return address), the one-time code, the
  3. // exchange, the key's reach (its project only; create / comment / state; roles apply to the NAMED person; no
  4. // settings / members / other project / relations), the disconnect; and in a real headless Chrome: the connect page
  5. // (log-in hint, project choice, a new project, confirm, the link back), the project page's line and its Disconnect.
  6. // Ports: TICKETS_CONNECT_PORT (8700), TICKETS_CONNECT_IDENT_PORT (8701), Chrome debug 8703-8709.
  7. // node tests/connect.mjs
  8. import { spawn } from 'node:child_process';
  9. import { rmSync, mkdirSync, existsSync } from 'node:fs';
  10. import { dirname, join, resolve } from 'node:path';
  11. import { fileURLToPath } from 'node:url';
  12. import { launchBrowser } from './cdp.mjs';
  13. import http from 'node:http';
  14. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  15. const HERE = dirname(fileURLToPath(import.meta.url));
  16. const APP = resolve(HERE, '..');
  17. const BIN = join(APP, 'bin/hybriel');
  18. const PORT = Number(process.env.TICKETS_CONNECT_PORT || 8700);
  19. const IDENT_PORT = Number(process.env.TICKETS_CONNECT_IDENT_PORT || 8701);
  20. const BASE = `http://127.0.0.1:${PORT}`;
  21. const GITORIA = 'http://127.0.0.1:8702'; // the "app": only its URL is used (listed in TICKETS_CONNECT_ORIGINS)
  22. const WORK = join(APP, '.scratch/connect-gate');
  23. const STORE = join(WORK, 'store');
  24. const J = JSON.stringify;
  25. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  26. rmSync(WORK, { recursive: true, force: true });
  27. mkdirSync(join(STORE, 'mpackdb'), { recursive: true });
  28. let passes = 0, failures = 0;
  29. const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`); if (ok) passes++; else failures++; };
  30. let log = '', server = null, ident = null, browser = null;
  31. async function main() {
  32. // the ident stub: a one-time code → the identity it was minted for (5-character ids, as ident#23)
  33. const codes = new Map();
  34. let codeN = 0;
  35. const mint = (identity) => { const c = (++codeN).toString(16).padStart(8, '0').repeat(3); codes.set(c, identity); return c; };
  36. ident = http.createServer((req, res) => {
  37. let body = ''; req.on('data', d => body += d);
  38. req.on('end', () => {
  39. if (req.url === '/api/exchange' && req.method === 'POST') {
  40. const b = JSON.parse(body || '{}'); const id = codes.get(b.code); codes.delete(b.code);
  41. res.writeHead(id && b.secret === 'sk_stub' ? 200 : 400, { 'content-type': 'application/json' });
  42. return res.end(J(id ? { identity: id } : { error: 'unknown code' }));
  43. }
  44. res.writeHead(404); res.end('');
  45. });
  46. });
  47. await new Promise(ok => ident.listen(IDENT_PORT, '127.0.0.1', ok));
  48. ident.base = `http://127.0.0.1:${IDENT_PORT}`;
  49. ident.stop = () => new Promise(ok => ident.close(ok));
  50. const app = { key: 'pk_stub', secret: 'sk_stub' };
  51. const alice0 = { id: 'a2b3c' }, bob0 = { id: 'b4c5d' }, carol0 = { id: 'c6d7e' };
  52. server = spawn(BIN, ['project.hl'], { cwd: APP, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(STORE, 'mpackdb'), TICKETS_SESSIONS: join(STORE, 'sessions'),
  53. IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, TICKETS_CREATOR_IDENTITY: '', TICKETS_WATCH: '0', HL_HOST: '127.0.0.1', TICKETS_CONNECT_ORIGINS: GITORIA } });
  54. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  55. for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/')).ok) break; } catch {} await sleep(250); }
  56. let emitI = 0;
  57. const temit = async (event, payload, cookie) => {
  58. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  59. const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch {}
  60. return j ? j.value : undefined;
  61. };
  62. const call = async (method, path, body, headers = {}) => {
  63. const r = await fetch(BASE + path, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...headers }, body: body ? J(body) : undefined, redirect: 'manual' });
  64. let json = null; const t = await r.text(); try { json = JSON.parse(t); } catch {}
  65. return { status: r.status, json, text: t, headers: r.headers };
  66. };
  67. const login = async (who, name) => {
  68. const code = mint(who.id);
  69. const identity = who.id;
  70. const r = await fetch(BASE + '/login/callback?ident_code=' + code, { redirect: 'manual' });
  71. const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
  72. if (name) await temit('saveDisplayName', [name], cookie);
  73. return { cookie, identity };
  74. };
  75. const alice = await login(alice0, 'alice'), bob = await login(bob0, 'bob'), carol = await login(carol0, 'carol');
  76. const tok = async (u) => (await temit('tokenCreate', ['gate'], u.cookie)).token;
  77. alice.token = await tok(alice); bob.token = await tok(bob);
  78. const auth = (t, extra = {}) => ({ authorization: 'Bearer ' + t, ...extra });
  79. // ---- projects: alice admins "gitoria-tickets" and "other"; bob is a member (edit) of gitoria-tickets, carol nothing
  80. let r = await call('POST', '/api/projects', { title: 'Gitoria tickets', slug: 'gt' }, auth(alice.token));
  81. check('setup: alice opens project gt', r.status === 201, J(r.json));
  82. r = await call('POST', '/api/projects', { title: 'Other', slug: 'other' }, auth(alice.token));
  83. check('setup: alice opens project other', r.status === 201, J(r.json));
  84. r = await call('POST', '/api/projects/gt/members', { user: bob.identity, role: 'edit' }, auth(alice.token));
  85. check('setup: bob is edit in gt', r.status === 200, J(r.json));
  86. const bobUse = await call('POST', '/api/projects', { title: 'Bobs', slug: 'bobs' }, auth(bob.token));
  87. await call('POST', '/api/projects/bobs/members', { user: alice.identity, role: 'use' }, auth(bob.token));
  88. // ---- the request
  89. const good = `/connect?app=gitoria&label=${encodeURIComponent('anton/repo')}&return=${encodeURIComponent(GITORIA + '/connect/back')}&state=abc123`;
  90. r = await call('GET', good);
  91. const nonce = (r.headers.get('location') || '').replace('/connect/', '');
  92. check('request: a valid request redirects to /connect/<nonce>', r.status === 302 && /^[0-9a-f]{32}$/.test(nonce), r.status + ' ' + r.headers.get('location'));
  93. for (const [what, q] of [
  94. ['a return address on another host', `app=x&return=${encodeURIComponent('https://evil.example.com/x')}`],
  95. ['a worldapi.org lookalike', `app=x&return=${encodeURIComponent('https://worldapi.org.evil.com/x')}`],
  96. ['userinfo in the return address', `app=x&return=${encodeURIComponent('https://[email protected]/x')}`],
  97. ['http on worldapi.org', `app=x&return=${encodeURIComponent('http://gitoria.worldapi.org/x')}`],
  98. ['no return address', 'app=x'],
  99. ['no app name', `return=${encodeURIComponent('https://gitoria.worldapi.org/x')}`],
  100. ]) { r = await call('GET', '/connect?' + q); check('request refused: ' + what, r.status === 400 && !r.headers.get('location'), r.status + ' ' + r.text.slice(0, 200)); }
  101. r = await call('GET', `/connect?app=x&return=${encodeURIComponent('https://gitoria.worldapi.org/back?x=1')}`);
  102. check('request: a subdomain of worldapi.org is allowed', r.status === 302, String(r.status));
  103. // ---- the confirmation (face): only a named user, only an admin project, once
  104. const page = await call('GET', '/connect/' + nonce);
  105. check('page: the connect page names the app, its label and its host', page.status === 200 && page.text.includes('gitoria') && page.text.includes('anton/repo') && page.text.includes('127.0.0.1:8702'), page.text.slice(0, 200));
  106. check('page: an unknown request says used up or expired', (await call('GET', '/connect/' + '0'.repeat(32))).text.includes('used up or expired'));
  107. let v = await temit('connectConfirm', [nonce, '', 'x'], null);
  108. check('confirm: signed out → refused', v && v.error, J(v));
  109. const gtId = (await call('GET', '/api/projects/gt')).json.project.id;
  110. const otherId = (await call('GET', '/api/projects/other')).json.project.id;
  111. v = await temit('connectConfirm', [nonce, gtId, ''], carol.cookie);
  112. check('confirm: carol (no role in gt) → refused, request still open', v && v.error && v.error.includes('only an admin'), J(v));
  113. v = await temit('connectConfirm', [nonce, gtId, ''], bob.cookie);
  114. check('confirm: bob (edit, not admin) → refused', v && v.error && v.error.includes('only an admin'), J(v));
  115. v = await temit('connectConfirm', [nonce, gtId, ''], alice.cookie);
  116. check('confirm: alice (admin) → a link back with a one-time code and the state', v && v.url && v.url.startsWith(GITORIA + '/connect/back?code=') && v.url.endsWith('&state=abc123'), J(v));
  117. const code = v.url.match(/code=([0-9a-f]+)/)[1];
  118. v = await temit('connectConfirm', [nonce, gtId, ''], alice.cookie);
  119. check('confirm: the same request twice → refused', v && v.error && v.error.includes('used up'), J(v));
  120. // ---- the exchange
  121. r = await call('POST', '/api/connect/exchange', { code: 'ab'.repeat(24) });
  122. check('exchange: a wrong code → 400', r.status === 400, r.status + ' ' + r.text);
  123. r = await call('POST', '/api/connect/exchange', { code });
  124. const key = r.json && r.json.key;
  125. check('exchange: the code gives a key for gt (shown once)', r.status === 200 && /^tktc_[0-9a-f]{48}$/.test(key) && r.json.project === 'gt', r.status + ' ' + r.text);
  126. r = await call('POST', '/api/connect/exchange', { code });
  127. check('exchange: the code works once only', r.status === 400, r.status + ' ' + r.text);
  128. check('exchange: the key is not stored in clear', !(await call('GET', '/api/projects/gt/connections')).text.includes(key));
  129. // ---- the key's reach
  130. const named = (who) => auth(key, { 'x-tickets-identity': who.identity });
  131. r = await call('POST', '/api/projects/gt/tickets', { subject: 'from the app' }, named(bob));
  132. check('key: creates a ticket as the named person (bob, role edit)', r.status === 201 && r.json.ticket.subject === 'from the app', r.status + ' ' + r.text);
  133. const num = r.json && r.json.ticket && r.json.ticket.number;
  134. r = await call('GET', '/api/projects/gt/tickets/' + num);
  135. check('key: the ticket is created BY bob (event author, same ident id)', r.json.events[0].author === 'bob' && r.json.events[0].userId !== '', J(r.json.events && r.json.events[0]));
  136. r = await call('POST', '/api/tickets', { project: 'gt', subject: 'via the global route' }, named(alice));
  137. check('key: POST /api/tickets with project gt works too (alice)', r.status === 201, r.status + ' ' + r.text);
  138. r = await call('POST', `/api/projects/gt/tickets/${num}/comments`, { text: 'mentioned in PR #7' }, named(bob));
  139. check('key: posts a comment "mentioned in PR" as bob', r.status === 201 && r.json.event.author === 'bob', r.status + ' ' + r.text);
  140. r = await call('POST', `/api/projects/gt/tickets/${num}/state`, { state: 'review', text: 'fixed by commit abc' }, named(bob));
  141. check('key: changes the state (fixed by commit → review)', r.status === 201 && r.json.ticket.state === 'review', r.status + ' ' + r.text);
  142. r = await call('POST', '/api/projects/gt/tickets', { subject: 'no person' }, auth(key));
  143. check('key: without X-Tickets-Identity → 403 naming the header', r.status === 403 && r.text.includes('X-Tickets-Identity'), r.status + ' ' + r.text);
  144. r = await call('POST', '/api/projects/gt/tickets', { subject: 'carol' }, named(carol));
  145. check('key: a person with no role in the project → 403 (roles apply)', r.status === 403, r.status + ' ' + r.text);
  146. r = await call('POST', '/api/projects/gt/tickets', { subject: 'nobody' }, auth(key, { 'x-tickets-identity': 'zzzzz' }));
  147. check('key: an identity that never logged in → 403', r.status === 403, r.status + ' ' + r.text);
  148. r = await call('POST', '/api/projects/other/tickets', { subject: 'wrong project' }, named(alice));
  149. check('key: another project → 403', r.status === 403 && r.text.includes('another project'), r.status + ' ' + r.text);
  150. r = await call('POST', '/api/tickets', { project: 'other', subject: 'wrong project' }, named(alice));
  151. check('key: POST /api/tickets naming another project → 403', r.status === 403, r.status + ' ' + r.text);
  152. const oth = (await call('POST', '/api/projects/other/tickets', { subject: 'in other' }, auth(alice.token))).json.ticket.number;
  153. r = await call('POST', `/api/projects/other/tickets/${oth}/comments`, { text: 'token still works' }, auth(alice.token));
  154. check('token: a user token still comments and changes state as before', r.status === 201 && (await call('POST', `/api/projects/other/tickets/${oth}/state`, { state: 'progress' }, auth(alice.token))).status === 201, r.status + ' ' + r.text);
  155. r = await call('POST', `/api/projects/other/tickets/${oth}/comments`, { text: 'x' }, named(alice));
  156. check('key: a comment on a ticket of another project → 403', r.status === 403, r.status + ' ' + r.text);
  157. r = await call('POST', `/api/tickets/${(await call('GET', '/api/projects/other/tickets/' + oth)).json.ticket.id}/state`, { state: 'review' }, named(alice));
  158. check('key: a state change by ticket id in another project → 403', r.status === 403, r.status + ' ' + r.text);
  159. for (const [what, m, p, b] of [
  160. ['project settings', 'POST', '/api/projects/gt', { title: 'hacked' }],
  161. ['members', 'POST', '/api/projects/gt/members', { user: carol.identity, role: 'admin' }],
  162. ['invites', 'POST', '/api/projects/gt/invites', { role: 'use' }],
  163. ['a new project', 'POST', '/api/projects', { title: 'nope' }],
  164. ['editing a ticket', 'POST', `/api/projects/gt/tickets/${num}/edit`, { subject: 'x' }],
  165. ['assigning', 'POST', `/api/projects/gt/tickets/${num}/assign`, { assignee: 'bob' }],
  166. ['the parent link', 'POST', `/api/projects/gt/tickets/${num}/parent`, { parent: '' }],
  167. ['disconnecting', 'POST', '/api/projects/gt/connections/remove', { id: 'x' }],
  168. ['the inbox', 'GET', '/api/inbox', null],
  169. ]) { r = await call(m, p, b, named(alice)); check('key cannot use: ' + what, r.status === 401, r.status + ' ' + r.text.slice(0, 120)); }
  170. check('key: the project settings did not change', (await call('GET', '/api/projects/gt')).json.project.title === 'Gitoria tickets');
  171. r = await call('POST', '/api/projects/gt/tickets', { subject: 'bad key' }, auth('tktc_' + '0'.repeat(48), { 'x-tickets-identity': alice.identity }));
  172. check('key: a wrong key → 401', r.status === 401, String(r.status));
  173. r = await call('GET', '/api/projects/gt/connections');
  174. check('list: gt shows the connection "gitoria: anton/repo" (public)', r.json.connections.length === 1 && r.json.connections[0].text === 'gitoria: anton/repo' && r.json.connections[0].by === 'alice', J(r.json));
  175. check('list: the project other shows none', (await call('GET', '/api/projects/other/connections')).json.connections.length === 0);
  176. // ---- disconnect through the API: only an admin; the key dies at once
  177. const cid = r.json.connections[0].id;
  178. r = await call('POST', '/api/projects/gt/connections/remove', { id: cid }, auth(bob.token));
  179. check('disconnect: bob (edit) → 403', r.status === 403, r.status + ' ' + r.text);
  180. r = await call('POST', '/api/projects/gt/connections/remove', { id: cid }, auth(alice.token));
  181. check('disconnect: alice (admin) → connection gone', r.status === 200 && r.json.connections.length === 0, r.status + ' ' + r.text);
  182. r = await call('POST', '/api/projects/gt/tickets', { subject: 'after' }, named(alice));
  183. check('disconnect: the key is dead at once → 401', r.status === 401, String(r.status));
  184. // ---- the browser: log in, connect a new project, see it on the project page, disconnect
  185. browser = await launchBrowser({ debugPortRange: [8703, 8709] });
  186. const p = await browser.newPage();
  187. const nonce2 = ((await call('GET', good.replace('abc123', 'st2'))).headers.get('location') || '').replace('/connect/', '');
  188. await p.goto(BASE + '/connect/' + nonce2);
  189. await p.waitForSelector('#connecttitle');
  190. check('browser: signed out → the login hint, no form', (await p.evaluate('!!document.querySelector("#loginhint") && !document.querySelector("#connectform")')));
  191. const cookieName = alice.cookie.split('=')[0];
  192. await p.send('Network.setCookie', { name: cookieName, value: alice.cookie.slice(cookieName.length + 1), url: BASE });
  193. await p.goto(BASE + '/connect/' + nonce2);
  194. await p.waitForSelector('#connectform');
  195. check('browser: alice logged in → the project choice lists her admin projects and "A new project"',
  196. J(await p.evaluate('Array.from(document.querySelectorAll("#connectproject option")).map(o => o.textContent.trim())')) === J(['Gitoria tickets', 'Other', 'A new project']));
  197. check('browser: it says which app and host asks', (await p.evaluate('document.querySelector("#connectwho").textContent')).includes('anton/repo'));
  198. await p.evaluate('(() => { const s = document.querySelector("#connectproject"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
  199. await p.waitForSelector('#connecttitleinput');
  200. await p.type('#connecttitleinput', 'Repo tickets');
  201. await p.click('#connectconfirm');
  202. await p.waitForSelector('#connectcontinue');
  203. const back = await p.evaluate('document.querySelector("#connectcontinue").href');
  204. check('browser: after confirming, the page links back to the app with code and state', back.startsWith(GITORIA + '/connect/back?code=') && back.endsWith('&state=st2'), back);
  205. r = await call('POST', '/api/connect/exchange', { code: back.match(/code=([0-9a-f]+)/)[1] });
  206. const key2 = r.json.key;
  207. check('browser: the exchange gives a key for the NEW project', r.status === 200 && r.json.project === 'repo-tickets', r.status + ' ' + r.text);
  208. await p.goto(BASE + '/repo-tickets');
  209. await p.waitForSelector('#connections');
  210. check('browser: the project page shows "Connected to gitoria: anton/repo" and an admin sees Disconnect',
  211. (await p.evaluate('document.querySelector("#connections").textContent')).includes('Connected to gitoria: anton/repo') && await p.evaluate('!!document.querySelector("#connections .disconnect")'));
  212. await p.click('#connections .disconnect');
  213. await p.waitFor('!document.querySelector("#connections")', { label: 'connection line gone' });
  214. r = await call('POST', '/api/projects/repo-tickets/tickets', { subject: 'x' }, auth(key2, { 'x-tickets-identity': alice.identity }));
  215. check('browser: after Disconnect the key is dead (401)', r.status === 401, String(r.status));
  216. // a reader (not logged in) sees the line but no button
  217. r = await call('POST', '/api/projects/gt/connections/remove', { id: 'x' }, auth(alice.token));
  218. const p2 = await browser.newPage();
  219. await p2.goto(BASE + '/other');
  220. await p2.waitForSelector('#heading');
  221. check('browser: a project without a connection shows no line', await p2.evaluate('!document.querySelector("#connections")'));
  222. check('server log: no absorbed errors from the connect code', !/error absorbed/.test(log), log.split('\n').filter(l => /error absorbed/.test(l)).slice(0, 3).join(' | '));
  223. }
  224. try { await main(); } catch (e) { failures++; console.log('FAIL gate crashed — ' + (e && e.stack || e)); console.log(log.slice(-2500)); }
  225. finally {
  226. try { if (browser) await browser.close(); } catch {}
  227. try { if (server) server.kill('SIGTERM'); } catch {}
  228. try { if (ident) await ident.stop(); } catch {}
  229. console.log(`\n${passes} passed, ${failures} failed`);
  230. process.exit(failures ? 1 : 0);
  231. }

Branches

  • mainmain branch

Latest commits

  • bb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmre
  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre