gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main 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/short-id-switch.mjs

22.6 KB

  1. // tests/short-id-switch.mjs — THE SHORT-ID SWITCH GATE of tickets.worldapi.org (mission 039, ident#23), all over HTTP, no browser:
  2. // 1. the OLD ident (the pre-ident#23 build, a copy of its code: OLD_IDENT_DIR, default
  3. // ident.worldapi.org/.scratch/pre-023-ident) + this app on a fresh store: alice (Default AND a second identity
  4. // "Work") and bob log in and make data; alice's "Work" identity is deleted in ident afterwards;
  5. // 2. both stop; the ident store and the app store are COPIED (the originals stay as they were);
  6. // 3. the NEW ident (ident#23's dev build, a copy of ident.worldapi.org's code: NEW_IDENT_DIR) on the ident copy;
  7. // 4. CONTROL: the app on an UNMIGRATED copy → alice comes back as a NEW user (why the migration is needed);
  8. // 5. tools/migrate-short-ids.hl on the other copy, TWICE: counts; the second run changes nothing;
  9. // 6. the app on the migrated copy with the new ident: alice's OLD app session still works; a fresh login through the
  10. // new ident finds the SAME user with the SAME data; bob too; nobody sees the other's data.
  11. // Ports 8726, 8727 (ident, app). Work dir .scratch/m039-switch (wiped at start). Every process started is stopped by PID.
  12. // node tests/short-id-switch.mjs
  13. import { spawn, execFileSync } from 'node:child_process';
  14. import { cpSync, mkdirSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs';
  15. import { dirname, join, resolve } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. import { copyIdent } from './identkit.mjs';
  18. const HERE = dirname(fileURLToPath(import.meta.url));
  19. const APP = resolve(HERE, '..');
  20. const BIN = join(APP, 'bin/hybriel');
  21. const PROJECTS = resolve(APP, '..');
  22. const NEW_IDENT_DIR = process.env.NEW_IDENT_DIR || join(PROJECTS, 'ident.worldapi.org');
  23. const OLD_IDENT_DIR = process.env.OLD_IDENT_DIR || join(PROJECTS, 'ident.worldapi.org/.scratch/pre-023-ident');
  24. const IDENT_PORT = Number(process.env.SWITCH_IDENT_PORT || 8706), PORT = Number(process.env.SWITCH_PORT || 8707);
  25. const ID = `http://127.0.0.1:${IDENT_PORT}`;
  26. const BASE = `http://127.0.0.1:${PORT}`;
  27. const W = join(APP, '.scratch/m039-switch');
  28. const J = JSON.stringify;
  29. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  30. const SHORT = /^[2-9a-hj-km-np-z]{5}$/;
  31. const OLD = /^[0-9a-f]{32}$/;
  32. let passed = 0, failed = 0;
  33. const check = (name, ok, detail = '') => {
  34. if (ok) { passed++; console.log(' ok ' + name); } else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  35. };
  36. let log = '';
  37. const procs = new Set();
  38. function start(label, bin, args, cwd, env) {
  39. log += `\n==== ${label}\n`;
  40. const p = spawn(bin, args, { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  41. p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);
  42. procs.add(p);
  43. return p;
  44. }
  45. async function stop(p) {
  46. if (!p) return;
  47. try { p.kill('SIGTERM'); } catch {}
  48. await new Promise(r => { if (p.exitCode !== null || p.signalCode !== null) return r(); p.once('exit', r); setTimeout(r, 3000); });
  49. procs.delete(p);
  50. }
  51. async function up(url, what) {
  52. for (let i = 0; i < 120; i++) { try { const r = await fetch(url, { redirect: 'manual' }); if (r.status < 500) return; } catch {} await sleep(250); }
  53. throw new Error(what + ' did not come up\n' + log.slice(-3000));
  54. }
  55. // ---- ident (old or new code, on a given data dir; codes go to a mail sink — never real mail) -------------------
  56. async function startIdentOn(codeDir, dataDir) {
  57. mkdirSync(dataDir, { recursive: true });
  58. const sink = join(dataDir, 'mail.txt');
  59. if (!existsSync(sink)) writeFileSync(sink, '');
  60. const p = start('ident ' + codeDir, join(codeDir, 'bin/hybriel'), ['project.hl'], codeDir, {
  61. IDENT_PORT: String(IDENT_PORT), IDENT_STORAGE: join(dataDir, 'mpackdb'), IDENT_SESSIONS: join(dataDir, 'sessions') + '/',
  62. IDENT_MAIL_SINK: sink, IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', IDENT_WATCH: '0', HL_HOST: '127.0.0.1',
  63. SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '' });
  64. await up(ID + '/', 'ident');
  65. return { p, sink };
  66. }
  67. let iemitI = 0;
  68. async function identEmit(event, payload, cookie) {
  69. const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++iemitI, event, payload }) });
  70. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  71. return { value: j && j.value, raw: t, setCookie: (r.headers.get('set-cookie') || '').split(';')[0] };
  72. }
  73. async function identSignIn(sink, email) {
  74. const c = await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });
  75. if (c.status !== 200) throw new Error('ident code refused: ' + c.status);
  76. const code = readFileSync(sink, 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' ')).pop().split(' ')[1];
  77. const v = await identEmit('verifyCode', [email, code, 'Europe/Vienna']);
  78. if (!v.value || !v.value.account) throw new Error('ident sign-in failed: ' + v.raw);
  79. return { email, cookie: v.setCookie, identities: v.value.identities };
  80. }
  81. // the selector's path: identities (as the app's page asks) → choose → one-time code
  82. async function selectorCode(who, app, identityName = null) {
  83. const l = await (await fetch(ID + '/api/selector/identities?key=' + app.key, { headers: { origin: BASE, cookie: who.cookie } })).json();
  84. if (!l.identities || !l.identities.length) throw new Error('no identities: ' + J(l));
  85. const pick = identityName ? l.identities.find(i => i.name === identityName) : l.identities[0];
  86. if (!pick) throw new Error('no identity named ' + identityName + ': ' + J(l));
  87. const c = await (await fetch(ID + '/api/selector/choose?key=' + app.key, { method: 'POST', headers: { origin: BASE, cookie: who.cookie, 'content-type': 'application/json' }, body: J({ identity: pick.id }) })).json();
  88. if (!c.code) throw new Error('choose failed: ' + J(c));
  89. return c.code;
  90. }
  91. // ---- the app -----------------------------------------------------------------------------------------------------
  92. async function startApp(codeDir, store, app, extra = {}) {
  93. const p = start('tickets.worldapi.org on ' + store, join(codeDir, 'bin/hybriel'), ['project.hl'], codeDir, {
  94. ...appEnv(store), HL_HOST: '127.0.0.1', IDENT_URL: ID, IDENT_EXCHANGE_URL: ID, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, ...extra });
  95. await up(BASE + '/', 'tickets.worldapi.org');
  96. return p;
  97. }
  98. // the login button's / selector's landing: <app>/login/callback?ident_code= → the app's session cookie
  99. async function appLogin(who, app, identityName = null, cookie = null) {
  100. const code = await selectorCode(who, app, identityName);
  101. const r = await fetch(BASE + '/login/callback?ident_code=' + code, { redirect: 'manual', headers: cookie ? { cookie } : {} });
  102. const loc = r.headers.get('location') || '';
  103. if (r.status !== 302 || loc.startsWith('/login/failed')) {
  104. let why = r.status !== 302 ? (await r.text()).replace(/<(style|script)[\s\S]*?<\/(style|script)>/g, " ").replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) : '';
  105. const sc = (r.headers.get('set-cookie') || '').split(';')[0] || cookie;
  106. if (r.status === 302) try { why = await (await fetch(BASE + '/login/failed', { headers: sc ? { cookie: sc } : {} })).text(); why = (why.match(/login failed[^<]*|ident refused[^<]*|that is not[^<]*/i) || [why.slice(0, 200)])[0]; } catch {}
  107. return { error: 'login failed (' + r.status + ' → ' + loc + ') ' + why };
  108. }
  109. return { cookie: (r.headers.get('set-cookie') || '').split(';')[0] || cookie };
  110. }
  111. let aemitI = 0;
  112. async function appEmit(cookie, event, payload) {
  113. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: ++aemitI, event, payload }) });
  114. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  115. return j && j.value !== undefined ? j.value : { error: 'no value: ' + t.slice(0, 300) };
  116. }
  117. const runTool = (store, app) => execFileSync(BIN, ['tools/migrate-short-ids.hl'], { cwd: APP, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
  118. env: { ...process.env, ...appEnv(store), IDENT_EXCHANGE_URL: ID, IDENT_URL: ID, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret } });
  119. const counts = (out) => { const m = out.match(/users seen (\d+), mapped (\d+), already short (\d+), unmapped (\d+), conflicts (\d+), failed (\d+)/); return m ? m.slice(1).map(Number) : null; };
  120. import { readdirSync } from 'node:fs';
  121. // ---- tickets: what a user has — display name, tickets written (events/links `user` = users @id), API tokens, and the
  122. // CREATOR role (env TICKETS_CREATOR_IDENTITY = an ident id → the tool prints its new value) ------------------------------
  123. // tickets' users.hl (NOT changed by mission 039: the folder holds held tickets#20 work) refuses an exchange answer that is
  124. // not hex (`!isHex(j.identity, 64)`) — a short id like `a68sz` is refused. So the app runs from a COPY of its code in
  125. // which that one check is widened the way gitoria/notes/calendar were (unless the folder already has it); the gate ALSO
  126. // shows that the unchanged code cannot log anyone in after the switch.
  127. let creatorOld = null;
  128. let appCode = null;
  129. const HEX_CHECK = '!isHex(j.identity, 64)';
  130. const appEnv = (store) => ({ TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(store, 'mpackdb'), TICKETS_SESSIONS: join(store, 'sessions'),
  131. TICKETS_WATCH: '0', TICKETS_PUBLIC_URL: BASE, TICKETS_CREATOR_IDENTITY: creatorOld || '' });
  132. const SKIP_CODE = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', '.git']);
  133. const unpatched = () => readFileSync(join(APP, 'lib/users.hl'), 'utf8').includes(HEX_CHECK);
  134. async function prepareCode() {
  135. if (!unpatched()) { console.log(' (tickets\' users.hl already accepts short ids — running the folder itself)'); appCode = APP; return APP; }
  136. const to = join(W, 'tickets-code');
  137. mkdirSync(to, { recursive: true });
  138. for (const e of readdirSync(APP)) if (!SKIP_CODE.has(e)) cpSync(join(APP, e), join(to, e), { recursive: true });
  139. if (existsSync(join(to, '.env'))) throw new Error('a .env was copied — refusing');
  140. const u = readFileSync(join(to, 'lib/users.hl'), 'utf8');
  141. const fn = `static isIdentId = (s) => {
  142. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > 64) { return false }
  143. let i = 0
  144. while (i < s.length) {
  145. let c = s.charCodeAt(i)
  146. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122))) { return false }
  147. i = i + 1
  148. }
  149. return true
  150. }
  151. `;
  152. writeFileSync(join(to, 'lib/users.hl'), u.replace(HEX_CHECK, '!isIdentId(j.identity)').replace('// THE EXCHANGE:', fn + '// THE EXCHANGE:'));
  153. console.log(' (tickets runs from a COPY whose users.hl accepts short ids: ' + to + ')');
  154. appCode = to;
  155. return to;
  156. }
  157. const firstEnv = async () => ({});
  158. const laterEnv = async (mig) => ({ TICKETS_CREATOR_IDENTITY: (mig.ids || {})[creatorOld] || '' });
  159. const wapi = async (method, path, token, body) => {
  160. const r = await fetch(BASE + path, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: 'Bearer ' + token } : {}) }, body: body ? J(body) : undefined });
  161. let json = null; try { json = await r.json(); } catch {}
  162. return { status: r.status, json };
  163. };
  164. async function makeData(ca, cb, cw) {
  165. const out = {};
  166. // ticket #20: a ticket needs a role — alice opens the project m039 (its admin) and makes bob and Workname `edit`
  167. for (const [who, cookie, name] of [['alice', ca, 'Alice'], ['bob', cb, 'Bob'], ['work', cw, 'Workname']]) {
  168. const n = await appEmit(cookie, 'saveDisplayName', [name]);
  169. const k = await appEmit(cookie, 'tokenCreate', ['laptop']);
  170. const y = await appEmit(cookie, 'youData', []);
  171. out[who] = { name, token: k && k.token, oldId: y && y.identity, ok: n && !n.error && k && !k.error };
  172. }
  173. const pr = await wapi('POST', '/api/projects', out.alice.token, { title: 'm039' });
  174. const m1 = await wapi('POST', '/api/projects/m039/members', out.alice.token, { user: 'Bob', role: 'edit' });
  175. const m2 = await wapi('POST', '/api/projects/m039/members', out.alice.token, { user: 'Workname', role: 'edit' });
  176. for (const w of ['alice', 'bob', 'work']) {
  177. const t = await wapi('POST', '/api/projects/m039/tickets', out[w].token, { subject: out[w].name + '\'s ticket', summary: 'written before the switch' });
  178. out[w].ticket = t.json && t.json.ticket && t.json.ticket.id;
  179. out[w].ok = out[w].ok && !!out[w].ticket;
  180. }
  181. out.alice.ok = out.alice.ok && pr.status === 201 && m1.status < 300 && m2.status < 300;
  182. out.alice.other = out.bob.ticket; out.bob.other = out.alice.ticket;
  183. creatorOld = out.alice.oldId; // alice is the creator from now on
  184. check('old world: three users with names, a ticket and a token each; /you shows the OLD per-app id (32 hex)',
  185. ['alice', 'bob', 'work'].every(w => out[w].ok && /^tkt_/.test(out[w].token || '') && OLD.test(out[w].oldId || '')), J(out).slice(0, 600));
  186. return out;
  187. }
  188. async function toolExtra(r1, r2, mig, app, alice) {
  189. const want = (mig.ids || {})[creatorOld];
  190. check('the tool prints TICKETS_CREATOR_IDENTITY\'s new value (alice\'s short id), both runs',
  191. !!want && r1.includes('set TICKETS_CREATOR_IDENTITY=' + want + ' in .env') && r2.includes('set TICKETS_CREATOR_IDENTITY=' + want + ' in .env'), (r1.match(/TICKETS_CREATOR_IDENTITY.*/) || [''])[0]);
  192. if (!unpatched()) return;
  193. // the UNCHANGED tickets code on a throw-away copy of the migrated store: the new ident's answer is refused
  194. cpSync(join(W, 'app-store-migrated'), join(W, 'app-store-unpatched'), { recursive: true });
  195. const p = await startApp(APP, join(W, 'app-store-unpatched'), app, await laterEnv(mig));
  196. const l = await appLogin(alice, app);
  197. await stop(p);
  198. if (/^[0-9a-f]+$/.test(want)) { console.log(' skip the unchanged-code check: alice\'s short id ' + want + ' happens to be hex'); return; }
  199. check('the UNCHANGED tickets code (users.hl isHex) can NOT log alice in after the switch — tickets needs the users.hl change first',
  200. !!l.error, J(l).slice(0, 300));
  201. console.log(' | unchanged code: ' + (l.error || 'logged in?!'));
  202. }
  203. async function isNewEmptyUser(cookie, before) {
  204. const y = await appEmit(cookie, 'youData', []);
  205. return y && y.named === false && SHORT.test(y.identity || '') && y.identity !== before.alice.oldId;
  206. }
  207. async function sameUser(label, cookie, who, mig) {
  208. const y = await appEmit(cookie, 'youData', []);
  209. // ticket #20: the token made before still acts as the same user — with the same role in m039 (alice admin, the others edit)
  210. const members = (await wapi('GET', '/api/projects/m039')).json.members || [];
  211. const role = (members.find(m => m.name === who.name) || {}).role;
  212. const edit = await wapi('POST', `/api/tickets/${who.ticket}/edit`, who.token, { summary: 'edited after the switch: ' + label });
  213. const other = who.name === 'Bob' ? await wapi('POST', `/api/tickets/${who.other}/edit`, who.token, { summary: 'bob edits alice\'s (role edit): ' + label }) : { status: 200 };
  214. const want = mig ? mig.ids[who.oldId] : null;
  215. check(label + ': the SAME user — name ' + who.name + ', the token made before still works, role ' + (who.name === 'Alice' ? 'admin' : 'edit') + ' kept' + (mig ? ', /you shows the short id' : ''),
  216. y && y.name === who.name && (y.tokens || []).some(t => t.label === 'laptop') && role === (who.name === 'Alice' ? 'admin' : 'edit') && edit.status < 300 && other.status < 300 && (!mig || y.identity === want),
  217. J([y, role, edit, other, want]).slice(0, 500));
  218. }
  219. let identP = null, appP = null;
  220. try {
  221. rmSync(W, { recursive: true, force: true });
  222. mkdirSync(W, { recursive: true });
  223. const oldCode = join(W, 'ident-old-code'), newCode = join(W, 'ident-new-code');
  224. copyIdent(OLD_IDENT_DIR, oldCode);
  225. copyIdent(NEW_IDENT_DIR, newCode);
  226. const oldIsOld = !readFileSync(join(oldCode, 'store.hl'), 'utf8').includes('shortId');
  227. const newIsNew = readFileSync(join(newCode, 'project.hl'), 'utf8').includes('migrate-ids');
  228. check('the old ident has no short ids, the new one has /api/migrate-ids', oldIsOld && newIsNew);
  229. const CODE = await prepareCode();
  230. // ---- 1. the OLD world -------------------------------------------------------------------------------------------
  231. console.log('# 1. old ident + tickets.worldapi.org, data made');
  232. let id = await startIdentOn(oldCode, join(W, 'ident-data'));
  233. identP = id.p;
  234. const alice = await identSignIn(id.sink, '[email protected]');
  235. const bob = await identSignIn(id.sink, '[email protected]');
  236. const added = await identEmit('addIdentity', [{ identityName: 'Work' }], alice.cookie);
  237. const work = added.value && added.value.identities ? added.value.identities.find(i => i.identityName === 'Work') : null;
  238. const reg = await identEmit('appCreate', [{ name: 'tickets.worldapi.org', origins: [BASE] }], alice.cookie);
  239. if (!reg.value || !reg.value.secret) throw new Error('appCreate failed: ' + reg.raw);
  240. const app = { key: reg.value.app.apiKey, secret: reg.value.secret };
  241. writeFileSync(join(W, 'test-app-key.json'), J(app)); // the THROW-AWAY test app's key (for a by-hand rerun of the tool)
  242. appP = await startApp(CODE, join(W, 'app-store'), app, await firstEnv());
  243. const a1 = await appLogin(alice, app), b1 = await appLogin(bob, app);
  244. const w1 = await appLogin(alice, app, 'Work');
  245. check('old ident: alice, bob and alice\'s "Work" log in to tickets.worldapi.org', !a1.error && !b1.error && !w1.error && work != null, J([a1.error, b1.error, w1.error]));
  246. const before = await makeData(a1.cookie, b1.cookie, w1.cookie);
  247. await stop(appP); appP = null;
  248. const dropped = await identEmit('dropIdentity', [work.id], alice.cookie);
  249. check('alice deletes her "Work" identity in ident (its app user becomes UNMAPPED)', dropped.value && !dropped.value.error, dropped.raw.slice(0, 200));
  250. await stop(identP); identP = null;
  251. // ---- 2. copies --------------------------------------------------------------------------------------------------
  252. cpSync(join(W, 'ident-data'), join(W, 'ident-data-new'), { recursive: true });
  253. cpSync(join(W, 'app-store'), join(W, 'app-store-control'), { recursive: true });
  254. cpSync(join(W, 'app-store'), join(W, 'app-store-migrated'), { recursive: true });
  255. // ---- 3. the NEW ident on the copy -------------------------------------------------------------------------------
  256. console.log('# 2. new ident (ident#23) on a copy of the old ident store');
  257. id = await startIdentOn(newCode, join(W, 'ident-data-new'));
  258. identP = id.p;
  259. const mig = await (await fetch(ID + '/api/migrate-ids', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J(app) })).json();
  260. const olds = Object.keys(mig.ids || {});
  261. check('new ident: migrate-ids maps 2 old ids (alice Default, bob) to short ids; the deleted identity is left out; not finished',
  262. olds.length === 2 && olds.every(k => OLD.test(k)) && Object.values(mig.ids).every(v => SHORT.test(v)) && mig.finished === false, J(mig));
  263. // ---- 4. CONTROL: unmigrated store ---------------------------------------------------------------------------------
  264. console.log('# 3. control: the app on an UNMIGRATED copy');
  265. appP = await startApp(CODE, join(W, 'app-store-control'), app, await laterEnv(mig));
  266. const c1 = await appLogin(alice, app);
  267. if (c1.error) check('control: login through the new ident fails or …', false, c1.error);
  268. else check('control (not migrated): alice comes back as a NEW, EMPTY user — this is what the migration prevents', await isNewEmptyUser(c1.cookie, before), '');
  269. await stop(appP); appP = null;
  270. // ---- 5. the migration, twice --------------------------------------------------------------------------------------
  271. console.log('# 4. tools/migrate-short-ids.hl on the other copy, twice');
  272. const r1 = runTool(join(W, 'app-store-migrated'), app);
  273. writeFileSync(join(W, 'migrate-run1.txt'), r1);
  274. console.log(r1.trim().split('\n').map(l => ' | ' + l).join('\n'));
  275. check('run 1: users seen 3, mapped 2, already short 0, unmapped 1 (deleted identity), conflicts 0, failed 0', J(counts(r1)) === J([3, 2, 0, 1, 0, 0]), J(counts(r1)));
  276. const shorts = [...r1.matchAll(/MAPPED user \S+ → (\S+)/g)].map(m => m[1]).sort();
  277. check('run 1: the users now hold exactly the short ids ident mapped', J(shorts) === J(Object.values(mig.ids).sort()), J([shorts, mig.ids]));
  278. check('run 1 never prints the key or the secret', !r1.includes(app.secret) && !r1.includes(app.key));
  279. const r2 = runTool(join(W, 'app-store-migrated'), app);
  280. writeFileSync(join(W, 'migrate-run2.txt'), r2);
  281. check('run 2 (idempotent): seen 3, mapped 0, already short 2, unmapped 1', J(counts(r2)) === J([3, 0, 2, 1, 0, 0]), J(counts(r2)));
  282. await toolExtra(r1, r2, mig, app, alice);
  283. const badSecret = (() => { try { runTool(join(W, 'app-store-migrated'), { key: app.key, secret: 'sk_' + '0'.repeat(48) }); return 'ran'; } catch (e) { return String(e.stdout || '') + String(e.stderr || ''); } })();
  284. // the control copy: the app ran with the new ident BEFORE the migration and made a new user for alice's short id
  285. const rc = (() => { try { return 'exit 0: ' + runTool(join(W, 'app-store-control'), app); } catch (e) { return String(e.stdout || '') + String(e.stderr || ''); } })();
  286. writeFileSync(join(W, 'migrate-control.txt'), rc);
  287. check('the tool on the control copy (app ran before the migration): alice = CONFLICT, left alone, exit non-zero',
  288. J(counts(rc)) === J([4, 1, 1, 1, 1, 0]) && /CONFLICT user/.test(rc) && /CONFLICTS:/.test(rc) && !rc.startsWith('exit 0'), rc.slice(0, 400));
  289. check('a wrong secret: the tool stops with ident\'s 401, changes nothing', /401/.test(badSecret), badSecret.slice(0, 300));
  290. // ---- 6. the app on the migrated store ------------------------------------------------------------------------------
  291. console.log('# 5. the app on the MIGRATED copy with the new ident');
  292. appP = await startApp(CODE, join(W, 'app-store-migrated'), app, await laterEnv(mig));
  293. await sameUser('alice, OLD app session (no new login)', a1.cookie, before.alice);
  294. const a2 = await appLogin(alice, app);
  295. check('alice logs in again through the new ident', !a2.error, a2.error);
  296. if (!a2.error) await sameUser('alice, fresh login through the new ident', a2.cookie, before.alice, mig);
  297. const b2 = await appLogin(bob, app);
  298. check('bob logs in again through the new ident', !b2.error, b2.error);
  299. if (!b2.error) await sameUser('bob, fresh login through the new ident', b2.cookie, before.bob, mig);
  300. await stop(appP); appP = null;
  301. const r3 = runTool(join(W, 'app-store-migrated'), app);
  302. writeFileSync(join(W, 'migrate-run3.txt'), r3);
  303. check('after the logins: still 3 users (no new one was made), all short or unmapped as before', J(counts(r3)) === J([3, 0, 2, 1, 0, 0]), J(counts(r3)));
  304. await stop(identP); identP = null;
  305. } catch (err) {
  306. failed++; console.log(' FAIL (aborted) ' + (err.stack || err));
  307. } finally {
  308. for (const p of [...procs]) await stop(p);
  309. mkdirSync(W, { recursive: true });
  310. writeFileSync(join(W, 'servers.log'), log);
  311. }
  312. console.log(`\n${passed} passed, ${failed} failed`);
  313. process.exit(failed ? 1 : 0);

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