ident
All repositories: gitoria
19.9 KB
// tests/realdata-baseline.mjs (ident mission 009, code order): every read ident answers + a fixed write sequence, on a// COPY of the LIVE storage, saved for diffing two code trees (a cleanup must answer the same). Compare two outputs with// tests/realdata-compare.py. README "Test" → "Same output". Copied from gitoria's (mission 002) and made for ident.// node tests/realdata-baseline.mjs <tree> <port> <outdir> (kills only the server it started)// <tree> must be a CODE-ONLY copy (no .env: the server reads .env from its cwd; the mail goes to a sink file anyway).// The live copy: IDENT_REALDATA (default .scratch/m009/realdata) must hold mpackdb/ (Byrodin's storage/mpackdb), e.g.// tar -C /CONTAINERS/projects/ident.worldapi.org/storage -cf - mpackdb | ssh loreana tar -C <repo>/.scratch/m009/realdata -xf -// Each run works on a fresh copy of it (<outdir>.run, removed after). Signed in as the creator (identity az5b2 → account// 0mufd5afy0xm) through a session file written before the start; every page is fetched signed in AND signed out.// Writes: identities, time zone, an app (key/secret), the login button + exchange, the selector, kinds + notifications,// inbox switches, invites, migrate-ids, the OTP login of a new and of the creator's address (mail sink), sign out// (everywhere), app delete — then the stored state (tools/dump-store.hl on a copy).import { spawn, execSync, execFileSync } from 'node:child_process';import { request } from 'node:http';import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';import { createHash, randomBytes } from 'node:crypto';import { join, resolve } from 'node:path';const [tree, portArg, outArg] = process.argv.slice(2);const PORT = Number(portArg);const W = resolve(process.env.IDENT_REALDATA || '.scratch/m009/realdata'), OUT = resolve(outArg), RUN = OUT + '.run';const ACCOUNT = '0mufd5afy0xm', IDENTITY = '0mufd5afyezw', SHORT = 'az5b2';if (existsSync(join(resolve(tree), '.env'))) throw new Error('the tree has a .env — use a code-only copy');rmSync(RUN, { recursive: true, force: true }); rmSync(OUT, { recursive: true, force: true });mkdirSync(join(RUN, 'sessions'), { recursive: true }); mkdirSync(OUT, { recursive: true });execSync(`cp -a ${W}/mpackdb ${RUN}/mpackdb`);const SINK = join(RUN, 'mail.txt');// the creator's address, read off a dump of a copy (opening a table rewrites it, hybriel #40)const dumpOf = (src) => {const cp = RUN + '/dump-copy'; rmSync(cp, { recursive: true, force: true }); execSync(`cp -a ${src} ${cp}`);const out = execFileSync(resolve(tree, 'bin/hybriel'), ['tools/dump-store.hl'], { cwd: resolve(tree), env: { ...process.env, IDENT_STORAGE: cp, IDENT_WATCH: '0' }, encoding: 'utf8' });rmSync(cp, { recursive: true, force: true });return JSON.parse(out.slice(out.indexOf('{')));};const before = dumpOf(join(RUN, 'mpackdb'));const EMAIL = before.accounts.find(a => a.id === ACCOUNT).email;const conns = before.connections.map(c => c.id).sort();const apps = before.apps.slice().sort((a, b) => a.name < b.name ? -1 : 1);// ---- the session (hl:web: file name sha256(sid), JSON) -------------------------------------------------------------const sid = randomBytes(16).toString('hex'), now = Date.now();writeFileSync(join(RUN, 'sessions', createHash('sha256').update(sid).digest('hex')),JSON.stringify({ created: now, data: {}, id: sid, seen: now, user: { id: ACCOUNT } }), { mode: 0o600 });const COOKIE = 'identsid=' + sid;const sleep = ms => new Promise(r => setTimeout(r, ms));const HOST = `ident.test:${PORT}`;const srv = spawn(resolve(tree, 'bin/hybriel'), ['project.hl'], { cwd: resolve(tree), stdio: ['ignore', 'pipe', 'pipe'],env: { ...process.env, IDENT_PORT: String(PORT), HL_HOST: '127.0.0.1', IDENT_STORAGE: join(RUN, 'mpackdb'), IDENT_SESSIONS: join(RUN, 'sessions') + '/',IDENT_MAIL_SINK: SINK, IDENT_WATCH: '0', IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', IDENT_PUBLIC_URL: '',SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '' } });let log = ''; srv.stdout.on('data', d => log += d); srv.stderr.on('data', d => log += d);const hreq = (method, path, headers = {}, body = null) => new Promise((res) => {const rq = request({ host: '127.0.0.1', port: PORT, path, method, headers: { host: HOST, ...headers } }, (r) => {const parts = []; r.on('data', d => parts.push(d)); r.on('end', () => res({ status: r.statusCode, headers: r.headers, body: Buffer.concat(parts).toString('latin1') }));});rq.on('error', (e) => res({ status: 0, headers: {}, body: 'ERROR ' + e.message }));if (body != null) { rq.setHeader('content-length', Buffer.byteLength(body)); rq.write(body); }rq.end();});let n = 0;const file = (name, text) => writeFileSync(join(OUT, String(++n).padStart(4, '0') + '-' + name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120)), text);const head = (r) => [r.status, r.headers['content-type'] || '', r.headers['location'] || '', r.headers['cache-control'] || '',r.headers['access-control-allow-origin'] || '', r.headers['access-control-allow-credentials'] || '', r.headers['access-control-allow-methods'] || '',r.headers['vary'] || '', r.headers['set-cookie'] ? 'set-cookie' : ''].join(' | ');const save = async (name, method, path, headers = {}, body = null) => {const r = await hreq(method, path, headers, body);file(name, head(r) + '\n' + r.body);return r;};const J = JSON.stringify;const post = (name, path, body, headers = {}) => save(name, 'POST', path, { 'content-type': 'application/json', ...headers }, typeof body === 'string' ? body : J(body));const val = (r) => { try { return JSON.parse(r.body); } catch { return null; } };let ei = 0;// a face over hl:web's POST carrier; answers { value, cookie (a fresh session's) }const face = async (name, event, payload, cookie = COOKIE) => {const r = await hreq('POST', '/__hl/emit', { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, J({ t: 'emit', i: ++ei, event, payload }));file('w-face-' + name, head(r) + '\n' + r.body);let v = null; try { v = JSON.parse(r.body).value; } catch {}return { value: v, cookie: r.headers['set-cookie'] ? r.headers['set-cookie'][0].split(';')[0] : null };};const lastCode = (email) => {const lines = readFileSync(SINK, 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));return lines.length ? lines[lines.length - 1].split(' ')[1] : null;};try {for (let i = 0; i < 240; i++) { if ((await hreq('GET', '/api/online')).status === 204) break; await sleep(250); }// ---- READS (signed in and signed out) ------------------------------------------------------------------------------const pages = ['/', '/start', '/apps', '/inbox', '/inbox/nosuch', '/inbox/' + 'x'.repeat(200), ...conns.map(c => '/inbox/' + c),'/code', '/signin/' + 'f'.repeat(32), '/signin/' + 'f'.repeat(32) + '/code', '/signin/xyz/code', '/signin/xyz', '/nope', '/nope/deeper','/selector.js', '/avatar.js', '/favicon.ico', '/icons/icon.svg', '/icons/icon-192.png', '/api/online', '/api/nope', '/api/exchange','/api/code', '/api/kinds', '/api/notify', '/api/invites', '/api/invites/list', '/api/invites/get', '/api/invites/revoke', '/api/migrate-ids','/login', '/login?key=pk_nope&return=https://x.example', '/invite/nosuch', '/invite/' + 'a'.repeat(100),'/__hl/app.css', '/__hl/manifest.webmanifest', '/__hl/sw.js'];for (const a of apps) {pages.push('/login?key=' + a.apiKey, '/login?key=' + a.apiKey + '&return=https://evil.example/x', '/login?key=' + a.apiKey + '&return=' + encodeURIComponent('javascript:alert(1)'),'/login?key=' + a.apiKey + '&return=' + encodeURIComponent(a.origins.split(' ')[0] + '@evil.example/'));}const modules = new Set();for (const p of pages) {const r = await save('in-' + p, 'GET', p, { cookie: COOKIE }); await save('out-' + p, 'GET', p);for (const m of r.body.matchAll(/(\/(?:components|__hl)\/[\w./-]+\.(?:hl|js|css))\?v=[0-9a-f]+/g)) modules.add(m[0]);}for (const m of [...modules].sort()) await save('module-' + m.replace(/\?v=.*/, ''), 'GET', m);// the selector of every live app: its origin signed in / out, a foreign origin, no origin, the preflight, a bad choosefor (const a of apps) {const o = a.origins.split(' ')[0];await save('sel-in-' + a.name, 'GET', '/api/selector/identities?key=' + a.apiKey, { origin: o, cookie: COOKIE });await save('sel-out-' + a.name, 'GET', '/api/selector/identities?key=' + a.apiKey, { origin: o });await save('sel-evil-' + a.name, 'GET', '/api/selector/identities?key=' + a.apiKey, { origin: 'https://evil.example', cookie: COOKIE });await save('sel-pre-' + a.name, 'OPTIONS', '/api/selector/choose?key=' + a.apiKey, { origin: o });await save('sel-bad-' + a.name, 'POST', '/api/selector/choose?key=' + a.apiKey, { origin: o, cookie: COOKIE, 'content-type': 'application/json' }, J({ identity: 'nope' }));await save('sel-post-ids-' + a.name, 'POST', '/api/selector/identities?key=' + a.apiKey, { origin: o, cookie: COOKIE });}await save('sel-noorigin', 'GET', '/api/selector/identities?key=' + apps[0].apiKey, { cookie: COOKIE });await save('sel-nokey', 'GET', '/api/selector/identities', { origin: 'https://tickets.worldapi.org', cookie: COOKIE });// the app APIs refused (no secret is known for a live app)for (const [p, body] of [['/api/exchange', { key: apps[0].apiKey, secret: 'sk_x', code: 'c' }], ['/api/exchange', { key: apps[0].apiKey, secret: ' ', code: 'c' }],['/api/exchange', '{"key":'], ['/api/exchange', '[1]'], ['/api/exchange', { key: 1, secret: 's', code: 'c' }], ['/api/exchange', { key: 'k', secret: 's', code: 'c', x: 1 }],['/api/kinds', { key: apps[0].apiKey, secret: 'sk_x', kinds: [] }], ['/api/notify', { key: apps[0].apiKey, secret: 'sk_x', identity: SHORT, name: 'n', text: 't' }],['/api/invites', { key: apps[0].apiKey, secret: 'sk_x', project: 'p', role: 'r', return: 'https://tickets.worldapi.org/' }], ['/api/invites/list', { key: 'k', secret: 's' }],['/api/invites/get', { key: 'k', secret: 's', id: 'x' }], ['/api/invites/revoke', { key: 'k', secret: 's' }], ['/api/migrate-ids', { key: apps[0].apiKey, secret: 'sk_x' }],['/api/code', { email: 'not-an-address' }], ['/api/code', { email: 5 }], ['/api/code', ''], ['/api/code', { email: '[email protected]', more: 1 }]]) {await post('api-refused-' + p, p, body);}// ---- WRITES ---------------------------------------------------------------------------------------------------------// identities + time zone (the creator, through the session file)let f = await face('addIdentity', 'addIdentity', [{ identityName: 'RD', nickname: 'rd', firstname: 'Real', lastname: 'Data' }]);const newIdent = f.value && f.value.identity;await face('addIdentity-bad', 'addIdentity', [{ nope: 'x' }]);await face('editIdentity', 'editIdentity', [newIdent, { nickname: 'rd2', avatar: '' }]);await face('editIdentity-other', 'editIdentity', ['0mufd5afy623', { nickname: 'x' }]);await face('changeTimeZone-bad', 'changeTimeZone', ['Not a zone!']);await face('changeTimeZone', 'changeTimeZone', ['Europe/Berlin']);await save('w-page-identities', 'GET', '/', { cookie: COOKIE });await face('dropIdentity', 'dropIdentity', [newIdent]);await face('noSession-addIdentity', 'addIdentity', [{}], null);// an appf = await face('appCreate-bad', 'appCreate', [{ name: 'RD', origins: ['ftp://x'] }]);f = await face('appCreate', 'appCreate', [{ name: 'RD app', origins: ['https://rd.example.org', 'http://127.0.0.1:9'] }]);const app = f.value.app; let secret = f.value.secret;await face('appUpdate', 'appUpdate', [app.id, { name: 'RD app 2', origins: ['https://rd.example.org'] }]);f = await face('appNewSecret', 'appNewSecret', [app.id]);const oldSecret = secret; secret = f.value.secret;await save('w-page-apps', 'GET', '/apps', { cookie: COOKIE });const K = app.apiKey, ORIGIN = 'https://rd.example.org';await post('w-kinds-oldsecret', '/api/kinds', { key: K, secret: oldSecret, kinds: [] });await post('w-kinds', '/api/kinds', { key: K, secret, kinds: [{ name: 'comment', push: true, email: false }, { name: 'mention', push: false, email: true }] });await post('w-kinds-bad', '/api/kinds', { key: K, secret, kinds: [{ name: 'x', push: 1, email: true }] });// the login buttonlet r = await save('w-login', 'GET', '/login?key=' + K + '&return=' + encodeURIComponent(ORIGIN + '/back?x=1'));let rid = r.headers.location.split('/').pop();await save('w-signin-in', 'GET', '/signin/' + rid, { cookie: COOKIE });await save('w-signin-out', 'GET', '/signin/' + rid);await save('w-signin-code', 'GET', '/signin/' + rid + '/code', { cookie: COOKIE });f = await face('chooseIdentity', 'chooseIdentity', [rid, IDENTITY]);const code = new URL(f.value.url).searchParams.get('ident_code');await face('chooseIdentity-again', 'chooseIdentity', [rid, IDENTITY]);await post('w-exchange', '/api/exchange', { key: K, secret, code });await post('w-exchange-again', '/api/exchange', { key: K, secret, code });// the selectorr = await save('w-sel-ids', 'GET', '/api/selector/identities?key=' + K, { origin: ORIGIN, cookie: COOKIE });const pick = val(r).identities[0].id;r = await save('w-sel-choose', 'POST', '/api/selector/choose?key=' + K, { origin: ORIGIN, cookie: COOKIE, 'content-type': 'application/json' }, J({ identity: pick }));await post('w-sel-exchange', '/api/exchange', { key: K, secret, code: val(r).code });await save('w-sel-choose-out', 'POST', '/api/selector/choose?key=' + K, { origin: ORIGIN, 'content-type': 'application/json' }, J({ identity: pick }));// notificationsawait post('w-notify', '/api/notify', { key: K, secret, identity: SHORT, name: 'comment', text: 'Hello\nworld', icon: 'https://rd.example.org/i.png', link: 'https://rd.example.org/x' });await post('w-notify-urgent', '/api/notify', { key: K, secret, identity: SHORT.toUpperCase(), name: 'mention', text: 'urgent one', urgent: true });await post('w-notify-unregistered', '/api/notify', { key: K, secret, identity: SHORT, name: 'other', text: 'not registered' });await post('w-notify-unknown', '/api/notify', { key: K, secret, identity: 'zzzzz', name: 'comment', text: 'x' });await post('w-notify-badurl', '/api/notify', { key: K, secret, identity: SHORT, name: 'comment', text: 'x', icon: 'javascript:x' });r = await save('w-page-inbox', 'GET', '/inbox', { cookie: COOKIE });const conn = [...r.body.matchAll(/\/inbox\/(0m[0-9a-z]{10})/g)].map(m => m[1]).filter(c => !conns.includes(c))[0];const note = [...r.body.matchAll(/value="(0m[0-9a-z]{10})"/g)].map(m => m[1])[0];await face('inboxMark', 'inboxMark', [note, true]);await face('inboxMark-bad', 'inboxMark', [note, 'yes']);await save('w-page-conn', 'GET', '/inbox/' + conn, { cookie: COOKIE });await face('inboxSwitch', 'inboxSwitch', [conn, 'comment', 'push', false]);await face('inboxSwitch-unreg', 'inboxSwitch', [conn, 'other', 'push', true]);await face('inboxSwitch-email', 'inboxSwitch', [conn, 'other', 'email', false]);await face('inboxOverride', 'inboxOverride', [conn, 'active', true]);await face('inboxOverride-push', 'inboxOverride', [conn, 'push', true]);await post('w-notify-after', '/api/notify', { key: K, secret, identity: SHORT, name: 'comment', text: 'after the switches', urgent: true });await post('w-kinds-again', '/api/kinds', { key: K, secret, kinds: [{ name: 'other', push: true, email: true }] });await save('w-page-conn-after', 'GET', '/inbox/' + conn, { cookie: COOKIE });await save('w-page-inbox-after', 'GET', '/inbox', { cookie: COOKIE });// invitesr = await post('w-invite', '/api/invites', { key: K, secret, project: 'proj', role: 'member', return: ORIGIN + '/joined', uses: 2, days: 3 });const inv = val(r);await post('w-invite-mail', '/api/invites', { key: K, secret, project: 'proj', role: 'viewer', return: ORIGIN + '/joined', email: '[email protected]' });await post('w-invite-bad', '/api/invites', { key: K, secret, project: 'proj', role: 'member', return: 'https://evil.example/' });await post('w-invites-list', '/api/invites/list', { key: K, secret });r = await save('w-invite-open', 'GET', '/invite/' + inv.url.split('/').pop());rid = r.headers.location.split('/').pop();await save('w-invite-signin', 'GET', '/signin/' + rid, { cookie: COOKIE });await face('chooseIdentity-invite', 'chooseIdentity', [rid, IDENTITY]);await post('w-invite-get', '/api/invites/get', { key: K, secret, id: inv.id });await post('w-invite-revoke', '/api/invites/revoke', { key: K, secret, id: inv.id });await post('w-invite-revoke-again', '/api/invites/revoke', { key: K, secret, id: inv.id });await save('w-invite-open-revoked', 'GET', '/invite/' + inv.url.split('/').pop());await post('w-migrate-ids', '/api/migrate-ids', { key: K, secret });await post('w-migrate-ids-finish', '/api/migrate-ids', { key: K, secret, finish: true });// the OTP login: a new address (creates the account), then the creator's ownfor (const [tag, email] of [['new', '[email protected]'], ['creator', EMAIL]]) {await post('w-code-' + tag, '/api/code', { email: email.toUpperCase() });f = await face('rememberPending-' + tag, 'rememberPending', [email], null);const ck = f.cookie;await save('w-codepage-' + tag, 'GET', '/code', { cookie: ck });await save('w-home-pending-' + tag, 'GET', '/', { cookie: ck });await face('verifyCode-wrong-' + tag, 'verifyCode', [email, '000000', 'Pacific/Auckland'], ck);await face('verifyCode-' + tag, 'verifyCode', [email, lastCode(email), 'Pacific/Auckland'], ck);await save('w-home-signedin-' + tag, 'GET', '/', { cookie: ck });await save('w-codepage-after-' + tag, 'GET', '/code', { cookie: ck });await face('rememberPending-none-' + tag, 'rememberPending', [email], ck);await face('signOut-' + tag, 'signOut', [], ck);await save('w-home-signedout-' + tag, 'GET', '/', { cookie: ck });}// an app login with the code step on /signin/<rid>/code, and "other address"r = await save('w-login2', 'GET', '/login?key=' + K + '&return=' + encodeURIComponent(ORIGIN + '/b'));rid = r.headers.location.split('/').pop();await post('w-code-app', '/api/code', { email: '[email protected]' });f = await face('rememberPending-app', 'rememberPending', ['[email protected]'], null);await save('w-signin-codepage', 'GET', '/signin/' + rid + '/code', { cookie: f.cookie });await face('forgetPending', 'forgetPending', [], f.cookie);await save('w-signin-codepage-forgot', 'GET', '/signin/' + rid + '/code', { cookie: f.cookie });for (let i = 0; i < 3; i++) await post('w-code-limit-' + i, '/api/code', { email: '[email protected]' });await post('w-code-ip', '/api/code', { email: '[email protected]' }, { 'x-client-ip': '2a01:4f9:3080:1126::1' });// sign out everywhere (the new account, signed in again), then delete the appawait post('w-code-new2', '/api/code', { email: '[email protected]' });f = await face('verifyCode-new2', 'verifyCode', ['[email protected]', lastCode('[email protected]'), 'UTC'], null);await face('signOutAll', 'signOutAll', [], f.cookie);await save('w-home-after-all', 'GET', '/', { cookie: f.cookie });await face('inboxDisconnect', 'inboxDisconnect', [conn]);await face('appDelete', 'appDelete', [app.id]);await save('w-page-apps-after', 'GET', '/apps', { cookie: COOKIE });await save('w-page-inbox-end', 'GET', '/inbox', { cookie: COOKIE });await save('w-page-home-end', 'GET', '/', { cookie: COOKIE });file('w-mail-sink', readFileSync(SINK, 'utf8').replace(/ \d{6}$/gm, ' CODE'));file('w-mail-invites', existsSync(SINK + '.invites') ? readFileSync(SINK + '.invites', 'utf8') : '');} finally {srv.kill('SIGTERM'); await sleep(500); try { srv.kill('SIGKILL'); } catch {}writeFileSync(join(OUT, '0000-server-log'), log);}// the stored state after the writesconst after = dumpOf(join(RUN, 'mpackdb'));const sortRows = (rows) => rows.map(r => J(Object.fromEntries(Object.entries(r).sort()))).sort();file('w-store', Object.keys(after).sort().map(t => '## ' + t + '\n' + sortRows(after[t]).join('\n')).join('\n'));rmSync(RUN, { recursive: true, force: true });console.log(`${n} files in ${OUT}`);
Branches
- mainmain branch
Latest commits
- 5fdbb6b2ident mission 009: report — scratch folder notemre
- 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre