row 272: preserve granted-user default chat on denied session catalog

This commit is contained in:
jason.woltje committed 2026-10-23 05:56:16 -05:00
1 parent 352aa5dc07
commit b44332a936
10 files changed
+341 -7

No files matched your search

+2
View File
@@ -5395,3 +5395,5 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23 | Huey | Row 272 sage-3145 preservation correction. refs/stash is shared, so it was unsafe as sole draft storage. Pinned exact stash commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45 to local huey/row272-stash-keep and left the entry untouched. Sage explicitly directed committing the rebased draft before testing to preserve it: an untested local checkpoint, no acceptance claim. No Node/test/queue/network/live action. Separate claims and test CLEARs remain pending; old-test delta is with Mos via sage-3144.
2026-10-23 | Huey | Row 272 early static review follow-up, scrooge-272-early-read-huey-1. Before: a bad-session 404 erased sibling draft/recovery caches, malformed clear/cancel write receipts omitted the audit line, and another session's op key produced an ambiguous error. Draft after: purge only on named thread-level refusals, write one redacted malformed-receipt audit line, and identify key reuse without a write. New browser/HTTP/CLI tests authored, unrun under test hold; approved old-test hunks unchanged. Published 850b22680/round-1 manifest unchanged; follow-up is uncommitted, no acceptance claim.
2026-10-23 | Huey | Row 272 round 2 on row 219 landing 5fdfd7275, after 295. Replayed round 1 and B1/N1/N2; seven conflicts resolved retaining both log histories, both test sets, 219 instance chooser and 295 owner-settings-only transport. Static comparison confirms old tests differ only by mos-1388 approved hunks. Sage3357/Mos1442 authorize bounded granted-user legacy fallback: only session.list access-refused and omitted-session route; no new session grants or owner routing. RED first under3358 on new file only: 1 expected legacy assertion failure, 3 guards pass, 3.504s, TMPDIR mktemp/no underscore, no compose network; slot returned as huey0035. Then implemented fallback and added timeout/bus/malformed guards plus isolated-copy mutant runner, all unrun afterward. Candidate commit/push precedes Sage's full gate by3354; no green claim.
+86 -1
View File
@@ -1,6 +1,6 @@
# Row 272 / 264e (#1777): session CLI and Console chooser
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base b23783f39, branch huey/row272-b23783f39 (sage-3141). Original draft base 2d0d407ba on huey/row272.
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base 5fdfd7275, branch huey/row272-5fdfd7275 (sage-3354). Original draft base 2d0d407ba; round 1 was 850b22680 on b23783f39.
Brief: docs/plans/2026-10-21_t1-sessions.md (ff0be0c79), section 8; row 264 rulings
at 5f5b51dd6. Dependencies: 264a-d landed; 270/271 landing is 7328da8ca.
Count/clear implementation and contracts are now in the current base. No runtime, launcher, library adapter,
@@ -183,3 +183,88 @@ Execution watch: inspect message-filter.test first if the bus suite reports a B6
fixture red (manual message_threads rows must name the run's bound session); do not
change an old fixture/assertion without Mos's ruling via Sage. thread-shares uses the
persisted session_defaults mapping per Scrooge's check. No runtime verdict claimed.
## Granted-user legacy compatibility (sage-3357; pre-candidate design)
219/295 are retained on base 5fdfd7275. USER_VERBS still excludes session verbs;
193/T5 user session grants are a separate DEFERRED item, outside 272. No owner
transport carries a user session operation. Bounded negotiation applies only to the
session.list read: only `access-refused` and an omitted-session route permit legacy
thread-default read/send/open. All other errors keep the unavailable/error state, and
explicit session links always refuse. On fallback session controls are hidden and one
status line says sessions are not available for this account. No fake session ID,
count or lifecycle status is invented; the broker continues choosing its persisted
default under current grants on every existing thread request. Owner routes keep the
normal chooser and explicit selected-session arguments.
New file chat-sessions-access.test.mjs uses the real broker with an already granted
principal over loopback and the existing repository browser fixture. It checks legacy
read/send/open with no session controls, explicit-link refusal, another-error refusal,
and unchanged owner chooser. No shared stub or existing assertion changes. The three
required mutants remove the access-refused check, remove the omitted-session check,
and display the controls during fallback; guard tests must kill each by assertion.
RED-first slot sage-3358: only this new file, before fallback implementation. Expected
run 100 s including four Chromium fixtures; bound 300 s (3x), command
`timeout -k 10 300 node --test packages/webui/tests/chat-sessions-access.test.mjs`.
TMPDIR must be assigned from mktemp -d, with no underscore. No compose service is used;
check/remove the generated directory's compose default network if any exists. Expected
legacy-positive red is placed/reported afterward under mos-1272. Any other red is
reported to Sage before a fix. No other test file/suite is authorized in this slot.
sage-3358 RED run completed before the fallback implementation. Only the new
chat-sessions-access.test.mjs ran, under the 300 s bound, TMPDIR allocated by
`mktemp -d /tmp/huey272-red-XXXXXXXX` as /tmp/huey272-red-0q66Jcf4. Exit 1;
4 tests, expected legacy-positive assertion red and 3 passes, no other reds/skips/
cancellations/timeouts, 3.504 s. Exact output: r2-red-access.tap. No matching compose
network existed. Slot returned to Sage/Mos as huey-0035; no further tests run.
mos-1442 / sage-3361 adds explicit timeout, bus-error and malformed-answer refusal
coverage. The new private fixture adds those guard cases (unrun), with a widening-key
mutant alongside the access-refused/omitted-selector/control mutants. No shared stub
or existing assertion changed. Native preview status/open both reported unavailable
in this headless environment before the authorized repository-browser regression.
Implementation now local: the catalog request catches only access-refused on omitted
session routes. Its internal null result selects the existing broker-chosen thread
default with no fabricated session ID. Read/send/open omit the session property in
that mode. One status line replaces all session controls. Explicit links and any
other failure never select that mode. USER_VERBS and all owner transport routing
remain byte-unchanged. Guard cases are preserved on the pre-fallback baseline; their
RED evidence comes from the required mutants, as Sage accepted in sage-3358.
Fallback mutants are executable via fallback-mutants-r2.py, only on Sage's slot.
It copies packages/roles into a private mktemp directory and mutates only the copied
chat.js, never candidate bytes. Each run has a 300 s bound and requires a named
assertion failure; timeout/import/setup failures do not count. F1 removes the exact
access-refused key (human-required guard); F2 removes the omitted-selector check;
F3 shows session controls; F4 widens the key to timeout/unreachable, no-bus-host and
invalid-response (timeout guard). The test file also independently checks bus-error
and malformed-answer refusal. These mutants and the post-implementation tests have
not run locally. The earlier six non-fallback mutants in this packet remain required
in the full gate; the new runner covers the four fallback mutants only.
## Round 2 rebase receipt (sage-3354)
Queue rev 3510 returned row 272 to in-progress. On new branch huey/row272-5fdfd7275,
replayed round 1 and the preserved B1/N1/N2 fixes onto 5fdfd7275 (219, after 295).
Resolved exactly the seven named conflicts. Test append conflicts retain both complete
sets of tests; existing test content equals the new base plus only the mos-1388 hunks.
No old assertion or shared stub was changed beyond those approved hunks. Static byte
comparison confirms the entire upstream test file prefixes and approved modifications.
Both append-only logs retain the complete 5fdfd7275 prefix plus Huey's entries.
219's per-instance thread picker, explicit createThread instance forwarding, choice
refresh/revocation behavior and tests remain present. 295's createChat owner-settings
read stays unchanged: only plugins uses the owner transport; user chat verbs stay on
the user transport. Removed a duplicate access-refused status entry introduced by
combining those patches. USER_VERBS is unchanged. No schema or migration changed.
This is a pre-gate candidate per Sage's explicit instruction to commit/push/request
round 2 before Sage runs the full gate. Only the expected-red new fallback test ran
under sage-3358. No green test claim, no suite/mutant result and no old-file run is
implied. Every touched test file still needs its whole-file run on Sage's slot, plus
all touched suites and mutants. Any first-run red in launcher/creative/chat-browser
returns to Mos via Sage before fixing. TMPDIR for gate runs comes only from mktemp -d,
with no underscore; remove a matching compose default network only if created.
+1
View File
@@ -0,0 +1 @@
5fdfd7275ce3048e488d93dcdb1f9f2aa5961a69
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Run only on Sage's slot. Mutate disposable sources, never the candidate tree."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
GUARD = "if (err.code === 'access-refused' && !match[3]) return null;"
CONTROLS = " if (current.legacyDefault) return '<p role=\"status\">Sessions are not available for this account.</p>';"
MUTANTS = {
'F1-any-code': (GUARD, "if (!match[3]) return null;", 'human-required catalog failure'),
'F2-explicit-link': (GUARD, "if (err.code === 'access-refused') return null;", 'granted explicit-session route'),
'F3-controls': (CONTROLS, ' // mutant: show session controls on a legacy fallback', 'granted legacy route'),
'F4-widened-key': (GUARD, "if (['access-refused', 'unreachable', 'no-bus-host', 'invalid-response'].includes(err.code) && !match[3]) return null;", 'timeout catalog failure'),
}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--out', type=Path, required=True)
ap.add_argument('--mutant', choices=list(MUTANTS), action='append')
args = ap.parse_args()
source = Path(__file__).resolve().parents[4]
args.out.mkdir(parents=True, exist_ok=True)
for name in args.mutant or MUTANTS:
# Explicit mktemp, no underscores: same TMPDIR rule as the real gate.
scratch = Path(subprocess.check_output(['mktemp', '-d', '/tmp/huey272-fallback-mutant-XXXXXXXX'], text=True).strip())
try:
for directory in ['packages', 'roles']:
shutil.copytree(source / directory, scratch / directory)
if (source / 'node_modules').exists():
(scratch / 'node_modules').symlink_to((source / 'node_modules').resolve(), target_is_directory=True)
for filename in ['package.json', 'RELEASE']:
if (source / filename).exists():
shutil.copy2(source / filename, scratch / filename)
path = scratch / 'packages/webui/src/public/chat.js'
old, new, target = MUTANTS[name]
text = path.read_text()
if text.count(old) != 1:
raise RuntimeError(f'{name}: mutation anchor is not unique')
path.write_text(text.replace(old, new, 1))
env = dict(os.environ, TMPDIR=str(scratch))
result = subprocess.run(['timeout', '-k', '10', '300', 'node', '--test', '--test-reporter=tap',
'packages/webui/tests/chat-sessions-access.test.mjs'],
cwd=scratch, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
(args.out / f'{name}.tap').write_text(result.stdout)
killed = result.returncode == 1 and 'ERR_ASSERTION' in result.stdout and bool(
re.search(r'not ok [^\n]*' + re.escape(target), result.stdout))
print(json.dumps({'mutant': name, 'returncode': result.returncode, 'assertion_kill': killed}), flush=True)
if not killed:
raise RuntimeError(f'{name}: not an assertion kill; stop, diagnose and report before any fix')
finally:
# These tests never use compose. Remove the disposable copy after browser teardown;
# retained TAP lives only in --out. No live network/volume/service cleanup is performed.
shutil.rmtree(scratch)
if __name__ == '__main__':
main()
@@ -0,0 +1,17 @@
c21f1bd9af6841126baf19f5c9926ca23ba8e56b9af342373d8e4fefcee63632 packages/bus/src/broker.mjs
3957243d59e50eaa2326b88b9205ed8e8b5d33bde2ce1ef62e68f20e56283568 packages/bus/tests/user-principal.test.mjs
0a920131a981c2a1c9b73428a998966f446babae4dfe7d10eebdd6455571c0c9 packages/cli/README.md
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
1a30048032f5bb64419ae9716a1692978bc1a137220a094d1900298aca6ba1d7 packages/cli/src/session.mjs
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
359c9fbfe30d860046ee3d29d23681900d9c73b3a4c3a91cd2f4e1d2c6be143e packages/cli/tests/session-cli.test.mjs
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
6e743878e507be20d87ae10afdb01c105f6f4fab76d02410e065f3f20d18e597 packages/webui/src/chat-http.mjs
7a5a18a9879fe7132f20a651c6927b007475571bd8fc45314eda9d6809288cea packages/webui/src/chat.mjs
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
e4c25743a6d8cbe15eaac2b4d8c3bd309ffa5793f35584d862690a9d89725b72 packages/webui/src/public/chat.js
1aa313f1602cf87a0ddac145bf58bbd7f1073c02009c65797e85418fdfdbd838 packages/webui/tests/chat-browser.test.mjs
23de8678566f24a6c8bf0ca75b4550969015b06253586070e229f4eaf0bba2b4 packages/webui/tests/chat-sessions-access.test.mjs
773538c0c6a26ab525aa30e588249ba4b4ee981129533db0348f2107da5dd1df packages/webui/tests/chat.test.mjs
@@ -0,0 +1,32 @@
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
✔ 272 fallback guard: granted explicit-session route refuses without default history or writes (780.341865ms)
✔ 272 fallback guard: another catalog error never enables default-thread access (765.314522ms)
✔ 272 fallback guard: owner route retains chooser and explicit default-session addressing (901.74851ms)
ℹ tests 4
ℹ suites 0
ℹ pass 3
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3503.583341
✖ failing tests:
test at packages/webui/tests/chat-sessions-access.test.mjs:74:1
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
AssertionError [ERR_ASSERTION]: legacy granted-user thread must remain usable after session.list access-refused
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/huey-system-manager-design/packages/webui/tests/chat-sessions-access.test.mjs:77:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
+1
View File
@@ -1579,3 +1579,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3141 local rebase | Draft reapplied from 2d0d407ba onto b23783f39 on huey/row272-b23783f39; product merge clean, append-only log conflicts retained both sides. Landed 270/271 contracts checked; no Node/tests/claim/commit/push pending CLEAR.
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3145 draft preservation | Shared stash commit 07dc9d2f7 pinned to huey/row272-stash-keep; rebased draft committed locally by explicit Sage instruction before tests. Untested checkpoint only; claims/test/push CLEARs still pending.
2026-10-23 | Huey (cabd45ca) | Row 272 Scrooge early static read on 850b22680 | B1 and N1/N2 follow-up drafted with new tests only; old assertions/shared stub unchanged. Static diff clean; no tests/commit/push pending CLEAR, round 1 remains frozen.
2026-10-23 | Huey (cabd45ca) | Row 272 round 2, sage-3354/3357/3358/3361 | Rebased onto 5fdfd7275 on huey/row272-5fdfd7275, preserving 219/295 and approved old-test hunks; rev3510 in-progress. New fallback test ran RED first in Sage's slot: expected legacy failure, 3 guards pass; slot returned. Bounded fallback and extra guards/mutants prepared for Sage's full gate; no post-implementation run or green claim.
+1 -1
View File
@@ -19,7 +19,7 @@ const STATUS = {
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
'unknown-verb': 503, 'channel-operation': 503, 'access-refused': 403,
'unknown-verb': 503, 'channel-operation': 503,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
// FED R6 (#1800): the broker's refusals of a granted user.
'access-refused': 403, 'access-revoked': 401,
+19 -5
View File
@@ -74,6 +74,7 @@
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
`unknown (${value.reason ?? 'not-collected'})`;
function sessionControls(t) {
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
const clear = form('clear:' + c.key);
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
@@ -98,7 +99,7 @@
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.kind === 'thread') {
const t = c.threads.find(t => t.id === c.id);
if (t && c.session && !c.refused) html += conversation(t);
if (t && (c.session || c.legacyDefault) && !c.refused) html += conversation(t);
else html += heading('Thread unavailable');
} else if (c.kind === 'project') {
const p = c.projects.find(p => p.id === c.id);
@@ -143,7 +144,7 @@
paint(); return;
}
const old = current;
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, sessions: old?.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old?.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old?.id === id ? old.key : id };
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old?.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old?.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old?.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
// Resolve the explicit session before touching history/cursors. Missing selectors never
@@ -151,11 +152,24 @@
const [projects, listed, catalog] = await Promise.all([
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal) : null,
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal).catch(err => {
// A denied optional catalog does not revoke an existing default-thread grant.
// Explicit selectors and every other failure keep their refusal/error state.
if (err.code === 'access-refused' && !match[3]) return null;
throw err;
}) : null,
]);
if (g !== generation || out()) return;
current.projects = projects.rows; current.threads = listed.rows;
let s = null, page = null, after = null;
if (kind === 'thread' && catalog === null) {
current.legacyDefault = true; current.sessions = []; current.session = null;
current.key = id + '/legacy-default';
s = state(current.key);
after = s.loaded && (!s.more || next) ? s.after : null;
page = await request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
if (g !== generation || out()) return;
}
if (catalog) {
if (catalog.rows.some(r => !UUID.test(r.id) || typeof r.name !== 'string')) throw new ChatError('invalid-response');
const selected = match[3] ? catalog.rows.find(r => r.id === match[3]) : catalog.rows.find(r => r.default === true);
@@ -302,7 +316,7 @@
event.preventDefault();
if (event.target.id === 'chat-send-form') {
const id = current.id, session = current.session, key = current.key;
void write('send', key, { body: state(key).draft, session }, `threads/${id}/messages`);
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
} else if (event.target.id === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
@@ -322,7 +336,7 @@
});
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.id === 'chat-resume') void write('open', current.key, { session: current.session }, `threads/${current.id}/open`);
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
if (event.target.id === 'chat-next') void refresh(true);
@@ -0,0 +1,120 @@
// Row 272, sage-3357: UI session negotiation over the real broker's account boundary.
// The loopback fixture binds one already granted principal; login/CSRF are covered by
// granted-user.test.mjs. No real account, provider, config or owner transport is used.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { browser } from './browser.mjs';
async function setup(t, { owner = false, catalogError = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'huey-session-access-'));
const store = new Store(root), calls = [];
const access = { accessVersion:1, revision:1, users:[], grants:[{id:'g-1',user:'alice',business:'demo',projects:['*'],instances:['assistant'],level:'collaborator'}] };
const broker = new Broker({store, access:()=>access, businesses:{demo:{
id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},
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:JSON.parse(readFileSync(new URL('../../../roles/assistant.json',import.meta.url),'utf8')).messaging}},
}}});
const human = broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const cap = owner ? human : broker.bindUser({business:'demo',user:'alice'});
const invoke = (verb,args={}) => broker.request(cap,{verb,args});
const project = broker.request(human,{verb:'project.create',args:{name:'shared'}}).id;
const thread = invoke('thread.create',{project,name:'account thread',instance:'assistant'}).id;
invoke('thread.send',{thread,body:'existing account history'});
const session = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1',thread).session;
let b;
const server = createServer(async (req,res) => {
const url = new URL(req.url,'http://fixture');
const json = (status,value) => {res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
if(url.pathname === '/') {res.writeHead(200,{'content-type':'text/html'});res.end('<!doctype html><title>Session access fixture</title><div id="view"></div><script src="/chat.js"></script>');return;}
if(url.pathname === '/chat.js') {res.writeHead(200,{'content-type':'text/javascript'});res.end(readFileSync(new URL('../src/public/chat.js',import.meta.url)));return;}
try {
let verb,args={};
const match=/^\/api\/chat\/threads\/([^/]+)\/(sessions|messages|open)$/.exec(url.pathname);
if(url.pathname === '/api/chat/projects') verb='project.list';
else if(url.pathname === '/api/chat/threads') verb='thread.list';
else if(match) {
args.thread=match[1];
if(match[2] === 'sessions') verb='session.list';
else if(match[2] === 'open') verb='thread.open';
else verb=req.method === 'POST' ? 'thread.send' : 'thread.messages';
if(req.method === 'POST') {
let body='';for await(const part of req)body+=part;
Object.assign(args,JSON.parse(body));
if(verb === 'thread.send')args.class='REQUEST';
} else if(verb === 'thread.messages') {
args.limit=Number(url.searchParams.get('limit')??100);
for(const key of ['session','after'])if(url.searchParams.has(key))args[key]=url.searchParams.get(key);
}
} else return json(404,{error:'not-found'});
calls.push({verb,args:structuredClone(args),owner});
if(verb === 'session.list' && catalogError === 'malformed')return json(200,{rows:{invalid:true}});
if(verb === 'session.list' && catalogError)throw Object.assign(new Error(catalogError),{code:catalogError});
const value=invoke(verb,args);
if(req.method === 'POST')return json(200,value);
return json(200,{rows:value,after:verb==='thread.messages'?value.at(-1)?.id??null:null,hasMore:false});
} catch(e) {json(['access-refused','human-required'].includes(e.code)?403:503,{error:e.code??'invalid-response',sent:false});}
});
t.after(async()=>{await b?.close();await new Promise(r=>server.close(r));store.close();rmSync(root,{recursive:true,force:true});});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
b=await browser();
if(catalogError === 'timeout')await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`{const actual=fetch.bind(window);window.fetch=(url,opts)=>String(url).endsWith('/sessions')?Promise.reject(new DOMException('fixture transport timeout','TimeoutError')):actual(url,opts);}`});
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
const render = async explicit => {
const path=`/chat/threads/${thread}${explicit?`/sessions/${session}`:''}`;
await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#'+path)};await window.MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,navigated:true});return true;})()`);
};
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`);
return {b,calls,render,wait,thread,session,store};
}
test('272 fallback: granted legacy route keeps default history, send and open without session controls', async t => {
const {b,calls,render,wait,thread,store}=await setup(t);
await render(false);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),true,'legacy granted-user thread must remain usable after session.list access-refused');
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
assert.match(await b.evaluate('document.getElementById("view").textContent'),/sessions.*not.*available.*account/i);
await b.evaluate('(()=>{const input=document.getElementById("chat-body");input.value="new granted message";input.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-send-form button").click();return true;})()');
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("Accepted")'),true);
assert.equal(store.get("SELECT count(*) n FROM messages WHERE body='new granted message'").n,1);
const send=calls.find(c=>c.verb==='thread.send');
assert.equal(send.args.thread,thread);assert.equal(Object.hasOwn(send.args,'session'),false);
await b.evaluate('document.getElementById("chat-resume").click(),true');
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("resume requested")'),true);
assert.deepEqual(calls.find(c=>c.verb==='thread.open').args,{thread});
assert.ok(calls.some(c=>c.verb==='session.list'));
assert.ok(calls.filter(c=>c.verb==='thread.messages').every(c=>!Object.hasOwn(c.args,'session')));
assert.ok(calls.every(c=>c.owner===false),'no request may switch to owner authority');
});
test('272 fallback guard: granted explicit-session route refuses without default history or writes', async t => {
const {b,calls,render}=await setup(t);
await render(true);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
assert.equal(calls.some(c=>['thread.messages','thread.send','thread.open'].includes(c.verb)),false);
});
for(const failure of ['human-required','timeout','no-bus-host','malformed']) {
test(`272 fallback guard: ${failure} catalog failure never enables default-thread access`, async t => {
const {b,calls,render}=await setup(t,{catalogError:failure});
await render(false);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
assert.equal(calls.some(c=>c.verb==='thread.messages'),false);
});
}
test('272 fallback guard: owner route retains chooser and explicit default-session addressing', async t => {
const {b,calls,render,session}=await setup(t,{owner:true});
await render(false);
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),true);
assert.equal(await b.evaluate('document.getElementById("chat-session").value'),session);
assert.equal(calls.find(c=>c.verb==='thread.messages').args.session,session);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
});