feat(webui): shared threads and per-grant viewer read-only (193d)

This commit is contained in:
jason.woltje committed 2026-10-23 23:22:19 -05:00
1 parent ec86f2c847
commit 4a4bef6c13
16 files changed
+550 -31

No files matched your search

+12
View File
@@ -5457,3 +5457,15 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-24 | Goldie | Row325 / #1821 before (mos-1563/1564) | Real .mosaic/project.json idstack disagrees with Case1 project's mosaic-stack key; row323 scratch fixture omitted project file and only warned. New goldie/row325 at99930362e changes only that JSON key and expressly authorized old assertion key; new real-byte project-copy validation tests require valid and reject old key. Mutant prepared; no tests before Sage CLEAR, no queue writes/host actions.
2026-10-24 | Goldie | Row325 / #1821 after (sage-3882 CLEAR) | Only Case1 projects key becomesstack; only old test70 expected key updated under mos-1564. New3-case test copies real .mosaic/project.json bytes, requires CLI project valid, rejects old key. Serialized shortTMP timeout-k10: new3/3, business264/264, old-key mutant killed/restored SHA9e43bfef, restored new3/3; all positive0 fail/skip/cancel. Manifest4/4 verifies including unchanged project document. Logs/load lines and mutant diff in BUILD.md; no queue writes/host action, Gladstone review/Sage gate pending.
2026-10-24 — Poe, row 208 / 193d (#1706), before: building Console shared list,
share controls and per-grant viewer read-only against approved design e2d161e2e.
Base ec86f2c84. Isolated tests and UI-guard mutants pending Sage CLEAR; no live action.
2026-10-24 — Poe, row 208 / 193d (#1706), after: Console shared list, share/unshare
controls and live per-grant viewer UI implemented. Webui 418/418 and plugins
119/119 green under sage-3925 CLEAR; four UI guard mutants killed with viewer
bus bypass refusals. Prior old-test draft regression fixed under sage-3918;
missing root oauth4webapi corrected by pinned npm ci under sage-3924. Evidence:
agents/poe/work/queue-208/BUILD.md. Independent reviews and Sage's global gate
remain; no live state, schema or bus policy change.
+122
View File
@@ -0,0 +1,122 @@
# Row 208 / 193d — Console shared threads and viewer reads
Base ec86f2c84, branch poe/row208; Gitea #1706. Approved design: e2d161e2e,
sha256 dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523.
## Before
FED R6 supplies per-session user transport and the user-only page, row 192 SSO,
and row 206 supplies schema 3l shares and bus refusals. No bus/schema changes needed.
The webui channel adapter must forward the three existing share verbs.
## Choices and acceptance
Use the existing chat HTTP write fences and per-session transport for shares.
Expose only live collaborator grant project/instance scopes as UI hints, never
identity or bus authority. Re-read hints when rendering a user page; mixed grants
must hide writes on viewer-only threads even when another project allows writes.
Shared threads always read without session management, composer, resume or sharing.
Keep the exact governance notice on user thread/share pages. Targets are typed
owner/user principal names or *, avoiding a new user directory read API.
Add new isolated HTTP/broker and browser coverage. Remove each UI guard in mutants
and show the real broker still refuses viewer create, send, open and share writes.
Every test execution requires Sage CLEAR; use scratch TMPDIR, deadlines, npm ci,
and MOSAIC_REQUIRE_EDITOR_BUILD=1. No live state or network destinations.
## Tracking
Queue next from linked tree refused as designed; canonical next matched pinned brief.
Claim poe-0001 initially refused on Sage's queue lock; retry the same operation id.
Claim poe-0001 succeeded at canonical queue revision 3807 (in-progress).
During implementation, three string replacements briefly targeted canonical
packages/webui/src/public/{bus.js,user.js,chat.js} due to an incorrect command cwd.
Checked the complete diffs, surgically reversed exactly those replacements, and
applied them to this worktree. Sage independently confirmed canonical packages/
has no remaining diff (sage-3914). No tests or live actions occurred there.
poe-0002 requested the test slot. sage-3914 approved the exact serialized plan,
but CLEAR is pending Bentina returning her slot. No node/npm/browser commands
before CLEAR. New tests are thread-sharing.test.mjs with thread-sharing-fixture.mjs;
mutants.mjs runs four served-source mutants with assertion kills plus bus bypass.
## First verification (sage-3917 CLEAR)
Scratch logs: /mnt/storage/scratch/tmp/YU1G5B/.
Syntax 12/12 exit 0; npm-ci.log exit 0; editor-build.log exit 0;
thread-sharing.tap 7/7 exit 0. mutants.log and poe-208-mutants.json: four
absence-test assertion kills (exit 1 each), four bus bypass proofs (exit 0 each),
runner exit 0. Each mutant's individual log is poe-208-NAME-kill.tap or -bypass.tap.
Full serialized webui.tap stopped on an existing-test red: 44 pass, 1 fail before
termination; runner exit 1. Plugins not run. Slot returned to Sage via poe-0003.
Existing failure: chat-browser.test.mjs:123, uncertain writes/read failure draft
preservation, line 133 expects #chat-body after no-bus-host. The new current.thread
is not retained before list reads succeed, unlike old current.threads. A transient
read failure therefore hides the existing owner conversation. Proposed fix retains
same-thread metadata across refresh, never stale user grant scopes. No old tests
changed and no further execution pending Sage's ruling/CLEAR.
## Approved correction (sage-3918)
The old test is correct; this change broke its transient-failure draft contract.
Retain current.thread and shared only when the old id equals the requested id.
User chatGrants still start empty and are read fresh, so a stale hint is never
carried forward. A real 403/404 clears thread/shared metadata plus cached history,
drafts and operation forms. chat-browser.test.mjs remains byte-identical.
Fix applied statically; Sage will CLEAR the four mutants, full webui and plugins
suites after Bentina returns the slot. No node/npm/browser commands before then.
Clarification during static review: retain the existing distinction between a
thread-level refusal and a local bad-session selector (old row 272 B1 test).
All 403/404 responses clear visible thread/shared metadata, as before; thread-level
thread-not-owned/thread-not-found/access-refused also erase sibling cached history
and drafts. A bad-session selector never erases valid sibling drafts/recovery keys.
This preserves the existing invalidation contract rather than broadening the fix.
sage-3920 CLEAR: first mutant rerun stopped on a new-test failure, not a valid
mutant kill. /mnt/storage/scratch/tmp/8UlAhS/poe-208-composer-kill.tap shows null
old.thread on initial /chat: old?.id and undefined route id compared equal.
Fixed by requiring old to exist before either same-id metadata carry. This is a
new defect in the approved correction, no old test change. Rerunning the plan.
## Second slot outcome (sage-3920)
Scratch /mnt/storage/scratch/tmp/8UlAhS/.
Initial mutants.log exit 1 was the new null-previous-state defect above. Fixed;
mutants-r2.log exit 0: all four assertion kills exit 1, all four bypass proofs
exit 0 (individual logs overwritten by the successful rerun; summary JSON kept).
Full serialized webui.tap stopped on old tests: 114 top-level pass, 5 fail,
runner exit 1. The original draft-preservation failure and row 272 B1 both pass.
The new old-test failure is environmental: decide-sso.test.mjs cannot import
root dependency oauth4webapi (ERR_MODULE_NOT_FOUND); all five failures share it.
packages/webui npm ci installs its editor tooling, not root runtime dependencies.
No tests changed; plugins not run. Request root npm ci from pinned lockfile in
next slot before rerunning webui and plugins. Slot returned via poe-0004.
sage-3924 ruled the second red an environment gap, not a product defect. Approved
root npm ci --ignore-scripts from the pinned lock, full webui then plugins,
serialized. sage-3925 CLEAR starts that sequence. Push only after green; send
base/tip and every changed path's SHA-256 to Sage and Gladstone.
## Final builder verification (sage-3925 CLEAR)
Root npm ci --ignore-scripts exit 0 from pinned lock.
Full webui: exit 0, 418 pass, 0 fail, 0 skipped (271964 ms).
Plugins: exit 0, 119 pass, 0 fail, 0 skipped (823 ms).
Logs /mnt/storage/scratch/tmp/Et9xAa/{root-npm-ci.log,webui.tap,plugins.tap}.
Slot returned immediately after plugins via poe-0005. All new acceptance cases
are included in the full webui run. Four latest UI mutants and bus bypass proofs
remain green at /mnt/storage/scratch/tmp/8UlAhS/mutants-r2.log and
poe-208-mutants.json. No old tests changed. No bus authority/schema changes.
Acceptance: owner and collaborator can share/unshare own writable threads;
shared lists preserve the plain-list shape and show owner names safely as text;
shared threads cannot compose, resume, mutate sessions or reshare. Viewer old
own threads retain read history with no write controls. Mixed grants and live
downgrades hide writes only where coverage requires; revoked shares clear the
view. Direct browser POSTs bypass all UI checks and the real broker still
refuses viewer create/send/open/share. Existing exact governance notice retained.
Global scripts/test-*.sh gate and independent reviewer approval remain Sage's
landing gate; this builder does not claim those or any live deployment.
+28
View File
@@ -0,0 +1,28 @@
// Run only under Sage's CLEAR, with scratch TMPDIR and MOSAIC_REQUIRE_EDITOR_BUILD=1.
// Mutants alter only the fixture-served copy of chat.js; repository source is untouched.
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
const file = 'packages/webui/tests/thread-sharing.test.mjs';
const results = [];
for (const mutant of ['composer', 'open', 'share', 'create']) {
for (const [stage, pattern, expected] of [
['kill', '193d viewer UI has no', 1],
['bypass', '193d bypassed UI checks', 0],
]) {
const run = spawnSync(process.execPath, ['--test', '--test-concurrency=1', '--test-name-pattern=' + pattern, file], {
env: { ...process.env, POE_UI_MUTANT: mutant }, encoding: 'utf8', timeout: 90000,
});
const log = join(process.env.TMPDIR, `poe-208-${mutant}-${stage}.tap`);
writeFileSync(log, (run.stdout ?? '') + (run.stderr ?? ''));
const killedByAssertion = /ERR_ASSERTION/.test(run.stdout ?? '');
const passed = run.status === expected && (stage !== 'kill' || killedByAssertion);
results.push({ mutant, stage, exit: run.status, signal: run.signal, passed, log });
console.log(JSON.stringify(results.at(-1)));
if (!passed) {
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
process.exit(1);
}
}
}
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
+2
View File
@@ -1620,3 +1620,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-24T00:41Z | Duckworth (Opus 5.5) | row 323 round 1 static review on 6a0f0f4f8 (sage-3865, sage-3870) | APPROVE (static) C1 Sage full gate cli, manifest 903ef022 matches, fields sourced, tests read shipped bytes, tester mutant real, N1-N2 doc (duckworth-0300) |
2026-10-24T01:13Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 Case1 project key | New goldie/row325 worktree at99930362e; pinned brief60d78e44e. Draft JSON key correction, new real-project-byte tests, sole old line70 key edit under mos-1564. Static checks only; requesting Sage CLEAR, no queue writes.
2026-10-24T01:14Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 verified | sage-3882: new3/3, business264/264, one key mutant killed/restored, new3/3 again, per-run fresh shortTMP/load/helperCLEAR. Four manifest entries verify; sole old assertion key change, no queue writes. Candidate push/report to Sage and Gladstone next.
2026-10-24 | Poe | Q246 / mos-1566, row 208 / 193d (#1706), poe/row208 | Started; approved design hash verified; implementation and isolated acceptance pending.
2026-10-24 | Poe | Row 208 / 193d (#1706), poe/row208 | Builder acceptance green: webui 418/418, plugins 119/119, four UI mutants killed with bus bypass proofs; candidate prepared for Gladstone then Duckworth guard review.
+3
View File
@@ -9,6 +9,9 @@ const VERBS = new Set([
"thread.messages",
"thread.send",
"thread.open",
"thread.shares",
"thread.share",
"thread.unshare",
"session.list",
"session.create",
"session.clear",
+13
View File
@@ -1091,3 +1091,16 @@ During Q175's read-only interim both buttons show as unavailable and point to
`mosaic halt` / `mosaic resume` in the instance CLI. There is no halt-only send
exception or alternate authentication path. Landing the UI and activating it
remain separately gated.
Row 208 (193d): Chat includes a **Shared with me** list. Shared threads are
read-only, including for the business owner. On your own writable threads,
**Share read-only** accepts `user:ID`, the business owner's name, or `*` for
everyone with coverage; **Unshare** ends that target's share. A share grants
no project or instance access of its own.
Viewers can read shared threads and their old own threads. Their pages omit
creation, composer, resume, share and session mutation controls. Mixed grants
apply separately to each project and instance; a collaborator grant elsewhere
does not enable writes here. Grant hints refresh on each user chat render and
background refresh, while the bus decides every operation from live grants.
The existing user notice names the governance records visible to the owner.
+10
View File
@@ -18,6 +18,16 @@ export function consoleAccess({ load, business }) {
} catch { return null; }
};
return Object.freeze({
// UI hints only: no grants belonging to another user, receipts or subjects escape.
chatGrants: session => {
try {
if (lookup(session.provider, session.sub) !== session.user) return [];
const doc = load(), b = business();
return doc.grants.filter(g => g.user === session.user && g.business === b &&
covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'collaborator' }))
.map(g => ({ projects: [...g.projects], instances: [...g.instances] }));
} catch { return []; }
},
// The user id this (provider, sub) signs in as, or null.
find: ({ provider, sub }) => lookup(provider, sub),
// Is this session's user still the holder of its own subject, with a live grant?
+17 -3
View File
@@ -22,6 +22,7 @@ const STATUS = {
'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.
'share-not-covered': 403, 'share-exists': 409, 'share-not-found': 404,
'access-refused': 403, 'access-revoked': 401,
};
function safeMessage(code) {
@@ -72,7 +73,7 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
try {
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation)$/.exec(path);
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation|shares|share|unshare)$/.exec(path);
if (match) {
thread = match[1];
if (!id(thread)) throw Error();
@@ -89,6 +90,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (['share', 'unshare'].includes(match?.[2])) {
if (!exact(v, ['to']) || typeof v.to !== 'string' || !v.to.length || v.to.length > 200 || /[\r\n\0]/.test(v.to)) throw Error();
action = match[2]; args = { thread, to: v.to };
} else if (match?.[2] === 'sessions') {
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
action = 'createSession'; args = { thread, name: v.name, op: v.op };
@@ -109,9 +113,12 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
} else if (path === '/api/chat/projects') {
query(search, []); action = 'projects'; args = {};
} else if (path === '/api/chat/threads') {
const q = query(search, ['project']);
const q = query(search, ['project', 'shared']);
if (q.has('shared') && q.get('shared') !== 'true') throw Error();
if (q.has('project') && !id(q.get('project'))) throw Error();
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
action = 'threads'; args = { ...(q.has('project') ? { project: q.get('project') } : {}), ...(q.has('shared') ? { shared: true } : {}) };
} else if (match?.[2] === 'shares') {
query(search, []); action = 'shares'; args = { thread };
} else if (match?.[2] === 'sessions') {
query(search, []); action = 'sessions'; args = { thread };
} else if (match?.[2] === 'operation') {
@@ -155,6 +162,13 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
} : {};
return json(res, 200, { rows: value, ...page, at: new Date().toISOString() });
}
if (action === 'share' || action === 'unshare') {
if (value?.thread !== thread || value?.to !== args.to || value?.op !== action) {
line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown');
}
line(`thread=${thread} op=${action}`);
return json(res, 200, { thread, to: value.to, op: value.op });
}
if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) ||
(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) {
line('error=outcome-unknown');
+4 -1
View File
@@ -24,7 +24,10 @@ export function createChat({ call, owner = null }) {
return Object.freeze({
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
threads: ({ project, shared } = {}) => channel('thread.list', { ...(project === undefined ? {} : { project }), ...(shared === true ? { shared: true } : {}) }),
shares: ({ thread }) => channel('thread.shares', { thread }),
share: ({ thread, to }) => channel('thread.share', { thread, to }),
unshare: ({ thread, to }) => channel('thread.unshare', { thread, to }),
createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
sessions: ({ thread }) => channel('session.list', { thread }),
createSession: ({ thread, name, op }) => channel('session.create', { thread, name, op }),
+1 -1
View File
@@ -1232,7 +1232,7 @@
window.MosaicChat?.leave(path);
if (path === '/chat' || path.startsWith('/chat/')) {
// Row 177 (#1675): the tester Console writes to its own threads.
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, navigated });
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
if (g !== gen || out()) return;
shown = route; schedule(); return;
}
+81 -23
View File
@@ -57,23 +57,35 @@
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const stamp = at => `<time>${txt(at)}</time>`;
// Per-grant UI hints; all writes still use the caller's bus principal.
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
(g.projects.includes('*') || g.projects.includes(project)) &&
(instance === undefined || g.instances.includes('*') || g.instances.includes(instance)));
const threadWrites = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance);
function sharing(t) {
if (!current.canSend || !threadWrites() || current.tester) return '';
const f = form('share:' + t.id);
return `<section aria-label="Thread sharing"><h2>Share read-only</h2><p>Share with a user (user:id), the business owner's name, or * for everyone granted on this thread.</p><ul>${(current.shares ?? []).map(r => `<li>${txt(r.to)} <button class="btn" type="button" data-unshare="${esc(r.to)}"${disabled(f.pending)}>Unshare</button></li>`).join('')}</ul><form id="chat-share-form"><label>Share with<input id="chat-share-target" value="${esc(f.to ?? '')}" maxlength="200" required></label><button class="btn" type="submit"${disabled(f.pending)}>Share read-only</button></form><p role="status">${txt(f.receipt)}</p></section>`;
}
function creation(kind, key) {
const f = form(key), project = kind === 'project';
if (current.user && (project || !scopeWrites(key))) return '';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
const choices = current.instances ?? [];
const choices = (current.instances ?? []).filter(r => scopeWrites(key, r.instance));
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
}
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
`unknown (${value.reason ?? 'not-collected'})`;
function sessionControls(t) {
if (!threadWrites()) return '<p>Read-only thread.</p>';
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);
@@ -84,7 +96,7 @@
const s = state(current.key);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
}
function capture() {
if (!current) return null;
@@ -98,16 +110,16 @@
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
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);
const t = c.thread;
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);
html += heading(p?.name ?? 'Project unavailable');
if (p && !c.refused) html += threads(c.threads) + creation('thread', c.id);
if (p && !c.refused) html += threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
} else {
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div>' + creation('project', 'projects');
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>';
@@ -132,7 +144,7 @@
err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
}
async function render({ path, container, canSend, navigated = false, next = false }) {
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
if (out()) return;
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
controller?.abort(); controller = new AbortController();
@@ -144,23 +156,46 @@
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, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, 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 && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
if (user) {
const config = await fetch('/api/config', { cache: 'no-store', redirect: 'manual', signal });
if (config.type === 'opaqueredirect' || config.status === 401) { signOut(); return; }
if (!config.ok) throw new ChatError('invalid-response');
const hints = await config.json();
if (g !== generation || out()) return;
current.chatGrants = Array.isArray(hints.chatGrants) ? hints.chatGrants : [];
current.canSend = hints.send === true;
}
// Resolve the explicit session before touching history/cursors. Missing selectors never
// silently fall back to a sibling. The saved default is used only for thread-only routes.
const [projects, listed, catalog] = await Promise.all([
const [projects, listed, shared] = await Promise.all([
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
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,
tester ? { rows: [] } : request('threads?shared=true' + (kind === 'project' ? '&project=' + id : ''), undefined, signal).catch(err => {
if (['signed-out', 'access-revoked'].includes(err.code)) throw err;
return { rows: [] };
}),
]);
if (g !== generation || out()) return;
current.projects = projects.rows; current.threads = listed.rows;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
current.thread = current.threads.find(t => t.id === id);
current.shared = current.thread?.shared === true;
const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => {
if (err.code === 'access-refused' && !match[3]) return null;
throw err;
}) : null;
if (g !== generation || out()) return;
if (current.shared && match[3]) throw new ChatError('session-not-found', 404);
current.shares = [];
if (kind === 'thread' && current.canSend && threadWrites() && !tester) {
const shares = await request(`threads/${id}/shares`, undefined, signal).catch(() => ({ rows: [] }));
if (g !== generation || out()) return;
current.shares = shares.rows;
}
let s = null, page = null, after = null;
if (kind === 'thread' && catalog === null) {
current.legacyDefault = true; current.sessions = []; current.session = null;
@@ -208,13 +243,13 @@
if (g !== generation || out()) return;
if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
throw new ChatError('invalid-response');
current.instances = listed.rows; current.instanceError = '';
current.instances = listed.rows.filter(r => scopeWrites(id, r.instance)); current.instanceError = '';
const f = form(id);
if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
if (f.instance && !current.instances.some(row => row.instance === f.instance)) {
f.instance = ''; f.instanceChosen = true;
f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
}
if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
if (!f.instanceChosen && !f.instance && current.instances.length === 1) f.instance = current.instances[0].instance;
} catch (err) {
if (err.name === 'AbortError' || out() || g !== generation) return;
current.instances = [];
@@ -244,7 +279,7 @@
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
}
if (err.status === 403 || err.status === 404) {
current.projects = []; current.threads = []; current.refused = true;
current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true;
}
paint(navigated);
}
@@ -258,6 +293,7 @@
document.addEventListener('input', event => {
if (!current || out()) return;
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || f.pending || !f.op; }
if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value;
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
if (f && event.target.dataset.chatField) {
@@ -275,6 +311,8 @@
async function write(action, key, value, path) {
if (!current || !current.canSend || out()) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
const targetThread = current.id;
const s = isThread ? state(key) : form(key);
if (s.pending || isThread && selectedInactive()) return;
@@ -314,7 +352,9 @@
document.addEventListener('submit', event => {
if (!current || !event.target.id.startsWith('chat-')) return;
event.preventDefault();
if (event.target.id === 'chat-send-form') {
if (event.target.id === 'chat-share-form') {
void changeShare('share', form('share:' + current.id).to ?? '');
} else 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 ? { session } : {}) }, `threads/${id}/messages`);
} else if (event.target.id === 'chat-session-form') {
@@ -336,6 +376,7 @@
});
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
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();
@@ -347,6 +388,23 @@
location.hash = sessionPath(current.id, event.target.value);
}
});
async function changeShare(op, to) {
if (!current?.canSend || !threadWrites() || current.tester || out()) return;
const id = current.id, f = form('share:' + id);
if (f.pending || !to.trim()) return;
f.pending = true; f.receipt = ''; paint();
try {
const r = await request(`threads/${id}/${op}`, { to });
if (r.thread !== id || r.to !== to || r.op !== op) throw new ChatError('outcome-unknown');
f.receipt = op === 'share' ? 'Shared read-only.' : 'Share ended.';
if (op === 'share') f.to = '';
} catch (e) {
f.receipt = e.sent === false ? `Share refused (${inert(e.code)}).` : 'Outcome unknown. Read shares before trying again; nothing was retried.';
} finally {
f.pending = false;
if (!out() && current?.id === id) { await refresh(); paint(); }
}
}
const clearText = r => r.state === 'cancelled' ? `Operation ${r.op}: ${r.retired === true ? 'startup cancelled; old session remains retired' : 'replacement cancelled; old session active'}. Cancelled inputs are not replayed.` : `Operation ${r.op}: ${r.state}; successor ${r.successor}; start ${r.start ?? 'pending'}${r.refusal ? '; ' + r.refusal : ''}. No stop is forced.`;
async function checkClear(thread, key, f) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) { f.receipt = 'Enter a valid operation key.'; paint(); return; }
@@ -361,7 +419,7 @@
}
async function cancelClear() {
const c = current, key = c.key, f = form('clear:' + key);
if (!c.canSend || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!c.canSend || !threadWrites() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return;
const op = f.op;
f.pending = true; paint();
@@ -379,7 +437,7 @@
}
async function clearContext() {
const c = current, key = c.key, selected = c.sessions.find(r => r.id === c.session), f = form('clear:' + key);
if (!c.canSend || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
if (!c.canSend || !threadWrites() || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
+2 -1
View File
@@ -9,10 +9,11 @@
return path.startsWith('/chat') ? path : '/chat';
};
function show(navigated) {
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated });
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated });
}
document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; });
window.addEventListener('hashchange', () => show(true));
setInterval(() => { if (!document.hidden) show(false); }, 5000);
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => {
if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; }
const config = await res.json();
+3 -2
View File
@@ -632,7 +632,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false, chatGrants = [];
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -700,6 +700,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; files = USER_FILES; granted = true;
chatGrants = sso.chatGrants?.(session) ?? [];
}
}
if (named && preview !== null) {
@@ -839,7 +840,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+1
View File
@@ -352,6 +352,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
chatGrants: session => users?.chatGrants?.(session) ?? [],
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
@@ -0,0 +1,87 @@
// Row 208: isolated real broker + chat HTTP; no config, slot, provider or live host.
import { createServer, request } from 'node:http';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { createChat } from '../src/chat.mjs';
import { chatHTTP } from '../src/chat-http.mjs';
import { consoleAccess } from '../src/access.mjs';
import { BusReadError } from '../src/bus.mjs';
export const mutants = {
composer: ["${threadWrites() ? `<form id=\"chat-send-form\"", "${true ? `<form id=\"chat-send-form\""],
open: ['${threadWrites() ? `<button class="btn" type="button" id="chat-resume"', '${true ? `<button class="btn" type="button" id="chat-resume"'],
share: ["if (!current.canSend || !threadWrites() || current.tester) return '';", "if (!current.canSend || current.tester) return '';"],
create: ["if (current.user && (project || !scopeWrites(key))) return '';", "if (current.user && project) return '';"],
};
export async function sharingFixture(t, { owner = false, mutant = process.env.POE_UI_MUTANT } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'poe-sharing-'));
const store = new Store(dir), calls = [], lines = [];
const access = { accessVersion: 1, revision: 1, users: [
{ id: 'alice', home: 'local', subjects: [{ provider: 'fake', sub: 'alice-sub' }] },
{ id: 'bob', home: 'local', subjects: [{ provider: 'fake', sub: 'bob-sub' }] },
], grants: ['alice', 'bob'].map(user => ({ id: 'g-' + user, user, 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))).messaging } },
} } });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const invoke = (cap, verb, args = {}) => broker.request(cap, { verb, args });
const alice = () => broker.bindUser({ business: 'demo', user: 'alice' });
const bob = () => broker.bindUser({ business: 'demo', user: 'bob' });
const project = invoke(human, 'project.create', { name: 'Viewer project' }).id;
const other = invoke(human, 'project.create', { name: 'Collaborator project' }).id;
const own = invoke(alice(), 'thread.create', { project, instance: 'assistant', name: 'Old own thread' }).id;
const writable = invoke(alice(), 'thread.create', { project: other, instance: 'assistant', name: 'Writable thread' }).id;
const shared = invoke(bob(), 'thread.create', { project, instance: 'assistant', name: 'Shared thread <img src=x onerror=alert(1)>' }).id;
invoke(alice(), 'thread.send', { thread: own, body: 'Own history' });
invoke(bob(), 'thread.send', { thread: shared, body: 'Shared history' });
invoke(bob(), 'thread.share', { thread: shared, to: 'user:alice' });
access.grants[0].level = 'viewer';
const hints = consoleAccess({ load: () => access, business: () => 'demo' });
const principal = () => owner ? human : alice();
const chat = createChat({ call: async (verb, args) => {
calls.push([verb, args]);
if (verb === 'plugins') return { plugins: { channel: ['webui'] } };
try { return invoke(principal(), verb, args); }
catch (e) { throw new BusReadError(e.code); }
} });
const source = readFileSync(new URL('../src/public/chat.js', import.meta.url), 'utf8');
let chatSource = source;
if (mutant) {
const [before, after] = mutants[mutant] ?? [];
if (!before || !source.includes(before)) throw Error('mutant anchor missing: ' + mutant);
chatSource = source.replace(before, after);
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://fixture');
if (url.pathname === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(JSON.stringify({ send: true, user: !owner, chatGrants: hints.chatGrants({ user: 'alice', provider: 'fake', sub: 'alice-sub' }) }));
}
if (url.pathname.startsWith('/api/chat/')) return chatHTTP(req, res, {
path: url.pathname, search: url.search, chat, named: true, send: true,
preview: { sso: true, user: owner ? 'jason' : 'alice', origin: origin() }, log: line => lines.push(line),
});
if (url.pathname === '/chat.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(chatSource); }
if (url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
return res.end('<!doctype html><title>Sharing fixture</title><div id="view"></div><script src="/chat.js"></script>');
}
res.writeHead(404); res.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const origin = () => `http://127.0.0.1:${server.address().port}`;
t.after(async () => { await new Promise(r => server.close(r)); store.close(); rmSync(dir, { recursive: true, force: true }); });
const http = (path, value, extra = {}) => new Promise((resolve, reject) => {
const body = value === undefined ? undefined : JSON.stringify(value);
const req = request(origin() + path, { method: body ? 'POST' : 'GET', headers: body ? { origin: origin(), 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...extra } : extra }, res => {
let data = ''; res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) }));
}); req.on('error', reject); req.end(body);
});
return { store, access, broker, human, alice, bob, invoke, project, other, own, writable, shared, calls, lines, origin: origin(), http, hints, source, chatSource };
}
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sharingFixture } from './thread-sharing-fixture.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { consoleBus } from '../src/console-bus.mjs';
async function page(t, f) {
const b = await browser(); t.after(() => b.close());
await b.navigate(f.origin);
const render = async path => {
await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#' + path)};await MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,user:${!f.owner},navigated:true});return true;})()`);
};
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`);
return { b, render, wait };
}
const counts = f => Object.fromEntries(['threads', 'messages', 'thread_opens', 'thread_shares'].map(table => [table, f.store.get(`SELECT count(*) n FROM ${table}`).n]));
test('193d HTTP shares use the caller, plain/shared lists, exact args and write fences', async t => {
const f = await sharingFixture(t);
let r = await f.http('/api/chat/threads?shared=true');
assert.equal(r.status, 200); assert.deepEqual(r.body.rows.map(x => x.id), [f.shared]);
assert.equal(r.body.rows[0].owner, 'user:bob');
r = await f.http('/api/chat/threads');
assert.deepEqual(r.body.rows.map(x => x.id), [f.own, f.writable]);
assert.ok(r.body.rows.every(x => !Object.hasOwn(x, 'owner')), 'plain list shape unchanged');
assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).status, 200);
const before = counts(f);
for (const [path, body] of [
['/api/chat/threads', { project: f.project, instance: 'assistant', name: 'forbidden' }],
[`/api/chat/threads/${f.own}/messages`, { body: 'forbidden' }],
[`/api/chat/threads/${f.own}/open`, {}],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob' }],
[`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' }],
]) {
r = await f.http(path, body);
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false);
}
assert.equal((await f.http(`/api/chat/threads/${f.own}/shares`)).body.error, 'access-refused');
assert.deepEqual(counts(f), before);
f.access.grants[0].level = 'collaborator';
r = await f.http(`/api/chat/threads/${f.own}/share`, { to: 'user:bob' });
assert.equal(r.status, 200); assert.deepEqual(r.body, { thread: f.own, to: 'user:bob', op: 'share' });
assert.deepEqual((await f.http(`/api/chat/threads/${f.own}/shares`)).body.rows.map(x => x.to), ['user:bob']);
assert.equal((await f.http(`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' })).status, 200);
for (const [path, body] of [
['/api/chat/threads?shared=false', undefined], ['/api/chat/threads?shared=true&shared=true', undefined],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob', owner: 'jason' }],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob', verb: 'thread.send' }],
[`/api/chat/threads/${f.own}/shares?to=user:bob`, undefined],
]) assert.equal((await f.http(path, body)).status, 400, path);
assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { origin: 'http://other.test' })).status, 403);
assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { 'sec-fetch-site': 'cross-site' })).status, 403);
f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' });
assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).body.error, 'thread-not-owned');
});
test('193d config supplies only the signed-in user live collaborator scopes and updates on downgrade', async t => {
const f = await sharingFixture(t);
f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] });
const session = { user: 'alice', name: 'alice', provider: 'fake', sub: 'alice-sub' };
let web;
const sso = { get publicHost() { return `127.0.0.1:${web.address().port}`; }, get publicOrigin() { return `http://${this.publicHost}`; },
formTargets: () => [], session: () => session, chatGrants: s => f.hints.chatGrants(s) };
web = await startServer({ port: 0, sso, log: () => {}, userOptions: () => consoleBus(async () => [], { send: true }) });
t.after(() => new Promise(r => web.close(r)));
const read = async () => (await fetch(`http://127.0.0.1:${web.address().port}/api/config`)).json();
let config = await read();
assert.equal(config.user, true); assert.deepEqual(config.chatGrants, [{ projects: [f.other], instances: ['assistant'] }]);
assert.equal(config.send, true, 'send mode is separate from individual grant levels');
f.access.grants[2].level = 'viewer';
assert.deepEqual((await read()).chatGrants, []);
f.access.grants[2].level = 'collaborator'; f.access.grants[2].expires = new Date(Date.now() - 1000).toISOString();
assert.deepEqual((await read()).chatGrants, []);
assert.deepEqual(f.hints.chatGrants({ ...session, sub: 'bob-sub' }), []);
});
test('193d viewer UI has no composer, resume, share, session mutations or create controls', async t => {
const f = await sharingFixture(t); f.owner = false;
const { b, render } = await page(t, f);
await render('/chat');
assert.match(await b.evaluate('document.getElementById("view").textContent'), /Shared with me/);
assert.equal(await b.evaluate('!!document.querySelector("#chat-project-form")'), false);
await render('/chat/projects/' + f.project);
assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form")'), false);
for (const thread of [f.own, f.shared]) {
await render('/chat/threads/' + thread);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /history/);
for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '#chat-session-form', '#chat-clear', '#chat-cancel']) {
assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, `${thread}: ${selector}`);
}
}
assert.equal(await b.evaluate('!!document.querySelector("img")'), false, 'shared name remains text');
const sharedSessions = f.calls.filter(([verb, args]) => verb === 'session.list' && args.thread === f.shared);
assert.equal(sharedSessions.length, 0, 'shared read never requests an owner-only catalog');
});
test('193d mixed grants keep viewer project read-only and collaborator own thread writable; downgrade hides controls', async t => {
const f = await sharingFixture(t); f.owner = false;
f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] });
const { b, render, wait } = await page(t, f);
await render('/chat/threads/' + f.own);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
await render('/chat/threads/' + f.writable);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), true);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true);
await b.evaluate('(()=>{const x=document.getElementById("chat-share-target");x.value="user:bob";x.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-share-form button").click();return true;})()');
assert.equal(await wait('!!document.querySelector("[data-unshare]")'), true);
assert.equal(f.store.get("SELECT count(*) n FROM thread_shares WHERE thread=? AND op='share'", f.writable).n, 1);
await b.evaluate('document.querySelector("[data-unshare]").click(),true');
assert.equal(await wait('!document.querySelector("[data-unshare]")'), true);
f.access.grants[2].level = 'viewer';
await render('/chat/threads/' + f.writable);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form,#chat-share-form,#chat-resume")'), false);
// Loss of a share clears visible history on the next render.
await render('/chat/threads/' + f.shared);
f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' });
await render('/chat/threads/' + f.shared);
assert.equal(await b.evaluate('!!document.querySelector(".chat-log")'), false);
});
test('193d owner has share controls on own threads and shared threads remain read-only', async t => {
const f = await sharingFixture(t, { owner: true }); f.owner = true;
const own = f.invoke(f.human, 'thread.create', { project: f.project, instance: 'assistant', name: 'Owner thread' }).id;
f.invoke(f.bob(), 'thread.share', { thread: f.shared, to: 'jason' });
const { b, render } = await page(t, f);
await render('/chat/threads/' + own);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true);
await render('/chat/threads/' + f.shared);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /Shared history/);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume")'), false);
});
// Run under each served-source mutant after the normal absence test kills it.
// Every browser POST bypasses all local UI checks and still uses the viewer transport.
test('193d bypassed UI checks still reach a refusing bus for every viewer write', async t => {
const f = await sharingFixture(t); f.owner = false;
const { b, render } = await page(t, f);
await render('/chat/threads/' + f.own);
const before = counts(f);
for (const [path, value] of [
['threads', { project: f.project, instance: 'assistant', name: 'mutant create' }],
[`threads/${f.own}/messages`, { body: 'mutant send' }],
[`threads/${f.own}/open`, {}], [`threads/${f.own}/share`, { to: 'user:bob' }],
]) {
const r = JSON.parse(await b.evaluate(`(async()=>{const r=await fetch('/api/chat/'+${JSON.stringify(path)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(${JSON.stringify(value)})});return JSON.stringify({status:r.status,body:await r.json()});})()`));
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false);
}
assert.deepEqual(counts(f), before);
for (const verb of ['thread.create', 'thread.send', 'thread.open', 'thread.share']) assert.ok(f.calls.some(([v]) => v === verb), `${verb} reached the bus`);
});
test('193d collaborator shares stay read-only and never expose reshare or wake controls', async t => {
const f = await sharingFixture(t); f.owner = false;
f.access.grants[0].level = 'collaborator';
const { b, render } = await page(t, f);
await render('/chat/threads/' + f.shared);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume,#chat-session-form")'), false);
for (const [action, body] of [['messages', { body: 'shared write' }], ['open', {}], ['share', { to: '*' }]]) {
const r = await f.http(`/api/chat/threads/${f.shared}/${action}`, body);
assert.equal(r.status, 403); assert.equal(r.body.error, 'thread-not-owned');
}
});