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
+38
View File
@@ -0,0 +1,38 @@
import { redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
import type { LayoutServerLoad } from './$types';
import type { SessionCompany } from '$lib/server/auth/types';
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) {
const target = url.pathname + url.search;
throw redirect(303, `/login?next=${encodeURIComponent(target)}`);
}
// Load the user's companies so the sidebar switcher can render.
const rows = await db
.select({
id: companies.id,
name: companies.name,
slug: companies.slug,
role: companyUsers.role
})
.from(companyUsers)
.innerJoin(companies, eq(companies.id, companyUsers.companyId))
.where(eq(companyUsers.userId, locals.user.id));
const userCompanies: SessionCompany[] = rows.map((r) => ({
id: r.id,
name: r.name,
slug: r.slug,
role: r.role
}));
return {
user: locals.user,
company: locals.company,
companies: userCompanies
};
};
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import TopBar from '$lib/components/TopBar.svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
let sidebarOpen = $state(false);
</script>
<div class="flex min-h-screen">
<Sidebar
user={data.user}
company={data.company}
companies={data.companies}
open={sidebarOpen}
onclose={() => (sidebarOpen = false)}
/>
<div class="flex min-w-0 flex-1 flex-col">
<TopBar onmenu={() => (sidebarOpen = true)} />
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 lg:px-6">
{@render children()}
</div>
</main>
</div>
</div>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Hi, {data.user.displayName.split(' ')[0]}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{#if data.company}
Active company: <span class="font-medium">{data.company.name}</span>
{:else}
No active company — pick one from the sidebar.
{/if}
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Overdue maintenance
</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100"></p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once maintenance schedules exist.</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
My open tasks
</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100"></p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once projects/tasks exist.</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Recent decisions
</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100"></p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once decisions are logged.</p>
</div>
</div>
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 0 complete.</p>
<p class="mt-1">Auth, layout shell, storage interface, and the tenancy schema are wired. The remaining schema modules (projects, properties, assets, maintenance, checklists, decisions, documents, wiki, audit) land in Phase 1.</p>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-10 dark:bg-gray-900">
<div class="w-full max-w-sm">
<div class="mb-6 flex items-center justify-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<span class="inline-flex h-7 w-7 items-center justify-center rounded bg-primary-600 text-white text-xs font-bold">B</span>
buildfor_life · ops
</div>
{@render children()}
</div>
</div>
+74
View File
@@ -0,0 +1,74 @@
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '$lib/server/db/client';
import { users } from '$lib/server/db/schema/tenancy';
import { verifyPassword } from '$lib/server/auth/password';
import {
SESSION_COOKIE,
SESSION_COOKIE_MAX_AGE,
createSession,
generateSessionToken
} from '$lib/server/auth/session';
import { normalizeEmail } from '$lib/utils/email';
import type { Actions, PageServerLoad } from './$types';
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1).max(256),
next: z.string().optional()
});
export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.user) {
const next = url.searchParams.get('next') ?? '/';
throw redirect(303, next);
}
return { next: url.searchParams.get('next') ?? '/' };
};
export const actions: Actions = {
default: async ({ request, cookies, getClientAddress, url }) => {
const form = await request.formData();
const parsed = LoginSchema.safeParse({
email: form.get('email'),
password: form.get('password'),
next: form.get('next') ?? undefined
});
if (!parsed.success) {
return fail(400, { error: 'Invalid email or password format.' });
}
const emailNormalized = normalizeEmail(parsed.data.email);
const [user] = await db
.select()
.from(users)
.where(eq(users.emailNormalized, emailNormalized))
.limit(1);
// Uniform error for unknown user vs. bad password to prevent enumeration.
if (!user || !user.passwordHash || !user.isActive) {
return fail(400, { error: 'Invalid credentials.' });
}
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
if (!ok) return fail(400, { error: 'Invalid credentials.' });
const token = generateSessionToken();
await createSession(token, user.id, {
ip: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !url.hostname.includes('localhost'),
maxAge: SESSION_COOKIE_MAX_AGE
});
const next = parsed.data.next && parsed.data.next.startsWith('/') ? parsed.data.next : '/';
throw redirect(303, next);
}
};
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<form
method="post"
use:enhance
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Log in</h1>
<input type="hidden" name="next" value={data.next} />
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</span>
<input
type="email"
name="email"
required
autocomplete="email"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Password</span>
<input
type="password"
name="password"
required
autocomplete="current-password"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</label>
{#if form?.error}
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
{/if}
<button
type="submit"
class="inline-flex w-full justify-center rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Log in
</button>
</form>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="max-w-md rounded-lg border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-sm font-semibold text-gray-400 dark:text-gray-500">Error {page.status}</p>
<h1 class="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{page.error?.message ?? 'Something went wrong.'}
</h1>
<a
href="/"
class="mt-6 inline-flex rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Back to dashboard
</a>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+35
View File
@@ -0,0 +1,35 @@
import { error } from '@sveltejs/kit';
import { getStorage } from '$lib/server/storage';
import { LocalDiskStorage } from '$lib/server/storage/local';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const storage = getStorage();
if (!(storage instanceof LocalDiskStorage)) {
throw error(501, 'Non-local storage adapter must use its own signed URL');
}
let verified: { key: string; disposition: string; filename: string };
try {
verified = storage.verifySignedUrl(url.searchParams);
} catch {
throw error(403, 'Invalid or expired link');
}
const obj = await storage.get(verified.key).catch(() => null);
if (!obj) throw error(404, 'Not found');
const headers = new Headers({
'content-type': obj.contentType,
'content-length': String(obj.sizeBytes)
});
if (verified.filename) {
const dispHeader =
verified.disposition === 'attachment'
? `attachment; filename="${verified.filename.replace(/"/g, '')}"`
: `inline; filename="${verified.filename.replace(/"/g, '')}"`;
headers.set('content-disposition', dispHeader);
}
return new Response(obj.stream as unknown as ReadableStream, { headers });
};
+14
View File
@@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth/session';
import type { RequestHandler } from './$types';
async function handleLogout(event: Parameters<RequestHandler>[0]): Promise<Response> {
if (event.locals.sessionId) {
await invalidateSession(event.locals.sessionId);
}
event.cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/login');
}
export const GET: RequestHandler = handleLogout;
export const POST: RequestHandler = handleLogout;