feat(isac): add oversight proposal corrections

This commit is contained in:
JB
2026-07-25 14:06:07 -04:00
parent 32dbef822b
commit e6ad7b4c4e
10 changed files with 131 additions and 21 deletions
+4 -2
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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.
+1
View File
@@ -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}}
+13
View File
@@ -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');
+3 -3
View File
@@ -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();
+36 -5
View File
@@ -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>
+1 -1
View File
@@ -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>