feat(isac): add oversight proposal corrections
This commit is contained in:
+4
-2
@@ -113,7 +113,7 @@ INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-admin','Admin','Full administrative access to all current and future ISAC capabilities');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-moderator','Moderator','Community moderation role reserved for build, guide, and comment tools');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-moderator','Community Oversight','Community oversight role reserved for build, guide, and comment tools');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-admin','system.admin','Unrestricted access to all administrative capabilities');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-isac-access','isac.access','Open the ISAC interface');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-manage','data.manage','Create and edit builder source data');
|
||||
@@ -133,7 +133,9 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-viewer','Data Viewer','Read-only access to ISAC game-data registries');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-contributor','Data Contributor','View game data and submit moderated change proposals');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-approver','Data Approver','Review proposals and apply approved changes to live game data');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-approver','Data Oversight','Review, correct, and decide proposals before applying approved changes to live game data');
|
||||
UPDATE roles SET name='Community Oversight',description='Community oversight role reserved for build, guide, and comment tools' WHERE id='role-moderator';
|
||||
UPDATE roles SET name='Data Oversight',description='Review, correct, and decide proposals before applying approved changes to live game data' WHERE id='role-data-approver';
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-viewer','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-viewer','permission-data-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-isac-access');
|
||||
|
||||
+20
-5
@@ -224,9 +224,9 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
||||
be textual and use the danger color; inactivity remains a separate activity
|
||||
classification rather than a punishment state.
|
||||
- Role names describe durable organizational responsibility. `Admin` is the
|
||||
unrestricted administrative role; `Moderator` is reserved for community
|
||||
content moderation and does not imply ISAC access. Operational ISAC access is
|
||||
delegated separately through scoped roles and permissions.
|
||||
unrestricted administrative role; `Community Oversight` is reserved for
|
||||
community content moderation and does not imply ISAC access. Operational
|
||||
ISAC access is delegated separately through scoped roles and permissions.
|
||||
- Roles & Permissions is a separate User Operations registry. Role definitions
|
||||
compose discrete capabilities through a dense permission catalogue with an
|
||||
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
|
||||
@@ -547,13 +547,28 @@ and other non-mutating inspection controls remain visible and interactive.
|
||||
|
||||
The ISAC command-status cluster includes an explicit `DATA MODE` cell alongside
|
||||
system, access, and environment. It reports the highest effective data authority
|
||||
in this order: `ADMIN`, `DATA MANAGER`, `APPROVER`, `CONTRIBUTOR`, then
|
||||
in this order: `ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then
|
||||
`READ-ONLY`. Use a neutral treatment for read-only, orange for contributor,
|
||||
blue for approval authority, and green for direct-management authority. The
|
||||
blue for Oversight authority, and green for direct-management authority. The
|
||||
label and value are mandatory because color is supplemental. At medium widths
|
||||
the four cells become a two-column grid; on narrow screens the cluster spans
|
||||
the command header width.
|
||||
|
||||
### Oversight proposal corrections
|
||||
|
||||
User-facing references to moderators and data approvers use the SHD term
|
||||
`Oversight`. Distinguish `Community Oversight` from `Data Oversight` when a role
|
||||
name must identify its operational domain.
|
||||
|
||||
Pending proposal review uses three equal decision controls: approve, correct,
|
||||
and reject. `CORRECT` is limited to small text or numeric fixes. Present the
|
||||
stored form fields in a compact two-column correction grid and identify their
|
||||
original field names; long descriptions and structured JSON values use
|
||||
textareas. Uploaded file evidence is visible but immutable. Every correction
|
||||
must be separately saved and audit logged before approval, and material changes
|
||||
should be rejected for contributor revision. Collapse the decision controls and
|
||||
correction fields to one column on narrow screens.
|
||||
|
||||
Data-navigation links with pending proposals use a dedicated four-column layout:
|
||||
module code, flexible label, square orange count cell, then navigation arrow.
|
||||
Never insert a count into the ordinary three-column link without changing its
|
||||
|
||||
@@ -113,7 +113,7 @@ mutation rather than deletion or modification of the original event:
|
||||
ISAC data access is split into `data.view`, `data.propose`, `data.manage`, and
|
||||
`data.approve`. Data Viewers can inspect registries. Data Contributors can submit
|
||||
the same editor forms, but the request is serialized into `data_change_proposals`
|
||||
instead of mutating canonical tables. Data Approvers review the queue and must
|
||||
instead of mutating canonical tables. Data Oversight reviews the queue and must
|
||||
explicitly confirm approval or rejection.
|
||||
|
||||
Approval replays the stored form through its original SvelteKit server action.
|
||||
@@ -124,8 +124,8 @@ proposal as failed without changing live data. Pending counts appear alongside
|
||||
each data registry in ISAC so collaborators can see concurrent suggestions.
|
||||
|
||||
The protected seeded roles are Data Viewer, Data Contributor, Data Editor, and
|
||||
Data Approver. Data Editor retains direct-write access for trusted operators;
|
||||
Data Approver combines direct-write access with queue review. New ISAC data
|
||||
Data Oversight. Data Editor retains direct-write access for trusted operators;
|
||||
Data Oversight combines direct-write access with queue review. New ISAC data
|
||||
modules must use the shared moderation interception path and audit both the final
|
||||
mutation and proposal lifecycle.
|
||||
|
||||
|
||||
@@ -134,4 +134,5 @@
|
||||
.contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:9px}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:8px;font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:9px;letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:8px;line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:8px;letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:7px}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}}
|
||||
.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}}
|
||||
.data-readonly .module-toolbar button,.data-readonly .dashboard-row:not(.table-heading)>button,.data-readonly .dashboard-editor,.data-readonly form[method=post]{display:none!important}
|
||||
.command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}}
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ export function deserializeProposal(payload: string): FormData {
|
||||
return form;
|
||||
}
|
||||
|
||||
export async function refreshProposalDependencies(proposalId: string, payload: string) {
|
||||
const database = await getDatabase();
|
||||
const text = serializedTextValues(payload).map((entry) => entry.value).join('\n');
|
||||
const candidates = await (await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE id<>? AND draft_entity_id IS NOT NULL`)).all(proposalId);
|
||||
const dependencies = candidates.filter((candidate) => text.includes(String(candidate.draft_entity_id)));
|
||||
await (await database.prepare('DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?')).run(proposalId);
|
||||
for (const dependency of dependencies) {
|
||||
await (await database.prepare('INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)')).run(proposalId, dependency.id);
|
||||
}
|
||||
return dependencies.map((dependency) => String(dependency.id));
|
||||
}
|
||||
|
||||
const serializedTextValues = (payload: string) =>
|
||||
(JSON.parse(payload) as SerializedEntry[]).filter((entry): entry is Extract<SerializedEntry, { kind: 'text' }> => entry.kind === 'text');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
|
||||
const guidelinesPage = $derived(page.url.pathname === '/admin/contributor-guidelines');
|
||||
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
|
||||
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.manage') ? 'DATA MANAGER' : can('data.approve') ? 'APPROVER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||||
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||
let readOnlyBlocked = $state(false);
|
||||
const dataSections = [
|
||||
@@ -23,7 +23,7 @@
|
||||
];
|
||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||||
const oversightSections = [
|
||||
{ href: '/admin/data-moderation', label: 'Data change queue', code: 'CHNG', permission: 'data.approve' },
|
||||
{ href: '/admin/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
|
||||
{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
];
|
||||
@@ -33,7 +33,7 @@
|
||||
<main class="admin-page">
|
||||
<header class="admin-command">
|
||||
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div><div class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:approver={dataModeClass === 'approver'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><span>DATA MODE</span><strong>{dataMode}</strong></div></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div><div class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><span>DATA MODE</span><strong>{dataMode}</strong></div></div>
|
||||
</header>
|
||||
<div class="admin-dashboard">
|
||||
<aside class="admin-sidebar">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<span>04</span>
|
||||
<div><h3>HOW PROPOSALS WORK</h3><p>Contributor saves do not immediately change the live builder. Each addition or edit enters the moderation queue with your account, timestamp, submitted values, and dependencies. Approvers may accept, reject, or ask for correction. Actions are audit logged.</p></div>
|
||||
<div><h3>HOW PROPOSALS WORK</h3><p>Contributor saves do not immediately change the live builder. Each addition or edit enters the moderation queue with your account, timestamp, submitted values, and dependencies. Oversight may correct minor errors, approve, reject, or ask for revision. Actions are audit logged.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>05</span>
|
||||
|
||||
@@ -3,13 +3,16 @@ import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { deserializeProposal } from '$lib/server/data-moderation';
|
||||
import { deserializeProposal, refreshProposalDependencies } from '$lib/server/data-moderation';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'];
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
@@ -37,6 +40,51 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
correct: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
let corrections: Array<{ index: number; value: string }>;
|
||||
try {
|
||||
const parsed = JSON.parse(String(form.get('corrections') ?? '[]'));
|
||||
if (!Array.isArray(parsed)) throw new Error();
|
||||
corrections = parsed.map((item) => ({ index: Number(item.index), value: String(item.value ?? '') }));
|
||||
} catch {
|
||||
return fail(400, { moderationError: 'The proposed corrections could not be read.' });
|
||||
}
|
||||
if (corrections.length > 250 || corrections.some((item) => !Number.isInteger(item.index) || item.index < 0 || item.value.length > 100_000)) {
|
||||
return fail(400, { moderationError: 'One or more corrected fields exceed the safe proposal limits.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const entries = JSON.parse(String(proposal.payload_json)) as SerializedEntry[];
|
||||
const changes: Array<{ index: number; name: string; before: string; after: string }> = [];
|
||||
for (const correction of corrections) {
|
||||
const entry = entries[correction.index];
|
||||
if (!entry || entry.kind !== 'text') return fail(400, { moderationError: 'Only existing text and numeric proposal fields may be corrected.' });
|
||||
if (entry.value !== correction.value) {
|
||||
changes.push({ index: correction.index, name: entry.name, before: entry.value, after: correction.value });
|
||||
entry.value = correction.value;
|
||||
}
|
||||
}
|
||||
if (!changes.length) return fail(400, { moderationError: 'No proposal fields were changed.' });
|
||||
const payload = JSON.stringify(entries);
|
||||
const oldDependencies = (await (await database.prepare('SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id')).all(id)).map((row) => String(row.depends_on_proposal_id));
|
||||
const name = entries.find((entry) => entry.kind === 'text' && entry.name === 'name')?.value.trim();
|
||||
const summary = `${String(proposal.action).replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
||||
await (await database.prepare('UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?')).run(payload, summary, id);
|
||||
const dependencies = await refreshProposalDependencies(id, payload);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.corrected',
|
||||
entityId: id,
|
||||
before: { summary: proposal.summary, dependencies: oldDependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.before })) },
|
||||
after: { summary, dependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.after })) }
|
||||
});
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
approve: async ({ cookies, request, fetch, url }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
|
||||
@@ -4,13 +4,23 @@
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
let { data, form } = $props();
|
||||
let reviewing = $state<any>(null);
|
||||
let decision = $state<'approve' | 'reject'>('approve');
|
||||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
||||
let correctionEntries = $state<Array<{ index: number; name: string; value: string; kind: string; fileName?: string }>>([]);
|
||||
let handledToken = $state('');
|
||||
const date = (value: string) => new Date(value).toLocaleString();
|
||||
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
|
||||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
|
||||
);
|
||||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||
const openReview = (proposal: any) => {
|
||||
reviewing = proposal;
|
||||
decision = 'approve';
|
||||
correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({
|
||||
index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName
|
||||
}));
|
||||
};
|
||||
const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value })));
|
||||
const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{');
|
||||
$effect(() => {
|
||||
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
|
||||
handledToken = form.moderationToken;
|
||||
@@ -19,9 +29,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Data change queue — ISAC</title></svelte:head>
|
||||
<svelte:head><title>Data Oversight — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar moderation-toolbar">
|
||||
<div><p class="eyebrow">DATA OVERSIGHT / CONTRIBUTION CONTROL</p><h2>CHANGE MODERATION</h2><p>Validate contributor intelligence before deployment to the live builder registry.</p></div>
|
||||
<div><p class="eyebrow">DATA OVERSIGHT / CONTRIBUTION CONTROL</p><h2>DATA OVERSIGHT</h2><p>Review, correct, and decide contributor intelligence before deployment to the live builder registry.</p></div>
|
||||
<div class="queue-readout"><span>PENDING REVIEW</span><strong>{data.summary.pending ?? 0}</strong><small>CHANGE REQUESTS</small></div>
|
||||
</div>
|
||||
<KpiFilters parameter="status" active={data.status} items={[
|
||||
@@ -45,7 +55,7 @@
|
||||
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
|
||||
<span>{date(proposal.created_at)}</span>
|
||||
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
|
||||
{#if proposal.status === 'pending'}<button onclick={() => { reviewing = proposal; decision = 'approve'; }}>REVIEW →</button>{:else}<button onclick={() => reviewing = proposal}>EVIDENCE →</button>{/if}
|
||||
{#if proposal.status === 'pending'}<button onclick={() => openReview(proposal)}>REVIEW →</button>{:else}<button onclick={() => openReview(proposal)}>EVIDENCE →</button>{/if}
|
||||
</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} CHANGE REQUESTS</strong><span>Adjust the module or search filters to inspect another part of the queue.</span></div>{/each}
|
||||
</div>
|
||||
@@ -59,7 +69,27 @@
|
||||
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
|
||||
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
|
||||
{#if reviewing.status === 'pending'}
|
||||
<div class="decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN WITHOUT CHANGES</small></span></button></div>
|
||||
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>FIX MINOR ERRORS</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
|
||||
{#if decision === 'correct'}
|
||||
<PendingForm action="?/correct">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
|
||||
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text').length} EDITABLE FIELDS</b></div>
|
||||
<div class="proposal-correction-grid">
|
||||
{#each correctionEntries as entry}
|
||||
{#if entry.kind === 'text'}
|
||||
<label class="field"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea bind:value={entry.value}></textarea>{:else}<input bind:value={entry.value}/>{/if}</label>
|
||||
{:else}
|
||||
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
|
||||
<div class="moderation-warning"><b>!</b><span>SAVE ONLY MINOR CORRECTIONS. MATERIAL CHANGES SHOULD BE REJECTED FOR CONTRIBUTOR REVISION.</span></div>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CORRECTIONS →'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
{:else}
|
||||
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={reviewing.id}/>
|
||||
@@ -69,6 +99,7 @@
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
{/if}
|
||||
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<section id="service"><span>01</span><div><h2>THE SERVICE</h2><p>SHD.build is an independent community loadout, build-guide, and discussion service. By accessing or using it, you agree to these terms. If you do not agree, do not use the service. Features may change, be suspended, or be discontinued as development continues.</p></div></section>
|
||||
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section>
|
||||
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section>
|
||||
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Moderators may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
|
||||
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
|
||||
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, and comments. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>OAuth and email providers process information under their own policies.</p></div></section>
|
||||
<section id="cookies"><span>06</span><div><h2>ESSENTIAL COOKIES</h2><p>SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.</p><div class="legal-table-wrap"><table class="legal-table"><thead><tr><th>COOKIE</th><th>PURPOSE</th><th>DURATION</th><th>PROTECTION</th></tr></thead><tbody><tr><td><code>shd_session</code></td><td>Keeps you securely signed in and associates requests with your server-side session.</td><td>Up to 30 days, or until sign-out or session invalidation.</td><td>HTTP-only, SameSite=Lax, Secure in production.</td></tr><tr><td><code>shd_discord_oauth_state</code></td><td>Validates a Discord sign-in or linking response and helps prevent request forgery.</td><td>Up to 10 minutes; removed when the callback is processed.</td><td>HTTP-only, SameSite=Lax, Secure in production; limited to the Discord authentication path.</td></tr></tbody></table></div><p>The informational notice remembers its dismissal in browser local storage under <code>shd-essential-cookie-notice</code>. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.</p><p>If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.</p></div></section>
|
||||
<section id="retention"><span>07</span><div><h2>RETENTION & DELETION</h2><p>IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.</p><p>Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.</p></div></section>
|
||||
|
||||
Reference in New Issue
Block a user