Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
npm run test:mysql > evidence/mysql-ci.txt 2>&1
node scripts/test-hash-databases.mjs >> evidence/mysql-ci.txt 2>&1
node scripts/test-historical-persistence.mjs >> evidence/mysql-ci.txt 2>&1
node scripts/test-onboarding-databases.mjs >> evidence/mysql-ci.txt 2>&1
node scripts/test-pack-persistence.mjs >> evidence/mysql-ci.txt 2>&1
env:
TEST_DATABASE_URL: mysql://guard:test-only-password@127.0.0.1:3306/guard_test
Expand Down Expand Up @@ -94,6 +95,7 @@ jobs:
npm run test:postgres
node scripts/test-hash-databases.mjs
node scripts/test-historical-persistence.mjs
node scripts/test-onboarding-databases.mjs
node scripts/test-pack-persistence.mjs
env:
TEST_DATABASE_URL: postgresql://guard:test-only-password@localhost:5432/guard_test
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ See [native text architecture, boundaries and privacy](docs/TEXT_MODERATION.md),

Text tests never moderate Discord. Submitted text is not retained in jobs, logs, notifications, exports or decision evidence. Empty, unavailable, over-limit or insufficient evidence routes to review without punishment. Links, Markdown, Unicode and code blocks are evidence; linked sites, DMs and history are not fetched. Model probabilities are estimates, not measured accuracy or verified wrongdoing. Live provider/Discord acceptance and production deployment require separate authorization.

## Agent-assisted onboarding

Administrators can open **Agent setup** in the Activity or sign in independently at `/setup` on the public origin. A compatible browser agent can inspect guild setup, preview/customize four starter compositions and propose a concrete plan. The administrator accepts the exact plan before application; all new rules remain disabled, review-only and unapproved, with enforcement off. A manual planner is available without WebMCP. See [browser setup, compatibility, starters and screenshots](docs/AGENT_ONBOARDING.md) and [validation limits](evidence/issue44-validation.md). Chrome 155 with WebMCP enabled passed native in-page tools and real standalone Discord OAuth, accepted-plan application to isolated local SQL, receipts and logout. Discord web's actual iframe denied registration with `NotAllowedError` because it does not delegate `tools`; use the browser dashboard. Other browser agents/Discord clients and real provider acceptance remain unverified. This adds no remote MCP service or production deployment.

## Portable moderation policy

Export individual references/groups/prompts/rules, dependency-complete selections or a complete portable setup. Import self-contained, checksummed image packs through an administrator dry run with safe conflict defaults, destination mappings, atomic persistence and destination approval. Complete policy excludes secrets, history, jobs and operational state. Schema migration **004** is required for this version. [Pack format, starters and compatibility](docs/MODERATION_PACKS.md) · [validation and limitations](docs/PACK_VALIDATION.md). Publication does not authorize production deployment.
82 changes: 60 additions & 22 deletions SHA256SUMS

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ While the repaired head was in CI (one complete run green), main advanced to a80

See [issue39 validation](evidence/issue39-validation.md) for 320 software tests, 32 offline browser checks, isolated PostgreSQL/MySQL and Compose recovery/restore results, dataset provenance and remaining external acceptance. Native text is disabled by default, with modality-specific approval and no retained raw submitted text. The source contract was verified against official TypeSafe documentation; no paid provider evaluation or live Discord acceptance was performed. Production publication requires separately authorized manual workflow dispatch. Earlier dated results in this file remain historical evidence, not claims of current production acceptance.

## Agent onboarding / issue #44

[Issue44 validation](evidence/issue44-validation.md) records check/build/tests, PostgreSQL/MySQL rollback/concurrency/receipts, desktop/mobile screenshots, unsupported-browser fallback, native Chrome in-page agent evidence and remaining live Discord/embedding/provider gates. Local results grant neither production acceptance nor deployment authority.

## Reusable moderation packs

See [the feature validation record](docs/PACK_VALIDATION.md) for exact local test commands, isolated PostgreSQL/MySQL persistence, real offline UI workflow/screenshots, CI links and limitations. Owner-reviewed starter images are not a negative/held-out evaluation dataset. No paid model evaluation or production deployment is authorized or claimed.
44 changes: 39 additions & 5 deletions activity/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setupPanel } from './onboarding.js';
import type { Pack, PackPreview, ImportOptions, PackKind } from '../../shared/packs.js';
async function editDefinition(d: any) {
const name = input(d.name), content = structuredClone(d.content), body = h('div', { class: 'form-stack' }, field('Definition name', name));
Expand Down Expand Up @@ -168,6 +169,22 @@ type Child = Node | string | number | false | null | undefined;
const app = document.querySelector<HTMLDivElement>('#app')!;
let session = '', guild = '', authority: Authority | null = null, sdk: SDK | null = null, meta: Metadata = { name: '', roles: [], channels: [] }, settings: SettingsVersion | null = null, page = 'Overview', preview = false;
let status: HTMLDivElement;
let browserDashboardUrl='/setup';
let agentSetup: ReturnType<typeof setupPanel> | undefined;
function clearAgentSetup() { agentSetup?.stop(); agentSetup = undefined; }
function initializeAgentSetup() {
clearAgentSetup();
if (!admin() || preview) return;
const expectedGuild=guild, expectedSession=session;
agentSetup=setupPanel(async (...args)=>{
if(guild!==expectedGuild||session!==expectedSession)throw new HttpError('session_changed',401);
const result=await api(...args);
if(args[0]==='onboarding/apply'&&result.receipt) settings={value:result.after,revision:result.receipt.settingsRevision};
if(args[0]==='pause'){settings=await api<SettingsVersion>('settings');void render().catch(fail);}
return result;
},()=>{page='Agent setup';void render().catch(fail);},browserDashboardUrl);
}
window.addEventListener('pagehide',clearAgentSetup);
const can = (cap: Capability) => authority?.capabilities.includes(cap) ?? false;
const admin = () => !!(authority?.owner || authority?.administrator);
const pretty = (s: string) => s === 'severe_threshold_must_not_be_lower' ? 'Action floors must satisfy timeout <= kick <= ban.' : s === 'match_threshold_must_be_a_finite_number_from_0.5_to_1.0' ? 'Match threshold must be a finite number from 0.5 to 1.0 (inclusive).' : s.replaceAll('_', ' ').replaceAll('.', ' / ');
Expand Down Expand Up @@ -229,8 +246,8 @@ async function api<T = any>(path: string, method = 'GET', body?: unknown, global
const response = await fetch(global ? path : `/api/g/${guild}/${path}`, { method, headers: { ...(session ? { Authorization: `Bearer ${session}` } : {}), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
const data = await response.json();
if (!response.ok) {
if (response.status === 401)
session = '';
if ([401,403].includes(response.status)) clearAgentSetup();
if (response.status === 401) session = '';
throw new HttpError(data.error ?? 'Request failed', response.status);
}
return data as T;
Expand All @@ -254,7 +271,7 @@ function confirmAction(title: string, description: string): Promise<boolean> { r
resolve(false); }); }); }
function links() {
return [
['Moderation packs', admin()], ['Overview', can('dashboard.view')], ['Historical scans', admin()], ['Cost & Usage', can('dashboard.view')], ['Image library', can('images.view') || can('images.add')], ['Rules & tests', can('rules.view') || can('rules.create')],
['Agent setup', admin() && !preview], ['Moderation packs', admin() && !preview], ['Overview', can('dashboard.view')], ['Historical scans', admin()], ['Cost & Usage', can('dashboard.view')], ['Image library', can('images.view') || can('images.add')], ['Rules & tests', can('rules.view') || can('rules.create')],
['Activity log', can('logs.view')], ['Permissions', admin() || can('moderators.delegate')], ['Settings', admin() || can('channels.configure')]
] as const;
}
Expand Down Expand Up @@ -286,6 +303,7 @@ const connectOnce = serializeConnection(async () => {
settings = await api<SettingsVersion>('settings');
const first = links().find(([, allowed]) => allowed);
page = first?.[0] ?? 'Overview';
initializeAgentSetup();
await render();
});
async function connect(clientId: string) { connectionClientId = clientId; return connectOnce(); }
Expand All @@ -299,7 +317,7 @@ async function render() {
status = h('div', { class: 'notice', role: 'status' });
status.hidden = true;
const enforcement = settings?.value.enforcement;
const header = h('header', { class: 'topbar' }, h('div', {}, h('p', { class: 'eyebrow' }, 'DISCORD ACTIVITY'), h('strong', {}, meta.name)), h('div', { class: 'actions' }, badge(enforcement ? 'ENFORCEMENT ON' : 'MONITOR ONLY', enforcement ? 'green' : 'amber'), button('Refresh', () => render(), 'quiet'), button('Reconnect', async () => { session = ''; authority = null; settings = null; await start(false); }, 'quiet'), can('enforcement.pause') ? button('Pause enforcement', async () => { await api('pause', 'POST', {}); if (settings)
const header = h('header', { class: 'topbar' }, h('div', {}, h('p', { class: 'eyebrow' }, location.pathname === '/setup' ? 'BROWSER DASHBOARD' : 'DISCORD ACTIVITY'), h('strong', {}, meta.name)), h('div', { class: 'actions' }, badge(enforcement ? 'ENFORCEMENT ON' : 'MONITOR ONLY', enforcement ? 'green' : 'amber'), button('Refresh', () => render(), 'quiet'), button('Log out / reconnect', async () => { clearAgentSetup(); await api('/api/auth/logout', 'POST', {}, true).catch(()=>{}); session = ''; authority = null; settings = null; await start(false); }, 'quiet'), can('enforcement.pause') ? button('Pause enforcement', async () => { await api('pause', 'POST', {}); if (settings)
settings.value.enforcement = false; await render(); message('Automatic enforcement paused. Requests already in flight may still finish.'); }, 'danger') : null));
const content = h('main', { class: 'content' }, h('div', { class: 'page-heading' }, h('div', {}, h('p', { class: 'eyebrow' }, 'CONTROL PANEL'), h('h1', {}, page)), badge('GUILD ' + guild)), status);
if (preview)
Expand All @@ -309,7 +327,8 @@ async function render() {
content.append(loading);
try {
authority = await api<Authority>('me');
if (page === 'Moderation packs')
if (page === 'Agent setup') { const setup=h('section', {class:'form-stack'}); content.append(setup); agentSetup?.mount(setup); }
else if (page === 'Moderation packs')
await packsPage(content);
else if (page === 'Overview')
await overview(content);
Expand Down Expand Up @@ -681,7 +700,21 @@ async function settingsPage(parent: HTMLElement) {
} }, 'danger') : null)));
}
async function start(autoConnect = true) {
clearAgentSetup();
const config = await api('/api/config', 'GET', undefined, true);
browserDashboardUrl=config.dashboardUrl ?? '/setup';
if (location.pathname === '/setup') {
app.replaceChildren();
const server=input(new URLSearchParams(location.search).get('guild') ?? '');
const login=async()=>{const b=await api('/api/auth/browser/start','POST',{guildId:server.value.trim()},true);location.assign(b.authorizeUrl);};
app.append(h('main',{class:'login'},h('h1',{},'Browser-assisted setup'),h('p',{},'Sign in with Discord for this guild. Administrator authority is checked live. WebMCP support is experimental; the same manual planner works without it.'),field('Discord server ID',server),button('Sign in with Discord',login),h('a',{href:'/'},'Activity and installation help')));
try {
const auth=await api('/api/auth/browser/session','POST',{},true);
guild=auth.guildId;session=auth.sessionToken;authority=auth.authority;
meta=await api<Metadata>('meta');settings=await api<SettingsVersion>('settings');page='Agent setup';initializeAgentSetup();await render();
} catch(e) {if (!(e instanceof HttpError && e.status===401))fail(e);}
return;
}
preview = config.preview === true;
app.replaceChildren();
status = h('div', { class: 'notice', role: 'status' });
Expand All @@ -696,6 +729,7 @@ async function start(autoConnect = true) {
button(preview ? 'Open read-only preview' : 'Connect in Discord', () => connect(config.clientId), showInstall ? 'quiet' : 'primary')),
showInstall ? h('p', { class: 'install-help', id: 'install-help' }, 'Opens Discord in a new tab. You need Manage Server permission to install. Then open the TypeCheck Activity in your server; an administrator can choose channels, add a Jev API key, and configure rules. Automatic moderation stays off until approved and enabled.') : null,
status, h('p', { class: 'muted' }, 'Launch from a server Activity. Administrators have access automatically; moderators require an explicit role grant.'),
showInstall ? h('a', { class:'button quiet', href:'/setup' }, 'Browser-assisted setup') : null,
showInstall ? h('p', { class: 'legal-links' }, h('a', { href: '/terms' }, 'Terms of Service'), h('a', { href: '/privacy' }, 'Privacy Policy')) : null));
if (autoConnect && new URLSearchParams(location.search).has('frame_id'))
await connect(config.clientId);
Expand Down
Loading
Loading