feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s

This commit is contained in:
jb
2026-08-01 21:42:09 -04:00
parent b47e2588e9
commit dd84dff055
288 changed files with 83254 additions and 38351 deletions
+7 -7
View File
@@ -5,11 +5,11 @@ import { notificationSummary } from '$lib/server/notifications';
import { getSiteAccessSettings } from '$lib/server/site-access';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
return {
user,
isacAccess: await hasPermission(user, 'isac.access'),
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
siteAccess: await getSiteAccessSettings()
};
const user = await getSessionUser(cookies);
return {
user,
isacAccess: await hasPermission(user, 'isac.access'),
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
siteAccess: await getSiteAccessSettings(),
};
};
+1 -1
View File
@@ -14,7 +14,7 @@
<section class="community-channels">
<div class="section-heading"><div><p class="eyebrow"><span></span> COMMUNITY CHANNELS</p><h2>ONE NETWORK. EVERY MISSION.</h2></div><p>SHD.build grows with the community and the game.</p></div>
<div class="community-channel-grid">
<a href="/news"><small>01 // FIELD REPORTS</small><strong>NEWS & UPDATES</strong><span>Track game announcements, updates, and community reporting.</span><b>OPEN CHANNEL →</b></a>
<a href="/news"><small>01 // FIELD REPORTS</small><strong>NEWS</strong><span>Track game announcements, updates, and community reporting.</span><b>OPEN CHANNEL →</b></a>
<a href="/database"><small>02 // ISAC ARCHIVE</small><strong>GAME DATABASE</strong><span>Reference attributes, talents, brands, gear, weapons, mods, and protocols.</span><b>QUERY DATABASE →</b></a>
<a href="/builds"><small>03 // AGENT LOADOUTS</small><strong>COMMUNITY BUILDS</strong><span>Discover, rate, copy, and discuss configurations from other agents.</span><b>EXPLORE BUILDS →</b></a>
<a href="/guides"><small>04 // FIELD MANUAL</small><strong>GUIDES</strong><span>Read detailed play styles, rotations, strategies, and build walkthroughs.</span><b>READ GUIDES →</b></a>
+494 -264
View File
@@ -4,286 +4,516 @@ import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploadedAvatar, selectPresetAvatar } from '$lib/server/avatars';
import {
AVATAR_PRESETS,
AvatarValidationError,
processAvatarUpload,
saveUploadedAvatar,
selectPresetAvatar,
} from '$lib/server/avatars';
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import {
emailDeliveryConfigured,
sendAuthenticationLink,
} from '$lib/server/auth/mail';
import {
canRemoveAuthenticationMethod,
getAuthenticationMethods,
} from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit';
import { randomUUID } from 'node:crypto';
import {
moderateCommunityImage,
recordCommunityModerationScan
moderateCommunityImage,
recordCommunityModerationScan,
} from '$lib/server/community-moderation';
import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
import {
saveProfileBio,
updateProfileVisibility,
} from '$lib/server/profile-settings';
import { agentProfileHref, displayNameKey } from '$lib/agents';
import {
createPublicProfileSlug,
displayNameIsAvailable
createPublicProfileSlug,
displayNameIsAvailable,
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const ownerProfile = await (await database.prepare(
'SELECT public_slug FROM user_profiles WHERE user_id=?'
)).get(user.id);
const profileTarget = new URL(
agentProfileHref({
id: user.id,
publicSlug: ownerProfile?.public_slug ? String(ownerProfile.public_slug) : null
}),
url.origin
);
const requestedPanel = url.searchParams.get('panel');
if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
const value = url.searchParams.get(key);
if (value) profileTarget.searchParams.set(key, value);
}
redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const ownerProfile = await (
await database.prepare(
'SELECT public_slug FROM user_profiles WHERE user_id=?',
)
).get(user.id);
const profileTarget = new URL(
agentProfileHref({
id: user.id,
publicSlug: ownerProfile?.public_slug
? String(ownerProfile.public_slug)
: null,
}),
url.origin,
);
const requestedPanel = url.searchParams.get('panel');
if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
const value = url.searchParams.get(key);
if (value) profileTarget.searchParams.set(key, value);
}
redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
};
export const actions: Actions = {
saveBio: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const result = await saveProfileBio(user, form.get('bio'));
if (!result.ok) {
return fail(400, {
bioError: result.error,
bioResultToken: randomUUID()
});
}
return {
bioSaved: true,
bioReviewQueued: result.reviewQueued,
bioMarkdown: result.bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const result = await updateProfileVisibility(
user,
(await request.formData()).get('isPublic')
);
if (!result.ok) {
return fail(400, {
profileVisibilityError: result.error,
profileVisibilityResultToken: randomUUID()
});
}
return {
profileVisibilitySaved: true,
profileIsPublic: result.isPublic,
profileVisibilityResultToken: randomUUID()
};
},
requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, { emailError: 'Enter a valid email address.' });
}
if (!emailDeliveryConfigured()) return fail(503, { emailError: 'Email delivery is not configured yet.' });
const database = await getDatabase();
const owner = await (await database.prepare('SELECT user_id FROM user_emails WHERE email=? COLLATE NOCASE AND user_id<>?')).get(email, user.id);
if (owner) return fail(409, { emailError: 'That email address is already linked to another agent.' });
const token = await createMagicLinkToken(database, user.id, email, 'verify_email');
await sendAuthenticationLink(email, token, 'verify_email');
return { emailVerificationSent: true };
},
removePasskey: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const methods = await getAuthenticationMethods(database, user.id);
if (!canRemoveAuthenticationMethod(methods, 'passkey')) {
return fail(409, { passkeyError: 'Add another sign-in method before removing your only passkey.' });
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(id, user.id);
return { passkeyRemoved: true };
},
removeEmailIdentity: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'email')) {
return fail(409, { emailError: 'Add another sign-in method before removing your verified email.' });
}
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(user.id);
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(user.id);
return { emailRemoved: true };
},
removeLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const password = String((await request.formData()).get('password') ?? '');
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { localCredentialError: 'Password sign-in is not configured.' });
if (!(await verifyPassword(credential.password_hash, password))) return fail(400, { localCredentialError: 'Your password is incorrect.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'local')) {
return fail(409, { localCredentialError: 'Add another sign-in method before removing username and password sign-in.' });
}
await (await database.prepare('UPDATE users SET login_username=NULL,password_hash=NULL WHERE id=?')).run(user.id);
return { localCredentialsRemoved: true };
},
selectAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const preset = String((await request.formData()).get('preset') ?? '');
try {
await selectPresetAvatar(await getDatabase(), user.id, preset);
return { avatarChanged: true };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
}
},
uploadAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const file = (await request.formData()).get('avatar');
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
try {
const output = await processAvatarUpload(file);
const moderation = await moderateCommunityImage({ contentType: 'avatar', image: output });
const database = await getDatabase();
const queueReview = moderation.decision !== 'approved';
const { uploadedAt, reviewId } = await saveUploadedAvatar(database, user.id, output, { queueReview });
await recordCommunityModerationScan({
contentType: 'avatar',
contentId: user.id,
authorId: user.id,
result: moderation
});
await recordIsacAudit({
actorId: user.id,
category: 'user_moderation',
action: 'avatar.submitted',
entityId: user.id,
after: {
status: queueReview ? 'pending_review' : 'automatically_approved',
uploadedAt,
byteSize: output.length,
reviewId
}
});
return { avatarChanged: true, avatarReviewQueued: queueReview };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
}
},
changeLoginUsername: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
const database = await getDatabase();
const credential = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.login_username !== 'string' || typeof credential?.password_hash !== 'string') {
return fail(409, { loginUsernameError: 'Add username and password sign-in before changing its username.' });
}
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { loginUsernameError: 'Use 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { loginUsernameError: 'That login username is reserved.' });
if (await (await database.prepare('SELECT id FROM users WHERE login_username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username = ? WHERE id = ?')).run(username, user.id);
return { loginUsernameChanged: true };
},
addLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
const password = String(form.get('newPassword') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { localCredentialError: 'Use a username with 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { localCredentialError: 'That login username is reserved.' });
if (password.length < 12 || password.length > 128) return fail(400, { localCredentialError: 'Use a password between 12 and 128 characters.' });
if (password !== confirm) return fail(400, { localCredentialError: 'The new passwords do not match.' });
const database = await getDatabase();
const current = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (current?.login_username || current?.password_hash) return fail(409, { localCredentialError: 'Username and password sign-in is already configured.' });
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) return fail(409, { localCredentialError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username=?,password_hash=? WHERE id=?'))
.run(username, await hashPassword(password), user.id);
return { localCredentialsAdded: true };
},
changeDisplayName: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const displayName = String((await request.formData()).get('displayName') ?? '').normalize('NFKC').trim();
const length = Array.from(displayName).length;
if (length < 2 || length > 40 || /[\p{C}\r\n]/u.test(displayName)) {
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
}
const now = new Date().toISOString(), database = await getDatabase();
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
return fail(409, { displayNameError: 'That display name is already in use.' });
}
const publicSlug = await createPublicProfileSlug(database, displayName, user.id);
await (await database.prepare(`INSERT INTO user_profiles
saveBio: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const result = await saveProfileBio(user, form.get('bio'));
if (!result.ok) {
return fail(400, {
bioError: result.error,
bioResultToken: randomUUID(),
});
}
return {
bioSaved: true,
bioReviewQueued: result.reviewQueued,
bioMarkdown: result.bio,
bioResultToken: randomUUID(),
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const result = await updateProfileVisibility(
user,
(await request.formData()).get('isPublic'),
);
if (!result.ok) {
return fail(400, {
profileVisibilityError: result.error,
profileVisibilityResultToken: randomUUID(),
});
}
return {
profileVisibilitySaved: true,
profileIsPublic: result.isPublic,
profileVisibilityResultToken: randomUUID(),
};
},
requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const email = String((await request.formData()).get('email') ?? '')
.normalize('NFKC')
.trim()
.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, { emailError: 'Enter a valid email address.' });
}
if (!emailDeliveryConfigured())
return fail(503, { emailError: 'Email delivery is not configured yet.' });
const database = await getDatabase();
const owner = await (
await database.prepare(
'SELECT user_id FROM user_emails WHERE email=? COLLATE NOCASE AND user_id<>?',
)
).get(email, user.id);
if (owner)
return fail(409, {
emailError: 'That email address is already linked to another agent.',
});
const token = await createMagicLinkToken(
database,
user.id,
email,
'verify_email',
);
await sendAuthenticationLink(email, token, 'verify_email');
return { emailVerificationSent: true };
},
removePasskey: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const methods = await getAuthenticationMethods(database, user.id);
if (!canRemoveAuthenticationMethod(methods, 'passkey')) {
return fail(409, {
passkeyError:
'Add another sign-in method before removing your only passkey.',
});
}
await (
await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')
).run(id, user.id);
return { passkeyRemoved: true };
},
removeEmailIdentity: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, user.id),
'email',
)
) {
return fail(409, {
emailError:
'Add another sign-in method before removing your verified email.',
});
}
await (
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
).run(user.id);
await (
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
).run(user.id);
return { emailRemoved: true };
},
removeLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const password = String((await request.formData()).get('password') ?? '');
const database = await getDatabase();
const credential = await (
await database.prepare('SELECT password_hash FROM users WHERE id=?')
).get(user.id);
if (typeof credential?.password_hash !== 'string')
return fail(409, {
localCredentialError: 'Password sign-in is not configured.',
});
if (!(await verifyPassword(credential.password_hash, password)))
return fail(400, { localCredentialError: 'Your password is incorrect.' });
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, user.id),
'local',
)
) {
return fail(409, {
localCredentialError:
'Add another sign-in method before removing username and password sign-in.',
});
}
await (
await database.prepare(
'UPDATE users SET login_username=NULL,password_hash=NULL WHERE id=?',
)
).run(user.id);
return { localCredentialsRemoved: true };
},
selectAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const preset = String((await request.formData()).get('preset') ?? '');
try {
await selectPresetAvatar(await getDatabase(), user.id, preset);
return { avatarChanged: true };
} catch (error) {
if (error instanceof AvatarValidationError)
return fail(400, { avatarError: error.message });
throw error;
}
},
uploadAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const file = (await request.formData()).get('avatar');
if (!(file instanceof File))
return fail(400, { avatarError: 'Choose an image to upload.' });
try {
const output = await processAvatarUpload(file);
const moderation = await moderateCommunityImage({
contentType: 'avatar',
image: output,
});
const database = await getDatabase();
const queueReview = moderation.decision !== 'approved';
const { uploadedAt, reviewId } = await saveUploadedAvatar(
database,
user.id,
output,
{ queueReview },
);
await recordCommunityModerationScan({
contentType: 'avatar',
contentId: user.id,
authorId: user.id,
result: moderation,
});
await recordIsacAudit({
actorId: user.id,
category: 'user_moderation',
action: 'avatar.submitted',
entityId: user.id,
after: {
status: queueReview ? 'pending_review' : 'automatically_approved',
uploadedAt,
byteSize: output.length,
reviewId,
},
});
return { avatarChanged: true, avatarReviewQueued: queueReview };
} catch (error) {
if (error instanceof AvatarValidationError)
return fail(400, { avatarError: error.message });
throw error;
}
},
changeLoginUsername: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const username = String((await request.formData()).get('username') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
const credential = await (
await database.prepare(
'SELECT login_username,password_hash FROM users WHERE id=?',
)
).get(user.id);
if (
typeof credential?.login_username !== 'string' ||
typeof credential?.password_hash !== 'string'
) {
return fail(409, {
loginUsernameError:
'Add username and password sign-in before changing its username.',
});
}
if (
username.length < 3 ||
username.length > 32 ||
!/^[A-Za-z0-9_-]+$/.test(username)
)
return fail(400, {
loginUsernameError:
'Use 3–32 ASCII letters, numbers, underscores, or hyphens.',
});
if (
['admin', 'administrator', 'moderator', 'shd', 'support'].includes(
username.toLowerCase(),
)
)
return fail(400, {
loginUsernameError: 'That login username is reserved.',
});
if (
await (
await database.prepare(
'SELECT id FROM users WHERE login_username = ? COLLATE NOCASE AND id <> ?',
)
).get(username, user.id)
)
return fail(409, {
loginUsernameError: 'That login username is already in use.',
});
await (
await database.prepare('UPDATE users SET login_username = ? WHERE id = ?')
).run(username, user.id);
return { loginUsernameChanged: true };
},
addLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const username = String(form.get('username') ?? '')
.normalize('NFKC')
.trim();
const password = String(form.get('newPassword') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
if (
username.length < 3 ||
username.length > 32 ||
!/^[A-Za-z0-9_-]+$/.test(username)
)
return fail(400, {
localCredentialError:
'Use a username with 3–32 ASCII letters, numbers, underscores, or hyphens.',
});
if (
['admin', 'administrator', 'moderator', 'shd', 'support'].includes(
username.toLowerCase(),
)
)
return fail(400, {
localCredentialError: 'That login username is reserved.',
});
if (password.length < 12 || password.length > 128)
return fail(400, {
localCredentialError: 'Use a password between 12 and 128 characters.',
});
if (password !== confirm)
return fail(400, {
localCredentialError: 'The new passwords do not match.',
});
const database = await getDatabase();
const current = await (
await database.prepare(
'SELECT login_username,password_hash FROM users WHERE id=?',
)
).get(user.id);
if (current?.login_username || current?.password_hash)
return fail(409, {
localCredentialError:
'Username and password sign-in is already configured.',
});
if (
await (
await database.prepare(
'SELECT id FROM users WHERE login_username=? COLLATE NOCASE',
)
).get(username)
)
return fail(409, {
localCredentialError: 'That login username is already in use.',
});
await (
await database.prepare(
'UPDATE users SET login_username=?,password_hash=? WHERE id=?',
)
).run(username, await hashPassword(password), user.id);
return { localCredentialsAdded: true };
},
changeDisplayName: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const displayName = String(
(await request.formData()).get('displayName') ?? '',
)
.normalize('NFKC')
.trim();
const length = Array.from(displayName).length;
if (length < 2 || length > 40 || /[\p{C}\r\n]/u.test(displayName)) {
return fail(400, {
displayNameError: 'Use 2–40 visible characters on one line.',
});
}
const now = new Date().toISOString(),
database = await getDatabase();
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
return fail(409, {
displayNameError: 'That display name is already in use.',
});
}
const publicSlug = await createPublicProfileSlug(
database,
displayName,
user.id,
);
await (
await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
public_slug=excluded.public_slug,
updated_at=excluded.updated_at`))
.run(user.id, displayName, displayNameKey(displayName), publicSlug, now);
return { displayNameChanged: true };
},
changePassword: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const current = String(form.get('currentPassword') ?? ''), next = String(form.get('newPassword') ?? ''), confirm = String(form.get('confirmPassword') ?? '');
if (next.length < 12 || next.length > 128) return fail(400, { passwordError: 'Use between 12 and 128 characters.' });
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { passwordError: 'Add username and password sign-in first.' });
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
await createSession(cookies, user.id);
return { passwordChanged: true };
},
deleteAccount: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
const password = String(form.get('password') ?? '');
const purgeSecurityData = form.get('purgeSecurityData') === 'on';
if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
return fail(400, { deleteError: 'Your password is incorrect.' });
}
if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner', purgeSecurityData))) {
return fail(409, { deleteError: 'This account has already been deleted.' });
}
cookies.delete('shd_session', { path: '/' });
redirect(303, '/?account_deleted=1');
},
unlinkDiscord: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const credential = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(user.id);
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'discord')) {
return fail(400, { discordUnlinkError: 'Add another sign-in method before unlinking Discord so you do not lose access.' });
}
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(user.id);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(user.id);
return { discordUnlinked: true };
}
updated_at=excluded.updated_at`)
).run(user.id, displayName, displayNameKey(displayName), publicSlug, now);
return { displayNameChanged: true };
},
changePassword: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const current = String(form.get('currentPassword') ?? ''),
next = String(form.get('newPassword') ?? ''),
confirm = String(form.get('confirmPassword') ?? '');
if (next.length < 12 || next.length > 128)
return fail(400, { passwordError: 'Use between 12 and 128 characters.' });
if (next !== confirm)
return fail(400, { passwordError: 'The new passwords do not match.' });
const database = await getDatabase();
const credential = await (
await database.prepare('SELECT password_hash FROM users WHERE id = ?')
).get(user.id);
if (typeof credential?.password_hash !== 'string')
return fail(409, {
passwordError: 'Add username and password sign-in first.',
});
if (
typeof credential?.password_hash === 'string' &&
!(await verifyPassword(credential.password_hash, current))
)
return fail(400, {
passwordError: 'Your current password is incorrect.',
});
await (
await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
).run(await hashPassword(next), user.id);
await (
await database.prepare('DELETE FROM sessions WHERE user_id = ?')
).run(user.id);
await createSession(cookies, user.id);
return { passwordChanged: true };
},
deleteAccount: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const confirmation = String(form.get('confirmation') ?? '')
.normalize('NFKC')
.trim();
const password = String(form.get('password') ?? '');
const purgeSecurityData = form.get('purgeSecurityData') === 'on';
if (confirmation !== 'DELETE')
return fail(400, {
deleteError: 'Enter DELETE exactly to confirm deletion.',
});
const database = await getDatabase();
const credential = await (
await database.prepare('SELECT password_hash FROM users WHERE id=?')
).get(user.id);
if (
typeof credential?.password_hash === 'string' &&
!(await verifyPassword(credential.password_hash, password))
) {
return fail(400, { deleteError: 'Your password is incorrect.' });
}
if (
!(await softDeleteAccount(
database,
user.id,
user.id,
'Deleted by account owner',
purgeSecurityData,
))
) {
return fail(409, {
deleteError: 'This account has already been deleted.',
});
}
cookies.delete('shd_session', { path: '/' });
redirect(303, '/?account_deleted=1');
},
unlinkDiscord: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const credential = await (
await database.prepare('SELECT discord_id FROM users WHERE id=?')
).get(user.id);
if (!credential?.discord_id)
return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, user.id),
'discord',
)
) {
return fail(400, {
discordUnlinkError:
'Add another sign-in method before unlinking Discord so you do not lose access.',
});
}
await (
await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')
).run(user.id);
await (
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
).run(user.id);
return { discordUnlinked: true };
},
};
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in?returnTo=%2Faccount%2Farticles');
const canManageNews = await hasPermission(user, 'news.manage');
const rows = await (
await (
await getDatabase()
)
.prepare(`SELECT articles.id,articles.kind,articles.slug,articles.visibility,
articles.pending_revision_id,articles.published_revision_id,articles.updated_at,draft.title
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.deleted_at IS NULL AND (articles.owner_id=? ${canManageNews ? "OR articles.kind='news'" : ''})
ORDER BY articles.updated_at DESC`)
).all(user.id);
return { articles: rows, canManageNews };
};
+4
View File
@@ -0,0 +1,4 @@
<script lang="ts">let{data}=$props();</script>
<svelte:head><title>Your Articles — SHD.build</title></svelte:head>
<main class="page account-articles"><header><div><p class="eyebrow"><span></span> AUTHOR WORKSPACE</p><h1>YOUR ARTICLES</h1><p>Return to private drafts, pending reviews, and published Guides or News transmissions.</p></div><div><a class="secondary" href="/guides/new">NEW GUIDE</a>{#if data.canManageNews}<a class="primary" href="/news/new">NEW NEWS ARTICLE</a>{/if}</div></header><section>{#each data.articles as article}<article><div><span>{article.kind.toUpperCase()} · {article.visibility.toUpperCase()}</span><h2>{article.title}</h2><small>{article.pending_revision_id?'REVIEW PENDING':article.published_revision_id?'PUBLISHED REVISION AVAILABLE':'PRIVATE DRAFT'} · UPDATED {new Date(article.updated_at).toLocaleDateString()}</small></div><a class="secondary" href={`/${article.kind==='guide'?'guides':'news'}/${article.slug}/edit`}>CONTINUE EDITING →</a></article>{:else}<div class="catalogue-empty"><span>◇</span><strong>NO ARTICLE DRAFTS</strong><p>Start a Guide when you are ready to share field knowledge.</p></div>{/each}</section></main>
<style>.account-articles{padding-top:28px}.account-articles>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:20px;padding:18px 0 20px;border-bottom:1px solid var(--shd-border);background:transparent}.account-articles>header h1,.account-articles>header p{margin:0}.account-articles>header>div:first-child>p:last-child{margin-top:8px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial)}.account-articles>header>div:last-child{display:flex;gap:7px}.account-articles>section{display:grid;margin-top:10px}.account-articles>section>article{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:16px;padding:14px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.account-articles>section>article:first-child{border-top:1px solid var(--shd-border)}.account-articles article>div{display:grid;gap:3px}.account-articles article span,.account-articles article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.account-articles article h2{margin:0}@media(max-width:650px){.account-articles>header{align-items:start;flex-direction:column}.account-articles>header>div:last-child{width:100%;display:grid;grid-template-columns:1fr}.account-articles>section>article{grid-template-columns:1fr}.account-articles>section>article>a{width:100%}}</style>
@@ -4,12 +4,19 @@ import { getSessionUser } from '$lib/server/auth/session';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in to view this contact message.');
const database = await getDatabase();
const message = await (await database.prepare(`SELECT contact_messages.* FROM contact_messages
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in to view this contact message.');
const database = await getDatabase();
const message = await (
await database.prepare(`SELECT contact_messages.* FROM contact_messages
JOIN contact_message_recipients ON contact_message_recipients.message_id=contact_messages.id
WHERE contact_messages.id=? AND contact_message_recipients.user_id=?`)).get(params.id, user.id);
if (!message) error(404, 'Contact message not found.');
return { message: { ...message, dmca: message.dmca_json ? JSON.parse(String(message.dmca_json)) : null } };
WHERE contact_messages.id=? AND contact_message_recipients.user_id=?`)
).get(params.id, user.id);
if (!message) error(404, 'Contact message not found.');
return {
message: {
...message,
dmca: message.dmca_json ? JSON.parse(String(message.dmca_json)) : null,
},
};
};
+203 -105
View File
@@ -4,121 +4,219 @@ import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
import {
OVERSIGHT_NOTIFICATION_CATEGORIES,
OVERSIGHT_NOTIFICATION_FREQUENCIES,
} from '$lib/server/oversight-notifications';
import {
notificationOrderBy,
parseNotificationSort,
parseNotificationState,
} from '$lib/server/notification-sort';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const state = parseNotificationState(url.searchParams.get('state'));
const sort = parseNotificationSort(url.searchParams.get('sort'));
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
if (state === 'unread') conditions.push('n.read_at IS NULL');
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
if (search) {
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const state = parseNotificationState(url.searchParams.get('state'));
const sort = parseNotificationSort(url.searchParams.get('sort'));
const { search, pattern } = getRegistrySearch(url);
const conditions = [
'n.user_id=?',
'(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)',
];
const args: unknown[] = [user.id];
if (state === 'unread') conditions.push('n.read_at IS NULL');
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
if (search) {
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = notificationOrderBy(sort);
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = notificationOrderBy(sort);
const total = Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`,
)
).get(...args)
)?.total ?? 0,
);
const pagination = getPagination(url, total);
const notifications = await (
await database.prepare(`SELECT n.* FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
const permissions = await getPermissions(user);
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
(category) => permissions.includes('*') || permissions.includes(category.permission)
);
const oversightRows = oversightCategories.length
? await (await database.prepare('SELECT * FROM oversight_notification_preferences WHERE user_id=?')).all(user.id)
: [];
const oversightPreferences = oversightCategories.map((category) => ({
...category,
frequency: String(oversightRows.find((row) => row.oversight_category === category.id)?.frequency ?? 'immediate')
}));
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
const stored = preferenceRows.find((row) => row.category === name);
return {
category: name,
inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled),
browser: Boolean(stored?.browser_enabled),
emailFrequency: String(stored?.email_frequency ?? 'none')
};
});
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (
await database.prepare(
'SELECT * FROM notification_preferences WHERE user_id=?',
)
).all(user.id);
const permissions = await getPermissions(user);
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
(category) =>
permissions.includes('*') || permissions.includes(category.permission),
);
const oversightRows = oversightCategories.length
? await (
await database.prepare(
'SELECT * FROM oversight_notification_preferences WHERE user_id=?',
)
).all(user.id)
: [];
const oversightPreferences = oversightCategories.map((category) => ({
...category,
frequency: String(
oversightRows.find((row) => row.oversight_category === category.id)
?.frequency ?? 'immediate',
),
}));
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
const stored = preferenceRows.find((row) => row.category === name);
return {
category: name,
inApp:
name === 'isac'
? true
: stored?.in_app_enabled === undefined
? true
: Boolean(stored.in_app_enabled),
browser: Boolean(stored?.browser_enabled),
emailFrequency: String(stored?.email_frequency ?? 'none'),
};
});
const unread = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return { notifications, preferences, oversightPreferences, pagination, filters: { state, sort, search }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
).get(user.id)
)?.count ?? 0,
);
return {
notifications,
preferences,
oversightPreferences,
pagination,
filters: { state, sort, search },
unread,
hasVerifiedEmail: Boolean(
await (
await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')
).get(user.id),
),
};
};
export const actions: Actions = {
markRead: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?')).run(new Date().toISOString(), id, user.id);
return { notificationUpdated: true };
},
markAllRead: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL')).run(new Date().toISOString(), user.id);
return { notificationsCleared: true };
},
savePreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
const hasEmail = Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id));
for (const category of NOTIFICATION_CATEGORIES) {
const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on';
const browser = form.get(`browser:${category}`) === 'on';
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.' });
if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.' });
await (await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
markRead: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('id') ?? '');
await (
await (
await getDatabase()
).prepare(
'UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?',
)
).run(new Date().toISOString(), id, user.id);
return { notificationUpdated: true };
},
markAllRead: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
await (
await (
await getDatabase()
).prepare(
'UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL',
)
).run(new Date().toISOString(), user.id);
return { notificationsCleared: true };
},
savePreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
const hasEmail = Boolean(
await (
await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')
).get(user.id),
);
for (const category of NOTIFICATION_CATEGORIES) {
const inApp =
category === 'isac' || form.get(`inApp:${category}`) === 'on';
const browser = form.get(`browser:${category}`) === 'on';
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
if (!['none', 'immediate', 'daily'].includes(emailFrequency))
return fail(400, {
notificationPreferenceError: 'Choose a valid email frequency.',
});
if (emailFrequency !== 'none' && !hasEmail)
return fail(400, {
notificationPreferenceError:
'Add and verify a magic-link email before enabling email notifications.',
});
await (
await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
}
return {
notificationPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID()
};
},
saveOversightPreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
(category) => permissions.includes('*') || permissions.includes(category.permission)
);
if (!available.length) return fail(403, { oversightPreferenceError: 'Oversight permission is required.' });
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
for (const category of available) {
const frequency = String(form.get(`oversight:${category.id}`) ?? 'immediate');
if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) {
return fail(400, { oversightPreferenceError: 'Choose a valid Oversight notification frequency.' });
}
await (await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at)
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`)
).run(
user.id,
category,
inApp ? 1 : 0,
browser ? 1 : 0,
emailFrequency,
now,
);
}
return {
notificationPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID(),
};
},
saveOversightPreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
(category) =>
permissions.includes('*') || permissions.includes(category.permission),
);
if (!available.length)
return fail(403, {
oversightPreferenceError: 'Oversight permission is required.',
});
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
for (const category of available) {
const frequency = String(
form.get(`oversight:${category.id}`) ?? 'immediate',
);
if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) {
return fail(400, {
oversightPreferenceError:
'Choose a valid Oversight notification frequency.',
});
}
await (
await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at)
VALUES(?,?,?,?) ON CONFLICT(user_id,oversight_category) DO UPDATE SET
frequency=excluded.frequency,updated_at=excluded.updated_at`)).run(user.id, category.id, frequency, now);
}
return {
oversightPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID()
};
}
frequency=excluded.frequency,updated_at=excluded.updated_at`)
).run(user.id, category.id, frequency, now);
}
return {
oversightPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID(),
};
},
};
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -30,7 +30,6 @@
id: string;
slug: string;
title: string;
summary: string | null;
updatedAt: string;
};
type AuthoredComment = {
@@ -45,6 +44,7 @@
parentId: string | null;
status: 'visible' | 'removed';
bodyHtml: string;
removalReason: string | null;
createdAt: string;
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string; roles: string[] };
canDelete: boolean;
@@ -53,6 +53,7 @@
hasReplies: boolean;
rootId: string;
isFollowing: boolean;
isPinned: boolean;
};
type PageData = {
agent: PublicAgent;
@@ -156,7 +157,7 @@
<svelte:head>
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, Guides, and community activity on SHD.build.`}/>
</svelte:head>
<main class="page public-agent-page">
@@ -172,7 +173,7 @@
<input type="hidden" name="isPublic" value={data.agent.isPublic ? '0' : '1'}/>
<button class="visibility-toggle" class:enabled={data.agent.isPublic} role="switch" aria-checked={data.agent.isPublic} aria-label={`Public profile ${data.agent.isPublic ? 'enabled' : 'hidden'}. Toggle visibility.`}><span aria-hidden="true"></span><b>{data.agent.isPublic ? 'PUBLIC' : 'HIDDEN'}</b></button>
</PendingForm>
<InfoPopover label="About profile visibility" title="PROFILE VISIBILITY" text="A hidden profile leaves your name and avatar on public builds, guides, and comments, but removes the profile from discovery and blocks new profile messages."/>
<InfoPopover label="About profile visibility" title="PROFILE VISIBILITY" text="A hidden profile leaves your name and avatar on public Builds, Guides, and comments, but removes the profile from discovery and blocks new profile messages."/>
<a class="mobile-account-jump" href="#account-control">ACCOUNT CONTROL ↓</a>
{:else if data.viewer.canReportProfile}
<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>
@@ -229,16 +230,15 @@
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, Attributes, and calculated performance.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
<div class="public-content-grid">
{#each data.guides as guide}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}><span>GUIDE</span><h3>{guide.title}</h3><p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p><footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLISHED GUIDES</strong><p>This Agent has not published a guide.</p></div>{/each}
{#each data.guides as guide}<a class="public-content-card" href={`/guides/${encodeURIComponent(guide.slug)}`}><span>GUIDE</span><h3>{guide.title}</h3><p>Open this Guide for the complete strategy, embedded Builds, and field notes.</p><footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLISHED GUIDES</strong><p>This Agent has not published a Guide.</p></div>{/each}
</div>
</section>
</div>
@@ -281,6 +281,7 @@
{#if comment.status === 'visible'}
<header>
<AgentIdentityLink agent={comment.author} roles={comment.author.roles} meta={dateLabel(comment.createdAt)} />
{#if comment.isPinned}<b class="pinned-comment-label">PINNED COMMENT</b>{/if}
</header>
<MarkdownContent html={comment.bodyHtml} compact />
<div class="comment-actions">
@@ -301,7 +302,14 @@
<input type="hidden" name="intent" value={comment.isFollowing ? 'unfollow' : 'follow'}/>
<button>{comment.isFollowing ? 'UNFOLLOW THREAD' : 'FOLLOW THREAD'}</button>
</PendingForm>
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain menuItem/>{/if}
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain menuItem/>{/if}
{#if data.viewer.isSelf && comment.depth === 0}
<PendingForm action="?/setPinnedProfileComment">
<input type="hidden" name="commentId" value={comment.id}/>
<input type="hidden" name="intent" value={comment.isPinned ? 'unpin' : 'pin'}/>
<button>{comment.isPinned ? 'UNPIN COMMENT' : 'PIN COMMENT'}</button>
</PendingForm>
{/if}
{#if comment.canDelete}
<button type="button" class="comment-remove danger" onclick={() => { deletingCommentId = comment.id; close(); }}>REMOVE MESSAGE</button>
{/if}
@@ -311,7 +319,7 @@
</div>
{:else}
<div class="removed-message">
<strong>MESSAGE REMOVED</strong>
<strong>{comment.removalReason === 'Removed by Community Oversight' ? 'MESSAGE REMOVED BY COMMUNITY OVERSIGHT' : 'MESSAGE REMOVED BY POSTER'}</strong>
<span>Replies remain visible to preserve the conversation.</span>
</div>
{/if}
@@ -322,7 +330,7 @@
{/if}
{#if deletingCommentId === comment.id}
<div class="comment-delete-confirm" role="alert">
<p>Remove this profile message?</p>
<p>Remove this message? It remains as a labeled placeholder only when replies depend on it.</p>
<div>
<button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button>
<PendingForm action="?/deleteProfileComment">
@@ -366,7 +374,7 @@
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
<p>Comments this Agent leaves on builds will appear here.</p>
</div>
{/each}
</div>
@@ -383,12 +391,13 @@
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.42fr);align-items:start;gap:10px;margin-top:10px}.agent-main-column,.agent-primary,.agent-activity{display:grid;gap:10px;min-width:0}.agent-main-column{grid-column:1}.agent-activity{position:sticky;top:88px;grid-column:2}.agent-activity .public-empty{min-height:110px}#account-control{scroll-margin-top:88px}.agent-published-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.agent-published-grid .public-content-grid{grid-template-columns:1fr}
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.bio-edit-trigger{min-height:32px;padding:0 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-orange);background:var(--shd-surface-active)}.visibility-toggle{min-height:32px;display:flex;align-items:center;gap:7px;padding:0 8px;border:1px solid var(--shd-border-strong);background:#0d1011;color:var(--shd-text-muted);cursor:pointer}.visibility-toggle>span{position:relative;width:26px;height:14px;border:1px solid var(--shd-border-strong);background:#070909}.visibility-toggle>span::after{content:"";position:absolute;top:2px;left:2px;width:8px;height:8px;background:var(--shd-text-dim)}.visibility-toggle.enabled{border-color:#6b5128;color:var(--shd-orange)}.visibility-toggle.enabled>span::after{left:14px;background:var(--shd-orange)}.visibility-toggle b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.owner-notifications>header>a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.owner-notification-list{display:grid}.owner-notification-list>a{min-width:0;display:grid;gap:5px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text-muted)}.owner-notification-list>a:first-child{border-top:0}.owner-notification-list>a.unread{box-shadow:inset 2px 0 var(--shd-orange)}.owner-notification-list>a.isac{box-shadow:inset 2px 0 var(--shd-info)}.owner-notification-list span{min-width:0;display:flex;justify-content:space-between;gap:8px}.owner-notification-list b,.owner-notification-list small{font-size:var(--shd-type-telemetry);letter-spacing:.5px}.owner-notification-list b{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.owner-notification-list small{color:var(--shd-text-dim);white-space:nowrap}.owner-notification-list strong{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
+40
View File
@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { saveArticleDraft } from '$lib/server/articles';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request, url }) => {
if (
!isAllowedShdBuildRequestOrigin(request.headers.get('origin'), url.origin)
) {
return json({ message: 'Request origin is not allowed.' }, { status: 403 });
}
const user = await getSessionUser(cookies);
if (!user)
return json({ message: 'Sign in to save this article.' }, { status: 401 });
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return json({ message: 'Draft request is invalid.' }, { status: 400 });
}
const result = await saveArticleDraft({
articleId: String(body.articleId ?? ''),
actorId: user.id,
canManageNews: await hasPermission(user, 'news.manage'),
title: body.title,
deck: body.deck,
document: body.document,
tagIds: Array.isArray(body.tagIds) ? body.tagIds : [],
channel: body.channel,
coverImageId: body.coverImageId,
coverAlt: body.coverAlt,
coverCaption: body.coverCaption,
version: Number(body.version),
});
return json(result.ok ? result : { message: result.message }, {
status: result.ok ? 200 : result.status,
});
};
+80
View File
@@ -0,0 +1,80 @@
import { json } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import {
ManagedImageValidationError,
processManagedImage,
saveManagedImageFile,
} from '$lib/server/managed-images';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request, url }) => {
if (
!isAllowedShdBuildRequestOrigin(request.headers.get('origin'), url.origin)
) {
return json({ message: 'Request origin is not allowed.' }, { status: 403 });
}
const user = await getSessionUser(cookies);
if (!user)
return json(
{ message: 'Sign in to upload article images.' },
{ status: 401 },
);
const form = await request.formData();
const articleId = String(form.get('articleId') ?? '').trim();
const file = form.get('image');
if (!(file instanceof File) || !file.size)
return json({ message: 'Choose an image to upload.' }, { status: 400 });
const database = await getDatabase();
const article = await (
await database.prepare(
'SELECT id,kind,owner_id FROM articles WHERE id=? AND deleted_at IS NULL',
)
).get(articleId);
if (
!article ||
(article.kind === 'guide'
? article.owner_id !== user.id
: !(await hasPermission(user, 'news.manage')))
) {
return json({ message: 'Article not found.' }, { status: 404 });
}
try {
const processed = await processManagedImage(file, false, false);
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO managed_images(
id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
created_by,created_at,updated_at
) VALUES(?,?,'General','["article"]',0,0,?,?,?,?,?,?,?,?)`)
).run(
id,
`Article image · ${id.slice(0, 8)}`,
storageKey,
file.name,
processed.output.length,
processed.width,
processed.height,
user.id,
now,
now,
);
await (
await database.prepare(
'INSERT INTO article_media(article_id,image_id,uploaded_by,created_at) VALUES(?,?,?,?)',
)
).run(articleId, id, user.id, now);
return json({ id, width: processed.width, height: processed.height });
} catch (cause) {
const message =
cause instanceof ManagedImageValidationError
? cause.message
: 'The image could not be uploaded.';
return json({ message }, { status: 400 });
}
};
+89 -89
View File
@@ -1,115 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({
row: { healthy: 1 } as { healthy?: number },
err: null as Error | null
row: { healthy: 1 } as { healthy?: number },
err: null as Error | null,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => {
if (state.err) {
throw state.err;
}
getDatabase: vi.fn(async () => {
if (state.err) {
throw state.err;
}
return {
prepare: async () => ({
get: async () => state.row
})
};
})
return {
prepare: async () => ({
get: async () => state.row,
}),
};
}),
}));
import { GET } from './health/+server';
describe('GET /api/health', () => {
let originalBuildNumber: string | undefined;
let originalCommitSha: string | undefined;
let originalImage: string | undefined;
let originalBuildNumber: string | undefined;
let originalCommitSha: string | undefined;
let originalImage: string | undefined;
beforeEach(() => {
state.row = { healthy: 1 };
state.err = null;
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
originalCommitSha = process.env.SHD_COMMIT_SHA;
originalImage = process.env.SHD_IMAGE;
delete process.env.SHD_BUILD_NUMBER;
delete process.env.SHD_COMMIT_SHA;
delete process.env.SHD_IMAGE;
});
beforeEach(() => {
state.row = { healthy: 1 };
state.err = null;
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
originalCommitSha = process.env.SHD_COMMIT_SHA;
originalImage = process.env.SHD_IMAGE;
delete process.env.SHD_BUILD_NUMBER;
delete process.env.SHD_COMMIT_SHA;
delete process.env.SHD_IMAGE;
});
afterEach(() => {
if (originalBuildNumber === undefined) {
delete process.env.SHD_BUILD_NUMBER;
} else {
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
}
afterEach(() => {
if (originalBuildNumber === undefined) {
delete process.env.SHD_BUILD_NUMBER;
} else {
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
}
if (originalCommitSha === undefined) {
delete process.env.SHD_COMMIT_SHA;
} else {
process.env.SHD_COMMIT_SHA = originalCommitSha;
}
if (originalCommitSha === undefined) {
delete process.env.SHD_COMMIT_SHA;
} else {
process.env.SHD_COMMIT_SHA = originalCommitSha;
}
if (originalImage === undefined) {
delete process.env.SHD_IMAGE;
} else {
process.env.SHD_IMAGE = originalImage;
}
});
if (originalImage === undefined) {
delete process.env.SHD_IMAGE;
} else {
process.env.SHD_IMAGE = originalImage;
}
});
it('returns healthy status and metadata when checks pass', async () => {
process.env.SHD_BUILD_NUMBER = '123';
process.env.SHD_COMMIT_SHA = 'abc1234';
it('returns healthy status and metadata when checks pass', async () => {
process.env.SHD_BUILD_NUMBER = '123';
process.env.SHD_COMMIT_SHA = 'abc1234';
const response = await GET();
const payload = await response.json();
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toMatchObject({
status: 'ok',
status_code: 200,
ready: true,
service: 'app',
version: expect.any(String),
build_number: '123',
build_revision: 'abc1234',
checks: {
database: {
status: 'ok',
error: null
}
}
});
});
expect(response.status).toBe(200);
expect(payload).toMatchObject({
status: 'ok',
status_code: 200,
ready: true,
service: 'app',
version: expect.any(String),
build_number: '123',
build_revision: 'abc1234',
checks: {
database: {
status: 'ok',
error: null,
},
},
});
});
it('returns degraded status and 503 when DB check fails', async () => {
state.err = new Error('database unavailable');
it('returns degraded status and 503 when DB check fails', async () => {
state.err = new Error('database unavailable');
const response = await GET();
const payload = await response.json();
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload).toMatchObject({
status: 'degraded',
status_code: 503,
ready: false,
checks: {
database: {
status: 'unhealthy',
error: 'database unavailable'
}
}
});
});
expect(response.status).toBe(503);
expect(payload).toMatchObject({
status: 'degraded',
status_code: 503,
ready: false,
checks: {
database: {
status: 'unhealthy',
error: 'database unavailable',
},
},
});
});
it('uses image tag as revision when commit sha is absent', async () => {
process.env.SHD_IMAGE = 'shd-build-production:7';
it('uses image tag as revision when commit sha is absent', async () => {
process.env.SHD_IMAGE = 'shd-build-production:7';
const response = await GET();
const payload = await response.json();
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.build_revision).toBe('7');
expect(payload.build_number).toBe('0');
});
expect(response.status).toBe(200);
expect(payload.build_revision).toBe('7');
expect(payload.build_number).toBe('0');
});
});
+47 -47
View File
@@ -3,63 +3,63 @@ import { json } from '@sveltejs/kit';
import { version as appVersion } from '../../../../package.json';
function configuredBuildNumber() {
const raw = process.env.SHD_BUILD_NUMBER?.trim();
if (!raw || !/^\d+$/.test(raw)) return '0';
return raw;
const raw = process.env.SHD_BUILD_NUMBER?.trim();
if (!raw || !/^\d+$/.test(raw)) return '0';
return raw;
}
function inferredRevision() {
const direct = process.env.SHD_COMMIT_SHA?.trim();
if (direct) return direct;
const direct = process.env.SHD_COMMIT_SHA?.trim();
if (direct) return direct;
const image = process.env.SHD_IMAGE?.trim();
if (!image) return null;
const image = process.env.SHD_IMAGE?.trim();
if (!image) return null;
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
return tag ? tag : null;
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
return tag ? tag : null;
}
export async function GET() {
const startedAt = new Date().toISOString();
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
let databaseError: string | null = null;
const startedAt = new Date().toISOString();
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
let databaseError: string | null = null;
try {
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const row = await statement.get();
if (row?.healthy !== 1) {
databaseStatus = 'unhealthy';
databaseError = 'health-check query returned unexpected value';
}
} catch (err) {
databaseStatus = 'unhealthy';
databaseError = err instanceof Error ? err.message : String(err);
}
try {
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const row = await statement.get();
if (row?.healthy !== 1) {
databaseStatus = 'unhealthy';
databaseError = 'health-check query returned unexpected value';
}
} catch (err) {
databaseStatus = 'unhealthy';
databaseError = err instanceof Error ? err.message : String(err);
}
const ready = databaseStatus === 'ok';
const responsePayload = {
status: 'ok',
service: 'app',
status_code: 200,
version: appVersion,
build_revision: inferredRevision(),
build_number: configuredBuildNumber(),
ready,
uptime_ms: process.uptime() * 1000,
timestamp: startedAt,
checks: {
database: {
status: databaseStatus,
error: databaseError
}
}
};
const ready = databaseStatus === 'ok';
const responsePayload = {
status: 'ok',
service: 'app',
status_code: 200,
version: appVersion,
build_revision: inferredRevision(),
build_number: configuredBuildNumber(),
ready,
uptime_ms: process.uptime() * 1000,
timestamp: startedAt,
checks: {
database: {
status: databaseStatus,
error: databaseError,
},
},
};
if (!ready) {
responsePayload.status = 'degraded';
responsePayload.status_code = 503;
}
if (!ready) {
responsePayload.status = 'degraded';
responsePayload.status_code = 503;
}
return json(responsePayload, { status: ready ? 200 : 503 });
return json(responsePayload, { status: ready ? 200 : 503 });
}
+7 -7
View File
@@ -2,12 +2,12 @@ import { getDatabase } from '$lib/server/database';
import { json } from '@sveltejs/kit';
export async function GET() {
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const result = await statement.get();
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const result = await statement.get();
return json({
status: result?.healthy === 1 ? 'ok' : 'error',
engine: '@tursodatabase/database'
});
return json({
status: result?.healthy === 1 ? 'ok' : 'error',
engine: '@tursodatabase/database',
});
}
+11 -11
View File
@@ -1,20 +1,20 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { runScheduledNotificationTasks } from '$lib/server/scheduled-notifications';
import { runScheduledTasks } from '$lib/server/scheduled-tasks';
import { json } from '@sveltejs/kit';
function tokenMatches(header: string | null) {
const configured = env.SCHEDULER_TOKEN;
const provided = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (!configured || !provided) return false;
const expectedHash = createHash('sha256').update(configured).digest();
const providedHash = createHash('sha256').update(provided).digest();
return timingSafeEqual(expectedHash, providedHash);
const configured = env.SCHEDULER_TOKEN;
const provided = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (!configured || !provided) return false;
const expectedHash = createHash('sha256').update(configured).digest();
const providedHash = createHash('sha256').update(provided).digest();
return timingSafeEqual(expectedHash, providedHash);
}
export async function POST({ request }) {
if (!tokenMatches(request.headers.get('authorization'))) {
return json({ error: 'Not found' }, { status: 404 });
}
return json({ status: 'ok', ...(await runScheduledNotificationTasks()) });
if (!tokenMatches(request.headers.get('authorization'))) {
return json({ error: 'Not found' }, { status: 404 });
}
return json({ status: 'ok', ...(await runScheduledTasks()) });
}
@@ -2,16 +2,16 @@ import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { recordIsacAudit } from '$lib/server/audit';
import {
AiMechanicConfigurationError,
AiMechanicGenerationError
AiMechanicConfigurationError,
AiMechanicGenerationError,
} from '$lib/server/ai/mechanic-authoring-service';
import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
AiMechanicRateLimitError,
reserveAiMechanicGeneration,
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import {
AiCalculationRequestError,
parseAiCalculationAuthoringRequest
AiCalculationRequestError,
parseAiCalculationAuthoringRequest,
} from '$lib/server/ai/calculation-authoring-schema';
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt';
@@ -22,91 +22,108 @@ import type { RequestHandler } from './$types';
const MAX_REQUEST_BYTES = 350_000;
export const POST: RequestHandler = async ({ cookies, request, url }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.calculation.generate'
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin calculation authoring requests are not allowed.' },
{ status: 403 }
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
}
payload = JSON.parse(body);
} catch {
return json({ message: 'The calculation authoring request must be valid JSON.' }, { status: 400 });
}
try {
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiCalculationDraft(authoringRequest, operator.id);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'calculation_ai.generated',
entityId: authoringRequest.baseRevisionId
? `calculation_revision:${authoringRequest.baseRevisionId}`
: `calculation_revision_draft:${response.meta.requestId}`,
after: {
promptVersion: AI_CALCULATION_PROMPT_VERSION,
baseRevisionId: authoringRequest.baseRevisionId,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens
},
clarificationCount: response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount: response.status === 'capability_gap'
? response.gaps.length
: 0,
draftValidated: response.status === 'draft_ready'
}
});
return json(response);
} catch (caught) {
if (caught instanceof AiCalculationRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) }
}
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Calculation authoring assistant failed.', caught);
return json(
{ message: 'The AI calculation authoring service is temporarily unavailable.' },
{ status: 502 }
);
}
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.calculation.generate',
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{
message: 'Cross-origin calculation authoring requests are not allowed.',
},
{ status: 403 },
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json(
{ message: 'The calculation authoring request is too large.' },
{ status: 413 },
);
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json(
{ message: 'The calculation authoring request is too large.' },
{ status: 413 },
);
}
payload = JSON.parse(body);
} catch {
return json(
{ message: 'The calculation authoring request must be valid JSON.' },
{ status: 400 },
);
}
try {
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiCalculationDraft(
authoringRequest,
operator.id,
);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'calculation_ai.generated',
entityId: authoringRequest.baseRevisionId
? `calculation_revision:${authoringRequest.baseRevisionId}`
: `calculation_revision_draft:${response.meta.requestId}`,
after: {
promptVersion: AI_CALCULATION_PROMPT_VERSION,
baseRevisionId: authoringRequest.baseRevisionId,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens,
},
clarificationCount:
response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount:
response.status === 'capability_gap' ? response.gaps.length : 0,
draftValidated: response.status === 'draft_ready',
},
});
return json(response);
} catch (caught) {
if (caught instanceof AiCalculationRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) },
},
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Calculation authoring assistant failed.', caught);
return json(
{
message:
'The AI calculation authoring service is temporarily unavailable.',
},
{ status: 502 },
);
}
};
+107 -93
View File
@@ -2,18 +2,18 @@ import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { recordIsacAudit } from '$lib/server/audit';
import {
AiMechanicConfigurationError,
AiMechanicGenerationError,
generateAiMechanicDraft
AiMechanicConfigurationError,
AiMechanicGenerationError,
generateAiMechanicDraft,
} from '$lib/server/ai/mechanic-authoring-service';
import { AI_MECHANIC_PROMPT_VERSION } from '$lib/server/ai/mechanic-authoring-prompt';
import {
AiMechanicRequestError,
parseAiMechanicAuthoringRequest
AiMechanicRequestError,
parseAiMechanicAuthoringRequest,
} from '$lib/server/ai/mechanic-authoring-schema';
import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
AiMechanicRateLimitError,
reserveAiMechanicGeneration,
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
@@ -22,90 +22,104 @@ import type { RequestHandler } from './$types';
const MAX_REQUEST_BYTES = 100_000;
export const POST: RequestHandler = async ({ cookies, request, url }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 }
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
}
payload = JSON.parse(body);
} catch {
return json({ message: 'The mechanic authoring request must be valid JSON.' }, { status: 400 });
}
try {
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiMechanicDraft(authoringRequest, operator.id);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'mechanic_ai.generated',
entityId: `${authoringRequest.context.sourceKind}:${response.meta.requestId}`,
after: {
sourceKind: authoringRequest.context.sourceKind,
promptVersion: AI_MECHANIC_PROMPT_VERSION,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens
},
variantKeys: authoringRequest.valueColumns.map((column) => column.variantKey),
clarificationCount: response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount: response.status === 'capability_gap'
? response.gaps.length
: 0,
draftValidated: response.status === 'draft_ready'
}
});
return json(response);
} catch (caught) {
if (caught instanceof AiMechanicRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) }
}
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Mechanic authoring assistant failed.', caught);
return json(
{ message: 'The AI mechanic authoring service is temporarily unavailable.' },
{ status: 502 }
);
}
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.mechanic.generate',
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 },
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json(
{ message: 'The mechanic authoring request is too large.' },
{ status: 413 },
);
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json(
{ message: 'The mechanic authoring request is too large.' },
{ status: 413 },
);
}
payload = JSON.parse(body);
} catch {
return json(
{ message: 'The mechanic authoring request must be valid JSON.' },
{ status: 400 },
);
}
try {
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiMechanicDraft(authoringRequest, operator.id);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'mechanic_ai.generated',
entityId: `${authoringRequest.context.sourceKind}:${response.meta.requestId}`,
after: {
sourceKind: authoringRequest.context.sourceKind,
promptVersion: AI_MECHANIC_PROMPT_VERSION,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens,
},
variantKeys: authoringRequest.valueColumns.map(
(column) => column.variantKey,
),
clarificationCount:
response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount:
response.status === 'capability_gap' ? response.gaps.length : 0,
draftValidated: response.status === 'draft_ready',
},
});
return json(response);
} catch (caught) {
if (caught instanceof AiMechanicRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) },
},
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Mechanic authoring assistant failed.', caught);
return json(
{
message:
'The AI mechanic authoring service is temporarily unavailable.',
},
{ status: 502 },
);
}
};
@@ -1,9 +1,9 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import {
MechanicGapReportError,
parseMechanicGapReport,
submitMechanicGapReport
MechanicGapReportError,
parseMechanicGapReport,
submitMechanicGapReport,
} from '$lib/server/ai/mechanic-gap-report';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
@@ -12,36 +12,48 @@ import type { RequestHandler } from './$types';
const MAX_REQUEST_BYTES = 100_000;
export const POST: RequestHandler = async ({ cookies, request, url }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 });
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic gap report is too large.' }, { status: 413 });
}
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic gap report is too large.' }, { status: 413 });
}
const report = parseMechanicGapReport(JSON.parse(body));
return json(await submitMechanicGapReport(operator.id, report));
} catch (caught) {
if (caught instanceof SyntaxError) {
return json({ message: 'The mechanic gap report must be valid JSON.' }, { status: 400 });
}
if (caught instanceof MechanicGapReportError) {
return json({ message: caught.message }, { status: caught.status });
}
console.error('Mechanic gap report submission failed.', caught);
return json(
{ message: 'The mechanic gap could not be submitted to the admin team.' },
{ status: 500 }
);
}
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.mechanic.generate',
);
const origin = request.headers.get('origin');
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin mechanic gap reports are not allowed.' },
{ status: 403 },
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json(
{ message: 'The mechanic gap report is too large.' },
{ status: 413 },
);
}
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json(
{ message: 'The mechanic gap report is too large.' },
{ status: 413 },
);
}
const report = parseMechanicGapReport(JSON.parse(body));
return json(await submitMechanicGapReport(operator.id, report));
} catch (caught) {
if (caught instanceof SyntaxError) {
return json(
{ message: 'The mechanic gap report must be valid JSON.' },
{ status: 400 },
);
}
if (caught instanceof MechanicGapReportError) {
return json({ message: caught.message }, { status: caught.status });
}
console.error('Mechanic gap report submission failed.', caught);
return json(
{ message: 'The mechanic gap could not be submitted to the admin team.' },
{ status: 500 },
);
}
};
+16 -8
View File
@@ -4,15 +4,23 @@ import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in required');
const database = await getDatabase();
const rows = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in required');
const database = await getDatabase();
const rows = await (
await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND COALESCE(p.browser_enabled,0)=1
ORDER BY n.created_at DESC LIMIT 20`)).all(user.id);
const count = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
ORDER BY n.created_at DESC LIMIT 20`)
).all(user.id);
const count = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return json({ count, notifications: rows });
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
).get(user.id)
)?.count ?? 0,
);
return json({ count, notifications: rows });
};
+43 -40
View File
@@ -9,50 +9,53 @@ import type { RequestHandler } from './$types';
const STATE_COOKIE = 'shd_discord_oauth_state';
export const GET: RequestHandler = async ({ cookies, url }) => {
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
error(503, 'Discord sign-in has not been configured yet.');
}
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
error(503, 'Discord sign-in has not been configured yet.');
}
const state = randomBytes(32).toString('base64url');
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin';
const user = mode === 'link' ? await getSessionUser(cookies) : null;
const state = randomBytes(32).toString('base64url');
const redirectUri =
env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin';
const user = mode === 'link' ? await getSessionUser(cookies) : null;
if (mode === 'link' && !user) {
redirect(303, '/sign-in?oauth_error=link_requires_login');
}
if (mode === 'link' && !user) {
redirect(303, '/sign-in?oauth_error=link_requires_login');
}
const database = await getDatabase();
const removeExpired = await database.prepare('DELETE FROM oauth_states WHERE expires_at <= ?');
await removeExpired.run(new Date().toISOString());
const insertState = await database.prepare(
'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
const now = new Date();
await insertState.run(
createHash('sha256').update(state).digest('hex'),
'discord',
mode,
user?.id ?? null,
new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
now.toISOString()
);
const database = await getDatabase();
const removeExpired = await database.prepare(
'DELETE FROM oauth_states WHERE expires_at <= ?',
);
await removeExpired.run(new Date().toISOString());
const insertState = await database.prepare(
'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const now = new Date();
await insertState.run(
createHash('sha256').update(state).digest('hex'),
'discord',
mode,
user?.id ?? null,
new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
now.toISOString(),
);
cookies.set(STATE_COOKIE, state, {
path: '/auth/discord',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: 60 * 10
});
cookies.set(STATE_COOKIE, state, {
path: '/auth/discord',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: 60 * 10,
});
const authorizeUrl = new URL('https://discord.com/oauth2/authorize');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', 'identify');
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('prompt', 'consent');
const authorizeUrl = new URL('https://discord.com/oauth2/authorize');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', 'identify');
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('prompt', 'consent');
redirect(302, authorizeUrl.toString());
redirect(302, authorizeUrl.toString());
};
+177 -108
View File
@@ -1,6 +1,10 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { createSession, getSessionUser, recordLogin } from '$lib/server/auth/session';
import {
createSession,
getSessionUser,
recordLogin,
} from '$lib/server/auth/session';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
@@ -13,139 +17,204 @@ import { insertUserProfile } from '$lib/server/agent-profiles';
const STATE_COOKIE = 'shd_discord_oauth_state';
type DiscordToken = { access_token: string; token_type: string };
type DiscordUser = { id: string; username: string; global_name: string | null; avatar: string | null };
type DiscordUser = {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
};
async function saveDiscordProfile(userId: string, discordUser: DiscordUser): Promise<void> {
const database = await getDatabase();
await (await database.prepare(`INSERT INTO discord_profiles(user_id,discord_id,username,display_name,avatar_hash,updated_at)
async function saveDiscordProfile(
userId: string,
discordUser: DiscordUser,
): Promise<void> {
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO discord_profiles(user_id,discord_id,username,display_name,avatar_hash,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
discord_id=excluded.discord_id,username=excluded.username,display_name=excluded.display_name,
avatar_hash=excluded.avatar_hash,updated_at=excluded.updated_at`))
.run(userId, discordUser.id, discordUser.username, discordUser.global_name, discordUser.avatar, new Date().toISOString());
avatar_hash=excluded.avatar_hash,updated_at=excluded.updated_at`)
).run(
userId,
discordUser.id,
discordUser.username,
discordUser.global_name,
discordUser.avatar,
new Date().toISOString(),
);
}
function stateMatches(expected: string | undefined, received: string | null): boolean {
if (!expected || !received) return false;
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(received);
return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
function stateMatches(
expected: string | undefined,
received: string | null,
): boolean {
if (!expected || !received) return false;
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(received);
return (
expectedBytes.length === receivedBytes.length &&
timingSafeEqual(expectedBytes, receivedBytes)
);
}
async function uniqueUsername(): Promise<string> {
const database = await getDatabase();
const base = `agent_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
const find = await database.prepare('SELECT 1 FROM users WHERE username = ? COLLATE NOCASE');
const database = await getDatabase();
const base = `agent_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
const find = await database.prepare(
'SELECT 1 FROM users WHERE username = ? COLLATE NOCASE',
);
if (!(await find.get(base))) return base;
if (!(await find.get(base))) return base;
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
}
export const GET: RequestHandler = async ({ cookies, url, fetch, request, getClientAddress }) => {
const expectedState = cookies.get(STATE_COOKIE);
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
export const GET: RequestHandler = async ({
cookies,
url,
fetch,
request,
getClientAddress,
}) => {
const expectedState = cookies.get(STATE_COOKIE);
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
if (url.searchParams.has('error')) {
redirect(303, '/sign-in?oauth_error=discord_denied');
}
if (url.searchParams.has('error')) {
redirect(303, '/sign-in?oauth_error=discord_denied');
}
const code = url.searchParams.get('code');
if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) {
redirect(303, '/sign-in?oauth_error=invalid_state');
}
const code = url.searchParams.get('code');
if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) {
redirect(303, '/sign-in?oauth_error=invalid_state');
}
const database = await getDatabase();
const findState = await database.prepare(`
const database = await getDatabase();
const findState = await database.prepare(`
SELECT mode, user_id FROM oauth_states
WHERE state_hash = ? AND provider = 'discord' AND expires_at > ?
`);
const stateHash = createHash('sha256').update(String(url.searchParams.get('state'))).digest('hex');
const oauthState = await findState.get(stateHash, new Date().toISOString());
const deleteState = await database.prepare('DELETE FROM oauth_states WHERE state_hash = ?');
await deleteState.run(stateHash);
if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state');
const stateHash = createHash('sha256')
.update(String(url.searchParams.get('state')))
.digest('hex');
const oauthState = await findState.get(stateHash, new Date().toISOString());
const deleteState = await database.prepare(
'DELETE FROM oauth_states WHERE state_hash = ?',
);
await deleteState.run(stateHash);
if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state');
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
redirect(303, '/sign-in?oauth_error=not_configured');
}
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
redirect(303, '/sign-in?oauth_error=not_configured');
}
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const tokenResponse = await fetch('https://discord.com/api/v10/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri
})
});
const redirectUri =
env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const tokenResponse = await fetch(
'https://discord.com/api/v10/oauth2/token',
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
},
);
if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange');
const token = (await tokenResponse.json()) as DiscordToken;
if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange');
const token = (await tokenResponse.json()) as DiscordToken;
const userResponse = await fetch('https://discord.com/api/v10/users/@me', {
headers: { authorization: `${token.token_type} ${token.access_token}` }
});
if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity');
const discordUser = (await userResponse.json()) as DiscordUser;
const userResponse = await fetch('https://discord.com/api/v10/users/@me', {
headers: { authorization: `${token.token_type} ${token.access_token}` },
});
if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity');
const discordUser = (await userResponse.json()) as DiscordUser;
if (oauthState.mode === 'link') {
const sessionUser = await getSessionUser(cookies);
if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) {
redirect(303, '/account?oauth_error=link_session_changed');
}
if (oauthState.mode === 'link') {
const sessionUser = await getSessionUser(cookies);
if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) {
redirect(303, '/account?oauth_error=link_session_changed');
}
const ownerStatement = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
const owner = await ownerStatement.get(discordUser.id);
if (owner && String(owner.id) !== sessionUser.id) {
redirect(303, '/account?oauth_error=discord_in_use');
}
const ownerStatement = await database.prepare(
'SELECT id FROM users WHERE discord_id = ?',
);
const owner = await ownerStatement.get(discordUser.id);
if (owner && String(owner.id) !== sessionUser.id) {
redirect(303, '/account?oauth_error=discord_in_use');
}
const link = await database.prepare('UPDATE users SET discord_id = ? WHERE id = ?');
await link.run(discordUser.id, sessionUser.id);
await saveDiscordProfile(sessionUser.id, discordUser);
redirect(303, '/account?discord_linked=1');
}
const link = await database.prepare(
'UPDATE users SET discord_id = ? WHERE id = ?',
);
await link.run(discordUser.id, sessionUser.id);
await saveDiscordProfile(sessionUser.id, discordUser);
redirect(303, '/account?discord_linked=1');
}
const findUser = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
let user = await findUser.get(discordUser.id);
let created = false;
const findUser = await database.prepare(
'SELECT id FROM users WHERE discord_id = ?',
);
let user = await findUser.get(discordUser.id);
let created = false;
if (!user) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
redirect(303, '/sign-in?oauth_error=registration_closed');
}
const username = await uniqueUsername();
const id = randomUUID();
const now = new Date().toISOString();
const insertUser = await database.prepare(
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
await insertUser.run(id, username, discordUser.id, 'user', now);
await insertUserProfile(
database,
id,
discordUser.global_name || discordUser.username,
now,
{ resolveCollision: true }
);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
user = { id };
created = true;
}
if (!user) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
redirect(303, '/sign-in?oauth_error=registration_closed');
}
const username = await uniqueUsername();
const id = randomUUID();
const now = new Date().toISOString();
const insertUser = await database.prepare(
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)',
);
await insertUser.run(id, username, discordUser.id, 'user', now);
await insertUserProfile(
database,
id,
discordUser.global_name || discordUser.username,
now,
{ resolveCollision: true },
);
await triggerAutomatedSystemMessages({
eventKey: 'account.created',
userId: id,
eventId: id,
}).catch((error) =>
console.error('Unable to deliver new-agent welcome message', error),
);
user = { id };
created = true;
}
await ensureBootstrapAdmin(database, String(user.id));
const status = await (await database.prepare('SELECT blocked_at,banned_until FROM user_status WHERE user_id=?')).get(String(user.id));
const banned = typeof status?.banned_until === 'string' && new Date(status.banned_until) > new Date();
if (status?.blocked_at || banned) {
await recordLogin(String(user.id), 'discord', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: discordUser.id });
redirect(303, '/sign-in?oauth_error=account_blocked');
}
await saveDiscordProfile(String(user.id), discordUser);
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'discord', true, { request, getClientAddress, credentialId: discordUser.id, sessionId });
redirect(303, created ? '/account?choose_display_name=1' : '/account');
await ensureBootstrapAdmin(database, String(user.id));
const status = await (
await database.prepare(
'SELECT blocked_at,banned_until FROM user_status WHERE user_id=?',
)
).get(String(user.id));
const banned =
typeof status?.banned_until === 'string' &&
new Date(status.banned_until) > new Date();
if (status?.blocked_at || banned) {
await recordLogin(String(user.id), 'discord', false, {
request,
getClientAddress,
failureReason: 'account_restricted',
credentialId: discordUser.id,
});
redirect(303, '/sign-in?oauth_error=account_blocked');
}
await saveDiscordProfile(String(user.id), discordUser);
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'discord', true, {
request,
getClientAddress,
credentialId: discordUser.id,
sessionId,
});
redirect(303, created ? '/account?choose_display_name=1' : '/account');
};
+46 -23
View File
@@ -5,30 +5,53 @@ import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies, request, getClientAddress }) => {
const token = url.searchParams.get('token') ?? '';
const database = await getDatabase();
const link = token ? await consumeMagicLinkToken(database, token) : null;
if (!link) redirect(303, '/sign-in?magic_error=invalid');
export const GET: RequestHandler = async ({
url,
cookies,
request,
getClientAddress,
}) => {
const token = url.searchParams.get('token') ?? '';
const database = await getDatabase();
const link = token ? await consumeMagicLinkToken(database, token) : null;
if (!link) redirect(303, '/sign-in?magic_error=invalid');
const user = await (await database.prepare(`SELECT users.id,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
const user = await (
await database.prepare(`SELECT users.id,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(link.userId);
const unavailable = !user || user.deleted_at || user.blocked_at ||
(typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date());
if (unavailable) {
await recordLogin(link.userId, 'magic_link', false, { request, getClientAddress, failureReason: 'account_restricted' });
redirect(303, '/sign-in?magic_error=unavailable');
}
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)
).get(link.userId);
const unavailable =
!user ||
user.deleted_at ||
user.blocked_at ||
(typeof user.banned_until === 'string' &&
new Date(user.banned_until) > new Date());
if (unavailable) {
await recordLogin(link.userId, 'magic_link', false, {
request,
getClientAddress,
failureReason: 'account_restricted',
});
redirect(303, '/sign-in?magic_error=unavailable');
}
if (link.purpose === 'verify_email') {
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO user_emails(user_id,email,verified_at,updated_at) VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`))
.run(link.userId, link.email, now, now);
}
await ensureBootstrapAdmin(database, link.userId);
const sessionId = await createSession(cookies, link.userId);
await recordLogin(link.userId, 'magic_link', true, { request, getClientAddress, sessionId });
redirect(303, link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account');
if (link.purpose === 'verify_email') {
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO user_emails(user_id,email,verified_at,updated_at) VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`)
).run(link.userId, link.email, now, now);
}
await ensureBootstrapAdmin(database, link.userId);
const sessionId = await createSession(cookies, link.userId);
await recordLogin(link.userId, 'magic_link', true, {
request,
getClientAddress,
sessionId,
});
redirect(
303,
link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account',
);
};
@@ -5,13 +5,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url }) => {
const database = await getDatabase();
const context = passkeyContext(url);
const options = await generateAuthenticationOptions({
rpID: context.rpID,
userVerification: 'required',
allowCredentials: []
});
const challengeId = await storeChallenge(database, 'authentication', options.challenge, context);
return json({ options, challengeId });
const database = await getDatabase();
const context = passkeyContext(url);
const options = await generateAuthenticationOptions({
rpID: context.rpID,
userVerification: 'required',
allowCredentials: [],
});
const challengeId = await storeChallenge(
database,
'authentication',
options.challenge,
context,
);
return json({ options, challengeId });
};
@@ -2,56 +2,102 @@ import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { consumeChallenge } from '$lib/server/auth/passkeys';
import { createSession, recordLogin } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { verifyAuthenticationResponse, type AuthenticationResponseJSON, type AuthenticatorTransportFuture } from '@simplewebauthn/server';
import {
verifyAuthenticationResponse,
type AuthenticationResponseJSON,
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
function bytes(value: unknown): Uint8Array<ArrayBuffer> {
if (value instanceof Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return copy;
}
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
if (Array.isArray(value)) return new Uint8Array(value);
throw new Error('Invalid passkey public key.');
if (value instanceof Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return copy;
}
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
if (Array.isArray(value)) return new Uint8Array(value);
throw new Error('Invalid passkey public key.');
}
export const POST: RequestHandler = async ({ cookies, request, getClientAddress }) => {
const body = await request.json() as { challengeId?: string; response?: AuthenticationResponseJSON };
if (!body.challengeId || !body.response) error(400, 'Incomplete passkey authentication.');
const database = await getDatabase();
const challenge = await consumeChallenge(database, body.challengeId, 'authentication');
if (!challenge) error(400, 'The passkey request expired. Try again.');
const passkey = await (await database.prepare(`SELECT passkeys.*,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
export const POST: RequestHandler = async ({
cookies,
request,
getClientAddress,
}) => {
const body = (await request.json()) as {
challengeId?: string;
response?: AuthenticationResponseJSON;
};
if (!body.challengeId || !body.response)
error(400, 'Incomplete passkey authentication.');
const database = await getDatabase();
const challenge = await consumeChallenge(
database,
body.challengeId,
'authentication',
);
if (!challenge) error(400, 'The passkey request expired. Try again.');
const passkey = await (
await database.prepare(`SELECT passkeys.*,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
FROM passkeys JOIN users ON users.id=passkeys.user_id
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE passkeys.id=?`)).get(body.response.id);
if (!passkey) error(400, 'This passkey is not registered with SHD.build.');
const unavailable = passkey.deleted_at || passkey.blocked_at ||
(typeof passkey.banned_until === 'string' && new Date(passkey.banned_until) > new Date());
if (unavailable) {
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: String(passkey.id) });
error(403, 'This account is not permitted to sign in.');
}
const transports = passkey.transports ? JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[] : undefined;
const verification = await verifyAuthenticationResponse({
response: body.response,
expectedChallenge: String(challenge.challenge),
expectedOrigin: String(challenge.origin),
expectedRPID: String(challenge.rp_id),
credential: { id: String(passkey.id), publicKey: bytes(passkey.public_key), counter: Number(passkey.counter), transports },
requireUserVerification: true
});
if (!verification.verified) {
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'verification_failed', credentialId: String(passkey.id) });
error(400, 'Passkey authentication could not be verified.');
}
const now = new Date().toISOString();
await (await database.prepare('UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?'))
.run(verification.authenticationInfo.newCounter, now, passkey.id);
await ensureBootstrapAdmin(database, String(passkey.user_id));
const sessionId = await createSession(cookies, String(passkey.user_id));
await recordLogin(String(passkey.user_id), 'passkey', true, { request, getClientAddress, credentialId: String(passkey.id), sessionId });
return json({ verified: true, redirectTo: '/account' });
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE passkeys.id=?`)
).get(body.response.id);
if (!passkey) error(400, 'This passkey is not registered with SHD.build.');
const unavailable =
passkey.deleted_at ||
passkey.blocked_at ||
(typeof passkey.banned_until === 'string' &&
new Date(passkey.banned_until) > new Date());
if (unavailable) {
await recordLogin(String(passkey.user_id), 'passkey', false, {
request,
getClientAddress,
failureReason: 'account_restricted',
credentialId: String(passkey.id),
});
error(403, 'This account is not permitted to sign in.');
}
const transports = passkey.transports
? (JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[])
: undefined;
const verification = await verifyAuthenticationResponse({
response: body.response,
expectedChallenge: String(challenge.challenge),
expectedOrigin: String(challenge.origin),
expectedRPID: String(challenge.rp_id),
credential: {
id: String(passkey.id),
publicKey: bytes(passkey.public_key),
counter: Number(passkey.counter),
transports,
},
requireUserVerification: true,
});
if (!verification.verified) {
await recordLogin(String(passkey.user_id), 'passkey', false, {
request,
getClientAddress,
failureReason: 'verification_failed',
credentialId: String(passkey.id),
});
error(400, 'Passkey authentication could not be verified.');
}
const now = new Date().toISOString();
await (
await database.prepare(
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
)
).run(verification.authenticationInfo.newCounter, now, passkey.id);
await ensureBootstrapAdmin(database, String(passkey.user_id));
const sessionId = await createSession(cookies, String(passkey.user_id));
await recordLogin(String(passkey.user_id), 'passkey', true, {
request,
getClientAddress,
credentialId: String(passkey.id),
sessionId,
});
return json({ verified: true, redirectTo: '/account' });
};
@@ -1,29 +1,46 @@
import { getSessionUser } from '$lib/server/auth/session';
import { PASSKEY_RP_NAME, passkeyContext, storeChallenge } from '$lib/server/auth/passkeys';
import {
PASSKEY_RP_NAME,
passkeyContext,
storeChallenge,
} from '$lib/server/auth/passkeys';
import { getDatabase } from '$lib/server/database';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in before registering a passkey.');
const database = await getDatabase();
const passkeys = await (await database.prepare('SELECT id,transports FROM passkeys WHERE user_id=?')).all(user.id);
const context = passkeyContext(url);
const options = await generateRegistrationOptions({
rpName: PASSKEY_RP_NAME,
rpID: context.rpID,
userID: new TextEncoder().encode(user.id),
userName: user.loginUsername ?? user.displayName,
userDisplayName: user.displayName,
attestationType: 'none',
excludeCredentials: passkeys.map((passkey) => ({
id: String(passkey.id),
transports: passkey.transports ? JSON.parse(String(passkey.transports)) : undefined
})),
authenticatorSelection: { residentKey: 'required', userVerification: 'required' }
});
const challengeId = await storeChallenge(database, 'registration', options.challenge, context, user.id);
return json({ options, challengeId });
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in before registering a passkey.');
const database = await getDatabase();
const passkeys = await (
await database.prepare('SELECT id,transports FROM passkeys WHERE user_id=?')
).all(user.id);
const context = passkeyContext(url);
const options = await generateRegistrationOptions({
rpName: PASSKEY_RP_NAME,
rpID: context.rpID,
userID: new TextEncoder().encode(user.id),
userName: user.loginUsername ?? user.displayName,
userDisplayName: user.displayName,
attestationType: 'none',
excludeCredentials: passkeys.map((passkey) => ({
id: String(passkey.id),
transports: passkey.transports
? JSON.parse(String(passkey.transports))
: undefined,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
});
const challengeId = await storeChallenge(
database,
'registration',
options.challenge,
context,
user.id,
);
return json({ options, challengeId });
};
@@ -1,34 +1,61 @@
import { getSessionUser } from '$lib/server/auth/session';
import { consumeChallenge } from '$lib/server/auth/passkeys';
import { getDatabase } from '$lib/server/database';
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server';
import {
verifyRegistrationResponse,
type RegistrationResponseJSON,
} from '@simplewebauthn/server';
import { error, json } from '@sveltejs/kit';
import { Buffer } from 'node:buffer';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in before registering a passkey.');
const body = await request.json() as { challengeId?: string; name?: string; response?: RegistrationResponseJSON };
if (!body.challengeId || !body.response) error(400, 'Incomplete passkey registration.');
const database = await getDatabase();
const challenge = await consumeChallenge(database, body.challengeId, 'registration', user.id);
if (!challenge) error(400, 'The passkey registration expired. Try again.');
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge: String(challenge.challenge),
expectedOrigin: String(challenge.origin),
expectedRPID: String(challenge.rp_id),
requireUserVerification: true
});
if (!verification.verified || !verification.registrationInfo) error(400, 'Passkey registration could not be verified.');
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const name = String(body.name || 'My passkey').trim().slice(0, 60) || 'My passkey';
await (await database.prepare(`INSERT INTO passkeys(id,user_id,webauthn_user_id,public_key,counter,device_type,backed_up,transports,name,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
credential.id, user.id, Buffer.from(user.id).toString('base64url'),
credential.publicKey, credential.counter, credentialDeviceType, credentialBackedUp ? 1 : 0,
credential.transports ? JSON.stringify(credential.transports) : null, name, new Date().toISOString()
);
return json({ verified: true });
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in before registering a passkey.');
const body = (await request.json()) as {
challengeId?: string;
name?: string;
response?: RegistrationResponseJSON;
};
if (!body.challengeId || !body.response)
error(400, 'Incomplete passkey registration.');
const database = await getDatabase();
const challenge = await consumeChallenge(
database,
body.challengeId,
'registration',
user.id,
);
if (!challenge) error(400, 'The passkey registration expired. Try again.');
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge: String(challenge.challenge),
expectedOrigin: String(challenge.origin),
expectedRPID: String(challenge.rp_id),
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo)
error(400, 'Passkey registration could not be verified.');
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const name =
String(body.name || 'My passkey')
.trim()
.slice(0, 60) || 'My passkey';
await (
await database.prepare(`INSERT INTO passkeys(id,user_id,webauthn_user_id,public_key,counter,device_type,backed_up,transports,name,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`)
).run(
credential.id,
user.id,
Buffer.from(user.id).toString('base64url'),
credential.publicKey,
credential.counter,
credentialDeviceType,
credentialBackedUp ? 1 : 0,
credential.transports ? JSON.stringify(credential.transports) : null,
name,
new Date().toISOString(),
);
return json({ verified: true });
};
+23 -16
View File
@@ -6,24 +6,31 @@ import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, cookies }) => {
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
const database = await getDatabase();
const held = await (await database.prepare(`SELECT review.id
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
const database = await getDatabase();
const held = await (
await database.prepare(`SELECT review.id
FROM avatar_reviews review
JOIN user_avatars avatar
ON avatar.user_id=review.user_id AND avatar.updated_at=review.avatar_updated_at
WHERE review.user_id=? AND review.status='pending' AND review.review_origin='automated'
LIMIT 1`)).get(params.id);
if (held && !await hasPermission(await getSessionUser(cookies), 'avatars.review')) {
redirect(307, '/avatars/default/field-agent.webp');
}
const image = await readUploadedAvatar(database, params.id);
if (!image) error(404, 'Avatar not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': held ? 'private, no-store' : 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
LIMIT 1`)
).get(params.id);
if (
held &&
!(await hasPermission(await getSessionUser(cookies), 'avatars.review'))
) {
redirect(307, '/avatars/default/field-agent.webp');
}
const image = await readUploadedAvatar(database, params.id);
if (!image) error(404, 'Avatar not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': held
? 'private, no-store'
: 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff',
},
});
};
+608 -260
View File
@@ -4,280 +4,628 @@ import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { normalizeUserMarkdown } from '$lib/markdown';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
.replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').slice(0, 60) || 'loadout';
const slugify = (value: string) =>
value
.toLowerCase()
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.slice(0, 60) || 'loadout';
function parseLoadout(raw: FormDataEntryValue | null) {
if (typeof raw !== 'string' || raw.length > 250_000) return null;
try {
return parseLoadoutDraft(JSON.parse(raw));
} catch {
return null;
}
if (typeof raw !== 'string' || raw.length > 250_000) return null;
try {
return parseLoadoutDraft(JSON.parse(raw));
} catch {
return null;
}
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const requestedSlug = url.searchParams.get('build');
let initialBuild = null;
if (requestedSlug && viewer) {
const row = await (await database.prepare(
'SELECT id,title,slug,visibility,summary,guide_body,loadout_json FROM builds WHERE slug=? AND owner_id=?'
)).get(requestedSlug, viewer.id);
const loadout = typeof row?.loadout_json === 'string' ? parseLoadout(row.loadout_json) : null;
if (row && loadout) initialBuild = {
id: String(row.id),
slug: String(row.slug),
visibility: String(row.visibility),
summary: String(row.summary ?? ''),
guideBody: String(row.guide_body ?? ''),
loadout
};
}
const [
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates, conditionalRules, conditionalPools, conditionalClauses, conditionalOutcomes,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
icons, specializations, specializationFocuses, specializationAbilities, specializationAbilityAttacks, focusAbilities,
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles, skillCalculationProfiles
] = await Promise.all([
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
(await database.prepare('SELECT * FROM gear_talents')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_talent_options ORDER BY talent_slot')).all(),
(await database.prepare('SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
(await database.prepare('SELECT * FROM conditional_effect_rules WHERE configured=1')).all(),
(await database.prepare('SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order')).all(),
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order')).all(),
(await database.prepare('SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order')).all(),
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(),
(await database.prepare("SELECT * FROM game_data_icons")).all(),
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_ability_attacks')).all(),
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const requestedSlug = url.searchParams.get('build');
let initialBuild = null;
if (requestedSlug && viewer) {
const row = await (
await database.prepare(
'SELECT id,title,slug,visibility,loadout_json FROM builds WHERE slug=? AND owner_id=?',
)
).get(requestedSlug, viewer.id);
const loadout =
typeof row?.loadout_json === 'string'
? parseLoadout(row.loadout_json)
: null;
if (row && loadout)
initialBuild = {
id: String(row.id),
slug: String(row.slug),
visibility: String(row.visibility),
loadout,
};
}
const [
attributes,
gear,
gearBrands,
gearTalents,
gearAugments,
gearAugmentCosts,
brands,
brandEffects,
weapons,
weaponRanges,
weaponTalentOptions,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets,
chipTwoPiece,
chipPrimary,
chipSecondary,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
coreRanges,
icons,
specializations,
specializationFocuses,
specializationAbilities,
specializationAbilityAttacks,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationRankCosts,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
progression,
rankProfiles,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([
(
await database.prepare(
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
(await database.prepare('SELECT * FROM gear_talents')).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')
).all(),
(
await database.prepare(
'SELECT * FROM weapon_talent_options ORDER BY talent_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE configured=1',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
)
).all(),
(await database.prepare('SELECT * FROM game_data_icons')).all(),
(
await database.prepare(
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
)
).all(),
(
await database.prepare(
"SELECT * FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
(
await database.prepare(
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
)
).get(),
(
await database.prepare(`SELECT stage.* FROM calculation_stages stage
JOIN calculation_revisions revision ON revision.id=stage.revision_id
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
WHERE revision.status='active' ORDER BY stage.display_order`)
).all(),
(
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all(),
(await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
]);
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip' | 'specialization' | 'specialization_ability' | 'specialization_node', id: unknown) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId);
return rule ? {
rule,
statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id),
clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id),
outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id),
triggers: []
} : null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
iconUrl: iconUrl('talent', talent.id),
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
})),
rule: hydrateRule('talent', talent.id)
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
initialBuild,
attributes,
coreRanges,
gear: gear.map((item) => ({
...item,
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
})),
gearAugments: gearAugments.map((augment) => ({
...augment,
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id)
})),
brands: brands.map((brand) => ({
...brand,
iconUrl: iconUrl('brand_set', brand.id),
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
weapons: attachWeaponCategoryBonuses(weapons.map((weapon) => ({
...weapon,
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
talent1Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 1).map((link) => String(link.talent_id)),
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id))
})), weaponBonuses),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
return {
...set,
iconUrl: iconUrl('skill_chip', set.id),
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
talent: talent ? {
...talent,
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id),
rule: hydrateRule('skill_chip_talent', talent.id)
} : null
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find((item) => item.quality === protocol.quality && item.primary_attribute_type === protocol.primary_attribute_type);
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
iconUrl: iconUrl('os_protocol', protocol.id),
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
talent: talent ? {
...talent,
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id),
rule: hydrateRule('os_protocol_talent', talent.id)
} : null
};
}),
specializations: specializations.map((specialization) => ({
...specialization,
iconUrl: iconUrl('specialization', specialization.id),
focuses: specializationFocuses.filter((focus) => focus.specialization_id === specialization.id).map((focus) => {
const assignedAbilities = focusAbilities.filter((link) => link.focus_id === focus.id)
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
.map((link) => {
const ability = specializationAbilities.find((item) => item.id === link.ability_id);
return ability ? {
...ability,
slot: Number(link.ability_slot),
iconUrl: iconUrl('specialization_ability', ability.id),
attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null
} : null;
}).filter(Boolean);
const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({
...node,
iconUrl: iconUrl('specialization_node', node.id),
ranks: specializationRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
...rank,
effects: specializationRankEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
costs: specializationRankCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)),
combatEffects: specializationRankCombatEffects
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
.map((effect) => ({
...effect,
definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id),
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id),
rule: hydrateRule('specialization_tree_rank', effect.id)
}))
}))
}));
return { ...focus, abilities: assignedAbilities, nodes };
})
})),
specializationProgression: {
...progression,
availablePoints: Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19),
rankProfiles
},
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles
}
};
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
]);
const iconUrl = (
type:
| 'talent'
| 'brand_set'
| 'os_protocol'
| 'skill_chip'
| 'specialization'
| 'specialization_ability'
| 'specialization_node',
id: unknown,
) => {
const icon = icons.find(
(item) => item.entity_type === type && item.entity_id === id,
);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find(
(item) => item.source_type === sourceType && item.source_id === sourceId,
);
return rule
? {
rule,
statePools: conditionalPools.filter(
(pool) => pool.rule_id === rule.id,
),
clauses: conditionalClauses.filter(
(clause) => clause.rule_id === rule.id,
),
outcomes: conditionalOutcomes.filter(
(outcome) => outcome.rule_id === rule.id,
),
triggers: [],
}
: null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
iconUrl: iconUrl('talent', talent.id),
template:
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
'',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter(
(effect) => effect.talent_tier_id === tier.id,
),
})),
rule: hydrateRule('talent', talent.id),
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
initialBuild,
attributes,
coreRanges,
gear: gear.map((item) => ({
...item,
brandIds: gearBrands
.filter((link) => link.gear_id === item.id)
.map((link) => String(link.brand_set_id)),
talentIds: gearTalents
.filter((link) => link.gear_id === item.id)
.map((link) => String(link.talent_id)),
})),
gearAugments: gearAugments.map((augment) => ({
...augment,
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id),
})),
brands: brands.map((brand) => ({
...brand,
iconUrl: iconUrl('brand_set', brand.id),
effects: brandEffects.filter(
(effect) => effect.brand_set_id === brand.id,
),
})),
weapons: attachWeaponCategoryBonuses(
weapons.map((weapon) => ({
...weapon,
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
talent1Ids: weaponTalentOptions
.filter(
(link) =>
link.weapon_id === weapon.id && Number(link.talent_slot) === 1,
)
.map((link) => String(link.talent_id)),
talent2Ids: weaponTalentOptions
.filter(
(link) =>
link.weapon_id === weapon.id && Number(link.talent_slot) === 2,
)
.map((link) => String(link.talent_id)),
})),
weaponBonuses,
),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
iconUrl: iconUrl('skill_chip', set.id),
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
primaryStatIds: chipPrimary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('skill_chip_talent', talent.id),
}
: null,
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find(
(item) =>
item.quality === protocol.quality &&
item.primary_attribute_type === protocol.primary_attribute_type,
);
const talent = protocolTalents.find(
(item) => item.protocol_id === protocol.id,
);
return {
...protocol,
iconUrl: iconUrl('os_protocol', protocol.id),
primaryEffect:
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
null,
secondaryEffects: profile
? protocolSecondary.filter(
(effect) => effect.profile_id === profile.id,
)
: [],
talent: talent
? {
...talent,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('os_protocol_talent', talent.id),
}
: null,
};
}),
specializations: specializations.map((specialization) => ({
...specialization,
iconUrl: iconUrl('specialization', specialization.id),
focuses: specializationFocuses
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => {
const assignedAbilities = focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) => {
const ability = specializationAbilities.find(
(item) => item.id === link.ability_id,
);
return ability
? {
...ability,
slot: Number(link.ability_slot),
iconUrl: iconUrl('specialization_ability', ability.id),
attack:
specializationAbilityAttacks.find(
(attack) => attack.ability_id === ability.id,
) ?? null,
}
: null;
})
.filter(Boolean);
const nodes = specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
...node,
iconUrl: iconUrl('specialization_node', node.id),
ranks: specializationRanks
.filter((rank) => rank.node_id === node.id)
.map((rank) => ({
...rank,
effects: specializationRankEffects.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
),
costs: specializationRankCosts.filter(
(cost) =>
cost.node_id === node.id &&
Number(cost.rank) === Number(rank.rank),
),
combatEffects: specializationRankCombatEffects
.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
)
.map((effect) => ({
...effect,
definition: specializationCombatEffects.find(
(definition) =>
definition.id === effect.combat_effect_id,
),
modifiers: specializationCombatModifiers.filter(
(modifier) =>
modifier.rank_combat_effect_id === effect.id,
),
rule: hydrateRule('specialization_tree_rank', effect.id),
})),
})),
}));
return { ...focus, abilities: assignedAbilities, nodes };
}),
})),
specializationProgression: {
...progression,
availablePoints:
Number(progression?.xp_points ?? 30) +
Number(progression?.shd_cache_points ?? 19),
rankProfiles,
},
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { buildError: 'Sign in to save and share this loadout.' });
const formData = await request.formData();
const loadout = parseLoadout(formData.get('loadout'));
const visibility = String(formData.get('visibility') ?? 'private');
const id = String(formData.get('id') ?? '').trim();
const summary = normalizeUserMarkdown(formData.get('summary'), 500);
const guideBody = normalizeUserMarkdown(formData.get('guideBody'), 12_000);
if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' });
if (summary === null || guideBody === null) return fail(400, { buildError: 'Build notes contain unsupported characters or exceed their length limit.' });
const title = loadout.buildName.trim();
if (title.length < 2 || title.length > 100) return fail(400, { buildError: 'Build names must be between 2 and 100 characters.' });
if (!validVisibility.has(visibility)) return fail(400, { buildError: 'Choose a valid build visibility.' });
save: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user)
return fail(401, {
buildError: 'Sign in to save and share this loadout.',
});
const formData = await request.formData();
const loadout = parseLoadout(formData.get('loadout'));
const visibility = String(formData.get('visibility') ?? 'private');
const id = String(formData.get('id') ?? '').trim();
if (!loadout)
return fail(400, {
buildError:
'The loadout data is invalid. Refresh the builder and try again.',
});
const title = loadout.buildName.trim();
if (title.length < 2 || title.length > 100)
return fail(400, {
buildError: 'Build names must be between 2 and 100 characters.',
});
if (!validVisibility.has(visibility))
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const now = new Date().toISOString();
let buildId = id;
let slug = '';
if (buildId) {
const existing = await (await database.prepare('SELECT id,slug FROM builds WHERE id=? AND owner_id=?')).get(buildId, user.id);
if (!existing) return fail(404, { buildError: 'This saved build could not be found.' });
slug = String(existing.slug);
await (await database.prepare(
'UPDATE builds SET title=?,visibility=?,summary=?,guide_body=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
)).run(title, visibility, summary, guideBody, JSON.stringify(loadout), now, buildId, user.id);
} else {
buildId = randomUUID();
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
await (await database.prepare(
`INSERT INTO builds(id,owner_id,title,slug,visibility,summary,guide_body,loadout_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`
)).run(buildId, user.id, title, slug, visibility, summary, guideBody, JSON.stringify(loadout), now, now);
}
return {
buildSaved: true,
buildToken: randomUUID(),
savedBuild: { id: buildId, slug, visibility, title }
};
}
const database = await getDatabase();
const now = new Date().toISOString();
let buildId = id;
let slug = '';
if (buildId) {
const existing = await (
await database.prepare(
'SELECT id,slug FROM builds WHERE id=? AND owner_id=?',
)
).get(buildId, user.id);
if (!existing)
return fail(404, {
buildError: 'This saved build could not be found.',
});
slug = String(existing.slug);
await (
await database.prepare(
'UPDATE builds SET title=?,visibility=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?',
)
).run(title, visibility, JSON.stringify(loadout), now, buildId, user.id);
} else {
buildId = randomUUID();
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
await (
await database.prepare(
`INSERT INTO builds(id,owner_id,title,slug,visibility,loadout_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`,
)
).run(
buildId,
user.id,
title,
slug,
visibility,
JSON.stringify(loadout),
now,
now,
);
}
return {
buildSaved: true,
buildToken: randomUUID(),
savedBuild: { id: buildId, slug, visibility, title },
};
},
};
-18
View File
@@ -8,7 +8,6 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
@@ -27,9 +26,6 @@
let active = $state('specialization:focus');
let saved = $state(false);
let message = $state('');
let buildSummary = $state(untrack(() => data.initialBuild?.summary ?? ''));
let buildGuide = $state(untrack(() => data.initialBuild?.guideBody ?? ''));
let buildNotesOpen = $state(untrack(() => Boolean(data.initialBuild?.summary || data.initialBuild?.guideBody)));
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
@@ -501,9 +497,6 @@
onMount(() => {
if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3');
buildSummary = localStorage.getItem('shd-build-summary-v1') ?? '';
buildGuide = localStorage.getItem('shd-build-guide-v1') ?? '';
buildNotesOpen = Boolean(buildSummary || buildGuide);
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
if (!stored) return;
try {
@@ -531,8 +524,6 @@
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
localStorage.setItem('shd-build-summary-v1', buildSummary);
localStorage.setItem('shd-build-guide-v1', buildGuide);
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -545,15 +536,12 @@
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" type="button" aria-expanded={buildNotesOpen} onclick={() => buildNotesOpen = !buildNotesOpen}>BUILD NOTES</button>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
<input type="hidden" name="id" value={persistedBuildId}/>
<input type="hidden" name="loadout" value={JSON.stringify(draft)}/>
<input type="hidden" name="summary" value={buildSummary}/>
<input type="hidden" name="guideBody" value={buildGuide}/>
<button class="primary" type="submit" name="visibility" value={persistedVisibility} aria-label="Save build to account">{pending ? 'SAVING…' : 'SAVE TO ACCOUNT'}</button>
{/snippet}
</PendingForm>
@@ -573,12 +561,6 @@
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
{#if buildNotesOpen}
<section class="builder-notes" aria-label="Build sharing notes">
<label for="builder-build-summary"><span>STRENGTHS / SUMMARY <small>OPTIONAL · SHOWN WHEN SHARED</small></span><textarea id="builder-build-summary" name="builder-build-summary" bind:value={buildSummary} maxlength="500" rows="3" placeholder="What this build does well and where it fits."></textarea></label>
<MarkdownEditor bind:value={buildGuide} name="builder-guide-editor" label="HOW TO USE THIS BUILD" help="Add an operating sequence, positioning advice, and important tradeoffs for players who open the shared build." maxLength={12000} rows={7}/>
</section>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
+20 -17
View File
@@ -3,8 +3,9 @@ import type { PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const builds = await (await database.prepare(`
const database = await getDatabase();
const builds = await (
await database.prepare(`
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
@@ -22,19 +23,21 @@ export const load: PageServerLoad = async () => {
GROUP BY builds.id
ORDER BY builds.updated_at DESC
LIMIT 60
`)).all();
return {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
)
}))
};
`)
).all();
return {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at,
),
})),
};
};
+69 -43
View File
@@ -2,15 +2,19 @@ import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import {
evaluateLoadoutCombat,
summarizeLoadout,
} from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const build = await (await database.prepare(`
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const build = await (
await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
@@ -22,43 +26,65 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)).get(params.slug);
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
let loadout: LoadoutDraft;
try {
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, combatCatalog] = await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database)
]);
const combatResults = evaluateLoadoutCombat(loadout, combatCatalog, summarizeLoadout(loadout, combatCatalog));
return {
build: {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
),
owner: build.owner_id === viewer?.id,
updatedAt: String(build.updated_at),
summary: String(build.summary ?? ''),
guideBody: String(build.guide_body ?? '')
},
loadout, gear, weapons, chips, protocols, brands, combatResults
};
`)
).get(params.slug);
if (
!build ||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
)
error(404, 'Build not found');
let loadout: LoadoutDraft;
try {
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, combatCatalog] =
await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(
await database.prepare('SELECT id,name,category,is_exotic FROM weapons')
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database),
]);
const combatResults = evaluateLoadoutCombat(
loadout,
combatCatalog,
summarizeLoadout(loadout, combatCatalog),
);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility: String(build.visibility),
author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at,
),
owner: build.owner_id === viewer?.id,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
combatResults,
};
};
+1 -11
View File
@@ -1,31 +1,21 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import { renderUserMarkdown } from '$lib/markdown';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const guideHtml = $derived(renderUserMarkdown(data.build.guideBody));
</script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
{#if data.build.summary || data.build.guideBody}
<section class="shared-build-guide" aria-labelledby="shared-guide-heading">
<header><span>FIELD GUIDE</span><b>AUTHOR NOTES</b></header>
{#if data.build.summary}<p class="shared-build-summary">{data.build.summary}</p>{/if}
{#if data.build.guideBody}<div><h2 id="shared-guide-heading">HOW TO RUN THIS BUILD</h2><MarkdownContent html={guideHtml}/></div>{/if}
</section>
{/if}
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
<div class="builder-section-title">
<div>
+16 -11
View File
@@ -1,17 +1,22 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessSettings, SITE_SECTION_KEYS } from '$lib/server/site-access';
import {
getSiteAccessSettings,
SITE_SECTION_KEYS,
} from '$lib/server/site-access';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
const settings = await getSiteAccessSettings();
const unavailable = url.searchParams.get('unavailable');
const unavailableSection = SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
if (!user && !settings.comingSoonEnabled && !unavailableSection) redirect(303, '/');
return {
settings,
authenticated: Boolean(user),
unavailable: unavailableSection
};
const user = await getSessionUser(cookies);
const settings = await getSiteAccessSettings();
const unavailable = url.searchParams.get('unavailable');
const unavailableSection =
SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
if (!user && !settings.comingSoonEnabled && !unavailableSection)
redirect(303, '/');
return {
settings,
authenticated: Boolean(user),
unavailable: unavailableSection,
};
};
+133 -73
View File
@@ -9,22 +9,26 @@ const submissions = new Map<string, number[]>();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function clean(value: FormDataEntryValue | null, maximum: number) {
return String(value ?? '').normalize('NFKC').trim().slice(0, maximum);
return String(value ?? '')
.normalize('NFKC')
.trim()
.slice(0, maximum);
}
function allowSubmission(key: string) {
const cutoff = Date.now() - 60 * 60 * 1000;
const recent = (submissions.get(key) ?? []).filter((time) => time > cutoff);
if (recent.length >= 3) return false;
recent.push(Date.now());
submissions.set(key, recent);
return true;
const cutoff = Date.now() - 60 * 60 * 1000;
const recent = (submissions.get(key) ?? []).filter((time) => time > cutoff);
if (recent.length >= 3) return false;
recent.push(Date.now());
submissions.set(key, recent);
return true;
}
async function recipients(team: string) {
const database = await getDatabase();
const admin = team === 'administrators' || team === 'dmca';
return (await database.prepare(`SELECT DISTINCT users.id FROM users
const database = await getDatabase();
const admin = team === 'administrators' || team === 'dmca';
return (
await database.prepare(`SELECT DISTINCT users.id FROM users
LEFT JOIN user_roles ON user_roles.user_id=users.id
LEFT JOIN roles ON roles.id=user_roles.role_id
LEFT JOIN role_permissions ON role_permissions.role_id=roles.id
@@ -32,76 +36,132 @@ async function recipients(team: string) {
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
WHERE NOT EXISTS (SELECT 1 FROM deleted_accounts WHERE deleted_accounts.user_id=users.id)
AND (${admin
? "users.role='admin' OR role_permission.name='system.admin' OR direct_permission.name='system.admin'"
: "users.role='admin' OR roles.id IN ('role-moderator','role-data-approver') OR role_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review') OR direct_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review')"})`))
.all();
AND (${
admin
? "users.role='admin' OR role_permission.name='system.admin' OR direct_permission.name='system.admin'"
: "users.role='admin' OR roles.id IN ('role-moderator','role-data-approver') OR role_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review') OR direct_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review')"
})`)
).all();
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
const initialTeam = url.searchParams.get('team') === 'dmca' ? 'dmca' : 'oversight';
if (!user) return { sender: null, initialTeam };
const database = await getDatabase();
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(user.id);
return { sender: { name: user.displayName, email: typeof email?.email === 'string' ? email.email : '' }, initialTeam };
const user = await getSessionUser(cookies);
const initialTeam =
url.searchParams.get('team') === 'dmca' ? 'dmca' : 'oversight';
if (!user) return { sender: null, initialTeam };
const database = await getDatabase();
const email = await (
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
).get(user.id);
return {
sender: {
name: user.displayName,
email: typeof email?.email === 'string' ? email.email : '',
},
initialTeam,
};
};
export const actions: Actions = {
send: async ({ request, cookies, getClientAddress }) => {
const form = await request.formData();
if (clean(form.get('website'), 200)) return { contactSent: true };
const user = await getSessionUser(cookies);
let address = 'unknown';
try { address = getClientAddress(); } catch { /* Development adapters may not expose a client address. */ }
if (!allowSubmission(user ? `user:${user.id}` : `ip:${address}`)) {
return fail(429, { contactError: 'This contact channel has received several recent transmissions. Try again later.' });
}
send: async ({ request, cookies, getClientAddress }) => {
const form = await request.formData();
if (clean(form.get('website'), 200)) return { contactSent: true };
const user = await getSessionUser(cookies);
let address = 'unknown';
try {
address = getClientAddress();
} catch {
/* Development adapters may not expose a client address. */
}
if (!allowSubmission(user ? `user:${user.id}` : `ip:${address}`)) {
return fail(429, {
contactError:
'This contact channel has received several recent transmissions. Try again later.',
});
}
const team = clean(form.get('team'), 30);
const senderName = clean(form.get('senderName'), 100);
const senderEmail = clean(form.get('senderEmail'), 254).toLocaleLowerCase();
const subject = clean(form.get('subject'), 140);
const body = clean(form.get('body'), 8000);
if (!['oversight', 'administrators', 'dmca'].includes(team)) return fail(400, { contactError: 'Choose a valid destination team.' });
if (senderName.length < 2 || !emailPattern.test(senderEmail)) return fail(400, { contactError: 'Provide your name and a valid reply email address.' });
if (subject.length < 3 || body.length < 10) return fail(400, { contactError: 'Provide a subject and a message of at least 10 characters.' });
const team = clean(form.get('team'), 30);
const senderName = clean(form.get('senderName'), 100);
const senderEmail = clean(form.get('senderEmail'), 254).toLocaleLowerCase();
const subject = clean(form.get('subject'), 140);
const body = clean(form.get('body'), 8000);
if (!['oversight', 'administrators', 'dmca'].includes(team))
return fail(400, { contactError: 'Choose a valid destination team.' });
if (senderName.length < 2 || !emailPattern.test(senderEmail))
return fail(400, {
contactError: 'Provide your name and a valid reply email address.',
});
if (subject.length < 3 || body.length < 10)
return fail(400, {
contactError:
'Provide a subject and a message of at least 10 characters.',
});
let dmca: Record<string, string | boolean> | null = null;
if (team === 'dmca') {
dmca = {
work: clean(form.get('copyrightedWork'), 4000),
locations: clean(form.get('contentLocations'), 4000),
contact: clean(form.get('claimantContact'), 2000),
signature: clean(form.get('signature'), 200),
goodFaith: form.get('goodFaith') === '1',
accurate: form.get('accurate') === '1'
};
if (String(dmca.work).length < 5 || String(dmca.locations).length < 5 || String(dmca.contact).length < 10 || String(dmca.signature).length < 2 || !dmca.goodFaith || !dmca.accurate) {
return fail(400, { contactError: 'DMCA notices require the work, material location, complete contact information, electronic signature, and both required statements.' });
}
}
let dmca: Record<string, string | boolean> | null = null;
if (team === 'dmca') {
dmca = {
work: clean(form.get('copyrightedWork'), 4000),
locations: clean(form.get('contentLocations'), 4000),
contact: clean(form.get('claimantContact'), 2000),
signature: clean(form.get('signature'), 200),
goodFaith: form.get('goodFaith') === '1',
accurate: form.get('accurate') === '1',
};
if (
String(dmca.work).length < 5 ||
String(dmca.locations).length < 5 ||
String(dmca.contact).length < 10 ||
String(dmca.signature).length < 2 ||
!dmca.goodFaith ||
!dmca.accurate
) {
return fail(400, {
contactError:
'DMCA notices require the work, material location, complete contact information, electronic signature, and both required statements.',
});
}
}
const targetUsers = await recipients(team);
if (!targetUsers.length) return fail(503, { contactError: 'No authorized recipient is currently assigned to this channel. Please use the Discord community link for assistance.' });
const database = await getDatabase();
const messageId = randomUUID();
const createdAt = new Date().toISOString();
await (await database.prepare(`INSERT INTO contact_messages(id,sender_user_id,sender_name,sender_email,team,subject,body,dmca_json,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`)).run(messageId, user?.id ?? null, senderName, senderEmail, team, subject, body, dmca ? JSON.stringify(dmca) : null, createdAt);
for (const recipient of targetUsers) {
const userId = String(recipient.id);
await (await database.prepare('INSERT INTO contact_message_recipients(message_id,user_id) VALUES(?,?)')).run(messageId, userId);
await createNotification({
userId,
category: 'isac',
source: 'SHD CONTACT RELAY',
title: `${team === 'dmca' ? 'DMCA NOTICE' : team === 'oversight' ? 'OVERSIGHT CONTACT' : 'ADMIN CONTACT'} · ${subject}`,
body: `${senderName} (${senderEmail}) submitted a new ${team === 'dmca' ? 'copyright request' : 'contact message'}.\n\n${body.slice(0, 1200)}`,
link: `/account/contact/${messageId}`,
mandatory: true
});
}
return { contactSent: true, contactToken: messageId };
}
const targetUsers = await recipients(team);
if (!targetUsers.length)
return fail(503, {
contactError:
'No authorized recipient is currently assigned to this channel. Please use the Discord community link for assistance.',
});
const database = await getDatabase();
const messageId = randomUUID();
const createdAt = new Date().toISOString();
await (
await database.prepare(`INSERT INTO contact_messages(id,sender_user_id,sender_name,sender_email,team,subject,body,dmca_json,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`)
).run(
messageId,
user?.id ?? null,
senderName,
senderEmail,
team,
subject,
body,
dmca ? JSON.stringify(dmca) : null,
createdAt,
);
for (const recipient of targetUsers) {
const userId = String(recipient.id);
await (
await database.prepare(
'INSERT INTO contact_message_recipients(message_id,user_id) VALUES(?,?)',
)
).run(messageId, userId);
await createNotification({
userId,
category: 'isac',
source: 'SHD CONTACT RELAY',
title: `${team === 'dmca' ? 'DMCA NOTICE' : team === 'oversight' ? 'OVERSIGHT CONTACT' : 'ADMIN CONTACT'} · ${subject}`,
body: `${senderName} (${senderEmail}) submitted a new ${team === 'dmca' ? 'copyright request' : 'contact message'}.\n\n${body.slice(0, 1200)}`,
link: `/account/contact/${messageId}`,
mandatory: true,
});
}
return { contactSent: true, contactToken: messageId };
},
};
+38 -30
View File
@@ -4,26 +4,33 @@ import { databaseCategories } from '$lib/database/categories';
import type { PageServerLoad } from './$types';
const tables = {
gear: 'gear',
weapons: 'weapons',
'weapon-mods': 'weapon_mods',
'gear-mods': 'gear_mods',
'os-protocols': 'os_protocols',
talents: 'talents',
brands: 'brand_sets',
'skill-chips': 'skill_chip_sets',
specializations: 'specializations'
gear: 'gear',
weapons: 'weapons',
'weapon-mods': 'weapon_mods',
'gear-mods': 'gear_mods',
'os-protocols': 'os_protocols',
talents: 'talents',
brands: 'brand_sets',
'skill-chips': 'skill_chip_sets',
specializations: 'specializations',
} as const;
export const load: PageServerLoad = async ({ url }) => {
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const counts = await Promise.all(databaseCategories.map(async (category) => {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
return { ...category, count: Number(row?.count ?? 0) };
}));
const searchResults = search
? await (await database.prepare(`
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const counts = await Promise.all(
databaseCategories.map(async (category) => {
const row = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`,
)
).get();
return { ...category, count: Number(row?.count ?? 0) };
}),
);
const searchResults = search
? await (
await database.prepare(`
SELECT * FROM (
SELECT 'gear' AS slug,'Gear' AS category,item.name,
item.slot || ' · T' || item.tier AS detail
@@ -113,17 +120,18 @@ export const load: PageServerLoad = async ({ url }) => {
) matches
ORDER BY name COLLATE NOCASE
LIMIT 40
`)).all(...Array(27).fill(pattern))
: [];
return {
registries: counts,
total: counts.reduce((total, registry) => total + registry.count, 0),
search,
searchResults: searchResults.map((result) => ({
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? '')
}))
};
`)
).all(...Array(27).fill(pattern))
: [];
return {
registries: counts,
total: counts.reduce((total, registry) => total + registry.count, 0),
search,
searchResults: searchResults.map((result) => ({
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? ''),
})),
};
};
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -2,7 +2,15 @@
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
import DatabaseDiscoveryConsole from '$lib/components/DatabaseDiscoveryConsole.svelte';
let { data } = $props();
import DatabaseDiscussionDialog from '$lib/components/DatabaseDiscussionDialog.svelte';
import { page } from '$app/state';
let { data, form } = $props();
const discussionCloseHref = () => {
const target = new URL(page.url);
target.searchParams.delete('discussion');
target.hash = data.selectedItem ? `database-item-${data.selectedItem.id}` : '';
return `${target.pathname}${target.search}${target.hash}`;
};
</script>
<svelte:head>
@@ -27,4 +35,7 @@
</div>
{/each}
</section>
{#if data.selectedItem}
<DatabaseDiscussionDialog item={data.selectedItem} comments={data.discussionComments} viewer={data.viewer} {form} signInHref={`/sign-in?returnTo=${encodeURIComponent(`/database/${data.category.slug}?discussion=${data.selectedItem.id}`)}`} closeHref={discussionCloseHref()}/>
{/if}
</main>
+39
View File
@@ -0,0 +1,39 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { listPublishedArticles, syncArticleTags } from '$lib/server/articles';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
const database = await getDatabase();
await syncArticleTags(database);
const tagSlugs = url.searchParams.getAll('tag').filter(Boolean);
const sortValue = url.searchParams.get('sort');
const sort = ['relevance', 'newest', 'rating', 'rated'].includes(
String(sortValue),
)
? (sortValue as 'relevance' | 'newest' | 'rating' | 'rated')
: undefined;
const [results, tags] = await Promise.all([
listPublishedArticles({
kind: 'guide',
query: url.searchParams.get('q') ?? '',
tagSlugs,
sort,
page: Number(url.searchParams.get('page') ?? 1),
}),
(
await database.prepare(
"SELECT name,slug,group_name FROM article_tags WHERE active=1 AND group_name<>'news_channel' ORDER BY group_name,name COLLATE NOCASE",
)
).all(),
]);
return {
...results,
tags,
query: url.searchParams.get('q') ?? '',
selectedTags: tagSlugs,
sort: sort ?? (url.searchParams.get('q') ? 'relevance' : 'newest'),
canCreate: Boolean(user),
};
};
+24 -2
View File
@@ -1,2 +1,24 @@
<svelte:head><title>Build guides — SHD.build</title></svelte:head>
<main class="page guides-page"><div class="catalogue-hero"><div><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>BUILD<br/><em>GUIDES.</em></h1><p>Long-form tactical notes, play styles, rotations, and loadout walkthroughs from experienced agents.</p></div><div class="catalogue-count"><strong>FIELD</strong><span>INTELLIGENCE NETWORK</span><a class="primary" href="/builder">CREATE A BUILD +</a></div></div><div class="guide-layout"><section class="guide-feature"><div class="guide-code">SHD // INTEL</div><p class="eyebrow">EDITORIAL SYSTEM</p><h2>FIELD REPORTS<br/>COMING ONLINE</h2><p>The guide publishing route is ready for build-linked articles. Rich writing, tactical sections, author profiles, and community discovery arrive in a later increment.</p><div class="guide-meta"><span>STATUS <b>QUEUED</b></span><span>PRIORITY <b>NORMAL</b></span><span>NODE <b>NYC-01</b></span></div></section><aside class="guide-index"><p class="eyebrow">PLANNED CHANNELS</p>{#each ['PVE OPERATIONS','PVP DOCTRINE','WEAPON ANALYSIS','TEAM COMPOSITIONS'] as item,i}<div><small>0{i+1}</small><span>{item}</span><b>›</b></div>{/each}</aside></div></main>
<script lang="ts">
import ArticleCard from '$lib/components/ArticleCard.svelte';
let { data } = $props();
const pinned = $derived(data.items.filter((item: any) => item.pinnedOrder !== null).sort((a: any,b: any) => a.pinnedOrder-b.pinnedOrder));
const featured = $derived(data.items.filter((item: any) => item.featured && item.pinnedOrder === null));
const regular = $derived(data.items.filter((item: any) => item.pinnedOrder === null && !item.featured));
function tagHref(slug: string) { const params = new URLSearchParams(); if (data.query) params.set('q',data.query); for (const tag of data.selectedTags) if (tag !== slug) params.append('tag',tag); if (!data.selectedTags.includes(slug)) params.append('tag',slug); params.set('sort',data.sort); return `?${params}`; }
</script>
<svelte:head><title>Community Guides — SHD.build</title><meta name="description" content="Search tactical Guides, Build walkthroughs, specialization strategies, PvE, PvP, Dark Zone, and weapon advice from SHD.build Agents."/></svelte:head>
<main class="page guide-index-page">
<header class="guide-index-command"><div><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>TACTICS,<br/><em>AUTHORED.</em></h1><p>Search community intelligence by objective, specialization, Focus, weapon, or exact field terminology.</p></div><div><p><strong>{data.total}</strong><span>PUBLISHED GUIDES</span></p>{#if data.canCreate}<a class="secondary" href="/account/articles">YOUR ARTICLES</a><a class="primary" href="/guides/new">WRITE A GUIDE +</a>{:else}<a class="primary" href="/sign-in?returnTo=%2Fguides%2Fnew">SIGN IN TO WRITE</a>{/if}</div></header>
<form class="guide-discovery" method="GET"><label><span>SEARCH TITLE AND CONTENT</span><input name="q" value={data.query} placeholder="Search tactics, equipment, activities…"/></label><label><span>SORT</span><select name="sort" value={data.sort}><option value="relevance">BEST MATCH</option><option value="newest">NEWEST</option><option value="rating">HIGHEST RATED</option><option value="rated">MOST RATED</option></select></label>{#each data.selectedTags as tag}<input type="hidden" name="tag" value={tag}/>{/each}<button class="primary">SEARCH</button></form>
<nav class="guide-tag-filter" aria-label="Guide filters">{#each data.tags as tag}<a class:active={data.selectedTags.includes(tag.slug)} href={tagHref(tag.slug)}>{tag.name}</a>{/each}</nav>
{#if pinned.length}<section class="guide-pinned"><header><span>PINNED BY COMMUNITY OVERSIGHT</span><b>{pinned.length} ACTIVE</b></header>{#each pinned as article}<ArticleCard {article} kind="guide" prominent={pinned.length === 1}/>{/each}</section>{/if}
{#if featured.length}<section class="guide-featured"><header><div><span>FEATURED FIELD GUIDES</span><h2>OVERSIGHT SELECTION</h2></div></header><div>{#each featured as article}<ArticleCard {article} kind="guide"/>{/each}</div></section>{/if}
<section class="guide-results"><header><div><span>{data.query ? 'SEARCH RESULTS' : 'LATEST INTELLIGENCE'}</span><h2>{data.total} GUIDE{data.total === 1 ? '' : 'S'}</h2></div></header><div>{#each regular as article}<ArticleCard {article} kind="guide"/>{:else}{#if !pinned.length && !featured.length}<div class="catalogue-empty"><span>◇</span><strong>NO GUIDES MATCH THIS QUERY</strong><p>Adjust the search or filters, or publish the first Guide for this subject.</p></div>{/if}{/each}</div></section>
{#if data.pageCount > 1}<nav class="guide-pagination" aria-label="Guide pages">{#each Array(data.pageCount) as _,index}<a class:active={data.page===index+1} href={`?page=${index+1}`}>{index+1}</a>{/each}</nav>{/if}
</main>
<style>
.guide-index-page{padding-top:28px}.guide-index-command{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:30px;padding:24px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.guide-index-command h1,.guide-index-command p{margin:0}.guide-index-command h1{max-width:10ch;font-size:clamp(3.5rem,7vw,6rem);line-height:.84;letter-spacing:-.035em}.guide-index-command>div:first-child>p:last-child{max-width:64ch;margin-top:18px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.12rem}.guide-index-command>div:last-child{min-width:190px;display:grid;grid-template-columns:1fr;justify-items:stretch;gap:7px}.guide-index-command>div:last-child>p{display:flex;align-items:baseline;justify-content:flex-end;gap:8px;margin-bottom:5px}.guide-index-command>div:last-child strong{color:var(--shd-orange);font-size:2.25rem;line-height:1}.guide-index-command>div:last-child span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.guide-index-command>div:last-child a{text-align:center}
.guide-discovery{display:grid;grid-template-columns:minmax(260px,1fr) 190px auto;align-items:end;gap:8px;padding:10px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.guide-discovery label{display:grid;gap:5px}.guide-discovery label span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.guide-discovery button{min-height:40px}.guide-tag-filter{display:flex;gap:5px;padding:10px 0;overflow:auto}.guide-tag-filter a{min-height:34px;display:flex;align-items:center;padding:0 9px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;white-space:nowrap}.guide-tag-filter a.active,.guide-tag-filter a:hover,.guide-tag-filter a:focus-visible{border-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}
.guide-pinned,.guide-featured,.guide-results{display:grid;gap:10px;margin-top:20px}.guide-pinned>header,.guide-featured>header,.guide-results>header{display:flex;justify-content:space-between;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.guide-pinned>header span,.guide-pinned>header b,.guide-featured>header span,.guide-results>header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.guide-pinned>header span{color:var(--shd-orange)}.guide-featured h2,.guide-results h2{margin:3px 0 0}.guide-featured>div,.guide-results>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.guide-pagination{display:flex;justify-content:center;gap:4px;margin-top:24px}.guide-pagination a{width:40px;height:40px;display:grid;place-items:center;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-weight:900}.guide-pagination a.active{border-color:var(--shd-orange);color:var(--shd-orange)}
@media(max-width:1000px){.guide-featured>div,.guide-results>div{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.guide-index-page{padding-top:12px}.guide-index-command{grid-template-columns:1fr;gap:24px;padding:20px 0 24px}.guide-index-command>div:last-child{min-width:0;grid-template-columns:1fr 1fr}.guide-index-command>div:last-child>p{grid-column:1/-1;justify-content:flex-start}.guide-discovery{grid-template-columns:1fr;padding:8px}.guide-discovery button{min-height:48px}.guide-tag-filter{margin-inline:-18px;padding:10px 18px;scroll-padding-inline:18px}.guide-featured>div,.guide-results>div{grid-template-columns:1fr}.guide-index-command h1{font-size:clamp(3.25rem,17vw,4.5rem)}}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { articlePageActions, loadArticlePage } from '$lib/server/article-page';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) =>
loadArticlePage('guide', params.slug, cookies);
export const actions = articlePageActions('guide');
+2
View File
@@ -0,0 +1,2 @@
<script lang="ts">import ArticlePage from '$lib/components/ArticlePage.svelte';let {data,form}=$props();</script>
<ArticlePage {data} {form}/>
@@ -0,0 +1,60 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import {
deleteArticle,
loadArticleEditor,
NEWS_CHANNELS,
} from '$lib/server/articles';
import { saveAndSubmitArticleForm } from '$lib/server/article-form';
import type { Actions, PageServerLoad } from './$types';
async function guideId(slug: string) {
const row = await (
await (
await getDatabase()
).prepare("SELECT id FROM articles WHERE kind='guide' AND slug=?")
).get(slug);
if (!row) error(404, 'Guide not found');
return String(row.id);
}
export const load: PageServerLoad = async ({ params, cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user)
redirect(303, `/sign-in?returnTo=${encodeURIComponent(url.pathname)}`);
return {
...(await loadArticleEditor(await guideId(params.slug), user.id, false)),
newsChannels: NEWS_CHANNELS,
};
};
export const actions: Actions = {
publish: async ({ params, cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user)
return fail(401, { articleError: 'Sign in to publish this Guide.' });
const result = await saveAndSubmitArticleForm({
form: await request.formData(),
user,
canManageNews: false,
});
if (!result.ok)
return fail(result.status, { articleError: result.message });
return {
articleMessage:
result.state === 'pending_review'
? 'Guide submitted to Community Oversight.'
: result.state === 'private'
? 'Guide saved as private.'
: 'Guide published.',
};
},
delete: async ({ params, cookies }) => {
const user = await getSessionUser(cookies);
if (!user)
return fail(401, { articleError: 'Sign in to delete this Guide.' });
await deleteArticle(await guideId(params.slug), user.id, false);
redirect(303, '/guides');
},
};
@@ -0,0 +1,3 @@
<script lang="ts">import ArticleComposer from '$lib/components/ArticleComposer.svelte';let { data, form } = $props();</script>
<svelte:head><title>Edit {data.draft.title} — SHD.build</title></svelte:head>
<ArticleComposer {data} {form}/>
+45
View File
@@ -0,0 +1,45 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { createArticle } from '$lib/server/articles';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user)
redirect(
303,
`/sign-in?returnTo=${encodeURIComponent(url.pathname + url.search)}`,
);
const buildId = url.searchParams.get('build')?.trim() || '';
const build = buildId
? await (
await (
await getDatabase()
).prepare('SELECT id,title FROM builds WHERE id=? AND owner_id=?')
).get(buildId, user.id)
: null;
return {
build: build ? { id: String(build.id), title: String(build.title) } : null,
};
};
export const actions: Actions = {
create: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { articleError: 'Sign in to create a Guide.' });
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
if (title.length < 2 || title.length > 140)
return fail(400, {
articleError: 'Guide titles must be between 2 and 140 characters.',
});
const article = await createArticle({
kind: 'guide',
ownerId: user.id,
title,
prefillBuildId: String(form.get('buildId') ?? '') || null,
});
redirect(303, `/guides/${encodeURIComponent(article.slug)}/edit`);
},
};
+4
View File
@@ -0,0 +1,4 @@
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let { data, form } = $props();</script>
<svelte:head><title>Create a Guide — SHD.build</title></svelte:head>
<main class="page article-start-page"><section><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>START A<br/><em>GUIDE.</em></h1><p>Open a private writing workspace. Your Guide remains private until you submit a finished revision for moderation.</p><PendingForm action="?/create"><label>WORKING TITLE<input name="title" minlength="2" maxlength="140" required placeholder="Name the tactic, Build, or problem this Guide explains"/></label>{#if data.build}<input type="hidden" name="buildId" value={data.build.id}/><div class="prefill-build"><span>INITIAL BUILD EMBED</span><strong>{data.build.title}</strong><small>You can remove it or add more Builds in the composer.</small></div>{/if}{#if form?.articleError}<p class="error">{form.articleError}</p>{/if}<button class="primary">OPEN GUIDE COMPOSER →</button></PendingForm></section></main>
<style>.article-start-page{min-height:calc(100vh - 160px);display:grid;place-items:start center;padding-top:clamp(48px,8vh,110px)}.article-start-page>section{width:min(760px,100%);display:grid;gap:16px;padding:clamp(24px,5vw,60px);border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-start-page h1,.article-start-page p{margin:0}.article-start-page h1{font-size:clamp(3rem,8vw,6rem);line-height:.85}.article-start-page>section>p:not(.eyebrow){max-width:62ch;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.article-start-page :global(form){display:grid;gap:12px}.article-start-page label{display:grid;gap:6px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-start-page input{min-height:52px;font-size:1.1rem}.prefill-build{display:grid;gap:3px;padding:12px;border:1px solid var(--shd-orange);background:#17140e}.prefill-build span,.prefill-build small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.prefill-build strong{font-size:1.2rem}@media(max-width:650px){.article-start-page{padding-top:40px}.article-start-page>section{padding:24px}.article-start-page :global(form button){width:100%;min-height:48px}}</style>
@@ -4,14 +4,21 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
if (!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])) error(404, 'Icon not found');
const image = await readGameDataIcon(await getDatabase(), params.type, params.id);
if (!image) error(404, 'Icon not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
if (
!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])
)
error(404, 'Icon not found');
const image = await readGameDataIcon(
await getDatabase(),
params.type,
params.id,
);
if (!image) error(404, 'Icon not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff',
},
});
};
+9 -9
View File
@@ -4,13 +4,13 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const image = await readManagedImage(await getDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
const image = await readManagedImage(await getDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff',
},
});
};
+96 -46
View File
@@ -6,56 +6,106 @@ import type { LayoutServerLoad } from './$types';
import { getContributorMode } from '$lib/server/contributor-mode';
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');
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
const canManageData = permissions.includes('*') || permissions.includes('data.manage');
const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
const canApproveData = permissions.includes('*') || permissions.includes('data.approve');
const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review');
const canReviewCommunity = permissions.includes('*')
|| permissions.includes('community.comments.moderate')
|| permissions.includes('community.profiles.moderate');
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
const database = canSeeData || canReviewAvatars || canReviewCommunity ? await getDatabase() : null;
const pendingRows = canSeeData
? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
: [];
const pendingProposalTotal = pendingRows.reduce((total, row) => total + Number(row.count), 0);
const pendingAvatarReviews = canReviewAvatars
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
: 0;
const canReviewComments = permissions.includes('*') || permissions.includes('community.comments.moderate');
const canReviewProfiles = permissions.includes('*') || permissions.includes('community.profiles.moderate');
const communityReviewTypes = [
...(canReviewComments ? ['comment', 'guide'] : []),
...(canReviewProfiles ? ['profile_bio'] : [])
];
const communityTypePlaceholders = communityReviewTypes.map(() => '?').join(',');
const pendingCommunityReviews = canReviewCommunity && communityReviewTypes.length
? Number((await (await database!.prepare(`SELECT
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');
const canSeeData =
permissions.includes('*') ||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
(permission) => permissions.includes(permission),
);
const canManageData =
permissions.includes('*') || permissions.includes('data.manage');
const canProposeData =
permissions.includes('*') || permissions.includes('data.propose');
const canApproveData =
permissions.includes('*') || permissions.includes('data.approve');
const canReviewAvatars =
permissions.includes('*') || permissions.includes('avatars.review');
const canReviewCommunity =
permissions.includes('*') ||
permissions.includes('community.comments.moderate') ||
permissions.includes('community.profiles.moderate') ||
permissions.includes('community.guides.moderate');
const contributorMode =
!canManageData && canProposeData ? await getContributorMode(user.id) : null;
const database =
canSeeData || canReviewAvatars || canReviewCommunity
? await getDatabase()
: null;
const pendingRows = canSeeData
? await (
await database!.prepare(
"SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module",
)
).all()
: [];
const pendingProposalTotal = pendingRows.reduce(
(total, row) => total + Number(row.count),
0,
);
const pendingAvatarReviews = canReviewAvatars
? Number(
(
await (
await database!.prepare(
"SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'",
)
).get()
)?.count ?? 0,
)
: 0;
const canReviewComments =
permissions.includes('*') ||
permissions.includes('community.comments.moderate');
const canReviewProfiles =
permissions.includes('*') ||
permissions.includes('community.profiles.moderate');
const canReviewGuides =
permissions.includes('*') ||
permissions.includes('community.guides.moderate');
const communityReviewTypes = [
...(canReviewComments ? ['comment'] : []),
...(canReviewGuides ? ['guide'] : []),
...(canReviewProfiles ? ['profile_bio'] : []),
];
const communityTypePlaceholders = communityReviewTypes
.map(() => '?')
.join(',');
const pendingCommunityReviews =
canReviewCommunity && communityReviewTypes.length
? Number(
(
await (
await database!.prepare(`SELECT
(SELECT COUNT(*) FROM content_moderation_scans scan
WHERE scan.content_type IN (${communityTypePlaceholders})
AND scan.status IN ('review','priority_review','error')
AND NOT EXISTS(SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id))
+
(SELECT COUNT(*) FROM user_content_reports
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`))
.get(...communityReviewTypes, ...communityReviewTypes))?.count ?? 0)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
return {
permissions,
contributorMode,
buildNumber,
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0,
avatars: pendingAvatarReviews,
community: pendingCommunityReviews
}
};
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`)
).get(...communityReviewTypes, ...communityReviewTypes)
)?.count ?? 0,
)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber)
? configuredBuildNumber
: '0';
return {
permissions,
contributorMode,
buildNumber,
pendingProposals: Object.fromEntries(
pendingRows.map((row) => [String(row.module), Number(row.count)]),
),
oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0,
avatars: pendingAvatarReviews,
community: pendingCommunityReviews,
},
};
};
+14 -2
View File
@@ -11,6 +11,7 @@
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
const isacNode = 'NYC-01';
let readOnlyBlocked = $state(false);
let contributorNote = $state('');
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
@@ -36,7 +37,8 @@
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' | 'community' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'], reviewKey: 'community' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate', 'community.guides.moderate'], reviewKey: 'community' },
{ href: '/isac/community/guides', label: 'Guide Oversight', permissions: ['community.guides.moderate', 'guides.curate'] },
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) =>
@@ -128,13 +130,23 @@
event.preventDefault();
readOnlyBlocked = true;
}
const form = event.target;
if (form instanceof HTMLFormElement && contributorEnabled && can('data.propose') && !can('data.manage') && form.method.toLowerCase() === 'post') {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'contributorNote'; input.value = contributorNote;
form.append(input);
}
}}>
{#if dataModulePage && 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"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
{:else if dataModulePage && contributorEnabled && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if dataModulePage && readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/>{#if contributorEnabled && can('data.propose') && !can('data.manage')}<label class="contributor-note"><span>CONTRIBUTOR RATIONALE <small>OPTIONAL · SHARED WITH OVERSIGHT</small></span><textarea bind:value={contributorNote} maxlength="1000" rows="2" placeholder="Explain the observed inaccuracy, source, or reason for this change…"></textarea></label>{/if}</div>{/if}
{@render children()}
</section>
</div>
</main>
<style>
.contributor-note{display:grid;gap:6px;margin-top:8px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.contributor-note>span{display:flex;justify-content:space-between;gap:8px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.contributor-note small{color:var(--shd-text-muted);font:inherit}.contributor-note textarea{width:100%;resize:vertical}
</style>
+19 -10
View File
@@ -4,14 +4,23 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view'))
redirect(303, '/isac/users');
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (
permissions.includes('*') ||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
(permission) => permissions.includes(permission),
)
)
redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('site.settings.manage'))
redirect(303, '/isac/site-controls');
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage'))
redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
};
+313 -102
View File
@@ -1,116 +1,327 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireDataView, 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';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const pending = (await getPendingEntities(user.id, [{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
}])).map((stat) => ({
...stat,
calculation_type: stat.calculationType,
gear_min: stat.gearMin || null,
gear_max: stat.gearMax || null,
weapon_min: stat.weaponMin || null,
weapon_max: stat.weaponMax || null,
skill_chip_min: stat.chipMin || null,
skill_chip_max: stat.chipMax || null,
cap_value: stat.capValue || null
}));
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const pending = (
await getPendingEntities(user.id, [
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: [
'description',
'type',
'category',
'calculationType',
'unit',
'gearMin',
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
'capValue',
],
},
])
).map((stat) => ({
...stat,
calculation_type: stat.calculationType,
gear_min: stat.gearMin || null,
gear_max: stat.gearMax || null,
weapon_min: stat.weaponMin || null,
weapon_max: stat.weaponMax || null,
skill_chip_min: stat.chipMin || null,
skill_chip_max: stat.chipMax || null,
cap_value: stat.capValue || null,
}));
const summary = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC' },
type: { asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
category: { asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
calculation: { asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
}, 'name');
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const visiblePending = pending.filter((stat) =>
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
&& (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
).sort((left, right) => {
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
const result = String((left as any)[field] ?? '').localeCompare(String((right as any)[field] ?? ''), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args);
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const stats = databaseLimit
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
: [];
return {
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
pagination,
filter,
search,
sort
};
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)
).get();
const filter = ['active', 'offensive', 'defensive'].includes(
url.searchParams.get('filter') ?? '',
)
? String(url.searchParams.get('filter'))
: '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC',
desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC',
},
type: {
asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
},
category: {
asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
},
calculation: {
asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
},
state: {
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
defaultDirection: 'desc',
},
},
'name',
);
const conditions = [
...(filter === 'active'
? ['active=1']
: filter === 'offensive'
? ["type='Offensive'"]
: filter === 'defensive'
? ["type='Defensive'"]
: []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const visiblePending = pending
.filter(
(stat) =>
(filter === 'offensive'
? String((stat as any).type) === 'Offensive'
: filter === 'defensive'
? String((stat as any).type) === 'Defensive'
: true) &&
(!search ||
String(stat.name)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())),
)
.sort((left, right) => {
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
const result = String((left as any)[field] ?? '').localeCompare(
String((right as any)[field] ?? ''),
undefined,
{ sensitivity: 'base', numeric: true },
);
return (
(sort.direction === 'asc' ? result : -result) ||
String(left.name).localeCompare(String(right.name), undefined, {
sensitivity: 'base',
})
);
});
const filtered = await (
await database.prepare(
`SELECT COUNT(*) AS total FROM stat_attributes ${where}`,
)
).get(...args);
const pagination = getPagination(
url,
Number(filtered?.total ?? 0) + visiblePending.length,
);
const pendingOnPage =
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const stats = databaseLimit
? await (
await database.prepare(
`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
)
).all(...args, databaseLimit, databaseOffset)
: [];
return {
stats: [
...pendingOnPage,
...stats.map((stat) => ({
...stat,
unit: stat.unit === 'flat' ? '' : stat.unit,
})),
],
summary: {
...summary,
total: Number(summary?.total ?? 0) + pending.length,
pending: pending.length,
},
pagination,
filter,
search,
sort,
};
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string, allowNegative = true) => {
const raw = String(form.get(key) ?? '').trim();
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || (!allowNegative && value < 0)) throw new Error();
return value;
};
let values: Array<number|null>;
try {
values = [
...['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax'].map((key) => number(key)),
number('capValue', false)
];
} catch { return fail(400, { error: 'Ranges must be numbers. Optional caps cannot be negative.' }); }
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?')).get(name, unit, id || 'new')) return fail(409, { error: 'That attribute and unit combination already exists.' });
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
const savedItem = await auditRecord('stat_attributes', statId);
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('stat_attributes', id);
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'attribute.archived', entityId: id, before, after: await auditRecord('stat_attributes', id) }); return { archived: true };
}
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? ''),
name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const description = String(form.get('description') ?? '')
.normalize('NFKC')
.trim();
const type = String(form.get('type') ?? ''),
category = String(form.get('category') ?? ''),
calculation = String(form.get('calculationType') ?? ''),
unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80)
return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (description.length > 1200)
return fail(400, {
error: 'Attribute descriptions must be 1,200 characters or fewer.',
});
if (
!['Offensive', 'Defensive'].includes(type) ||
!['Weapon', 'Skill', 'Character'].includes(category) ||
!['Additive', 'Multiplicative'].includes(calculation)
)
return fail(400, { error: 'Choose valid attribute options.' });
if (!['', '%', 's', 'Shots', 'm'].includes(unit))
return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string, allowNegative = true) => {
const raw = String(form.get(key) ?? '').trim();
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || (!allowNegative && value < 0))
throw new Error();
return value;
};
let values: Array<number | null>;
try {
values = [
...[
'gearMin',
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
].map((key) => number(key)),
number('capValue', false),
];
} catch {
return fail(400, {
error: 'Ranges must be numbers. Optional caps cannot be negative.',
});
}
for (let index = 0; index < 6; index += 2)
if (
(values[index] === null) !== (values[index + 1] === null) ||
(values[index] !== null &&
Number(values[index]) > Number(values[index + 1]))
)
return fail(400, {
error: 'Each enabled source needs a valid minimum and maximum.',
});
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?',
)
).get(name, unit, id || 'new')
)
return fail(409, {
error: 'That attribute and unit combination already exists.',
});
const now = new Date().toISOString(),
statId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
if (id)
await (
await database.prepare(
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
)
).run(
name,
description || null,
type,
category,
calculation,
unit,
...values,
now,
id,
);
else
await (
await database.prepare(
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
statId,
name,
description || null,
type,
category,
calculation,
unit,
...values,
now,
now,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'attribute.updated' : 'attribute.created',
entityId: statId,
before,
after: await auditRecord('stat_attributes', statId),
});
const savedItem = await auditRecord('stat_attributes', statId);
return {
saved: true,
editorMode: id ? 'edit' : 'add',
savedItem: {
...savedItem,
unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit,
},
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('stat_attributes', id);
await (
await (
await getDatabase()
).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'attribute.archived',
entityId: id,
before,
after: await auditRecord('stat_attributes', id),
});
return { archived: true };
},
};
+424 -147
View File
@@ -1,4 +1,7 @@
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import {
getPermissions,
requirePermission,
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
@@ -8,83 +11,137 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const reversibleActions = new Set([
'attribute.updated', 'attribute.archived',
'brand_set.archived', 'gear.archived', 'weapon.archived',
'role.updated', 'access.updated'
'attribute.updated',
'attribute.archived',
'brand_set.archived',
'gear.archived',
'weapon.archived',
'role.updated',
'access.updated',
]);
const parseSnapshot = (value: unknown): Record<string, any> | null => {
if (typeof value !== 'string' || !value) return null;
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; }
if (typeof value !== 'string' || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
const normalize = (value: unknown): unknown => {
if (Array.isArray(value)) {
const normalized = value.map(normalize);
return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item))
? normalized.sort((left, right) => String(left).localeCompare(String(right)))
: normalized;
}
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, normalize(item)]));
if (Array.isArray(value)) {
const normalized = value.map(normalize);
return normalized.every((item) =>
['string', 'number', 'boolean'].includes(typeof item),
)
? normalized.sort((left, right) =>
String(left).localeCompare(String(right)),
)
: normalized;
}
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, normalize(item)]),
);
};
const comparable = (value: unknown): string => JSON.stringify(normalize(value));
const sameRecord = (current: Record<string, unknown> | null, expected: Record<string, unknown> | null) => {
if (!current || !expected) return false;
return Object.entries(expected).every(([key, value]) =>
!['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value)
);
const sameRecord = (
current: Record<string, unknown> | null,
expected: Record<string, unknown> | null,
) => {
if (!current || !expected) return false;
return Object.entries(expected).every(
([key, value]) =>
!['created_at', 'updated_at'].includes(key) &&
comparable(current[key]) === comparable(value),
);
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'audit.view');
const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert');
const database = await getDatabase();
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
const category = url.searchParams.get('category')?.trim() ?? '';
const action = url.searchParams.get('action')?.trim() ?? '';
const sort = getRegistrySort(url, {
event: {
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC'
},
operator: {
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC'
},
target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC' },
time: {
asc: 'audit_log.created_at ASC',
desc: 'audit_log.created_at DESC',
defaultDirection: 'desc'
}
}, 'time', 'desc');
const conditions: string[] = [];
const args: unknown[] = [];
if (query) {
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
const operator = await requirePermission(
await getSessionUser(cookies),
'audit.view',
);
const canRevert =
(await getPermissions(operator)).includes('*') ||
(await getPermissions(operator)).includes('audit.revert');
const database = await getDatabase();
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
const category = url.searchParams.get('category')?.trim() ?? '';
const action = url.searchParams.get('action')?.trim() ?? '';
const sort = getRegistrySort(
url,
{
event: {
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC',
},
operator: {
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC',
},
target: {
asc: 'audit_log.entity_id COLLATE NOCASE ASC',
desc: 'audit_log.entity_id COLLATE NOCASE DESC',
},
time: {
asc: 'audit_log.created_at ASC',
desc: 'audit_log.created_at DESC',
defaultDirection: 'desc',
},
},
'time',
'desc',
);
const conditions: string[] = [];
const args: unknown[] = [];
if (query) {
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
OR audit_log.before_json LIKE ? ESCAPE '\\' COLLATE NOCASE
OR audit_log.after_json LIKE ? ESCAPE '\\' COLLATE NOCASE
OR users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
args.push(pattern, pattern, pattern, pattern, pattern);
}
if (category === 'user') conditions.push("audit_log.entity_type IN ('user','user_access','user_moderation','identity')");
else if (category === 'access_policy') conditions.push("audit_log.entity_type IN ('role','role_management')");
else if (category) { conditions.push('audit_log.entity_type=?'); args.push(category); }
if (action) { conditions.push('audit_log.action=?'); args.push(action); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [total, categories, actions, summary] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)).get(...args),
(await database.prepare('SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type')).all(),
(await database.prepare('SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action')).all(),
(await database.prepare(`SELECT COUNT(*) AS total,
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
args.push(pattern, pattern, pattern, pattern, pattern);
}
if (category === 'user')
conditions.push(
"audit_log.entity_type IN ('user','user_access','user_moderation','identity')",
);
else if (category === 'access_policy')
conditions.push("audit_log.entity_type IN ('role','role_management')");
else if (category) {
conditions.push('audit_log.entity_type=?');
args.push(category);
}
if (action) {
conditions.push('audit_log.action=?');
args.push(action);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [total, categories, actions, summary] = await Promise.all([
(
await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)
).get(...args),
(
await database.prepare(
'SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type',
)
).all(),
(
await database.prepare(
'SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action',
)
).all(),
(
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
@@ -93,95 +150,315 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration,
SUM(CASE WHEN entity_type='ai_assistance' THEN 1 ELSE 0 END) AS ai_assistance
FROM audit_log`)).get()
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
const entries = await (await database.prepare(`SELECT audit_log.*,
FROM audit_log`)
).get(),
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
const entries = await (
await database.prepare(`SELECT audit_log.*,
COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login
FROM audit_log JOIN users ON users.id=audit_log.actor_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : [];
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
return {
entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })),
categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort
};
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)
).all(...args, pagination.perPage, pagination.offset);
const revertedIds = entries.length
? await (
await database.prepare(`SELECT entity_id FROM audit_log
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)
).all(...entries.map((entry) => entry.id))
: [];
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
return {
entries: entries.map((entry) => ({
...entry,
reversible:
canRevert &&
reversibleActions.has(String(entry.action)) &&
Boolean(entry.before_json) &&
!reverted.has(String(entry.id)),
reverted: reverted.has(String(entry.id)),
})),
categories,
actions,
summary,
pagination,
filters: { query, category, action },
canRevert,
sort,
};
};
export const actions: Actions = {
revert: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'audit.revert');
const operatorPermissions = await getPermissions(operator);
const isFullAdmin = operatorPermissions.includes('*');
const eventId = String((await request.formData()).get('eventId') ?? '');
const database = await getDatabase();
const event = await (await database.prepare('SELECT * FROM audit_log WHERE id=?')).get(eventId);
if (!event) return fail(404, { revertError: 'Audit event not found.' });
if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.' });
if (await (await database.prepare("SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?")).get(eventId)) {
return fail(409, { revertError: 'This audit event has already been reverted.' });
}
const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json);
if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.' });
const action = String(event.action), targetId = String(event.entity_id);
let current: Record<string, unknown> | null = null;
let restored: Record<string, unknown> | null = null;
try {
if (action.startsWith('attribute.')) {
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
.run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
} else if (action.endsWith('.archived')) {
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
if (!table) return fail(400, { revertError: 'Unsupported archive category.' });
current = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.' });
await (await database.prepare(`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`)).run(before.active, new Date().toISOString(), targetId);
restored = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
} else if (action === 'role.updated') {
const currentRole = await (await database.prepare('SELECT name,description FROM roles WHERE id=?')).get(targetId);
const currentPermissions = await (await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(targetId);
current = { ...currentRole, permissions: currentPermissions.map((row) => row.name) };
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.' });
const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : [];
if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.' });
const permissionRows = permissionNames.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`)).all(...permissionNames) : [];
if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.' });
await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(before.name, before.description ?? null, targetId);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(targetId);
for (const permission of permissionRows) await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(targetId, permission.id);
restored = { name: before.name, description: before.description ?? null, permissions: permissionNames };
} else if (action === 'access.updated') {
const currentRoles = await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(targetId);
const currentPermissions = await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(targetId);
current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id) };
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.' });
const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : [];
const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : [];
const restoresAdminRole = roleIds.includes('role-admin');
const restoresSystemPermission = permissionIds.includes('permission-system-admin');
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.' });
const roleCount = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
const permissionCount = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
if (roleCount !== roleIds.length || permissionCount !== permissionIds.length) return fail(409, { revertError: 'A previous role or permission no longer exists.' });
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(targetId);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(targetId);
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(targetId, roleId);
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(targetId, permissionId);
restored = { roleIds, permissionIds };
} else {
return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.' });
}
} catch (error) {
if (error && typeof error === 'object' && 'status' in error) throw error;
return fail(409, { revertError: 'The previous state could not be restored because related data has changed.' });
}
await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored } });
return { reverted: true, resultToken: randomUUID() };
}
revert: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'audit.revert',
);
const operatorPermissions = await getPermissions(operator);
const isFullAdmin = operatorPermissions.includes('*');
const eventId = String((await request.formData()).get('eventId') ?? '');
const database = await getDatabase();
const event = await (
await database.prepare('SELECT * FROM audit_log WHERE id=?')
).get(eventId);
if (!event) return fail(404, { revertError: 'Audit event not found.' });
if (!reversibleActions.has(String(event.action)) || !event.before_json)
return fail(400, {
revertError:
'This event does not contain a supported reversible change.',
});
if (
await (
await database.prepare(
"SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?",
)
).get(eventId)
) {
return fail(409, {
revertError: 'This audit event has already been reverted.',
});
}
const before = parseSnapshot(event.before_json),
after = parseSnapshot(event.after_json);
if (!before || !after)
return fail(400, {
revertError: 'The stored snapshots cannot be safely interpreted.',
});
const action = String(event.action),
targetId = String(event.entity_id);
let current: Record<string, unknown> | null = null;
let restored: Record<string, unknown> | null = null;
try {
if (action.startsWith('attribute.')) {
current =
(await (
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
).get(targetId)) ?? null;
if (!sameRecord(current, after))
return fail(409, {
revertError:
'The attribute changed after this event. Reversion was blocked to preserve newer work.',
});
await (
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
).run(
before.name,
before.description ?? null,
before.type,
before.category,
before.calculation_type,
before.unit,
before.gear_min,
before.gear_max,
before.weapon_min,
before.weapon_max,
before.skill_chip_min,
before.skill_chip_max,
before.cap_value,
before.active,
new Date().toISOString(),
targetId,
);
restored =
(await (
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
).get(targetId)) ?? null;
} else if (action.endsWith('.archived')) {
const table = action.startsWith('brand_set.')
? 'brand_sets'
: action.startsWith('gear.')
? 'gear'
: action.startsWith('weapon.')
? 'weapons'
: '';
if (!table)
return fail(400, { revertError: 'Unsupported archive category.' });
current =
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
).get(targetId)) ?? null;
if (!sameRecord(current, after))
return fail(409, {
revertError:
'The record changed after it was archived. Reversion was blocked.',
});
await (
await database.prepare(
`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`,
)
).run(before.active, new Date().toISOString(), targetId);
restored =
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
).get(targetId)) ?? null;
} else if (action === 'role.updated') {
const currentRole = await (
await database.prepare(
'SELECT name,description FROM roles WHERE id=?',
)
).get(targetId);
const currentPermissions = await (
await database.prepare(
`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`,
)
).all(targetId);
current = {
...currentRole,
permissions: currentPermissions.map((row) => row.name),
};
if (comparable(current) !== comparable(after))
return fail(409, {
revertError:
'The role changed after this event. Reversion was blocked.',
});
const permissionNames = Array.isArray(before.permissions)
? before.permissions.map(String)
: [];
if (permissionNames.includes('system.admin') && !isFullAdmin)
return fail(403, {
revertError:
'Only a full administrator can restore unrestricted system access.',
});
const permissionRows = permissionNames.length
? await (
await database.prepare(
`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`,
)
).all(...permissionNames)
: [];
if (permissionRows.length !== permissionNames.length)
return fail(409, {
revertError: 'A previous permission no longer exists.',
});
await (
await database.prepare(
'UPDATE roles SET name=?,description=? WHERE id=?',
)
).run(before.name, before.description ?? null, targetId);
await (
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
).run(targetId);
for (const permission of permissionRows)
await (
await database.prepare(
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
)
).run(targetId, permission.id);
restored = {
name: before.name,
description: before.description ?? null,
permissions: permissionNames,
};
} else if (action === 'access.updated') {
const currentRoles = await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(targetId);
const currentPermissions = await (
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
)
).all(targetId);
current = {
roleIds: currentRoles.map((row) => row.role_id),
permissionIds: currentPermissions.map((row) => row.permission_id),
};
if (comparable(current) !== comparable(after))
return fail(409, {
revertError:
'The user’s access changed after this event. Reversion was blocked.',
});
const roleIds = Array.isArray(before.roleIds)
? before.roleIds.map(String)
: [];
const permissionIds = Array.isArray(before.permissionIds)
? before.permissionIds.map(String)
: [];
const restoresAdminRole = roleIds.includes('role-admin');
const restoresSystemPermission = permissionIds.includes(
'permission-system-admin',
);
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin)
return fail(403, {
revertError:
'Only a full administrator can restore administrator access.',
});
const roleCount = roleIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
)
).get(...roleIds)
)?.count,
)
: 0;
const permissionCount = permissionIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).get(...permissionIds)
)?.count,
)
: 0;
if (
roleCount !== roleIds.length ||
permissionCount !== permissionIds.length
)
return fail(409, {
revertError: 'A previous role or permission no longer exists.',
});
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(targetId);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(targetId);
for (const roleId of roleIds)
await (
await database.prepare(
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
)
).run(targetId, roleId);
for (const permissionId of permissionIds)
await (
await database.prepare(
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
)
).run(targetId, permissionId);
restored = { roleIds, permissionIds };
} else {
return fail(400, {
revertError:
'This change requires a specialized recovery workflow that is not available yet.',
});
}
} catch (error) {
if (error && typeof error === 'object' && 'status' in error) throw error;
return fail(409, {
revertError:
'The previous state could not be restored because related data has changed.',
});
}
await recordIsacAudit({
actorId: operator.id,
category: 'audit_control',
action: 'audit.reverted',
entityId: eventId,
before: current,
after: {
originalEventId: eventId,
originalAction: action,
targetId,
restored,
},
});
return { reverted: true, resultToken: randomUUID() };
},
};
+166 -64
View File
@@ -12,21 +12,34 @@ import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'avatars.review');
const database = await getDatabase();
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const conditions = ['r.status=?'];
const args: unknown[] = [status];
if (search) {
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
conditions.push("(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)");
args.push(value, value);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const reviews = await (await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
await requirePermission(await getSessionUser(cookies), 'avatars.review');
const database = await getDatabase();
const status = statuses.includes(url.searchParams.get('status') ?? '')
? String(url.searchParams.get('status'))
: 'pending';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const conditions = ['r.status=?'];
const args: unknown[] = [status];
if (search) {
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
conditions.push(
"(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)",
);
args.push(value, value);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`,
)
).get(...args)
)?.total ?? 0,
);
const pagination = getPagination(url, total);
const reviews = await (
await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
CASE WHEN a.source='upload' AND a.updated_at=r.avatar_updated_at THEN 1 ELSE 0 END AS is_current,
COALESCE(rp.display_name,ru.username) AS reviewer_name
FROM avatar_reviews r
@@ -35,56 +48,145 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN user_avatars a ON a.user_id=r.user_id
LEFT JOIN users ru ON ru.id=r.reviewed_by
LEFT JOIN user_profiles rp ON rp.user_id=ru.id
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status')).all();
return { reviews, pagination, status, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])) };
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)
).all(...args, pagination.perPage, pagination.offset);
const summaryRows = await (
await database.prepare(
'SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status',
)
).all();
return {
reviews,
pagination,
status,
search,
summary: Object.fromEntries(
summaryRows.map((row) => [String(row.status), Number(row.count)]),
),
};
};
export const actions: Actions = {
approve: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
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 review = await (await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at) {
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
}
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
.run(now, reviewer.id, note || null, id);
await (await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note || 'Oversight approved the current custom avatar.', review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
},
remove: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
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, { avatarReviewError: 'Add a brief reason for removing this avatar.' });
const database = await getDatabase();
const review = await (await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at || review.current_storage_key !== review.storage_key) {
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
}
const before = { reviewId: id, status: 'pending', storageKey: review.storage_key, byteSize: review.byte_size };
await deleteUploadedAvatar(database, String(review.user_id));
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
.run(now, reviewer.id, note, id);
await (await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note, review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
}
approve: async ({ cookies, request }) => {
const reviewer = await requirePermission(
await getSessionUser(cookies),
'avatars.review',
);
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 review = await (
await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
).get(id);
if (!review)
return fail(404, {
avatarReviewError: 'That pending avatar review no longer exists.',
});
if (
review.source !== 'upload' ||
review.current_updated_at !== review.avatar_updated_at
) {
return fail(409, {
avatarReviewError:
'This image is no longer the agent’s current custom avatar.',
});
}
const now = new Date().toISOString();
await (
await database.prepare(
"UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'",
)
).run(now, reviewer.id, note || null, id);
await (
await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
).run(
now,
reviewer.id,
note || 'Oversight approved the current custom avatar.',
review.user_id,
);
await recordIsacAudit({
actorId: reviewer.id,
category: 'user_moderation',
action: 'avatar.approved',
entityId: String(review.user_id),
before: { reviewId: id, status: 'pending' },
after: { reviewId: id, status: 'approved', note: note || null },
});
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
},
remove: async ({ cookies, request }) => {
const reviewer = await requirePermission(
await getSessionUser(cookies),
'avatars.review',
);
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, {
avatarReviewError: 'Add a brief reason for removing this avatar.',
});
const database = await getDatabase();
const review = await (
await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
).get(id);
if (!review)
return fail(404, {
avatarReviewError: 'That pending avatar review no longer exists.',
});
if (
review.source !== 'upload' ||
review.current_updated_at !== review.avatar_updated_at ||
review.current_storage_key !== review.storage_key
) {
return fail(409, {
avatarReviewError:
'This image is no longer the agent’s current custom avatar.',
});
}
const before = {
reviewId: id,
status: 'pending',
storageKey: review.storage_key,
byteSize: review.byte_size,
};
await deleteUploadedAvatar(database, String(review.user_id));
const now = new Date().toISOString();
await (
await database.prepare(
"UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?",
)
).run(now, reviewer.id, note, id);
await (
await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
).run(now, reviewer.id, note, review.user_id);
await recordIsacAudit({
actorId: reviewer.id,
category: 'user_moderation',
action: 'avatar.removed',
entityId: String(review.user_id),
before,
after: { reviewId: id, status: 'removed', source: 'default', note },
});
await createNotification({
userId: String(review.user_id),
category: 'isac',
source: 'ISAC AVATAR OVERSIGHT',
title: 'Custom avatar removed',
body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`,
link: '/account#avatar',
mandatory: true,
});
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
},
};
+432 -118
View File
@@ -1,39 +1,99 @@
import { requireDataView, 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';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
const thresholds = [2, 3, 4] as const;
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const [pendingBrands, pendingStats] = await Promise.all([
getPendingEntities(user.id, [{ module: 'brands', action: 'save', kind: 'brand', fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'] }]),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }])
]);
const mappedPendingBrands = pendingBrands.map((brand) => ({
...brand,
iconUrl: '',
effects: thresholds.map((pieces) => ({
pieces_required: pieces,
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
value: Number(brand[`value${pieces}`] ?? 0)
}))
}));
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const [pendingBrands, pendingStats] = await Promise.all([
getPendingEntities(user.id, [
{
module: 'brands',
action: 'save',
kind: 'brand',
fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'],
},
]),
getPendingEntities(user.id, [
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['unit'],
},
]),
]);
const mappedPendingBrands = pendingBrands.map((brand) => ({
...brand,
iconUrl: '',
renderUrl: '',
effects: thresholds.map((pieces) => ({
pieces_required: pieces,
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
value: Number(brand[`value${pieces}`] ?? 0),
})),
}));
const summary = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)).get();
const filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)
).get();
const filter = ['active', 'archived', 'complete'].includes(
url.searchParams.get('filter') ?? '',
)
? String(url.searchParams.get('filter'))
: '';
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
const { search, pattern } = getRegistrySearch(url);
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
SELECT attributes.name FROM brand_set_effects effect
JOIN stat_attributes attributes ON attributes.id=effect.stat_attribute_id
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
@@ -43,102 +103,356 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
LIMIT 1
), 0) ${direction}, name COLLATE NOCASE ASC, id ASC`;
const sort = getRegistrySort(url, {
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
}, 'name');
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'complete' ? ['(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3'] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args);
const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))).sort((left, right) => {
const pieces = sort.key.startsWith('bonus') ? Number(sort.key.slice(5)) : 0;
const valueFor = (brand: any) => pieces
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
: sort.key === 'state' ? String(brand.active ?? 1) : String(brand.name);
const result = valueFor(left).localeCompare(valueFor(right), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
]);
return {
brands: [...pendingOnPage, ...brands.map((brand) => {
const icon = icons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
})],
stats: [...pendingStats, ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
pagination,
filter,
search,
sort
};
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC, id ASC',
desc: 'name COLLATE NOCASE DESC, id DESC',
},
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
state: {
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
defaultDirection: 'desc',
},
},
'name',
);
const conditions = [
...(requestedItemId
? ['id=?']
: filter === 'active'
? ['active=1']
: filter === 'archived'
? ['active=0']
: filter === 'complete'
? [
'(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3',
]
: []),
...(!requestedItemId && search
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
: []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = [
...(requestedItemId ? [requestedItemId] : []),
...(!requestedItemId && search ? [pattern] : []),
];
const filtered = await (
await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)
).get(...args);
const visiblePending = mappedPendingBrands
.filter((brand) =>
requestedItemId
? brand.id === requestedItemId
: filter !== 'archived' &&
(!search ||
String(brand.name)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())),
)
.sort((left, right) => {
const pieces = sort.key.startsWith('bonus')
? Number(sort.key.slice(5))
: 0;
const valueFor = (brand: any) =>
pieces
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
: sort.key === 'state'
? String(brand.active ?? 1)
: String(brand.name);
const result = valueFor(left).localeCompare(valueFor(right), undefined, {
sensitivity: 'base',
numeric: true,
});
return (
(sort.direction === 'asc' ? result : -result) ||
String(left.name).localeCompare(String(right.name), undefined, {
sensitivity: 'base',
})
);
});
const pagination = getPagination(
url,
Number(filtered?.total ?? 0) + visiblePending.length,
);
const pendingOnPage =
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [brands, effects, stats, effectSummary, icons, renders] =
await Promise.all([
databaseLimit
? (
await database.prepare(
`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
)
).all(...args, databaseLimit, databaseOffset)
: [],
(
await database.prepare(
'SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT COUNT(*) AS total FROM brand_set_effects',
)
).get(),
(
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='brand_set_render'",
)
).all(),
]);
return {
brands: [
...pendingOnPage,
...brands.map((brand) => {
const icon = icons.find((item) => item.entity_id === brand.id);
const render = renders.find(
(item) => item.source_entity_id === brand.id,
);
return {
...brand,
iconUrl: icon
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
: '',
renderUrl: render
? managedImageUrl(String(render.id), render.updated_at)
: '',
effects: effects.filter((effect) => effect.brand_set_id === brand.id),
};
}),
],
stats: [...pendingStats, ...stats],
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingBrands.length,
pending: pendingBrands.length,
effects: Number(effectSummary?.total ?? 0),
},
pagination,
filter,
requestedItemId,
search,
sort,
};
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { brandError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
const effects = thresholds.map((pieces) => ({ pieces, statId: String(form.get(`stat${pieces}`) ?? ''), value: Number(form.get(`value${pieces}`)) }));
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) {
return fail(400, { brandError: 'Every threshold needs an attribute and a non-negative value.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { brandError: 'That brand set already exists.' });
}
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
const brandId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
for (const effect of effects) {
await (await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
}
await applyIconMutation(database, 'brand_set', brandId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
return { brandArchived: true, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
if (!before) return fail(404, { brandError: 'Brand set not found.' });
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.unarchived', entityId: id, before, after: await auditRecord('brand_sets', id) });
return { brandUnarchived: true, resultToken: randomUUID() };
}
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { brandError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { brandError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
if (name.length < 2 || name.length > 80)
return fail(400, {
brandError: 'Brand set names must be 2–80 characters.',
});
const effects = thresholds.map((pieces) => ({
pieces,
statId: String(form.get(`stat${pieces}`) ?? ''),
value: Number(form.get(`value${pieces}`)),
}));
if (
effects.some(
(effect) =>
!effect.statId || !Number.isFinite(effect.value) || effect.value < 0,
)
) {
return fail(400, {
brandError:
'Every threshold needs an attribute and a non-negative value.',
});
}
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, { brandError: 'That brand set already exists.' });
}
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`,
)
).get(...uniqueStatIds);
if (Number(validStats?.count) !== uniqueStatIds.length)
return fail(400, {
brandError: 'One or more selected attributes are unavailable.',
});
const brandId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
now = new Date().toISOString();
const before = id
? {
record: await auditRecord('brand_sets', id),
icon: await gameDataIconMetadata(database, 'brand_set', id),
effects: await (
await database.prepare(
'SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required',
)
).all(id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='brand_set_render' AND source_entity_id=?",
)
).get(id),
),
}
: null;
if (id)
await (
await database.prepare(
'UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?',
)
).run(name, now, id);
else
await (
await database.prepare(
'INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)',
)
).run(brandId, name, now, now);
for (const effect of effects) {
await (
await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`)
).run(
randomUUID(),
brandId,
effect.pieces,
effect.statId,
effect.value,
now,
now,
);
}
await applyIconMutation(
database,
'brand_set',
brandId,
iconMutation,
operator.id,
);
const render = renderAuditMetadata(
await applyGameDataRender(
database,
'brand_set',
brandId,
name,
renderMutation,
operator.id,
),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'brand_set.updated' : 'brand_set.created',
entityId: brandId,
before,
after: {
record: await auditRecord('brand_sets', brandId),
icon: await gameDataIconMetadata(database, 'brand_set', brandId),
effects,
render,
},
});
return {
brandSaved: true,
brandMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
await (
await (
await getDatabase()
).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'brand_set.archived',
entityId: id,
before,
after: await auditRecord('brand_sets', id),
});
return { brandArchived: true, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
if (!before) return fail(404, { brandError: 'Brand set not found.' });
await (
await (
await getDatabase()
).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'brand_set.unarchived',
entityId: id,
before,
after: await auditRecord('brand_sets', id),
});
return { brandUnarchived: true, resultToken: randomUUID() };
},
};
+16 -4
View File
@@ -5,20 +5,28 @@
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] };
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
type Editor = { id: string; name: string; iconUrl: string; renderUrl: string; active: number; effects: Effect[] };
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', renderUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
let editing = $state<Editor | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, renderUrl: brand.renderUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.brands.find((brand) => brand.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = null;
@@ -26,7 +34,10 @@
}
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.brandMode === 'add') iconResetToken += 1;
if (form.brandMode === 'add') {
iconResetToken += 1;
renderResetToken += 1;
}
editing = form.brandMode === 'add' ? blank() : null;
}
});
@@ -40,6 +51,7 @@
<input type="hidden" name="id" value={editing.id} />
<div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="BRAND SET RENDER" resetToken={renderResetToken}/>
<div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { createNotification } from '$lib/server/notifications';
import type { Actions, PageServerLoad } from './$types';
const allowed = (permissions: string[], permission: string) =>
permissions.includes('*') || permissions.includes(permission);
const reason = (value: unknown) =>
String(value ?? '')
.normalize('NFKC')
.trim()
.slice(0, 500);
export const load: PageServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
const canModerate = allowed(permissions, 'community.guides.moderate');
const canCurate = allowed(permissions, 'guides.curate');
if (!canModerate && !canCurate)
error(403, 'Guide Oversight permission required');
const database = await getDatabase();
const [requests, reports, guides] = await Promise.all([
canModerate
? (
await database.prepare(`SELECT request.*,revision.title,revision.deck,revision.plain_text,
scan.status AS scan_status,scan.risk_level,scan.reason AS scan_reason,scan.provider,scan.model,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,articles.slug
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
JOIN article_revisions revision ON revision.id=request.revision_id
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
WHERE request.status='pending_review' ORDER BY request.requested_at`)
).all()
: [],
canModerate
? (
await database.prepare(`SELECT report.*,revision.title,revision.deck,articles.slug,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author
FROM user_content_reports report JOIN articles ON articles.id=report.content_id AND articles.kind='guide'
JOIN article_revisions revision ON revision.id=report.article_revision_id
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
WHERE report.content_type='guide' AND report.status='pending' ORDER BY report.created_at`)
).all()
: [],
canCurate
? (
await database.prepare(`SELECT articles.id,articles.slug,revision.id AS revision_id,revision.title,
curation.pinned_order,COALESCE(curation.featured,0) AS featured,COALESCE(curation.recommended,0) AS recommended
FROM articles JOIN article_revisions revision ON revision.id=articles.published_revision_id
LEFT JOIN guide_curations curation ON curation.article_id=articles.id AND curation.curated_revision_id=revision.id
WHERE articles.kind='guide' AND articles.visibility='public' AND articles.deleted_at IS NULL
ORDER BY articles.published_at DESC`)
).all()
: [],
]);
return { canModerate, canCurate, requests, reports, guides };
};
export const actions: Actions = {
decide: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { guideError: 'Sign in required.' });
const permissions = await getPermissions(user);
if (!allowed(permissions, 'community.guides.moderate'))
return fail(403, { guideError: 'Guide moderation permission required.' });
const form = await request.formData();
const id = String(form.get('id') ?? '');
const decision = String(form.get('decision') ?? '');
const reviewReason = reason(form.get('reason'));
if (
!id ||
!['approve', 'reject'].includes(decision) ||
reviewReason.length < 3
)
return fail(400, {
guideError: 'Choose a decision and provide a reason.',
});
const database = await getDatabase();
const row = await (
await database.prepare(`SELECT request.*,articles.owner_id,articles.slug,articles.published_revision_id,
revision.title,revision.deck,revision.plain_text,scan.id AS scan_id,scan.status AS scan_status
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
JOIN article_revisions revision ON revision.id=request.revision_id JOIN content_moderation_scans scan ON scan.id=request.scan_id
WHERE request.id=? AND request.status='pending_review' AND scan.article_revision_id=request.revision_id`)
).get(id);
if (!row)
return fail(404, {
guideError: 'That Guide review is no longer pending.',
});
const now = new Date().toISOString();
const reviewId = randomUUID();
await database.exec('BEGIN IMMEDIATE');
try {
await (
await database.prepare(
'INSERT INTO content_moderation_reviews(id,scan_id,reviewer_id,decision,reason,created_at) VALUES(?,?,?,?,?,?)',
)
).run(
reviewId,
row.scan_id,
user.id,
decision === 'approve' ? 'approved' : 'rejected',
reviewReason,
now,
);
if (decision === 'approve') {
const changed = await (
await database.prepare(`UPDATE articles SET published_revision_id=?,pending_revision_id=NULL,visibility=?,moderation_hold=0,
published_at=COALESCE(published_at,?),updated_at=? WHERE id=? AND pending_revision_id=?
AND COALESCE(published_revision_id,'')=COALESCE(?,'')`)
).run(
row.revision_id,
row.requested_visibility,
now,
now,
row.article_id,
row.revision_id,
row.base_published_revision_id ?? null,
);
if (!changed.changes)
throw new Error('The Guide changed before approval.');
await (
await database.prepare(
'DELETE FROM article_search WHERE article_id=?',
)
).run(row.article_id);
if (row.requested_visibility === 'public') {
const searchTags = await (
await database.prepare(
`SELECT GROUP_CONCAT(tags.name,' ') AS names FROM article_revision_tags links JOIN article_tags tags ON tags.id=links.tag_id WHERE links.revision_id=?`,
)
).get(row.revision_id);
await (
await database.prepare(
'INSERT INTO article_search(article_id,title,deck,body,tags) VALUES(?,?,?,?,?)',
)
).run(
row.article_id,
row.title,
row.deck,
row.plain_text,
String(searchTags?.names ?? ''),
);
}
await (
await database.prepare(
'DELETE FROM guide_curations WHERE article_id=?',
)
).run(row.article_id);
} else
await (
await database.prepare(
'UPDATE articles SET pending_revision_id=NULL,moderation_hold=0 WHERE id=? AND pending_revision_id=?',
)
).run(row.article_id, row.revision_id);
await (
await database.prepare(
`UPDATE article_publication_requests SET status=?,decided_by=?,decided_at=?,decision_reason=? WHERE id=? AND status='pending_review'`,
)
).run(
decision === 'approve' ? 'approved' : 'rejected',
user.id,
now,
reviewReason,
id,
);
await recordIsacAudit({
actorId: user.id,
category: 'community_moderation',
action: `guide.review_${decision === 'approve' ? 'approved' : 'rejected'}`,
entityId: String(row.article_id),
before: {
requestId: id,
revisionId: row.revision_id,
scanId: row.scan_id,
},
after: {
reviewId,
reason: reviewReason,
visibility:
decision === 'approve' ? row.requested_visibility : 'unchanged',
},
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
return fail(409, {
guideError:
cause instanceof Error
? cause.message
: 'The review could not be saved.',
});
}
await createNotification({
userId: String(row.owner_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: `Guide ${decision === 'approve' ? 'approved' : 'rejected'}`,
body: `${row.title}: ${reviewReason}`,
link: `/guides/${encodeURIComponent(String(row.slug))}`,
actions: [
{
label: 'VIEW GUIDE',
href: `/guides/${encodeURIComponent(String(row.slug))}`,
},
],
mandatory: true,
});
return { guideSaved: true };
},
report: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { guideError: 'Sign in required.' });
const permissions = await getPermissions(user);
if (!allowed(permissions, 'community.guides.moderate'))
return fail(403, { guideError: 'Guide moderation permission required.' });
const form = await request.formData();
const id = String(form.get('id') ?? '');
const decision = String(form.get('decision') ?? '');
const reviewReason = reason(form.get('reason'));
if (
!id ||
!['dismiss', 'hide'].includes(decision) ||
reviewReason.length < 3
)
return fail(400, {
guideError: 'Choose a decision and provide a reason.',
});
const database = await getDatabase();
const now = new Date().toISOString();
let report: Record<string, unknown> | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
report =
(await (
await database.prepare(`SELECT report.*,articles.owner_id,articles.slug,articles.visibility,articles.published_revision_id,revision.title
FROM user_content_reports report JOIN articles ON articles.id=report.content_id JOIN article_revisions revision ON revision.id=report.article_revision_id
WHERE report.id=? AND report.status='pending' AND report.content_type='guide'`)
).get(id)) ?? null;
if (!report) throw new Error('That report is no longer pending.');
if (decision === 'hide') {
const hidden = await (
await database.prepare(
"UPDATE articles SET visibility='private',moderation_hold=1,updated_at=? WHERE id=? AND published_revision_id=?",
)
).run(now, report.content_id, report.article_revision_id);
if (!hidden.changes)
throw new Error(
'A newer Guide revision is now public; dismiss this stale report instead.',
);
await (
await database.prepare(
'DELETE FROM article_search WHERE article_id=?',
)
).run(report.content_id);
await (
await database.prepare(
'DELETE FROM guide_curations WHERE article_id=?',
)
).run(report.content_id);
}
const reviewed = await (
await database.prepare(
"UPDATE user_content_reports SET status=?,reviewed_at=?,reviewed_by=?,resolution_reason=? WHERE id=? AND status='pending'",
)
).run(
decision === 'hide' ? 'resolved' : 'dismissed',
now,
user.id,
reviewReason,
id,
);
if (!reviewed.changes)
throw new Error('Another reviewer already handled this report.');
await recordIsacAudit({
actorId: user.id,
category: 'community_moderation',
action:
decision === 'hide'
? 'guide.report_hidden'
: 'guide.report_dismissed',
entityId: String(report.content_id),
before: {
reportId: id,
revisionId: report.article_revision_id,
category: report.category,
},
after: { reason: reviewReason },
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
return fail(409, {
guideError:
cause instanceof Error
? cause.message
: 'The report could not be handled.',
});
}
return { guideSaved: true };
},
curate: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { guideError: 'Sign in required.' });
const permissions = await getPermissions(user);
if (!allowed(permissions, 'guides.curate'))
return fail(403, { guideError: 'Guide curation permission required.' });
const form = await request.formData();
const articleId = String(form.get('articleId') ?? '');
const placement = String(form.get('placement') ?? '');
const enabled = form.get('enabled') === '1';
if (!['pinned', 'featured', 'recommended'].includes(placement))
return fail(400, { guideError: 'Choose a valid placement.' });
const database = await getDatabase();
const guide = await (
await database.prepare(
"SELECT id,published_revision_id FROM articles WHERE id=? AND kind='guide' AND visibility='public' AND deleted_at IS NULL",
)
).get(articleId);
if (!guide) return fail(404, { guideError: 'Public Guide not found.' });
const current = await (
await database.prepare('SELECT * FROM guide_curations WHERE article_id=?')
).get(articleId);
const values = {
pinned: current?.pinned_order ?? null,
featured: Number(current?.featured ?? 0),
recommended: Number(current?.recommended ?? 0),
};
if (placement === 'pinned') {
const next = enabled
? await (
await database.prepare(
'SELECT COALESCE(MAX(pinned_order),-1)+1 AS position FROM guide_curations',
)
).get()
: null;
values.pinned = enabled ? Number(next?.position ?? 0) : null;
}
if (placement === 'featured') values.featured = enabled ? 1 : 0;
if (placement === 'recommended') values.recommended = enabled ? 1 : 0;
const now = new Date().toISOString();
await (
await database.prepare(
`INSERT INTO guide_curations(article_id,pinned_order,featured,recommended,curated_revision_id,curated_by,curated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id) DO UPDATE SET pinned_order=excluded.pinned_order,featured=excluded.featured,recommended=excluded.recommended,curated_revision_id=excluded.curated_revision_id,curated_by=excluded.curated_by,curated_at=excluded.curated_at`,
)
).run(
articleId,
values.pinned,
values.featured,
values.recommended,
guide.published_revision_id,
user.id,
now,
);
await recordIsacAudit({
actorId: user.id,
category: 'community_moderation',
action: `guide.${placement}_${enabled ? 'enabled' : 'disabled'}`,
entityId: articleId,
before: current ?? null,
after: values,
});
return { guideSaved: true };
},
};
@@ -0,0 +1,7 @@
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let{data,form}=$props();</script>
<svelte:head><title>Guide Oversight — ISAC</title></svelte:head>
<section class="dashboard-section guide-oversight"><div class="section-title"><div><p class="eyebrow">COMMUNITY OVERSIGHT</p><h2>GUIDE REVIEW & CURATION</h2></div><span>{data.requests.length+data.reports.length} PENDING</span></div>{#if form?.guideError}<p class="error">{form.guideError}</p>{/if}
{#if data.canModerate}<h3>PUBLICATION REVIEWS</h3><div class="guide-review-list">{#each data.requests as item}<article><header><div><span>{item.scan_status?.toUpperCase()} · {item.risk_level?.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author} · {item.requested_visibility.toUpperCase()}</small></div><a href={`/isac/community/guides/${item.id}`}>REVIEW IMMUTABLE REVISION →</a></header><p>{item.deck}</p><details><summary>REVIEW TEXT</summary><p>{item.plain_text}</p></details><div class="review-actions">{#each [['approve','APPROVE'],['reject','REJECT']] as action}<PendingForm action="?/decide"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='reject'?'danger':'primary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide revisions need review.</p>{/each}</div>
<h3>REPORTED GUIDES</h3><div class="guide-review-list">{#each data.reports as item}<article><header><div><span>REPORTED · {item.category.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author}</small></div></header><p>{item.details||'No additional details supplied.'}</p><div class="review-actions">{#each [['dismiss','DISMISS'],['hide','HIDE GUIDE']] as action}<PendingForm action="?/report"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='hide'?'danger':'secondary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide reports are pending.</p>{/each}</div>{/if}
{#if data.canCurate}<h3>PUBLIC GUIDE CURATION</h3><div class="guide-curation-list">{#each data.guides as guide}<article><div><strong>{guide.title}</strong><a href={`/guides/${guide.slug}`}>VIEW →</a></div>{#each [['pinned',guide.pinned_order!==null],['featured',Boolean(guide.featured)],['recommended',Boolean(guide.recommended)]] as placement}<PendingForm action="?/curate"><input type="hidden" name="articleId" value={guide.id}/><input type="hidden" name="placement" value={placement[0]}/><input type="hidden" name="enabled" value={placement[1]?'0':'1'}/><button class:active={placement[1]}>{placement[1]?'REMOVE ':''}{String(placement[0]).toUpperCase()}</button></PendingForm>{/each}</article>{:else}<p class="empty-state">No public Guides are available to curate.</p>{/each}</div>{/if}</section>
<style>.guide-oversight{display:grid;gap:12px}.guide-oversight>h3{margin:18px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.guide-review-list{display:grid;gap:8px}.guide-review-list>article{display:grid;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.guide-review-list article>header{display:flex;justify-content:space-between;gap:12px}.guide-review-list h4,.guide-review-list p{margin:0}.guide-review-list h4{font-size:1.3rem}.guide-review-list header span,.guide-review-list header small,.guide-review-list header a{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-review-list header span,.guide-review-list header a{color:var(--shd-orange)}.guide-review-list details>p{max-height:260px;overflow:auto;padding:10px;border:1px solid var(--shd-border);white-space:pre-wrap}.review-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.review-actions :global(form){display:grid;gap:6px}.review-actions label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list{display:grid}.guide-curation-list>article{display:grid;grid-template-columns:minmax(0,1fr) repeat(3,auto);align-items:center;gap:5px;padding:7px;border:1px solid var(--shd-border);border-top:0}.guide-curation-list>article:first-child{border-top:1px solid var(--shd-border)}.guide-curation-list>article>div{display:grid}.guide-curation-list a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list button.active{color:var(--shd-orange);border-color:var(--shd-orange)}@media(max-width:800px){.review-actions{grid-template-columns:1fr}.guide-curation-list>article{grid-template-columns:1fr}.guide-curation-list :global(button){width:100%}}</style>
@@ -0,0 +1,67 @@
import { error } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { parseArticleDocument } from '$lib/articles/document';
import { managedImageUrl } from '$lib/server/managed-images';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
if (
!permissions.includes('*') &&
!permissions.includes('community.guides.moderate')
)
error(403, 'Guide moderation permission required');
const database = await getDatabase();
const row = await (
await database.prepare(`SELECT request.id AS request_id,request.status,revision.*,COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,
scan.reason,scan.risk_level,cover.updated_at AS cover_updated_at FROM article_publication_requests request JOIN article_revisions revision ON revision.id=request.revision_id
JOIN articles ON articles.id=request.article_id JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id LEFT JOIN managed_images cover ON cover.id=revision.cover_image_id WHERE request.id=? AND articles.kind='guide'`)
).get(params.requestId);
if (!row) error(404, 'Guide review not found');
const parsed = parseArticleDocument(String(row.document_json));
if (!parsed) error(500, 'Guide revision is invalid');
const [buildRows, imageRows] = await Promise.all([
(
await database.prepare(
'SELECT embed_key,build_snapshot_json FROM article_revision_build_embeds WHERE revision_id=?',
)
).all(row.id),
parsed.imageIds.length
? (
await database.prepare(
`SELECT id,updated_at FROM managed_images WHERE id IN (${parsed.imageIds.map(() => '?').join(',')})`,
)
).all(...parsed.imageIds)
: [],
]);
return {
revision: {
...row,
document: parsed.document,
coverUrl: row.cover_image_id
? managedImageUrl(String(row.cover_image_id), row.cover_updated_at)
: '',
},
builds: Object.fromEntries(
buildRows.map((item) => [
String(item.embed_key),
{ ...JSON.parse(String(item.build_snapshot_json)), available: true },
]),
),
images: Object.fromEntries(
imageRows.map((item) => [
String(item.id),
{
url: managedImageUrl(String(item.id), item.updated_at),
alt: '',
caption: '',
},
]),
),
};
};
@@ -0,0 +1,4 @@
<script lang="ts">import ArticleContent from '$lib/components/ArticleContent.svelte';let { data }=$props();</script>
<svelte:head><title>Guide Revision Review // ISAC</title></svelte:head>
<main class="review-preview"><a href="/isac/community/guides">← GUIDE OVERSIGHT</a><header><span>IMMUTABLE REVISION // {data.revision.status}</span><h1>{data.revision.title}</h1><p>{data.revision.deck}</p><small>BY {data.revision.author} · RISK {String(data.revision.risk_level??'UNKNOWN').toUpperCase()}</small></header>{#if data.revision.coverUrl}<figure><img src={data.revision.coverUrl} alt={data.revision.cover_alt}/><figcaption>{data.revision.cover_caption}</figcaption></figure>{/if}<section class="evidence"><strong>AUTOMATED REVIEW</strong><p>{data.revision.reason??'No automated evidence recorded.'}</p></section><ArticleContent document={data.revision.document.document} builds={data.builds} images={data.images}/></main>
<style>.review-preview{max-width:980px;margin:0 auto;padding:40px 24px 90px}.review-preview>a,.review-preview header>span,.review-preview small,.evidence strong{font:700 11px var(--shd-font-display);letter-spacing:.12em;color:var(--shd-orange)}header{padding:55px 0 35px;border-bottom:1px solid var(--shd-border)}h1{font:900 clamp(40px,8vw,78px)/.9 var(--shd-font-display);text-transform:uppercase;max-width:900px;margin:12px 0}header p{font:400 21px var(--shd-font-editorial);color:var(--shd-text-muted)}figure{margin:30px 0}figure img{width:100%;max-height:520px;object-fit:cover}.evidence{margin:30px 0;padding:18px;border:1px solid var(--shd-border);background:var(--shd-surface)}</style>
+64 -27
View File
@@ -1,39 +1,76 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import {
requireDataView,
requirePermission,
} from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
await requireDataView(await getSessionUser(cookies));
const ranges = await (await (await getDatabase()).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
return { ranges };
await requireDataView(await getSessionUser(cookies));
const ranges = await (
await (
await getDatabase()
).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')
).all();
return { ranges };
};
export const actions: Actions = {
saveCoreRanges: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const ranges = [0, 1, 2].map((tier) => ({
tier,
min: Number(form.get(`tier${tier}Min`)),
max: Number(form.get(`tier${tier}Max`))
}));
if (ranges.some((range) => !Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min < 0 || range.max < range.min)) {
return fail(400, { configurationError: 'Every tier requires a valid nominal minimum and maximum.' });
}
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
const now = new Date().toISOString();
for (const range of ranges) {
await (await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`))
.run(range.tier, range.min, range.max, now);
}
const after = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'configuration.core_ranges_updated', entityId: 'core-attribute-tier-ranges', before, after });
return { configurationSaved: true, resultToken: randomUUID() };
}
saveCoreRanges: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const ranges = [0, 1, 2].map((tier) => ({
tier,
min: Number(form.get(`tier${tier}Min`)),
max: Number(form.get(`tier${tier}Max`)),
}));
if (
ranges.some(
(range) =>
!Number.isFinite(range.min) ||
!Number.isFinite(range.max) ||
range.min < 0 ||
range.max < range.min,
)
) {
return fail(400, {
configurationError:
'Every tier requires a valid nominal minimum and maximum.',
});
}
const database = await getDatabase();
const before = await (
await database.prepare(
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
)
).all();
const now = new Date().toISOString();
for (const range of ranges) {
await (
await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`)
).run(range.tier, range.min, range.max, now);
}
const after = await (
await database.prepare(
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
)
).all();
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'configuration.core_ranges_updated',
entityId: 'core-attribute-tier-ranges',
before,
after,
});
return { configurationSaved: true, resultToken: randomUUID() };
},
};
@@ -3,70 +3,91 @@ import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { CONTRIBUTOR_GUIDELINES_VERSION, getContributorMode } from '$lib/server/contributor-mode';
import {
CONTRIBUTOR_GUIDELINES_VERSION,
getContributorMode,
} from '$lib/server/contributor-mode';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
const safeReturn = (value: FormDataEntryValue | null) => {
const path = String(value ?? '/isac/attributes');
return path.startsWith('/isac/') && !path.startsWith('//') ? path : '/isac/attributes';
const path = String(value ?? '/isac/attributes');
return path.startsWith('/isac/') && !path.startsWith('//')
? path
: '/isac/attributes';
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
const eligible = permissions.includes('*') || permissions.includes('data.propose');
if (!eligible) redirect(303, '/isac/attributes');
return {
contributorMode: await getContributorMode(user.id),
returnTo: safeReturn(url.searchParams.get('returnTo'))
};
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
const eligible =
permissions.includes('*') || permissions.includes('data.propose');
if (!eligible) redirect(303, '/isac/attributes');
return {
contributorMode: await getContributorMode(user.id),
returnTo: safeReturn(url.searchParams.get('returnTo')),
};
};
export const actions: Actions = {
enable: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !permissions.includes('data.propose')) return fail(403, { contributorError: 'This account is not eligible to contribute data.' });
const form = await request.formData();
if (form.get('acceptGuidelines') !== 'on') return fail(400, { contributorError: 'Accept the contributor guidelines to enable contributor mode.' });
const returnTo = safeReturn(form.get('returnTo'));
const database = await getDatabase();
const before = await getContributorMode(user.id);
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
enable: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !permissions.includes('data.propose'))
return fail(403, {
contributorError: 'This account is not eligible to contribute data.',
});
const form = await request.formData();
if (form.get('acceptGuidelines') !== 'on')
return fail(400, {
contributorError:
'Accept the contributor guidelines to enable contributor mode.',
});
const returnTo = safeReturn(form.get('returnTo'));
const database = await getDatabase();
const before = await getContributorMode(user.id);
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
VALUES(?,?,?,1,?,NULL)
ON CONFLICT(user_id) DO UPDATE SET guidelines_version=excluded.guidelines_version,accepted_at=excluded.accepted_at,
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: before.accepted ? 'contributor.enabled' : 'contributor.guidelines_accepted',
entityId: user.id,
before,
after: await getContributorMode(user.id)
});
redirect(303, returnTo);
},
disable: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const returnTo = safeReturn((await request.formData()).get('returnTo'));
const database = await getDatabase();
const before = await getContributorMode(user.id);
if (before.enabled) {
await (await database.prepare('UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?')).run(new Date().toISOString(), user.id);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'contributor.disabled',
entityId: user.id,
before,
after: await getContributorMode(user.id)
});
}
redirect(303, returnTo);
}
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)
).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: before.accepted
? 'contributor.enabled'
: 'contributor.guidelines_accepted',
entityId: user.id,
before,
after: await getContributorMode(user.id),
});
redirect(303, returnTo);
},
disable: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const returnTo = safeReturn((await request.formData()).get('returnTo'));
const database = await getDatabase();
const before = await getContributorMode(user.id);
if (before.enabled) {
await (
await database.prepare(
'UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?',
)
).run(new Date().toISOString(), user.id);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'contributor.disabled',
entityId: user.id,
before,
after: await getContributorMode(user.id),
});
}
redirect(303, returnTo);
},
};
+602 -232
View File
@@ -3,7 +3,10 @@ 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, refreshProposalDependencies } from '$lib/server/data-moderation';
import {
deserializeProposal,
refreshProposalDependencies,
} from '$lib/server/data-moderation';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
@@ -11,258 +14,625 @@ import { createNotification } from '$lib/server/notifications';
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
const modules = [
'configuration',
'calculations',
'specializations',
'attributes',
'talents',
'brands',
'gear',
'weapons',
'weaponmods',
'gearmods',
'protocols',
'skillchips',
];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
| { name: string; kind: 'text'; value: string }
| {
name: string;
kind: 'file';
value: string;
fileName: string;
type: string;
};
async function specializationProposalSnapshot(database: any, proposal: any) {
const id = String(proposal.entity_id ?? '');
if (proposal.action === 'saveProgression') {
return {
record: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
rankProfiles: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
};
}
if (!id) return null;
if (proposal.action === 'saveSpecialization') {
return { record: await (await database.prepare('SELECT * FROM specializations WHERE id=?')).get(id) ?? null };
}
if (proposal.action === 'saveFocus') {
return { record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null };
}
if (proposal.action === 'saveAbility') {
return {
record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null,
attack: await (await database.prepare(
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?'
)).get(id) ?? null,
effects: await (await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
)).all(id)
};
}
if (proposal.action === 'saveCombatEffect') {
return {
record: await (await database.prepare('SELECT * FROM specialization_combat_effects WHERE id=?')).get(id) ?? null,
attachments: await (await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
)).all(id)
};
}
if (proposal.action === 'saveFocusAbilities') {
return {
record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
abilities: await (await database.prepare(
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot'
)).all(id)
};
}
if (proposal.action === 'saveNode') {
return {
record: await (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE id=?')).get(id) ?? null,
ranks: await (await database.prepare(
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank'
)).all(id),
effects: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id),
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id),
combatEffects: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id)
};
}
return null;
const id = String(proposal.entity_id ?? '');
if (proposal.action === 'saveProgression') {
return {
record: await (
await database.prepare(
"SELECT * FROM specialization_progression_config WHERE id='default'",
)
).get(),
rankProfiles: await (
await database.prepare(
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
};
}
if (!id) return null;
if (proposal.action === 'saveSpecialization') {
return {
record:
(await (
await database.prepare('SELECT * FROM specializations WHERE id=?')
).get(id)) ?? null,
};
}
if (proposal.action === 'saveFocus') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_focuses WHERE id=?',
)
).get(id)) ?? null,
};
}
if (proposal.action === 'saveAbility') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_abilities WHERE id=?',
)
).get(id)) ?? null,
attack:
(await (
await database.prepare(
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?',
)
).get(id)) ?? null,
effects: await (
await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
)
).all(id),
};
}
if (proposal.action === 'saveCombatEffect') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_combat_effects WHERE id=?',
)
).get(id)) ?? null,
attachments: await (
await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order',
)
).all(id),
};
}
if (proposal.action === 'saveFocusAbilities') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_focuses WHERE id=?',
)
).get(id)) ?? null,
abilities: await (
await database.prepare(
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
)
).all(id),
};
}
if (proposal.action === 'saveNode') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_tree_nodes WHERE id=?',
)
).get(id)) ?? null,
ranks: await (
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank',
)
).all(id),
effects: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order',
)
).all(id),
costs: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id',
)
).all(id),
combatEffects: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order',
)
).all(id),
};
}
return null;
}
async function skillChipProposalSnapshot(database: any, id: string) {
const talent = await (await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?'
)).get(id);
return {
record: await (await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')).get(id) ?? null,
twoPiece: await (await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?'
)).get(id) ?? null,
primaryOptions: await (await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
)).all(id),
secondaryOptions: await (await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
)).all(id),
talent: talent ?? null,
talentEffects: talent ? await (await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order'
)).all(talent.id) : [],
conditional: talent
? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id))
: null
};
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record:
(await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get(id)) ?? null,
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
primaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'skill_chip_talent',
String(talent.id),
)
: null,
};
}
async function calculationProposalSnapshot(database: any, proposal: any) {
const id = String(proposal.entity_id ?? '');
if (!id) return null;
if (proposal.action === 'saveRevision') return {
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id),
skillProfiles: await (await database.prepare(
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
if (proposal.action === 'saveCalibration') return {
record: await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null
};
return null;
const id = String(proposal.entity_id ?? '');
if (!id) return null;
if (proposal.action === 'saveRevision')
return {
record:
(await (
await database.prepare(
'SELECT * FROM calculation_revisions WHERE id=?',
)
).get(id)) ?? null,
stages: await (
await database.prepare(
'SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order',
)
).all(id),
profiles: await (
await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id',
)
).all(id),
skillProfiles: await (
await database.prepare(
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id',
)
).all(id),
};
if (proposal.action === 'saveCalibration')
return {
record:
(await (
await database.prepare(
'SELECT * FROM calculation_calibration_cases WHERE id=?',
)
).get(id)) ?? null,
};
return null;
}
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 sort = getRegistrySort(url, {
change: {
asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC',
desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC'
},
contributor: {
asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC',
desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC'
},
submitted: { asc: 'p.created_at ASC', desc: 'p.created_at DESC', defaultDirection: 'desc' },
status: { asc: 'p.status COLLATE NOCASE ASC', desc: 'p.status COLLATE NOCASE DESC' }
}, 'submitted', 'desc');
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,
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 sort = getRegistrySort(
url,
{
change: {
asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC',
desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC',
},
contributor: {
asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC',
desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC',
},
submitted: {
asc: 'p.created_at ASC',
desc: 'p.created_at DESC',
defaultDirection: 'desc',
},
status: {
asc: 'p.status COLLATE NOCASE ASC',
desc: 'p.status COLLATE NOCASE DESC',
},
},
'submitted',
'desc',
);
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 ${sort.orderBy},p.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const proposalsWithSnapshots = await Promise.all(proposals.map(async (proposal) => ({
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(database, String(proposal.entity_id))
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null
})));
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals: proposalsWithSnapshots, pagination, status, module, search, sort, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
${where} ORDER BY ${sort.orderBy},p.id LIMIT ? OFFSET ?`)
).all(...args, pagination.perPage, pagination.offset);
const proposalsWithSnapshots = await Promise.all(
proposals.map(async (proposal) => ({
...proposal,
current_snapshot:
proposal.module === 'attributes' && proposal.entity_id
? ((await (
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
).get(String(proposal.entity_id))) ?? null)
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null,
})),
);
const summaryRows = await (
await database.prepare(
'SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status',
)
).all();
return {
proposals: proposalsWithSnapshots,
pagination,
status,
module,
search,
sort,
summary: Object.fromEntries(
summaryRows.map((row) => [String(row.status), Number(row.count)]),
),
modules,
};
};
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();
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 unresolved = await (await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
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();
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 unresolved = await (
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)).all(id);
if (unresolved.length) {
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
}
const target = new URL(`/isac/${proposal.module}?/${proposal.action}`, url.origin);
const proposalForm = deserializeProposal(String(proposal.payload_json));
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
const response = await fetch(target, { method: 'POST', body: proposalForm, 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 } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/isac/${proposal.module}`, mandatory: true });
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',applied_entity_id=COALESCE(entity_id,draft_entity_id),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, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/isac/${proposal.module}`, mandatory: true });
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 } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/isac/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() };
}
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
).all(id);
if (unresolved.length) {
const names = unresolved
.map((item) => `${item.summary} (${item.status})`)
.join(', ');
return fail(409, {
moderationError: `Approve this proposal's dependencies first: ${names}.`,
});
}
const target = new URL(
`/isac/${proposal.module}?/${proposal.action}`,
url.origin,
);
const proposalForm = deserializeProposal(String(proposal.payload_json));
if (proposal.draft_entity_id && !proposal.entity_id)
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
const response = await fetch(target, {
method: 'POST',
body: proposalForm,
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 },
});
await createNotification({
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution could not be deployed',
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
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',applied_entity_id=COALESCE(entity_id,draft_entity_id),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,
appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id,
},
});
await createNotification({
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution approved',
body: `${proposal.summary} was approved and deployed to the live builder data set.`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
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,
},
});
await createNotification({
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution needs revision',
body: `${String(proposal.action)
.replace(/([A-Z])/g, ' $1')
.trim()} was not approved. Oversight reason: ${note}`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
return { moderationSaved: true, moderationToken: randomUUID() };
},
};
@@ -71,6 +71,7 @@
<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>{moduleLabel(reviewing.module)}</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>
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
<details>
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
+680 -255
View File
@@ -1,281 +1,706 @@
import { requireDataView, 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';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const slots = [
'Holster',
'Gloves',
'Backpack',
'Knee Pads',
'Body Armor',
'Mask',
];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
const augmentSlotCount = (slot: string) =>
slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
const augmentPercentage = (slot: string, augmentSlot: number) =>
slot === 'Backpack' || slot === 'Body Armor'
? [0, 0, 40, 70, 100][augmentSlot]
: [0, 0, 50, 100][augmentSlot];
slot === 'Backpack' || slot === 'Body Armor'
? [0, 0, 40, 70, 100][augmentSlot]
: [0, 0, 50, 100][augmentSlot];
async function augmentProfileSnapshot(slot: string) {
const database = await getDatabase();
const augments = await (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
const costs = await (await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)).all(slot);
return augments.map((augment) => ({ ...augment, costs: costs.filter((cost) => cost.augment_id === augment.id) }));
const database = await getDatabase();
const augments = await (
await database.prepare(
'SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot',
)
).all(slot);
const costs = await (
await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)
).all(slot);
return augments.map((augment) => ({
...augment,
costs: costs.filter((cost) => cost.augment_id === augment.id),
}));
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
const user = 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,
SUM(CASE WHEN slot IN ('Gloves','Knee Pads','Body Armor') THEN 1 ELSE 0 END) AS armored,
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)).get();
const filter = ['active', 'armored', 'talented'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
slot: { asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
tier: { asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC', desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC' },
ranges: { asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC', desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' },
brands: {
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC"
},
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
}, 'name');
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'armored' ? ["slot IN ('Gloves','Knee Pads','Body Armor')"] : filter === 'talented' ? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)'] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(...args);
const pendingDefinitions = await getPendingEntities(user.id, [
{ module: 'brands', action: 'save', kind: 'brand' },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] }
]);
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
(filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true)
&& (!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
).sort((left, right) => {
const valueFor = (item: any) => sort.key === 'ranges' ? Number(item.healthMin ?? 0)
: sort.key === 'brands' ? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds]).filter(Boolean).sort().join(':')
: sort.key === 'state' ? Number(item.active ?? 1)
: item[sort.key] ?? item.name;
const leftValue = valueFor(left), rightValue = valueFor(right);
const result = typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons, renders] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
(await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'")).all()
]);
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
...talent,
id: talentId,
name: String(talent.name),
category: String(talent.category),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
};
return {
gear: [...pendingGearOnPage.map((item) => ({
...item,
tier: Number(item.tier),
base_health_min: item.healthMin,
base_health_max: item.healthMax,
base_armor_min: item.armorMin || null,
base_armor_max: item.armorMax || null,
brandIds: Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds].filter(Boolean),
talentIds: Array.isArray(item.talentIds) ? item.talentIds : [item.talentIds].filter(Boolean)
})), ...gear.map((item) => ({
...item,
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)),
renderUrl: (() => {
const render = renders.find((image) => image.source_entity_id === item.id);
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
})()
}))],
brands: [...pendingBrands, ...brands.map((brand) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
})],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
augments: augmentRows.filter((augment) => augment.gear_slot === slot).map((augment) => ({
...augment,
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
}))
})),
stats: [...pendingStats, ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
pagination: adjustedPagination,
filter,
search,
sort
};
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)
).get();
const filter = ['active', 'armored', 'talented'].includes(
url.searchParams.get('filter') ?? '',
)
? String(url.searchParams.get('filter'))
: '';
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC, id ASC',
desc: 'name COLLATE NOCASE DESC, id DESC',
},
slot: {
asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
},
tier: {
asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC',
},
ranges: {
asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC',
defaultDirection: 'desc',
},
brands: {
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC",
},
state: {
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
defaultDirection: 'desc',
},
},
'name',
);
const conditions = [
...(requestedItemId
? ['id=?']
: filter === 'active'
? ['active=1']
: filter === 'armored'
? ["slot IN ('Gloves','Knee Pads','Body Armor')"]
: filter === 'talented'
? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)']
: []),
...(!requestedItemId && search
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
: []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = [
...(requestedItemId ? [requestedItemId] : []),
...(!requestedItemId && search ? [pattern] : []),
];
const filtered = await (
await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)
).get(...args);
const pendingDefinitions = await getPendingEntities(user.id, [
{ module: 'brands', action: 'save', kind: 'brand' },
{
module: 'talents',
action: 'saveTalent',
kind: 'talent',
fields: ['category'],
},
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['unit'],
},
{
module: 'gear',
action: 'save',
kind: 'gear',
fields: [
'slot',
'tier',
'healthMin',
'healthMax',
'armorMin',
'armorMax',
'brandIds',
'talentIds',
],
},
]);
const pendingGear = pendingDefinitions
.filter((item) => item.kind === 'gear')
.filter(
(item) =>
(requestedItemId
? item.id === requestedItemId
: filter === 'armored'
? armoredSlots.has(String(item.slot))
: filter === 'talented'
? ['Backpack', 'Body Armor'].includes(String(item.slot))
: true) &&
(requestedItemId ||
!search ||
String(item.name)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())),
)
.sort((left, right) => {
const valueFor = (item: any) =>
sort.key === 'ranges'
? Number(item.healthMin ?? 0)
: sort.key === 'brands'
? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds])
.filter(Boolean)
.sort()
.join(':')
: sort.key === 'state'
? Number(item.active ?? 1)
: (item[sort.key] ?? item.name);
const leftValue = valueFor(left),
rightValue = valueFor(right);
const result =
typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), undefined, {
sensitivity: 'base',
numeric: true,
});
return (
(sort.direction === 'asc' ? result : -result) ||
String(left.name).localeCompare(String(right.name), undefined, {
sensitivity: 'base',
})
);
});
const adjustedPagination = getPagination(
url,
Number(filtered?.total ?? 0) + pendingGear.length,
);
const pendingGearOnPage =
adjustedPagination.page === 1
? pendingGear.slice(0, adjustedPagination.perPage)
: [];
const databaseOffset = Math.max(
0,
adjustedPagination.offset - pendingGear.length,
);
const databaseLimit = Math.max(
0,
adjustedPagination.perPage - pendingGearOnPage.length,
);
const [
gear,
brandLinks,
talentLinks,
brands,
talents,
augmentRows,
augmentCosts,
stats,
brandIcons,
talentIcons,
renders,
] = await Promise.all([
databaseLimit
? (
await database.prepare(
`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
)
).all(...args, databaseLimit, databaseOffset)
: [],
(
await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')
).all(),
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`,
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
)
).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
)
).all(),
(
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'",
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'",
)
).all(),
]);
const pendingBrands = pendingDefinitions.filter(
(item) => item.kind === 'brand',
);
const pendingTalents = pendingDefinitions.filter(
(item) =>
item.kind === 'talent' &&
['Backpack', 'Body Armor'].includes(String(item.category)),
);
const pendingStats = pendingDefinitions.filter(
(item) => item.kind === 'attribute',
);
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find(
(item) => String(item.entity_id) === talentId,
);
return {
...talent,
id: talentId,
name: String(talent.name),
category: String(talent.category),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : '',
};
};
return {
gear: [
...pendingGearOnPage.map((item) => ({
...item,
tier: Number(item.tier),
base_health_min: item.healthMin,
base_health_max: item.healthMax,
base_armor_min: item.armorMin || null,
base_armor_max: item.armorMax || null,
brandIds: Array.isArray(item.brandIds)
? item.brandIds
: [item.brandIds].filter(Boolean),
talentIds: Array.isArray(item.talentIds)
? item.talentIds
: [item.talentIds].filter(Boolean),
})),
...gear.map((item) => ({
...item,
brandIds: brandLinks
.filter((link) => link.gear_id === item.id)
.map((link) => String(link.brand_set_id)),
talentIds: talentLinks
.filter((link) => link.gear_id === item.id)
.map((link) => String(link.talent_id)),
renderUrl: (() => {
const render = renders.find(
(image) => image.source_entity_id === item.id,
);
return render
? managedImageUrl(String(render.id), render.updated_at)
: '';
})(),
})),
],
brands: [
...pendingBrands,
...brands.map((brand) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return {
...brand,
iconUrl: icon
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
: '',
};
}),
],
talents: [
...pendingTalents,
...talents.map((talent) => ({
...talent,
category: talent.category === 'Vest' ? 'Body Armor' : talent.category,
})),
].map(talentOptionWithIcon),
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
augments: augmentRows
.filter((augment) => augment.gear_slot === slot)
.map((augment) => ({
...augment,
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id),
})),
})),
stats: [...pendingStats, ...stats],
summary: {
...summary,
total:
Number(summary?.total ?? 0) +
pendingDefinitions.filter((item) => item.kind === 'gear').length,
pending: pendingDefinitions.filter((item) => item.kind === 'gear').length,
},
pagination: adjustedPagination,
filter,
requestedItemId,
search,
sort,
};
};
const readRange = (form: FormData, prefix: string) => {
const min = Number(form.get(`${prefix}Min`));
const max = Number(form.get(`${prefix}Max`));
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error();
return [min, max] as const;
const min = Number(form.get(`${prefix}Min`));
const max = Number(form.get(`${prefix}Max`));
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min)
throw new Error();
return [min, max] as const;
};
export const actions: Actions = {
saveAugmentProfile: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const slot = String(form.get('slot') ?? '');
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
const requiredSlots = augmentSlotCount(slot);
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
slot: index + 1,
effectType: index === 0 ? 'Custom' : 'Base Percentage',
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1),
costs: [0, 1, 2].map((tier) => ({
tier,
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`))
}))
}));
if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) {
return fail(400, { augmentProfileError: 'Tactical Augment 01 requires one custom attribute and value.' });
}
if (augments.some((augment) => augment.costs.some((cost) => !cost.raw || !Number.isInteger(cost.value) || cost.value < 0))) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.' });
}
const database = await getDatabase();
const statIds = [augments[0].statId];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
const before = await augmentProfileSnapshot(slot);
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
for (const augment of augments) {
const augmentId = randomUUID();
await (await database.prepare(`INSERT INTO gear_tactical_augments
saveAugmentProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const slot = String(form.get('slot') ?? '');
if (!slots.includes(slot))
return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
const requiredSlots = augmentSlotCount(slot);
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
slot: index + 1,
effectType: index === 0 ? 'Custom' : 'Base Percentage',
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
value:
index === 0
? Number(form.get('value1'))
: augmentPercentage(slot, index + 1),
costs: [0, 1, 2].map((tier) => ({
tier,
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`)),
})),
}));
if (
!augments[0].statId ||
!augments[0].rawValue ||
!Number.isFinite(augments[0].value)
) {
return fail(400, {
augmentProfileError:
'Tactical Augment 01 requires one custom attribute and value.',
});
}
if (
augments.some((augment) =>
augment.costs.some(
(cost) =>
!cost.raw || !Number.isInteger(cost.value) || cost.value < 0,
),
)
) {
return fail(400, {
augmentProfileError:
'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.',
});
}
const database = await getDatabase();
const statIds = [augments[0].statId];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
augmentProfileError: 'One or more selected attributes are unavailable.',
});
const before = await augmentProfileSnapshot(slot);
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (
await database.prepare(
'DELETE FROM gear_tactical_augments WHERE gear_slot=?',
)
).run(slot);
for (const augment of augments) {
const augmentId = randomUUID();
await (
await database.prepare(`INSERT INTO gear_tactical_augments
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(augmentId, slot, augment.slot, augment.effectType, augment.statId || null, augment.value, now, now);
for (const cost of augment.costs) {
await (await database.prepare('INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)')).run(augmentId, cost.tier, cost.value);
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
entityId: slot,
before,
after: await augmentProfileSnapshot(slot)
});
return { augmentProfileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true);
} catch (error) {
if (error instanceof ManagedImageValidationError) return fail(400, { gearError: error.message });
throw error;
}
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const slot = String(form.get('slot') ?? '');
const tier = Number(form.get('tier'));
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) {
return fail(400, { gearError: 'Choose a valid slot and tier.' });
}
if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' });
const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
if (talentCategory && talentIds.length < 1) return fail(400, { gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.` });
if (!talentCategory && talentIds.length) return fail(400, { gearError: `${slot} gear cannot have talents.` });
let health: readonly [number, number], armor: readonly [number, number] | [null, null];
try {
health = readRange(form, 'health');
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
} catch {
return fail(400, { gearError: 'Base ranges require non-negative minimums and maximums greater than or equal to them.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { gearError: 'That gear name already exists.' });
}
const validBrands = await (await database.prepare(`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`)).get(...brandIds);
if (Number(validBrands?.count) !== brandIds.length) return fail(400, { gearError: 'One or more selected brand sets are unavailable.' });
if (talentCategory) {
const allowedCategories = talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
}
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id), render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?")).get(id) ?? null } : null;
if (id) {
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
.run(name, slot, tier, ...health, ...armor, now, id);
} else {
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(gearId, name, slot, tier, ...health, ...armor, now, now);
}
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId);
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
const render = await applyGameDataRender(database, 'gear', gearId, name, renderMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds, render } });
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('gear', id);
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear.archived', entityId: id, before, after: await auditRecord('gear', id) });
return { gearArchived: true };
}
VALUES(?,?,?,?,?,?,?,?)`)
).run(
augmentId,
slot,
augment.slot,
augment.effectType,
augment.statId || null,
augment.value,
now,
now,
);
for (const cost of augment.costs) {
await (
await database.prepare(
'INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)',
)
).run(augmentId, cost.tier, cost.value);
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: before.length
? 'gear_augment_profile.updated'
: 'gear_augment_profile.created',
entityId: slot,
before,
after: await augmentProfileSnapshot(slot),
});
return { augmentProfileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { gearError: error.message });
throw error;
}
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const slot = String(form.get('slot') ?? '');
const tier = Number(form.get('tier'));
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
if (name.length < 2 || name.length > 80)
return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
if (
!slots.includes(slot) ||
!Number.isInteger(tier) ||
tier < 0 ||
tier > 2
) {
return fail(400, { gearError: 'Choose a valid slot and tier.' });
}
if (brandIds.length < 2)
return fail(400, {
gearError: 'Gear must be available from at least two brand sets.',
});
const talentCategory =
slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
if (talentCategory && talentIds.length < 1)
return fail(400, {
gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.`,
});
if (!talentCategory && talentIds.length)
return fail(400, { gearError: `${slot} gear cannot have talents.` });
let health: readonly [number, number],
armor: readonly [number, number] | [null, null];
try {
health = readRange(form, 'health');
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
} catch {
return fail(400, {
gearError:
'Base ranges require non-negative minimums and maximums greater than or equal to them.',
});
}
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, { gearError: 'That gear name already exists.' });
}
const validBrands = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`,
)
).get(...brandIds);
if (Number(validBrands?.count) !== brandIds.length)
return fail(400, {
gearError: 'One or more selected brand sets are unavailable.',
});
if (talentCategory) {
const allowedCategories =
talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
const validTalents = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`,
)
).get(...allowedCategories, ...talentIds);
if (Number(validTalents?.count) !== talentIds.length)
return fail(400, {
gearError: `Every selected talent must be a ${slot} talent.`,
});
}
const gearId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
now = new Date().toISOString();
const before = id
? {
record: await auditRecord('gear', id),
brandIds: await (
await database.prepare(
'SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?',
)
).all(id),
talentIds: await (
await database.prepare(
'SELECT talent_id FROM gear_talents WHERE gear_id=?',
)
).all(id),
render:
(await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?",
)
).get(id)) ?? null,
}
: null;
if (id) {
await (
await database.prepare(
`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`,
)
).run(name, slot, tier, ...health, ...armor, now, id);
} else {
await (
await database.prepare(
`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
)
).run(gearId, name, slot, tier, ...health, ...armor, now, now);
}
await (
await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')
).run(gearId);
for (const brandId of brandIds)
await (
await database.prepare(
'INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)',
)
).run(gearId, brandId);
await (
await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')
).run(gearId);
for (const talentId of talentIds)
await (
await database.prepare(
'INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)',
)
).run(gearId, talentId);
const render = await applyGameDataRender(
database,
'gear',
gearId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'gear.updated' : 'gear.created',
entityId: gearId,
before,
after: {
record: await auditRecord('gear', gearId),
brandIds,
talentIds,
render,
},
});
return {
gearSaved: true,
gearMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('gear', id);
await (
await (
await getDatabase()
).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear.archived',
entityId: id,
before,
after: await auditRecord('gear', id),
});
return { gearArchived: true };
},
};
+7 -1
View File
@@ -22,6 +22,7 @@
let augmentEditing = $state<any | null>(null);
let handledResultToken = $state('');
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean);
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
@@ -58,6 +59,11 @@
editing = null;
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.gear.find((item) => item.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
augmentEditing = null;
@@ -95,7 +101,7 @@
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
</PendingForm>
</div>
{/if}
+470 -173
View File
@@ -1,198 +1,495 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { requireDataView, 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 { getPendingEntities } from '$lib/server/data-moderation';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
type QualityValue = {
quality: string;
primaryLevel1Min: number;
primaryLevel1Max: number;
primaryLevel20Min: number;
primaryLevel20Max: number;
quality: string;
primaryLevel1Min: number;
primaryLevel1Max: number;
primaryLevel20Min: number;
primaryLevel20Max: number;
};
type SecondaryRange = {
statId: string;
values: Record<string, { min: number; max: number }>;
};
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; }
try {
return JSON.parse(String(value ?? '')) as T;
} catch {
return fallback;
}
}
async function snapshot(id: string) {
const database = await getDatabase();
return {
record: await auditRecord('gear_mods', id),
qualityValues: await (await database.prepare('SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality')).all(id),
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id')).all(id),
secondaryQualityValues: await (await database.prepare('SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality')).all(id)
};
const database = await getDatabase();
return {
record: await auditRecord('gear_mods', id),
qualityValues: await (
await database.prepare(
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryQualityValues: await (
await database.prepare(
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
)
).all(id),
render:
(await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='gear_mod_render' AND source_entity_id=?",
)
).get(id)) ?? null,
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC' },
secondaries: { asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC' },
state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc' }
}, 'name');
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'pending', 'archived'].includes(requestedFilter)
? requestedFilter
: '';
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC,id ASC',
desc: 'name COLLATE NOCASE DESC,id DESC',
},
secondaries: {
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
},
state: {
asc: 'active ASC,name COLLATE NOCASE ASC',
desc: 'active DESC,name COLLATE NOCASE ASC',
defaultDirection: 'desc',
},
},
'name',
);
const conditions = [
...(requestedItemId
? ['id=?']
: filter === 'active'
? ['active=1']
: filter === 'archived'
? ['active=0']
: filter === 'pending'
? ['1=0']
: []),
...(!requestedItemId && search
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
: []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = [
...(requestedItemId ? [requestedItemId] : []),
...(!requestedItemId && search ? [pattern] : []),
];
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
(
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]),
getPendingEntities(user.id, [{ module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues', 'secondaryRanges'] }])
]);
const pendingRows = filter === 'archived' || filter === 'active' ? [] : pendingMods.filter((item) =>
!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
]);
const parsedPending = pendingOnPage.map((item) => ({
...item,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), [])
}));
return {
mods: [...parsedPending, ...mods.map((mod) => ({
...mod,
qualityValues: qualityRows.filter((row) => row.gear_mod_id === mod.id).map((row) => ({
quality: row.quality,
primaryLevel1Min: Number(row.primary_level_1_min),
primaryLevel1Max: Number(row.primary_level_1_max),
primaryLevel20Min: Number(row.primary_level_20_min),
primaryLevel20Max: Number(row.primary_level_20_max)
})),
secondaryRanges: secondaryOptions.filter((option) => option.gear_mod_id === mod.id).map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(secondaryValues
.filter((row) => row.gear_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id)
.map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }]))
}))
}))],
stats: [...pendingStats, ...stats],
qualities,
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingMods.length,
pending: pendingMods.length
},
filter,
pagination,
sort,
search
};
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)
).get(...args),
getPendingEntities(user.id, [
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['unit'],
},
]),
getPendingEntities(user.id, [
{
module: 'gearmods',
action: 'save',
kind: 'gearmod',
fields: ['qualityValues', 'secondaryRanges'],
},
]),
]);
const pendingRows = requestedItemId
? pendingMods.filter((item) => item.id === requestedItemId)
: filter === 'archived' || filter === 'active'
? []
: pendingMods.filter(
(item) =>
!search ||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
);
const pagination = getPagination(
url,
Number(filtered?.total ?? 0) + pendingRows.length,
);
const pendingOnPage =
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
await Promise.all([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
).all(...args, databaseLimit, databaseOffset)
: [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(
await database.prepare('SELECT * FROM gear_mod_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM gear_mod_secondary_quality_values',
)
).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
)
).all(),
]);
const parsedPending = pendingOnPage.map((item) => ({
...item,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []),
}));
return {
mods: [
...parsedPending,
...mods.map((mod) => ({
...mod,
qualityValues: qualityRows
.filter((row) => row.gear_mod_id === mod.id)
.map((row) => ({
quality: row.quality,
primaryLevel1Min: Number(row.primary_level_1_min),
primaryLevel1Max: Number(row.primary_level_1_max),
primaryLevel20Min: Number(row.primary_level_20_min),
primaryLevel20Max: Number(row.primary_level_20_max),
})),
secondaryRanges: secondaryOptions
.filter((option) => option.gear_mod_id === mod.id)
.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
secondaryValues
.filter(
(row) =>
row.gear_mod_id === mod.id &&
row.stat_attribute_id === option.stat_attribute_id,
)
.map((row) => [
String(row.quality),
{ min: Number(row.min_value), max: Number(row.max_value) },
]),
),
})),
renderUrl: (() => {
const render = renders.find(
(item) => item.source_entity_id === mod.id,
);
return render
? managedImageUrl(String(render.id), render.updated_at)
: '';
})(),
})),
],
stats: [...pendingStats, ...stats],
qualities,
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingMods.length,
pending: pendingMods.length,
},
filter,
requestedItemId,
pagination,
sort,
search,
};
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.' });
if (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length ||
qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) ||
[row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) ||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max))) {
return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.' });
}
if (secondaryRanges.length < 2 || secondaryRanges.length > 50 ||
new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length ||
secondaryRanges.some((range) => !range.statId || qualities.some((quality) => {
const values = range.values?.[quality];
return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) ||
Number(values.max) < Number(values.min);
}))) {
return fail(400, { gearModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { gearModError: 'That Gear Mod name is already registered.' });
}
const statIds = secondaryRanges.map((range) => range.statId);
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { gearModError: 'One or more selected attributes are unavailable.' });
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' });
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) await (await database.prepare('UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(modId, name, now, now);
await (await database.prepare('DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?')).run(modId);
for (const row of qualityValues) await (await database.prepare(`INSERT INTO gear_mod_quality_values
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { gearModError: error.message });
throw error;
}
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const qualityValues = parseJson<QualityValue[]>(
form.get('qualityValues'),
[],
);
const secondaryRanges = parseJson<SecondaryRange[]>(
form.get('secondaryRanges'),
[],
);
if (name.length < 2 || name.length > 100)
return fail(400, {
gearModError: 'Gear Mod names must be 2–100 characters.',
});
if (
qualityValues.length !== qualities.length ||
new Set(qualityValues.map((row) => row.quality)).size !==
qualities.length ||
qualityValues.some(
(row) =>
!qualities.includes(row.quality as (typeof qualities)[number]) ||
[
row.primaryLevel1Min,
row.primaryLevel1Max,
row.primaryLevel20Min,
row.primaryLevel20Max,
].some((value) => !Number.isFinite(Number(value))) ||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max),
)
) {
return fail(400, {
gearModError:
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
});
}
if (
secondaryRanges.length < 2 ||
secondaryRanges.length > 50 ||
new Set(secondaryRanges.map((range) => range.statId)).size !==
secondaryRanges.length ||
secondaryRanges.some(
(range) =>
!range.statId ||
qualities.some((quality) => {
const values = range.values?.[quality];
return (
!values ||
!Number.isFinite(Number(values.min)) ||
!Number.isFinite(Number(values.max)) ||
Number(values.max) < Number(values.min)
);
}),
)
) {
return fail(400, {
gearModError:
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
});
}
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, {
gearModError: 'That Gear Mod name is already registered.',
});
}
const statIds = secondaryRanges.map((range) => range.statId);
const validStats = await (
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
gearModError: 'One or more selected attributes are unavailable.',
});
const modId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id)
await (
await database.prepare(
'UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?',
)
).run(name, now, id);
else
await (
await database.prepare(
'INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)',
)
).run(modId, name, now, now);
await (
await database.prepare(
'DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?',
)
).run(modId);
for (const row of qualityValues)
await (
await database.prepare(`INSERT INTO gear_mod_quality_values
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max);
await (await database.prepare('DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?')).run(modId);
for (const range of secondaryRanges) {
await (await database.prepare('INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId, range.statId);
for (const quality of qualities) await (await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`))
.run(modId, range.statId, quality, range.values[quality].min, range.values[quality].max);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'gear_mod.updated' : 'gear_mod.created',
entityId: modId,
before,
after: await snapshot(modId)
});
return { gearModSaved: true, gearModMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id) });
return { gearModArchived: true };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id) });
return { gearModUnarchived: true };
}
VALUES(?,?,?,?,?,?)`)
).run(
modId,
row.quality,
row.primaryLevel1Min,
row.primaryLevel1Max,
row.primaryLevel20Min,
row.primaryLevel20Max,
);
await (
await database.prepare(
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
)
).run(modId);
for (const range of secondaryRanges) {
await (
await database.prepare(
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
)
).run(modId, range.statId);
for (const quality of qualities)
await (
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
).run(
modId,
range.statId,
quality,
range.values[quality].min,
range.values[quality].max,
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await applyGameDataRender(
database,
'gear_mod',
modId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'gear_mod.updated' : 'gear_mod.created',
entityId: modId,
before,
after: await snapshot(modId),
});
return {
gearModSaved: true,
gearModMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
await (
await (
await getDatabase()
).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod.archived',
entityId: id,
before,
after: await snapshot(id),
});
return { gearModArchived: true };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
await (
await (
await getDatabase()
).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod.unarchived',
entityId: id,
before,
after: await snapshot(id),
});
return { gearModUnarchived: true };
},
};
+13 -2
View File
@@ -7,6 +7,7 @@
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
let { data, form } = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
@@ -21,6 +22,7 @@
id: '',
name: '',
active: 1,
renderUrl: '',
qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(), blankSecondary()]
});
@@ -28,6 +30,8 @@
let levelPreview = $state(1);
let qualityPreview = $state('High-end');
let handledResultToken = $state('');
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
@@ -57,8 +61,14 @@
}
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if (form?.gearModSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.gearModMode === 'add') renderResetToken += 1;
editing = form.gearModMode === 'add' ? blank() : null;
levelPreview = 1;
qualityPreview = 'High-end';
@@ -90,7 +100,7 @@
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
<button aria-label="Close editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/save">
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
@@ -108,6 +118,7 @@
<small>One primary attribute is selected while configuring the mod in a build.</small>
</div>
</div>
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="GEAR MOD RENDER" resetToken={renderResetToken}/>
<section class="weapon-mod-quality-section">
<div class="selection-head">
@@ -178,7 +189,7 @@
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
<span>STANDARD → HIGH-END</span>
+226 -126
View File
@@ -5,146 +5,246 @@ import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import {
MANAGED_IMAGE_CATEGORIES,
ManagedImageDeletionError,
ManagedImageValidationError,
deleteManagedImage,
managedImageUrl,
processManagedImage,
saveManagedImageFile
MANAGED_IMAGE_CATEGORIES,
ManagedImageDeletionError,
ManagedImageValidationError,
deleteManagedImage,
managedImageUrl,
processManagedImage,
saveManagedImageFile,
} from '$lib/server/managed-images';
import type { Actions, PageServerLoad } from './$types';
const normalizeTags = (value: unknown) => [...new Set(String(value ?? '')
.split(',').map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase()).filter(Boolean))]
.slice(0, 20);
const normalizeTags = (value: unknown) =>
[
...new Set(
String(value ?? '')
.split(',')
.map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean),
),
].slice(0, 20);
const readMetadata = (form: FormData) => {
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '').trim();
const tags = normalizeTags(form.get('tags'));
if (name.length < 2 || name.length > 80) return { error: 'Name must be 2–80 characters.' } as const;
if (!MANAGED_IMAGE_CATEGORIES.includes(category as (typeof MANAGED_IMAGE_CATEGORIES)[number])) return { error: 'Choose a valid assignment category.' } as const;
if (tags.some((tag) => tag.length > 30)) return { error: 'Tags must be 30 characters or fewer.' } as const;
return { name, category, tags } as const;
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const category = String(form.get('category') ?? '').trim();
const tags = normalizeTags(form.get('tags'));
if (name.length < 2 || name.length > 80)
return { error: 'Name must be 2–80 characters.' } as const;
if (
!MANAGED_IMAGE_CATEGORIES.includes(
category as (typeof MANAGED_IMAGE_CATEGORIES)[number],
)
)
return { error: 'Choose a valid assignment category.' } as const;
if (tags.some((tag) => tag.length > 30))
return { error: 'Tags must be 30 characters or fewer.' } as const;
return { name, category, tags } as const;
};
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'images.manage');
const database = await getDatabase();
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
const perPage = [10, 25, 50, 100].includes(requestedPerPage) ? requestedPerPage : 25;
const requestedPage = Math.max(1, Number(url.searchParams.get('page') ?? 1) || 1);
const where = query ? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE` : '';
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const args = query ? [escaped, escaped, escaped] : [];
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM managed_images ${where}`)).get(...args))?.count ?? 0);
const pageCount = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(requestedPage, pageCount);
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
await requirePermission(await getSessionUser(cookies), 'images.manage');
const database = await getDatabase();
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
const perPage = [10, 25, 50, 100].includes(requestedPerPage)
? requestedPerPage
: 25;
const requestedPage = Math.max(
1,
Number(url.searchParams.get('page') ?? 1) || 1,
);
const where = query
? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE`
: '';
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const args = query ? [escaped, escaped, escaped] : [];
const total = Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM managed_images ${where}`,
)
).get(...args)
)?.count ?? 0,
);
const pageCount = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(requestedPage, pageCount);
const images = await (
await database.prepare(`SELECT managed_images.*, users.username AS creator_username
FROM managed_images LEFT JOIN users ON users.id=managed_images.created_by ${where}
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)).all(...args, perPage, (page - 1) * perPage);
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)
).all(...args, perPage, (page - 1) * perPage);
const stats = await (
await database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
COUNT(DISTINCT category) AS categories,
COALESCE(SUM(CASE WHEN source_entity_type IS NOT NULL THEN 1 ELSE 0 END),0) AS linked
FROM managed_images`)).get();
return {
categories: MANAGED_IMAGE_CATEGORIES,
images: images.map((image) => ({
...image,
tags: JSON.parse(String(image.tags_json)),
url: managedImageUrl(String(image.id), image.updated_at)
})),
stats,
search: query,
pagination: { page, perPage, total, pageCount }
};
FROM managed_images`)
).get();
return {
categories: MANAGED_IMAGE_CATEGORIES,
images: images.map((image) => ({
...image,
tags: JSON.parse(String(image.tags_json)),
url: managedImageUrl(String(image.id), image.updated_at),
})),
stats,
search: query,
pagination: { page, perPage, total, pageCount },
};
};
export const actions: Actions = {
upload: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const form = await request.formData();
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const file = form.get('image');
if (!(file instanceof File) || !file.size) return fail(400, { imageError: 'Choose an image to upload.' });
const isIcon = form.get('isIcon') === 'on';
const forceWhite = isIcon && form.get('forceWhite') === 'on';
try {
const processed = await processManagedImage(file, isIcon, forceWhite);
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const now = new Date().toISOString();
const after = {
id, ...metadata, is_icon: isIcon ? 1 : 0, force_white: forceWhite ? 1 : 0,
storage_key: storageKey, original_filename: file.name, byte_size: processed.output.length,
width: processed.width, height: processed.height, created_by: operator.id,
created_at: now, updated_at: now
};
const database = await getDatabase();
await (await database.prepare(`INSERT INTO managed_images
upload: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'images.manage',
);
const form = await request.formData();
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const file = form.get('image');
if (!(file instanceof File) || !file.size)
return fail(400, { imageError: 'Choose an image to upload.' });
const isIcon = form.get('isIcon') === 'on';
const forceWhite = isIcon && form.get('forceWhite') === 'on';
try {
const processed = await processManagedImage(file, isIcon, forceWhite);
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const now = new Date().toISOString();
const after = {
id,
...metadata,
is_icon: isIcon ? 1 : 0,
force_white: forceWhite ? 1 : 0,
storage_key: storageKey,
original_filename: file.name,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
created_by: operator.id,
created_at: now,
updated_at: now,
};
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, metadata.name, metadata.category, JSON.stringify(metadata.tags), isIcon ? 1 : 0,
forceWhite ? 1 : 0, storageKey, file.name, processed.output.length,
processed.width, processed.height, operator.id, now, now
);
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.created', entityId: id, before: null, after
});
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
} catch (error) {
if (error instanceof ManagedImageValidationError) return fail(400, { imageError: error.message });
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
update: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!before) return fail(404, { imageError: 'Image not found.' });
const updatedAt = new Date().toISOString();
try {
await (await database.prepare('UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?'))
.run(metadata.name, metadata.category, JSON.stringify(metadata.tags), updatedAt, id);
const after = { ...before, name: metadata.name, category: metadata.category, tags_json: JSON.stringify(metadata.tags), updated_at: updatedAt };
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.updated', entityId: id, before, after
});
return { imageSaved: true, editorMode: 'edit', resultToken: randomUUID() };
} catch (error) {
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
let deleted;
try {
deleted = await deleteManagedImage(database, id);
} catch (error) {
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
throw error;
}
if (!deleted) return fail(404, { imageError: 'Image not found.' });
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.deleted', entityId: id,
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
after: { libraryImage: null, sourceIcon: null }
});
return { imageDeleted: true, resultToken: randomUUID() };
}
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
metadata.name,
metadata.category,
JSON.stringify(metadata.tags),
isIcon ? 1 : 0,
forceWhite ? 1 : 0,
storageKey,
file.name,
processed.output.length,
processed.width,
processed.height,
operator.id,
now,
now,
);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'image.created',
entityId: id,
before: null,
after,
});
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { imageError: error.message });
if (String(error).includes('UNIQUE'))
return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
update: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'images.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const database = await getDatabase();
const before = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
if (!before) return fail(404, { imageError: 'Image not found.' });
const updatedAt = new Date().toISOString();
try {
await (
await database.prepare(
'UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?',
)
).run(
metadata.name,
metadata.category,
JSON.stringify(metadata.tags),
updatedAt,
id,
);
const after = {
...before,
name: metadata.name,
category: metadata.category,
tags_json: JSON.stringify(metadata.tags),
updated_at: updatedAt,
};
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'image.updated',
entityId: id,
before,
after,
});
return {
imageSaved: true,
editorMode: 'edit',
resultToken: randomUUID(),
};
} catch (error) {
if (String(error).includes('UNIQUE'))
return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'images.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
let deleted;
try {
deleted = await deleteManagedImage(database, id);
} catch (error) {
if (error instanceof ManagedImageDeletionError)
return fail(409, { imageError: error.message });
throw error;
}
if (!deleted) return fail(404, { imageError: 'Image not found.' });
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'image.deleted',
entityId: id,
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
after: { libraryImage: null, sourceIcon: null },
});
return { imageDeleted: true, resultToken: randomUUID() };
},
};
@@ -4,21 +4,25 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
export const load: PageServerLoad = async ({ cookies }) => {
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const attributes = await (await database.prepare(`
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const attributes = await (
await database.prepare(`
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
FROM stat_attributes
WHERE active=1
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
`)).all();
`)
).all();
return {
attributes,
attributeSummary: {
total: attributes.length,
documented: attributes.filter((attribute) => String(attribute.description ?? '').trim()).length
}
};
return {
attributes,
attributeSummary: {
total: attributes.length,
documented: attributes.filter((attribute) =>
String(attribute.description ?? '').trim(),
).length,
},
};
};
+111 -67
View File
@@ -4,10 +4,23 @@ import { getDatabase } from '$lib/server/database';
import { env } from '$env/dynamic/private';
import { stat } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os';
import {
arch,
availableParallelism,
cpus,
freemem,
loadavg,
platform,
release,
totalmem,
uptime,
} from 'node:os';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import type { PageServerLoad } from './$types';
import { getRemoveBgCreditStatus, refreshRemoveBgCredits } from '$lib/server/remove-bg-monitor';
import {
getRemoveBgCreditStatus,
refreshRemoveBgCredits,
} from '$lib/server/remove-bg-monitor';
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
@@ -16,79 +29,110 @@ let previousCpuAt = performance.now();
let previousElu = performance.eventLoopUtilization();
const numericPragma = (row: Record<string, unknown> | undefined) =>
Number(Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0);
Number(
Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0,
);
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'metrics.view');
const startedAt = performance.now();
const database = await getDatabase();
const databasePath = resolve(env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const now = performance.now();
const cpu = process.cpuUsage(previousCpu);
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
previousCpu = process.cpuUsage();
previousCpuAt = now;
const elu = performance.eventLoopUtilization(previousElu);
previousElu = performance.eventLoopUtilization();
const memory = process.memoryUsage();
await requirePermission(await getSessionUser(cookies), 'metrics.view');
const startedAt = performance.now();
const database = await getDatabase();
const databasePath = resolve(
env.TDR_DATABASE_PATH || './data/shd-build-development.db',
);
const now = performance.now();
const cpu = process.cpuUsage(previousCpu);
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
previousCpu = process.cpuUsage();
previousCpuAt = now;
const elu = performance.eventLoopUtilization(previousElu);
previousElu = performance.eventLoopUtilization();
const memory = process.memoryUsage();
const [pageCount, pageSize, freePages, tableCount, counts, databaseFile, removeBackground] = await Promise.all([
(await database.prepare('PRAGMA page_count')).get(),
(await database.prepare('PRAGMA page_size')).get(),
(await database.prepare('PRAGMA freelist_count')).get(),
(await database.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).get(),
(await database.prepare(`SELECT
const [
pageCount,
pageSize,
freePages,
tableCount,
counts,
databaseFile,
removeBackground,
] = await Promise.all([
(await database.prepare('PRAGMA page_count')).get(),
(await database.prepare('PRAGMA page_size')).get(),
(await database.prepare('PRAGMA freelist_count')).get(),
(
await database.prepare(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
)
).get(),
(
await database.prepare(`SELECT
(SELECT COUNT(*) FROM users) AS users,
(SELECT COUNT(*) FROM sessions WHERE expires_at>?) AS active_sessions,
(SELECT COUNT(*) FROM builds) AS builds,
(SELECT COUNT(*) FROM comments) AS comments,
(SELECT COUNT(*) FROM audit_log) AS audit_events,
(SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()),
stat(databasePath).catch(() => null),
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus())
]);
const pages = numericPragma(pageCount);
const bytesPerPage = numericPragma(pageSize);
const unusedPages = numericPragma(freePages);
const hostTotalMemory = totalmem();
const hostFreeMemory = freemem();
const coreCount = availableParallelism?.() ?? cpus().length;
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)
).get(new Date().toISOString()),
stat(databasePath).catch(() => null),
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus()),
]);
const pages = numericPragma(pageCount);
const bytesPerPage = numericPragma(pageSize);
const unusedPages = numericPragma(freePages);
const hostTotalMemory = totalmem();
const hostFreeMemory = freemem();
const coreCount = availableParallelism?.() ?? cpus().length;
return {
capturedAt: new Date().toISOString(),
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
process: {
uptimeSeconds: process.uptime(),
cpuPercent: Number((((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2)),
rss: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
external: memory.external,
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2))
},
host: {
platform: platform(), release: release(), architecture: arch(), cores: coreCount,
uptimeSeconds: uptime(), loadAverage: loadavg(), totalMemory: hostTotalMemory,
freeMemory: hostFreeMemory, usedMemoryPercent: Number((((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(2))
},
runtime: {
node: process.version,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
pid: process.pid
},
database: {
filename: basename(databasePath),
fileSize: databaseFile?.size ?? pages * bytesPerPage,
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
pageCount: pages,
pageSize: bytesPerPage,
freePages: unusedPages,
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
tableCount: Number(tableCount?.count ?? 0)
},
counts,
services: { removeBackground }
};
return {
capturedAt: new Date().toISOString(),
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
process: {
uptimeSeconds: process.uptime(),
cpuPercent: Number(
(((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2),
),
rss: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
external: memory.external,
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2)),
},
host: {
platform: platform(),
release: release(),
architecture: arch(),
cores: coreCount,
uptimeSeconds: uptime(),
loadAverage: loadavg(),
totalMemory: hostTotalMemory,
freeMemory: hostFreeMemory,
usedMemoryPercent: Number(
(((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(
2,
),
),
},
runtime: {
node: process.version,
environment:
process.env.NODE_ENV === 'production' ? 'production' : 'development',
pid: process.pid,
},
database: {
filename: basename(databasePath),
fileSize: databaseFile?.size ?? pages * bytesPerPage,
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
pageCount: pages,
pageSize: bytesPerPage,
freePages: unusedPages,
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
tableCount: Number(tableCount?.count ?? 0),
},
counts,
services: { removeBackground },
};
};
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -8,6 +8,7 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
@@ -31,13 +32,15 @@
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
let protocolLevel = $state(1);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
@@ -93,13 +96,21 @@
protocolLevel = 1;
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.protocols.find((item: any) => item.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
profileEditing = null;
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.protocolMode === 'add') iconResetToken += 1;
if (form.protocolMode === 'add') {
iconResetToken += 1;
renderResetToken += 1;
}
editing = form.protocolMode === 'add' ? blank() : null;
talentUpgrade = 0;
protocolLevel = 1;
@@ -153,7 +164,7 @@
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
+339 -181
View File
@@ -1,7 +1,14 @@
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import {
getPermissions,
requirePermission,
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -9,206 +16,357 @@ import { recordIsacAudit } from '$lib/server/audit';
const filters = ['isac', 'data', 'users', 'audit', 'system'];
const seededRoleIds = new Set([
'role-data-editor',
'role-user-operator',
'role-access-manager',
'role-admin',
'role-moderator',
'role-data-viewer',
'role-data-contributor',
'role-data-approver',
'role-auditor'
'role-data-editor',
'role-user-operator',
'role-access-manager',
'role-admin',
'role-moderator',
'role-data-viewer',
'role-data-contributor',
'role-data-approver',
'role-auditor',
]);
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'roles.name COLLATE NOCASE ASC', desc: 'roles.name COLLATE NOCASE DESC' },
users: { asc: 'user_count ASC', desc: 'user_count DESC', defaultDirection: 'desc' },
permissions: {
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
defaultDirection: 'desc'
},
state: {
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC"
}
}, 'name');
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const relatedPermissionFilter = filter === 'data'
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
: filter === 'system'
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
: '';
const conditions = [
...(permissionPrefix ? [`EXISTS (
const operator = await requirePermission(
await getSessionUser(cookies),
'users.roles',
);
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '')
? String(url.searchParams.get('filter'))
: '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'roles.name COLLATE NOCASE ASC',
desc: 'roles.name COLLATE NOCASE DESC',
},
users: {
asc: 'user_count ASC',
desc: 'user_count DESC',
defaultDirection: 'desc',
},
permissions: {
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
defaultDirection: 'desc',
},
state: {
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC",
},
},
'name',
);
const permissionPrefix =
filter === 'isac'
? 'isac.%'
: filter === 'data'
? 'data.%'
: filter === 'users'
? 'users.%'
: filter === 'audit'
? 'audit.%'
: filter === 'system'
? 'system.%'
: '';
const relatedPermissionFilter =
filter === 'data'
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
: filter === 'system'
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
: '';
const conditions = [
...(permissionPrefix
? [
`EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND (p.name LIKE ?${relatedPermissionFilter})
)`] : []),
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const filterArgs = [...(permissionPrefix ? [permissionPrefix] : []), ...(search ? [pattern] : [])];
const [summary, filtered, permissions] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
)`,
]
: []),
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const filterArgs = [
...(permissionPrefix ? [permissionPrefix] : []),
...(search ? [pattern] : []),
];
const [summary, filtered, permissions] = await Promise.all([
(
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)).all()
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const roles = await (await database.prepare(`SELECT roles.*,
FROM roles`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)
).get(...filterArgs),
(
await database.prepare(`SELECT id,name,description FROM permissions
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)
).all(),
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const roles = await (
await database.prepare(`SELECT roles.*,
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
FROM roles ${where} ORDER BY ${sort.orderBy},roles.name COLLATE NOCASE,roles.id
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
const roleIds = roles.map((role) => String(role.id));
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)).all(...roleIds) : [];
return {
roles: roles.map((role) => ({
...role,
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
systemRole: seededRoleIds.has(String(role.id)),
protected: role.id === 'role-admin',
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
canDelete: role.id !== 'role-admin'
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
})),
permissions,
summary,
filter,
search,
sort,
pagination,
canManageSystemAdmin
};
LIMIT ? OFFSET ?`)
).all(...filterArgs, pagination.perPage, pagination.offset);
const roleIds = roles.map((role) => String(role.id));
const links = roleIds.length
? await (
await database.prepare(`SELECT role_id,permission_id FROM role_permissions
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)
).all(...roleIds)
: [];
return {
roles: roles.map((role) => ({
...role,
permissionIds: links
.filter((link) => link.role_id === role.id)
.map((link) => String(link.permission_id)),
systemRole: seededRoleIds.has(String(role.id)),
protected: role.id === 'role-admin',
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
canDelete:
role.id !== 'role-admin' &&
(canManageSystemAdmin || !seededRoleIds.has(String(role.id))),
})),
permissions,
summary,
filter,
search,
sort,
pagination,
canManageSystemAdmin,
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
if (name.length < 2 || name.length > 60) return fail(400, { roleError: 'Role names must be 2–60 characters.' });
if (description.length > 240) return fail(400, { roleError: 'Descriptions must be 240 characters or fewer.' });
const database = await getDatabase();
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin' && !canManageSystemAdmin) {
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
}
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
const validPermissions = permissionIds.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).all(...permissionIds) : [];
if (validPermissions.length !== permissionIds.length) return fail(400, { roleError: 'One or more permissions are invalid.' });
if (!canManageSystemAdmin && validPermissions.some((permission) => permission.name === 'system.admin')) {
return fail(403, { roleError: 'Only a full administrator can grant unrestricted system access.' });
}
const roleId = id || randomUUID();
const beforeLinks = id ? await (await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(id) : [];
if (id) await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(name, description || null, id);
else await (await database.prepare('INSERT INTO roles(id,name,description) VALUES(?,?,?)')).run(roleId, name, description || null);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
for (const permissionId of permissionIds) {
await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(roleId, permissionId);
}
await recordIsacAudit({
actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId,
before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) } : null,
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
});
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
if (id === 'role-admin') {
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
}
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
}
const database = await getDatabase();
const role = await (await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?'
)).get(id);
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name)) {
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
}
const [permissions, assignedUsers] = await Promise.all([
(await database.prepare(`SELECT permissions.name FROM role_permissions
save: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.roles',
);
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const description = String(form.get('description') ?? '')
.normalize('NFKC')
.trim();
const permissionIds = [
...new Set(form.getAll('permissionIds').map(String)),
];
if (name.length < 2 || name.length > 60)
return fail(400, { roleError: 'Role names must be 2–60 characters.' });
if (description.length > 240)
return fail(400, {
roleError: 'Descriptions must be 240 characters or fewer.',
});
const database = await getDatabase();
const existing = id
? await (
await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?',
)
).get(id)
: null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin' && !canManageSystemAdmin) {
return fail(403, {
roleError: 'Only a full administrator can modify the core Admin role.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
const validPermissions = permissionIds.length
? await (
await database.prepare(
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).all(...permissionIds)
: [];
if (validPermissions.length !== permissionIds.length)
return fail(400, { roleError: 'One or more permissions are invalid.' });
if (
!canManageSystemAdmin &&
validPermissions.some((permission) => permission.name === 'system.admin')
) {
return fail(403, {
roleError:
'Only a full administrator can grant unrestricted system access.',
});
}
const roleId = id || randomUUID();
const beforeLinks = id
? await (
await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
).all(id)
: [];
if (id)
await (
await database.prepare(
'UPDATE roles SET name=?,description=? WHERE id=?',
)
).run(name, description || null, id);
else
await (
await database.prepare(
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
)
).run(roleId, name, description || null);
await (
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
).run(roleId);
for (const permissionId of permissionIds) {
await (
await database.prepare(
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
)
).run(roleId, permissionId);
}
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: id ? 'role.updated' : 'role.created',
entityId: roleId,
before: id
? {
name: existing?.name,
description: existing?.description,
permissions: beforeLinks.map((row) => row.name),
}
: null,
after: {
name,
description: description || null,
permissions: validPermissions.map((row) => row.name),
},
});
return {
roleSaved: true,
roleMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.roles',
);
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const confirmation = String(form.get('confirmation') ?? '')
.normalize('NFKC')
.trim();
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
if (id === 'role-admin') {
return fail(403, {
roleDeleteError: 'The core Admin role cannot be deleted.',
});
}
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
return fail(403, {
roleDeleteError:
'Only a full administrator can delete a seeded system role.',
});
}
const database = await getDatabase();
const role = await (
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
).get(id);
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name)) {
return fail(400, {
roleDeleteError: 'Enter the exact role name to confirm deletion.',
});
}
const [permissions, assignedUsers] = await Promise.all([
(
await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
(await database.prepare(`SELECT users.id,
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
).all(id),
(
await database.prepare(`SELECT users.id,
COALESCE(user_profiles.display_name,users.username) AS display_name
FROM user_roles JOIN users ON users.id=user_roles.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE user_roles.role_id=?
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
]);
await database.exec('BEGIN IMMEDIATE');
try {
if (seededRoleIds.has(id)) {
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
ORDER BY display_name COLLATE NOCASE,users.id`)
).all(id),
]);
await database.exec('BEGIN IMMEDIATE');
try {
if (seededRoleIds.has(id)) {
await (
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
VALUES(?,?,?)
ON CONFLICT(role_id) DO UPDATE SET
deleted_at=excluded.deleted_at,
deleted_by=excluded.deleted_by`))
.run(id, new Date().toISOString(), operator.id);
}
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: 'role.deleted',
entityId: id,
before: {
name: String(role.name),
description: role.description ? String(role.description) : null,
permissions: permissions.map((row) => String(row.name)),
assignedUsers: assignedUsers.map((row) => ({
id: String(row.id),
displayName: String(row.display_name)
}))
},
after: {
deleted: true,
systemRole: seededRoleIds.has(id),
removedAssignmentCount: assignedUsers.length
}
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { roleDeleted: true, resultToken: randomUUID() };
}
deleted_by=excluded.deleted_by`)
).run(id, new Date().toISOString(), operator.id);
}
await (
await database.prepare('DELETE FROM user_roles WHERE role_id=?')
).run(id);
await (
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
).run(id);
const deleted = await (
await database.prepare('DELETE FROM roles WHERE id=?')
).run(id);
if (!deleted.changes)
throw new Error('The role changed before it could be deleted.');
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: 'role.deleted',
entityId: id,
before: {
name: String(role.name),
description: role.description ? String(role.description) : null,
permissions: permissions.map((row) => String(row.name)),
assignedUsers: assignedUsers.map((row) => ({
id: String(row.id),
displayName: String(row.display_name),
})),
},
after: {
deleted: true,
systemRole: seededRoleIds.has(id),
removedAssignmentCount: assignedUsers.length,
},
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { roleDeleted: true, resultToken: randomUUID() };
},
};
File diff suppressed because it is too large Load Diff
@@ -1,223 +1,244 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
audits: [] as Array<Record<string, unknown>>,
executed: [] as string[],
inserts: [] as unknown[][],
invalidatePolicy: vi.fn(),
invalidateSiteAccess: vi.fn(),
siteSettings: {} as Record<string, unknown>,
moderationSettings: {} as Record<string, unknown>
audits: [] as Array<Record<string, unknown>>,
executed: [] as string[],
inserts: [] as unknown[][],
invalidatePolicy: vi.fn(),
invalidateSiteAccess: vi.fn(),
siteSettings: {} as Record<string, unknown>,
moderationSettings: {} as Record<string, unknown>,
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' }))
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requirePermission: vi.fn(async (user) => user)
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: vi.fn(async (event) => {
mocks.audits.push(event);
})
recordIsacAudit: vi.fn(async (event) => {
mocks.audits.push(event);
}),
}));
vi.mock('$lib/server/site-access', () => ({
invalidateSiteAccessSettings: mocks.invalidateSiteAccess
invalidateSiteAccessSettings: mocks.invalidateSiteAccess,
}));
vi.mock('$lib/server/community-moderation', async (importOriginal) => {
const actual = await importOriginal<typeof import('$lib/server/community-moderation')>();
return { ...actual, invalidateCommunityModerationPolicy: mocks.invalidatePolicy };
const actual =
await importOriginal<typeof import('$lib/server/community-moderation')>();
return {
...actual,
invalidateCommunityModerationPolicy: mocks.invalidatePolicy,
};
});
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
exec: async (statement: string) => {
mocks.executed.push(statement);
},
prepare: (statement: string) => ({
all: async () => statement.includes('FROM community_moderation_terms') ? [] : [],
get: async () => {
if (statement.includes('FROM site_launch_settings')) return { ...mocks.siteSettings };
if (statement.includes('FROM community_moderation_settings')) return { ...mocks.moderationSettings };
return null;
},
run: async (...values: unknown[]) => {
if (statement.includes('INSERT INTO community_moderation_terms')) mocks.inserts.push(values);
if (statement.includes('SET coming_soon_enabled=')) {
mocks.siteSettings.coming_soon_enabled = values[0];
mocks.siteSettings.updated_at = values[1];
mocks.siteSettings.updated_by = values[2];
}
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
mocks.moderationSettings.automated_enabled = values[0];
mocks.moderationSettings.provider_model = values[1];
mocks.moderationSettings.policy_version = Number(mocks.moderationSettings.policy_version) + 1;
mocks.moderationSettings.updated_at = values[2];
mocks.moderationSettings.updated_by = values[3];
}
return { changes: 1 };
}
})
}))
getDatabase: vi.fn(async () => ({
exec: async (statement: string) => {
mocks.executed.push(statement);
},
prepare: (statement: string) => ({
all: async () =>
statement.includes('FROM community_moderation_terms') ? [] : [],
get: async () => {
if (statement.includes('FROM site_launch_settings'))
return { ...mocks.siteSettings };
if (statement.includes('FROM community_moderation_settings'))
return { ...mocks.moderationSettings };
return null;
},
run: async (...values: unknown[]) => {
if (statement.includes('INSERT INTO community_moderation_terms'))
mocks.inserts.push(values);
if (statement.includes('SET coming_soon_enabled=')) {
mocks.siteSettings.coming_soon_enabled = values[0];
mocks.siteSettings.updated_at = values[1];
mocks.siteSettings.updated_by = values[2];
}
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
mocks.moderationSettings.automated_enabled = values[0];
mocks.moderationSettings.provider_model = values[1];
mocks.moderationSettings.policy_version =
Number(mocks.moderationSettings.policy_version) + 1;
mocks.moderationSettings.updated_at = values[2];
mocks.moderationSettings.updated_by = values[3];
}
return { changes: 1 };
},
}),
})),
}));
import { actions } from './+page.server';
describe('bulk moderation term action', () => {
beforeEach(() => {
mocks.audits.length = 0;
mocks.executed.length = 0;
mocks.inserts.length = 0;
mocks.invalidatePolicy.mockClear();
mocks.invalidateSiteAccess.mockClear();
mocks.siteSettings = {
id: 'primary',
coming_soon_enabled: 1,
account_creation_enabled: 1,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null
};
mocks.moderationSettings = {
id: 'primary',
automated_enabled: 1,
provider_model: 'omni-moderation-latest',
policy_version: 4,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null
};
});
beforeEach(() => {
mocks.audits.length = 0;
mocks.executed.length = 0;
mocks.inserts.length = 0;
mocks.invalidatePolicy.mockClear();
mocks.invalidateSiteAccess.mockClear();
mocks.siteSettings = {
id: 'primary',
coming_soon_enabled: 1,
account_creation_enabled: 1,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null,
};
mocks.moderationSettings = {
id: 'primary',
automated_enabled: 1,
provider_model: 'omni-moderation-latest',
policy_version: 4,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null,
};
});
it('creates and audits each normalized rule in one transaction', async () => {
const form = new FormData();
form.set('listType', 'allow');
form.set('enabled', '1');
form.append('scopes', 'comment');
form.append('scopes', 'guide');
form.set('terms', 'Kill\nmachine gun\nkill');
form.set('note', 'Common game vocabulary');
const result = await actions.bulkAddModerationTerms?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/bulkAddModerationTerms', {
method: 'POST',
body: form
})
} as never);
it('creates and audits each normalized rule in one transaction', async () => {
const form = new FormData();
form.set('listType', 'allow');
form.set('enabled', '1');
form.append('scopes', 'comment');
form.append('scopes', 'guide');
form.set('terms', 'Kill\nmachine gun\nkill');
form.set('note', 'Common game vocabulary');
const result = await actions.bulkAddModerationTerms?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/site-controls?/bulkAddModerationTerms',
{
method: 'POST',
body: form,
},
),
} as never);
expect(result).toMatchObject({
bulkModerationTermsSaved: true,
bulkModerationTermsAdded: 2,
bulkModerationTermsSkipped: 1
});
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
expect(mocks.inserts).toHaveLength(2);
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
['allow', 'word', 'Kill', 'kill'],
['allow', 'phrase', 'machine gun', 'machine gun']
]);
expect(mocks.audits).toHaveLength(2);
expect(mocks.audits[0]).toMatchObject({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.term_created',
entityId: expect.any(String),
before: null,
after: {
listKind: 'allow',
matchMode: 'word',
value: 'Kill',
normalizedValue: 'kill',
scopes: ['comment', 'guide'],
enabled: 1,
note: 'Common game vocabulary'
}
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
});
expect(result).toMatchObject({
bulkModerationTermsSaved: true,
bulkModerationTermsAdded: 2,
bulkModerationTermsSkipped: 1,
});
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
expect(mocks.inserts).toHaveLength(2);
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
['allow', 'word', 'Kill', 'kill'],
['allow', 'phrase', 'machine gun', 'machine gun'],
]);
expect(mocks.audits).toHaveLength(2);
expect(mocks.audits[0]).toMatchObject({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.term_created',
entityId: expect.any(String),
before: null,
after: {
listKind: 'allow',
matchMode: 'word',
value: 'Kill',
normalizedValue: 'kill',
scopes: ['comment', 'guide'],
enabled: 1,
note: 'Common game vocabulary',
},
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
});
it('saves and audits the launch gate without changing unrelated site controls', async () => {
const form = new FormData();
form.set('comingSoonEnabled', '0');
it('saves and audits the launch gate without changing unrelated site controls', async () => {
const form = new FormData();
form.set('comingSoonEnabled', '0');
const result = await actions.saveLaunchGate?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveLaunchGate', {
method: 'POST',
body: form
})
} as never);
const result = await actions.saveLaunchGate?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/site-controls?/saveLaunchGate',
{
method: 'POST',
body: form,
},
),
} as never);
expect(result).toMatchObject({ launchGateSaved: true });
expect(mocks.siteSettings).toMatchObject({
coming_soon_enabled: 0,
account_creation_enabled: 1,
updated_by: 'operator-1'
});
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'site_access.launch_gate_updated',
entityId: 'primary',
before: expect.objectContaining({ coming_soon_enabled: 1 }),
after: expect.objectContaining({ coming_soon_enabled: 0 })
})
]);
});
expect(result).toMatchObject({ launchGateSaved: true });
expect(mocks.siteSettings).toMatchObject({
coming_soon_enabled: 0,
account_creation_enabled: 1,
updated_by: 'operator-1',
});
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'site_access.launch_gate_updated',
entityId: 'primary',
before: expect.objectContaining({ coming_soon_enabled: 1 }),
after: expect.objectContaining({ coming_soon_enabled: 0 }),
}),
]);
});
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
const form = new FormData();
form.set('automatedEnabled', '0');
form.set('providerModel', 'omni-moderation-2024-09-26');
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
const form = new FormData();
form.set('automatedEnabled', '0');
form.set('providerModel', 'omni-moderation-2024-09-26');
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
method: 'POST',
body: form
})
} as never);
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/site-controls?/saveModerationScreening',
{
method: 'POST',
body: form,
},
),
} as never);
expect(result).toMatchObject({ moderationScreeningSaved: true });
expect(mocks.moderationSettings).toMatchObject({
automated_enabled: 0,
provider_model: 'omni-moderation-2024-09-26',
policy_version: 5,
updated_by: 'operator-1'
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.screening_updated',
entityId: 'primary',
before: expect.objectContaining({ policy_version: 4 }),
after: expect.objectContaining({ policy_version: 5 })
})
]);
});
expect(result).toMatchObject({ moderationScreeningSaved: true });
expect(mocks.moderationSettings).toMatchObject({
automated_enabled: 0,
provider_model: 'omni-moderation-2024-09-26',
policy_version: 5,
updated_by: 'operator-1',
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.screening_updated',
entityId: 'primary',
before: expect.objectContaining({ policy_version: 4 }),
after: expect.objectContaining({ policy_version: 5 }),
}),
]);
});
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
const form = new FormData();
form.set('automatedEnabled', 'sometimes');
form.set('providerModel', 'omni-moderation-latest');
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
const form = new FormData();
form.set('automatedEnabled', 'sometimes');
form.set('providerModel', 'omni-moderation-latest');
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
method: 'POST',
body: form
})
} as never);
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/site-controls?/saveModerationScreening',
{
method: 'POST',
body: form,
},
),
} as never);
expect(result).toMatchObject({ status: 400 });
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
expect(mocks.audits).toHaveLength(0);
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
});
expect(result).toMatchObject({ status: 400 });
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
expect(mocks.audits).toHaveLength(0);
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
@@ -21,11 +22,13 @@
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
@@ -69,9 +72,17 @@
highEndCount = '0';
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.sets.find((item: any) => item.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') iconResetToken += 1;
if (form.skillChipMode === 'add') {
iconResetToken += 1;
renderResetToken += 1;
}
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
@@ -96,7 +107,7 @@
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -7,6 +7,7 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
@@ -14,7 +15,7 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string };
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
type FocusEditor = {
id: string;
specializationId: string;
@@ -47,6 +48,7 @@
effects: StructuredEffect[];
active: number;
iconUrl?: string;
renderUrl?: string;
};
type CombatEffectEditor = {
id: string;
@@ -77,6 +79,7 @@
ranks: RankEditor[];
active: number;
iconUrl?: string;
renderUrl?: string;
};
const primaryOptions = [
@@ -89,7 +92,7 @@
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
});
@@ -100,7 +103,7 @@
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
castSeconds: 0, targetCap: 1, chargeCount: 1, chargePolicy: 'none'
},
effects: [], active: 1, iconUrl: ''
effects: [], active: 1, iconUrl: '', renderUrl: ''
});
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
@@ -124,7 +127,7 @@
});
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: ''
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
});
const normalizedEffect = (effect: any): StructuredEffect => ({
type: (effect.effect_type ?? (effect.stat_attribute_id ? 'Attribute' : 'Custom')) as StructuredEffect['type'],
@@ -150,6 +153,8 @@
let abilitySlots = $state<string[]>(['', '', '']);
let handledResultToken = $state('');
let iconResetToken = $state('');
let renderResetToken = $state('');
let openedRequestedItem = $state(false);
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
@@ -319,6 +324,14 @@
});
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.specializations.find((item: any) => item.id === data.requestedItemId);
if (requested) {
selectSpecialization(String(requested.id));
editSpecialization(requested);
}
openedRequestedItem = true;
}
const token = String(form?.resultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
@@ -327,6 +340,7 @@
if (form.specializationMode === 'add') {
specializationEditing = blankSpecialization();
iconResetToken = token;
renderResetToken = token;
} else specializationEditing = null;
}
if (form?.focusSaved) focusEditing = form.focusMode === 'add' ? blankFocus(selectedSpecializationId) : null;
@@ -334,6 +348,7 @@
if (form.abilityMode === 'add') {
abilityEditing = blankAbility(selectedSpecializationId);
iconResetToken = token;
renderResetToken = token;
} else abilityEditing = null;
}
if (form?.combatEffectSaved) {
@@ -344,6 +359,7 @@
if (form.nodeMode === 'add') {
nodeEditing = blankNode(selectedFocusId, Number(nodeEditing?.rowNumber ?? 1));
iconResetToken = token;
renderResetToken = token;
} else nodeEditing = null;
}
if (form?.specializationArchived || form?.specializationUnarchived) specializationEditing = null;
@@ -416,6 +432,7 @@
<input type="hidden" name="id" value={specializationEditing.id}/>
<div class="isac-record-identity specialization-record-identity">
<IconUploadField currentUrl={specializationEditing.iconUrl} label="SPECIALIZATION ICON" resetToken={iconResetToken}/>
<GameDataRenderUploadField currentUrl={specializationEditing.renderUrl} label="SPECIALIZATION RENDER" resetToken={renderResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={specializationEditing.name} maxlength="80" required/></label>
<label class="field">DESCRIPTION<textarea name="description" bind:value={specializationEditing.description} maxlength="1200" placeholder="Describe the specialization's class identity and purpose."></textarea></label>
@@ -464,6 +481,7 @@
<input type="hidden" name="attackJson" value={JSON.stringify(abilityEditing.attack)}/>
<div class="isac-record-identity specialization-record-identity">
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
<GameDataRenderUploadField currentUrl={abilityEditing.renderUrl} label="ABILITY RENDER" resetToken={renderResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={abilityEditing.name} maxlength="100" required/></label>
{#if abilityEditing.id}
@@ -540,6 +558,7 @@
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
<div class="isac-record-identity specialization-record-identity node-record-identity">
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
<GameDataRenderUploadField currentUrl={nodeEditing.renderUrl} label="NODE RENDER" resetToken={renderResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={nodeEditing.name} maxlength="100" required/></label>
{#if nodeEditing.id}
+138 -60
View File
@@ -7,72 +7,150 @@ import { AUTOMATED_MESSAGE_EVENTS } from '$lib/server/automated-messages';
import type { Actions, PageServerLoad } from './$types';
const parseActions = (value: unknown) => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
const database = await getDatabase();
const messages = await (await database.prepare(`SELECT message.*,
await requirePermission(
await getSessionUser(cookies),
'system.notifications.manage',
);
const database = await getDatabase();
const messages = await (
await database.prepare(`SELECT message.*,
(SELECT COUNT(*) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS delivery_count,
(SELECT MAX(delivered_at) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS last_delivered_at
FROM automated_system_messages message ORDER BY name`)).all();
return {
events: AUTOMATED_MESSAGE_EVENTS,
messages: messages.map((message) => ({ ...message, actions: parseActions(message.actions_json) }))
};
FROM automated_system_messages message ORDER BY name`)
).all();
return {
events: AUTOMATED_MESSAGE_EVENTS,
messages: messages.map((message) => ({
...message,
actions: parseActions(message.actions_json),
})),
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '').normalize('NFKC').trim();
const title = String(form.get('title') ?? '').normalize('NFKC').trim();
const body = String(form.get('body') ?? '').normalize('NFKC').trim();
const link = String(form.get('link') ?? '').trim();
const delay = Number(form.get('sendDelayMinutes') ?? 0);
const enabled = form.get('enabled') === 'on';
if (!id) return fail(400, { messageError: 'Choose an automated message to edit.' });
if (source.length < 2 || source.length > 80) return fail(400, { messageError: 'Source must be 2–80 characters.' });
if (title.length < 2 || title.length > 140) return fail(400, { messageError: 'Title must be 2–140 characters.' });
if (body.length < 20 || body.length > 2000) return fail(400, { messageError: 'Message content must be 20–2,000 characters.' });
if (!Number.isInteger(delay) || delay < 0 || delay > 10080) return fail(400, { messageError: 'Delay must be between 0 and 10,080 minutes.' });
const validHref = (href: string) => href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
if (link && !validHref(link)) return fail(400, { messageError: 'Primary destinations must use an internal path or secure HTTPS URL.' });
const actions = form.getAll('actionLabel').map((label, index) => ({
label: String(label).normalize('NFKC').trim(),
href: String(form.getAll('actionHref')[index] ?? '').trim()
})).filter((action) => action.label || action.href);
if (actions.length > 8) return fail(400, { messageError: 'Use no more than eight action callouts.' });
if (actions.some((action) => action.label.length < 2 || action.label.length > 60 || !validHref(action.href))) {
return fail(400, { messageError: 'Each action needs a 2–60 character label and a valid internal or HTTPS destination.' });
}
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM automated_system_messages WHERE id=?')).get(id);
if (!before) return fail(404, { messageError: 'Automated message not found.' });
const now = new Date().toISOString();
const after = {
...before, source, title, body, link: link || null, actions_json: JSON.stringify(actions),
enabled: enabled ? 1 : 0, send_delay_minutes: delay, updated_at: now, updated_by: operator.id
};
await (await database.prepare(`UPDATE automated_system_messages SET
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`))
.run(enabled ? 1 : 0, delay, source, title, body, link || null, JSON.stringify(actions), now, operator.id, id);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'automated_message.updated',
entityId: id,
before,
after
});
return { messageSaved: true, editorMode: 'edit', resultToken: crypto.randomUUID() };
}
save: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'system.notifications.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '')
.normalize('NFKC')
.trim();
const title = String(form.get('title') ?? '')
.normalize('NFKC')
.trim();
const body = String(form.get('body') ?? '')
.normalize('NFKC')
.trim();
const link = String(form.get('link') ?? '').trim();
const delay = Number(form.get('sendDelayMinutes') ?? 0);
const enabled = form.get('enabled') === 'on';
if (!id)
return fail(400, {
messageError: 'Choose an automated message to edit.',
});
if (source.length < 2 || source.length > 80)
return fail(400, { messageError: 'Source must be 2–80 characters.' });
if (title.length < 2 || title.length > 140)
return fail(400, { messageError: 'Title must be 2–140 characters.' });
if (body.length < 20 || body.length > 2000)
return fail(400, {
messageError: 'Message content must be 20–2,000 characters.',
});
if (!Number.isInteger(delay) || delay < 0 || delay > 10080)
return fail(400, {
messageError: 'Delay must be between 0 and 10,080 minutes.',
});
const validHref = (href: string) =>
href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
if (link && !validHref(link))
return fail(400, {
messageError:
'Primary destinations must use an internal path or secure HTTPS URL.',
});
const actions = form
.getAll('actionLabel')
.map((label, index) => ({
label: String(label).normalize('NFKC').trim(),
href: String(form.getAll('actionHref')[index] ?? '').trim(),
}))
.filter((action) => action.label || action.href);
if (actions.length > 8)
return fail(400, {
messageError: 'Use no more than eight action callouts.',
});
if (
actions.some(
(action) =>
action.label.length < 2 ||
action.label.length > 60 ||
!validHref(action.href),
)
) {
return fail(400, {
messageError:
'Each action needs a 2–60 character label and a valid internal or HTTPS destination.',
});
}
const database = await getDatabase();
const before = await (
await database.prepare(
'SELECT * FROM automated_system_messages WHERE id=?',
)
).get(id);
if (!before)
return fail(404, { messageError: 'Automated message not found.' });
const now = new Date().toISOString();
const after = {
...before,
source,
title,
body,
link: link || null,
actions_json: JSON.stringify(actions),
enabled: enabled ? 1 : 0,
send_delay_minutes: delay,
updated_at: now,
updated_by: operator.id,
};
await (
await database.prepare(`UPDATE automated_system_messages SET
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`)
).run(
enabled ? 1 : 0,
delay,
source,
title,
body,
link || null,
JSON.stringify(actions),
now,
operator.id,
id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'automated_message.updated',
entityId: id,
before,
after,
});
return {
messageSaved: true,
editorMode: 'edit',
resultToken: crypto.randomUUID(),
};
},
};
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -3,6 +3,7 @@
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
@@ -19,6 +20,8 @@
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
let validationSummary = $state<HTMLElement | null>(null);
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string };
@@ -48,7 +51,7 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const blankTalent = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
@@ -132,10 +135,18 @@
});
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
if (requested) editTalent(requested);
openedRequestedItem = true;
}
if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
if (form.talentSaved) {
if (form.talentMode === 'add') iconResetToken += 1;
if (form.talentMode === 'add') {
iconResetToken += 1;
renderResetToken += 1;
}
talent = form.talentMode === 'add' ? blankTalent() : null;
} else if (talentErrors.length) {
if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true;
@@ -168,6 +179,7 @@
<div class="talent-icon-identity">
<div id="talent-icon" class:validation-area-error={hasErrors(['icon'])}>
<IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/>
<GameDataRenderUploadField currentUrl={talent.renderUrl} label="TALENT RENDER" resetToken={renderResetToken}/>
{#each errorsFor('icon') as error}<small class="field-error">{error.message}</small>{/each}
</div>
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
+136 -66
View File
@@ -5,19 +5,37 @@ import type { PageServerLoad } from './$types';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'users.view');
const query = url.searchParams.get('q')?.trim() ?? '';
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const sort = getRegistrySort(url, {
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
lastLogin: { asc: 'last_login_at ASC', desc: 'last_login_at DESC', defaultDirection: 'desc' },
lastActive: { asc: 'last_active_at ASC', desc: 'last_active_at DESC', defaultDirection: 'desc' },
content: { asc: 'content_count ASC', desc: 'content_count DESC', defaultDirection: 'desc' }
}, 'lastActive', 'desc');
const database = await getDatabase();
const summaryRows = await (await database.prepare(`
await requirePermission(await getSessionUser(cookies), 'users.view');
const query = url.searchParams.get('q')?.trim() ?? '';
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const sort = getRegistrySort(
url,
{
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
lastLogin: {
asc: 'last_login_at ASC',
desc: 'last_login_at DESC',
defaultDirection: 'desc',
},
lastActive: {
asc: 'last_active_at ASC',
desc: 'last_active_at DESC',
defaultDirection: 'desc',
},
content: {
asc: 'content_count ASC',
desc: 'content_count DESC',
defaultDirection: 'desc',
},
},
'lastActive',
'desc',
);
const database = await getDatabase();
const summaryRows = await (
await database.prepare(`
SELECT users.id,users.login_username,
COALESCE(user_profiles.display_name,users.username) AS display_name,
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
@@ -32,41 +50,88 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
`)).all(pattern, pattern);
const now = Date.now();
const state = (user: Record<string, unknown>) => {
const activity = user.last_active_at ?? user.last_login_at;
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
return { inactive, accessState };
};
const summaryStates = summaryRows.map((user) => ({ ...user, ...state(user) }));
const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
const comparisonValue = (user: Record<string, any>) => {
if (sort.key === 'user') return String(user.display_name ?? user.login_username ?? '').toLocaleLowerCase();
if (sort.key === 'access') return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
if (sort.key === 'roles') return String(user.assigned_roles ?? user.role ?? 'User').toLocaleLowerCase();
if (sort.key === 'lastLogin') return user.last_login_at ? new Date(String(user.last_login_at)).getTime() : -1;
if (sort.key === 'lastActive') return user.last_active_at ? new Date(String(user.last_active_at)).getTime() : -1;
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
};
const direction = sort.direction === 'asc' ? 1 : -1;
filteredStates.sort((left, right) => {
const leftValue = comparisonValue(left);
const rightValue = comparisonValue(right);
const comparison = typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue));
return comparison * direction
|| String(left.display_name ?? left.login_username ?? '').localeCompare(String(right.display_name ?? right.login_username ?? ''))
|| String(left.id).localeCompare(String(right.id));
});
const pagination = getPagination(url, filteredStates.length);
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
const userRows = pageIds.length ? await (await database.prepare(`
`)
).all(pattern, pattern);
const now = Date.now();
const state = (user: Record<string, unknown>) => {
const activity = user.last_active_at ?? user.last_login_at;
const inactive =
!activity ||
now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
const banned =
typeof user.banned_until === 'string' &&
new Date(user.banned_until) > new Date();
const accessState = user.deleted_at
? 'DELETED'
: user.blocked_at
? 'BLOCKED'
: banned
? 'BANNED'
: 'ACTIVE';
return { inactive, accessState };
};
const summaryStates = summaryRows.map((user) => ({
...user,
...state(user),
}));
const filter = ['active', 'inactive', 'restricted'].includes(
url.searchParams.get('filter') ?? '',
)
? String(url.searchParams.get('filter'))
: '';
const filteredStates = summaryStates.filter((user) =>
filter === 'active'
? user.accessState === 'ACTIVE' && !user.inactive
: filter === 'inactive'
? user.inactive
: filter === 'restricted'
? user.accessState !== 'ACTIVE'
: true,
);
const comparisonValue = (user: Record<string, any>) => {
if (sort.key === 'user')
return String(
user.display_name ?? user.login_username ?? '',
).toLocaleLowerCase();
if (sort.key === 'access')
return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
if (sort.key === 'roles')
return String(
user.assigned_roles ?? user.role ?? 'User',
).toLocaleLowerCase();
if (sort.key === 'lastLogin')
return user.last_login_at
? new Date(String(user.last_login_at)).getTime()
: -1;
if (sort.key === 'lastActive')
return user.last_active_at
? new Date(String(user.last_active_at)).getTime()
: -1;
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
};
const direction = sort.direction === 'asc' ? 1 : -1;
filteredStates.sort((left, right) => {
const leftValue = comparisonValue(left);
const rightValue = comparisonValue(right);
const comparison =
typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue));
return (
comparison * direction ||
String(left.display_name ?? left.login_username ?? '').localeCompare(
String(right.display_name ?? right.login_username ?? ''),
) ||
String(left.id).localeCompare(String(right.id))
);
});
const pagination = getPagination(url, filteredStates.length);
const pageIds = filteredStates
.slice(pagination.offset, pagination.offset + pagination.perPage)
.map((user) => String(user.id));
const userRows = pageIds.length
? await (
await database.prepare(`
SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at,
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
@@ -81,22 +146,27 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
`)).all(...pageIds) : [];
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
return {
users: users.map((user) => {
return { ...user, ...state(user) };
}),
query,
filter,
sort,
summary: {
results: summaryRows.length,
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
inactive: summaryStates.filter((user) => user.inactive).length,
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
},
pagination
};
`)
).all(...pageIds)
: [];
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
return {
users: users.map((user) => {
return { ...user, ...state(user) };
}),
query,
filter,
sort,
summary: {
results: summaryRows.length,
active: summaryStates.filter(
(user) => user.accessState === 'ACTIVE' && !user.inactive,
).length,
inactive: summaryStates.filter((user) => user.inactive).length,
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE')
.length,
},
pagination,
};
};
+602 -201
View File
@@ -1,4 +1,7 @@
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import {
getPermissions,
requirePermission,
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
@@ -7,46 +10,113 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import {
canRemoveAuthenticationMethod,
getAuthenticationMethods,
} from '$lib/server/auth/methods';
import { getPagination } from '$lib/server/pagination';
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { createNotification } from '$lib/server/notifications';
export const load: PageServerLoad = async ({ cookies, params, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
const database = await getDatabase();
await purgeExpiredLoginMetadata();
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : '';
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : '';
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
const operator = await requirePermission(
await getSessionUser(cookies),
'users.view',
);
const database = await getDatabase();
await purgeExpiredLoginMetadata();
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
const loginStatus = ['success', 'failed'].includes(
url.searchParams.get('loginStatus') ?? '',
)
? String(url.searchParams.get('loginStatus'))
: '';
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
const buildVisibility = ['draft', 'private', 'public'].includes(
url.searchParams.get('buildVisibility') ?? '',
)
? String(url.searchParams.get('buildVisibility'))
: '';
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
const pattern = (value: string) =>
`%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\'
OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`;
const loginPattern = pattern(loginQuery);
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
const loginPattern = pattern(loginQuery);
const loginArgs: unknown[] = [
params.id,
loginPattern,
loginPattern,
loginPattern,
loginPattern,
loginPattern,
...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : []),
];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [
params.id,
pattern(buildQuery),
pattern(buildQuery),
...(buildVisibility ? [buildVisibility] : []),
];
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
const [loginCount, buildCount, commentCount] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
const commentArgs: unknown[] = [
params.id,
pattern(commentQuery),
pattern(commentQuery),
];
const [loginCount, buildCount, commentCount] = await Promise.all([
(
await database.prepare(
`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`,
)
).get(...loginArgs),
(
await database.prepare(
`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`,
)
).get(...buildArgs),
(
await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere}`)).get(...commentArgs)
]);
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
const commentPagination = getPagination(url, Number(commentCount?.count ?? 0), 'comment');
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys] = await Promise.all([
(await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
WHERE ${commentWhere}`)
).get(...commentArgs),
]);
const loginPagination = getPagination(
url,
Number(loginCount?.count ?? 0),
'login',
);
const buildPagination = getPagination(
url,
Number(buildCount?.count ?? 0),
'build',
);
const commentPagination = getPagination(
url,
Number(commentCount?.count ?? 0),
'comment',
);
const [
profile,
roles,
permissions,
assignedRoles,
directPermissions,
logins,
builds,
comments,
authenticationMethods,
emails,
passkeys,
] = await Promise.all([
(
await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at,
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
@@ -58,190 +128,521 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id),
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
(await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)
).get(params.id),
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
(
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).all(params.id),
(
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=?',
)
).all(params.id),
(
await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system,
login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city,
login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id,
login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at,
sessions.expires_at AS session_expires_at
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)
).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(
await database.prepare(
`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
)
).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(
await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
,getAuthenticationMethods(database, params.id)
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
]);
if (!profile) error(404, 'User not found');
return {
profile: {
...profile,
avatar_url: avatarUrl(profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at)
},
roles,
permissions,
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
logins, builds, comments, emails, passkeys,
historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery },
historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination },
authenticationMethods,
operatorPermissions: await getPermissions(operator),
isSelf: operator.id === params.id
};
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)
).all(...commentArgs, commentPagination.perPage, commentPagination.offset),
getAuthenticationMethods(database, params.id),
(
await database.prepare(
'SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?',
)
).all(params.id),
(
await database.prepare(
'SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC',
)
).all(params.id),
]);
if (!profile) error(404, 'User not found');
return {
profile: {
...profile,
avatar_url: avatarUrl(
profile.avatar_source,
profile.avatar_value,
String(profile.id),
profile.avatar_updated_at,
),
},
roles,
permissions,
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
directPermissionIds: directPermissions.map((row) =>
String(row.permission_id),
),
logins,
builds,
comments,
emails,
passkeys,
historyFilters: {
loginQuery,
loginStatus,
buildQuery,
buildVisibility,
commentQuery,
},
historyPagination: {
login: loginPagination,
build: buildPagination,
comment: commentPagination,
},
authenticationMethods,
operatorPermissions: await getPermissions(operator),
isSelf: operator.id === params.id,
};
};
export const actions: Actions = {
removeCustomAvatar: async ({ cookies, params, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
const reason = String((await request.formData()).get('reason') ?? '').trim().slice(0, 500);
if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.' });
const database = await getDatabase();
const avatar = await (await database.prepare(
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
)).get(params.id);
if (avatar?.source !== 'upload') {
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
}
await deleteUploadedAvatar(database, params.id);
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
.run(new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at);
await recordIsacAudit({
actorId: operator.id,
category: 'user_moderation',
action: 'avatar.removed',
entityId: params.id,
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
after: { source: 'default', reviewSource: 'agent_operations', reason }
});
await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true });
return { avatarRemoved: true };
},
setAccess: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const form = await request.formData();
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
const database = await getDatabase();
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { accessError: 'User not found.' });
const operatorPermissions = await getPermissions(operator);
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole);
if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) {
return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' });
}
const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' });
const before = {
roleIds: (await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(params.id)).map((row) => row.role_id),
permissionIds: (await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(params.id)).map((row) => row.permission_id)
};
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id);
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId);
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
return { accessSaved: true };
},
moderate: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' });
const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim();
if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' });
if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' });
const untilRaw = String(form.get('bannedUntil') ?? '');
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' });
const now = new Date().toISOString(), database = await getDatabase();
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { moderationError: 'User not found.' });
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
}
const before = await (await database.prepare('SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?')).get(params.id);
await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`))
.run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now);
if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
return { moderationSaved: true };
},
deleteAccount: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.delete');
if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' });
const reason = String((await request.formData()).get('reason') ?? '').trim();
if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
const database = await getDatabase();
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' });
}
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
}
return { accountDeleted: true };
},
unlinkDiscord: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const database = await getDatabase();
const target = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { identityError: 'User not found.' });
if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'discord')) {
return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.' });
}
const now = new Date().toISOString();
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
return { identityUnlinked: true };
},
removeEmail: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const database = await getDatabase();
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(params.id);
if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'email')) {
return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.' });
}
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
return { emailRemoved: true };
},
removePasskey: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const passkey = await (await database.prepare('SELECT id,name FROM passkeys WHERE id=? AND user_id=?')).get(passkeyId, params.id);
if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'passkey')) {
return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.' });
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
return { passkeyRemoved: true };
}
removeCustomAvatar: async ({ cookies, params, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'avatars.review',
);
const reason = String((await request.formData()).get('reason') ?? '')
.trim()
.slice(0, 500);
if (reason.length < 3)
return fail(400, {
avatarModerationError: 'Provide a reason for removing this avatar.',
});
const database = await getDatabase();
const avatar = await (
await database.prepare(
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?',
)
).get(params.id);
if (avatar?.source !== 'upload') {
return fail(409, {
avatarModerationError:
'This agent does not currently use a custom avatar.',
});
}
await deleteUploadedAvatar(database, params.id);
await (
await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`)
).run(
new Date().toISOString(),
operator.id,
reason,
params.id,
avatar.updated_at,
);
await recordIsacAudit({
actorId: operator.id,
category: 'user_moderation',
action: 'avatar.removed',
entityId: params.id,
before: {
source: avatar.source,
storageKey: avatar.value,
byteSize: avatar.byte_size,
},
after: { source: 'default', reviewSource: 'agent_operations', reason },
});
await createNotification({
userId: params.id,
category: 'isac',
source: 'ISAC USER OPERATIONS',
title: 'Custom avatar removed',
body: `Your custom avatar was removed by Oversight. Reason: ${reason}`,
link: '/account#avatar',
mandatory: true,
});
return { avatarRemoved: true };
},
setAccess: async ({ cookies, request, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.roles',
);
const form = await request.formData();
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
const permissionIds = [
...new Set(form.getAll('permissionIds').map(String)),
];
const database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { accessError: 'User not found.' });
const operatorPermissions = await getPermissions(operator);
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
const changesAdminRole =
roleIds.includes('role-admin') !== Boolean(targetAdminRole);
if (
(target.role === 'admin' || changesAdminRole) &&
!operatorPermissions.includes('*')
) {
return fail(403, {
accessError:
'Only an administrator can grant, remove, or modify administrator access.',
});
}
const validRoles = roleIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
)
).get(...roleIds)
)?.count,
)
: 0;
const validPermissions = permissionIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).get(...permissionIds)
)?.count,
)
: 0;
if (
validRoles !== roleIds.length ||
validPermissions !== permissionIds.length
)
return fail(400, {
accessError: 'One or more access selections are invalid.',
});
const before = {
roleIds: (
await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(params.id)
).map((row) => row.role_id),
permissionIds: (
await (
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
)
).all(params.id)
).map((row) => row.permission_id),
};
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(params.id);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(params.id);
for (const roleId of roleIds)
await (
await database.prepare(
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
)
).run(params.id, roleId);
for (const permissionId of permissionIds)
await (
await database.prepare(
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
)
).run(params.id, permissionId);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'user_access',
params.id,
'access.updated',
JSON.stringify(before),
JSON.stringify({ roleIds, permissionIds }),
new Date().toISOString(),
);
return { accessSaved: true };
},
moderate: async ({ cookies, request, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.moderate',
);
if (operator.id === params.id)
return fail(400, {
moderationError: 'You cannot restrict your own account.',
});
const form = await request.formData(),
state = String(form.get('state') ?? ''),
reason = String(form.get('reason') ?? '').trim();
if (!['active', 'blocked', 'banned'].includes(state))
return fail(400, { moderationError: 'Choose a valid access state.' });
if (state !== 'active' && reason.length < 3)
return fail(400, { moderationError: 'Provide a moderation reason.' });
const untilRaw = String(form.get('bannedUntil') ?? '');
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
if (
state === 'banned' &&
(!untilRaw ||
Number.isNaN(bannedUntil?.getTime()) ||
bannedUntil! <= new Date())
)
return fail(400, { moderationError: 'Choose a future ban expiration.' });
const now = new Date().toISOString(),
database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { moderationError: 'User not found.' });
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
if (
(target.role === 'admin' || targetAdminRole) &&
!(await getPermissions(operator)).includes('*')
) {
return fail(403, {
moderationError:
'Only an administrator can moderate another administrator.',
});
}
const before = await (
await database.prepare(
'SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?',
)
).get(params.id);
await (
await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`)
).run(
params.id,
state === 'blocked' ? now : null,
state === 'active' ? null : operator.id,
state === 'banned' ? bannedUntil!.toISOString() : null,
state === 'active' ? null : reason,
now,
);
if (state !== 'active')
await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(params.id);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'user_moderation',
params.id,
`moderation.${state}`,
JSON.stringify(before ?? null),
JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }),
now,
);
return { moderationSaved: true };
},
deleteAccount: async ({ cookies, request, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.delete',
);
if (operator.id === params.id)
return fail(400, {
adminDeleteError:
'Delete your own account from the agent profile page.',
});
const reason = String(
(await request.formData()).get('reason') ?? '',
).trim();
if (reason.length < 3)
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
const database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
if (
(target.role === 'admin' || targetAdminRole) &&
!(await getPermissions(operator)).includes('*')
) {
return fail(403, {
adminDeleteError:
'Only an administrator can delete another administrator.',
});
}
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
return fail(409, {
adminDeleteError: 'This account has already been deleted.',
});
}
return { accountDeleted: true };
},
unlinkDiscord: async ({ cookies, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.identities',
);
const database = await getDatabase();
const target = await (
await database.prepare('SELECT discord_id FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { identityError: 'User not found.' });
if (!target.discord_id)
return fail(409, {
identityError: 'This user does not have a linked Discord account.',
});
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, params.id),
'discord',
)
) {
return fail(400, {
identityError:
'Discord cannot be unlinked because it is this user’s only sign-in method.',
});
}
const now = new Date().toISOString();
await (
await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')
).run(params.id);
await (
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
).run(params.id);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'identity',
params.id,
'identity.discord_unlinked',
JSON.stringify({ discordId: target.discord_id }),
now,
);
return { identityUnlinked: true };
},
removeEmail: async ({ cookies, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.identities',
);
const database = await getDatabase();
const email = await (
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
).get(params.id);
if (!email)
return fail(409, {
identityError: 'This user does not have a verified email sign-in.',
});
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, params.id),
'email',
)
) {
return fail(400, {
identityError:
'Email cannot be removed because it is this user’s only sign-in method.',
});
}
await (
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
).run(params.id);
await (
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
).run(params.id);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'identity',
params.id,
'identity.email_removed',
JSON.stringify({ email: email.email }),
new Date().toISOString(),
);
return { emailRemoved: true };
},
removePasskey: async ({ cookies, request, params }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.identities',
);
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const passkey = await (
await database.prepare(
'SELECT id,name FROM passkeys WHERE id=? AND user_id=?',
)
).get(passkeyId, params.id);
if (!passkey)
return fail(404, {
identityError: 'That passkey was not found for this user.',
});
if (
!canRemoveAuthenticationMethod(
await getAuthenticationMethods(database, params.id),
'passkey',
)
) {
return fail(400, {
identityError:
'This passkey cannot be removed because it is the user’s only sign-in method.',
});
}
await (
await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')
).run(passkeyId, params.id);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'identity',
params.id,
'identity.passkey_removed',
JSON.stringify({ id: passkey.id, name: passkey.name }),
new Date().toISOString(),
);
return { passkeyRemoved: true };
},
};
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,6 +7,7 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props();
@@ -18,7 +19,7 @@
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
const blank = () => ({
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '',
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
});
@@ -27,6 +28,8 @@
let qualityPreview = $state('High-end');
let isExotic = $state(false);
let handledResultToken = $state('');
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
@@ -60,8 +63,14 @@
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
if (requested) edit(requested);
openedRequestedItem = true;
}
if (form?.weaponModSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.weaponModMode === 'add') renderResetToken += 1;
editing = form.weaponModMode === 'add' ? blank() : null;
levelPreview = 1;
isExotic = false;
@@ -83,7 +92,7 @@
{#snippet weaponModEditor()}
<div class="dashboard-editor weapon-mod-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
@@ -96,6 +105,7 @@
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required/>
</div>
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="WEAPON MOD RENDER" resetToken={renderResetToken}/>
<div class="weapon-mod-compatibility">
<div>
@@ -158,7 +168,7 @@
<div class="dashboard-table weapon-mod-dashboard-table">
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render weaponModEditor()}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { listPublishedArticles, NEWS_CHANNELS } from '$lib/server/articles';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
return {
...(await listPublishedArticles({
kind: 'news',
query: url.searchParams.get('q') ?? '',
channel: url.searchParams.get('channel') ?? '',
page: Number(url.searchParams.get('page') ?? 1),
})),
query: url.searchParams.get('q') ?? '',
channel: url.searchParams.get('channel') ?? '',
channels: NEWS_CHANNELS,
canManage: await hasPermission(user, 'news.manage'),
};
};
+4 -26
View File
@@ -1,26 +1,4 @@
<svelte:head>
<title>News & updates — SHD.build</title>
<meta name="description" content="The Division Resurgence game news, update coverage, community reports, and SHD.build development notices."/>
</svelte:head>
<main class="page community-index-page news-page">
<header class="catalogue-hero community-index-hero">
<div><p class="eyebrow"><span></span> SHD FIELD REPORTS</p><h1>NEWS &<br/><em>UPDATES.</em></h1><p>Game announcements, update intelligence, community reporting, and SHD.build development transmissions.</p></div>
<div class="catalogue-count"><strong>FEED</strong><span>EDITORIAL CHANNEL</span><a class="primary" href="/contact?team=admins">SUBMIT A LEAD →</a></div>
</header>
<section class="news-channel-grid">
<article class="news-primary">
<div><span>CHANNEL STATUS</span><b>EDITORIAL SYSTEM IN DEVELOPMENT</b></div>
<p class="eyebrow"><span></span> TRANSMISSION 00</p>
<h2>THE COMMUNITY NEWSROOM IS COMING ONLINE.</h2>
<p>SHD.build is expanding beyond loadouts into a complete Resurgence community resource. This channel will cover official announcements, game updates, database changes, community projects, and field reports without presenting speculation as confirmed information.</p>
<footer><span>SOURCE POLICY <b>VERIFIABLE</b></span><span>COVERAGE <b>RESURGENCE</b></span><span>STATUS <b>QUEUED</b></span></footer>
</article>
<aside>
<p class="eyebrow">PLANNED FEEDS</p>
{#each ['OFFICIAL GAME NEWS','PATCH & DATA UPDATES','COMMUNITY REPORTS','SHD.BUILD DEVELOPMENT'] as channel,index}
<div><small>{String(index + 1).padStart(2, '0')}</small><span>{channel}</span><b>QUEUED</b></div>
{/each}
</aside>
</section>
</main>
<script lang="ts">import ArticleCard from '$lib/components/ArticleCard.svelte';let { data }=$props();const lead=$derived(data.items[0]);const rest=$derived(data.items.slice(1));</script>
<svelte:head><title>News — SHD.build</title><meta name="description" content="The Division Resurgence news, update coverage, community reports, and SHD.build development transmissions."/></svelte:head>
<main class="page news-index"><header><div><p class="eyebrow"><span></span> SHD FIELD REPORTS</p><h1>NEWS</h1><p>Verified announcements, update intelligence, community reporting, and SHD.build development notes.</p></div>{#if data.canManage}<a class="primary" href="/news/new">NEW ARTICLE +</a>{/if}</header><form method="GET"><label>SEARCH NEWS<input name="q" value={data.query}/></label><label>CHANNEL<select name="channel"><option value="">ALL CHANNELS</option>{#each data.channels as channel}<option value={channel} selected={data.channel===channel}>{channel}</option>{/each}</select></label><button class="primary">FILTER</button></form>{#if lead}<section class="news-lead"><ArticleCard article={lead} kind="news" prominent/></section>{/if}<section class="news-grid">{#each rest as article}<ArticleCard {article} kind="news"/>{:else}{#if !lead}<div class="catalogue-empty"><span>◇</span><strong>NO TRANSMISSIONS PUBLISHED</strong><p>The newsroom has no public articles in this channel.</p></div>{/if}{/each}</section></main>
<style>.news-index{padding-top:28px}.news-index>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:24px;padding:24px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.news-index>header h1,.news-index>header p{margin:0}.news-index>header h1{max-width:10ch;font-size:clamp(3.5rem,7vw,6rem);line-height:.84;letter-spacing:-.035em}.news-index>header>div>p:last-child{max-width:62ch;margin-top:18px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.news-index>form{display:grid;grid-template-columns:minmax(250px,1fr) 260px auto;align-items:end;gap:8px;padding:10px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.news-index>form label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.news-index>form button{min-height:40px}.news-lead{margin-top:24px}.news-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}@media(max-width:950px){.news-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.news-index{padding-top:12px}.news-index>header{align-items:start;flex-direction:column;padding:20px 0 24px}.news-index>header>a{width:100%;text-align:center}.news-index>form,.news-grid{grid-template-columns:1fr}.news-index>form{padding:8px}.news-index>form button{min-height:48px}.news-index>header h1{font-size:clamp(3.25rem,17vw,4.5rem)}}</style>
+5
View File
@@ -0,0 +1,5 @@
import { articlePageActions, loadArticlePage } from '$lib/server/article-page';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) =>
loadArticlePage('news', params.slug, cookies);
export const actions = articlePageActions('news');
+2
View File
@@ -0,0 +1,2 @@
<script lang="ts">import ArticlePage from '$lib/components/ArticlePage.svelte';let {data,form}=$props();</script>
<ArticlePage {data} {form}/>
@@ -0,0 +1,59 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import {
deleteArticle,
loadArticleEditor,
NEWS_CHANNELS,
} from '$lib/server/articles';
import { saveAndSubmitArticleForm } from '$lib/server/article-form';
import type { Actions, PageServerLoad } from './$types';
async function newsId(slug: string) {
const row = await (
await (
await getDatabase()
).prepare("SELECT id FROM articles WHERE kind='news' AND slug=?")
).get(slug);
if (!row) error(404, 'News article not found');
return String(row.id);
}
export const load: PageServerLoad = async ({ params, cookies }) => {
const user = await getSessionUser(cookies);
if (!user || !(await hasPermission(user, 'news.manage')))
redirect(303, '/news');
return {
...(await loadArticleEditor(await newsId(params.slug), user.id, true)),
newsChannels: NEWS_CHANNELS,
};
};
export const actions: Actions = {
publish: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user || !(await hasPermission(user, 'news.manage')))
return fail(403, { articleError: 'News editor permission required.' });
const result = await saveAndSubmitArticleForm({
form: await request.formData(),
user,
canManageNews: true,
});
if (!result.ok)
return fail(result.status, { articleError: result.message });
return {
articleMessage:
result.state === 'private'
? 'News article saved as private.'
: 'News article published.',
};
},
delete: async ({ params, cookies }) => {
const user = await getSessionUser(cookies);
if (!user || !(await hasPermission(user, 'news.manage')))
return fail(403, { articleError: 'News editor permission required.' });
await deleteArticle(await newsId(params.slug), user.id, true);
redirect(303, '/news');
},
};
+3
View File
@@ -0,0 +1,3 @@
<script lang="ts">import ArticleComposer from '$lib/components/ArticleComposer.svelte';let { data, form } = $props();</script>
<svelte:head><title>Edit {data.draft.title} — SHD.build</title></svelte:head>
<ArticleComposer {data} {form}/>
+31
View File
@@ -0,0 +1,31 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { createArticle } from '$lib/server/articles';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in?returnTo=%2Fnews%2Fnew');
if (!(await hasPermission(user, 'news.manage'))) redirect(303, '/news');
return {};
};
export const actions: Actions = {
create: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user || !(await hasPermission(user, 'news.manage')))
return fail(403, { articleError: 'News editor permission required.' });
const title = String((await request.formData()).get('title') ?? '').trim();
if (title.length < 2 || title.length > 140)
return fail(400, {
articleError: 'News titles must be between 2 and 140 characters.',
});
const article = await createArticle({
kind: 'news',
ownerId: user.id,
title,
});
redirect(303, `/news/${encodeURIComponent(article.slug)}/edit`);
},
};
+4
View File
@@ -0,0 +1,4 @@
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let { form } = $props();</script>
<svelte:head><title>Create News — SHD.build</title></svelte:head>
<main class="page article-start-page"><section><p class="eyebrow"><span></span> SHD NEWSROOM</p><h1>NEW<br/><em>TRANSMISSION.</em></h1><p>Create a private editorial workspace. News editors publish directly without community-content moderation.</p><PendingForm action="?/create"><label>WORKING TITLE<input name="title" minlength="2" maxlength="140" required/></label>{#if form?.articleError}<p class="error">{form.articleError}</p>{/if}<button class="primary">OPEN NEWSROOM →</button></PendingForm></section></main>
<style>.article-start-page{min-height:calc(100vh - 160px);display:grid;place-items:start center;padding-top:clamp(48px,8vh,110px)}.article-start-page>section{width:min(760px,100%);display:grid;gap:16px;padding:clamp(24px,5vw,60px);border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-start-page h1,.article-start-page p{margin:0}.article-start-page h1{font-size:clamp(3rem,8vw,6rem);line-height:.85}.article-start-page>section>p:not(.eyebrow){max-width:62ch;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.article-start-page :global(form),.article-start-page label{display:grid;gap:10px}.article-start-page label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-start-page input{min-height:52px;font-size:1.1rem}@media(max-width:650px){.article-start-page{padding-top:40px}.article-start-page>section{padding:24px}.article-start-page :global(form button){width:100%;min-height:48px}}</style>
+1 -1
View File
@@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
redirect(308, `/account/notifications${url.search}`);
redirect(308, `/account/notifications${url.search}`);
};
+269 -128
View File
@@ -1,148 +1,289 @@
import { createSession, destroySession, recordLogin } from '$lib/server/auth/session';
import {
createSession,
destroySession,
recordLogin,
} from '$lib/server/auth/session';
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import {
emailDeliveryConfigured,
sendAuthenticationLink,
} from '$lib/server/auth/mail';
import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
import {
displayNameIsAvailable,
insertUserProfile
displayNameIsAvailable,
insertUserProfile,
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ url }) => {
const { accountCreationEnabled } = await getSiteAccessSettings();
const oauthError = url.searchParams.get('oauth_error');
return {
accountCreationEnabled,
emailDeliveryConfigured: emailDeliveryConfigured(),
magicLinkSent: url.searchParams.has('magic_sent'),
magicError: url.searchParams.get('magic_error') === 'unavailable'
? 'This account is not permitted to sign in.'
: url.searchParams.has('magic_error') ? 'That sign-in link is invalid or expired.' : null,
oauthError: oauthError === 'registration_closed'
? 'New account registration is currently closed. Existing Discord-linked agents can still sign in.'
: oauthError ? 'Discord sign-in could not be completed.' : null
};
const { accountCreationEnabled } = await getSiteAccessSettings();
const oauthError = url.searchParams.get('oauth_error');
return {
accountCreationEnabled,
emailDeliveryConfigured: emailDeliveryConfigured(),
magicLinkSent: url.searchParams.has('magic_sent'),
magicError:
url.searchParams.get('magic_error') === 'unavailable'
? 'This account is not permitted to sign in.'
: url.searchParams.has('magic_error')
? 'That sign-in link is invalid or expired.'
: null,
oauthError:
oauthError === 'registration_closed'
? 'New account registration is currently closed. Existing Discord-linked agents can still sign in.'
: oauthError
? 'Discord sign-in could not be completed.'
: null,
};
};
export const actions: Actions = {
magicLink: async ({ request }) => {
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, { magicLinkError: 'Enter a valid email address.', email });
}
if (!emailDeliveryConfigured()) return fail(503, { magicLinkError: 'Email sign-in is not configured yet.', email });
const database = await getDatabase();
let identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
magicLink: async ({ request }) => {
const email = String((await request.formData()).get('email') ?? '')
.normalize('NFKC')
.trim()
.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, {
magicLinkError: 'Enter a valid email address.',
email,
});
}
if (!emailDeliveryConfigured())
return fail(503, {
magicLinkError: 'Email sign-in is not configured yet.',
email,
});
const database = await getDatabase();
let identity = await (
await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
FROM user_emails LEFT JOIN magic_link_tokens ON magic_link_tokens.user_id=user_emails.user_id
AND magic_link_tokens.purpose='login' AND magic_link_tokens.consumed_at IS NULL
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)).get(email);
let purpose: 'login' | 'verify_email' = 'login';
if (!identity?.user_id) {
identity = await (await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)
).get(email);
let purpose: 'login' | 'verify_email' = 'login';
if (!identity?.user_id) {
identity = await (
await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
WHERE email=? COLLATE NOCASE AND purpose='verify_email' AND consumed_at IS NULL AND expires_at>?
ORDER BY created_at DESC LIMIT 1`)).get(email, new Date().toISOString());
purpose = 'verify_email';
}
if (!identity?.user_id) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
magicLinkError: 'New account registration is currently closed. Enter an email already linked to an agent account.',
email
});
}
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
identity = { user_id: id, created_at: null };
purpose = 'verify_email';
}
const recent = identity?.created_at && Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
if (identity?.user_id && !recent) {
const token = await createMagicLinkToken(database, String(identity.user_id), email, purpose);
await sendAuthenticationLink(email, token, purpose);
}
return { magicLinkSent: true };
},
register: async ({ request, cookies, getClientAddress }) => {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
registerError: 'New account registration is currently closed.',
registerDisplayName: '',
registerUsername: ''
});
}
const form = await request.formData();
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
const password = String(form.get('password') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
const displayLength = Array.from(displayName).length;
const values = { registerDisplayName: displayName, registerUsername: username };
if (displayLength < 2 || displayLength > 40 || /[\p{C}\r\n]/u.test(displayName)) {
return fail(400, { registerError: 'Use a display name with 2–40 visible characters.', ...values });
}
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) {
return fail(400, { registerError: 'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.', ...values });
}
if (['admin', 'administrator', 'moderator', 'shd', 'support'].includes(username.toLowerCase())) {
return fail(400, { registerError: 'That login username is reserved.', ...values });
}
if (password.length < 12 || password.length > 128) {
return fail(400, { registerError: 'Use a password between 12 and 128 characters.', ...values });
}
if (password !== confirm) return fail(400, { registerError: 'The passwords do not match.', ...values });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
return fail(409, { registerError: 'That login username is already in use.', ...values });
}
if (!(await displayNameIsAvailable(database, displayName))) {
return fail(409, { registerError: 'That display name is already in use.', ...values });
}
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
await insertUserProfile(database, id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
const sessionId = await createSession(cookies, id);
await recordLogin(id, 'password', true, { request, getClientAddress, sessionId });
redirect(303, '/account');
},
login: async ({ request, cookies, getClientAddress }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username });
const database = await getDatabase();
const user = await (await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)).get(username);
const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password);
if (!valid) {
await recordLogin(user?.id ? String(user.id) : null, 'password', false, { request, getClientAddress, failureReason: 'invalid_credentials' });
return fail(400, { loginError: 'Invalid username or password.', username });
}
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
if (user.blocked_at || banned) {
await recordLogin(String(user.id), 'password', false, { request, getClientAddress, failureReason: 'account_restricted' });
return fail(403, { loginError: 'This account is not permitted to sign in.', username });
}
await ensureBootstrapAdmin(database, String(user.id));
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'password', true, { request, getClientAddress, sessionId });
redirect(303, '/account');
},
logout: async ({ cookies }) => {
await destroySession(cookies);
redirect(303, '/');
}
ORDER BY created_at DESC LIMIT 1`)
).get(email, new Date().toISOString());
purpose = 'verify_email';
}
if (!identity?.user_id) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
magicLinkError:
'New account registration is currently closed. Enter an email already linked to an agent account.',
email,
});
}
const id = randomUUID(),
now = new Date().toISOString();
await (
await database.prepare(
'INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)',
)
).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
await insertUserProfile(database, id, 'Agent', now, {
placeholder: true,
});
await triggerAutomatedSystemMessages({
eventKey: 'account.created',
userId: id,
eventId: id,
}).catch((error) =>
console.error('Unable to deliver new-agent welcome message', error),
);
identity = { user_id: id, created_at: null };
purpose = 'verify_email';
}
const recent =
identity?.created_at &&
Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
if (identity?.user_id && !recent) {
const token = await createMagicLinkToken(
database,
String(identity.user_id),
email,
purpose,
);
await sendAuthenticationLink(email, token, purpose);
}
return { magicLinkSent: true };
},
register: async ({ request, cookies, getClientAddress }) => {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
registerError: 'New account registration is currently closed.',
registerDisplayName: '',
registerUsername: '',
});
}
const form = await request.formData();
const displayName = String(form.get('displayName') ?? '')
.normalize('NFKC')
.trim();
const username = String(form.get('username') ?? '')
.normalize('NFKC')
.trim();
const password = String(form.get('password') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
const displayLength = Array.from(displayName).length;
const values = {
registerDisplayName: displayName,
registerUsername: username,
};
if (
displayLength < 2 ||
displayLength > 40 ||
/[\p{C}\r\n]/u.test(displayName)
) {
return fail(400, {
registerError: 'Use a display name with 2–40 visible characters.',
...values,
});
}
if (
username.length < 3 ||
username.length > 32 ||
!/^[A-Za-z0-9_-]+$/.test(username)
) {
return fail(400, {
registerError:
'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.',
...values,
});
}
if (
['admin', 'administrator', 'moderator', 'shd', 'support'].includes(
username.toLowerCase(),
)
) {
return fail(400, {
registerError: 'That login username is reserved.',
...values,
});
}
if (password.length < 12 || password.length > 128) {
return fail(400, {
registerError: 'Use a password between 12 and 128 characters.',
...values,
});
}
if (password !== confirm)
return fail(400, {
registerError: 'The passwords do not match.',
...values,
});
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM users WHERE login_username=? COLLATE NOCASE',
)
).get(username)
) {
return fail(409, {
registerError: 'That login username is already in use.',
...values,
});
}
if (!(await displayNameIsAvailable(database, displayName))) {
return fail(409, {
registerError: 'That display name is already in use.',
...values,
});
}
const id = randomUUID(),
now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
VALUES(?,?,?,?,?,?)`)
).run(
id,
`agent_${id.replaceAll('-', '').slice(0, 16)}`,
username,
await hashPassword(password),
'user',
now,
);
await insertUserProfile(database, id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({
eventKey: 'account.created',
userId: id,
eventId: id,
}).catch((error) =>
console.error('Unable to deliver new-agent welcome message', error),
);
const sessionId = await createSession(cookies, id);
await recordLogin(id, 'password', true, {
request,
getClientAddress,
sessionId,
});
redirect(303, '/account');
},
login: async ({ request, cookies, getClientAddress }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
if (!username || !password)
return fail(400, {
loginError: 'Enter your username and password.',
username,
});
const database = await getDatabase();
const user = await (
await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)
).get(username);
const valid =
typeof user?.password_hash === 'string' &&
(await verifyPassword(user.password_hash, password));
if (!valid) {
await recordLogin(user?.id ? String(user.id) : null, 'password', false, {
request,
getClientAddress,
failureReason: 'invalid_credentials',
});
return fail(400, {
loginError: 'Invalid username or password.',
username,
});
}
const banned =
typeof user.banned_until === 'string' &&
new Date(user.banned_until) > new Date();
if (user.blocked_at || banned) {
await recordLogin(String(user.id), 'password', false, {
request,
getClientAddress,
failureReason: 'account_restricted',
});
return fail(403, {
loginError: 'This account is not permitted to sign in.',
username,
});
}
await ensureBootstrapAdmin(database, String(user.id));
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'password', true, {
request,
getClientAddress,
sessionId,
});
redirect(303, '/account');
},
logout: async ({ cookies }) => {
await destroySession(cookies);
redirect(303, '/');
},
};
+21 -4
View File
@@ -149,8 +149,8 @@
providers process information under their own policies.
</p>
<p>
When automated content screening is enabled, SHD.build sends the submitted text
or custom avatar image to OpenAI’s API for safety classification. SHD.build does
When automated content screening is enabled, SHD.build sends submitted text,
Guide images, or custom avatar images to OpenAI’s API for safety classification. SHD.build does
not include your password, authentication tokens, or private sign-in credentials
in that moderation request. We retain the resulting category scores, flags,
request reference, content hash, local-rule matches, and review outcome as needed
@@ -180,8 +180,8 @@
<div>
<h2>ESSENTIAL COOKIES</h2>
<p>
SHD.build currently uses only first-party cookies that are strictly necessary for
authentication and sign-in protection. We do not currently use analytics,
SHD.build currently uses only first-party cookies needed for authentication,
sign-in protection, and anonymous Guide ratings or reports. We do not use analytics,
advertising, behavioral-tracking, or personalization cookies. Because these cookies
are essential, SHD.build does not offer a preference control for disabling them.
You may block cookies through your browser, but account sign-in will not function
@@ -198,6 +198,16 @@
</tr>
</thead>
<tbody>
<tr>
<td><code>shd_visitor</code></td>
<td>
Provides a random pseudonymous browser identity for Guide ratings, duplicate
report suppression, and abuse limits. The database stores only a
domain-separated SHA-256 hash of the cookie value.
</td>
<td>Up to one year.</td>
<td>HTTP-only, SameSite=Lax, Secure in production.</td>
</tr>
<tr>
<td><code>shd_session</code></td>
<td>
@@ -222,6 +232,13 @@
</tbody>
</table>
</div>
<p>
After repeated anonymous rating or reporting activity, SHD.build may display a
Cloudflare Turnstile challenge. The server sends the challenge token and request
address to Cloudflare for verification. Cloudflare processes that information
under its own terms and privacy policy. SHD.build does not store the raw address
as the rating or report identity.
</p>
<p>
Your browser stores dismissal of the informational notice in local storage under
<code>shd-essential-cookie-notice</code>. Your browser does not send this value with