feat: add automated community moderation

This commit is contained in:
JB
2026-07-27 16:09:56 -04:00
parent 6b76110b76
commit 27cbeca0ed
24 changed files with 3241 additions and 83 deletions
+1
View File
@@ -3,6 +3,7 @@
OPENAI_MECHANIC_API_KEY= OPENAI_MECHANIC_API_KEY=
OPENAI_MECHANIC_MODEL=gpt-5.6-terra OPENAI_MECHANIC_MODEL=gpt-5.6-terra
OPENAI_MECHANIC_REASONING_EFFORT=medium OPENAI_MECHANIC_REASONING_EFFORT=medium
OPENAI_MODERATION_API_KEY=
# Local path used by the embedded @tursodatabase/database engine. # Local path used by the embedded @tursodatabase/database engine.
# Relative paths are resolved from the project directory. Development and # Relative paths are resolved from the project directory. Development and
+166 -1
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS avatar_reviews (
storage_key TEXT NOT NULL, storage_key TEXT NOT NULL,
byte_size INTEGER NOT NULL, byte_size INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'removed', 'withdrawn', 'superseded')), status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'removed', 'withdrawn', 'superseded')),
review_origin TEXT NOT NULL DEFAULT 'legacy' CHECK(review_origin IN ('automated', 'user_report', 'legacy')),
submitted_at TEXT NOT NULL, submitted_at TEXT NOT NULL,
reviewed_at TEXT, reviewed_at TEXT,
reviewed_by TEXT REFERENCES users(id), reviewed_by TEXT REFERENCES users(id),
@@ -150,7 +151,7 @@ CREATE TABLE IF NOT EXISTS comments (
author_id TEXT NOT NULL REFERENCES users(id), author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id), parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL, body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'visible' CHECK(status IN ('visible', 'removed')), status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
removed_at TEXT, removed_at TEXT,
@@ -159,6 +160,170 @@ CREATE TABLE IF NOT EXISTS comments (
); );
CREATE INDEX IF NOT EXISTS comments_thread_status_created_idx ON comments(thread_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS comments_thread_status_created_idx ON comments(thread_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS comments_author_status_created_idx ON comments(author_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS comments_author_status_created_idx ON comments(author_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS community_moderation_settings (
id TEXT PRIMARY KEY CHECK(id='primary'),
automated_enabled INTEGER NOT NULL DEFAULT 0 CHECK(automated_enabled IN (0,1)),
auto_publish_clean INTEGER NOT NULL DEFAULT 1 CHECK(auto_publish_clean IN (0,1)),
hold_on_provider_error INTEGER NOT NULL DEFAULT 1 CHECK(hold_on_provider_error IN (0,1)),
scan_comments INTEGER NOT NULL DEFAULT 1 CHECK(scan_comments IN (0,1)),
scan_profiles INTEGER NOT NULL DEFAULT 1 CHECK(scan_profiles IN (0,1)),
scan_guides INTEGER NOT NULL DEFAULT 1 CHECK(scan_guides IN (0,1)),
scan_avatars INTEGER NOT NULL DEFAULT 1 CHECK(scan_avatars IN (0,1)),
provider_model TEXT NOT NULL DEFAULT 'omni-moderation-latest' CHECK(length(trim(provider_model))>0),
detect_pii INTEGER NOT NULL DEFAULT 1 CHECK(detect_pii IN (0,1)),
max_links INTEGER NOT NULL DEFAULT 3 CHECK(max_links BETWEEN 0 AND 100),
duplicate_window_seconds INTEGER NOT NULL DEFAULT 300 CHECK(duplicate_window_seconds BETWEEN 0 AND 86400),
policy_version INTEGER NOT NULL DEFAULT 1 CHECK(policy_version>=1),
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
);
INSERT OR IGNORE INTO community_moderation_settings(
id,automated_enabled,auto_publish_clean,hold_on_provider_error,
scan_comments,scan_profiles,scan_guides,scan_avatars,provider_model,detect_pii,
max_links,duplicate_window_seconds,policy_version,updated_at
) VALUES(
'primary',0,1,1,1,1,1,1,'omni-moderation-latest',1,3,300,1,
'2026-07-27T00:00:00.000Z'
);
CREATE TABLE IF NOT EXISTS community_moderation_thresholds (
category TEXT PRIMARY KEY CHECK(length(trim(category))>0),
review_threshold REAL NOT NULL CHECK(review_threshold BETWEEN 0 AND 1),
priority_threshold REAL NOT NULL CHECK(priority_threshold BETWEEN 0 AND 1 AND priority_threshold>=review_threshold),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
);
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('harassment',0.30,0.70,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('harassment/threatening',0.10,0.40,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('hate',0.20,0.60,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('hate/threatening',0.10,0.40,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('illicit',0.30,0.70,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('illicit/violent',0.10,0.40,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('self-harm',0.15,0.50,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('self-harm/intent',0.10,0.35,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('self-harm/instructions',0.10,0.35,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('sexual',0.25,0.65,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('sexual/minors',0.05,0.20,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('violence',0.35,0.75,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO community_moderation_thresholds(category,review_threshold,priority_threshold,enabled,updated_at)
VALUES('violence/graphic',0.20,0.55,1,'2026-07-27T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS community_moderation_terms (
id TEXT PRIMARY KEY,
list_kind TEXT NOT NULL CHECK(list_kind IN ('allow','ban')),
match_mode TEXT NOT NULL CHECK(match_mode IN ('word','phrase')),
value TEXT NOT NULL CHECK(length(trim(value))>0),
normalized_value TEXT NOT NULL CHECK(length(trim(normalized_value))>0),
scopes_json TEXT NOT NULL DEFAULT '["comment","profile_bio","guide"]',
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
note TEXT,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL REFERENCES users(id),
UNIQUE(list_kind,match_mode,normalized_value,scopes_json)
);
CREATE INDEX IF NOT EXISTS community_moderation_terms_enabled_kind_idx
ON community_moderation_terms(enabled,list_kind,normalized_value);
CREATE TABLE IF NOT EXISTS moderated_content_revisions (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('profile_bio','guide')),
content_id TEXT NOT NULL,
submitted_by TEXT NOT NULL REFERENCES users(id),
body_markdown TEXT NOT NULL,
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
created_at TEXT NOT NULL,
UNIQUE(content_type,content_id,content_hash)
);
CREATE INDEX IF NOT EXISTS moderated_content_revisions_content_created_idx
ON moderated_content_revisions(content_type,content_id,created_at DESC);
CREATE TABLE IF NOT EXISTS content_moderation_scans (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
content_id TEXT NOT NULL,
author_id TEXT NOT NULL REFERENCES users(id),
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
revision_id TEXT REFERENCES moderated_content_revisions(id),
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
model TEXT NOT NULL CHECK(length(trim(model))>0),
provider_request_id TEXT,
status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')),
risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')),
reason TEXT NOT NULL CHECK(length(trim(reason))>0),
scores_json TEXT NOT NULL DEFAULT '{}',
flags_json TEXT NOT NULL DEFAULT '[]',
rule_matches_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
completed_at TEXT,
error_code TEXT,
error_message TEXT,
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
CHECK(
(content_type IN ('comment','avatar') AND revision_id IS NULL)
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL)
),
CHECK(
(status='error' AND error_code IS NOT NULL)
OR (status<>'error' AND error_code IS NULL AND error_message IS NULL)
)
);
CREATE INDEX IF NOT EXISTS content_moderation_scans_pending_queue_idx
ON content_moderation_scans(status,created_at)
WHERE status IN ('review','priority_review');
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
ON content_moderation_scans(content_type,content_id,created_at DESC);
CREATE TABLE IF NOT EXISTS content_moderation_reviews (
id TEXT PRIMARY KEY,
scan_id TEXT NOT NULL REFERENCES content_moderation_scans(id),
reviewer_id TEXT NOT NULL REFERENCES users(id),
decision TEXT NOT NULL CHECK(decision IN ('approved','rejected','superseded')),
reason TEXT NOT NULL CHECK(length(trim(reason))>0),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS content_moderation_reviews_scan_created_idx
ON content_moderation_reviews(scan_id,created_at DESC);
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
ON content_moderation_reviews(reviewer_id,created_at DESC);
CREATE TABLE IF NOT EXISTS user_content_reports (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
content_id TEXT NOT NULL,
reporter_id TEXT NOT NULL REFERENCES users(id),
category TEXT NOT NULL CHECK(category IN (
'spam','harassment','hate','sexual','violence','self_harm','privacy','other'
)),
details TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','resolved','dismissed')),
created_at TEXT NOT NULL,
reviewed_at TEXT,
reviewed_by TEXT REFERENCES users(id),
resolution_reason TEXT,
CHECK(
(status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL)
OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL
AND resolution_reason IS NOT NULL AND length(trim(resolution_reason))>0)
)
);
CREATE INDEX IF NOT EXISTS user_content_reports_pending_queue_idx
ON user_content_reports(created_at)
WHERE status='pending';
CREATE INDEX IF NOT EXISTS user_content_reports_content_idx
ON user_content_reports(content_type,content_id,created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
ON user_content_reports(reporter_id,content_type,content_id)
WHERE status='pending';
CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id)); CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id));
CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL); CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1); CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
+124
View File
@@ -0,0 +1,124 @@
# Community content moderation
SHD.build applies one moderation lifecycle to public comments, profile
biographies, custom avatars, guides, and build descriptions. The shared server
service lives in `src/lib/server/community-moderation.ts`. Content save actions
must use that service before publication.
## Publication decisions
The moderation service combines local rules with the configured provider
result:
- Clean content publishes without human review when automatic publication is
enabled.
- Banned terms, PII checks, link limits, provider flags, and configured score
thresholds send content to Community Oversight.
- Provider failures send content to Oversight when fail-closed behavior is
enabled.
- An allow term can suppress the matching local term rule. It cannot override a
provider flag or threshold.
- A user report sends visible content to Oversight. One report does not hide
the content because reports can be mistaken or abusive.
The service records every automated scan in `content_moderation_scans`.
Automated scans do not write to the human ISAC audit log. Human policy changes
and review decisions use the shared audit utility.
## Content storage
Comments use an explicit status:
- `visible`: public and approved
- `pending_review`: hidden while Oversight decides
- `removed`: unavailable after author removal or an Oversight decision
Long-form content uses immutable pending revisions. Profile and guide edits that
need review go into `moderated_content_revisions`; the public row keeps its last
approved text. Approval copies the reviewed revision into the public row.
Rejection preserves the revision and review evidence without publishing it.
Custom avatar uploads remain active after a clean image scan. A flagged image
or fail-closed scan error creates an `avatar_reviews` item and is unavailable
through the public avatar route until approval. An Agent report also creates a
review item, but leaves an already-published avatar visible while Oversight
decides.
## ISAC responsibilities
ISAC Site Controls stores the site-wide policy:
- provider scanning and per-content enablement;
- clean-content and provider-failure behavior;
- provider model;
- PII, link, and duplicate checks;
- category review and priority thresholds;
- allowed and banned local terms.
Site Controls requires `site.settings.manage`. Community Oversight handles text
decisions through the existing comment and profile permissions. Avatar
Oversight handles custom images through `avatars.review`.
Moderators must record a reason when they approve, reject, remove, restore,
dismiss, or resolve a queued item. The action must append an audit event after
the content and review records change.
## User reports
Authenticated Agents can report visible content they do not own. The report
control must identify the content type and stable content ID, validate that the
content remains public, and create a pending `user_content_reports` row.
Duplicate pending reports from the same Agent for the same content are rejected.
A reviewer either dismisses the report and keeps the content or resolves the
report through a moderation action. Report details must not copy authentication
secrets or unnecessary personal information into the audit log.
## Adding a public UGC field
Guide authoring, build descriptions, and later public text fields must complete
this checklist:
1. Normalize and validate the submitted text before scanning.
2. Call `moderateCommunityText` with a supported content type.
3. Place the shared `ModerationDisclosure` beside the submission control.
4. Publish only an `approved` result.
5. Store held long-form text as an immutable revision so the last approved
version remains public.
6. Call `recordCommunityModerationScan` with the stable content ID, author, hash,
policy version, provider evidence, and local-rule matches.
7. Add the content type to the Community Oversight queue and permission checks.
8. Add the shared report control to the public rendering.
9. Delay follower, owner, or reply notifications until the content becomes
public.
10. Add approval, rejection, report dismissal, author notification, and audit
tests.
11. Confirm every public query selects approved content explicitly.
Do not add a route-specific word filter, provider client, threshold table, or
moderation status vocabulary. Extend the shared service and schema when a new
content type needs more evidence or a different review presentation.
## Operations and calibration
The API key stays in `OPENAI_MODERATION_API_KEY`. The server can use
`OPENAI_MECHANIC_API_KEY` as a temporary fallback, but production should use a
separate moderation credential.
Public submission notices link to the Terms & Privacy page instead of
summarizing provider data handling beside each form. The Terms & Privacy page
must not promise that OpenAI never retains or uses moderation inputs. It states
that OpenAI controls downstream processing under its then-current terms, data
controls, and account settings. If SHD.build enables an OpenAI data-sharing
option, update the legal copy before sending user content under that setting.
Threshold changes increment the policy version. Stored scans retain their model
and policy version so reviewers can interpret older decisions. Test threshold
changes against representative Division terminology before activation,
including weapon names, damage descriptions, kills, status effects, and build
strategy language.
Database migrations that add moderation states or revision storage follow the
repository database-preservation procedure: stop this project's development
server, verify the database path, create and verify a JSON backup, migrate,
check foreign keys and critical record counts, then restart and health-check.
+21
View File
@@ -468,6 +468,27 @@ Public biographies and community comments use the shared `MarkdownEditor` and
remaining-character count, concise help, a readable editorial typeface, and a remaining-character count, concise help, a readable editorial typeface, and a
minimum 44px touch target for toolbar actions on narrow screens. It supports minimum 44px touch target for toolbar actions on narrow screens. It supports
headings, lists, quotes, links, emphasis, and inline code. Raw HTML is escaped. headings, lists, quotes, links, emphasis, and inline code. Raw HTML is escaped.
Public user-generated content uses the shared `ContentReport` control when the
viewer is authenticated and does not own the content. Keep the control visually
secondary to reading and reply actions. Its disclosure names the concern,
accepts optional bounded context, and states that a report requests human review
without removing content immediately. On narrow screens, present the report
form as a fixed bottom panel with full-width touch controls.
Submission forms for public user-generated text and custom images use the
shared `ModerationDisclosure` notice. Place it after the input and before
validation or submission controls. The always-visible notice states that
automated safety review may occur and links to the Terms & Privacy section for
the details. Do not hide this disclosure in a hover-only tooltip.
Automated moderation is exception-based. Clean content publishes without an
Oversight task when policy permits. Flagged comments remain hidden with an
explicit pending state; flagged long-form edits leave the last approved version
public. Flagged custom avatars use the default avatar publicly until approval.
Community Oversight shows automated evidence and Agent reports in one pending
queue, but labels their sources separately. Human approval, rejection, removal,
and report dismissal require a reason and confirmation.
Only HTTP and HTTPS links are active; rendered links open separately with UGC, Only HTTP and HTTPS links are active; rendered links open separately with UGC,
nofollow, and opener-isolation attributes. `MarkdownContent` must receive HTML nofollow, and opener-isolation attributes. `MarkdownContent` must receive HTML
from the server renderer, never raw user input or client-rendered Markdown. from the server renderer, never raw user input or client-rendered Markdown.
+18
View File
@@ -82,6 +82,24 @@ explains registration closure. This guard runs on the server, so a crafted form
submission or OAuth callback cannot create an account while registration is submission or OAuth callback cannot create an account while registration is
closed. closed.
## Community content moderation
Comments, profile biographies, custom avatars, guides, and build descriptions
share the moderation lifecycle documented in
[`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local rules and the configured
provider classify each submission before publication. Clean submissions can
publish without human work. Flagged submissions and fail-closed provider errors
enter the appropriate Oversight queue.
Agents may report visible content they do not own. Reports enter Oversight
without hiding the content. Human decisions require a reason and append an
audit event. Long-form edits that need review use immutable pending revisions
so the last approved biography, guide, or description remains public.
ISAC Site Controls owns provider behavior, content scopes, thresholds, link and
PII checks, and allowed or banned terms. API credentials remain in server
environment configuration.
## Increment plan ## Increment plan
1. Product shell and anonymous local-draft builder. 1. Product shell and anonymous local-draft builder.
+153 -1
View File
@@ -171,7 +171,7 @@ if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includ
author_id TEXT NOT NULL REFERENCES users(id), author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id), parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL, body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'visible' CHECK(status IN ('visible', 'removed')), status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
removed_at TEXT, removed_at TEXT,
@@ -196,6 +196,158 @@ if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includ
} }
} }
const normalizedCommentsDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'"
)).get();
if (
typeof normalizedCommentsDefinition?.sql === 'string'
&& !normalizedCommentsDefinition.sql.includes("'pending_review'")
) {
console.log('Adding pre-publication review status to community comments.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('ALTER TABLE comments RENAME TO comments_previous');
await database.exec(`CREATE TABLE comments (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
removed_at TEXT,
removed_by TEXT REFERENCES users(id),
removal_reason TEXT
)`);
await database.exec(`INSERT INTO comments(
id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at,
removed_at,removed_by,removal_reason
)
SELECT id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at,
removed_at,removed_by,removal_reason
FROM comments_previous`);
await database.exec('DROP TABLE comments_previous');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const moderationScansDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='content_moderation_scans'"
)).get();
const moderationScansMissingEvidence = (
typeof moderationScansDefinition?.sql === 'string'
&& (
!moderationScansDefinition.sql.includes('author_id TEXT')
|| !moderationScansDefinition.sql.includes('risk_level TEXT')
|| !moderationScansDefinition.sql.includes('reason TEXT')
)
);
if (
typeof moderationScansDefinition?.sql === 'string'
&& (
!moderationScansDefinition.sql.includes("'avatar'")
|| moderationScansMissingEvidence
)
) {
if (moderationScansMissingEvidence) {
const existingScans = await (
await database.prepare('SELECT COUNT(*) AS count FROM content_moderation_scans')
).get();
if (Number(existingScans?.count) > 0) {
throw new Error(
'Existing moderation scans lack required author, risk, or reason evidence and cannot be backfilled safely.'
);
}
}
console.log('Aligning automated community moderation scan evidence.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE content_moderation_scans_expanded (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
content_id TEXT NOT NULL,
author_id TEXT NOT NULL REFERENCES users(id),
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
revision_id TEXT REFERENCES moderated_content_revisions(id),
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
model TEXT NOT NULL CHECK(length(trim(model))>0),
provider_request_id TEXT,
status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')),
risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')),
reason TEXT NOT NULL CHECK(length(trim(reason))>0),
scores_json TEXT NOT NULL DEFAULT '{}',
flags_json TEXT NOT NULL DEFAULT '[]',
rule_matches_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
completed_at TEXT,
error_code TEXT,
error_message TEXT,
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
CHECK(
(content_type IN ('comment','avatar') AND revision_id IS NULL)
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL)
),
CHECK(
(status='error' AND error_code IS NOT NULL)
OR (status<>'error' AND error_code IS NULL AND error_message IS NULL)
)
)`);
if (!moderationScansMissingEvidence) {
await database.exec(`INSERT INTO content_moderation_scans_expanded(
id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model,
provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json,
created_at,completed_at,error_code,error_message
)
SELECT id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model,
provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json,
created_at,completed_at,error_code,error_message
FROM content_moderation_scans`);
}
await database.exec('DROP TABLE content_moderation_scans');
await database.exec('ALTER TABLE content_moderation_scans_expanded RENAME TO content_moderation_scans');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const communityModerationSettingsColumns = await (
await database.prepare("PRAGMA table_info('community_moderation_settings')")
).all();
if (
communityModerationSettingsColumns.length
&& !communityModerationSettingsColumns.some((column) => column.name === 'scan_avatars')
) {
console.log('Adding avatar scanning to community moderation settings.');
await database.exec(`ALTER TABLE community_moderation_settings
ADD COLUMN scan_avatars INTEGER NOT NULL DEFAULT 1 CHECK(scan_avatars IN (0,1))`);
}
const avatarReviewColumns = await (
await database.prepare("PRAGMA table_info('avatar_reviews')")
).all();
if (
avatarReviewColumns.length
&& !avatarReviewColumns.some((column) => column.name === 'review_origin')
) {
console.log('Adding review origin evidence to avatar oversight.');
await database.exec(`ALTER TABLE avatar_reviews
ADD COLUMN review_origin TEXT NOT NULL DEFAULT 'legacy'
CHECK(review_origin IN ('automated','user_report','legacy'))`);
}
// The project schema deliberately avoids semicolons inside string literals. // The project schema deliberately avoids semicolons inside string literals.
const statements = schema const statements = schema
.split(';') .split(';')
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let {
contentType,
contentId,
label = 'REPORT',
result
}: {
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
contentId: string;
label?: string;
result?: Record<string, unknown> | null;
} = $props();
const categories = [
{ value: 'spam', label: 'SPAM OR PROMOTION' },
{ value: 'harassment', label: 'HARASSMENT OR BULLYING' },
{ value: 'hate', label: 'HATEFUL CONTENT' },
{ value: 'sexual', label: 'SEXUAL CONTENT' },
{ value: 'violence', label: 'VIOLENCE OR THREATS' },
{ value: 'self_harm', label: 'SELF-HARM' },
{ value: 'privacy', label: 'PRIVATE INFORMATION' },
{ value: 'other', label: 'OTHER POLICY CONCERN' }
];
const matchesResult = $derived(
String(result?.userReportContentId ?? '') === contentId
&& String(result?.userReportContentType ?? '') === contentType
);
</script>
<details class="content-report">
<summary>{label}</summary>
<div class="content-report-panel">
<strong>REQUEST OVERSIGHT REVIEW</strong>
<p>Send this content to Community Oversight. A report does not remove it automatically.</p>
<PendingForm action="?/reportContent" toast={false}>
{#snippet children(pending)}
<input type="hidden" name="contentType" value={contentType}/>
<input type="hidden" name="contentId" value={contentId}/>
<label class="field">CONCERN
<select name="category" required>
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
</select>
</label>
<label class="field">DETAILS — OPTIONAL
<textarea name="details" maxlength="500" rows="3" placeholder="Give Oversight useful context without repeating sensitive information."></textarea>
</label>
{#if matchesResult && result?.userReportError}<p class="error">{String(result.userReportError)}</p>{/if}
{#if matchesResult && result?.userReportSaved}<p class="success">Report sent to Community Oversight.</p>{/if}
<div class="content-report-actions">
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SENDING…' : 'SEND REPORT →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</details>
<style>
.content-report{position:relative}.content-report summary{list-style:none;min-height:32px;display:inline-flex;align-items:center;padding:0 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.content-report summary::-webkit-details-marker{display:none}.content-report summary:hover,.content-report summary:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}.content-report[open] summary{border-color:var(--shd-orange);color:var(--shd-orange)}.content-report-panel{position:absolute;z-index:8;right:0;width:min(380px,calc(100vw - 32px));display:grid;gap:8px;padding:10px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:0 18px 46px #000a}.content-report-panel>strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.content-report-panel>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.content-report-panel :global(form fieldset){display:grid;gap:8px}.content-report-panel :global(.field){margin:0}.content-report-panel textarea{min-height:80px}.content-report-actions{display:flex;justify-content:flex-end}.content-report-actions button{min-height:40px}
@media(max-width:650px){.content-report-panel{position:fixed;right:16px;bottom:16px;left:16px;width:auto}.content-report summary{min-height:44px}.content-report-actions button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.content-report summary{transition:none}}
</style>
@@ -0,0 +1,17 @@
<aside class="moderation-disclosure" aria-label="Automated safety review">
<span aria-hidden="true">i</span>
<p>
This submission may receive automated safety review.
<a href="/terms#privacy">TERMS & PRIVACY →</a>
</p>
</aside>
<style>
.moderation-disclosure{display:flex;align-items:flex-start;gap:8px;margin:8px 0;padding:8px 9px;border:1px solid var(--shd-border-soft);background:var(--shd-surface);color:var(--shd-text-muted)}
.moderation-disclosure>span{flex:0 0 20px;display:grid;place-items:center;width:20px;height:20px;border:1px solid var(--shd-border-strong);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
.moderation-disclosure p{margin:0;font-size:var(--shd-type-telemetry);line-height:1.5}
.moderation-disclosure a{display:inline-block;margin-left:4px;color:var(--shd-orange);font-weight:900;letter-spacing:.4px}
.moderation-disclosure a:hover{text-decoration:underline}
.moderation-disclosure a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}
@media(max-width:650px){.moderation-disclosure{padding:10px}.moderation-disclosure a{display:block;min-height:44px;margin:4px 0 0;padding-top:8px}}
</style>
+47 -5
View File
@@ -49,7 +49,12 @@ export async function processAvatarUpload(file: File): Promise<Buffer> {
} }
} }
export async function saveUploadedAvatar(database: Database, userId: string, output: Buffer): Promise<string> { export async function saveUploadedAvatar(
database: Database,
userId: string,
output: Buffer,
options: { queueReview: boolean } = { queueReview: true }
): Promise<{ uploadedAt: string; reviewId: string | null }> {
const key = storageKey(userId); const key = storageKey(userId);
const directory = avatarStorageDirectory(); const directory = avatarStorageDirectory();
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true });
@@ -61,18 +66,55 @@ export async function saveUploadedAvatar(database: Database, userId: string, out
.run(userId, 'upload', key, output.length, now); .run(userId, 'upload', key, output.length, now);
await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'")) await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId); .run(now, userId);
let reviewId: string | null = null;
if (options.queueReview) {
reviewId = await queueCurrentAvatarReview(
database,
userId,
{
origin: 'automated',
summary: 'Automated safety checks flagged a custom avatar for Oversight review.'
}
);
}
return { uploadedAt: now, reviewId };
}
export async function queueCurrentAvatarReview(
database: Database,
userId: string,
options: {
origin: 'automated' | 'user_report' | 'legacy';
summary?: string;
} = { origin: 'user_report' }
): Promise<string | null> {
const avatar = await (await database.prepare(`SELECT source,value,byte_size,updated_at
FROM user_avatars WHERE user_id=?`)).get(userId);
if (avatar?.source !== 'upload' || typeof avatar.value !== 'string') return null;
const existing = await (await database.prepare(
"SELECT id FROM avatar_reviews WHERE user_id=? AND avatar_updated_at=? AND status='pending'"
)).get(userId, avatar.updated_at);
if (existing?.id) return String(existing.id);
const reviewId = randomUUID(); const reviewId = randomUUID();
await (await database.prepare(`INSERT INTO avatar_reviews await (await database.prepare(`INSERT INTO avatar_reviews
(id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at) (id,user_id,avatar_updated_at,storage_key,byte_size,status,review_origin,submitted_at)
VALUES(?,?,?,?,?,'pending',?)`)).run(reviewId, userId, now, key, output.length, now); VALUES(?,?,?,?,?,'pending',?,?)`)).run(
reviewId,
userId,
avatar.updated_at,
avatar.value,
avatar.byte_size,
options.origin,
new Date().toISOString()
);
await notifyOversightReview({ await notifyOversightReview({
category: 'avatars', category: 'avatars',
itemId: reviewId, itemId: reviewId,
title: 'Avatar awaiting review', title: 'Avatar awaiting review',
summary: 'A new custom agent avatar was uploaded and is ready for Oversight review.', summary: options.summary ?? 'An Agent report requested Oversight review of a custom avatar.',
link: '/isac/avatar-oversight?status=pending' link: '/isac/avatar-oversight?status=pending'
}).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error)); }).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error));
return now; return reviewId;
} }
export async function selectPresetAvatar(database: Database, userId: string, preset: string): Promise<void> { export async function selectPresetAvatar(database: Database, userId: string, preset: string): Promise<void> {
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
assessCommunityModeration,
findLocalModerationMatches,
normalizeModerationTerm,
type CommunityModerationPolicy
} from './community-moderation';
const policy = (overrides: Partial<CommunityModerationPolicy> = {}): CommunityModerationPolicy => ({
automatedEnabled: true,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 1,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [{
category: 'harassment',
enabled: true,
reviewThreshold: 0.5,
priorityThreshold: 0.85
}],
terms: [],
...overrides
});
describe('community moderation policy', () => {
it('normalizes configured terms consistently', () => {
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
});
it('allows an exact local exception to suppress a matching banned term', () => {
const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({
detectPii: false,
terms: [
{ id: 'ban', listType: 'ban', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true },
{ id: 'allow', listType: 'allow', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true }
]
}));
expect(matches).toEqual([]);
});
it('finds scoped banned terms, PII, and excessive links', () => {
const matches = findLocalModerationMatches(
'Contact test@example.com about forbidden plans at https://one.test and https://two.test',
'comment',
policy({
terms: [{
id: 'ban',
listType: 'ban',
matchMode: 'word',
value: 'forbidden',
normalizedValue: 'forbidden',
scopes: ['comment'],
enabled: true
}]
})
);
expect(matches.map((match) => match.kind)).toEqual(['banned_term', 'pii', 'link_limit']);
});
it('routes priority scores ahead of ordinary review scores', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: true,
categoryScores: { harassment: 0.9 },
ruleMatches: []
}).decision).toBe('priority_review');
});
it('holds provider failures under the default fail-closed policy', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'profile_bio',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: 'timeout'
}).decision).toBe('error');
});
it('publishes clean content only when auto publication is enabled', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('approved');
expect(assessCommunityModeration({
policy: policy({ autoPublishClean: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('review');
});
it('publishes locally clean content when provider scanning is disabled', () => {
expect(assessCommunityModeration({
policy: policy({ automatedEnabled: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: {},
ruleMatches: []
}).decision).toBe('approved');
});
});
+611
View File
@@ -0,0 +1,611 @@
import { env } from '$env/dynamic/private';
import { getDatabase } from '$lib/server/database';
import { createHash, randomUUID } from 'node:crypto';
import OpenAI from 'openai';
export const MODERATION_CATEGORIES = [
'harassment',
'harassment/threatening',
'hate',
'hate/threatening',
'illicit',
'illicit/violent',
'self-harm',
'self-harm/intent',
'self-harm/instructions',
'sexual',
'sexual/minors',
'violence',
'violence/graphic'
] as const;
export type ModerationCategory = (typeof MODERATION_CATEGORIES)[number];
export type ModeratedContentType = 'comment' | 'profile_bio' | 'guide' | 'avatar';
export type ModerationDecision = 'approved' | 'review' | 'priority_review' | 'error';
export type ModerationThreshold = {
category: ModerationCategory;
enabled: boolean;
reviewThreshold: number;
priorityThreshold: number;
};
export type ModerationTerm = {
id: string;
listType: 'allow' | 'ban';
matchMode: 'word' | 'phrase';
value: string;
normalizedValue: string;
scopes: ModeratedContentType[];
enabled: boolean;
};
export type CommunityModerationPolicy = {
automatedEnabled: boolean;
autoPublishClean: boolean;
holdOnProviderError: boolean;
scanComments: boolean;
scanProfiles: boolean;
scanGuides: boolean;
scanAvatars: boolean;
providerModel: 'omni-moderation-latest' | 'omni-moderation-2024-09-26';
detectPii: boolean;
maxLinks: number;
duplicateWindowSeconds: number;
policyVersion: number;
thresholds: ModerationThreshold[];
terms: ModerationTerm[];
};
export type LocalModerationMatch = {
kind: 'banned_term' | 'pii' | 'link_limit';
label: string;
excerpt: string;
termId?: string;
};
export type CommunityModerationResult = {
contentHash: string;
decision: ModerationDecision;
riskLevel: 'low' | 'medium' | 'high';
reason: string;
policyVersion: number;
provider: 'openai' | 'local';
model: string;
requestId: string | null;
categoryScores: Partial<Record<ModerationCategory, number>>;
categoryFlags: Partial<Record<ModerationCategory, boolean>>;
ruleMatches: LocalModerationMatch[];
error: string | null;
};
const DEFAULT_POLICY: CommunityModerationPolicy = {
automatedEnabled: false,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 3,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [],
terms: []
};
let cachedPolicy: { value: CommunityModerationPolicy; expiresAt: number } | null = null;
let client: OpenAI | null = null;
export function invalidateCommunityModerationPolicy() {
cachedPolicy = null;
}
export function normalizeModerationTerm(value: string) {
return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
}
export function hashModeratedContent(value: string) {
return createHash('sha256').update(value.normalize('NFC')).digest('hex');
}
function parseScopes(value: unknown): ModeratedContentType[] {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) return [];
return parsed.filter((scope): scope is ModeratedContentType =>
scope === 'comment' || scope === 'profile_bio' || scope === 'guide' || scope === 'avatar'
);
} catch {
return [];
}
}
function boundedNumber(value: unknown, fallback: number, min: number, max: number) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
export async function getCommunityModerationPolicy(): Promise<CommunityModerationPolicy> {
if (cachedPolicy && cachedPolicy.expiresAt > Date.now()) return cachedPolicy.value;
const database = await getDatabase();
const [settings, thresholdRows, termRows] = await Promise.all([
(await database.prepare("SELECT * FROM community_moderation_settings WHERE id='primary'")).get(),
(await database.prepare('SELECT * FROM community_moderation_thresholds ORDER BY category')).all(),
(await database.prepare('SELECT * FROM community_moderation_terms WHERE enabled=1 ORDER BY list_kind,value')).all()
]);
const model = String(settings?.provider_model ?? DEFAULT_POLICY.providerModel);
const value: CommunityModerationPolicy = {
automatedEnabled: Boolean(settings?.automated_enabled),
autoPublishClean: settings?.auto_publish_clean === undefined
? DEFAULT_POLICY.autoPublishClean
: Boolean(settings.auto_publish_clean),
holdOnProviderError: settings?.hold_on_provider_error === undefined
? DEFAULT_POLICY.holdOnProviderError
: Boolean(settings.hold_on_provider_error),
scanComments: settings?.scan_comments === undefined ? true : Boolean(settings.scan_comments),
scanProfiles: settings?.scan_profiles === undefined ? true : Boolean(settings.scan_profiles),
scanGuides: settings?.scan_guides === undefined ? true : Boolean(settings.scan_guides),
scanAvatars: settings?.scan_avatars === undefined ? true : Boolean(settings.scan_avatars),
providerModel: model === 'omni-moderation-2024-09-26'
? model
: 'omni-moderation-latest',
detectPii: settings?.detect_pii === undefined ? true : Boolean(settings.detect_pii),
maxLinks: boundedNumber(settings?.max_links, DEFAULT_POLICY.maxLinks, 0, 20),
duplicateWindowSeconds: boundedNumber(
settings?.duplicate_window_seconds,
DEFAULT_POLICY.duplicateWindowSeconds,
0,
3_600
),
policyVersion: Math.max(1, Number(settings?.policy_version ?? 1)),
thresholds: thresholdRows.flatMap((row) => {
const category = String(row.category);
if (!MODERATION_CATEGORIES.includes(category as ModerationCategory)) return [];
return [{
category: category as ModerationCategory,
enabled: Boolean(row.enabled),
reviewThreshold: boundedNumber(row.review_threshold, 0.5, 0, 1),
priorityThreshold: boundedNumber(row.priority_threshold, 0.85, 0, 1)
}];
}),
terms: termRows.flatMap((row) => {
const listType = String(row.list_kind);
const matchMode = String(row.match_mode);
if (!['allow', 'ban'].includes(listType) || !['word', 'phrase'].includes(matchMode)) return [];
return [{
id: String(row.id),
listType: listType as 'allow' | 'ban',
matchMode: matchMode as 'word' | 'phrase',
value: String(row.value),
normalizedValue: String(row.normalized_value),
scopes: parseScopes(row.scopes_json),
enabled: Boolean(row.enabled)
}];
})
};
cachedPolicy = { value, expiresAt: Date.now() + 30_000 };
return value;
}
function scopeEnabled(policy: CommunityModerationPolicy, contentType: ModeratedContentType) {
if (contentType === 'comment') return policy.scanComments;
if (contentType === 'profile_bio') return policy.scanProfiles;
if (contentType === 'guide') return policy.scanGuides;
return policy.scanAvatars;
}
function wordPattern(value: string) {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}(?=$|[^\\p{L}\\p{N}_])`, 'iu');
}
function matchesTerm(text: string, term: ModerationTerm) {
return term.matchMode === 'word'
? wordPattern(term.normalizedValue).test(text)
: text.includes(term.normalizedValue);
}
function excerpt(value: string, limit = 80) {
const compact = value.replace(/\s+/g, ' ').trim();
return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
}
const piiPatterns = [
{ label: 'EMAIL ADDRESS', pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/iu },
{ label: 'PHONE NUMBER', pattern: /(?:\+?\d[\d\s().-]{7,}\d)/u },
{ label: 'IP ADDRESS', pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/u }
] as const;
const linkPattern = /\bhttps?:\/\/[^\s<>()]+/giu;
export function findLocalModerationMatches(
text: string,
contentType: ModeratedContentType,
policy: Pick<CommunityModerationPolicy, 'terms' | 'detectPii' | 'maxLinks'>
): LocalModerationMatch[] {
const normalized = normalizeModerationTerm(text);
const scoped = policy.terms.filter((term) =>
term.enabled && (term.scopes.length === 0 || term.scopes.includes(contentType))
);
const allowKeys = new Set(scoped.filter((term) => term.listType === 'allow' && matchesTerm(normalized, term))
.map((term) => `${term.matchMode}:${term.normalizedValue}`));
const matches: LocalModerationMatch[] = scoped
.filter((term) =>
term.listType === 'ban'
&& !allowKeys.has(`${term.matchMode}:${term.normalizedValue}`)
&& matchesTerm(normalized, term)
)
.map((term) => ({
kind: 'banned_term',
label: `BANNED ${term.matchMode.toLocaleUpperCase()}`,
excerpt: excerpt(term.value),
termId: term.id
}));
if (policy.detectPii) {
for (const definition of piiPatterns) {
const match = text.match(definition.pattern);
if (match?.[0]) matches.push({
kind: 'pii',
label: definition.label,
excerpt: excerpt(match[0])
});
}
}
const links = [...text.matchAll(linkPattern)];
if (links.length > policy.maxLinks) matches.push({
kind: 'link_limit',
label: 'LINK LIMIT',
excerpt: `${links.length} links submitted; policy allows ${policy.maxLinks}`
});
return matches;
}
export function assessCommunityModeration(input: {
policy: CommunityModerationPolicy;
contentType: ModeratedContentType;
providerFlagged: boolean;
categoryScores: Partial<Record<ModerationCategory, number>>;
categoryFlags?: Partial<Record<ModerationCategory, boolean>>;
ruleMatches: LocalModerationMatch[];
providerError?: string | null;
}): Pick<CommunityModerationResult, 'decision' | 'riskLevel' | 'reason'> {
if (!input.policy.automatedEnabled || !scopeEnabled(input.policy, input.contentType)) {
if (input.ruleMatches.some((match) => match.kind === 'banned_term')) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A configured banned term requires priority review.' };
}
if (input.ruleMatches.length) {
return { decision: 'review', riskLevel: 'medium', reason: 'A deterministic safety rule requires review.' };
}
return { decision: 'approved', riskLevel: 'low', reason: 'Automated provider scanning is disabled and no local rule was triggered.' };
}
if (input.providerError) {
return input.policy.holdOnProviderError
? { decision: 'error', riskLevel: 'medium', reason: 'The moderation provider could not classify this content.' }
: { decision: 'approved', riskLevel: 'low', reason: 'Provider errors are configured to allow publication.' };
}
if (input.ruleMatches.some((match) => match.kind === 'banned_term')) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A configured banned term requires priority review.' };
}
if (input.ruleMatches.length) {
return { decision: 'review', riskLevel: 'medium', reason: 'A deterministic safety rule requires review.' };
}
const enabledThresholds = input.policy.thresholds.filter((threshold) => threshold.enabled);
if (enabledThresholds.some((threshold) =>
Number(input.categoryScores[threshold.category] ?? 0) >= threshold.priorityThreshold
)) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A provider category exceeded its priority threshold.' };
}
if (input.providerFlagged || enabledThresholds.some((threshold) =>
Number(input.categoryScores[threshold.category] ?? 0) >= threshold.reviewThreshold
)) {
return { decision: 'review', riskLevel: 'medium', reason: 'The moderation provider flagged this content for review.' };
}
return input.policy.autoPublishClean
? { decision: 'approved', riskLevel: 'low', reason: 'No configured moderation rule was triggered.' }
: { decision: 'review', riskLevel: 'medium', reason: 'Clean content requires human approval under the current policy.' };
}
function moderationClient() {
const apiKey = env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey) return null;
if (!client) client = new OpenAI({ apiKey, timeout: 15_000, maxRetries: 1 });
return client;
}
export async function moderateCommunityText(input: {
contentType: ModeratedContentType;
text: string;
policy?: CommunityModerationPolicy;
}): Promise<CommunityModerationResult> {
const policy = input.policy ?? await getCommunityModerationPolicy();
const contentHash = hashModeratedContent(input.text);
const ruleMatches = findLocalModerationMatches(input.text, input.contentType, policy);
if (!policy.automatedEnabled || !scopeEnabled(policy, input.contentType)) {
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'local',
model: 'local-rules-v1',
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error: null
};
}
const openai = moderationClient();
if (!openai) {
const error = 'OpenAI moderation is not configured.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches,
providerError: error
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error
};
}
try {
const response = await openai.moderations.create({
model: policy.providerModel,
input: input.text
});
const result = response.results[0];
const scores = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Number(result?.category_scores[category] ?? 0)
])) as Record<ModerationCategory, number>;
const flags = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Boolean(result?.categories[category])
])) as Record<ModerationCategory, boolean>;
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: Boolean(result?.flagged),
categoryScores: scores,
categoryFlags: flags,
ruleMatches
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: String(response.model),
requestId: String((response as typeof response & { _request_id?: string })._request_id ?? response.id),
categoryScores: scores,
categoryFlags: flags,
ruleMatches,
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Moderation provider request failed.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches,
providerError: message
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error: message
};
}
}
export async function moderateCommunityImage(input: {
contentType: 'avatar';
image: Buffer;
policy?: CommunityModerationPolicy;
}): Promise<CommunityModerationResult> {
const policy = input.policy ?? await getCommunityModerationPolicy();
const contentHash = createHash('sha256').update(input.image).digest('hex');
if (!policy.automatedEnabled || !scopeEnabled(policy, input.contentType)) {
return {
contentHash,
decision: 'approved',
riskLevel: 'low',
reason: 'Automated image scanning is disabled.',
policyVersion: policy.policyVersion,
provider: 'local',
model: 'local-rules-v1',
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error: null
};
}
const openai = moderationClient();
if (!openai) {
const error = 'OpenAI moderation is not configured.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: error
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error
};
}
try {
const response = await openai.moderations.create({
model: policy.providerModel,
input: [{
type: 'image_url',
image_url: { url: `data:image/webp;base64,${input.image.toString('base64')}` }
}]
});
const result = response.results[0];
const scores = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Number(result?.category_scores[category] ?? 0)
])) as Record<ModerationCategory, number>;
const flags = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Boolean(result?.categories[category])
])) as Record<ModerationCategory, boolean>;
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: Boolean(result?.flagged),
categoryScores: scores,
categoryFlags: flags,
ruleMatches: []
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: String(response.model),
requestId: String((response as typeof response & { _request_id?: string })._request_id ?? response.id),
categoryScores: scores,
categoryFlags: flags,
ruleMatches: [],
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Moderation provider request failed.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: message
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error: message
};
}
}
export async function recordCommunityModerationScan(input: {
contentType: ModeratedContentType;
contentId: string;
revisionId?: string | null;
authorId: string;
result: CommunityModerationResult;
}) {
const database = await getDatabase();
const id = randomUUID();
const now = new Date().toISOString();
const result = await (await database.prepare(`INSERT OR IGNORE INTO content_moderation_scans
(id,content_type,content_id,revision_id,author_id,content_hash,policy_version,
provider,model,provider_request_id,status,risk_level,reason,scores_json,
flags_json,rule_matches_json,error_code,error_message,created_at,completed_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id,
input.contentType,
input.contentId,
input.revisionId ?? null,
input.authorId,
input.result.contentHash,
input.result.policyVersion,
input.result.provider,
input.result.model,
input.result.requestId,
input.result.decision,
input.result.riskLevel,
input.result.reason,
JSON.stringify(input.result.categoryScores),
JSON.stringify(input.result.categoryFlags),
JSON.stringify(input.result.ruleMatches),
input.result.error ? 'provider_error' : null,
input.result.error,
now,
now
);
if (result.changes) return id;
const existing = await (await database.prepare(`SELECT id FROM content_moderation_scans
WHERE content_type=? AND content_id=? AND content_hash=? AND policy_version=?
AND provider=? AND model=?`)).get(
input.contentType,
input.contentId,
input.result.contentHash,
input.result.policyVersion,
input.result.provider,
input.result.model
);
return String(existing?.id ?? id);
}
export async function createUserContentReport(input: {
contentType: ModeratedContentType;
contentId: string;
reporterId: string;
category: 'spam' | 'harassment' | 'hate' | 'sexual' | 'violence' | 'self_harm' | 'privacy' | 'other';
details?: string | null;
}) {
const database = await getDatabase();
const id = randomUUID();
const createdAt = new Date().toISOString();
const result = await (await database.prepare(`INSERT OR IGNORE INTO user_content_reports
(id,content_type,content_id,reporter_id,category,details,status,created_at)
VALUES(?,?,?,?,?,?,'pending',?)`)).run(
id,
input.contentType,
input.contentId,
input.reporterId,
input.category,
input.details?.trim() || null,
createdAt
);
return result.changes ? { id, createdAt } : null;
}
+69 -14
View File
@@ -11,12 +11,17 @@ import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/se
import { recordIsacAudit } from '$lib/server/audit'; import { recordIsacAudit } from '$lib/server/audit';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown'; import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import {
moderateCommunityImage,
moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies); const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in'); if (!user) redirect(303, '/sign-in');
const database = await getDatabase(); const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile] = await Promise.all([ const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile, pendingBioRevision] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id), (await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id), (await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id), (await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -38,7 +43,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=?`)).get(user.id) WHERE profile.user_id=?`)).get(user.id),
(await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.status,scan.reason
FROM moderated_content_revisions revision
JOIN content_moderation_scans scan ON scan.revision_id=revision.id
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
WHERE revision.content_type='profile_bio' AND revision.content_id=?
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
ORDER BY revision.created_at DESC LIMIT 1`)).get(user.id)
]); ]);
const oauthErrorCode = url.searchParams.get('oauth_error'); const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = { const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -64,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return { return {
user: { user: {
...user, ...user,
bioMarkdown: String(profile?.bio_markdown ?? ''), bioMarkdown: String(pendingBioRevision?.body_markdown ?? profile?.bio_markdown ?? ''),
profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public) profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
}, },
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) }, account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
@@ -96,6 +108,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
isPublic: Boolean(profile?.is_public), isPublic: Boolean(profile?.is_public),
bioHidden: Boolean(profile?.bio_hidden), bioHidden: Boolean(profile?.bio_hidden),
moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null, moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
bioReviewPending: Boolean(pendingBioRevision),
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null, bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}` href: `/agents/${encodeURIComponent(user.id)}`
} }
@@ -116,16 +131,37 @@ export const actions: Actions = {
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
const database = await getDatabase(); const database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at) const candidateRevisionId = randomUUID();
VALUES(?,?,?,1,?,?) await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
ON CONFLICT(user_id) DO UPDATE SET (id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
bio_markdown=excluded.bio_markdown, VALUES(?,'profile_bio',?,?,?,?,?)`))
bio_updated_at=excluded.bio_updated_at, .run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
updated_at=excluded.updated_at`)) const storedRevision = await (await database.prepare(`SELECT id
.run(user.id, user.displayName, bio, now, now); FROM moderated_content_revisions
WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
.get(user.id, moderation.contentHash);
const revisionId = String(storedRevision?.id ?? candidateRevisionId);
if (moderation.decision === 'approved') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,1,?,?)
ON CONFLICT(user_id) DO UPDATE SET
bio_markdown=excluded.bio_markdown,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`))
.run(user.id, user.displayName, bio, now, now);
}
await recordCommunityModerationScan({
contentType: 'profile_bio',
contentId: user.id,
revisionId,
authorId: user.id,
result: moderation
});
return { return {
bioSaved: true, bioSaved: true,
bioReviewQueued: moderation.decision !== 'approved',
bioMarkdown: bio, bioMarkdown: bio,
bioResultToken: randomUUID() bioResultToken: randomUUID()
}; };
@@ -227,10 +263,29 @@ export const actions: Actions = {
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' }); if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
try { try {
const output = await processAvatarUpload(file); const output = await processAvatarUpload(file);
const moderation = await moderateCommunityImage({ contentType: 'avatar', image: output });
const database = await getDatabase(); const database = await getDatabase();
const uploadedAt = await saveUploadedAvatar(database, user.id, output); const queueReview = moderation.decision !== 'approved';
await recordIsacAudit({ actorId: user.id, category: 'user_moderation', action: 'avatar.submitted', entityId: user.id, after: { status: 'pending', uploadedAt, byteSize: output.length } }); const { uploadedAt, reviewId } = await saveUploadedAvatar(database, user.id, output, { queueReview });
return { avatarChanged: true, avatarReviewQueued: true }; await recordCommunityModerationScan({
contentType: 'avatar',
contentId: user.id,
authorId: user.id,
result: moderation
});
await recordIsacAudit({
actorId: user.id,
category: 'user_moderation',
action: 'avatar.submitted',
entityId: user.id,
after: {
status: queueReview ? 'pending_review' : 'automatically_approved',
uploadedAt,
byteSize: output.length,
reviewId
}
});
return { avatarChanged: true, avatarReviewQueued: queueReview };
} catch (error) { } catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message }); if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error; throw error;
+15 -4
View File
@@ -3,6 +3,7 @@
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte'; import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte'; import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -60,8 +61,13 @@
if (!token || token === handledBioToken) return; if (!token || token === handledBioToken) return;
handledBioToken = token; handledBioToken = token;
if (form?.bioSaved) { if (form?.bioSaved) {
bioDraft = String(user.bioMarkdown ?? bioDraft); bioDraft = String(form.bioMarkdown ?? user.bioMarkdown ?? bioDraft);
toast.success('Profile bio saved', 'Your public Agent profile has been updated.'); toast.success(
form?.bioReviewQueued ? 'Profile bio received' : 'Profile bio saved',
form?.bioReviewQueued
? 'Automated checks sent this revision to Oversight. Your last approved bio remains public.'
: 'Your public Agent profile has been updated.'
);
} else if (form?.bioError) { } else if (form?.bioError) {
toast.error('Profile bio not saved', String(form.bioError)); toast.error('Profile bio not saved', String(form.bioError));
} }
@@ -154,8 +160,9 @@
<p class="eyebrow">CUSTOM AVATAR</p> <p class="eyebrow">CUSTOM AVATAR</p>
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/> <ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small> <small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
<ModerationDisclosure/>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if} {#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar active now · queued for Oversight review.' : 'Avatar updated.'}</p>{/if} {#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar received and held for Oversight review. The default portrait remains public.' : 'Avatar updated.'}</p>{/if}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button> <button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm> </PendingForm>
{/if} {/if}
@@ -167,6 +174,9 @@
{#if data.profile.bioHidden} {#if data.profile.bioHidden}
<div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div> <div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div>
{/if} {/if}
{#if data.profile.bioReviewPending}
<div class="account-alert" role="status"><b>BIO REVISION AWAITING REVIEW</b><span>{data.profile.bioReviewReason ?? 'Automated safety checks requested human review.'} Your last approved biography remains public while Oversight reviews this revision.</span></div>
{/if}
<PendingForm action="?/saveBio"> <PendingForm action="?/saveBio">
<MarkdownEditor <MarkdownEditor
name="bio" name="bio"
@@ -176,8 +186,9 @@
rows={10} rows={10}
bind:value={bioDraft} bind:value={bioDraft}
/> />
<ModerationDisclosure/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if} {#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
{#if form?.bioSaved}<p class="success">Public bio updated.</p>{/if} {#if form?.bioSaved}<p class="success">{form?.bioReviewQueued ? 'Bio revision queued for Oversight review.' : 'Public bio updated.'}</p>{/if}
<button class="primary console-action">SAVE PUBLIC BIO →</button> <button class="primary console-action">SAVE PUBLIC BIO →</button>
</PendingForm> </PendingForm>
<div class="profile-visibility-control"> <div class="profile-visibility-control">
+96 -7
View File
@@ -3,13 +3,19 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown'; import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications'; import { createNotification } from '$lib/server/notifications';
import {
getCommunityModerationPolicy,
moderateCommunityText,
createUserContentReport,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000; const COMMENT_MAX_CHARACTERS = 2_000;
const RAPID_COMMENT_SECONDS = 10; const RAPID_COMMENT_SECONDS = 10;
const DUPLICATE_COMMENT_SECONDS = 300;
const unavailable = (row: Record<string, unknown> | null | undefined) => { const unavailable = (row: Record<string, unknown> | null | undefined) => {
const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date(); const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date();
@@ -163,11 +169,13 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
comment.avatar_updated_at comment.avatar_updated_at
) )
}, },
canDelete: viewer?.id === String(comment.author_id) canDelete: viewer?.id === String(comment.author_id),
canReport: Boolean(viewer) && viewer?.id !== String(comment.author_id)
})), })),
viewer: { viewer: {
id: viewer?.id ?? null, id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public), canComment: Boolean(viewer) && Boolean(agent.is_public),
canReportProfile: Boolean(viewer) && !isSelf,
isSelf isSelf
} }
}; };
@@ -187,6 +195,7 @@ export const actions: Actions = {
}); });
} }
const database = await getDatabase(); const database = await getDatabase();
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date(); const now = new Date();
const rows = await (await database.prepare(`SELECT users.id, const rows = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,profile.is_public, COALESCE(profile.display_name,users.username) AS display_name,profile.is_public,
@@ -207,7 +216,7 @@ export const actions: Actions = {
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status='visible' WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
AND thread.target_type='profile' AND thread.target_id=? AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id); ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
if (typeof recent?.created_at === 'string') { if (typeof recent?.created_at === 'string') {
@@ -215,11 +224,17 @@ export const actions: Actions = {
if (elapsed < RAPID_COMMENT_SECONDS) { if (elapsed < RAPID_COMMENT_SECONDS) {
return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' }); return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
} }
if (elapsed < DUPLICATE_COMMENT_SECONDS && recent.body_markdown === body) { if (elapsed < moderationPolicy.duplicateWindowSeconds && recent.body_markdown === body) {
return fail(409, { profileCommentError: 'That comment was already posted.' }); return fail(409, { profileCommentError: 'That comment was already posted.' });
} }
} }
const moderation = await moderateCommunityText({
contentType: 'comment',
text: body,
policy: moderationPolicy
});
const commentStatus = moderation.decision === 'approved' ? 'visible' : 'pending_review';
const createdAt = now.toISOString(); const createdAt = now.toISOString();
const proposedThreadId = randomUUID(); const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
@@ -232,9 +247,15 @@ export const actions: Actions = {
const commentId = randomUUID(); const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at) (id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,NULL,?,'visible',?,?)`)) VALUES(?,?,?,NULL,?,?,?,?)`))
.run(commentId, thread.id, commenter.id, body, createdAt, createdAt); .run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
if (commenter.id !== params.id) { await recordCommunityModerationScan({
contentType: 'comment',
contentId: commentId,
authorId: commenter.id,
result: moderation
});
if (commentStatus === 'visible' && commenter.id !== params.id) {
await createNotification({ await createNotification({
userId: params.id, userId: params.id,
category: 'comments', category: 'comments',
@@ -250,6 +271,7 @@ export const actions: Actions = {
} }
return { return {
profileCommentSaved: true, profileCommentSaved: true,
profileCommentReviewQueued: commentStatus === 'pending_review',
profileCommentId: commentId, profileCommentId: commentId,
profileCommentResultToken: randomUUID() profileCommentResultToken: randomUUID()
}; };
@@ -278,5 +300,72 @@ export const actions: Actions = {
profileCommentId: commentId, profileCommentId: commentId,
profileCommentResultToken: randomUUID() profileCommentResultToken: randomUUID()
}; };
},
reportContent: async ({ params, cookies, request }) => {
const reporter = await getSessionUser(cookies);
const form = await request.formData();
const contentType = String(form.get('contentType') ?? '');
const contentId = String(form.get('contentId') ?? '').trim();
const category = String(form.get('category') ?? '');
const details = String(form.get('details') ?? '').normalize('NFKC').trim();
const failure = (status: number, message: string) => fail(status, {
userReportError: message,
userReportContentId: contentId,
userReportContentType: contentType,
userReportResultToken: randomUUID()
});
if (!reporter) return failure(401, 'Sign in to request an Oversight review.');
if (!['comment', 'profile_bio', 'avatar'].includes(contentType)) {
return failure(400, 'Choose reportable content.');
}
if (!['spam', 'harassment', 'hate', 'sexual', 'violence', 'self_harm', 'privacy', 'other'].includes(category)) {
return failure(400, 'Choose the policy concern that best fits this report.');
}
if (details.length > 500 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(details)) {
return failure(400, 'Report details may contain up to 500 visible characters.');
}
const database = await getDatabase();
if (contentType === 'comment') {
const comment = await (await database.prepare(`SELECT comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, params.id);
if (!comment) return failure(404, 'That comment is no longer available.');
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
} else {
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
WHERE profile.user_id=?`)).get(params.id);
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
return failure(404, 'That biography is no longer available.');
}
if (contentType === 'avatar' && profile.avatar_source !== 'upload') {
return failure(400, 'Preset SHD avatars are managed site assets and do not need an Agent report.');
}
}
const report = await createUserContentReport({
contentType: contentType as 'comment' | 'profile_bio' | 'avatar',
contentId,
reporterId: reporter.id,
category: category as 'spam' | 'harassment' | 'hate' | 'sexual' | 'violence' | 'self_harm' | 'privacy' | 'other',
details
});
if (!report) return failure(409, 'You already have a pending report for this content.');
if (contentType === 'avatar') {
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
}
return {
userReportSaved: true,
userReportContentId: contentId,
userReportContentType: contentType,
userReportResultToken: randomUUID()
};
} }
}; };
+12 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte'; import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ContentReport from '$lib/components/ContentReport.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte'; import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte'; import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte'; import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
@@ -32,6 +34,7 @@
createdAt: string; createdAt: string;
author: { id: string; displayName: string; avatarUrl: string }; author: { id: string; displayName: string; avatarUrl: string };
canDelete: boolean; canDelete: boolean;
canReport: boolean;
}; };
type PageData = { type PageData = {
agent: PublicAgent; agent: PublicAgent;
@@ -40,7 +43,7 @@
guides: PublicContent[]; guides: PublicContent[];
authoredComments: AuthoredComment[]; authoredComments: AuthoredComment[];
profileComments: ProfileComment[]; profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; isSelf: boolean }; viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
}; };
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props(); let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
@@ -75,6 +78,7 @@
<header class="agent-command"> <header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" /> <AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-actions"> <div class="agent-command-actions">
{#if data.viewer.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if} {#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
</div> </div>
</header> </header>
@@ -89,7 +93,7 @@
<div class="agent-profile-grid"> <div class="agent-profile-grid">
<div class="agent-primary"> <div class="agent-primary">
<section class="profile-section agent-bio"> <section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><span>PUBLIC BIO</span></header> <header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><div class="profile-header-actions"><span>PUBLIC BIO</span>{#if data.viewer.canReportProfile && data.agent.bioHtml}<ContentReport contentType="profile_bio" contentId={data.agent.id} label="REPORT BIO" result={form}/>{/if}</div></header>
<div class="bio-content"> <div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." /> <MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div> </div>
@@ -167,8 +171,11 @@
required required
bind:value={profileCommentDraft} bind:value={profileCommentDraft}
/> />
<ModerationDisclosure/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if} {#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}<p class="success">Profile message transmitted.</p>{/if} {#if form?.profileCommentSaved || form?.commentSaved}
<p class="success">{form?.profileCommentReviewQueued ? 'Profile message received and held for Oversight review.' : 'Profile message transmitted.'}</p>
{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button> <button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm> </PendingForm>
{:else if !data.viewer.id} {:else if !data.viewer.id}
@@ -185,6 +192,7 @@
{#if comment.canDelete} {#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button> <button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if} {/if}
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form}/>{/if}
</header> </header>
<MarkdownContent html={comment.bodyHtml} compact /> <MarkdownContent html={comment.bodyHtml} compact />
{#if deletingCommentId === comment.id} {#if deletingCommentId === comment.id}
@@ -223,6 +231,7 @@
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px} .public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end} .authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start} .profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}} @media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}} @media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}} @media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
+16 -4
View File
@@ -1,16 +1,28 @@
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { readUploadedAvatar } from '$lib/server/avatars'; import { readUploadedAvatar } from '$lib/server/avatars';
import { error } from '@sveltejs/kit'; import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found'); if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
const image = await readUploadedAvatar(await getDatabase(), params.id); const database = await getDatabase();
const held = await (await database.prepare(`SELECT review.id
FROM avatar_reviews review
JOIN user_avatars avatar
ON avatar.user_id=review.user_id AND avatar.updated_at=review.avatar_updated_at
WHERE review.user_id=? AND review.status='pending' AND review.review_origin='automated'
LIMIT 1`)).get(params.id);
if (held && !await hasPermission(await getSessionUser(cookies), 'avatars.review')) {
redirect(307, '/avatars/default/field-agent.webp');
}
const image = await readUploadedAvatar(database, params.id);
if (!image) error(404, 'Avatar not found'); if (!image) error(404, 'Avatar not found');
return new Response(new Uint8Array(image), { return new Response(new Uint8Array(image), {
headers: { headers: {
'content-type': 'image/webp', 'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable', 'cache-control': held ? 'private, no-store' : 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff' 'x-content-type-options': 'nosniff'
} }
}); });
+24 -2
View File
@@ -15,8 +15,11 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const canProposeData = permissions.includes('*') || permissions.includes('data.propose'); const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
const canApproveData = permissions.includes('*') || permissions.includes('data.approve'); const canApproveData = permissions.includes('*') || permissions.includes('data.approve');
const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review'); const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review');
const canReviewCommunity = permissions.includes('*')
|| permissions.includes('community.comments.moderate')
|| permissions.includes('community.profiles.moderate');
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null; const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
const database = canSeeData || canReviewAvatars ? await getDatabase() : null; const database = canSeeData || canReviewAvatars || canReviewCommunity ? await getDatabase() : null;
const pendingRows = canSeeData const pendingRows = canSeeData
? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all() ? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
: []; : [];
@@ -24,6 +27,24 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const pendingAvatarReviews = canReviewAvatars const pendingAvatarReviews = canReviewAvatars
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0) ? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
: 0; : 0;
const canReviewComments = permissions.includes('*') || permissions.includes('community.comments.moderate');
const canReviewProfiles = permissions.includes('*') || permissions.includes('community.profiles.moderate');
const communityReviewTypes = [
...(canReviewComments ? ['comment', 'guide'] : []),
...(canReviewProfiles ? ['profile_bio'] : [])
];
const communityTypePlaceholders = communityReviewTypes.map(() => '?').join(',');
const pendingCommunityReviews = canReviewCommunity && communityReviewTypes.length
? Number((await (await database!.prepare(`SELECT
(SELECT COUNT(*) FROM content_moderation_scans scan
WHERE scan.content_type IN (${communityTypePlaceholders})
AND scan.status IN ('review','priority_review','error')
AND NOT EXISTS(SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id))
+
(SELECT COUNT(*) FROM user_content_reports
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`))
.get(...communityReviewTypes, ...communityReviewTypes))?.count ?? 0)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0'; const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0'; const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
return { return {
@@ -33,7 +54,8 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])), pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
oversightReviewCounts: { oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0, data: canApproveData ? pendingProposalTotal : 0,
avatars: pendingAvatarReviews avatars: pendingAvatarReviews,
community: pendingCommunityReviews
} }
}; };
}; };
+2 -2
View File
@@ -33,10 +33,10 @@
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' } { href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
]; ];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission))); const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [ const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' | 'community' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' }, { href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' }, { href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'] }, { href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'], reviewKey: 'community' },
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' } { href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
]; ];
const visibleOversightSections = $derived(oversightSections.filter((section) => const visibleOversightSections = $derived(oversightSections.filter((section) =>
@@ -56,6 +56,9 @@ export const actions: Actions = {
const now = new Date().toISOString(); const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'")) await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
.run(now, reviewer.id, note || null, id); .run(now, reviewer.id, note || null, id);
await (await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note || 'Oversight approved the current custom avatar.', review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } }); await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() }; return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
}, },
@@ -77,6 +80,9 @@ export const actions: Actions = {
const now = new Date().toISOString(); const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?")) await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
.run(now, reviewer.id, note, id); .run(now, reviewer.id, note, id);
await (await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note, review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } }); await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true }); await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() }; return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
+681 -12
View File
@@ -44,6 +44,116 @@ const parseAuditSnapshot = (value: unknown) => {
} }
}; };
type QueueContentType = 'comment' | 'profile_bio' | 'guide';
type QueueSource = 'scan' | 'report';
const contentPermission = (contentType: QueueContentType) =>
contentType === 'profile_bio' ? PROFILE_PERMISSION : COMMENT_PERMISSION;
const parseJsonObject = (value: unknown) => {
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
};
const parseJsonArray = (value: unknown) => {
if (typeof value !== 'string') return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const queueTargetHref = (
contentType: QueueContentType,
contentId: unknown,
targetType: unknown,
targetId: unknown,
slug: unknown
) => {
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
if (contentType === 'guide') {
return slug
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
return targetHref(String(targetType), targetId, slug);
};
const queueActionFailure = (status: number, message: string) => fail(status, {
communityQueueError: message,
communityResultToken: randomUUID()
});
const allowedQueueTypes = (canModerateComments: boolean, canModerateProfiles: boolean) => [
...(canModerateComments ? ['comment', 'guide'] as QueueContentType[] : []),
...(canModerateProfiles ? ['profile_bio'] as QueueContentType[] : [])
];
const assertQueuePermission = async (
operator: NonNullable<Awaited<ReturnType<typeof getSessionUser>>>,
contentType: QueueContentType
) => {
const granted = await getPermissions(operator);
if (!has(granted, contentPermission(contentType))) {
error(403, contentType === 'profile_bio'
? 'Profile moderation permission required'
: 'Comment moderation permission required');
}
};
const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
profile.updated_at AS profile_updated_at,COALESCE(profile.display_name,users.username) AS display_name
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
LEFT JOIN users ON users.id=scan.author_id
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`)).get(id);
const reportQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT report.*,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.author_id AS comment_author_id,comments.thread_id,
thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.is_public AS profile_is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(report.content_type='guide' AND target_build.id=report.content_id)
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
AND profile.user_id=report.content_id
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE report.id=? AND report.status='pending'
AND report.content_type IN ('comment','profile_bio','guide')`)).get(id);
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await getSessionUser(cookies); const operator = await getSessionUser(cookies);
if (!operator) error(401, 'Sign in required'); if (!operator) error(401, 'Sign in required');
@@ -53,18 +163,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required'); if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
const requestedTab = url.searchParams.get('tab'); const requestedTab = url.searchParams.get('tab');
const tab = requestedTab === 'bios' const tab = requestedTab === 'queue'
? 'queue'
: requestedTab === 'bios'
? 'bios' ? 'bios'
: requestedTab === 'comments' : requestedTab === 'comments'
? 'comments' ? 'comments'
: canModerateComments ? 'comments' : 'bios'; : 'queue';
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required'); if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required'); if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
const allowedStatuses = tab === 'comments' ? ['all', 'visible', 'removed'] : ['all', 'visible', 'hidden']; const allowedStatuses = tab === 'queue'
? ['all', 'priority', 'automated', 'reported']
: tab === 'comments'
? ['all', 'visible', 'removed']
: ['all', 'visible', 'hidden'];
const status = allowedStatuses.includes(String(url.searchParams.get('status'))) const status = allowedStatuses.includes(String(url.searchParams.get('status')))
? String(url.searchParams.get('status')) ? String(url.searchParams.get('status'))
: 'visible'; : tab === 'queue' ? 'all' : 'visible';
const { search, pattern } = getRegistrySearch(url); const { search, pattern } = getRegistrySearch(url);
const database = await getDatabase(); const database = await getDatabase();
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString(); const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
@@ -88,6 +204,207 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const commentSummary = Object.fromEntries( const commentSummary = Object.fromEntries(
commentSummaryRows.map((row) => [String(row.status), Number(row.count)]) commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
); );
const queueTypes = allowedQueueTypes(canModerateComments, canModerateProfiles);
const queueTypePlaceholders = queueTypes.map(() => '?').join(',');
const [scanQueueSummary, reportQueueSummary] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN status='priority_review' THEN 1 ELSE 0 END) AS priority,
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
FROM content_moderation_scans scan
WHERE scan.content_type IN (${queueTypePlaceholders})
AND scan.status IN ('review','priority_review','error')
AND NOT EXISTS(
SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id
)`)).get(...queueTypes),
(await database.prepare(`SELECT COUNT(*) AS total
FROM user_content_reports
WHERE content_type IN (${queueTypePlaceholders}) AND status='pending'`)).get(...queueTypes)
]);
const queueSummary = {
automated: Number(scanQueueSummary?.total ?? 0),
reported: Number(reportQueueSummary?.total ?? 0),
priority: Number(scanQueueSummary?.priority ?? 0) + Number(scanQueueSummary?.errors ?? 0)
};
const summary = {
queue: { ...queueSummary, total: queueSummary.automated + queueSummary.reported },
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
};
if (tab === 'queue') {
const scanQueue = `SELECT
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
scan.created_at AS queued_at,scan.status AS scan_status,scan.risk_level,scan.reason,
NULL AS category,scan.provider,scan.model,scan.provider_request_id,
scan.scores_json,scan.flags_json,scan.rule_matches_json,NULL AS reporter_name,
NULL AS details,thread.target_type,thread.target_id,
CASE
WHEN scan.content_type='profile_bio'
THEN COALESCE(author_profile.display_name,author.username,'Agent profile')
WHEN scan.content_type='guide'
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN users target_user ON scan.content_type='comment'
AND thread.target_type='profile' AND target_user.id=thread.target_id
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN users author ON author.id=scan.author_id
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
WHERE scan.content_type IN (${queueTypePlaceholders})
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`;
const reportQueue = `SELECT
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
report.created_at AS queued_at,NULL AS scan_status,'reported' AS risk_level,
COALESCE(report.details,'A community member requested Oversight review.') AS reason,
report.category,NULL AS provider,NULL AS model,NULL AS provider_request_id,
'{}' AS scores_json,'{}' AS flags_json,'[]' AS rule_matches_json,
COALESCE(reporter_profile.display_name,reporter.username,'Community member') AS reporter_name,
report.details,thread.target_type,thread.target_id,
CASE
WHEN report.content_type='profile_bio'
THEN COALESCE(profile.display_name,author.username,'Agent profile')
WHEN report.content_type='guide'
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
AND profile.user_id=report.content_id
LEFT JOIN builds target_build ON
(report.content_type='guide' AND target_build.id=report.content_id)
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN users target_user ON report.content_type='comment'
AND thread.target_type='profile' AND target_user.id=thread.target_id
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN users author ON author.id=COALESCE(comments.author_id,profile.user_id,target_build.owner_id)
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
LEFT JOIN users reporter ON reporter.id=report.reporter_id
LEFT JOIN user_profiles reporter_profile ON reporter_profile.user_id=reporter.id
WHERE report.content_type IN (${queueTypePlaceholders}) AND report.status='pending'`;
const union = status === 'automated'
? scanQueue
: status === 'reported'
? reportQueue
: `${scanQueue} UNION ALL ${reportQueue}`;
const args: unknown[] = status === 'automated' || status === 'reported'
? [...queueTypes]
: [...queueTypes, ...queueTypes];
const conditions: string[] = [];
if (status === 'priority') {
conditions.push("item_source='scan' AND scan_status IN ('priority_review','error')");
}
if (search) {
conditions.push(`(body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR target_title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(reason,'') LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(category,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern, pattern, pattern);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const total = Number((await (await database.prepare(
`WITH queue_items AS (${union}) SELECT COUNT(*) AS count FROM queue_items ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`WITH queue_items AS (${union})
SELECT * FROM queue_items ${where}
ORDER BY CASE
WHEN scan_status='priority_review' THEN 0
WHEN scan_status='error' THEN 1
WHEN item_source='report' THEN 2
ELSE 3
END,queued_at DESC LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary,
items: rows.map((row) => {
const contentType = String(row.content_type) as QueueContentType;
const scores = parseJsonObject(row.scores_json);
const flags = parseJsonObject(row.flags_json);
return {
id: String(row.queue_id),
source: String(row.item_source) as QueueSource,
contentType,
contentId: String(row.content_id),
bodyHtml: renderUserMarkdown(row.body_markdown),
queuedAt: String(row.queued_at),
scanStatus: row.scan_status ? String(row.scan_status) : null,
riskLevel: String(row.risk_level),
reason: String(row.reason),
category: row.category ? String(row.category) : null,
provider: row.provider ? String(row.provider) : null,
model: row.model ? String(row.model) : null,
providerRequestId: row.provider_request_id ? String(row.provider_request_id) : null,
reporterName: row.reporter_name ? String(row.reporter_name) : null,
scores: Object.entries(scores)
.map(([category, score]) => ({ category, score: Number(score) }))
.filter((entry) => Number.isFinite(entry.score) && entry.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 5),
flags: Object.entries(flags)
.filter(([, flagged]) => Boolean(flagged))
.map(([category]) => category),
ruleMatches: parseJsonArray(row.rule_matches_json).flatMap((match) => {
if (!match || typeof match !== 'object' || Array.isArray(match)) return [];
const record = match as Record<string, unknown>;
return [{
label: String(record.label ?? record.kind ?? 'LOCAL RULE'),
excerpt: String(record.excerpt ?? '')
}];
}),
targetType: contentType === 'comment' ? String(row.target_type ?? 'content') : contentType,
targetTitle: String(row.target_title),
targetHref: queueTargetHref(
contentType,
row.content_id,
row.target_type,
row.target_id,
row.slug
),
author: {
id: String(row.author_id ?? ''),
displayName: String(row.display_name),
avatarUrl: avatarUrl(
row.avatar_source,
row.avatar_value,
String(row.author_id ?? ''),
row.avatar_updated_at
)
}
};
}),
pagination,
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
}
if (tab === 'comments') { if (tab === 'comments') {
const sort = getRegistrySort(url, { const sort = getRegistrySort(url, {
@@ -150,10 +467,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
tab, tab,
status, status,
search, search,
summary: { summary,
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({ items: rows.map((row) => ({
id: String(row.id), id: String(row.id),
bodyHtml: renderUserMarkdown(row.body_markdown), bodyHtml: renderUserMarkdown(row.body_markdown),
@@ -221,10 +535,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
tab, tab,
status, status,
search, search,
summary: { summary,
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({ items: rows.map((row) => ({
id: String(row.user_id), id: String(row.user_id),
bodyHtml: renderUserMarkdown(row.bio_markdown), bodyHtml: renderUserMarkdown(row.bio_markdown),
@@ -244,6 +555,364 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
approveQueue: async ({ cookies, request }) => {
const operator = await getSessionUser(cookies);
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '') as QueueSource;
const reason = requiredReason(form.get('reason'));
if (!id || !['scan', 'report'].includes(source) || !reason) {
return queueActionFailure(400, 'Choose a queue item and provide a brief approval reason.');
}
const database = await getDatabase();
if (source === 'report') {
const report = await reportQueueRecord(database, id);
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
const contentType = String(report.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
try {
const result = await (await database.prepare(`UPDATE user_content_reports
SET status='dismissed',reviewed_at=?,reviewed_by=?,resolution_reason=?
WHERE id=? AND status='pending'`)).run(now, operator.id, reason, id);
if (!result.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'That report changed before it could be dismissed.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'content_report.dismissed',
entityId: id,
before: {
status: 'pending',
contentType,
contentId: String(report.content_id),
category: String(report.category),
reporterId: String(report.reporter_id)
},
after: {
status: 'dismissed',
reason,
reviewedAt: now,
reviewedBy: operator.id
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
return { communitySaved: true, communityResultToken: randomUUID() };
}
const scan = await scanQueueRecord(database, id);
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
const contentType = String(scan.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const body = contentType === 'comment' ? String(scan.comment_body ?? '') : String(scan.revision_body ?? '');
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
const now = new Date().toISOString();
const reviewId = randomUUID();
let targetOwnerId: string | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
(id,scan_id,reviewer_id,decision,reason,created_at)
SELECT ?,?,?,'approved',?,?
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
.run(reviewId, id, operator.id, reason, now, id);
if (!claim.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'Another reviewer already handled this scan.');
}
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before approval.');
targetOwnerId = String(scan.target_type) === 'profile'
? String(scan.target_id)
: scan.guide_owner_id ? String(scan.guide_owner_id) : null;
} else if (contentType === 'profile_bio') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,1,?,?)
ON CONFLICT(user_id) DO UPDATE SET
bio_markdown=excluded.bio_markdown,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`))
.run(scan.content_id, String(scan.display_name ?? 'Agent'), body, now, now);
await (await database.prepare(`UPDATE profile_moderation SET bio_hidden=0,
reason=?,moderated_at=?,moderated_by=? WHERE user_id=?`))
.run(reason, now, operator.id, scan.content_id);
} else {
const result = await (await database.prepare(`UPDATE builds
SET guide_body=?,updated_at=? WHERE id=? AND owner_id=?`))
.run(body, now, scan.content_id, scan.author_id);
if (!result.changes) throw new Error('The queued guide changed before approval.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: `${contentType}.review_approved`,
entityId: String(scan.content_id),
before: {
scanId: id,
status: String(scan.status),
riskLevel: String(scan.risk_level),
reason: String(scan.reason),
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model),
currentStatus: contentType === 'comment'
? String(scan.comment_status)
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
},
after: {
scanId: id,
reviewId,
decision: 'approved',
reason,
reviewedAt: now,
reviewedBy: operator.id,
published: true
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && cause.message.includes('changed before approval')) {
return queueActionFailure(409, cause.message);
}
throw cause;
}
const authorId = String(scan.author_id);
const link = queueTargetHref(
contentType,
scan.content_id,
scan.target_type,
scan.target_id,
scan.slug
);
await createNotification({
userId: authorId,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment approved'
: contentType === 'profile_bio' ? 'Agent bio approved' : 'Guide approved',
body: `Community Oversight approved your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
link,
actions: [{ label: 'VIEW CONTENT', href: link }],
mandatory: true
});
if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
await createNotification({
userId: targetOwnerId,
category: 'comments',
source: 'agent',
title: 'New comment',
body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
link,
actions: [{ label: 'VIEW COMMENT', href: link }]
});
}
return { communitySaved: true, communityResultToken: randomUUID() };
},
rejectQueue: async ({ cookies, request }) => {
const operator = await getSessionUser(cookies);
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '') as QueueSource;
const reason = requiredReason(form.get('reason'));
if (!id || !['scan', 'report'].includes(source) || !reason) {
return queueActionFailure(400, 'Choose a queue item and provide a brief rejection reason.');
}
const database = await getDatabase();
if (source === 'report') {
const report = await reportQueueRecord(database, id);
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
const contentType = String(report.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const authorId = contentType === 'comment'
? String(report.comment_author_id ?? '')
: contentType === 'profile_bio'
? String(report.content_id)
: String(report.guide_owner_id ?? '');
if (!authorId) return queueActionFailure(409, 'The reported content author is no longer available.');
const now = new Date().toISOString();
const before = {
reportId: id,
contentType,
contentId: String(report.content_id),
reportCategory: String(report.category),
reporterId: String(report.reporter_id),
contentStatus: contentType === 'comment'
? String(report.comment_status ?? 'missing')
: contentType === 'profile_bio'
? Boolean(report.bio_hidden) ? 'hidden' : 'visible'
: String(report.current_guide_visibility ?? 'missing')
};
await database.exec('BEGIN IMMEDIATE');
try {
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments SET status='removed',
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
WHERE id=? AND status='visible'`))
.run(now, operator.id, reason, now, report.content_id);
if (!result.changes) throw new Error('The reported comment is no longer visible.');
} else if (contentType === 'profile_bio') {
if (!String(report.current_bio ?? '').trim() || Boolean(report.bio_hidden)) {
throw new Error('The reported biography is no longer visible.');
}
await (await database.prepare(`INSERT INTO profile_moderation
(user_id,bio_hidden,reason,moderated_at,moderated_by) VALUES(?,1,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET bio_hidden=1,reason=excluded.reason,
moderated_at=excluded.moderated_at,moderated_by=excluded.moderated_by`))
.run(report.content_id, reason, now, operator.id);
} else {
if (!String(report.current_guide_body ?? '').trim() || report.current_guide_visibility === 'private') {
throw new Error('The reported guide is no longer publicly available.');
}
const result = await (await database.prepare(`UPDATE builds
SET visibility='private',updated_at=? WHERE id=? AND visibility<>'private'`))
.run(now, report.content_id);
if (!result.changes) throw new Error('The reported guide changed before it could be hidden.');
}
const resolved = await (await database.prepare(`UPDATE user_content_reports
SET status='resolved',reviewed_at=?,reviewed_by=?,resolution_reason=?
WHERE content_type=? AND content_id=? AND status='pending'`))
.run(now, operator.id, reason, contentType, report.content_id);
if (!resolved.changes) throw new Error('The pending reports changed before resolution.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: contentType === 'comment'
? 'comment.report_removed'
: contentType === 'profile_bio' ? 'profile_bio.report_hidden' : 'guide.report_hidden',
entityId: String(report.content_id),
before,
after: {
status: contentType === 'comment' ? 'removed' : 'hidden',
reason,
reviewedAt: now,
reviewedBy: operator.id,
resolvedReports: Number(resolved.changes)
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && (
cause.message.includes('no longer')
|| cause.message.includes('changed before')
)) return queueActionFailure(409, cause.message);
throw cause;
}
const link = queueTargetHref(
contentType,
report.content_id,
report.target_type,
report.target_id,
report.slug
);
await createNotification({
userId: authorId,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment removed'
: contentType === 'profile_bio' ? 'Agent bio hidden' : 'Guide hidden',
body: `Community Oversight ${contentType === 'comment' ? 'removed' : 'hid'} your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
link: contentType === 'profile_bio' ? '/account' : link,
actions: [{
label: 'REVIEW CONTENT',
href: contentType === 'profile_bio' ? '/account' : link
}],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
}
const scan = await scanQueueRecord(database, id);
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
const contentType = String(scan.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const now = new Date().toISOString();
const reviewId = randomUUID();
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
(id,scan_id,reviewer_id,decision,reason,created_at)
SELECT ?,?,?,'rejected',?,?
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
.run(reviewId, id, operator.id, reason, now, id);
if (!claim.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'Another reviewer already handled this scan.');
}
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments SET status='removed',
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
WHERE id=? AND status='pending_review'`))
.run(now, operator.id, reason, now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before rejection.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: `${contentType}.review_rejected`,
entityId: String(scan.content_id),
before: {
scanId: id,
status: String(scan.status),
riskLevel: String(scan.risk_level),
reason: String(scan.reason),
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model)
},
after: {
scanId: id,
reviewId,
decision: 'rejected',
reason,
reviewedAt: now,
reviewedBy: operator.id,
published: false
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && cause.message.includes('changed before rejection')) {
return queueActionFailure(409, cause.message);
}
throw cause;
}
await createNotification({
userId: String(scan.author_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment rejected'
: contentType === 'profile_bio' ? 'Agent bio revision rejected' : 'Guide revision rejected',
body: `Community Oversight rejected your ${contentType === 'profile_bio' ? 'Agent bio revision' : contentType}. Reason: ${reason}`,
link: '/account',
actions: [{ label: 'REVIEW ACCOUNT', href: '/account' }],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
removeComment: async ({ cookies, request }) => { removeComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION); const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData(); const form = await request.formData();
+212 -14
View File
@@ -26,17 +26,41 @@
moderationReason: string | null; moderationReason: string | null;
agent: AgentSummary; agent: AgentSummary;
}; };
type QueueItem = {
id: string;
source: 'scan' | 'report';
contentType: 'comment' | 'profile_bio' | 'guide';
contentId: string;
bodyHtml: string;
queuedAt: string;
scanStatus: string | null;
riskLevel: string;
reason: string;
category: string | null;
provider: string | null;
model: string | null;
providerRequestId: string | null;
reporterName: string | null;
scores: Array<{ category: string; score: number }>;
flags: string[];
ruleMatches: Array<{ label: string; excerpt: string }>;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio; type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio'; type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = { type PageData = {
tab: 'comments' | 'bios'; tab: 'queue' | 'comments' | 'bios';
status: string; status: string;
search: string; search: string;
summary: { summary: {
queue: { total: number; automated: number; reported: number; priority: number };
comments: { visible: number; removed: number }; comments: { visible: number; removed: number };
bios: { visible: number; hidden: number }; bios: { visible: number; hidden: number };
}; };
items: CommunityItem[]; items: Array<CommunityItem | QueueItem>;
pagination: any; pagination: any;
permissions: { comments: boolean; profiles: boolean }; permissions: { comments: boolean; profiles: boolean };
}; };
@@ -46,10 +70,15 @@
let moderationAction = $state<ModerationAction | null>(null); let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state(''); let moderationReason = $state('');
let confirming = $state(false); let confirming = $state(false);
let queueReviewing = $state<QueueItem | null>(null);
let queueDecision = $state<'approve' | 'reject'>('approve');
let queueReason = $state('');
let queueConfirming = $state(false);
let handledResultToken = $state(''); let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments); const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles); const canModerateProfiles = $derived(data.permissions.profiles);
const isQueueItem = (item: CommunityItem | QueueItem): item is QueueItem => 'source' in item;
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item; const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent; const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt; const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
@@ -81,7 +110,14 @@
: 'The bio will return to the Agent’s public profile.' : 'The bio will return to the Agent’s public profile.'
); );
const statusItems = $derived( const statusItems = $derived(
data.tab === 'comments' data.tab === 'queue'
? [
{ value: 'all', label: 'PENDING', count: data.summary.queue.total, description: 'All items requiring human review' },
{ value: 'priority', label: 'PRIORITY', count: data.summary.queue.priority, description: 'High-risk or failed automated scans' },
{ value: 'automated', label: 'AUTOMATED', count: data.summary.queue.automated, description: 'Items routed here by moderation scans' },
{ value: 'reported', label: 'USER REPORTS', count: data.summary.queue.reported, description: 'Content flagged by community members' }
]
: data.tab === 'comments'
? [ ? [
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' }, { value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' }, { value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
@@ -93,6 +129,22 @@
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' } { value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
] ]
); );
const queueActionLabel = $derived(
queueDecision === 'approve'
? queueReviewing?.source === 'report' ? 'KEEP CONTENT' : queueReviewing?.contentType === 'comment' ? 'APPROVE & PUBLISH' : 'APPROVE REVISION'
: queueReviewing?.source === 'report'
? queueReviewing?.contentType === 'comment' ? 'REMOVE COMMENT' : `HIDE ${queueReviewing?.contentType === 'profile_bio' ? 'BIO' : 'GUIDE'}`
: queueReviewing?.contentType === 'comment' ? 'REJECT & REMOVE' : 'REJECT REVISION'
);
const queueConsequence = $derived(
queueDecision === 'approve'
? queueReviewing?.source === 'report'
? 'This report will be dismissed. Other reports for the same content will remain pending.'
: 'This scan will be approved and the queued content will become public.'
: queueReviewing?.source === 'report'
? 'The content will be hidden or removed, and all pending reports for it will be resolved.'
: 'This scan will be rejected. The queued revision will not be published.'
);
function openModeration(item: CommunityItem) { function openModeration(item: CommunityItem) {
reviewing = item; reviewing = item;
@@ -110,33 +162,53 @@
confirming = false; confirming = false;
} }
function openQueueReview(item: QueueItem) {
queueReviewing = item;
queueDecision = 'approve';
queueReason = '';
queueConfirming = false;
}
function closeQueueReview() {
queueReviewing = null;
queueReason = '';
queueConfirming = false;
}
$effect(() => { $effect(() => {
const token = String(form?.communityResultToken ?? ''); const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return; if (!token || token === handledResultToken) return;
handledResultToken = token; handledResultToken = token;
if (form?.communitySaved) closeModeration(); if (form?.communitySaved) {
closeModeration();
closeQueueReview();
}
}); });
</script> </script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head> <svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => { <svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration(); if (event.key === 'Escape' && reviewing) closeModeration();
if (event.key === 'Escape' && queueReviewing) closeQueueReview();
}}/> }}/>
<div class="module-toolbar moderation-toolbar"> <div class="module-toolbar moderation-toolbar">
<div> <div>
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p> <p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
<h2>COMMUNITY OVERSIGHT</h2> <h2>COMMUNITY OVERSIGHT</h2>
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p> <p>Resolve automated safety flags and community reports, then inspect the retained comment and biography history when needed.</p>
</div> </div>
<div class="queue-readout"> <div class="queue-readout">
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span> <span>{data.tab === 'queue' ? 'PENDING REVIEW' : data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong> <strong>{data.tab === 'queue' ? data.summary.queue.total : data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>PUBLIC RECORDS</small> <small>{data.tab === 'queue' ? 'EXCEPTIONS ONLY' : 'PUBLIC RECORDS'}</small>
</div> </div>
</div> </div>
<nav class="community-tabs" aria-label="Community oversight channels"> <nav class="community-tabs" aria-label="Community oversight channels">
<a class:active={data.tab === 'queue'} aria-current={data.tab === 'queue' ? 'page' : undefined} href="/isac/community?tab=queue&status=all">
<span>REVIEW QUEUE</span><b>{data.summary.queue.total}</b>
</a>
{#if canModerateComments} {#if canModerateComments}
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all"> <a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b> <span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
@@ -154,14 +226,45 @@
<form class="audit-filter community-filter"> <form class="audit-filter community-filter">
<input type="hidden" name="tab" value={data.tab}/> <input type="hidden" name="tab" value={data.tab}/>
<input type="hidden" name="status" value={data.status}/> <input type="hidden" name="status" value={data.status}/>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/> <input name="search" type="search" value={data.search} placeholder={data.tab === 'queue' ? 'AUTHOR, TARGET, CATEGORY, OR CONTENT' : data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">SEARCH COMMUNITY →</button> <button class="secondary">{data.tab === 'queue' ? 'SEARCH QUEUE →' : 'SEARCH COMMUNITY →'}</button>
</form> </form>
<RegistryPagination pagination={data.pagination}/> <RegistryPagination pagination={data.pagination}/>
<div class="community-registry"> <div class="community-registry">
{#each data.items as item} {#each data.items as item}
{#if isQueueItem(item)}
<article class="queue-item" class:priority={item.riskLevel === 'high' || item.scanStatus === 'error'}>
<header>
<AgentIdentityLink agent={item.author} meta="CONTENT AUTHOR" />
<div class="community-state">
<span>{date(item.queuedAt)}</span>
<b class:visible={item.source === 'report'}>{item.source === 'scan' ? 'AUTOMATED SCAN' : 'USER REPORT'}</b>
</div>
</header>
<div class="community-target">
<span>{item.contentType.replace('_', ' ').toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
<div class="queue-evidence">
<div class="queue-signal">
<span class:priority={item.riskLevel === 'high'}>{item.riskLevel.toUpperCase()}</span>
<strong>{item.category ? item.category.replace('_', ' ').toUpperCase() : item.scanStatus?.replace('_', ' ').toUpperCase()}</strong>
</div>
<p>{item.reason}</p>
{#if item.reporterName}<small>REPORTED BY {item.reporterName}</small>{/if}
{#if item.provider}<small>{item.provider.toUpperCase()} / {item.model ?? 'MODEL UNKNOWN'}</small>{/if}
</div>
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage="The queued content is no longer available."/>
</div>
<footer>
<span>{item.source === 'scan' ? `SCAN ${item.id.slice(0,8).toUpperCase()}` : `REPORT ${item.id.slice(0,8).toUpperCase()}`}</span>
<button class="secondary" onclick={() => openQueueReview(item)}>REVIEW EVIDENCE →</button>
</footer>
</article>
{:else}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}> <article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header> <header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} /> <AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
@@ -193,14 +296,106 @@
{/if} {/if}
</footer> </footer>
</article> </article>
{/if}
{:else} {:else}
<div class="empty-dashboard"> <div class="empty-dashboard">
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong> <strong>NO MATCHING {data.tab === 'queue' ? 'REVIEW EXCEPTIONS' : data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>Adjust the status or search filters to inspect another part of the community record.</span> <span>{data.tab === 'queue' ? 'Automated checks have not routed any matching content to human Oversight.' : 'Adjust the status or search filters to inspect another part of the community record.'}</span>
</div> </div>
{/each} {/each}
</div> </div>
{#if queueReviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="queue-review-title">
<div class="editor-title">
<div>
<p class="eyebrow">{queueReviewing.source === 'scan' ? 'AUTOMATED SAFETY FLAG' : 'COMMUNITY REPORT'} / {queueReviewing.contentType.replace('_', ' ').toUpperCase()}</p>
<h3 id="queue-review-title">REVIEW CONTENT EXCEPTION</h3>
</div>
<button aria-label="Close content review" onclick={closeQueueReview}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={queueReviewing.author} meta="CONTENT AUTHOR" />
<span>{date(queueReviewing.queuedAt)}</span>
</div>
<div class="community-target review-target">
<span>{queueReviewing.contentType.replace('_', ' ').toUpperCase()}</span>
<a href={queueReviewing.targetHref}>{queueReviewing.targetTitle} →</a>
</div>
<div class="queue-review-evidence">
<div>
<span>SOURCE</span><strong>{queueReviewing.source === 'scan' ? 'AUTOMATED SCAN' : 'USER REPORT'}</strong>
</div>
<div>
<span>RISK / CATEGORY</span><strong>{queueReviewing.riskLevel.toUpperCase()} · {(queueReviewing.category ?? queueReviewing.scanStatus ?? 'REVIEW').replace('_', ' ').toUpperCase()}</strong>
</div>
{#if queueReviewing.provider}
<div><span>PROVIDER / MODEL</span><strong>{queueReviewing.provider.toUpperCase()} · {queueReviewing.model ?? 'UNKNOWN'}</strong></div>
{/if}
{#if queueReviewing.providerRequestId}
<div><span>REQUEST ID</span><strong>{queueReviewing.providerRequestId}</strong></div>
{/if}
{#if queueReviewing.reporterName}
<div><span>REPORTER</span><strong>{queueReviewing.reporterName}</strong></div>
{/if}
</div>
<div class="moderation-evidence queue-reason-evidence"><span>ROUTING REASON</span><p>{queueReviewing.reason}</p></div>
{#if queueReviewing.flags.length || queueReviewing.ruleMatches.length || queueReviewing.scores.length}
<div class="queue-signals">
{#if queueReviewing.flags.length}<div><span>FLAGGED CATEGORIES</span><p>{queueReviewing.flags.join(' · ')}</p></div>{/if}
{#if queueReviewing.ruleMatches.length}<div><span>LOCAL RULES</span>{#each queueReviewing.ruleMatches as match}<p><b>{match.label}</b>{match.excerpt ? ` — ${match.excerpt}` : ''}</p>{/each}</div>{/if}
{#if queueReviewing.scores.length}<div><span>TOP PROVIDER SCORES</span>{#each queueReviewing.scores as score}<p><b>{score.category}</b> {(score.score * 100).toFixed(1)}%</p>{/each}</div>{/if}
</div>
{/if}
<div class="community-review-content"><MarkdownContent html={queueReviewing.bodyHtml} emptyMessage="The queued content is no longer available."/></div>
<div class="decision-tabs queue-decision-tabs" role="group" aria-label="Moderation decision">
<button type="button" class:active={queueDecision === 'approve'} aria-pressed={queueDecision === 'approve'} onclick={() => { queueDecision = 'approve'; queueConfirming = false; }}>
<b>✓</b><span>{queueReviewing.source === 'report' ? 'KEEP' : 'APPROVE'}<small>{queueReviewing.source === 'report' ? 'DISMISS THIS REPORT' : 'PUBLISH QUEUED CONTENT'}</small></span>
</button>
<button type="button" class:active={queueDecision === 'reject'} aria-pressed={queueDecision === 'reject'} onclick={() => { queueDecision = 'reject'; queueConfirming = false; }}>
<b>×</b><span>{queueReviewing.source === 'report' ? 'REMOVE' : 'REJECT'}<small>{queueReviewing.source === 'report' ? 'RESOLVE ALL REPORTS' : 'KEEP CONTENT HIDDEN'}</small></span>
</button>
</div>
{#if !queueConfirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={queueReason} minlength="3" maxlength="500" required placeholder="Record the evidence and policy basis for this decision…"></textarea>
</label>
{#if form?.communityQueueError}<p class="error queue-error">{form.communityQueueError}</p>{/if}
<div class="moderation-warning" class:rejecting={queueDecision === 'reject'}>
<b>!</b><span>{queueConsequence} The review decision and reason will be added to the audit history.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeQueueReview}>CANCEL</button>
<button type="button" class="primary" disabled={queueReason.trim().length < 3} onclick={() => queueConfirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const queueReviewingId = queueReviewing.id}
{@const queueReviewingSource = queueReviewing.source}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{queueActionLabel}?</strong><p>{queueConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{queueReason}</dd></dl>
</div>
<PendingForm action={queueDecision === 'approve' ? '?/approveQueue' : '?/rejectQueue'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={queueReviewingId}/>
<input type="hidden" name="source" value={queueReviewingSource}/>
<input type="hidden" name="reason" value={queueReason}/>
{#if form?.communityQueueError}<p class="error">{form.communityQueueError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => queueConfirming = false}>BACK</button>
<button class={queueDecision === 'reject' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${queueActionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/if}
{#if reviewing && moderationAction} {#if reviewing && moderationAction}
<div class="isac-modal-backdrop" role="presentation"> <div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title"> <div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
@@ -256,15 +451,18 @@
{/if} {/if}
<style> <style>
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011} .community-tabs{height:auto;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)} .community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)} .community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)} .community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none} .community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
.community-registry>article.queue-item{grid-template-rows:auto auto auto minmax(100px,1fr) auto;border-left:1px solid var(--shd-info)}.community-registry>article.queue-item.priority{border-color:var(--shd-danger)}
.queue-evidence{display:grid;gap:5px;padding:9px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.queue-evidence p{margin:0;color:var(--shd-text);line-height:1.4;overflow-wrap:anywhere}.queue-evidence small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;overflow-wrap:anywhere}.queue-signal{display:flex;align-items:center;gap:7px}.queue-signal span,.queue-signal strong{padding:3px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.queue-signal span.priority{border-color:var(--shd-danger);color:var(--shd-danger)}
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4} .moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px} .community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px} .community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
.queue-review-evidence{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-bottom:1px solid var(--shd-border)}.queue-review-evidence>div{min-width:0;display:grid;gap:3px;padding:8px 10px;border-right:1px solid var(--shd-border);border-top:1px solid var(--shd-border-soft)}.queue-review-evidence span,.queue-signals span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.queue-review-evidence strong{overflow-wrap:anywhere}.queue-reason-evidence{margin-top:10px}.queue-signals{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 10px 10px;border:1px solid var(--shd-border);background:var(--shd-border)}.queue-signals>div{min-width:0;padding:8px;background:#0d1011}.queue-signals p{margin:4px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);overflow-wrap:anywhere}.queue-decision-tabs{margin:10px}.queue-error{margin:0 10px}
@media(max-width:900px){.community-registry{grid-template-columns:1fr}} @media(max-width:900px){.community-registry{grid-template-columns:1fr}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}} @media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm,.queue-review-evidence,.queue-signals{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}} @media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style> </style>
+350 -7
View File
@@ -5,10 +5,15 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit'; import { recordIsacAudit } from '$lib/server/audit';
import { invalidateSiteAccessSettings } from '$lib/server/site-access'; import { invalidateSiteAccessSettings } from '$lib/server/site-access';
import { invalidateCommunityModerationPolicy } from '$lib/server/community-moderation';
import { env } from '$env/dynamic/private';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
const SETTINGS_ID = 'primary'; const SETTINGS_ID = 'primary';
const CONFIRMATION = 'apply-site-access-settings'; const CONFIRMATION = 'apply-site-access-settings';
const MODERATION_CONFIRMATION = 'apply-community-moderation-policy';
const MODERATION_MODELS = new Set(['omni-moderation-latest', 'omni-moderation-2024-09-26']);
const MODERATION_SCOPES = new Set(['comment', 'profile_bio', 'guide']);
const settingSnapshot = async () => { const settingSnapshot = async () => {
const database = await getDatabase(); const database = await getDatabase();
@@ -25,17 +30,74 @@ const flagNames = [
const enabled = (form: FormData, name: (typeof flagNames)[number]) => const enabled = (form: FormData, name: (typeof flagNames)[number]) =>
String(form.get(name)) === '1' ? 1 : 0; String(form.get(name)) === '1' ? 1 : 0;
const moderationBooleanNames = [
'automatedEnabled', 'autoPublishClean', 'holdOnProviderError', 'scanComments',
'scanProfiles', 'scanGuides', 'scanAvatars', 'detectPii'
] as const;
const strictBoolean = (form: FormData, name: string) => {
const value = String(form.get(name) ?? '');
return value === '1' ? 1 : value === '0' ? 0 : null;
};
const finiteNumber = (value: FormDataEntryValue | null) => {
if (value === null || String(value).trim() === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeTermValue = (value: FormDataEntryValue | null) =>
String(value ?? '').normalize('NFKC').trim().toLowerCase();
const moderationPolicySnapshot = async () => {
const database = await getDatabase();
const [settings, thresholds] = await Promise.all([
(await database.prepare('SELECT * FROM community_moderation_settings WHERE id=?')).get(SETTINGS_ID),
(await database.prepare('SELECT * FROM community_moderation_thresholds ORDER BY category')).all()
]);
return { settings: settings ?? null, thresholds };
};
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const database = await getDatabase(); const database = await getDatabase();
const settings = await (await database.prepare(`SELECT settings.*, const [settings, moderationSettings, moderationThresholds, moderationTerms] = await Promise.all([
COALESCE(profile.display_name,user.username) AS updated_by_name (await database.prepare(`SELECT settings.*,
FROM site_launch_settings settings COALESCE(profile.display_name,user.username) AS updated_by_name
LEFT JOIN users user ON user.id=settings.updated_by FROM site_launch_settings settings
LEFT JOIN user_profiles profile ON profile.user_id=user.id LEFT JOIN users user ON user.id=settings.updated_by
WHERE settings.id=?`)).get(SETTINGS_ID); LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID),
(await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM community_moderation_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID),
(await database.prepare(`SELECT thresholds.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM community_moderation_thresholds thresholds
LEFT JOIN users user ON user.id=thresholds.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
ORDER BY thresholds.category`)).all(),
(await database.prepare(`SELECT terms.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM community_moderation_terms terms
LEFT JOIN users user ON user.id=terms.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
ORDER BY terms.enabled DESC,terms.list_kind,terms.normalized_value`)).all()
]);
if (!settings) error(500, 'Site launch settings are not initialized.'); if (!settings) error(500, 'Site launch settings are not initialized.');
return { settings }; if (!moderationSettings) error(500, 'Community moderation settings are not initialized.');
return {
settings,
moderationSettings,
moderationThresholds,
moderationTerms,
providerConfigured: Boolean(
env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim()
)
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -79,5 +141,286 @@ export const actions: Actions = {
after after
}); });
return { siteSettingsSaved: true, resultToken: randomUUID() }; return { siteSettingsSaved: true, resultToken: randomUUID() };
},
saveModerationPolicy: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const form = await request.formData();
if (String(form.get('confirmation') ?? '') !== MODERATION_CONFIRMATION) {
return fail(400, {
moderationPolicyError: 'Review and confirm the complete community moderation policy before applying it.',
moderationResultToken: randomUUID()
});
}
const booleanValues = Object.fromEntries(
moderationBooleanNames.map((name) => [name, strictBoolean(form, name)])
) as Record<(typeof moderationBooleanNames)[number], number | null>;
if (moderationBooleanNames.some((name) => booleanValues[name] === null)) {
return fail(400, {
moderationPolicyError: 'Every moderation control must have an explicit enabled or disabled state.',
moderationResultToken: randomUUID()
});
}
const providerModel = String(form.get('providerModel') ?? '').trim();
if (!MODERATION_MODELS.has(providerModel)) {
return fail(400, {
moderationPolicyError: 'Choose a supported OpenAI moderation model.',
moderationResultToken: randomUUID()
});
}
const maxLinks = finiteNumber(form.get('maxLinks'));
const duplicateWindowSeconds = finiteNumber(form.get('duplicateWindowSeconds'));
if (!Number.isInteger(maxLinks) || maxLinks === null || maxLinks < 0 || maxLinks > 20) {
return fail(400, {
moderationPolicyError: 'Maximum links must be a whole number from 0 through 20.',
moderationResultToken: randomUUID()
});
}
if (
!Number.isInteger(duplicateWindowSeconds)
|| duplicateWindowSeconds === null
|| duplicateWindowSeconds < 0
|| duplicateWindowSeconds > 3_600
) {
return fail(400, {
moderationPolicyError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.',
moderationResultToken: randomUUID()
});
}
const database = await getDatabase();
const thresholdRows = await (await database.prepare(
'SELECT category FROM community_moderation_thresholds ORDER BY category'
)).all();
if (!thresholdRows.length) {
return fail(409, {
moderationPolicyError: 'Moderation categories are not initialized.',
moderationResultToken: randomUUID()
});
}
const thresholds: Array<{
category: string;
enabled: number;
reviewThreshold: number;
priorityThreshold: number;
}> = [];
for (const row of thresholdRows) {
const category = String(row.category);
const thresholdEnabled = strictBoolean(form, `thresholdEnabled:${category}`);
const reviewThreshold = finiteNumber(form.get(`reviewThreshold:${category}`));
const priorityThreshold = finiteNumber(form.get(`priorityThreshold:${category}`));
if (
thresholdEnabled === null
|| reviewThreshold === null
|| priorityThreshold === null
|| reviewThreshold < 0
|| reviewThreshold > 1
|| priorityThreshold < 0
|| priorityThreshold > 1
|| priorityThreshold < reviewThreshold
) {
return fail(400, {
moderationPolicyError: `Review the ${category} thresholds. Values must be between 0 and 1, and priority must be at least the review threshold.`,
moderationResultToken: randomUUID()
});
}
thresholds.push({ category, enabled: thresholdEnabled, reviewThreshold, priorityThreshold });
}
const before = await moderationPolicySnapshot();
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
try {
await (await database.prepare(`UPDATE community_moderation_settings SET
automated_enabled=?,auto_publish_clean=?,hold_on_provider_error=?,
scan_comments=?,scan_profiles=?,scan_guides=?,scan_avatars=?,provider_model=?,detect_pii=?,
max_links=?,duplicate_window_seconds=?,policy_version=policy_version+1,
updated_at=?,updated_by=? WHERE id=?`)).run(
booleanValues.automatedEnabled,
booleanValues.autoPublishClean,
booleanValues.holdOnProviderError,
booleanValues.scanComments,
booleanValues.scanProfiles,
booleanValues.scanGuides,
booleanValues.scanAvatars,
providerModel,
booleanValues.detectPii,
maxLinks,
duplicateWindowSeconds,
now,
operator.id,
SETTINGS_ID
);
for (const threshold of thresholds) {
await (await database.prepare(`UPDATE community_moderation_thresholds SET
enabled=?,review_threshold=?,priority_threshold=?,updated_at=?,updated_by=?
WHERE category=?`)).run(
threshold.enabled,
threshold.reviewThreshold,
threshold.priorityThreshold,
now,
operator.id,
threshold.category
);
}
const after = await moderationPolicySnapshot();
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'moderation.policy_updated',
entityId: SETTINGS_ID,
before,
after
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
invalidateCommunityModerationPolicy();
return {
moderationPolicySaved: true,
moderationResultToken: randomUUID()
};
},
saveModerationTerm: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const listType = String(form.get('listType') ?? '');
const matchMode = String(form.get('matchMode') ?? '');
const value = String(form.get('value') ?? '').normalize('NFKC').trim();
const normalizedValue = normalizeTermValue(form.get('value'));
const requestedScopes = [...new Set(form.getAll('scopes').map(String))];
const scopes = [...MODERATION_SCOPES].filter((scope) => requestedScopes.includes(scope));
const termEnabled = strictBoolean(form, 'enabled');
const note = String(form.get('note') ?? '').normalize('NFKC').trim();
const failure = (message: string) => fail(400, {
moderationTermError: message,
moderationResultToken: randomUUID()
});
if (!['allow', 'ban'].includes(listType)) return failure('Choose whether this rule allows or bans matching content.');
if (!['word', 'phrase'].includes(matchMode)) return failure('Choose word or phrase matching.');
if (!normalizedValue || Array.from(normalizedValue).length > 200) return failure('Rule text must contain 1–200 characters.');
if (matchMode === 'word' && /\s/u.test(normalizedValue)) return failure('Word rules cannot contain whitespace. Use phrase matching instead.');
if (!requestedScopes.length || requestedScopes.some((scope) => !MODERATION_SCOPES.has(scope))) {
return failure('Choose at least one valid content scope.');
}
if (termEnabled === null) return failure('Choose whether the rule is enabled or disabled.');
if (Array.from(note).length > 500) return failure('Operational notes may contain up to 500 characters.');
const database = await getDatabase();
const existing = id
? await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(id)
: null;
if (id && !existing) return fail(404, {
moderationTermError: 'That moderation rule no longer exists.',
moderationResultToken: randomUUID()
});
const conflicts = await (await database.prepare(`SELECT id,scopes_json FROM community_moderation_terms
WHERE normalized_value=? AND match_mode=? AND id<>?`))
.all(normalizedValue, matchMode, id || '');
const scopeConflict = conflicts.some((candidate) => {
try {
const candidateScopes = JSON.parse(String(candidate.scopes_json ?? '[]'));
return Array.isArray(candidateScopes) && candidateScopes.some((scope) => scopes.includes(String(scope)));
} catch {
return true;
}
});
if (scopeConflict) return fail(409, {
moderationTermError: 'A rule with the same normalized text and overlapping content scope already exists.',
moderationResultToken: randomUUID()
});
const termId = id || randomUUID();
const now = new Date().toISOString();
if (existing) {
await (await database.prepare(`UPDATE community_moderation_terms SET
list_kind=?,match_mode=?,value=?,normalized_value=?,scopes_json=?,enabled=?,
note=?,updated_at=?,updated_by=? WHERE id=?`)).run(
listType,
matchMode,
value,
normalizedValue,
JSON.stringify(scopes),
termEnabled,
note || null,
now,
operator.id,
termId
);
} else {
await (await database.prepare(`INSERT INTO community_moderation_terms
(id,list_kind,match_mode,value,normalized_value,scopes_json,enabled,note,created_at,created_by,updated_at,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
termId,
listType,
matchMode,
value,
normalizedValue,
JSON.stringify(scopes),
termEnabled,
note || null,
now,
operator.id,
now,
operator.id
);
}
const after = await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(termId);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: existing ? 'moderation.term_updated' : 'moderation.term_created',
entityId: termId,
before: existing,
after: after ?? null
});
invalidateCommunityModerationPolicy();
return {
moderationTermSaved: true,
moderationResultToken: randomUUID()
};
},
archiveModerationTerm: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const id = String((await request.formData()).get('id') ?? '').trim();
if (!id) return fail(400, {
moderationArchiveError: 'Choose a moderation rule to archive.',
moderationResultToken: randomUUID()
});
const database = await getDatabase();
const before = await (await database.prepare(
'SELECT * FROM community_moderation_terms WHERE id=? AND enabled=1'
)).get(id);
if (!before) return fail(404, {
moderationArchiveError: 'That enabled moderation rule no longer exists.',
moderationResultToken: randomUUID()
});
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE community_moderation_terms
SET enabled=0,updated_at=?,updated_by=? WHERE id=? AND enabled=1`))
.run(now, operator.id, id);
if (!result.changes) return fail(409, {
moderationArchiveError: 'That moderation rule changed before it could be archived.',
moderationResultToken: randomUUID()
});
const after = await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(id);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'moderation.term_archived',
entityId: id,
before,
after: after ?? null
});
invalidateCommunityModerationPolicy();
return {
moderationTermArchived: true,
moderationResultToken: randomUUID()
};
} }
}; };
+386 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { untrack } from 'svelte';
let { data, form } = $props(); let { data, form } = $props();
const binaryOptions = [ const binaryOptions = [
@@ -18,7 +19,52 @@
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' }, { key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' } { key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
] as const; ] as const;
const moderationModelOptions = [
{ value: 'omni-moderation-latest', label: 'LATEST' },
{ value: 'omni-moderation-2024-09-26', label: 'PINNED 2024-09-26' }
];
const termListOptions = [
{ value: 'ban', label: 'BAN' },
{ value: 'allow', label: 'ALLOW' }
];
const termMatchOptions = [
{ value: 'word', label: 'WORD' },
{ value: 'phrase', label: 'PHRASE' }
];
const termScopeOptions = [
{ value: 'comment', label: 'COMMENTS' },
{ value: 'profile_bio', label: 'PROFILE BIOS' },
{ value: 'guide', label: 'GUIDES' }
];
type ModerationTermDraft = {
id: string;
listType: 'allow' | 'ban';
matchMode: 'word' | 'phrase';
value: string;
scopes: string[];
enabled: string;
note: string;
};
const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0'; const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0';
const moderationSettingValue = (key: string) => Number(data.moderationSettings[key]) ? '1' : '0';
const parseScopes = (value: unknown) => {
try {
const scopes = JSON.parse(String(value ?? '[]'));
return Array.isArray(scopes) ? scopes.map(String) : [];
} catch {
return [];
}
};
const categoryLabel = (value: unknown) => String(value).replaceAll('_', ' ').replaceAll('/', ' / ').toUpperCase();
const blankModerationTerm = (): ModerationTermDraft => ({
id: '',
listType: 'ban',
matchMode: 'word',
value: '',
scopes: ['comment', 'profile_bio', 'guide'],
enabled: '1',
note: ''
});
let comingSoonEnabled = $state(settingValue('coming_soon_enabled')); let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
let accountCreationEnabled = $state(settingValue('account_creation_enabled')); let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
@@ -26,8 +72,34 @@
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)]) sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
)); ));
let reviewing = $state(false); let reviewing = $state(false);
let reviewingModerationPolicy = $state(false);
let archivingModerationTerm = $state<any>(null);
let editingModerationTerm = $state<ModerationTermDraft | null>(null);
let synchronizedAt = $state(''); let synchronizedAt = $state('');
let moderationSynchronizedAt = $state('');
let handledResultToken = $state(''); let handledResultToken = $state('');
let handledModerationResultToken = $state('');
let automatedEnabled = $state(moderationSettingValue('automated_enabled'));
let autoPublishClean = $state(moderationSettingValue('auto_publish_clean'));
let holdOnProviderError = $state(moderationSettingValue('hold_on_provider_error'));
let scanComments = $state(moderationSettingValue('scan_comments'));
let scanProfiles = $state(moderationSettingValue('scan_profiles'));
let scanGuides = $state(moderationSettingValue('scan_guides'));
let scanAvatars = $state(moderationSettingValue('scan_avatars'));
let detectPii = $state(moderationSettingValue('detect_pii'));
let providerModel = $state(untrack(() => String(data.moderationSettings.provider_model)));
let maxLinks = $state(untrack(() => String(data.moderationSettings.max_links)));
let duplicateWindowSeconds = $state(untrack(() => String(data.moderationSettings.duplicate_window_seconds)));
let thresholdDrafts = $state<Record<string, { enabled: string; review: string; priority: string }>>(
untrack(() => Object.fromEntries(data.moderationThresholds.map((threshold: any) => [
String(threshold.category),
{
enabled: Number(threshold.enabled) ? '1' : '0',
review: String(threshold.review_threshold),
priority: String(threshold.priority_threshold)
}
])))
);
const synchronize = () => { const synchronize = () => {
comingSoonEnabled = settingValue('coming_soon_enabled'); comingSoonEnabled = settingValue('coming_soon_enabled');
@@ -37,6 +109,27 @@
settingValue(`${section.key}_enabled`) settingValue(`${section.key}_enabled`)
])); ]));
}; };
const synchronizeModeration = () => {
automatedEnabled = moderationSettingValue('automated_enabled');
autoPublishClean = moderationSettingValue('auto_publish_clean');
holdOnProviderError = moderationSettingValue('hold_on_provider_error');
scanComments = moderationSettingValue('scan_comments');
scanProfiles = moderationSettingValue('scan_profiles');
scanGuides = moderationSettingValue('scan_guides');
scanAvatars = moderationSettingValue('scan_avatars');
detectPii = moderationSettingValue('detect_pii');
providerModel = String(data.moderationSettings.provider_model);
maxLinks = String(data.moderationSettings.max_links);
duplicateWindowSeconds = String(data.moderationSettings.duplicate_window_seconds);
thresholdDrafts = Object.fromEntries(data.moderationThresholds.map((threshold: any) => [
String(threshold.category),
{
enabled: Number(threshold.enabled) ? '1' : '0',
review: String(threshold.review_threshold),
priority: String(threshold.priority_threshold)
}
]));
};
$effect(() => { $effect(() => {
const updatedAt = String(data.settings.updated_at); const updatedAt = String(data.settings.updated_at);
if (updatedAt === synchronizedAt) return; if (updatedAt === synchronizedAt) return;
@@ -48,6 +141,20 @@
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
reviewing = false; reviewing = false;
}); });
$effect(() => {
const syncKey = `${data.moderationSettings.updated_at}:${data.moderationSettings.policy_version}`;
if (syncKey === moderationSynchronizedAt) return;
moderationSynchronizedAt = syncKey;
synchronizeModeration();
});
$effect(() => {
const token = String(form?.moderationResultToken ?? '');
if (!token || token === handledModerationResultToken) return;
handledModerationResultToken = token;
if (form?.moderationPolicySaved) reviewingModerationPolicy = false;
if (form?.moderationTermSaved) editingModerationTerm = null;
if (form?.moderationTermArchived) archivingModerationTerm = null;
});
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED'; const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED'; const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
@@ -71,12 +178,67 @@
]; ];
return rows.filter((row) => row.before !== row.after); return rows.filter((row) => row.before !== row.after);
}); });
const moderationChanges = $derived.by(() => {
const settingsRows = [
{ label: 'AUTOMATED MODERATION', before: Number(data.moderationSettings.automated_enabled) ? 'ENABLED' : 'DISABLED', after: automatedEnabled === '1' ? 'ENABLED' : 'DISABLED' },
{ label: 'CLEAN CONTENT', before: Number(data.moderationSettings.auto_publish_clean) ? 'AUTO-PUBLISH' : 'HOLD', after: autoPublishClean === '1' ? 'AUTO-PUBLISH' : 'HOLD' },
{ label: 'PROVIDER ERROR', before: Number(data.moderationSettings.hold_on_provider_error) ? 'HOLD' : 'PUBLISH', after: holdOnProviderError === '1' ? 'HOLD' : 'PUBLISH' },
{ label: 'COMMENTS', before: Number(data.moderationSettings.scan_comments) ? 'SCAN' : 'BYPASS', after: scanComments === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'PROFILE BIOS', before: Number(data.moderationSettings.scan_profiles) ? 'SCAN' : 'BYPASS', after: scanProfiles === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'GUIDES', before: Number(data.moderationSettings.scan_guides) ? 'SCAN' : 'BYPASS', after: scanGuides === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'AVATARS', before: Number(data.moderationSettings.scan_avatars) ? 'SCAN' : 'BYPASS', after: scanAvatars === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'PII DETECTION', before: Number(data.moderationSettings.detect_pii) ? 'ENABLED' : 'DISABLED', after: detectPii === '1' ? 'ENABLED' : 'DISABLED' },
{ label: 'PROVIDER MODEL', before: String(data.moderationSettings.provider_model), after: providerModel },
{ label: 'MAXIMUM LINKS', before: String(data.moderationSettings.max_links), after: maxLinks },
{ label: 'DUPLICATE WINDOW', before: `${data.moderationSettings.duplicate_window_seconds} SEC`, after: `${duplicateWindowSeconds} SEC` }
];
const thresholdRows = data.moderationThresholds.flatMap((threshold: any) => {
const category = String(threshold.category);
const draft = thresholdDrafts[category];
if (!draft) return [];
return [
{
label: `${categoryLabel(category)} STATE`,
before: Number(threshold.enabled) ? 'ENABLED' : 'DISABLED',
after: draft.enabled === '1' ? 'ENABLED' : 'DISABLED'
},
{
label: `${categoryLabel(category)} REVIEW`,
before: String(threshold.review_threshold),
after: draft.review
},
{
label: `${categoryLabel(category)} PRIORITY`,
before: String(threshold.priority_threshold),
after: draft.priority
}
];
});
return [...settingsRows, ...thresholdRows].filter((row) => row.before !== row.after);
});
const editModerationTerm = (term: any) => {
editingModerationTerm = {
id: String(term.id),
listType: String(term.list_kind) as 'allow' | 'ban',
matchMode: String(term.match_mode) as 'word' | 'phrase',
value: String(term.value),
scopes: parseScopes(term.scopes_json),
enabled: Number(term.enabled) ? '1' : '0',
note: String(term.note ?? '')
};
};
const date = (value: unknown) => value const date = (value: unknown) => value
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
: 'INITIAL CONFIGURATION'; : 'INITIAL CONFIGURATION';
</script> </script>
<svelte:head><title>Site controls — ISAC</title></svelte:head> <svelte:head><title>Site controls — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key !== 'Escape') return;
reviewing = false;
reviewingModerationPolicy = false;
archivingModerationTerm = null;
}}/>
<div class="module-toolbar site-control-toolbar"> <div class="module-toolbar site-control-toolbar">
<div> <div>
@@ -127,6 +289,148 @@
</div> </div>
</div> </div>
<section class="community-safety" aria-labelledby="community-safety-heading">
<header class="community-safety-head">
<div>
<p class="eyebrow">SYSTEM CONFIGURATION / COMMUNITY SAFETY</p>
<h2 id="community-safety-heading">COMMUNITY SAFETY</h2>
<p>Configure automated screening, exception thresholds, and deterministic allow or ban rules for user-submitted content.</p>
</div>
<div class="moderation-network-state">
<span>MODERATION PROVIDER</span>
<strong class:restricted={!data.providerConfigured}>{data.providerConfigured ? 'CONFIGURED' : 'KEY REQUIRED'}</strong>
<small>POLICY VERSION {data.moderationSettings.policy_version} · {automatedEnabled === '1' ? 'AUTOMATION ENABLED' : 'MANUAL REVIEW ONLY'}</small>
</div>
</header>
{#if !data.providerConfigured}
<div class="moderation-provider-warning" role="status">
<b>PROVIDER NOT CONFIGURED</b>
<span>Automated provider checks cannot run until an OpenAI moderation key is configured. Hold-on-error remains the safe publication policy.</span>
</div>
{/if}
<div class="moderation-policy-grid">
<section class="moderation-policy-panel moderation-master">
<header><div><span>AUTOMATION GATE</span><h3>SCREENING PIPELINE</h3></div><b class:restricted={automatedEnabled === '0'}>{automatedEnabled === '1' ? 'ACTIVE' : 'DISABLED'}</b></header>
<p>When enabled, selected content enters automated screening before publication. Reviewers receive exceptions, not a queue of clean submissions.</p>
<SegmentedChoice legend="AUTOMATED MODERATION" name="moderationEnabledDraft" options={binaryOptions} bind:value={automatedEnabled}/>
<SegmentedChoice legend="PROVIDER MODEL" name="providerModelDraft" options={moderationModelOptions} bind:value={providerModel}/>
</section>
<section class="moderation-policy-panel moderation-publication">
<header><div><span>PUBLICATION POLICY</span><h3>VISIBILITY DECISIONS</h3></div><b>{autoPublishClean === '1' ? 'EXCEPTION QUEUE' : 'REVIEW ALL'}</b></header>
<p>Clean content can publish immediately. Flagged, uncertain, or failed checks remain hidden for Community Oversight.</p>
<SegmentedChoice legend="CLEAN CONTENT" name="autoPublishCleanDraft" options={[{value:'1',label:'AUTO-PUBLISH'},{value:'0',label:'HOLD FOR REVIEW'}]} bind:value={autoPublishClean}/>
<SegmentedChoice legend="PROVIDER FAILURE" name="holdOnProviderErrorDraft" options={[{value:'1',label:'HOLD CONTENT'},{value:'0',label:'ALLOW CONTENT'}]} bind:value={holdOnProviderError}/>
</section>
<section class="moderation-policy-panel moderation-scopes">
<header><div><span>CONTENT COVERAGE</span><h3>SCREENED CHANNELS</h3></div><b>{[scanComments,scanProfiles,scanGuides,scanAvatars].filter((value) => value === '1').length} / 4 ACTIVE</b></header>
<p>Disable a channel only when its submission flow should bypass automated checks.</p>
<div class="moderation-scope-list">
<div><span><b>COMMENTS</b><small>Build, guide, and profile discussion</small></span><SegmentedChoice legend="COMMENTS" name="scanCommentsDraft" options={binaryOptions} bind:value={scanComments}/></div>
<div><span><b>PROFILE BIOS</b><small>Public Agent biography revisions</small></span><SegmentedChoice legend="PROFILE BIOS" name="scanProfilesDraft" options={binaryOptions} bind:value={scanProfiles}/></div>
<div><span><b>GUIDES</b><small>Long-form community field manuals</small></span><SegmentedChoice legend="GUIDES" name="scanGuidesDraft" options={binaryOptions} bind:value={scanGuides}/></div>
<div><span><b>AVATARS</b><small>Uploaded custom Agent portraits</small></span><SegmentedChoice legend="AVATARS" name="scanAvatarsDraft" options={binaryOptions} bind:value={scanAvatars}/></div>
</div>
</section>
<section class="moderation-policy-panel moderation-local-rules">
<header><div><span>DETERMINISTIC CHECKS</span><h3>LOCAL SAFETY LIMITS</h3></div><b>{detectPii === '1' ? 'PII ACTIVE' : 'PII DISABLED'}</b></header>
<p>Local rules supplement provider categories with privacy, link, duplicate, and site-specific terminology checks.</p>
<SegmentedChoice legend="PII DETECTION" name="detectPiiDraft" options={binaryOptions} bind:value={detectPii}/>
<div class="moderation-limit-grid">
<label class="field">MAXIMUM LINKS<input type="number" min="0" max="20" step="1" bind:value={maxLinks}/><small>0–20 links per submission</small></label>
<label class="field">DUPLICATE WINDOW · SECONDS<input type="number" min="0" max="3600" step="1" bind:value={duplicateWindowSeconds}/><small>0 disables duplicate timing</small></label>
</div>
</section>
</div>
<section class="moderation-threshold-panel">
<header>
<div><span>PROVIDER CATEGORY MATRIX</span><h3>REVIEW & PRIORITY THRESHOLDS</h3></div>
<p>Review scores hold content for Oversight. Priority scores must be equal to or higher than review scores and move urgent exceptions to the front of the queue.</p>
</header>
<div class="moderation-threshold-table">
<div class="moderation-threshold-row heading"><span>CATEGORY</span><span>STATE</span><span>REVIEW · 0–1</span><span>PRIORITY · 0–1</span></div>
{#each data.moderationThresholds as threshold}
{@const category = String(threshold.category)}
{@const draft = thresholdDrafts[category]}
{#if draft}
<div class="moderation-threshold-row">
<div><strong>{categoryLabel(category)}</strong><small>{draft.enabled === '1' ? 'AUTOMATED DECISIONS ENABLED' : 'CATEGORY IGNORED'}</small></div>
<SegmentedChoice legend={`${categoryLabel(category)} STATE`} name={`${category}EnabledDraft`} options={binaryOptions} bind:value={draft.enabled}/>
<label class="field"><span class="sr-only">{categoryLabel(category)} review threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.review}/></label>
<label class="field"><span class="sr-only">{categoryLabel(category)} priority threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.priority}/></label>
</div>
{/if}
{/each}
</div>
</section>
{#if form?.moderationPolicyError}<p class="error moderation-policy-error" role="alert">{form.moderationPolicyError}</p>{/if}
<div class="moderation-policy-actions">
<div>
<span>POLICY CHANGE SET</span>
<b>{moderationChanges.length ? `${moderationChanges.length} PENDING` : 'SYNCHRONIZED'}</b>
<small>UPDATED BY {data.moderationSettings.updated_by_name ?? 'SYSTEM BOOTSTRAP'} · {date(data.moderationSettings.updated_at)}</small>
</div>
<button class="primary" disabled={!moderationChanges.length} onclick={() => reviewingModerationPolicy = true}>REVIEW POLICY →</button>
</div>
<div class="moderation-rule-toolbar">
<div><span>DETERMINISTIC RULE REGISTRY</span><h3>ALLOWED & BANNED TERMS</h3><p>Normalized rules run alongside provider checks and can target comments, profile bios, guides, or any combination.</p></div>
<button class="primary" disabled={Boolean(editingModerationTerm)} onclick={() => editingModerationTerm = blankModerationTerm()}>ADD RULE +</button>
</div>
{#if editingModerationTerm}
{@const termDraft = editingModerationTerm}
<section class="dashboard-editor horizontal-editor moderation-term-editor" aria-label={editingModerationTerm.id ? `Edit ${editingModerationTerm.value}` : 'Add moderation rule'}>
<div class="editor-title">
<div><p class="eyebrow">{editingModerationTerm.id ? 'EDIT DETERMINISTIC RULE' : 'NEW DETERMINISTIC RULE'}</p><h3>{editingModerationTerm.id ? editingModerationTerm.value : 'DEFINE TERM'}</h3></div>
<button type="button" aria-label="Close moderation rule editor" onclick={() => editingModerationTerm = null}>×</button>
</div>
<PendingForm action="?/saveModerationTerm">
{#snippet children(pending)}
<input type="hidden" name="id" value={termDraft.id}/>
<div class="moderation-term-grid">
<label class="field moderation-term-value">WORD OR PHRASE<input name="value" bind:value={termDraft.value} maxlength="200" required/><small>Stored with NFKC normalization and lowercase matching.</small></label>
<SegmentedChoice legend="RULE EFFECT" name="listType" options={termListOptions} bind:value={termDraft.listType}/>
<SegmentedChoice legend="MATCH MODE" name="matchMode" options={termMatchOptions} bind:value={termDraft.matchMode}/>
<SegmentedChoice legend="RULE STATE" name="enabled" options={binaryOptions} bind:value={termDraft.enabled}/>
<SegmentedChoice legend="CONTENT SCOPES" name="scopes" options={termScopeOptions} mode="checkbox" bind:selected={termDraft.scopes}/>
<label class="field moderation-term-note">OPERATIONAL NOTE<textarea name="note" rows="3" maxlength="500" bind:value={termDraft.note} placeholder="Optional reason, exception context, or policy reference"></textarea></label>
</div>
{#if form?.moderationTermError}<p class="error" role="alert">{form.moderationTermError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => editingModerationTerm = null}>CANCEL</button>
<button class="primary" disabled={pending || !termDraft.value.trim() || !termDraft.scopes.length} aria-busy={pending}>{pending ? 'SAVING RULE…' : 'SAVE RULE →'}</button>
</div>
{/snippet}
</PendingForm>
</section>
{/if}
<div class="moderation-rule-registry">
<div class="moderation-rule-row heading"><span>RULE</span><span>SCOPES</span><span>STATE</span><span>UPDATED</span><span></span></div>
{#each data.moderationTerms as term}
<div class="moderation-rule-row" class:archived={!Number(term.enabled)}>
<div><strong>{term.value}</strong><small>{String(term.list_kind).toUpperCase()} · {String(term.match_mode).toUpperCase()}{term.note ? ` · ${term.note}` : ''}</small></div>
<div class="moderation-rule-scopes">{#each parseScopes(term.scopes_json) as scope}<b>{categoryLabel(scope)}</b>{/each}</div>
<span class:restricted={!Number(term.enabled)}>{Number(term.enabled) ? 'ENABLED' : 'ARCHIVED'}</span>
<span><b>{date(term.updated_at)}</b><small>{term.updated_by_name ?? 'SYSTEM'}</small></span>
<div class="moderation-rule-actions">
<button class="secondary" disabled={Boolean(editingModerationTerm)} onclick={() => editModerationTerm(term)}>EDIT</button>
{#if Number(term.enabled)}<button class="danger" disabled={Boolean(editingModerationTerm)} onclick={() => archivingModerationTerm = term}>ARCHIVE</button>{/if}
</div>
</div>
{:else}
<div class="moderation-rule-empty"><strong>NO DETERMINISTIC TERMS CONFIGURED</strong><span>Provider thresholds and local structural checks remain active. Add a term only for a specific SHD.build policy need.</span></div>
{/each}
</div>
</section>
{#if reviewing} {#if reviewing}
<div class="isac-modal-backdrop" role="presentation"> <div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title"> <div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title">
@@ -156,8 +460,88 @@
</div> </div>
{/if} {/if}
{#if reviewingModerationPolicy}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-policy-review" role="dialog" aria-modal="true" aria-labelledby="moderation-policy-review-title">
<div class="editor-title">
<div><p class="eyebrow">CONFIRM COMMUNITY SAFETY POLICY</p><h3 id="moderation-policy-review-title">{moderationChanges.length} POLICY CHANGE{moderationChanges.length === 1 ? '' : 'S'}</h3></div>
<button type="button" aria-label="Close moderation policy review" onclick={() => reviewingModerationPolicy = false}>×</button>
</div>
<p>These settings control which submissions are scanned, published, or held for Community Oversight. Applying them creates policy version {Number(data.moderationSettings.policy_version) + 1}.</p>
<div class="site-control-change-list moderation-change-list">
{#each moderationChanges as change}
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={['DISABLED','HOLD','BYPASS'].includes(change.after)}>{change.after}</span></div>
{/each}
</div>
<PendingForm action="?/saveModerationPolicy" successMessage="Community moderation policy updated.">
{#snippet children(pending)}
<input type="hidden" name="confirmation" value="apply-community-moderation-policy"/>
<input type="hidden" name="automatedEnabled" value={automatedEnabled}/>
<input type="hidden" name="autoPublishClean" value={autoPublishClean}/>
<input type="hidden" name="holdOnProviderError" value={holdOnProviderError}/>
<input type="hidden" name="scanComments" value={scanComments}/>
<input type="hidden" name="scanProfiles" value={scanProfiles}/>
<input type="hidden" name="scanGuides" value={scanGuides}/>
<input type="hidden" name="scanAvatars" value={scanAvatars}/>
<input type="hidden" name="detectPii" value={detectPii}/>
<input type="hidden" name="providerModel" value={providerModel}/>
<input type="hidden" name="maxLinks" value={maxLinks}/>
<input type="hidden" name="duplicateWindowSeconds" value={duplicateWindowSeconds}/>
{#each data.moderationThresholds as threshold}
{@const category = String(threshold.category)}
{@const draft = thresholdDrafts[category]}
{#if draft}
<input type="hidden" name={`thresholdEnabled:${category}`} value={draft.enabled}/>
<input type="hidden" name={`reviewThreshold:${category}`} value={draft.review}/>
<input type="hidden" name={`priorityThreshold:${category}`} value={draft.priority}/>
{/if}
{/each}
{#if form?.moderationPolicyError}<p class="error" role="alert">{form.moderationPolicyError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => reviewingModerationPolicy = false}>CANCEL</button>
<button class="primary" disabled={pending || !moderationChanges.length} aria-busy={pending}>{pending ? 'APPLYING POLICY…' : 'CONFIRM POLICY →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
{#if archivingModerationTerm}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-archive-review" role="dialog" aria-modal="true" aria-labelledby="moderation-archive-title">
<div class="editor-title">
<div><p class="eyebrow">ARCHIVE DETERMINISTIC RULE</p><h3 id="moderation-archive-title">{archivingModerationTerm.value}</h3></div>
<button type="button" aria-label="Close archive confirmation" onclick={() => archivingModerationTerm = null}>×</button>
</div>
<div class="moderation-archive-consequence">
<b>RULE WILL STOP MATCHING NEW CONTENT</b>
<p>Existing moderation decisions and audit evidence remain unchanged. The archived rule stays in this registry and can be edited and enabled again.</p>
</div>
<PendingForm action="?/archiveModerationTerm" successMessage="Moderation rule archived.">
{#snippet children(pending)}
<input type="hidden" name="id" value={archivingModerationTerm.id}/>
{#if form?.moderationArchiveError}<p class="error" role="alert">{form.moderationArchiveError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => archivingModerationTerm = null}>CANCEL</button>
<button class="danger" disabled={pending} aria-busy={pending}>{pending ? 'ARCHIVING…' : 'CONFIRM ARCHIVE →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
<style> <style>
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)} .site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)}
@media(max-width:900px){.site-control-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}} .community-safety{display:grid;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--shd-orange)}.community-safety-head{display:flex;align-items:stretch;justify-content:space-between;gap:12px}.community-safety-head>div:first-child{max-width:72ch}.community-safety-head h2,.community-safety-head p,.moderation-policy-panel h3,.moderation-policy-panel p,.moderation-threshold-panel h3,.moderation-threshold-panel p,.moderation-rule-toolbar h3,.moderation-rule-toolbar p{margin:0}.community-safety-head>div:first-child>p:last-child,.moderation-policy-panel>p,.moderation-threshold-panel>header>p,.moderation-rule-toolbar p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.moderation-network-state{min-width:270px;display:grid;align-content:center;padding:10px 14px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-network-state span,.moderation-network-state small,.moderation-policy-panel header span,.moderation-threshold-panel header span,.moderation-policy-actions span,.moderation-policy-actions small,.moderation-rule-toolbar span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-network-state strong{color:var(--shd-success);font-size:21px}.moderation-network-state strong.restricted,.moderation-policy-panel header b.restricted,.moderation-rule-row>span.restricted{color:var(--shd-danger)}.moderation-provider-warning{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--shd-danger);background:#1b1111}.moderation-provider-warning b{flex:0 0 auto;color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-provider-warning span{color:#d9aaa0;font-size:var(--shd-type-supporting)}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article{grid-template-columns:1fr}.site-control-actions{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}} .moderation-policy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-policy-panel{min-width:0;display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-panel>header{display:flex;align-items:start;justify-content:space-between;gap:12px;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.moderation-policy-panel header span{display:block}.moderation-policy-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.moderation-master :global(.segmented-choice),.moderation-publication :global(.segmented-choice),.moderation-local-rules :global(.segmented-choice){margin:0}.moderation-scope-list{display:grid;gap:5px}.moderation-scope-list>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(190px,.7fr);align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border-soft);background:#0d1011}.moderation-scope-list>div>span{min-width:0;display:grid;gap:2px}.moderation-scope-list small{color:var(--shd-text-muted);overflow-wrap:anywhere}.moderation-scope-list :global(.segmented-choice){margin:0}.moderation-limit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-limit-grid .field{margin:0}.moderation-limit-grid small{color:var(--shd-text-muted)}
.moderation-threshold-panel{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-threshold-panel>header{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);gap:16px;padding:12px;border-bottom:1px solid var(--shd-border)}.moderation-threshold-table{overflow-x:auto}.moderation-threshold-row{min-width:760px;display:grid;grid-template-columns:minmax(220px,1.2fr) minmax(210px,1fr) minmax(120px,.55fr) minmax(120px,.55fr);align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft)}.moderation-threshold-row:first-child{border-top:0}.moderation-threshold-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-threshold-row>div:first-child{min-width:0;display:grid;gap:2px}.moderation-threshold-row>div:first-child strong{overflow-wrap:anywhere}.moderation-threshold-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-threshold-row :global(.segmented-choice),.moderation-threshold-row .field{margin:0}.moderation-threshold-row .field input{margin:0}.moderation-policy-error{margin:0}.moderation-policy-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-actions>div{display:grid}.moderation-policy-actions b{color:var(--shd-orange)}
.moderation-rule-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;margin-top:12px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.moderation-rule-toolbar>div{max-width:72ch}.moderation-term-editor{margin:0}.moderation-term-grid{display:grid;grid-template-columns:minmax(240px,1.25fr) repeat(3,minmax(170px,.65fr));gap:8px;padding:10px}.moderation-term-grid>.moderation-term-value{grid-row:span 2}.moderation-term-grid>:global(.segmented-choice){align-self:end;margin:0}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4){grid-column:2/4}.moderation-term-note{grid-column:4;grid-row:2}.moderation-term-grid .field{margin:0}.moderation-term-grid small{color:var(--shd-text-muted)}.moderation-term-editor :global(form>.error){margin:0 10px}.moderation-term-editor :global(.form-actions){padding:10px;border-top:1px solid var(--shd-border)}
.moderation-rule-registry{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-rule-row{display:grid;grid-template-columns:minmax(230px,1.25fr) minmax(180px,.9fr) minmax(90px,.45fr) minmax(160px,.7fr) minmax(150px,.55fr);align-items:center;gap:9px;padding:9px;border-top:1px solid var(--shd-border-soft)}.moderation-rule-row:first-child{border-top:0}.moderation-rule-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-rule-row.archived{opacity:.65}.moderation-rule-row>div:first-child,.moderation-rule-row>span:nth-child(4){min-width:0;display:grid;gap:3px}.moderation-rule-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.moderation-rule-row>span{font-size:var(--shd-type-telemetry);font-weight:900}.moderation-rule-row>span:nth-child(4) small{color:var(--shd-text-muted);font-weight:400}.moderation-rule-scopes{display:flex;flex-wrap:wrap;gap:4px}.moderation-rule-scopes b{padding:3px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px}.moderation-rule-actions{display:grid;grid-template-columns:1fr 1fr;gap:5px}.moderation-rule-actions button{min-height:36px;padding:0 8px}.moderation-rule-empty{min-height:130px;display:grid;place-content:center;gap:5px;padding:20px;text-align:center}.moderation-rule-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-rule-empty span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
.moderation-policy-review{width:min(930px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.moderation-policy-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.moderation-change-list{max-height:48vh;overflow:auto}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){padding:12px;border-top:1px solid var(--shd-border)}.moderation-policy-review :global(form>.error),.moderation-archive-review :global(form>.error){margin:0 12px 12px}.moderation-archive-review{width:min(620px,calc(100vw - 28px))}.moderation-archive-consequence{display:grid;gap:5px;padding:14px}.moderation-archive-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-archive-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
@media(max-width:1100px){.moderation-term-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-term-grid>.moderation-term-value{grid-row:auto}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4),.moderation-term-note{grid-column:auto;grid-row:auto}.moderation-rule-registry{overflow-x:auto}.moderation-rule-row{min-width:900px}}
@media(max-width:900px){.site-control-grid,.moderation-policy-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}.community-safety-head{display:grid}.moderation-network-state{min-width:0}.moderation-threshold-panel>header{grid-template-columns:1fr}.moderation-provider-warning{align-items:start;flex-direction:column}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article,.moderation-scope-list>div,.moderation-limit-grid,.moderation-term-grid{grid-template-columns:1fr}.site-control-actions,.moderation-policy-actions,.moderation-rule-toolbar{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*,.moderation-policy-actions>button,.moderation-rule-toolbar>button{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}.moderation-scope-list :global(.segmented-choice span),.moderation-policy-panel :global(.segmented-choice span){min-height:44px}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.moderation-term-note{grid-column:auto}.moderation-term-editor :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.moderation-rule-row,.moderation-policy-panel button,.moderation-rule-toolbar button{transition:none}}
</style> </style>
+37 -5
View File
@@ -11,7 +11,7 @@
<p class="eyebrow"><span></span> SHD NETWORK POLICY</p> <p class="eyebrow"><span></span> SHD NETWORK POLICY</p>
<h1>TERMS & <em>PRIVACY.</em></h1> <h1>TERMS & <em>PRIVACY.</em></h1>
<p> <p>
Effective July 25, 2026. These terms set the rules for using SHD.build and explain what Effective July 27, 2026. These terms set the rules for using SHD.build and explain what
information we process to operate and secure the service. information we process to operate and secure the service.
</p> </p>
</header> </header>
@@ -76,6 +76,12 @@
or presents a safety, privacy, legal, or abuse risk. Removing content does not or presents a safety, privacy, legal, or abuse risk. Removing content does not
remove related moderation, audit, or legal records when SHD.build must retain them. remove related moderation, audit, or legal records when SHD.build must retain them.
</p> </p>
<p>
SHD.build may screen user content with local rules and OpenAI’s moderation API
before or after publication. Automated classification can publish clean content or
hold flagged content for Community Oversight; an authorized human makes the final
decision on held or reported content.
</p>
</div> </div>
</section> </section>
<section id="conduct"> <section id="conduct">
@@ -140,6 +146,31 @@
notification category, and email delivery defaults to off. OAuth and email notification category, and email delivery defaults to off. OAuth and email
providers process information under their own policies. providers process information under their own policies.
</p> </p>
<p>
When automated content screening is enabled, SHD.build sends the submitted text
or custom avatar image to OpenAI’s API for safety classification. SHD.build does
not include your password, authentication tokens, or private sign-in credentials
in that moderation request. We retain the resulting category scores, flags,
request reference, content hash, local-rule matches, and review outcome as needed
to operate moderation, investigate abuse, and document decisions.
</p>
<p>
After SHD.build sends content to OpenAI, OpenAI controls its processing under
its then-current service terms, privacy policy, data controls, and SHD.build’s
OpenAI account settings. Those rules may permit retention, human safety review,
or use for model improvement, including when data sharing has been enabled.
OpenAI currently states that API data is not used to train or improve its models
unless the API customer opts in, and that default abuse-monitoring logs may be
retained for up to 30 days, subject to stated exceptions. SHD.build cannot
guarantee that OpenAI’s terms or practices will remain unchanged. Review
OpenAI’s
<a href="https://developers.openai.com/api/docs/guides/your-data#default-usage-policies-by-endpoint" rel="external"
>API data controls</a
>,
<a href="https://openai.com/policies/services-agreement/" rel="external">Services Agreement</a>,
and <a href="https://openai.com/policies/privacy-policy/" rel="external">privacy policy</a>
for the current rules governing API content.
</p>
</div> </div>
</section> </section>
<section id="cookies"> <section id="cookies">
@@ -231,10 +262,11 @@
<div> <div>
<h2>THIRD-PARTY SERVICES</h2> <h2>THIRD-PARTY SERVICES</h2>
<p> <p>
SHD.build may connect to Discord, email delivery services, and future identity SHD.build may connect to OpenAI for content safety classification, Discord and
providers. Each provider applies its own terms. Links to other sites do not imply future identity providers for sign-in, and email services for message delivery.
endorsement. When you choose a linked sign-in method, you authorize SHD.build to Each provider applies its own terms. Links to other sites do not imply endorsement.
request the minimum provider data needed for that method. When you choose a linked sign-in method, you authorize SHD.build to request the
minimum provider data needed for that method.
</p> </p>
</div> </div>
</section> </section>