// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares // a thread with a user, the business human or everyone; #readable lets a sharee read it while the // share is in force, the sharee's grant still covers it and a user owner's grant does too. Every // write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here. import test from 'node:test'; import assert from 'node:assert/strict'; import { DatabaseSync } from 'node:sqlite'; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { Store, schemaDigest } from '../src/store.mjs'; import { Broker } from '../src/broker.mjs'; const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const here = (p) => new URL(p, import.meta.url); const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over, }); // As user-principal.test.mjs: the access document is a function each test edits between requests. function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false }; const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } }, }, }, access: () => { if (access.fail) throw new Error('access file refused'); return access.doc; }, }); const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }), }; const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); const code = (who, verb, args = {}) => { try { call(who, verb, args); } catch (e) { return e.code; } return null; }; const p1 = call('human', 'project.create', { name: 'shared' }).id; const p2 = call('human', 'project.create', { name: 'private' }).id; // alice and bob hold collaborator grants on p1, carol on p2 only. const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })]; access.doc.grants = base(); for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name }); const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id; const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id; const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id; call('human', 'thread.send', { thread: H, body: 'jason: hello' }); const rows = () => store.get('SELECT count(*) n FROM thread_shares').n; const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body); return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read }; } test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => { const { call, code, A, p1, rows, read } = setup(t); assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('bob', 'thread.list', { shared: true }), []); assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' }); assert.deepEqual(read('bob', A), ['alice: hello']); const [listed] = call('bob', 'thread.list', { shared: true }); assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']); assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' }); // The plain list keeps its shape and holds only bob's own threads. assert.deepEqual(call('bob', 'thread.list'), []); assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']); // Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here. for (const [verb, args] of [ ['thread.send', { body: 'bob was here' }], ['thread.open', {}], ['thread.share', { to: '*' }], ['thread.share', { to: 'jason' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}], ]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb); assert.equal(rows(), 1); assert.deepEqual(read('alice', A), ['alice: hello']); // The share is bob's alone: the owner and carol still can't read. assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('human', 'thread.list', { shared: true }), []); assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned'); }); test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => { const { call, code, A, H, rows, read } = setup(t); assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' }); assert.deepEqual(read('alice', H), ['jason: hello']); assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned'); assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' }); assert.deepEqual(read('bob', A), ['alice: hello']); assert.deepEqual(read('human', A), ['alice: hello']); assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]); // Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester. assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('carol', 'thread.list', { shared: true }), []); assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('tester', 'thread.list', { shared: true }), []); assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]); assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]); assert.equal(rows(), 2); }); test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => { const { access, call, code, base, A, p2, read } = setup(t); call('alice', 'thread.share', { thread: A, to: 'user:bob' }); assert.deepEqual(read('bob', A), ['alice: hello']); // bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only. access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g)); assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('bob', 'thread.list', { shared: true }), []); // A grant on another instance is the same. access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g)); assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned'); access.doc.grants = base(); assert.deepEqual(read('bob', A), ['alice: hello']); // Removed, expired or an unreadable file: the gate refuses bob's next request. for (const [what, change] of [ ['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }], ['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }], ['unreadable', () => { access.fail = true; }], ]) { change(); assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what); access.doc.grants = base(); access.fail = false; assert.deepEqual(read('bob', A), ['alice: hello'], what); } }); test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => { const { access, call, code, base, A, sent, read } = setup(t); call('alice', 'thread.share', { thread: A, to: 'jason' }); assert.deepEqual(read('human', A), ['alice: hello']); assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent)); assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent)); for (const [what, change] of [ ['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }], ['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }], ['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }], ['unreadable', () => { access.fail = true; }], ]) { change(); assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what); assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what); for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`); access.doc.grants = base(); access.fail = false; assert.deepEqual(read('human', A), ['alice: hello'], what); } // A viewer grant still covers: rule 3 asks for any level. access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g)); assert.deepEqual(read('human', A), ['alice: hello']); }); test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => { const { store, call, code, A, rows, read } = setup(t); call('alice', 'thread.share', { thread: A, to: 'user:bob' }); assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists'); assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' }); assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.deepEqual(call('bob', 'thread.list', { shared: true }), []); assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []); assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found'); assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found'); call('alice', 'thread.share', { thread: A, to: 'user:bob' }); assert.deepEqual(read('bob', A), ['alice: hello']); assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']); assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [ { op: 'share', target: 'user:bob', by: 'user:alice' }, { op: 'unshare', target: 'user:bob', by: 'user:alice' }, { op: 'share', target: 'user:bob', by: 'user:alice' }, ]); // An unshare of `*` leaves bob's own share in force. call('alice', 'thread.share', { thread: A, to: '*' }); call('alice', 'thread.unshare', { thread: A, to: '*' }); assert.deepEqual(read('bob', A), ['alice: hello']); assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.equal(rows(), 5); }); test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => { const { b, caps, access, call, code, base, A, p1, rows, read } = setup(t); // carol's grant covers p2 only; dave has none; erin's is expired. access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })]; for (const to of ['user:carol', 'user:dave', 'user:erin']) assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to); assert.equal(rows(), 0); // A viewer grant is enough to be shared with, and the viewer reads the share (row 275). access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })]; assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' }); caps.vic = b.bindUser({ business: 'demo', user: 'vic' }); assert.deepEqual(read('vic', A), ['alice: hello']); // An unshare checks no coverage: an uncovered target's share can still be ended. access.doc.grants = base(); assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' }); assert.equal(rows(), 2); }); test('a viewer reads a thread shared with it, and share, unshare and shares refuse a viewer access-refused (design 4.3)', (t) => { const { access, call, code, base, A, rows, read } = setup(t); call('alice', 'thread.share', { thread: A, to: 'user:bob' }); // bob drops to viewer and still reads the share. access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, level: 'viewer' } : g)); assert.deepEqual(read('bob', A), ['alice: hello']); assert.deepEqual(call('bob', 'thread.list', { shared: true }).map((x) => x.id), [A]); // alice drops to viewer too: she reads her own thread, and each share verb on it refuses. access.doc.grants = base().map((g) => ({ ...g, level: 'viewer' })); assert.deepEqual(read('alice', A), ['alice: hello']); for (const [verb, args] of [['thread.share', { to: '*' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}]]) assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb); assert.equal(rows(), 1); // Rule 3 asks any level, so alice's viewer grant keeps the thread readable to bob. assert.deepEqual(read('bob', A), ['alice: hello']); }); test('a share target is `user:`, the business human or `*`, and never the sharer', (t) => { const { call, code, A, H, rows } = setup(t); for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']]) assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to)); for (const to of ['jason', 'user:x y']) assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to); assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request'); assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request'); assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request'); assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found'); assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request'); assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request'); assert.equal(rows(), 0); // The owner shares its own threads only; a user's thread refuses the business human too. assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned'); assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned'); assert.equal(rows(), 0); assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share'); }); test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => { const { access, call, code, base, A, p2, rows } = setup(t); call('alice', 'thread.share', { thread: A, to: 'user:bob' }); access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g)); for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]]) assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb); assert.equal(rows(), 1); }); test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => { const { store, call, code, A, H, rows } = setup(t); const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n; const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id; const was = refusals(); for (const verb of ['thread.share', 'thread.unshare', 'thread.shares']) for (const thread of [T, H, A]) { assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`); assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`); } // Each refusal is recorded, as every refused request is. assert.equal(refusals() - was, 18); assert.equal(rows(), 0); // A share with `*` of the owner's thread doesn't reach the tester's reads. call('human', 'thread.share', { thread: H, to: '*' }); assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned'); }); test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => { const { store, call, code, A, sent } = setup(t); // The run's reply to alice, as R3's `to: 'user'` path writes it. const reply = randomUUID(); store.run( 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)', reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice', ); // A new thread's default session has its own id (only a migrated thread's reuses the thread's). const { session } = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', A); store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, session); for (const who of ['human', 'reader']) { assert.deepEqual(call(who, 'trail', { subject: sent }), [], who); assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who); } call('alice', 'thread.share', { thread: A, to: 'jason' }); for (const who of ['human', 'reader']) { assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who); assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who); } // The tester never reads another's thread (rule 4). assert.deepEqual(call('tester', 'trail', { subject: sent }), []); assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found'); call('alice', 'thread.unshare', { thread: A, to: 'jason' }); for (const who of ['human', 'reader']) { assert.deepEqual(call(who, 'trail', { subject: sent }), [], who); assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who); } }); test('a share covers every session of the thread', (t) => { const { store, call, code, H, read } = setup(t); call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' }); const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id; call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' }); call('human', 'thread.share', { thread: H, to: 'user:alice' }); assert.deepEqual(read('alice', H), ['jason: hello']); assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']); assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found'); assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned'); }); test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => { const { call, code, A, H } = setup(t); call('alice', 'thread.share', { thread: A, to: 'jason' }); call('human', 'thread.share', { thread: H, to: '*' }); assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned'); assert.equal(code('reader', 'session.list', { thread: A }), 'read-only'); for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who); // The owners still list their own. assert.equal(call('human', 'session.list', { thread: H }).length, 1); }); test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => { const { b, caps, call, code, A, H, sent } = setup(t); // The assistant run serving alice's thread, and a chief run with no thread. caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A }); caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); for (const who of ['agent', 'chief']) call(who, 'role.claim'); const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent); for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`); call('alice', 'thread.share', { thread: A, to: '*' }); call('alice', 'thread.share', { thread: A, to: 'jason' }); // The human principals now read it; the agents and the tester see what they saw before. for (const who of ['human', 'reader']) assert.equal(seen(who), true, who); for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`); for (const who of ['agent', 'chief']) { assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who); assert.equal(code(who, 'replies', { id: sent }), 'human-required', who); } assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned'); assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found'); call('human', 'thread.share', { thread: H, to: '*' }); assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned'); }); // The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table. const AT = '2026-10-22T00:00:00.000Z'; const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8'); function source3l(t) { const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path); assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a'); db.exec(ddl3l); const run = (sql, ...args) => db.prepare(sql).run(...args); run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l'); run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db)); run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT); run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason'); for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) { run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id); run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner); run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id); } run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1'); run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2'); run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice'); db.close(); chmodSync(path, 0o600); return { root, path }; } const open = (path, fn) => { const db = new DatabaseSync(path, { readOnly: true }); try { return fn(db); } finally { db.close(); } }; const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }))); // Every table's rows but meta's, whose version and digest the step changes. const tables = (path) => open(path, (db) => Object.fromEntries( db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]), )); const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value); test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => { const { root, path } = source3l(t); const before = tables(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); assert.equal(version(path), '3v'); const after = tables(path); assert.deepEqual(after.thread_shares, []); delete after.thread_shares; // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) { assert.deepEqual(after[name], [], name); delete after[name]; } assert.deepEqual(after, before); // The migrated schema is the fresh one, object for object. const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-')); t.after(() => rmSync(freshRoot, { recursive: true, force: true })); new Store(freshRoot).close(); assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite'))); // No existing thread is shared: each stays private to its owner. const store = new Store(root); t.after(() => store.close()); store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice'); for (const [sql, args] of [ ["UPDATE thread_shares SET target='*'", []], ['DELETE FROM thread_shares', []], ['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']], ]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql); assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/); assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/); assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]); }); // The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step. async function engine3l(root) { const moduleRoot = join(root, 'engine-3l'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true }); const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`) .replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';") .replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l); for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql']) copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file)); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => { const { root, path } = source3l(t); new Store(root, { migrateSchema: true }).close(); const was = master(path), before = tables(path); const Store3l = await engine3l(root); assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/); assert.equal(version(path), '3v'); assert.deepEqual(master(path), was); assert.deepEqual(tables(path), before); // The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy. const { root: old } = source3l(t); new Store3l(old).close(); });