From bb01f9b69f68841b4fb225e2741cc31d4a0fdfbd Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 13:08:05 -0400 Subject: [PATCH] feat(isac): add moderated data contributions --- database/schema.sql | 33 ++++++++ docs/DESIGN_SYSTEM.md | 10 +++ docs/architecture.md | 21 ++++++ src/app.css | 1 + src/hooks.server.ts | 22 ++++++ src/lib/server/auth/authorization.ts | 9 +++ src/lib/server/data-moderation.ts | 66 ++++++++++++++++ src/routes/admin/+layout.server.ts | 11 ++- src/routes/admin/+layout.svelte | 33 +++++--- src/routes/admin/+page.server.ts | 2 +- src/routes/admin/brands/+page.server.ts | 4 +- .../admin/data-moderation/+page.server.ts | 75 +++++++++++++++++++ src/routes/admin/data-moderation/+page.svelte | 75 +++++++++++++++++++ src/routes/admin/gear/+page.server.ts | 4 +- src/routes/admin/protocols/+page.server.ts | 4 +- src/routes/admin/stats/+page.server.ts | 4 +- src/routes/admin/talents/+page.server.ts | 4 +- src/routes/admin/weapons/+page.server.ts | 4 +- 18 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/server/data-moderation.ts create mode 100644 src/routes/admin/data-moderation/+page.server.ts create mode 100644 src/routes/admin/data-moderation/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index 92ccdf7..b7e0be6 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 21103db..7cfb322 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 80a1915..5b66650 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/app.css b/src/app.css index 73082b7..c899f76 100644 --- a/src/app.css +++ b/src/app.css @@ -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} diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..7a86a52 --- /dev/null +++ b/src/hooks.server.ts @@ -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); +}; diff --git a/src/lib/server/auth/authorization.ts b/src/lib/server/auth/authorization.ts index 18e6784..4ace95f 100644 --- a/src/lib/server/auth/authorization.ts +++ b/src/lib/server/auth/authorization.ts @@ -31,6 +31,15 @@ export async function requirePermission(user: AuthUser | null, permission: strin return user; } +export async function requireDataView(user: AuthUser | null): Promise { + 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'); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts new file mode 100644 index 0000000..1f24fac --- /dev/null +++ b/src/lib/server/data-moderation.ts @@ -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 { + 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') + }; +} diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts index 49bd9f3..5f56308 100644 --- a/src/routes/admin/+layout.server.ts +++ b/src/routes/admin/+layout.server.ts @@ -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) + }; }; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 6a273c9..745511a 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -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 @@ {/if} - {#if can('data.manage')} + {#if canSeeData} {/if} {#if visibleOversightSections.length} @@ -45,6 +49,17 @@ {/if} -
{@render children()}
+
{ + if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') { + event.preventDefault(); + readOnlyBlocked = true; + } + }}> + {#if page.url.searchParams.has('proposal')}{/if} + {#if dataReadOnly}
READ-ONLY DATA ACCESSYou can inspect the registries but cannot submit or apply changes.
+ {:else if can('data.propose') && !can('data.manage')}
CONTRIBUTOR MODESaved edits and additions enter the approval queue; live builder data is not changed immediately.
{/if} + {#if readOnlyBlocked}{/if} + {@render children()} +
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 691f949..442308a 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -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'); diff --git a/src/routes/admin/brands/+page.server.ts b/src/routes/admin/brands/+page.server.ts index c299acb..ea6c716 100644 --- a/src/routes/admin/brands/+page.server.ts +++ b/src/routes/admin/brands/+page.server.ts @@ -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, diff --git a/src/routes/admin/data-moderation/+page.server.ts b/src/routes/admin/data-moderation/+page.server.ts new file mode 100644 index 0000000..65daa02 --- /dev/null +++ b/src/routes/admin/data-moderation/+page.server.ts @@ -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() }; + } +}; diff --git a/src/routes/admin/data-moderation/+page.svelte b/src/routes/admin/data-moderation/+page.svelte new file mode 100644 index 0000000..a723189 --- /dev/null +++ b/src/routes/admin/data-moderation/+page.svelte @@ -0,0 +1,75 @@ + + +Data change queue — ISAC +
+

DATA OVERSIGHT / CONTRIBUTION CONTROL

CHANGE MODERATION

Validate contributor intelligence before deployment to the live builder registry.

+
PENDING REVIEW{data.summary.pending ?? 0}CHANGE REQUESTS
+
+ +
+ + + + +
+ +
+
CHANGECONTRIBUTORSUBMITTEDSTATUS
+ {#each data.proposals as proposal} +
+
{proposal.module.slice(0,4).toUpperCase()}
{proposal.summary}{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}
+
{proposal.proposer_name}AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}
+ {date(proposal.created_at)} + {proposal.status.toUpperCase()} + {#if proposal.status === 'pending'}{:else}{/if} +
+ {:else}
NO {data.status.toUpperCase()} CHANGE REQUESTSAdjust the module or search filters to inspect another part of the queue.
{/each} +
+ +{#if reviewing} + +{/if} diff --git a/src/routes/admin/gear/+page.server.ts b/src/routes/admin/gear/+page.server.ts index 89aaaa3..e98db71 100644 --- a/src/routes/admin/gear/+page.server.ts +++ b/src/routes/admin/gear/+page.server.ts @@ -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, diff --git a/src/routes/admin/protocols/+page.server.ts b/src/routes/admin/protocols/+page.server.ts index 25b64d5..72ed39a 100644 --- a/src/routes/admin/protocols/+page.server.ts +++ b/src/routes/admin/protocols/+page.server.ts @@ -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) diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts index 6db3a54..fb65de5 100644 --- a/src/routes/admin/stats/+page.server.ts +++ b/src/routes/admin/stats/+page.server.ts @@ -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, diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index a5f523a..70667b0 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -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')) : ''; diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts index a1bdffc..c72ee9a 100644 --- a/src/routes/admin/weapons/+page.server.ts +++ b/src/routes/admin/weapons/+page.server.ts @@ -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,