diff --git a/.env.development b/.env.development index 603f1de..4bc396e 100644 --- a/.env.development +++ b/.env.development @@ -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= diff --git a/.env.example b/.env.example index 7572cad..64cf91b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.production b/.env.production index 3eb400f..9061750 100644 --- a/.env.production +++ b/.env.production @@ -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== diff --git a/.gitignore b/.gitignore index 1c6e2e7..d0b2806 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ Thumbs.db /data/*.db-* /data/*.turso /data/*.turso-* +/data/backups/ # Vite vite.config.js.timestamp-* diff --git a/AGENTS.md b/AGENTS.md index ba067ff..3af3882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -- ` only with an empty, migrated target +database. The restore script intentionally refuses to merge into a populated +database. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 3f7341b..28091b0 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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`. diff --git a/docs/architecture.md b/docs/architecture.md index fd61109..5382c36 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/.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. diff --git a/package.json b/package.json index 80449ce..65fc538 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-backup.mjs b/scripts/db-backup.mjs new file mode 100644 index 0000000..df37647 --- /dev/null +++ b/scripts/db-backup.mjs @@ -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}`); diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 5314bdb..3595b35 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -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); +} diff --git a/scripts/db-restore.mjs b/scripts/db-restore.mjs new file mode 100644 index 0000000..c29f3b9 --- /dev/null +++ b/scripts/db-restore.mjs @@ -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 -- '); + 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}`); diff --git a/scripts/default-users.mjs b/scripts/default-users.mjs new file mode 100644 index 0000000..af91fad --- /dev/null +++ b/scripts/default-users.mjs @@ -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; +} diff --git a/scripts/seed-initial-users.mjs b/scripts/seed-initial-users.mjs index e481516..db9fef6 100644 --- a/scripts/seed-initial-users.mjs +++ b/scripts/seed-initial-users.mjs @@ -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 }); diff --git a/src/app.css b/src/app.css index c06eab5..45c6c12 100644 --- a/src/app.css +++ b/src/app.css @@ -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} + diff --git a/src/lib/server/auth/bootstrap.ts b/src/lib/server/auth/bootstrap.ts new file mode 100644 index 0000000..8c1ea43 --- /dev/null +++ b/src/lib/server/auth/bootstrap.ts @@ -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 { + 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); +} diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index da38863..79401dc 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -11,7 +11,7 @@ let databasePromise: Promise | undefined; * imported into browser bundles. */ export function getDatabase(): Promise { - 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)); diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 16eeecc..1f3ed20 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -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; + } {user.username} — SHD.build
@@ -19,6 +27,6 @@

SAVED INTELLIGENCE

FAVORITES

{data.account.favorites.length}SAVED LOADOUTS

FIELD FEEDBACK

RATINGS

{data.account.ratings.length}RATED LOADOUTS
- +
diff --git a/src/routes/auth/discord/callback/+server.ts b/src/routes/auth/discord/callback/+server.ts index 72958e2..e2e9e66 100644 --- a/src/routes/auth/discord/callback/+server.ts +++ b/src/routes/auth/discord/callback/+server.ts @@ -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'); }; diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index 77beefd..766e2fb 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -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'); }, diff --git a/src/routes/sign-in/+page.svelte b/src/routes/sign-in/+page.svelte index f4cd3a6..ed760fe 100644 --- a/src/routes/sign-in/+page.svelte +++ b/src/routes/sign-in/+page.svelte @@ -1,3 +1,14 @@ - + Sign in — SHD.build -

SHD IDENTITY NETWORK

AGENT
ACCESS.

Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.

NETWORK ONLINENODE NYC-01
SECURE SIGN-INSHD // AUTH
◉ CONTINUE WITH DISCORD
OR USE LOCAL CREDENTIALS
{#if form?.loginError}

{form.loginError}

{/if}{#if data.oauthError}

{data.oauthError}

{/if}
Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.
+

SHD IDENTITY NETWORK

AGENT
ACCESS.

Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.

NETWORK ONLINENODE NYC-01
SECURE SIGN-INSHD // AUTH
Continue with Discord
OR USE LOCAL CREDENTIALS
{#if form?.loginError}

{form.loginError}

{/if}{#if data.oauthError}

{data.oauthError}

{/if}
Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.
diff --git a/static/discord-symbol.svg b/static/discord-symbol.svg new file mode 100644 index 0000000..65b8851 --- /dev/null +++ b/static/discord-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/vite.config.ts b/vite.config.ts index cb76b81..172b314 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {