Fase 0: scaffold monorepo, SvelteKit skeleton, and dev infrastructure

- Turborepo + pnpm workspaces with apps/web and packages/types
- SvelteKit app: Tailwind, Paraglide i18n (en/es), keycloak-js, Supabase client,
  auth/collective stores, PWA service worker skeleton, all route placeholders
- packages/types: domain types (User, Collective, ShoppingList, etc.) and
  database.ts placeholder for generated types
- Justfile with dev, db-*, kc-*, build, backup, restore, deploy recipes
- infra/docker-compose.dev.yml: Postgres 15, GoTrue, PostgREST, Realtime v2.83,
  Storage, Kong (port 8001), Studio, Keycloak 24
- infra/docker-compose.prod.yml, kong.yml, db-init scripts, backup/deploy scripts
- keycloak/realm-export.json with colectivo realm and 5 dev test users
- supabase/config.toml and seed.sql with sample collective and items
- GitHub Actions: ci.yml (lint+typecheck+build) and deploy.yml (GHCR + SSH)
- .env.example documenting all required variables
- Fixed docker-compose issues: Studio image tag, Kong port conflict (8001),
  internal role passwords init script, Realtime METRICS_JWT_SECRET/APP_NAME

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:37:51 +02:00
parent f5903ef442
commit 973f9e413c
57 changed files with 2508 additions and 1 deletions

45
apps/web/src/app.css Normal file
View File

@@ -0,0 +1,45 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* Inter font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Design tokens — light mode */
:root {
--background: 248 250 252; /* slate-50 */
--surface: 255 255 255; /* white */
--surface-raised: 241 245 249; /* slate-100 */
--border: 226 232 240; /* slate-200 */
--text-primary: 15 23 42; /* slate-900 */
--text-secondary: 100 116 139; /* slate-500 */
--text-muted: 148 163 184; /* slate-400 */
}
/* Design tokens — dark mode */
.dark {
--background: 15 23 42; /* slate-950 */
--surface: 30 41 59; /* slate-800 → closer to slate-900 card */
--surface-raised: 51 65 85; /* slate-700 */
--border: 51 65 85; /* slate-700 */
--text-primary: 248 250 252; /* slate-50 */
--text-secondary: 148 163 184; /* slate-400 */
--text-muted: 100 116 139; /* slate-500 */
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--text-primary));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

16
apps/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { KeycloakUser } from '$lib/auth';
declare global {
namespace App {
interface Locals {
user: KeycloakUser | null;
}
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
apps/web/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0f172a" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

78
apps/web/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import Keycloak from 'keycloak-js';
import { browser } from '$app/environment';
import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public';
export interface KeycloakUser {
id: string;
email: string;
displayName: string;
preferredUsername: string;
}
let keycloak: Keycloak | null = null;
export function getKeycloak(): Keycloak {
if (!keycloak) {
keycloak = new Keycloak({
url: PUBLIC_KEYCLOAK_URL,
realm: PUBLIC_KEYCLOAK_REALM,
clientId: PUBLIC_KEYCLOAK_CLIENT_ID
});
}
return keycloak;
}
export async function initAuth(): Promise<boolean> {
if (!browser) return false;
const kc = getKeycloak();
const authenticated = await kc.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
// Safari blocks third-party cookies in iframes — must be false
checkLoginIframe: false,
pkceMethod: 'S256'
});
if (authenticated) {
// Schedule proactive token refresh (60s before expiry)
scheduleTokenRefresh(kc);
}
return authenticated;
}
function scheduleTokenRefresh(kc: Keycloak): void {
setInterval(
async () => {
try {
await kc.updateToken(60);
} catch {
// Token refresh failed — redirect to login
kc.login();
}
},
30 * 1000 // check every 30s
);
}
export function getUser(kc: Keycloak): KeycloakUser | null {
if (!kc.authenticated || !kc.tokenParsed) return null;
const token = kc.tokenParsed as Record<string, unknown>;
return {
id: token['sub'] as string,
email: token['email'] as string,
displayName: (token['name'] as string) ?? (token['preferred_username'] as string),
preferredUsername: token['preferred_username'] as string
};
}
export async function login(): Promise<void> {
getKeycloak().login();
}
export async function logout(): Promise<void> {
getKeycloak().logout({ redirectUri: window.location.origin });
}

6
apps/web/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createI18n } from '@inlang/paraglide-sveltekit';
import * as runtime from '$lib/paraglide/runtime';
export const i18n = createI18n(runtime, {
defaultLanguageTag: 'en'
});

View File

@@ -0,0 +1,10 @@
import { writable, derived } from 'svelte/store';
import type Keycloak from 'keycloak-js';
import type { KeycloakUser } from '$lib/auth';
export const keycloak = writable<Keycloak | null>(null);
export const isAuthenticated = writable<boolean>(false);
export const currentUser = writable<KeycloakUser | null>(null);
export const authLoading = writable<boolean>(true);
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);

View File

@@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
export interface Collective {
id: string;
name: string;
emoji: string;
created_at: string;
}
export interface CollectiveMember {
user_id: string;
collective_id: string;
role: 'admin' | 'member' | 'guest';
display_name: string;
avatar_type: 'initials' | 'emoji' | 'upload';
avatar_emoji: string | null;
avatar_url: string | null;
}
export const currentCollective = writable<Collective | null>(null);
export const collectiveMembers = writable<CollectiveMember[]>([]);
export const userCollectives = writable<Collective[]>([]);

View File

@@ -0,0 +1,44 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { browser } from '$app/environment';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Database } from '@colectivo/types';
let supabase: SupabaseClient<Database> | null = null;
export function getSupabase(): SupabaseClient<Database> {
if (!supabase) {
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
auth: {
// Auth is handled by Keycloak — disable Supabase GoTrue auth flows
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false
},
global: {
headers: {}
}
});
}
return supabase;
}
/**
* Set the Keycloak access token on the Supabase client so RLS policies
* resolve auth.uid() to the Keycloak sub claim.
*/
export function setSupabaseToken(accessToken: string): void {
const client = getSupabase();
// @ts-expect-error — internal API to inject a custom JWT
client.rest.headers['Authorization'] = `Bearer ${accessToken}`;
if (browser) {
// Also set on the realtime client
// @ts-expect-error
client.realtime.setAuth(accessToken);
}
}
export function clearSupabaseToken(): void {
const client = getSupabase();
// @ts-expect-error
delete client.rest.headers['Authorization'];
}

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { currentCollective } from '$lib/stores/collective';
import { logout } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
const navItems = [
{ href: '/lists', label: () => m.nav_lists(), icon: '🛒' },
{ href: '/tasks', label: () => m.nav_tasks(), icon: '✓' },
{ href: '/notes', label: () => m.nav_notes(), icon: '📄' },
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
];
$: activePath = $page.url.pathname;
</script>
{#if $authLoading}
<div class="flex h-screen items-center justify-center bg-background">
<span class="text-text-secondary text-sm">{m.loading()}</span>
</div>
{:else if !$isAuthenticated}
<!-- Keycloak redirect is triggered in root layout; show nothing -->
{:else}
<div class="flex h-screen overflow-hidden bg-background">
<!-- Sidebar -->
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
<!-- Collective name -->
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{$currentCollective?.name ?? m.app_name()}
</span>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3">
{#each navItems as item}
<a
href={item.href}
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors
{activePath.startsWith(item.href)
? 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-50'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-50'}"
>
<span class="text-base">{item.icon}</span>
{item.label()}
</a>
{/each}
</nav>
<!-- User + settings -->
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
<div class="flex items-center justify-between">
<div class="flex min-w-0 items-center gap-2">
<!-- Avatar -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
>
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
</div>
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
{$currentUser?.displayName ?? ''}
</span>
</div>
<a
href="/settings"
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label={m.nav_settings()}
>
</a>
</div>
</div>
</aside>
<!-- Main content -->
<main class="flex flex-1 flex-col overflow-hidden">
<slot />
</main>
</div>
{/if}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.lists_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 2a: shopping lists content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.notes_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 3: notes content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.search_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 4: search content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 1: settings content (display name, avatar, language) -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.tasks_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 3: tasks content -->
</div>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { initAuth, getKeycloak, getUser } from '$lib/auth';
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
const PUBLIC_ROUTES = ['/invitation'];
onMount(async () => {
const kc = getKeycloak();
keycloak.set(kc);
const authenticated = await initAuth();
isAuthenticated.set(authenticated);
if (authenticated) {
const user = getUser(kc);
currentUser.set(user);
if (kc.token) setSupabaseToken(kc.token);
// Keep token in sync with Supabase client
kc.onTokenRefreshed = () => {
if (kc.token) setSupabaseToken(kc.token);
};
kc.onAuthLogout = () => {
clearSupabaseToken();
isAuthenticated.set(false);
currentUser.set(null);
};
}
authLoading.set(false);
// Redirect unauthenticated users unless on a public route
if (!authenticated) {
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
if (!isPublic) {
kc.login();
}
}
});
</script>
<ParaglideJS {i18n}>
<slot />
</ParaglideJS>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { isAuthenticated, authLoading } from '$lib/stores/auth';
onMount(() => {
const unsubLoading = authLoading.subscribe((loading) => {
if (!loading) {
unsubLoading();
goto('/lists', { replaceState: true });
}
});
});
</script>
<!-- Redirect root to /lists — handled in onMount after auth resolves -->

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
<div class="w-full max-w-sm space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
</div>
<!-- Fase 1: collective creation / join form -->
<div class="space-y-3">
<button
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.onboarding_create_collective()}
</button>
<button
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.onboarding_join_collective()}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = `colectivo-${version}`;
const ASSETS = [...build, ...files];
// Precache all static assets
precacheAndRoute(ASSETS.map((url) => ({ url, revision: version })));
// Cache-first for static assets (JS, CSS, fonts, icons)
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new CacheFirst({ cacheName: `${CACHE_NAME}-assets` })
);
// Network-first for app routes (HTML navigation)
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: `${CACHE_NAME}-pages`,
networkTimeoutSeconds: 3
})
)
);
// Background Sync for pending offline operations
const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', {
maxRetentionTime: 24 * 60 // 24 hours
});
// Listen for messages from the app
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});