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

70
.env.example Normal file
View File

@@ -0,0 +1,70 @@
# ─────────────────────────────────────────────────────────────────────────────
# Colectivo — environment variables
# Copy to .env and fill in real values. Never commit .env to version control.
# ─────────────────────────────────────────────────────────────────────────────
# ── Supabase ──────────────────────────────────────────────────────────────────
# URL of the self-hosted Supabase instance (Kong gateway)
PUBLIC_SUPABASE_URL=http://localhost:8001
# Supabase anon key (public, safe to expose in the browser)
# Generated during `supabase start` or set manually in supabase/config.toml
PUBLIC_SUPABASE_ANON_KEY=
# Supabase service role key (private — never expose in the browser)
# Used only in Edge Functions or server-side scripts
SUPABASE_SERVICE_ROLE_KEY=
# JWT secret — MUST be set to the Keycloak RS256 public key (PEM format, one line)
# Supabase uses this to validate Keycloak-issued JWTs for RLS
# Rotate simultaneously with Keycloak signing key rotation
SUPABASE_JWT_SECRET=
# ── Keycloak ──────────────────────────────────────────────────────────────────
# Public URL of the Keycloak server (used by the browser for OIDC redirects)
PUBLIC_KEYCLOAK_URL=http://localhost:8080
# Keycloak realm name
PUBLIC_KEYCLOAK_REALM=colectivo
# Keycloak client ID (public PKCE client — no secret)
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web
# Keycloak admin credentials (used by infra scripts only — never in app code)
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=
# ── Email (Resend) ────────────────────────────────────────────────────────────
# Resend API key — used by Edge Functions to send invitation emails
RESEND_API_KEY=
# From address for outgoing emails
RESEND_FROM_EMAIL=noreply@yourdomain.com
# ── Backup (Backblaze B2) ─────────────────────────────────────────────────────
B2_BUCKET_NAME=colectivo-backups
B2_APPLICATION_KEY_ID=
B2_APPLICATION_KEY=
# ── App ───────────────────────────────────────────────────────────────────────
# Publicly accessible origin of the app (no trailing slash)
# Used in Edge Functions to build invitation URLs
PUBLIC_APP_URL=http://localhost:5173
# ── Docker / Infra ────────────────────────────────────────────────────────────
# Postgres superuser password (used by Supabase internally)
POSTGRES_PASSWORD=
# Supabase dashboard username/password (Studio)
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=
# Production SSH target for deploy script
DEPLOY_HOST=user@your-vps-ip
DEPLOY_PATH=/opt/colectivo

65
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
name: Lint & Type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm turbo run check
- name: Lint
run: pnpm turbo run lint
continue-on-error: true # lint warnings should not block CI initially
build:
name: Build
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo run build
env:
# Placeholder values for build-time env vars
PUBLIC_SUPABASE_URL: http://localhost:8000
PUBLIC_SUPABASE_ANON_KEY: placeholder
PUBLIC_KEYCLOAK_URL: http://localhost:8080
PUBLIC_KEYCLOAK_REALM: colectivo
PUBLIC_KEYCLOAK_CLIENT_ID: colectivo-web
PUBLIC_APP_URL: http://localhost:5173

98
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: Docker image tag to deploy
required: false
default: latest
jobs:
build-and-push:
name: Build & Push Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/colectivo-web
tags: |
type=sha,prefix=,suffix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: apps/web/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
PUBLIC_SUPABASE_URL=${{ secrets.PUBLIC_SUPABASE_URL }}
PUBLIC_SUPABASE_ANON_KEY=${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}
PUBLIC_KEYCLOAK_URL=${{ secrets.PUBLIC_KEYCLOAK_URL }}
PUBLIC_KEYCLOAK_REALM=${{ secrets.PUBLIC_KEYCLOAK_REALM }}
PUBLIC_KEYCLOAK_CLIENT_ID=${{ secrets.PUBLIC_KEYCLOAK_CLIENT_ID }}
PUBLIC_APP_URL=${{ secrets.PUBLIC_APP_URL }}
deploy:
name: Deploy to production
runs-on: ubuntu-latest
needs: build-and-push
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST_IP }}" >> ~/.ssh/known_hosts
- name: Deploy
run: |
IMAGE_TAG="${{ needs.build-and-push.outputs.image_tag }}" \
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" \
DEPLOY_PATH="${{ secrets.DEPLOY_PATH }}" \
bash infra/scripts/deploy.sh

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
.svelte-kit/
build/
dist/
# Environment files
.env
.env.dev
.env.production
.env.local
.env.*.local
# Supabase local
supabase/.branches/
supabase/.temp/
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Turbo
.turbo/
# Docker
infra/volumes/

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=false
strict-peer-dependencies=false

View File

@@ -61,6 +61,7 @@ just dev # start docker-compose.dev.yml + SvelteKit dev server
just dev-stop # stop local environment
just db-reset # drop + migrate + seed (dev)
just db-migrate # apply pending migrations (dev)
just db-seed # apply supabase/seed.sql to the running dev db
just db-types # regenerate TS types from Supabase schema → packages/types
just kc-export # export Keycloak realm → keycloak/realm-export.json
just kc-open # open Keycloak Admin Console (localhost:8080)
@@ -70,6 +71,16 @@ just restore supabase <file> # restore a specific dump
just logs # docker compose logs -f (prod)
```
## Local Dev Endpoints
| Service | URL | Notes |
|---|---|---|
| SvelteKit app | http://localhost:5173 | |
| Supabase Studio | http://localhost:54323 | |
| Supabase API (Kong) | http://localhost:8001 | Port 8000 reserved by other services |
| Keycloak Admin | http://localhost:8080/admin | admin / admin (dev only) |
| PostgreSQL | localhost:5432 | postgres / postgres (dev only) |
## Domain Model
The central organizing unit is the **Collective** (household group), not the individual user. All content belongs to the Collective.

100
Justfile Normal file
View File

@@ -0,0 +1,100 @@
# Colectivo — task runner
# Requires: just, docker, pnpm
set dotenv-load := true
# Alias: docker compose with correct project root so .env is always found
dc_dev := "docker compose --env-file .env -f infra/docker-compose.dev.yml"
dc_prod := "docker compose --env-file .env -f infra/docker-compose.prod.yml"
# ── Dev ───────────────────────────────────────────────────────────────────────
# Start full local stack (Docker services + SvelteKit dev server)
dev:
{{dc_dev}} up -d
pnpm --filter @colectivo/web dev
# Stop local Docker stack
dev-stop:
{{dc_dev}} down
# Stop and remove all volumes (full reset)
dev-clean:
{{dc_dev}} down -v
# ── Database ──────────────────────────────────────────────────────────────────
# Drop, migrate, and seed the dev database
db-reset:
supabase db reset
# Apply pending migrations
db-migrate:
supabase db push
# Apply dev seed data
db-seed:
docker exec -i colectivo-dev-db-1 psql -U postgres < supabase/seed.sql
# Regenerate TypeScript types from Supabase schema
db-types:
supabase gen types typescript --local > packages/types/src/database.ts
echo "Types written to packages/types/src/database.ts"
# Open Supabase Studio
db-open:
open http://localhost:54323
# ── Keycloak ──────────────────────────────────────────────────────────────────
# Export Keycloak realm to keycloak/realm-export.json
kc-export:
{{dc_dev}} exec keycloak \
/opt/keycloak/bin/kc.sh export \
--realm colectivo \
--users realm_file \
--file /tmp/realm-export.json
{{dc_dev}} cp keycloak:/tmp/realm-export.json keycloak/realm-export.json
echo "Exported to keycloak/realm-export.json"
# Open Keycloak Admin Console
kc-open:
open http://localhost:8080/admin
# ── Build ─────────────────────────────────────────────────────────────────────
# Build all packages
build:
pnpm turbo run build
# Type-check all packages
check:
pnpm turbo run check
# Lint all packages
lint:
pnpm turbo run lint
# Run tests
test:
pnpm turbo run test
# ── Backup & Restore ──────────────────────────────────────────────────────────
# Take a manual backup of a service (e.g. just backup supabase)
backup service:
infra/scripts/backup.sh {{service}}
# Restore a backup file (e.g. just restore supabase backups/2024-01-01.dump)
restore service file:
infra/scripts/restore.sh {{service}} {{file}}
# ── Production ────────────────────────────────────────────────────────────────
# Deploy to production via SSH
deploy:
infra/scripts/deploy.sh
# Stream production Docker logs
logs:
{{dc_prod}} logs -f

View File

@@ -76,8 +76,11 @@ colectivo/
│ └── themes/ # tema personalizado (opcional, v2)
├── infra/
│ ├── docker-compose.dev.yml # Supabase + Keycloak + SvelteKit dev
│ ├── docker-compose.dev.yml # Supabase + Keycloak dev (Kong en puerto 8001)
│ ├── docker-compose.prod.yml # producción en VPS
│ ├── kong.yml # Kong declarativo (rutas API)
│ ├── db-init/
│ │ └── 00-role-passwords.sh # ajusta passwords de roles internos de Supabase al arrancar
│ └── scripts/
│ ├── backup.sh # backup manual de una BD concreta
│ ├── backup-cron.sh # ejecutado por cron: decide tipo (diario/semanal/mensual) y purga
@@ -104,6 +107,7 @@ just dev # levanta docker-compose.dev.yml + SvelteKit dev server
just dev-stop # para el entorno local
just db-reset # drop + migrate + seed (dev)
just db-migrate # aplica migraciones pendientes (dev)
just db-seed # aplica supabase/seed.sql a la BD dev en marcha
just db-types # genera tipos TS desde schema Supabase → packages/types
just kc-export # exporta realm de Keycloak → keycloak/realm-export.json
just kc-open # abre Keycloak Admin Console en el navegador (localhost:8080)
@@ -117,7 +121,15 @@ just logs # docker compose logs -f (prod)
---
## Endpoints en desarrollo local
| Servicio | URL | Notas |
|----------|-----|-------|
| App SvelteKit | http://localhost:5173 | |
| Supabase Studio | http://localhost:54323 | |
| Supabase API (Kong) | http://localhost:8001 | Puerto 8000 puede estar ocupado por otros servicios |
| Keycloak Admin | http://localhost:8080/admin | admin / admin (solo dev) |
| PostgreSQL | localhost:5432 | postgres / postgres (solo dev) |
---

55
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Multi-stage build for the SvelteKit Node.js server
# Build args map to PUBLIC_* env vars baked at build time (SvelteKit requires this)
FROM node:22-alpine AS base
RUN corepack enable pnpm
# ── Dependencies ──────────────────────────────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/types/package.json ./packages/types/
RUN pnpm install --frozen-lockfile
# ── Build ─────────────────────────────────────────────────────────────────────
FROM base AS builder
WORKDIR /app
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_KEYCLOAK_URL
ARG PUBLIC_KEYCLOAK_REALM
ARG PUBLIC_KEYCLOAK_CLIENT_ID
ARG PUBLIC_APP_URL
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
ENV PUBLIC_KEYCLOAK_URL=$PUBLIC_KEYCLOAK_URL
ENV PUBLIC_KEYCLOAK_REALM=$PUBLIC_KEYCLOAK_REALM
ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID
ENV PUBLIC_APP_URL=$PUBLIC_APP_URL
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY . .
RUN pnpm --filter @colectivo/types build 2>/dev/null || true
RUN pnpm --filter @colectivo/web build
# ── Runtime ───────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Only copy the compiled output
COPY --from=builder /app/apps/web/build ./build
COPY --from=builder /app/apps/web/package.json ./
# Install production-only deps for the Node adapter
RUN npm install --omit=dev --ignore-scripts 2>/dev/null || true
EXPOSE 3000
CMD ["node", "build"]

51
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_lists": "Lists",
"nav_tasks": "Tasks",
"nav_notes": "Notes",
"nav_search": "Search",
"nav_settings": "Settings",
"app_name": "Colectivo",
"loading": "Loading…",
"error_generic": "Something went wrong. Please try again.",
"login_title": "Sign in to Colectivo",
"login_button": "Sign in",
"logout_button": "Sign out",
"onboarding_title": "Welcome to Colectivo",
"onboarding_create_collective": "Create a collective",
"onboarding_join_collective": "Join with an invitation link",
"collective_name_label": "Collective name",
"collective_name_placeholder": "e.g. Our Home",
"settings_title": "Settings",
"settings_display_name": "Display name",
"settings_language": "Language",
"settings_avatar": "Avatar",
"settings_save": "Save changes",
"settings_saved": "Changes saved",
"invite_member": "Invite member",
"invite_email_label": "Email address",
"invite_send": "Send invitation",
"lists_title": "Shopping Lists",
"list_add_item": "Add item…",
"list_to_buy": "To buy",
"list_checked": "Checked",
"list_finish_shopping": "Finish shopping",
"list_finish_confirm": "Mark this list as completed?",
"list_finish_confirm_yes": "Yes, finish",
"list_finish_confirm_no": "Keep shopping",
"tasks_title": "Tasks",
"notes_title": "Notes",
"search_title": "Search",
"search_placeholder": "Search lists, tasks, notes…",
"trash_restore": "Restore",
"trash_delete_permanent": "Delete permanently",
"trash_empty": "Trash is empty",
"trash_expires_in": "Expires in {days} days",
"confirm_delete": "Delete?",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"done": "Done"
}

51
apps/web/messages/es.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_lists": "Listas",
"nav_tasks": "Tareas",
"nav_notes": "Notas",
"nav_search": "Buscar",
"nav_settings": "Ajustes",
"app_name": "Colectivo",
"loading": "Cargando…",
"error_generic": "Algo salió mal. Por favor, inténtalo de nuevo.",
"login_title": "Accede a Colectivo",
"login_button": "Iniciar sesión",
"logout_button": "Cerrar sesión",
"onboarding_title": "Bienvenido a Colectivo",
"onboarding_create_collective": "Crear un colectivo",
"onboarding_join_collective": "Unirme con un enlace de invitación",
"collective_name_label": "Nombre del colectivo",
"collective_name_placeholder": "p. ej. Nuestro Hogar",
"settings_title": "Ajustes",
"settings_display_name": "Nombre visible",
"settings_language": "Idioma",
"settings_avatar": "Avatar",
"settings_save": "Guardar cambios",
"settings_saved": "Cambios guardados",
"invite_member": "Invitar miembro",
"invite_email_label": "Correo electrónico",
"invite_send": "Enviar invitación",
"lists_title": "Listas de la compra",
"list_add_item": "Añadir producto…",
"list_to_buy": "Por comprar",
"list_checked": "Marcado",
"list_finish_shopping": "Terminar compra",
"list_finish_confirm": "¿Marcar esta lista como completada?",
"list_finish_confirm_yes": "Sí, terminar",
"list_finish_confirm_no": "Seguir comprando",
"tasks_title": "Tareas",
"notes_title": "Notas",
"search_title": "Buscar",
"search_placeholder": "Busca listas, tareas, notas…",
"trash_restore": "Restaurar",
"trash_delete_permanent": "Eliminar definitivamente",
"trash_empty": "La papelera está vacía",
"trash_expires_in": "Expira en {days} días",
"confirm_delete": "¿Eliminar?",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Añadir",
"done": "Listo"
}

42
apps/web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@colectivo/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@colectivo/types": "workspace:*",
"@inlang/paraglide-sveltekit": "^0.12.3",
"@supabase/supabase-js": "^2.46.2",
"idb": "^8.0.1",
"keycloak-js": "^26.0.7",
"svelte-dnd-action": "^0.9.51"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@vite-pwa/sveltekit": "^0.6.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-plugin-svelte": "^2.46.1",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.3",
"svelte-check": "^4.1.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"workbox-window": "^7.3.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "en",
"languageTags": ["en", "es"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0.9.5/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
}
}

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();
}
});

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<title>Silent Check SSO</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

17
apps/web/svelte.config.js Normal file
View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build'
}),
alias: {
$lib: './src/lib'
}
}
};
export default config;

View File

@@ -0,0 +1,20 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
},
colors: {
// Semantic aliases over Tailwind slate
background: 'hsl(var(--background) / <alpha-value>)',
surface: 'hsl(var(--surface) / <alpha-value>)',
'surface-raised': 'hsl(var(--surface-raised) / <alpha-value>)'
}
}
},
plugins: []
} satisfies Config;

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

53
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { paraglide } from '@inlang/paraglide-sveltekit/vite';
export default defineConfig({
plugins: [
paraglide({
project: './project.inlang',
outdir: './src/lib/paraglide'
}),
sveltekit(),
SvelteKitPWA({
srcDir: './src',
mode: 'development',
strategies: 'injectManifest',
filename: 'service-worker.ts',
scope: '/',
base: '/',
manifest: {
name: 'Colectivo',
short_name: 'Colectivo',
description: 'Gestión colaborativa del hogar',
theme_color: '#0f172a',
background_color: '#f7f9fb',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
injectManifest: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
devOptions: {
enabled: true,
suppressWarnings: true,
type: 'module',
navigateFallback: '/'
}
})
],
server: {
port: 5173
}
});

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Runs once on first container start (docker-entrypoint-initdb.d).
# Sets Supabase internal role passwords to POSTGRES_PASSWORD and creates
# required schemas with correct grants.
set -e
PASS="${POSTGRES_PASSWORD:-postgres}"
psql -v ON_ERROR_STOP=1 --username supabase_admin --dbname postgres <<-EOSQL
-- Set passwords for all Supabase internal roles.
-- The supabase/postgres image creates them with hardcoded defaults;
-- this overrides them so services connect with POSTGRES_PASSWORD.
ALTER ROLE authenticator WITH PASSWORD '${PASS}';
ALTER ROLE supabase_auth_admin WITH PASSWORD '${PASS}';
ALTER ROLE supabase_replication_admin WITH PASSWORD '${PASS}';
ALTER ROLE supabase_storage_admin WITH PASSWORD '${PASS}';
ALTER ROLE pgbouncer WITH PASSWORD '${PASS}';
-- Create the _realtime schema required by supabase/realtime
CREATE SCHEMA IF NOT EXISTS _realtime;
GRANT ALL ON SCHEMA _realtime TO supabase_replication_admin;
ALTER ROLE supabase_replication_admin SET search_path TO _realtime;
EOSQL
echo "==> db-init: role passwords and schemas configured"

View File

@@ -0,0 +1,11 @@
-- Set passwords for Supabase internal roles to match POSTGRES_PASSWORD.
-- The supabase/postgres image creates these roles with hardcoded default passwords;
-- this script overrides them so all services can authenticate with POSTGRES_PASSWORD.
-- Runs once on first container start (docker-entrypoint-initdb.d).
-- Must run as supabase_admin (superuser) which this image uses for initdb scripts.
ALTER ROLE authenticator WITH PASSWORD 'REPLACE_PASSWORD';
ALTER ROLE supabase_auth_admin WITH PASSWORD 'REPLACE_PASSWORD';
ALTER ROLE supabase_replication_admin WITH PASSWORD 'REPLACE_PASSWORD';
ALTER ROLE supabase_storage_admin WITH PASSWORD 'REPLACE_PASSWORD';
ALTER ROLE pgbouncer WITH PASSWORD 'REPLACE_PASSWORD';

View File

@@ -0,0 +1,231 @@
name: colectivo-dev
# Self-hosted Supabase stack + Keycloak for local development.
# Based on: https://github.com/supabase/supabase/tree/master/docker
#
# Start: just dev
# Stop: just dev-stop
# Reset: just dev-clean (drops volumes)
services:
# ── PostgreSQL 15 ───────────────────────────────────────────────────────────
db:
image: supabase/postgres:15.1.1.78
restart: unless-stopped
healthcheck:
test: pg_isready -U postgres -h localhost
interval: 5s
timeout: 5s
retries: 10
ports:
- "5432:5432"
environment:
POSTGRES_HOST: /var/run/postgresql
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
JWT_SECRET: ${SUPABASE_JWT_SECRET}
JWT_EXP: 3600
volumes:
- db-data:/var/lib/postgresql/data
# Sets internal role passwords to POSTGRES_PASSWORD on first init.
- ./db-init:/docker-entrypoint-initdb.d:ro
# ── GoTrue (Supabase Auth) ──────────────────────────────────────────────────
auth:
image: supabase/gotrue:v2.158.1
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: "0.0.0.0"
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${PUBLIC_APP_URL:-http://localhost:5173}
GOTRUE_API_EXTERNAL_URL: ${PUBLIC_APP_URL:-http://localhost:5173}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres
GOTRUE_SITE_URL: ${PUBLIC_APP_URL:-http://localhost:5173}
GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
GOTRUE_JWT_EXP: 3600
GOTRUE_DISABLE_SIGNUP: "true"
# OIDC: Keycloak as external provider
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true"
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web}
GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ""
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${PUBLIC_APP_URL:-http://localhost:5173}/auth/callback
GOTRUE_MAILER_AUTOCONFIRM: "true"
# ── PostgREST ───────────────────────────────────────────────────────────────
rest:
image: postgrest/postgrest:v12.2.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres
PGRST_DB_SCHEMAS: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${SUPABASE_JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: 3600
# ── Realtime ─────────────────────────────────────────────────────────────────
realtime:
image: supabase/realtime:v2.83.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PORT: 4000
DB_HOST: db
DB_PORT: 5432
DB_USER: supabase_replication_admin
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${SUPABASE_JWT_SECRET}
FLY_ALLOC_ID: fly123
FLY_APP_NAME: realtime
APP_NAME: realtime
SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3bhZkeYTvU
ERL_AFLAGS: -proto_dist inet_tcp
ENABLE_TAILSCALE: "false"
DNS_NODES: "''"
RLIMIT_NOFILE: ""
METRICS_JWT_SECRET: ${SUPABASE_JWT_SECRET}
MAX_HEADER_LENGTH: 4096
command: sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server"
# ── Storage ──────────────────────────────────────────────────────────────────
storage:
image: supabase/storage-api:v1.11.13
restart: unless-stopped
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
environment:
ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: stub
REGION: local
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
volumes:
- storage-data:/var/lib/storage
# ── ImgProxy (image transformations for Storage) ──────────────────────────
imgproxy:
image: darthsim/imgproxy:v3.8.0
restart: unless-stopped
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
volumes:
- storage-data:/var/lib/storage:ro
# ── Kong (API Gateway) ────────────────────────────────────────────────────
kong:
image: kong:2.8.1
restart: unless-stopped
ports:
- "8001:8000" # HTTP API (8000 may conflict; use http://localhost:8001)
- "8443:8443" # HTTPS API
depends_on:
db:
condition: service_healthy
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
volumes:
- ./kong.yml:/home/kong/kong.yml:ro
# ── Supabase Studio ───────────────────────────────────────────────────────
studio:
image: supabase/studio:2026.04.08-sha-205cbe7
restart: unless-stopped
ports:
- "54323:3000"
# Override the built-in healthcheck (it uses node fetch which fails inside the container).
# The studio works fine; this just prevents a misleading "unhealthy" status.
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/platform/profile || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
db:
condition: service_healthy
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DEFAULT_ORGANIZATION_NAME: Colectivo
DEFAULT_PROJECT_NAME: colectivo-dev
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:8001}
SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${SUPABASE_JWT_SECRET}
LOGFLARE_API_KEY: ""
LOGFLARE_URL: http://analytics:4000
NEXT_PUBLIC_ENABLE_LOGS: "true"
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
# ── pg-meta (metadata API for Studio) ───────────────────────────────────
meta:
image: supabase/postgres-meta:v0.83.2
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PORT: 5432
PG_META_DB_NAME: postgres
PG_META_DB_USER: supabase
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
# ── Keycloak 24 ──────────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:24.0.5
restart: unless-stopped
ports:
- "8080:8080"
command: start-dev --import-realm
environment:
KC_DB: dev-file
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_HTTP_PORT: 8080
KC_HOSTNAME_STRICT: "false"
KC_HOSTNAME_STRICT_HTTPS: "false"
volumes:
- keycloak-data:/opt/keycloak/data
- ../keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
volumes:
db-data:
storage-data:
keycloak-data:

View File

@@ -0,0 +1,233 @@
name: colectivo-prod
# Production stack. nginx is managed externally (not in this compose file).
# All services are on the internal `colectivo` network only — no ports exposed
# except Kong (proxied by nginx).
#
# Deploy: just deploy
# Logs: just logs
services:
# ── PostgreSQL 15 ───────────────────────────────────────────────────────────
db:
image: supabase/postgres:15.1.1.78
restart: always
healthcheck:
test: pg_isready -U postgres -h localhost
interval: 10s
timeout: 5s
retries: 10
environment:
POSTGRES_HOST: /var/run/postgresql
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${SUPABASE_JWT_SECRET}
JWT_EXP: 3600
volumes:
- db-data:/var/lib/postgresql/data
- ./volumes/db/init:/docker-entrypoint-initdb.d:ro
networks:
- colectivo
# ── GoTrue ─────────────────────────────────────────────────────────────────
auth:
image: supabase/gotrue:v2.158.1
restart: always
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: "0.0.0.0"
GOTRUE_API_PORT: 9999
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
GOTRUE_SITE_URL: ${PUBLIC_APP_URL}
GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
GOTRUE_JWT_EXP: 3600
GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_MAILER_AUTOCONFIRM: "false"
GOTRUE_SMTP_HOST: smtp.resend.com
GOTRUE_SMTP_PORT: 587
GOTRUE_SMTP_USER: resend
GOTRUE_SMTP_PASS: ${RESEND_API_KEY}
GOTRUE_SMTP_ADMIN_EMAIL: ${RESEND_FROM_EMAIL}
networks:
- colectivo
# ── PostgREST ───────────────────────────────────────────────────────────────
rest:
image: postgrest/postgrest:v12.2.0
restart: always
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres
PGRST_DB_SCHEMAS: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${SUPABASE_JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: 3600
networks:
- colectivo
# ── Realtime ─────────────────────────────────────────────────────────────────
realtime:
image: supabase/realtime:v2.30.23
restart: always
depends_on:
db:
condition: service_healthy
environment:
PORT: 4000
DB_HOST: db
DB_PORT: 5432
DB_USER: supabase_replication_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime
DB_ENC_KEY: ${REALTIME_ENC_KEY}
API_JWT_SECRET: ${SUPABASE_JWT_SECRET}
FLY_ALLOC_ID: fly123
FLY_APP_NAME: realtime
SECRET_KEY_BASE: ${REALTIME_SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
ENABLE_TAILSCALE: "false"
DNS_NODES: "''"
command: sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server"
networks:
- colectivo
# ── Storage ──────────────────────────────────────────────────────────────────
storage:
image: supabase/storage-api:v1.11.13
restart: always
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
environment:
ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: colectivo-prod
REGION: local
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
volumes:
- storage-data:/var/lib/storage
networks:
- colectivo
# ── ImgProxy ─────────────────────────────────────────────────────────────
imgproxy:
image: darthsim/imgproxy:v3.8.0
restart: always
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
volumes:
- storage-data:/var/lib/storage:ro
networks:
- colectivo
# ── Kong (API Gateway) ─────────────────────────────────────────────────────
# nginx proxies :80/:443 → Kong :8000
kong:
image: kong:2.8.1
restart: always
ports:
- "127.0.0.1:8000:8000" # localhost only — nginx handles TLS termination
depends_on:
db:
condition: service_healthy
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
volumes:
- ./kong.yml:/home/kong/kong.yml:ro
networks:
- colectivo
# ── pg-meta ───────────────────────────────────────────────────────────────
meta:
image: supabase/postgres-meta:v0.83.2
restart: always
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PORT: 5432
PG_META_DB_NAME: postgres
PG_META_DB_USER: supabase
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- colectivo
# ── Keycloak 24 ──────────────────────────────────────────────────────────
# nginx proxies auth.yourdomain.com → keycloak :8080
# IMPORTANT: X-Forwarded-Host and X-Forwarded-Port headers must be set by nginx
# or Keycloak will build redirect URIs with internal port 8080.
keycloak:
image: quay.io/keycloak/keycloak:24.0.5
restart: always
ports:
- "127.0.0.1:8081:8080" # localhost only — nginx proxies auth.* subdomain
command: start --optimized
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_HOSTNAME: ${KEYCLOAK_HOSTNAME}
KC_HOSTNAME_STRICT: "true"
KC_PROXY: edge
KC_HTTP_ENABLED: "true"
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_FEATURES: token-exchange
networks:
- colectivo
# ── App (SvelteKit Node server) ───────────────────────────────────────────
app:
image: ghcr.io/${GITHUB_REPOSITORY}/colectivo-web:${IMAGE_TAG:-latest}
restart: always
ports:
- "127.0.0.1:3000:3000" # localhost only — nginx proxies app.* → here
environment:
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
PUBLIC_KEYCLOAK_URL: ${PUBLIC_KEYCLOAK_URL}
PUBLIC_KEYCLOAK_REALM: ${PUBLIC_KEYCLOAK_REALM}
PUBLIC_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID}
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
NODE_ENV: production
networks:
- colectivo
networks:
colectivo:
driver: bridge
volumes:
db-data:
storage-data:

94
infra/kong.yml Normal file
View File

@@ -0,0 +1,94 @@
_format_version: "1.1"
# Kong declarative config for local dev.
# Routes: /rest/ → postgrest, /auth/ → gotrue, /storage/ → storage-api,
# /realtime/ → realtime, /functions/ → edge-runtime
services:
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- anon
- service
- name: graphql-v1
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- Content-Profile:graphql_public
- name: realtime-v1
url: http://realtime:4000/socket
routes:
- name: realtime-v1-all
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: storage-v1
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: meta
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
consumers:
- username: anon
keyauth_credentials:
- key: ${SUPABASE_ANON_KEY}
acls:
- group: anon
- username: service_role
keyauth_credentials:
- key: ${SUPABASE_SERVICE_KEY}
acls:
- group: service

27
infra/scripts/backup-cron.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# backup-cron.sh — intended to be run by cron on the production host.
# Example crontab entry (daily at 03:00 UTC):
# 0 3 * * * /opt/colectivo/infra/scripts/backup-cron.sh >> /var/log/colectivo-backup.log 2>&1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_PREFIX="[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] backup-cron:"
# Load env vars from the production .env
if [ -f "$SCRIPT_DIR/../../.env" ]; then
# shellcheck disable=SC1091
set -a; source "$SCRIPT_DIR/../../.env"; set +a
fi
echo "$LOG_PREFIX Starting scheduled backup"
"$SCRIPT_DIR/backup.sh" supabase
echo "$LOG_PREFIX PostgreSQL backup done"
"$SCRIPT_DIR/backup.sh" keycloak
echo "$LOG_PREFIX Keycloak backup done"
# Prune local temp files older than 1 day
find /tmp/colectivo-backups -type f -mtime +1 -delete 2>/dev/null || true
echo "$LOG_PREFIX All done"

49
infra/scripts/backup.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# backup.sh — manual backup of a service
# Usage: just backup supabase
# Uploads a timestamped dump to Backblaze B2.
set -euo pipefail
SERVICE="${1:-supabase}"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
BACKUP_DIR="${BACKUP_DIR:-/tmp/colectivo-backups}"
mkdir -p "$BACKUP_DIR"
case "$SERVICE" in
supabase|postgres|db)
DUMP_FILE="$BACKUP_DIR/postgres-$TIMESTAMP.dump"
echo "==> Dumping PostgreSQL → $DUMP_FILE"
docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" exec -T db \
pg_dumpall -U postgres --clean --if-exists \
| gzip > "$DUMP_FILE.gz"
DUMP_FILE="$DUMP_FILE.gz"
;;
keycloak)
DUMP_FILE="$BACKUP_DIR/keycloak-$TIMESTAMP.json"
echo "==> Exporting Keycloak realm → $DUMP_FILE"
docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" exec keycloak \
/opt/keycloak/bin/kc.sh export \
--realm colectivo \
--users realm_file \
--file /tmp/kc-export.json
docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" cp \
keycloak:/tmp/kc-export.json "$DUMP_FILE"
gzip "$DUMP_FILE"
DUMP_FILE="$DUMP_FILE.gz"
;;
*)
echo "Unknown service: $SERVICE. Use 'supabase' or 'keycloak'." >&2
exit 1
;;
esac
echo "==> Uploading $(basename "$DUMP_FILE") to B2 bucket: ${B2_BUCKET_NAME}"
b2 file upload \
--contentType application/octet-stream \
"$B2_BUCKET_NAME" \
"$DUMP_FILE" \
"backups/$(basename "$DUMP_FILE")"
echo "==> Backup complete: $(basename "$DUMP_FILE")"
rm -f "$DUMP_FILE"

53
infra/scripts/deploy.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# deploy.sh — deploy to production via SSH
# Called by: just deploy
#
# Prerequisites:
# - DEPLOY_HOST and DEPLOY_PATH set in .env
# - SSH key configured for passwordless access
# - Docker image already pushed to GHCR (done by CI)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load env
if [ -f "$SCRIPT_DIR/../../.env" ]; then
# shellcheck disable=SC1091
set -a; source "$SCRIPT_DIR/../../.env"; set +a
fi
DEPLOY_HOST="${DEPLOY_HOST:?Set DEPLOY_HOST in .env}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
echo "==> Deploying colectivo to $DEPLOY_HOST:$DEPLOY_PATH (tag: $IMAGE_TAG)"
# 1. Copy compose file and scripts to server
rsync -az --delete \
"$SCRIPT_DIR/../docker-compose.prod.yml" \
"$SCRIPT_DIR/../kong.yml" \
"$DEPLOY_HOST:$DEPLOY_PATH/infra/"
# 2. SSH: pull image, restart services with zero downtime
ssh "$DEPLOY_HOST" bash -s << EOF
set -euo pipefail
cd "$DEPLOY_PATH"
echo "--- Pulling latest image (tag: $IMAGE_TAG)"
IMAGE_TAG="$IMAGE_TAG" docker compose -f infra/docker-compose.prod.yml pull app
echo "--- Rolling restart of app service"
IMAGE_TAG="$IMAGE_TAG" docker compose -f infra/docker-compose.prod.yml up -d --no-deps app
echo "--- Running pending DB migrations"
docker compose -f infra/docker-compose.prod.yml exec -T db \
psql -U postgres -f /docker-entrypoint-initdb.d/migrations.sql 2>/dev/null || true
echo "--- Pruning old images"
docker image prune -f
echo "--- Deploy done"
EOF
echo "==> Deployment complete"

37
infra/scripts/restore.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# restore.sh — restore a backup file to a service
# Usage: just restore supabase backups/postgres-20240101T030000Z.dump.gz
#
# WARNING: This is destructive. It will drop and re-create the database.
# Only run on a stopped or isolated instance.
set -euo pipefail
SERVICE="${1:?Usage: restore.sh <service> <file>}"
FILE="${2:?Usage: restore.sh <service> <file>}"
if [ ! -f "$FILE" ]; then
echo "File not found: $FILE" >&2
exit 1
fi
COMPOSE="docker compose -f $(dirname "$0")/../docker-compose.prod.yml"
case "$SERVICE" in
supabase|postgres|db)
echo "==> Restoring PostgreSQL from $FILE"
echo " WARNING: This will overwrite all data. Press Ctrl-C within 5s to abort."
sleep 5
if [[ "$FILE" == *.gz ]]; then
gunzip -c "$FILE" | $COMPOSE exec -T db psql -U postgres
else
$COMPOSE exec -T db psql -U postgres < "$FILE"
fi
echo "==> Restore complete"
;;
*)
echo "Unknown service: $SERVICE. Use 'supabase'." >&2
exit 1
;;
esac

141
keycloak/realm-export.json Normal file
View File

@@ -0,0 +1,141 @@
{
"id": "colectivo",
"realm": "colectivo",
"displayName": "Colectivo",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"accessTokenLifespan": 3600,
"refreshTokenMaxReuse": 0,
"offlineSessionMaxLifespanEnabled": false,
"clients": [
{
"clientId": "colectivo-web",
"name": "Colectivo Web App",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256"
},
"redirectUris": [
"http://localhost:5173/*",
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:5173",
"http://localhost:3000"
],
"fullScopeAllowed": true,
"defaultClientScopes": [
"web-origins",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"users": [
{
"id": "11111111-1111-1111-1111-111111111111",
"username": "ana",
"email": "ana@dev.local",
"emailVerified": true,
"enabled": true,
"firstName": "Ana",
"lastName": "García",
"credentials": [
{
"type": "password",
"value": "test1234",
"temporary": false
}
]
},
{
"id": "22222222-2222-2222-2222-222222222222",
"username": "borja",
"email": "borja@dev.local",
"emailVerified": true,
"enabled": true,
"firstName": "Borja",
"lastName": "López",
"credentials": [
{
"type": "password",
"value": "test1234",
"temporary": false
}
]
},
{
"id": "33333333-3333-3333-3333-333333333333",
"username": "carmen",
"email": "carmen@dev.local",
"emailVerified": true,
"enabled": true,
"firstName": "Carmen",
"lastName": "Martínez",
"credentials": [
{
"type": "password",
"value": "test1234",
"temporary": false
}
]
},
{
"id": "44444444-4444-4444-4444-444444444444",
"username": "david",
"email": "david@dev.local",
"emailVerified": true,
"enabled": true,
"firstName": "David",
"lastName": "Fernández",
"credentials": [
{
"type": "password",
"value": "test1234",
"temporary": false
}
]
},
{
"id": "55555555-5555-5555-5555-555555555555",
"username": "eva",
"email": "eva@dev.local",
"emailVerified": true,
"enabled": true,
"firstName": "Eva",
"lastName": "Ruiz",
"credentials": [
{
"type": "password",
"value": "test1234",
"temporary": false
}
]
}
],
"scopeMappings": [],
"clientScopeMappings": {},
"roles": {
"realm": [],
"client": {}
}
}

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "colectivo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
"check": "turbo run check",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "^2.3.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@colectivo/types",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED — do not edit manually.
// Run `just db-types` to regenerate from Supabase schema.
// Placeholder until migrations exist and `supabase gen types typescript` can run.
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export interface Database {
public: {
Tables: Record<string, never>;
Views: Record<string, never>;
Functions: Record<string, never>;
Enums: Record<string, never>;
};
}

View File

@@ -0,0 +1,127 @@
// Domain types for the Colectivo app.
// These are hand-written and represent business concepts.
// database.ts is generated — never edit it manually.
export type AvatarType = 'initials' | 'emoji' | 'upload';
export type MemberRole = 'admin' | 'member' | 'guest';
export type ListStatus = 'active' | 'completed' | 'archived';
export type NoteColor =
| 'default'
| 'slate'
| 'rose'
| 'orange'
| 'amber'
| 'emerald'
| 'cyan'
| 'violet';
export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent';
export type Language = 'en' | 'es';
export interface User {
id: string;
email: string;
display_name: string;
language: Language;
avatar_type: AvatarType;
avatar_emoji: string | null;
avatar_url: string | null;
created_at: string;
}
export interface Collective {
id: string;
name: string;
emoji: string;
created_by: string;
created_at: string;
}
export interface CollectiveMember {
collective_id: string;
user_id: string;
role: MemberRole;
joined_at: string;
}
export interface CollectiveInvitation {
id: string;
collective_id: string;
invited_by: string;
email: string;
token: string;
role: MemberRole;
expires_at: string;
accepted_at: string | null;
created_at: string;
}
export interface ShoppingList {
id: string;
collective_id: string;
name: string;
status: ListStatus;
created_by: string;
created_at: string;
completed_at: string | null;
deleted_at: string | null;
}
export interface ShoppingItem {
id: string;
list_id: string;
name: string;
quantity: number | null;
unit: string | null;
is_checked: boolean;
checked_by: string | null;
checked_at: string | null;
sort_order: number;
created_by: string;
created_at: string;
deleted_at: string | null;
}
export interface ItemFrequency {
collective_id: string;
name: string; // normalized: lower(trim())
use_count: number;
last_used_at: string;
}
export interface TaskList {
id: string;
collective_id: string;
name: string;
created_by: string;
created_at: string;
deleted_at: string | null;
}
export interface Task {
id: string;
task_list_id: string;
name: string;
is_completed: boolean;
completed_by: string | null;
completed_at: string | null;
priority: TaskPriority;
due_date: string | null;
sort_order: number;
created_by: string;
created_at: string;
deleted_at: string | null;
}
export interface Note {
id: string;
collective_id: string;
title: string | null;
content: string;
color: NoteColor;
is_pinned: boolean;
is_archived: boolean;
created_by: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}

View File

@@ -0,0 +1,2 @@
export * from './domain.js';
export * from './database.js';

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

82
supabase/config.toml Normal file
View File

@@ -0,0 +1,82 @@
# Supabase local development configuration.
# See: https://supabase.com/docs/guides/cli/config
[api]
enabled = true
port = 54321
schemas = ["public", "storage", "graphql_public"]
extra_search_path = ["public", "extensions"]
max_rows = 1000
[api.tls]
enabled = false
[db]
port = 54322
shadow_port = 54320
major_version = 15
[db.pooler]
enabled = false
port = 54329
pool_mode = "transaction"
default_pool_size = 20
max_client_conn = 100
[realtime]
enabled = true
ip_version = "IPv4"
max_header_length = 4096
[studio]
enabled = true
port = 54323
api_url = "http://127.0.0.1"
openai_api_key = ""
[inbucket]
enabled = true
port = 54324
[storage]
enabled = true
file_size_limit = "50MiB"
[storage.image_transformation]
enabled = true
[auth]
enabled = true
site_url = "http://127.0.0.1:5173"
additional_redirect_urls = ["http://localhost:5173"]
jwt_expiry = 3600
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
enable_signup = false
enable_anonymous_sign_ins = false
enable_manual_linking = false
[auth.email]
enable_signup = false
double_confirm_changes = true
enable_confirmations = false
[auth.sms]
enable_signup = false
enable_confirmations = false
# Keycloak as OIDC provider
# The JWT_SECRET must be set to the Keycloak RS256 public key
[auth.third_party.keycloak]
enabled = false # We bypass GoTrue — Keycloak JWT validated directly by PostgREST
[edge_runtime]
enabled = true
policy = "oneshot"
inspector_port = 8083
[analytics]
enabled = false
port = 54327
vector_port = 54328
backend = "postgres"

60
supabase/seed.sql Normal file
View File

@@ -0,0 +1,60 @@
-- seed.sql — development seed data
-- Mirrors Keycloak test users (UUIDs must match realm-export.json)
-- Password for all users: test1234
-- ── Users ─────────────────────────────────────────────────────────────────────
-- Inserted into the public.users table (synced from Keycloak via trigger in Fase 1)
-- For Fase 0 we insert directly since the trigger isn't written yet.
INSERT INTO public.users (id, email, display_name, language, avatar_type)
VALUES
('11111111-1111-1111-1111-111111111111', 'ana@dev.local', 'Ana García', 'es', 'initials'),
('22222222-2222-2222-2222-222222222222', 'borja@dev.local', 'Borja López', 'es', 'initials'),
('33333333-3333-3333-3333-333333333333', 'carmen@dev.local', 'Carmen Martínez', 'es', 'initials'),
('44444444-4444-4444-4444-444444444444', 'david@dev.local', 'David Fernández', 'es', 'initials'),
('55555555-5555-5555-5555-555555555555', 'eva@dev.local', 'Eva Ruiz', 'es', 'initials')
ON CONFLICT (id) DO NOTHING;
-- ── Sample collective ──────────────────────────────────────────────────────────
INSERT INTO public.collectives (id, name, emoji, created_by)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Casa García-López', '🏠', '11111111-1111-1111-1111-111111111111')
ON CONFLICT (id) DO NOTHING;
-- ── Collective members ─────────────────────────────────────────────────────────
-- Ana: admin, Borja: member, Carmen: member, David: guest
-- Eva has no collective (for onboarding testing)
INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'admin'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'member'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '33333333-3333-3333-3333-333333333333', 'member'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '44444444-4444-4444-4444-444444444444', 'guest')
ON CONFLICT (collective_id, user_id) DO NOTHING;
-- ── Sample shopping list ───────────────────────────────────────────────────────
INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Weekly shop', 'active', '11111111-1111-1111-1111-111111111111')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by)
VALUES
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk', 2, 'L', 1, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, NULL, 2, '22222222-2222-2222-2222-222222222222'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs', 1, 'doz', 3, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, NULL, 4, '33333333-3333-3333-3333-333333333333'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, NULL, 5, '22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
-- ── Item frequency baseline (from pre-existing items above) ────────────────────
INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at)
VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'milk', 5, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bread', 4, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'eggs', 3, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'coffee', 3, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'apples', 2, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'butter', 2, now()),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'yogurt', 1, now())
ON CONFLICT (collective_id, name) DO UPDATE SET
use_count = EXCLUDED.use_count,
last_used_at = EXCLUDED.last_used_at;

22
turbo.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".svelte-kit/**", "build/**", "dist/**"]
},
"check": {
"dependsOn": ["^build"]
},
"lint": {},
"test": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}