Phase 0 scaffold: SvelteKit 5 + Drizzle + auth + storage interface

Stack matches sibling buildfor_life_* apps: SvelteKit 5 with adapter-node,
Svelte 5 runes, TypeScript, Tailwind v4 with @theme inline tokens,
PostgreSQL via Drizzle ORM, Argon2id sessions via @node-rs/argon2 and
@oslojs/crypto, EasyMDE ready for wiki/decision markdown, Sharp for
thumbnails.

Included in this commit:
- Config: package.json, svelte.config.js, vite.config.ts, tsconfig.json,
  drizzle.config.ts, .gitignore, .env.example, .gitattributes, .npmrc
- Tenancy schema: companies, users, company_users, sessions
  (10 enums pre-declared for the full domain so downstream migrations
   don't re-diff them; decision_scope widened to include asset +
   work_package per product decision)
- Auth: password hashing + SHA-256-hashed session cookies,
  session lifetime 30d with sliding renewal at T-15d,
  login + logout + session refresh in hooks
- Storage: StorageAdapter interface + LocalDiskStorage with HMAC-signed
  URLs served by /api/files, S3 drop-in with zero schema change
- UI shell: dark-mode bootstrap in app.html identical to siblings,
  sidebar (w-64, h-14 header, amber attention band pattern from repair),
  topbar with breadcrumbs, theme toggle with cross-tab sync via
  storage event, blue-600 primary, responsive drawer
- Routes: (app) authed group with auto-redirect to /login,
  (auth) login group, dashboard placeholder, error page, signed-file API
- Scripts: create-user script for bootstrapping first admin user
- Drizzle: initial migration generated (0000_init.sql)
- Shared agents and skills committed under .claude/; per-user
  permissions gitignored

Typecheck: 0 errors / 0 warnings across 555 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 15:38:14 +07:00
commit 0a3aaa5798
120 changed files with 19771 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import 'dotenv/config';
import { eq } from 'drizzle-orm';
import { pool } from '../src/lib/server/db/client';
import { db } from '../src/lib/server/db/client';
import { users, companies, companyUsers } from '../src/lib/server/db/schema/tenancy';
import { hashPassword } from '../src/lib/server/auth/password';
import { normalizeEmail } from '../src/lib/utils/email';
function readArg(flag: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : fallback;
}
async function main() {
const email = readArg('--email');
const password = readArg('--password');
const name = readArg('--name');
const companyName = readArg('--company');
const role = (readArg('--role', 'admin') ?? 'admin') as 'admin' | 'manager' | 'user' | 'viewer';
if (!email || !password || !name) {
console.error('Usage: npm run create-user -- --email <e> --password <p> --name <n> [--company <c>] [--role admin|manager|user|viewer]');
process.exit(1);
}
const normalized = normalizeEmail(email);
const hash = await hashPassword(password);
const [existing] = await db.select().from(users).where(eq(users.emailNormalized, normalized)).limit(1);
let userId: string;
if (existing) {
console.log(`User ${normalized} already exists; updating password.`);
await db.update(users).set({ passwordHash: hash, displayName: name }).where(eq(users.id, existing.id));
userId = existing.id;
} else {
const [created] = await db
.insert(users)
.values({ email, emailNormalized: normalized, displayName: name, passwordHash: hash })
.returning({ id: users.id });
userId = created.id;
console.log(`Created user ${normalized} (id ${userId}).`);
}
if (companyName) {
let [company] = await db.select().from(companies).where(eq(companies.name, companyName)).limit(1);
if (!company) {
const slug = companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const [created] = await db
.insert(companies)
.values({ name: companyName, slug })
.returning();
company = created;
console.log(`Created company ${companyName} (id ${company.id}).`);
}
const [link] = await db
.select()
.from(companyUsers)
.where(eq(companyUsers.userId, userId))
.limit(1);
if (!link || link.companyId !== company.id) {
await db
.insert(companyUsers)
.values({ companyId: company.id, userId, role })
.onConflictDoNothing();
console.log(`Linked user to company ${company.name} as ${role}.`);
}
}
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});