Add auth bootstrap and database safeguards

This commit is contained in:
JB
2026-07-24 22:12:52 -04:00
parent 8a0bfd2187
commit b430a291fa
22 changed files with 380 additions and 62 deletions
+4 -4
View File
@@ -5,7 +5,7 @@
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
# .env.development
TDR_DATABASE_PATH=encrypted:BFvKNEzpR2G6g6J33jBZHEVLChTcT292ahKJfJW3a/GKTD1I7QQzp+S+KMFYGVwWnRoPjBezfddAPQwSPWb4ifWGAw2DHSoasqNam2ZbrE0fwcibsqkX/HYJ7EQ/4vM/vt6uy9HmJ5N9OwjhU6e0wIT7TyU=
DISCORD_CLIENT_ID=encrypted:BLuEvsF1X2SdGXhfgySXJPvqdGATQGam7pdH2IDImFjKARB32gV3Yf+Uulus2j7FOsN6jVotrTbYTgguEPHKbYbTIcLb0/+Itc6pc+oNvsqcK9NccfakonAEr5VHriIIIqm17w9+xax3whUE9bIj4rpc5h0=
DISCORD_CLIENT_SECRET=encrypted:BI1tDKKWLaXVFWZqJLo2Vk/bl8APJVHzo3ePfreEVbfAx3P0R/oG2SZhHK6gljsTsVRj8z3x4e9GgIfdGDpOVrSe1YLN3AD0+fR5GFHZgzopRrT2695zlGSVN26p/JgNABLAJ9jWITfBdzP6rk9eGet8B46BbOyWE+dyE8HHyQVo
DISCORD_REDIRECT_URI=encrypted:BMDJ6dVfzllmKqzHQXL6s1YtYuN66NbkW2mXKFlcZa4rTsekYPJwc7MeNMRkfKoGBv+YwJGzDBqOM/acSml9DSCa9ZgY2ELkqmU/xhcZAxVCThBpANXRocPQCgwum4fIuz4fjlEI/ucyarqTR56YQrQG1nR0G6F43R8mSVAEA/9W4LqCi/PBewzNqZc=
TDR_DATABASE_PATH=encrypted:BHnVQl0N/qUPZBf0dXKVitJ/gKbQMMxMUqgnVEdFb/fHQ3S7zV2oU2piNv1RrcvaFvTsEsrroxokGbeKPWiGDzFly/HCdVy91MvZ00OU73dilDqYgGYtVSyFwUgU8k8VrpSTdEIrEEzOjoSj7AV5t2KSeKMoE+AlzuM/A1qfskY=
DISCORD_CLIENT_ID=encrypted:BDTGVwdnufaUNB8sW8lO3UO4LYXEuzqaBiFzCPiuImWfKZYIAXQaefJMRLuB6SgtJ12ajb96wqNX0ai1XU/7/XRWMW1AL7hc8lcdRr1ur5WlDSx3duTi1W8fzCApVSrl2FcpkQHl7HnDJHr/hIctoVVjals=
DISCORD_CLIENT_SECRET=encrypted:BN6NoKAqY/C2mn67YxitBKM18L5mQXp/5HFTXdLAc9dPOfmG9Np3+osDIYCe6It4DUS4uOJKyDIDT2AldjP6J8pWO09tPCyEQEhsP8kjG0pr3FYrSPTmNzA/9couRXV0BvpNcr0NOs8evwaggvYqLoXSdF3HECHf33hFNc1RD2Yu
DISCORD_REDIRECT_URI=encrypted:BMV9gZ2xp1toCtpdOZMrI+211v4DPWllGDLskfo4XZGp51wL1TFNnyAhGqMkq52qS32IirsS5YnpYzlFoyzFDe8uOQ7ojVMWyXKTvkCO5yuZdjmNdgtjoDyFxzo9jbBHIz2Xc8l6gf+nmrVRo9W5OXYwSb+EzkicU0y2/GrW/uBR8wwcedH7lG+YPHY=
+10 -4
View File
@@ -1,10 +1,16 @@
# Local path used by the embedded @tursodatabase/database engine.
# Relative paths are resolved from the project directory.
TDR_DATABASE_PATH=./data/shd-build.db
# Relative paths are resolved from the project directory. Development and
# production should always use different database files.
TDR_DATABASE_PATH=./data/shd-build-development.db
# Optional fixed credentials used only when initializing or explicitly resetting
# the two default accounts. Leave blank to generate secure temporary passwords.
DEFAULT_ADMIN_PASSWORD=
DEFAULT_USER_PASSWORD=
# Discord OAuth2 application credentials.
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Local: http://localhost:5173/auth/discord/callback
# Proxied development: https://dev.shd.build/auth/discord/callback
# Production: https://shd.build/auth/discord/callback
DISCORD_REDIRECT_URI=http://localhost:5173/auth/discord/callback
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
+4 -4
View File
@@ -5,7 +5,7 @@
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
# .env.production
TDR_DATABASE_PATH=encrypted:BF6m/a7eY9vr0nQgR/szp3OlQMAww1E3360Ri83R0kmvEhPhEKO3L4YKNySUqUC70wXphwSb8Odtp4Bu4K7vjS7duI1TB15qsoEr1xCMQ/+s9bByg5Fv1wk4NPJpbMM4iBghhORscVd81V6gbOUBiCi7aYQ=
DISCORD_CLIENT_ID=encrypted:BEdmWvxXdwetGw8f2r3k0HS9RbWMDMqqBcuujWhg2x1NISqO+o5b7aoW+AC2BitCSBkuFfIjkzLym1za9qNT3kC4yB0kE1O2MzY7HWIzh64IcJK4qvzmqMMR/T6ykUDiopnAUKG6oqzE2KjPA//FMhKdo7c=
DISCORD_CLIENT_SECRET=encrypted:BJ40+cXThndtnpTFuk4BhS8t4juecVKNSCrZRU+i1sQaut2RAE64P+bPrYPD5U9gbu1HnkIXDrxWZ17UbY/MOrWaURa8Yo6uavbEuMcbnrRO+tfpPyTMNhu50lhRWVcyeZ92Jev4PUuMpUA5mYCLEkvubexMN5AaJ94E1Y1n5NNV
DISCORD_REDIRECT_URI=encrypted:BIxQ5vrLcJnAYQ8Lfazqv1gnFNtyzEgI6JFqDWXevPJBGItO8JN0DJv4qYGUQkiKOAmreGHO8WykzWp4nicwDO2FSXsEw+oe2SaqhR3ko6NMp6fcp5ORhdq/5c2NpbKsvd1rME6UQD8ILb+TDFQe06JnI5n9cSTmqiFOQA21oBY1d8i6eo/zOg==
TDR_DATABASE_PATH=encrypted:BHL3JoS/+pSKq2PFYS/Aa15Xs1vpME0TImVOwk0KG073YlcqUb/i5G93og4hwDoufEmgWpcNE0ORugnLoP4zHMD6rYBtMaXtQG4Whm2CFyyL37h1Fb4boPIzyYQElklc9P6qaikq0Z/K4KWyGGvqMWpqMczjkwXTXCoDvFwrqw==
DISCORD_CLIENT_ID=encrypted:BEkEOyNIAuYmVcXOSruVANVdyBf3fUeyx4va4misGo7iQHWlGFfcjkV/PGfJ41awy46LwcawZuMqCxqPHH52I+qE7FRGu9kpRNqWwB7sEDTNjFfFvaibF6GfTgcjHg7ddIx6VSq4tDGq8JKCbyj2Ib0FixE=
DISCORD_CLIENT_SECRET=encrypted:BHlBoY4z2wCRPZXbD4hMQ5b9vOIeG5hzW2g62CBe8muqeSE9WvMPshhg1UTS8uWsMRw/Ez2eRAKnaGGuBBK+RlkWXiF5g+QeVY0JC2NWcCO3Dr5ZlpYRH5Ql2wENWCGzv6HePhGg40mgvmRv568M/Tm7DlEMhomDRBKUy2BEFELF
DISCORD_REDIRECT_URI=encrypted:BAjTP4WFTGf+aFONbsSHaWY+UR6NyfLfKM2br3UQ7ek/yYxblpIpv+DBld/6dhSVYzR+2YG7hNgl87jhNABRg5HLXaFU5gwBojZ6lf275bfMLlsr53l03DDAPYyRl9e9fr11fZOoZSE3+gf9mtvm/bPnfBNzvQf3bNMW3MUmzHREWENbCWsucg==
+1
View File
@@ -27,6 +27,7 @@ Thumbs.db
/data/*.db-*
/data/*.turso
/data/*.turso-*
/data/backups/
# Vite
vite.config.js.timestamp-*
+26
View File
@@ -33,3 +33,29 @@ Minor alignment fixes, isolated bug fixes, and corrections that do not introduce
or revise a reusable design decision do not require a documentation update.
If uncertain whether a visual change is significant, update the design system.
## Database preservation
Development data is valuable and may be entered by non-technical contributors.
Treat embedded database files as persistent user data, not disposable build
artifacts.
Before deleting, replacing, recreating, renaming, or destructively migrating a
database—and before any major schema change—create a timestamped JSON backup
with `npm run db:backup`.
Database work must follow these rules:
1. Confirm which environment and exact database path are in scope.
2. Stop processes that hold the embedded database lock before backup or restore.
3. Create and verify a JSON backup before destructive work.
4. Never overwrite or delete the only available backup.
5. Keep backups out of Git; they can contain user records and password hashes.
6. Prefer additive, repeatable schema migrations over database recreation.
7. After a restore, run the schema migration and verify record counts and
critical entities before resuming data entry.
8. Report the backup path when completing database-destructive work.
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
database. The restore script intentionally refuses to merge into a populated
database.
+18
View File
@@ -9,6 +9,9 @@ This document defines the visual language for SHD.build. New pages should feel l
3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas.
4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators.
5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment.
6. **Links use interface feedback, not underlines.** Links do not use native text
underlines. Communicate hover and focus through SHD accent color, borders,
brightness, and the required focus-visible outline.
## Tokens
@@ -62,6 +65,21 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
- On small screens, the split frame becomes a single column and account controls
move before archival content.
### Third-party identity buttons
- Follow the identity provider's current official brand and sign-in guidelines.
- Use official, unmodified assets downloaded from the provider's brand resources.
- Discord sign-in uses the official white Discord symbol on Blurple `#5865F2`
with the explicit label “Continue with Discord.”
- Discord account connection controls use the same official symbol and Blurple
treatment with the explicit label “Link Discord.”
- Preserve the provider's required clearspace, logo proportions, approved color,
readable contrast, and text/logo alignment even when the surrounding surface
follows the SHD theme.
- Provider buttons still require SHD.build's focus-visible, pressed, pending, and
duplicate-interaction protection where those states do not conflict with the
provider's branding requirements.
### Admin dashboard
- Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`.
+38
View File
@@ -45,3 +45,41 @@ persistent volume is attached.
5. Publishing, discovery, ratings, comments, favorites, and copy flow.
6. Rich build guides and moderation.
7. Admin editors, validation, audit log, and data import/export.
# Administrator bootstrap
The first successfully authenticated user is promoted to `admin` if the
database does not contain an administrator. Promotion uses one conditional
database update so concurrent first sign-ins cannot both become administrators.
Once an administrator exists, later sign-ins never alter roles.
# Database backup and restore
Embedded Turso databases contain contributor-entered source data and must be
preserved across development changes.
Create a timestamped development backup:
```sh
npm run db:backup
```
Create a production backup:
```sh
npm run db:backup:production
```
The scripts write versioned JSON documents beneath `data/backups/` by default.
That directory is ignored by Git because backups contain private user data,
including password hashes.
To restore, first create an empty database and apply the schema. Then run:
```sh
npm run db:restore -- data/backups/<backup-file>.json
```
The restore operation refuses to import into any database containing records.
This guard prevents accidental merging or overwriting of contributor data.
Stop the application before backup or restore so the embedded database file is
not locked by another process.
+7 -1
View File
@@ -4,11 +4,17 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "dotenvx run -f .env.development -- vite dev",
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
"build": "dotenvx run -f .env.production -- vite build",
"preview": "vite preview",
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
"env:decrypt": "node scripts/env-files.mjs decrypt",
"env:encrypt:development": "dotenvx encrypt -f .env.development",
+50
View File
@@ -0,0 +1,50 @@
import { connect } from '@tursodatabase/database';
import { mkdir, writeFile } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import process from 'node:process';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const requestedOutput = process.argv[2];
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
const outputPath = resolve(
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`
);
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
await mkdir(dirname(outputPath), { recursive: true });
const database = await connect(databasePath);
const tableRows = await (
await database.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY rowid
`)
).all();
const tables = [];
let totalRows = 0;
for (const tableRow of tableRows) {
const name = String(tableRow.name);
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
}
const backup = {
format: 'shd-build-database-backup',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables
};
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx'
});
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
console.log(`Backup: ${outputPath}`);
+13 -1
View File
@@ -1,10 +1,17 @@
import { connect } from '@tursodatabase/database';
import { mkdir, readFile } from 'node:fs/promises';
import { access, mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true;
try {
await access(databasePath);
} catch {
databaseAlreadyExisted = false;
}
await mkdir(dirname(databasePath), { recursive: true });
@@ -22,3 +29,8 @@ for (const statement of statements) {
}
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) {
console.log('New database detected; creating default users.');
await initializeDefaultUsers(database);
}
+58
View File
@@ -0,0 +1,58 @@
import { connect } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import process from 'node:process';
const backupArgument = process.argv[2];
if (!backupArgument) {
console.error('Usage: npm run db:restore -- <backup-file>');
process.exit(2);
}
const backupPath = resolve(backupArgument);
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
throw new Error('The selected file is not a supported SHD.build database backup.');
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
const database = await connect(databasePath);
// Restores are deliberately limited to empty databases. This prevents an
// accidental merge from overwriting contributor-entered records.
for (const table of backup.tables) {
const existing = await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
).get();
if (Number(existing?.count) > 0) {
throw new Error(
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
);
}
}
let restoredRows = 0;
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const columnSql = columns.map(quoteIdentifier).join(', ');
const placeholders = columns.map(() => '?').join(', ');
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
console.log(`Source backup: ${backupPath}`);
+92
View File
@@ -0,0 +1,92 @@
import { argon2id, hash } from 'argon2';
import { randomBytes, randomUUID } from 'node:crypto';
export const defaultUsers = [
{
username: 'Aewyn',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
},
{
username: 'Quartermaster',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
}
];
function temporaryPassword() {
// 120 bits of entropy encoded without punctuation that is awkward to paste.
return randomBytes(15).toString('base64url');
}
async function secureHash(password) {
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
}
/**
* Creates missing default users. With resetPasswords enabled, existing default
* users keep their IDs and related records but receive new password hashes.
*/
export async function initializeDefaultUsers(database, options = {}) {
const resetPasswords = options.resetPasswords === true;
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
const updateCredential = await database.prepare(
'UPDATE users SET password_hash = ?, role = ? WHERE id = ?'
);
const credentials = [];
for (const definition of defaultUsers) {
const existing = await findUser.get(definition.username);
if (existing && !resetPasswords) {
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
continue;
}
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
const password = configuredPassword || temporaryPassword();
if (password.length < 12 || password.length > 128) {
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
}
const passwordHash = await secureHash(password);
if (existing) {
await updateCredential.run(passwordHash, String(existing.role), existing.id);
console.log(`${definition.username}: credentials reset; role and related data preserved`);
} else {
await insertUser.run(
randomUUID(),
definition.username,
passwordHash,
definition.role,
new Date().toISOString()
);
console.log(`${definition.username}: default account created`);
}
credentials.push({
username: definition.username,
role: existing ? String(existing.role) : definition.role,
password,
generated: !configuredPassword
});
}
if (credentials.length) {
console.log('\nDefault user credentials (shown once):');
for (const credential of credentials) {
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
}
console.log('Change generated passwords after the first sign-in.\n');
}
return credentials;
}
+4 -42
View File
@@ -1,48 +1,10 @@
import { connect } from '@tursodatabase/database';
import { argon2id, hash } from 'argon2';
import { randomBytes, randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const database = await connect(databasePath);
const resetPasswords = process.argv.includes('--reset-passwords');
const users = [
{ username: 'Aewyn', role: 'admin' },
{ username: 'Quartermaster', role: 'user' }
];
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
for (const user of users) {
const existing = await findUser.get(user.username);
if (existing) {
console.log(`${user.username}: already exists (${existing.role}); left unchanged`);
continue;
}
// 120 bits of entropy encoded without punctuation that is awkward to paste.
const temporaryPassword = randomBytes(15).toString('base64url');
const passwordHash = await hash(temporaryPassword, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
await insertUser.run(
randomUUID(),
user.username,
passwordHash,
user.role,
new Date().toISOString()
);
console.log(`${user.username} (${user.role}) temporary password: ${temporaryPassword}`);
}
await initializeDefaultUsers(database, { resetPasswords });
+4 -2
View File
@@ -22,7 +22,7 @@
--shd-header-height:76px;
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none}
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);background-size:48px 48px}
header{height:76px;border-bottom:1px solid #292d2f;display:flex;align-items:center;padding:0 max(24px,5vw);position:relative;z-index:5;background:rgba(10,12,13,.93)}
.brand{display:flex;align-items:center;gap:12px;color:#fff;text-decoration:none;font-weight:900;letter-spacing:1.4px}.brand-mark{display:grid;place-items:center;width:36px;height:36px;border:2px solid #f6a821;border-radius:50%;color:#f6a821}.accent{color:#f6a821}
@@ -56,7 +56,9 @@
.site-header .brand-mark{position:relative}.site-header .brand-mark i{position:absolute;inset:3px;border:1px solid #f6a82155;border-radius:50%}.site-header .brand-mark b{position:relative;font-size:8px}.brand-name{display:grid;font-size:14px;line-height:1}.brand-name>span{display:inline;color:#f6a821}.brand-name small{font-size:5px;color:#555d5f;letter-spacing:1.3px;margin-top:4px}.site-header .account small i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success);margin-right:3px}
.catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 Georgia,serif}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:7px;color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:7px;color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:9px}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:8px;letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 Georgia,serif}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:7px;letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:9px;margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:8px}.guide-index span{font-size:9px;letter-spacing:.8px}.guide-index b{color:#f6a821}
.profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:grid;place-items:center;border:1px solid #f6a821;border-radius:50%;color:#f6a821;font-size:29px;font-weight:900;box-shadow:inset 0 0 0 6px #0a0c0d,inset 0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:7px;letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:7px;color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:10px}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:9px}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:7px;letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:7px;color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:8px}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:10px;letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px Georgia,serif}.profile-empty a{color:#f6a821;font-size:8px;font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:7px;color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:7px;letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:9px}.connection-card small{font-size:7px;color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:8px;font-weight:bold}
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{margin-top:18px}.auth-console small{margin-top:18px}
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px}
.builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821}
@media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}}
.discord-connection{display:grid;grid-template-columns:1fr;gap:9px;padding:10px}.connection-copy{display:flex!important;align-items:center;gap:10px}.connection-copy>img{width:28px;height:21px;padding:4px;background:#5865f2;border-radius:3px}.discord-link-button{min-height:42px;padding:8px 14px;display:flex;align-items:center;justify-content:center;gap:12px;background:#5865f2;color:#fff!important;border-radius:4px;font-size:14px!important;font-weight:600!important;letter-spacing:0;text-transform:none}.discord-link-button img{display:block;width:25px;height:19px;object-fit:contain}.discord-link-button:hover{background:#4752c4;filter:none}.discord-link-button:active{background:#3c45a5;filter:none}.discord-link-button:focus-visible{outline:2px solid #e0e3ff;outline-offset:3px}.discord-link-button[aria-busy="true"]::after{content:"";width:11px;height:11px;border:2px solid #fff;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}
+17
View File
@@ -0,0 +1,17 @@
import type { Database } from '@tursodatabase/database';
/**
* Atomically promotes the authenticated user when no administrator exists.
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
*/
export async function ensureBootstrapAdmin(database: Database, userId: string): Promise<void> {
const promote = await database.prepare(`
UPDATE users
SET role = 'admin'
WHERE id = ?
AND NOT EXISTS (
SELECT 1 FROM users WHERE role = 'admin'
)
`);
await promote.run(userId);
}
+1 -1
View File
@@ -11,7 +11,7 @@ let databasePromise: Promise<Database> | undefined;
* imported into browser bundles.
*/
export function getDatabase(): Promise<Database> {
const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build.db';
const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build-development.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
+9 -1
View File
@@ -2,6 +2,14 @@
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
function beginDiscordLink(event: MouseEvent) {
if (discordLinkPending) {
event.preventDefault();
return;
}
discordLinkPending = true;
}
</script>
<svelte:head><title>{user.username} — SHD.build</title></svelte:head>
<main class="page profile-page">
@@ -19,6 +27,6 @@
</div>
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
</section>
<aside class="account-console"><div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div><p class="eyebrow">PUBLIC IDENTITY</p><h2>USERNAME</h2><PendingForm action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<button class="primary console-action">UPDATE IDENTITY →</button></PendingForm><hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2><div class="connection-card"><span class="discord-node">◉</span><div><b>DISCORD</b><small>OAuth identity provider</small></div><a href="/auth/discord?mode=link">LINK →</a></div><hr/><p class="eyebrow">SECURITY</p><h2>CHANGE PASSWORD</h2><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm></aside>
<aside class="account-console"><div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div><p class="eyebrow">PUBLIC IDENTITY</p><h2>USERNAME</h2><PendingForm action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<button class="primary console-action">UPDATE IDENTITY →</button></PendingForm><hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2><div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div><hr/><p class="eyebrow">SECURITY</p><h2>CHANGE PASSWORD</h2><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm></aside>
</div>
</main>
@@ -1,6 +1,7 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { createSession, getSessionUser } from '$lib/server/auth/session';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
@@ -118,6 +119,7 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
created = true;
}
await ensureBootstrapAdmin(database, String(user.id));
await createSession(cookies, String(user.id));
redirect(303, created ? '/account?choose_username=1' : '/account');
};
+2
View File
@@ -1,6 +1,7 @@
import { createSession, destroySession } from '$lib/server/auth/session';
import { verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
@@ -14,6 +15,7 @@ export const actions: Actions = {
const user = await (await database.prepare('SELECT id, password_hash FROM users WHERE username = ? COLLATE NOCASE')).get(username);
const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password);
if (!valid) return fail(400, { loginError: 'Invalid username or password.', username });
await ensureBootstrapAdmin(database, String(user.id));
await createSession(cookies, String(user.id));
redirect(303, '/account');
},
+13 -2
View File
@@ -1,3 +1,14 @@
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte'; let { data, form } = $props();</script>
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
let discordPending = $state(false);
function beginDiscordSignIn(event: MouseEvent) {
if (discordPending) {
event.preventDefault();
return;
}
discordPending = true;
}
</script>
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div><a class="discord" href="/auth/discord">◉ CONTINUE WITH DISCORD</a><div class="divider"><span>OR USE LOCAL CREDENTIALS</span></div><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}<button class="primary full">AUTHENTICATE →</button></PendingForm><small>Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.</small></section></div></main>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div><a class="discord" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Continue with Discord</span></a><div class="divider"><span>OR USE LOCAL CREDENTIALS</span></div><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}<button class="primary full">AUTHENTICATE →</button></PendingForm><small>Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.</small></section></div></main>
+3
View File
@@ -0,0 +1,3 @@
<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -3,6 +3,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '0.0.0.0',
allowedHosts: ['dev.shd.build', 'shd.build']
},
plugins: [
sveltekit({
compilerOptions: {