feat(isac): add moderated data contributions

This commit is contained in:
JB
2026-07-25 13:08:05 -04:00
parent 6c982f7ed5
commit bb01f9b69f
18 changed files with 359 additions and 23 deletions
+33
View File
@@ -117,6 +117,9 @@ INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-moderator','Modera
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');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-view','data.view','View ISAC builder source data without changing live records');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-propose','data.propose','Suggest builder source-data additions and edits for approval');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-approve','data.approve','Review, approve, and reject proposed builder source-data changes');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-view','users.view','View users, activity, and content history');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-moderate','users.moderate','Block, ban, and reactivate users');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-roles','users.roles','Assign roles and direct permissions');
@@ -127,6 +130,19 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
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 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');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-data-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-data-propose');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-manage');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-approve');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-moderate');
@@ -140,6 +156,23 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-audit
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-audit-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view');
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('stats','talents','brands','gear','weapons','protocols')),
action TEXT NOT NULL,
entity_id TEXT,
summary TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
reviewed_by TEXT REFERENCES users(id),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT,
applied_at TEXT
);
CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC);
CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status);
CREATE TABLE IF NOT EXISTS discord_profiles (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discord_id TEXT NOT NULL UNIQUE,
+10
View File
@@ -503,3 +503,13 @@ All image uploads use the shared SHD drop-zone treatment and expose three equiva
selection paths: a file picker, drag and drop, and clipboard paste. The control shows
the selected filename and a local preview, but server-side format, size, transparency,
and normalization checks remain authoritative.
### Moderated data states
ISAC must clearly distinguish direct editing, contributor proposals, and read-only
access. Use a compact access banner above the module rather than changing the
registry layout. Pending proposal counts appear as orange numeric indicators in
data-navigation items. Proposal lifecycle states use text as well as color:
pending is orange/actionable, approved is green/configured, and rejected or
failed is red/archived. Review decisions always use the standard ISAC confirmation
surface and must never apply a proposal from a table-row click alone.
+21
View File
@@ -107,6 +107,27 @@ mutation rather than deletion or modification of the original event:
administrative access.
6. The server restores the previous state and appends an `audit.reverted` event
whose target is the original audit-event ID.
# Moderated game-data contributions
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
explicitly confirm approval or rejection.
Approval replays the stored form through its original SvelteKit server action.
This preserves canonical validation, relational updates, icon processing, and the
normal game-data audit event. Proposal creation and review decisions also produce
separate `data_moderation` audit events. Failed canonical validation marks a
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
modules must use the shared moderation interception path and audit both the final
mutation and proposal lifecycle.
7. A successfully reverted event cannot be reverted again.
Unsupported destructive identity operations, account deletion, incomplete
+1
View File
@@ -63,6 +63,7 @@
.avatar-picker{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}.avatar-preset-form fieldset{height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{font-size:6px;line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{margin-top:8px}
.image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:6px;text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:8px;letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}}
.icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:6px}
.proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:8px;letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:7px;color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a em{min-width:18px;padding:3px 5px;background:#8a5b16;color:#fff1d0;font-size:6px;font-style:normal;text-align:center}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(760px,100%)}.moderation-metadata{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:6px}.moderation-metadata b{color:var(--shd-text);font-size:8px}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:7px;cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:7px;white-space:pre-wrap}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata{grid-template-columns:1fr}}
.account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:6px;font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:9px}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 Georgia,serif}.danger-eyebrow{color:var(--shd-danger)}
.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1}
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
+22
View File
@@ -0,0 +1,22 @@
import type { Handle } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
export const handle: Handle = async ({ event, resolve }) => {
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/admin\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
if (module && DATA_MODULES.includes(module) && action) {
const user = await getSessionUser(event.cookies);
const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) {
const proposalId = await queueDataProposal(user, module, action, await event.request.formData());
const target = new URL(event.url.pathname, event.url);
target.searchParams.set('proposal', proposalId);
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
}
}
}
return resolve(event);
};
+9
View File
@@ -31,6 +31,15 @@ export async function requirePermission(user: AuthUser | null, permission: strin
return user;
}
export async function requireDataView(user: AuthUser | null): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !['data.view', 'data.manage', 'data.propose', 'data.approve'].some((permission) => permissions.includes(permission))) {
error(403, 'ISAC data access required');
}
return user;
}
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
+66
View File
@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import { getPermissions } from '$lib/server/auth/authorization';
import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
export const DATA_MODULES = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'] as const;
export type DataModule = typeof DATA_MODULES[number];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
async function serialize(form: FormData): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0) entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type
});
}
return entries;
}
export function deserializeProposal(payload: string): FormData {
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else form.append(entry.name, new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), entry.fileName);
}
return form;
}
export async function queueDataProposal(user: AuthUser, module: DataModule, action: string, form: FormData) {
const id = randomUUID();
const entityId = String(form.get('id') ?? '').trim() || null;
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const database = await getDatabase();
await (await database.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,summary,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, summary, payload, user.id, now);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: { module, action, entityId, summary }
});
return id;
}
export async function shouldModerateDataMutation(user: AuthUser | null) {
const permissions = await getPermissions(user);
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: permissions.includes('*') || permissions.includes('data.propose')
};
}
+10 -1
View File
@@ -1,11 +1,20 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions } from '$lib/server/auth/authorization';
import { redirect } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account');
return { permissions };
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
const pendingRows = canSeeData
? await (await (await getDatabase()).prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
: [];
return {
permissions,
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
pendingProposalTotal: pendingRows.reduce((total, row) => total + Number(row.count), 0)
};
};
+24 -9
View File
@@ -2,13 +2,16 @@
import { page } from '$app/state';
let { data, children } = $props();
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && !can('data.propose'));
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN' },
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR' }
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' },
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT', module: 'talents' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }
];
const userSections = [
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
@@ -16,6 +19,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/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
];
@@ -33,10 +37,10 @@
<div class="sidebar-label"><span>USER OPERATIONS</span><b>{String(visibleUserSections.length).padStart(2, '0')} MODULE{visibleUserSections.length === 1 ? '' : 'S'}</b></div>
<nav aria-label="User operations">{#each visibleUserSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
{/if}
{#if can('data.manage')}
{#if canSeeData}
<div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>{String(dataSections.length).padStart(2, '0')} MODULES</b></div>
<nav aria-label="Data management">
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em>{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
</nav>
{/if}
{#if visibleOversightSections.length}
@@ -45,6 +49,17 @@
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content">{@render children()}</section>
<section class="admin-content" class:data-readonly={dataReadOnly} onsubmit={(event) => {
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
event.preventDefault();
readOnlyBlocked = true;
}
}}>
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataReadOnly}<div class="data-access-banner"><b>READ-ONLY DATA ACCESS</b><span>You can inspect the registries but cannot submit or apply changes.</span></div>
{:else if can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><b>CONTRIBUTOR MODE</b><span>Saved edits and additions enter the approval queue; live builder data is not changed immediately.</span></div>{/if}
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{@render children()}
</section>
</div>
</main>
+1 -1
View File
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/stats');
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics');
redirect(303, '/account');
+2 -2
View File
@@ -1,4 +1,4 @@
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
@@ -11,7 +11,7 @@ import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconV
const thresholds = [2, 3, 4] as const;
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
@@ -0,0 +1,75 @@
import { fail } from '@sveltejs/kit';
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 { 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'];
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
const module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const conditions = ['p.status=?'];
const args: unknown[] = [status];
if (module) { conditions.push('p.module=?'); args.push(module); }
if (search) { conditions.push("(p.summary LIKE ? ESCAPE '\\' COLLATE NOCASE OR proposer.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR profile.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)"); const value = `%${search.replaceAll('\\','\\\\').replaceAll('%','\\%').replaceAll('_','\\_')}%`; args.push(value, value, value); }
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM data_change_proposals p JOIN users proposer ON proposer.id=p.proposed_by LEFT JOIN user_profiles profile ON profile.user_id=proposer.id ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const proposals = await (await database.prepare(`SELECT p.*,COALESCE(profile.display_name,proposer.username) AS proposer_name,
COALESCE(reviewer_profile.display_name,reviewer.username) AS reviewer_name
FROM data_change_proposals p
JOIN users proposer ON proposer.id=p.proposed_by
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
${where} ORDER BY p.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
};
export const actions: Actions = {
approve: async ({ cookies, request, fetch, url }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
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 target = new URL(`/admin/${proposal.module}?/${proposal.action}`, url.origin);
const response = await fetch(target, { method: 'POST', body: deserializeProposal(String(proposal.payload_json)), headers: { origin: url.origin } });
const now = new Date().toISOString();
if (!response.ok) {
const details = (await response.text()).slice(0, 1000);
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
}
await (await database.prepare("UPDATE data_change_proposals SET status='approved',reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
return { moderationSaved: true, moderationToken: randomUUID() };
},
reject: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
if (note.length < 3) return fail(400, { moderationError: 'Add a brief reason for rejecting this proposal.' });
const database = await getDatabase();
const proposal = await (await database.prepare("SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
const now = new Date().toISOString();
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
return { moderationSaved: true, moderationToken: randomUUID() };
}
};
@@ -0,0 +1,75 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'reject'>('approve');
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();
$effect(() => {
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
handledToken = form.moderationToken;
reviewing = null;
}
});
</script>
<svelte:head><title>Data change queue — 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 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={[
{value:'pending',label:'PENDING',count:data.summary.pending ?? 0,description:'Awaiting operator decision'},
{value:'approved',label:'APPROVED',count:data.summary.approved ?? 0,description:'Applied to live data'},
{value:'rejected',label:'REJECTED',count:data.summary.rejected ?? 0,description:'Declined by oversight'},
{value:'failed',label:'FAILED',count:data.summary.failed ?? 0,description:'Blocked by validation'}
]}/>
<form class="audit-filter moderation-filter">
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
<select name="module"><option value="">ALL DATA MODULES</option>{#each data.modules as module}<option value={module} selected={data.module === module}>{module.toUpperCase()}</option>{/each}</select>
<input type="hidden" name="status" value={data.status}/>
<button class="secondary">SEARCH QUEUE →</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table moderation-queue">
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
{#each data.proposals as proposal}
<div class="dashboard-row moderation-row">
<div class="change-identity"><span>{proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}</small></div></div>
<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}
</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>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{reviewing.module.toUpperCase()}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
<div class="evidence-header"><div><span>PROPOSED FORM PAYLOAD</span><small>CANONICAL VALIDATION RUNS WHEN APPROVED</small></div><b>{evidence(reviewing.payload_json).length} FIELDS</b></div>
<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>
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
<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>
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
</div>
</div>
{/if}
+2 -2
View File
@@ -1,4 +1,4 @@
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
@@ -11,7 +11,7 @@ const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
+2 -2
View File
@@ -1,5 +1,5 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
@@ -48,7 +48,7 @@ async function profileSnapshot(id: string) {
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
+2 -2
View File
@@ -1,5 +1,5 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
@@ -8,7 +8,7 @@ import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
+2 -2
View File
@@ -1,5 +1,5 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
@@ -11,7 +11,7 @@ import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconV
type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const db = await getDatabase();
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
+2 -2
View File
@@ -1,4 +1,4 @@
import { requirePermission } from '$lib/server/auth/authorization';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
@@ -23,7 +23,7 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit
});
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,