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
+53
View File
@@ -0,0 +1,53 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-900: #1e3a8a;
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
@layer base {
button:not(:disabled),
[role='button']:not(:disabled),
summary,
label[for],
select:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled),
input[type='button']:not(:disabled),
input[type='checkbox']:not(:disabled),
input[type='radio']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button'][aria-disabled='true'] {
cursor: not-allowed;
}
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { SessionUser, SessionCompany } from '$lib/server/auth/types';
declare global {
namespace App {
interface Locals {
user: SessionUser | null;
company: SessionCompany | null;
sessionId: string | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Error {
code?: string;
}
}
}
export {};
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
import type { Handle } from '@sveltejs/kit';
import {
SESSION_COOKIE,
SESSION_COOKIE_MAX_AGE,
refreshSession,
validateSession
} from '$lib/server/auth/session';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.company = null;
event.locals.sessionId = null;
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const lookup = await validateSession(token);
if (lookup) {
event.locals.user = lookup.user;
event.locals.company = lookup.company;
event.locals.sessionId = lookup.sessionId;
if (lookup.shouldRefresh) {
await refreshSession(lookup.sessionId);
event.cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !event.url.hostname.includes('localhost'),
maxAge: SESSION_COOKIE_MAX_AGE
});
}
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
}
return resolve(event);
};
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { page } from '$app/state';
import ThemeToggle from './ThemeToggle.svelte';
import type { SessionCompany, SessionUser } from '$lib/server/auth/types';
interface Props {
user: SessionUser;
company: SessionCompany | null;
companies: SessionCompany[];
open: boolean;
onclose: () => void;
}
let { user, company, companies, open, onclose }: Props = $props();
interface NavItem {
href: string;
label: string;
icon: 'home' | 'briefcase' | 'building' | 'cube' | 'check' | 'book';
}
const mainNav: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: 'home' },
{ href: '/projects', label: 'Projects', icon: 'briefcase' },
{ href: '/properties', label: 'Properties', icon: 'building' },
{ href: '/assets', label: 'Assets', icon: 'cube' },
{ href: '/checklists', label: 'Checklists', icon: 'check' },
{ href: '/wiki', label: 'Wiki', icon: 'book' }
];
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
</script>
{#if open}
<button
type="button"
aria-label="Close menu"
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
onclick={onclose}
></button>
{/if}
<aside
class="fixed inset-y-0 left-0 z-30 flex w-64 transform flex-col border-r border-gray-200 bg-white transition-transform duration-200 lg:relative lg:translate-x-0 dark:border-gray-700 dark:bg-gray-800 {open
? 'translate-x-0'
: '-translate-x-full'}"
>
<!-- Header -->
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
<a href="/" class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<span class="inline-flex h-6 w-6 items-center justify-center rounded bg-primary-600 text-white text-xs font-bold">B</span>
<span>buildfor_life · ops</span>
</a>
</div>
<!-- Nav -->
<nav class="flex-1 overflow-y-auto px-3 py-4 text-sm">
<ul>
{#each mainNav as item}
<li>
<a
href={item.href}
onclick={onclose}
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 font-medium {isActive(item.href)
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'}"
>
<span aria-hidden="true" class="inline-flex h-4 w-4 items-center justify-center">
{#if item.icon === 'home'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>
{:else if item.icon === 'briefcase'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.16 2.16 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
{:else if item.icon === 'building'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" /></svg>
{:else if item.icon === 'cube'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
{:else if item.icon === 'check'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
{:else if item.icon === 'book'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>
{/if}
</span>
<span>{item.label}</span>
</a>
</li>
{/each}
</ul>
{#if companies.length > 0}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Companies
</div>
<ul>
{#each companies as c}
<li>
<div
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium {company?.id === c.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
>
<span
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-[10px] font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200"
>
{c.name.slice(0, 2).toUpperCase()}
</span>
<span class="flex-1 truncate">{c.name}</span>
<span class="text-xs text-gray-400 capitalize dark:text-gray-500">{c.role}</span>
</div>
</li>
{/each}
</ul>
<!-- TODO: company switcher action is wired in Phase 1 (/switch-company form action). -->
{/if}
</nav>
<!-- Footer: user menu + theme toggle -->
<div class="flex h-14 items-center justify-between border-t border-gray-200 px-3 dark:border-gray-700">
<div class="flex min-w-0 items-center gap-2 text-sm">
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{user.displayName.slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-gray-900 dark:text-gray-100">{user.displayName}</div>
<a href="/logout" data-sveltekit-preload-data="off" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Log out</a>
</div>
</div>
<ThemeToggle />
</div>
</aside>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
let isDark = $state(false);
onMount(() => {
isDark = document.documentElement.classList.contains('dark');
const onStorage = (e: StorageEvent) => {
if (e.key === 'theme') {
isDark = e.newValue === 'dark';
document.documentElement.classList.toggle('dark', isDark);
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
});
function toggle() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
</script>
<button
type="button"
onclick={toggle}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
>
{#if isDark}
<!-- sun -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
{:else}
<!-- moon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
{/if}
</button>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
interface Props {
onmenu: () => void;
}
let { onmenu }: Props = $props();
// Simple breadcrumbs derived from pathname segments.
// Individual routes can override with page data later.
let crumbs = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean);
const out: { label: string; href: string }[] = [];
let acc = '';
for (const p of parts) {
acc += '/' + p;
out.push({ label: p.replace(/-/g, ' '), href: acc });
}
return out;
});
</script>
<header class="flex h-14 items-center gap-3 border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-800">
<button
type="button"
class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 lg:hidden dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
aria-label="Open menu"
onclick={onmenu}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
</button>
<nav aria-label="Breadcrumb" class="flex min-w-0 flex-1 items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
{#if crumbs.length === 0}
<span class="font-medium text-gray-900 dark:text-gray-100">Dashboard</span>
{:else}
<a href="/" class="hover:text-gray-700 dark:hover:text-gray-200">Home</a>
{#each crumbs as c, i}
<span aria-hidden="true" class="text-gray-300 dark:text-gray-600"></span>
{#if i === crumbs.length - 1}
<span class="truncate font-medium text-gray-900 capitalize dark:text-gray-100">{c.label}</span>
{:else}
<a href={c.href} class="capitalize hover:text-gray-700 dark:hover:text-gray-200">{c.label}</a>
{/if}
{/each}
{/if}
</nav>
</header>
+17
View File
@@ -0,0 +1,17 @@
import { hash, verify } from '@node-rs/argon2';
// OWASP-recommended Argon2id parameters (moderate server, 2024 guidance).
// Match sibling buildfor_life_budget exactly so a user can be migrated across apps later.
const ARGON2_OPTS = {
memoryCost: 19_456, // 19 MB
timeCost: 2,
parallelism: 1
} as const;
export function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTS);
}
export function verifyPassword(storedHash: string, password: string): Promise<boolean> {
return verify(storedHash, password);
}
+115
View File
@@ -0,0 +1,115 @@
import { and, eq, gt } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '../db/client';
import { sessions, users, companies, companyUsers } from '../db/schema/tenancy';
import type { SessionCompany, SessionUser } from './types';
const DAY_MS = 86_400_000;
const SESSION_LIFETIME_MS = 30 * DAY_MS;
const SESSION_RENEWAL_THRESHOLD_MS = 15 * DAY_MS;
export function generateSessionToken(): string {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return encodeHexLowerCase(bytes);
}
function hashToken(token: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
}
export async function createSession(
token: string,
userId: string,
meta: { ip?: string; userAgent?: string } = {}
): Promise<void> {
const id = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
await db.insert(sessions).values({
id,
userId,
expiresAt,
ip: meta.ip,
userAgent: meta.userAgent
});
}
export interface SessionLookup {
user: SessionUser;
company: SessionCompany | null;
sessionId: string;
shouldRefresh: boolean;
}
export async function validateSession(token: string): Promise<SessionLookup | null> {
const id = hashToken(token);
const row = await db
.select({
sessionId: sessions.id,
expiresAt: sessions.expiresAt,
activeCompanyId: sessions.activeCompanyId,
userId: users.id,
userEmail: users.email,
userDisplayName: users.displayName,
userIsActive: users.isActive
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date())))
.limit(1);
if (row.length === 0) return null;
const r = row[0];
if (!r.userIsActive) return null;
let company: SessionCompany | null = null;
if (r.activeCompanyId) {
const c = 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(and(eq(companyUsers.userId, r.userId), eq(companies.id, r.activeCompanyId)))
.limit(1);
if (c.length > 0) company = c[0];
}
const msLeft = r.expiresAt.getTime() - Date.now();
const shouldRefresh = msLeft < SESSION_RENEWAL_THRESHOLD_MS;
return {
sessionId: r.sessionId,
user: {
id: r.userId,
email: r.userEmail,
displayName: r.userDisplayName,
isActive: r.userIsActive
},
company,
shouldRefresh
};
}
export async function refreshSession(sessionId: string): Promise<void> {
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
await db
.update(sessions)
.set({ expiresAt, lastSeenAt: new Date() })
.where(eq(sessions.id, sessionId));
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export async function setActiveCompany(sessionId: string, companyId: string | null): Promise<void> {
await db.update(sessions).set({ activeCompanyId: companyId }).where(eq(sessions.id, sessionId));
}
export const SESSION_COOKIE = 'ops_session';
export const SESSION_COOKIE_MAX_AGE = SESSION_LIFETIME_MS / 1000;
+15
View File
@@ -0,0 +1,15 @@
export type Role = 'admin' | 'manager' | 'user' | 'viewer';
export interface SessionUser {
id: string;
email: string;
displayName: string;
isActive: boolean;
}
export interface SessionCompany {
id: string;
name: string;
slug: string;
role: Role;
}
+18
View File
@@ -0,0 +1,18 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import { env } from '../env';
import * as schema from './schema';
const pool = new pg.Pool({
connectionString: env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000
});
pool.on('error', (err) => {
console.error('pg pool error', err);
});
export const db = drizzle(pool, { schema });
export type DB = typeof db;
export { pool };
+70
View File
@@ -0,0 +1,70 @@
import { sql } from 'drizzle-orm';
import { pgEnum, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['admin', 'manager', 'user', 'viewer']);
export const taskStatusEnum = pgEnum('task_status', ['todo', 'doing', 'done', 'blocked']);
export const containerKindEnum = pgEnum('container_kind', ['project', 'property']);
export const scheduleKindEnum = pgEnum('schedule_kind', ['time', 'usage']);
export const intervalUnitEnum = pgEnum('interval_unit', [
'days',
'months',
'years',
'hours',
'cycles',
'km'
]);
export const fieldTypeEnum = pgEnum('field_type', [
'text',
'textarea',
'int',
'float',
'bool',
'date',
'ip',
'cidr',
'mac',
'enum',
'multi_enum',
'url',
'email',
'asset_ref'
]);
export const checklistScopeEnum = pgEnum('checklist_scope', [
'task',
'subtask',
'maintenance_event',
'ad_hoc'
]);
export const docScopeEnum = pgEnum('doc_scope', [
'project',
'property',
'asset',
'work_package',
'decision_event'
]);
export const wikiScopeEnum = pgEnum('wiki_scope', ['global', 'project', 'property']);
export const decisionScopeEnum = pgEnum('decision_scope', [
'project',
'property',
'asset',
'work_package'
]);
export const auditActionEnum = pgEnum('audit_action', [
'create',
'update',
'delete',
'move',
'assign',
'complete',
'login',
'logout'
]);
export const pk = () => uuid('id').primaryKey().default(sql`gen_random_uuid()`);
export const fk = (name: string) => uuid(name);
export const createdAt = () =>
timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
export const updatedAt = () =>
timestamp('updated_at', { withTimezone: true }).notNull().defaultNow();
export const deletedAt = () => timestamp('deleted_at', { withTimezone: true });
export const slugCol = (name = 'slug') => varchar(name, { length: 128 }).notNull();
+5
View File
@@ -0,0 +1,5 @@
export * from './_shared';
export * from './tenancy';
// Additional schema modules (projects, properties, assets, maintenance,
// checklists, decisions, documents, wiki, audit) are added in phases as
// laid out in project_buildfor_life_ops.md.
+101
View File
@@ -0,0 +1,101 @@
import {
pgTable,
varchar,
text,
boolean,
timestamp,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
import {
roleEnum,
pk,
fk,
createdAt,
updatedAt,
deletedAt,
slugCol
} from './_shared';
export const companies = pgTable('companies', {
id: pk(),
name: varchar('name', { length: 255 }).notNull(),
slug: slugCol().unique(),
// Company-level config (e.g. default_currency: 'THB'). Kept as text + JSON.parse
// so we don't need a migration every time a setting is added.
settings: text('settings_json'),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
});
export const users = pgTable(
'users',
{
id: pk(),
email: varchar('email', { length: 320 }).notNull(),
emailNormalized: varchar('email_normalized', { length: 320 }).notNull(),
displayName: varchar('display_name', { length: 255 }).notNull(),
// Argon2id hash; nullable for OIDC-only accounts
passwordHash: text('password_hash'),
oidcSubject: varchar('oidc_subject', { length: 255 }),
oidcIssuer: varchar('oidc_issuer', { length: 255 }),
isActive: boolean('is_active').notNull().default(true),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
emailNormUq: uniqueIndex('users_email_norm_uq').on(t.emailNormalized),
oidcUq: uniqueIndex('users_oidc_uq').on(t.oidcIssuer, t.oidcSubject)
})
);
export const companyUsers = pgTable(
'company_users',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: fk('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: roleEnum('role').notNull().default('user'),
joinedAt: createdAt()
},
(t) => ({
uq: uniqueIndex('company_users_uq').on(t.companyId, t.userId),
byUser: index('company_users_by_user').on(t.userId)
})
);
export const sessions = pgTable(
'sessions',
{
// Opaque token stored directly (hex from CSPRNG, not UUID).
id: varchar('id', { length: 128 }).primaryKey(),
userId: fk('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// Currently-active company context for this session.
activeCompanyId: fk('active_company_id').references(() => companies.id, {
onDelete: 'set null'
}),
userAgent: text('user_agent'),
ip: varchar('ip', { length: 64 }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
},
(t) => ({
byUser: index('sessions_by_user').on(t.userId),
byExpiry: index('sessions_by_expiry').on(t.expiresAt)
})
);
export type User = typeof users.$inferSelect;
export type Company = typeof companies.$inferSelect;
export type CompanyUser = typeof companyUsers.$inferSelect;
export type Session = typeof sessions.$inferSelect;
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
PUBLIC_BASE_URL: z.string().url(),
STORAGE_BACKEND: z.enum(['local', 's3']).default('local'),
STORAGE_LOCAL_ROOT: z.string().default('./storage'),
STORAGE_SIGNING_SECRET: z.string().min(32, 'STORAGE_SIGNING_SECRET must be at least 32 characters'),
OIDC_ENABLED: z
.string()
.transform((v) => v === 'true')
.default('false'),
OIDC_ISSUER: z.string().url().optional().or(z.literal('')),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional().or(z.literal(''))
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Environment validation failed:');
console.error(parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment. See errors above and .env.example for the expected shape.');
}
export const env = parsed.data;
export type AppEnv = typeof env;
+26
View File
@@ -0,0 +1,26 @@
import { env } from '../env';
import { LocalDiskStorage } from './local';
import type { StorageAdapter } from './types';
let instance: StorageAdapter | null = null;
export function getStorage(): StorageAdapter {
if (instance) return instance;
if (env.STORAGE_BACKEND === 'local') {
instance = new LocalDiskStorage(
env.STORAGE_LOCAL_ROOT,
env.STORAGE_SIGNING_SECRET,
env.PUBLIC_BASE_URL
);
} else {
throw new Error(`Storage backend '${env.STORAGE_BACKEND}' is not wired yet`);
}
return instance;
}
// Exported for tests / explicit swaps
export function _setStorageForTesting(s: StorageAdapter | null) {
instance = s;
}
export * from './types';
+128
View File
@@ -0,0 +1,128 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, stat, unlink } from 'node:fs/promises';
import { createHash, createHmac } from 'node:crypto';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import {
generateStorageKey,
type GetObjectResult,
type PutObjectInput,
type PutObjectResult,
type SignedUrlOptions,
type StorageAdapter
} from './types';
export class LocalDiskStorage implements StorageAdapter {
constructor(
private readonly root: string,
private readonly signingSecret: string,
private readonly publicBaseUrl: string
) {}
private resolve(key: string): string {
if (key.includes('..') || path.isAbsolute(key)) {
throw new Error('invalid storage key');
}
return path.join(this.root, key);
}
async put(input: PutObjectInput): Promise<PutObjectResult> {
const full = this.resolve(input.key);
await mkdir(path.dirname(full), { recursive: true });
const hash = createHash('sha256');
let size = 0;
const out = createWriteStream(full);
if (Buffer.isBuffer(input.body)) {
hash.update(input.body);
size = input.body.length;
out.end(input.body);
await new Promise<void>((res, rej) => {
out.on('finish', () => res());
out.on('error', rej);
});
} else {
await pipeline(
input.body,
async function* (src) {
for await (const chunk of src) {
const buf = chunk as Buffer;
hash.update(buf);
size += buf.length;
yield buf;
}
},
out
);
}
const sha256 = hash.digest('hex');
if (input.sha256 && input.sha256 !== sha256) {
await unlink(full).catch(() => {});
throw new Error('sha256 mismatch on upload');
}
return { key: input.key, sha256, sizeBytes: size };
}
async get(key: string): Promise<GetObjectResult> {
const full = this.resolve(key);
const st = await stat(full);
return {
stream: createReadStream(full),
contentType: 'application/octet-stream',
sizeBytes: st.size
};
}
async head(key: string) {
const st = await stat(this.resolve(key));
return { sizeBytes: st.size, contentType: 'application/octet-stream' };
}
async delete(key: string): Promise<void> {
await unlink(this.resolve(key)).catch((err) => {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
});
}
generateKey(filename: string): string {
return generateStorageKey(filename);
}
async getSignedUrl(key: string, opts: SignedUrlOptions): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + opts.expiresInSeconds;
const disposition = opts.disposition ?? 'inline';
const filename = opts.filename ?? '';
const payload = `${key}|${exp}|${disposition}|${filename}`;
const sig = createHmac('sha256', this.signingSecret).update(payload).digest('hex');
const q = new URLSearchParams({
k: key,
e: String(exp),
d: disposition,
n: filename,
s: sig
});
return `${this.publicBaseUrl}/api/files?${q.toString()}`;
}
verifySignedUrl(params: URLSearchParams): { key: string; disposition: string; filename: string } {
const key = params.get('k');
const expStr = params.get('e');
const disposition = params.get('d') ?? 'inline';
const filename = params.get('n') ?? '';
const sig = params.get('s');
if (!key || !expStr || !sig) throw new Error('missing signed-url params');
const exp = Number.parseInt(expStr, 10);
if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) {
throw new Error('signed url expired');
}
const payload = `${key}|${exp}|${disposition}|${filename}`;
const expected = createHmac('sha256', this.signingSecret).update(payload).digest('hex');
// timing-safe compare
if (sig.length !== expected.length) throw new Error('bad signature');
let diff = 0;
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
if (diff !== 0) throw new Error('bad signature');
return { key, disposition, filename };
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { Readable } from 'node:stream';
export interface PutObjectInput {
key: string;
body: Readable | Buffer;
contentType: string;
contentLength?: number;
sha256?: string;
metadata?: Record<string, string>;
}
export interface PutObjectResult {
key: string;
sha256: string;
sizeBytes: number;
}
export interface GetObjectResult {
stream: Readable;
contentType: string;
sizeBytes: number;
sha256?: string;
}
export interface SignedUrlOptions {
expiresInSeconds: number;
disposition?: 'inline' | 'attachment';
filename?: string;
}
export interface StorageAdapter {
put(input: PutObjectInput): Promise<PutObjectResult>;
get(key: string): Promise<GetObjectResult>;
head(key: string): Promise<{ sizeBytes: number; contentType: string; sha256?: string }>;
delete(key: string): Promise<void>;
getSignedUrl(key: string, opts: SignedUrlOptions): Promise<string>;
generateKey(filename: string): string;
}
export function generateStorageKey(filename: string): string {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const uuid = crypto.randomUUID();
const safe = filename
.normalize('NFKD')
.replace(/[^\w.-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 120);
return `${yyyy}/${mm}/${uuid}__${safe}`;
}
+3
View File
@@ -0,0 +1,3 @@
export function normalizeEmail(email: string): string {
return email.normalize('NFKC').trim().toLowerCase();
}
+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;