Add Compose, Gitea deploy, and Postgres dual-backend on develop.
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / backend-sqlite (push) Failing after 2s
Test Suite / frontend-build (push) Successful in 23s

Use host ports 3006/8005 and 3096/8096 so Kansho does not collide with Bookstack on 3005 or the sister products.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-07 12:15:08 +02:00
parent 32acdc220b
commit 07479f6a9f
37 changed files with 1613 additions and 102 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
.git
.gitea
**/.venv
**/__pycache__
**/*.pyc
backend/data
backend/.env
frontend/node_modules
frontend/dist
local-backups
transfer
tmp
temp
docs
*.md
!frontend/README.md

27
.env.example Normal file
View File

@ -0,0 +1,27 @@
# Compose secrets on the Pi. Copy to /home/lars/docker/kansho/.env and
# /home/lars/docker/kansho-dev/.env. Never commit the filled file.
# Local Windows development keeps using backend/.env (SQLite, ports 5188/8018).
# ─── DEV (docker-compose.dev-env.yml) ────────────────────────────────────────
# DB_NAME=kansho_dev
# DB_USER=kansho_dev
# DB_PASSWORD=dev_password_change_me
# APP_URL=https://dev.kansho.jinkendo.de
# ALLOWED_ORIGINS=https://dev.kansho.jinkendo.de,http://192.168.2.49:3096
# KANSHO_ENV=development
# KANSHO_FRONTEND_PORT=3096
# KANSHO_BACKEND_PORT=8096
# KANSHO_PROVIDER_KEY=
# KANSHO_DETECT_PROVIDER_KEY=
# ─── PROD (docker-compose.yml) ───────────────────────────────────────────────
DB_NAME=kansho
DB_USER=kansho
DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
APP_URL=https://kansho.jinkendo.de
ALLOWED_ORIGINS=https://kansho.jinkendo.de
KANSHO_ENV=production
KANSHO_FRONTEND_PORT=3006
KANSHO_BACKEND_PORT=8005
KANSHO_PROVIDER_KEY=
KANSHO_DETECT_PROVIDER_KEY=

View File

@ -0,0 +1,40 @@
name: Deploy Development
on:
push:
branches: [develop]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy Kanshō to development
run: |
set -e
echo "=== Deploying Kanshō to DEVELOPMENT ==="
REPO="http://192.168.2.144:3000/Lars/Kansho.git"
TARGET="/home/lars/docker/kansho-dev"
mkdir -p "$TARGET"
cd "$TARGET"
if [ ! -d .git ]; then
git clone -b develop "$REPO" .
else
git fetch origin develop
git checkout develop
git reset --hard origin/develop
fi
docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend
if ! docker compose -f docker-compose.dev-env.yml up -d --wait; then
echo "compose up --wait failed — backend logs:"
docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true
docker compose -f docker-compose.dev-env.yml ps || true
exit 1
fi
if ! curl -sf http://localhost:8096/api/health; then
echo "DEV API not reachable — backend logs:"
docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true
exit 1
fi
echo "DEV API /api/health OK"
curl -sf http://localhost:3096/api/health && echo "DEV frontend proxy /api/health OK"
echo "=== Kanshō DEV deploy complete ==="

View File

@ -0,0 +1,40 @@
name: Deploy Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy Kanshō to production
run: |
set -e
echo "=== Deploying Kanshō to PRODUCTION ==="
REPO="http://192.168.2.144:3000/Lars/Kansho.git"
TARGET="/home/lars/docker/kansho"
mkdir -p "$TARGET"
cd "$TARGET"
if [ ! -d .git ]; then
git clone -b main "$REPO" .
else
git fetch origin main
git checkout main
git reset --hard origin/main
fi
docker compose build --no-cache backend frontend
if ! docker compose up -d --wait; then
echo "compose up --wait failed — backend logs:"
docker compose logs backend --tail 150 || true
docker compose ps || true
exit 1
fi
if ! curl -sf http://localhost:8005/api/health; then
echo "PROD API not reachable — backend logs:"
docker compose logs backend --tail 150 || true
exit 1
fi
echo "PROD API /api/health OK"
curl -sf http://localhost:3006/api/health && echo "PROD frontend proxy /api/health OK"
echo "=== Kanshō PROD deploy complete ==="

67
.gitea/workflows/test.yml Normal file
View File

@ -0,0 +1,67 @@
name: Test Suite
on:
push:
branches: [develop, main]
pull_request:
branches: [develop]
jobs:
backend-sqlite:
runs-on: ubuntu-latest
steps:
- name: Backend tests against isolated SQLite
run: |
set -e
EVENT_REF="${{ github.ref_name }}"
BASE_REF="${{ github.base_ref }}"
APP_DIR="/home/lars/docker/kansho"
COMPOSE_FILE="docker-compose.yml"
if [ "$EVENT_REF" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
APP_DIR="/home/lars/docker/kansho-dev"
COMPOSE_FILE="docker-compose.dev-env.yml"
fi
echo "tests against ${APP_DIR} (${COMPOSE_FILE})"
cd "$APP_DIR"
for i in $(seq 1 60); do
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
echo "backend ready (attempt $i)"
break
fi
if [ "$i" -eq 60 ]; then
echo "timeout waiting for backend"
docker compose -f "$COMPOSE_FILE" ps || true
exit 1
fi
sleep 5
done
docker compose -f "$COMPOSE_FILE" exec -T \
-e KANSHO_DB_BACKEND=sqlite \
-e KANSHO_DB_PATH=/tmp/kansho-ci.sqlite \
-e KANSHO_PROVIDER_KEY= \
-e KANSHO_DETECT_PROVIDER_KEY= \
-e PYTHONUTF8=1 \
backend sh -lc '
set -e
rm -f /tmp/kansho-ci.sqlite
for test in tests/test_*.py; do
echo "=== $test ==="
python "$test"
done
'
frontend-build:
runs-on: ubuntu-latest
steps:
- name: Frontend production build on deploy checkout
run: |
set -e
EVENT_REF="${{ github.ref_name }}"
BASE_REF="${{ github.base_ref }}"
APP_DIR="/home/lars/docker/kansho"
if [ "$EVENT_REF" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
APP_DIR="/home/lars/docker/kansho-dev"
fi
cd "$APP_DIR/frontend"
npm ci
npm run build

8
.gitignore vendored
View File

@ -38,7 +38,7 @@ Thumbs.db
*.py[cod]
.pytest_cache/
# Test / local data
# Local data (never commit)
tmp/
temp/
data/local/
@ -47,7 +47,5 @@ local-backups/
*.kansho-backup.zip
*.db
# One-time self-hosted Gitea transport of the laptop backup (2026-09-07).
# Not a product datastore. Remove transfer/ after restore.
!transfer/
!transfer/*.zip
# Personal journal zip was a one-time Gitea transport. Do not re-add.
transfer/

View File

@ -91,14 +91,9 @@ Restore bestätigt ausdrücklich, legt vorher ein Sicherheitsbackup an und über
## Transfer auf die Heim-Umgebung
Persönliche Daten liegen **nicht** beim Provider. Der einmalige Laptop-Transport (kein Stick/NAS auf diesem Gerät) läuft über das selbst gehostete Gitea:
Persönliche Daten liegen **nicht** beim Provider. Der einmalige Laptop-Transport lief über das selbst gehostete Gitea. Nach dem Restore auf dem Heimrechner `transfer/` aus dem Arbeitsbaum entfernen (Historie behält das Zip).
1. `git pull` muss `transfer/kansho-laptop-20260907.zip` enthalten.
2. `.\scripts\dev-setup.ps1`, `backend/.env` aus der Example plus Keys.
3. Restore: `.\scripts\backup-local.ps1 restore -Archive .\transfer\kansho-laptop-20260907.zip -Confirm -Replace`
4. Docker/Postgres sind Welle 2, nicht der erste Restore.
Details: `docs/architecture/technical/environment_handover.md`, `transfer/README.md`.
Docker/Postgres, Gitea-Deploy und Cutover: `docs/DEPLOYMENT.md` und `docs/architecture/technical/runtime_and_deploy.md`.
## Lokal weiterarbeiten

View File

@ -22,3 +22,11 @@ KANSHO_DETECT_PROVIDER_KEY=
# Nur Tests
# KANSHO_FAKE_PROVIDER=1
# KANSHO_FAKE_DETECT=1
# Docker/Server only. Local Windows stays SQLite (default).
# KANSHO_DB_BACKEND=postgres
# DB_HOST=postgres
# DB_NAME=kansho
# DB_USER=kansho
# DB_PASSWORD=

18
backend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
ENV PIP_DEFAULT_TIMEOUT=120
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/startup.sh \
&& mkdir -p /app/data/media
EXPOSE 8000
CMD ["/app/startup.sh"]

View File

@ -1,4 +1,8 @@
"""SQLite persistence for the local frame. PostgreSQL remains the later target."""
"""Persistence for the local frame.
SQLite is the Windows/test engine. PostgreSQL 16 is the Docker/server engine.
Switch at connect time via KANSHO_DB_BACKEND / DB_HOST; stores keep SQLite-shaped SQL.
"""
from __future__ import annotations
import json
@ -6,14 +10,49 @@ import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Any
DATA_DIR = Path(__file__).resolve().parent / "data"
from sql_compat import (
adapt_sql,
postgres_connect_kwargs,
rewrite_catalog_sql,
split_sql,
sqlite_schema_to_postgres,
use_postgres,
)
_env_data = (os.environ.get("KANSHO_DATA_DIR") or "").strip()
DATA_DIR = Path(_env_data) if _env_data else Path(__file__).resolve().parent / "data"
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql"
SEED_PATH = Path(__file__).resolve().parent / "config" / "platform_seed.json"
PROMPTS_SEED_PATH = Path(__file__).resolve().parent / "config" / "prompts.seed.json"
_env_db = os.environ.get("KANSHO_DB_PATH")
DB_PATH = Path(_env_db) if _env_db else DATA_DIR / "kansho.sqlite"
SQLITE_HISTORY_MIGRATIONS = (
"001_frame",
"002_platform",
"003_dialogue_memory",
"004_mvp_journal",
"005_provider_settings",
"006_writing_profile_dialogue_style",
"007_journal_day_scratch",
"008_journal_source_refs",
"009_conversation_signals",
"010_profile_governance",
"011_profile_review",
"012_profile_shell",
"013_journal_generate_narration",
"014_identity_registry",
"015_journal_generation_settings",
"016_generation_instruction_fragments",
"017_generation_guidelines",
"018_debug_runs",
"019_debug_run_placement",
"020_voice_style_context",
"021_voice_legacy_immutable",
)
_PROFILE_COLUMNS = {
"status": "TEXT NOT NULL DEFAULT 'active'",
"tier_id": "TEXT NOT NULL DEFAULT 'local'",
@ -88,7 +127,61 @@ _IDENTITY_MAPPING_COLUMNS = {
}
def _connect() -> sqlite3.Connection:
class PostgresCompat:
"""sqlite3-like execute/commit surface over psycopg, with SQL translation."""
def __init__(self, raw: Any):
self._raw = raw
def execute(self, sql: str, params: Any = None):
special = rewrite_catalog_sql(sql)
if special:
adapted, extra = special
args = extra if extra is not None else params
else:
adapted = adapt_sql(sql)
args = params
if args is None:
return self._raw.execute(adapted)
return self._raw.execute(adapted, args)
def executemany(self, sql: str, seq_of_params: Any):
adapted = adapt_sql(sql)
cursor = None
for params in seq_of_params:
cursor = self._raw.execute(adapted, params)
return cursor
def executescript(self, script: str):
for statement in split_sql(sqlite_schema_to_postgres(script)):
self._raw.execute(statement)
return self
def commit(self):
self._raw.commit()
def rollback(self):
self._raw.rollback()
def close(self):
self._raw.close()
def _connect_postgres() -> PostgresCompat:
import psycopg
from psycopg.rows import dict_row
kwargs = postgres_connect_kwargs()
if "conninfo" in kwargs:
raw = psycopg.connect(kwargs["conninfo"], row_factory=dict_row)
else:
raw = psycopg.connect(row_factory=dict_row, **kwargs)
return PostgresCompat(raw)
def _connect():
if use_postgres():
return _connect_postgres()
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
@ -109,32 +202,32 @@ def get_db():
conn.close()
def row_to_dict(row: sqlite3.Row | None) -> dict | None:
def row_to_dict(row: Any) -> dict | None:
if row is None:
return None
return dict(row)
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
def _column_names(conn: Any, table: str) -> set[str]:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return {row["name"] for row in rows}
def _ensure_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
def _ensure_columns(conn: Any, table: str, columns: dict[str, str]) -> None:
existing = _column_names(conn, table)
for name, ddl in columns.items():
if name not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
def _mark(conn: sqlite3.Connection, migration_id: str) -> None:
def _mark(conn: Any, migration_id: str) -> None:
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)",
(migration_id,),
)
def _seed_platform(conn: sqlite3.Connection) -> None:
def _seed_platform(conn: Any) -> None:
seed = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for tier in seed.get("tiers", []):
conn.execute(
@ -171,7 +264,7 @@ def _seed_platform(conn: sqlite3.Connection) -> None:
)
def _seed_prompts(conn: sqlite3.Connection) -> None:
def _seed_prompts(conn: Any) -> None:
"""Prompts come from JSON/DB, never from Python string literals.
Untouched system prompts (template == default_template) receive the seed.
@ -246,7 +339,7 @@ def _parse_legacy_ids(raw: str | None) -> list[str]:
def _insert_source_refs(
conn: sqlite3.Connection,
conn: Any,
table: str,
owner_col: str,
owner_id: str,
@ -275,7 +368,7 @@ def _insert_source_refs(
)
def migrate_journal_source_refs(conn: sqlite3.Connection) -> None:
def migrate_journal_source_refs(conn: Any) -> None:
"""Copy JSON id lists into relational source-ref tables without touching Source."""
existing = {
row["name"]
@ -323,7 +416,7 @@ def migrate_journal_source_refs(conn: sqlite3.Connection) -> None:
)
def _migrate_writing_profile_dialogue_style(conn: sqlite3.Connection) -> None:
def _migrate_writing_profile_dialogue_style(conn: Any) -> None:
"""Existing DBs keep the old CHECK; recreate so dialogue_style is a valid source kind."""
row = row_to_dict(
conn.execute(
@ -354,7 +447,7 @@ def _migrate_writing_profile_dialogue_style(conn: sqlite3.Connection) -> None:
)
def _migrate_writing_profile_shell(conn: sqlite3.Connection) -> None:
def _migrate_writing_profile_shell(conn: Any) -> None:
"""Layers stay; inferred count-facets are not the profile. Manual style keys become traits."""
from writing_profile_schema import LEGACY_STYLE_KEYS, coerce_slug, facet_layer, normalize_facet_key
@ -449,7 +542,31 @@ def _migrate_writing_profile_shell(conn: sqlite3.Connection) -> None:
)
def _seed_runtime(conn: Any) -> None:
_seed_platform(conn)
_seed_prompts(conn)
from provider_settings import seed_provider_settings
from journal_generation_policy import backfill_missing_settings, seed_generation_instructions
seed_provider_settings(conn)
seed_generation_instructions(conn)
backfill_missing_settings(conn)
seed_generation_instructions(conn)
seed_generation_instructions(conn)
def init_db() -> None:
if use_postgres():
from db_init import ensure_postgres_ready
ensure_postgres_ready()
with get_db() as conn:
_seed_runtime(conn)
from writing_profile_store import bootstrap_from_existing
bootstrap_from_existing()
return
schema = SCHEMA_PATH.read_text(encoding="utf-8")
with get_db() as conn:
conn.executescript(schema)
@ -474,35 +591,16 @@ def init_db() -> None:
from identity_store import migrate_legacy_identity_rows
migrate_legacy_identity_rows(conn)
_mark(conn, "001_frame")
_mark(conn, "002_platform")
_mark(conn, "003_dialogue_memory")
_mark(conn, "004_mvp_journal")
_mark(conn, "005_provider_settings")
_mark(conn, "006_writing_profile_dialogue_style")
_mark(conn, "007_journal_day_scratch")
_mark(conn, "008_journal_source_refs")
_mark(conn, "009_conversation_signals")
_mark(conn, "010_profile_governance")
_mark(conn, "011_profile_review")
_mark(conn, "012_profile_shell")
_mark(conn, "013_journal_generate_narration")
_mark(conn, "014_identity_registry")
for migration_id in SQLITE_HISTORY_MIGRATIONS:
_mark(conn, migration_id)
from journal_generation_policy import backfill_missing_settings, seed_generation_instructions
_ensure_columns(conn, "generation_guidelines", _GUIDELINE_COLUMNS)
seed_generation_instructions(conn)
backfill_missing_settings(conn)
_mark(conn, "015_journal_generation_settings")
_mark(conn, "016_generation_instruction_fragments")
_mark(conn, "017_generation_guidelines")
_mark(conn, "018_debug_runs")
_ensure_columns(conn, "debug_runs", _DEBUG_RUN_COLUMNS)
_mark(conn, "019_debug_run_placement")
seed_generation_instructions(conn)
_mark(conn, "020_voice_style_context")
seed_generation_instructions(conn)
_mark(conn, "021_voice_legacy_immutable")
from writing_profile_store import bootstrap_from_existing
bootstrap_from_existing()

156
backend/db_init.py Normal file
View File

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Wait for PostgreSQL, load schema.sql, apply numbered SQL migrations. Fail-fast."""
from __future__ import annotations
import os
import re
import sys
import time
from pathlib import Path
from sql_compat import postgres_connect_kwargs, split_sql, sqlite_schema_to_postgres, use_postgres
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql"
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
SQLITE_HISTORY_MIGRATIONS = (
"001_frame",
"002_platform",
"003_dialogue_memory",
"004_mvp_journal",
"005_provider_settings",
"006_writing_profile_dialogue_style",
"007_journal_day_scratch",
"008_journal_source_refs",
"009_conversation_signals",
"010_profile_governance",
"011_profile_review",
"012_profile_shell",
"013_journal_generate_narration",
"014_identity_registry",
"015_journal_generation_settings",
"016_generation_instruction_fragments",
"017_generation_guidelines",
"018_debug_runs",
"019_debug_run_placement",
"020_voice_style_context",
"021_voice_legacy_immutable",
)
_LEADING_DIGITS = re.compile(r"^(\d{3})_.*\.sql$")
def _connect_raw():
import psycopg
kwargs = postgres_connect_kwargs()
if "conninfo" in kwargs:
return psycopg.connect(kwargs["conninfo"], autocommit=False)
return psycopg.connect(autocommit=False, **kwargs)
def wait_for_postgres(max_retries: int = 30) -> None:
print("Checking PostgreSQL connection...")
last_error = None
for attempt in range(1, max_retries + 1):
try:
conn = _connect_raw()
conn.close()
print("PostgreSQL ready")
return
except Exception as exc: # noqa: BLE001 — fail-fast after retries
last_error = exc
print(f" waiting ({attempt}/{max_retries})")
time.sleep(2)
print(f"PostgreSQL not ready: {last_error}")
sys.exit(1)
def _table_exists(conn, name: str) -> bool:
row = conn.execute(
"""
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
""",
(name,),
).fetchone()
return row is not None
def _load_schema(conn) -> None:
sql = sqlite_schema_to_postgres(SCHEMA_PATH.read_text(encoding="utf-8"))
for statement in split_sql(sql):
conn.execute(statement)
print("Schema loaded from schema.sql (Postgres dialect)")
def _applied_ids(conn) -> set[str]:
rows = conn.execute("SELECT id FROM schema_migrations").fetchall()
return {row[0] for row in rows}
def _record(conn, migration_id: str) -> None:
conn.execute(
"INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING",
(migration_id,),
)
def _migration_files() -> list[tuple[str, Path]]:
if not MIGRATIONS_DIR.is_dir():
return []
rows: list[tuple[str, Path]] = []
for path in sorted(MIGRATIONS_DIR.iterdir()):
match = _LEADING_DIGITS.match(path.name)
if not match or path.suffix != ".sql":
continue
rows.append((path.stem, path))
return rows
def apply_schema_and_migrations() -> None:
conn = _connect_raw()
try:
if not _table_exists(conn, "schema_migrations"):
_load_schema(conn)
conn.commit()
applied = _applied_ids(conn)
if "001_frame" not in applied:
for migration_id in SQLITE_HISTORY_MIGRATIONS:
_record(conn, migration_id)
conn.commit()
print("Recorded SQLite history 001-021 as applied (greenfield Postgres schema)")
applied = _applied_ids(conn)
for stem, path in _migration_files():
if stem in applied:
continue
sql = path.read_text(encoding="utf-8")
for statement in split_sql(sql):
conn.execute(statement)
_record(conn, stem)
conn.commit()
print(f"Applied migration {stem}")
except Exception as exc:
conn.rollback()
print(f"Database initialization failed: {exc}")
raise
finally:
conn.close()
def ensure_postgres_ready() -> None:
if not use_postgres():
return
wait_for_postgres()
try:
apply_schema_and_migrations()
except Exception:
sys.exit(1)
def main() -> None:
os.environ.setdefault("KANSHO_DB_BACKEND", "postgres")
ensure_postgres_ready()
print("db_init complete")
if __name__ == "__main__":
main()

View File

@ -2,6 +2,8 @@ from env_loader import load_env_file
load_env_file()
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@ -10,11 +12,32 @@ import data_layer_dialogue # noqa: F401
from routers import admin, auth, dialogue, generation_instructions, journal, placeholders, prompts, subscription, users
from version import APP_VERSION
def allowed_origins() -> list[str]:
origins = [
"http://localhost:5188",
"http://127.0.0.1:5188",
"https://kansho.jinkendo.de",
"https://dev.kansho.jinkendo.de",
]
extra = (os.environ.get("ALLOWED_ORIGINS") or os.environ.get("KANSHO_ALLOWED_ORIGINS") or "").strip()
if extra:
origins.extend(item.strip() for item in extra.split(",") if item.strip())
app_url = (os.environ.get("APP_URL") or "").strip().rstrip("/")
if app_url:
origins.append(app_url)
seen: list[str] = []
for item in origins:
if item not in seen:
seen.append(item)
return seen
app = FastAPI(title="Kanshō", version=APP_VERSION)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5188", "http://127.0.0.1:5188"],
allow_origins=allowed_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@ -0,0 +1,7 @@
-- Postgres runtime baseline for Kanshō.
-- Greenfield schema is backend/schema.sql translated at startup
-- (datetime('now') → CURRENT_TIMESTAMP::text). Historical SQLite
-- inline migrations 001-021 are recorded as applied, not replayed.
-- Future incremental DDL belongs in 023_*.sql and later.
SELECT 1;

View File

@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
bcrypt==4.2.1
httpx==0.28.1
python-multipart==0.0.20
psycopg[binary]==3.2.6

179
backend/sql_compat.py Normal file
View File

@ -0,0 +1,179 @@
"""Translate SQLite-oriented SQL so the same stores can run against PostgreSQL.
Local Windows development and tests keep SQLite. Docker/Server set
KANSHO_DB_BACKEND=postgres. Identity, keys and journal bodies are not logged here.
"""
from __future__ import annotations
import os
import re
_INSERT_OR_IGNORE = re.compile(r"INSERT\s+OR\s+IGNORE\s+INTO", re.IGNORECASE)
_ON_CONFLICT_PAREN = re.compile(r"ON\s+CONFLICT\s*\(", re.IGNORECASE)
_PRAGMA_TABLE = re.compile(
r"^\s*PRAGMA\s+table_info\(\s*['\"]?(\w+)['\"]?\s*\)\s*;?\s*$",
re.IGNORECASE,
)
_SQLITE_MASTER_TABLES = re.compile(
r"^\s*SELECT\s+name\s+FROM\s+sqlite_master\s+WHERE\s+type\s*=\s*'table'\s*;?\s*$",
re.IGNORECASE,
)
_SQLITE_MASTER_SQL = re.compile(
r"^\s*SELECT\s+sql\s+FROM\s+sqlite_master\s+WHERE\s+type\s*=\s*'table'\s+AND\s+name\s*=\s*\?\s*;?\s*$",
re.IGNORECASE,
)
def use_postgres() -> bool:
"""Decide the engine at connect time, not at import time.
Explicit KANSHO_DB_BACKEND wins. KANSHO_DB_PATH without an explicit postgres
backend keeps tests on isolated SQLite even inside a Compose container.
"""
raw = (os.environ.get("KANSHO_DB_BACKEND") or "").strip().lower()
if raw in {"sqlite", "sqlite3"}:
return False
if raw in {"postgres", "postgresql", "pg"}:
return True
if (os.environ.get("KANSHO_DB_PATH") or "").strip():
return False
url = (os.environ.get("DATABASE_URL") or "").strip().lower()
if url.startswith("postgres"):
return True
if (os.environ.get("DB_HOST") or "").strip():
return True
return False
def postgres_connect_kwargs() -> dict:
url = (os.environ.get("DATABASE_URL") or "").strip()
if url:
return {"conninfo": url}
host = (os.environ.get("DB_HOST") or "postgres").strip() or "postgres"
port = int((os.environ.get("DB_PORT") or "5432").strip() or "5432")
dbname = (os.environ.get("DB_NAME") or "kansho").strip() or "kansho"
user = (os.environ.get("DB_USER") or "kansho").strip() or "kansho"
password = os.environ.get("DB_PASSWORD") or ""
return {
"host": host,
"port": port,
"dbname": dbname,
"user": user,
"password": password,
}
def sqlite_schema_to_postgres(sql: str) -> str:
return sql.replace("datetime('now')", "CURRENT_TIMESTAMP::text")
def replace_placeholders(sql: str) -> str:
"""Replace SQLite `?` placeholders with psycopg `%s`, ignoring quoted text."""
out: list[str] = []
i = 0
in_single = False
in_double = False
while i < len(sql):
ch = sql[i]
if ch == "'" and not in_double:
if in_single and i + 1 < len(sql) and sql[i + 1] == "'":
out.append("''")
i += 2
continue
in_single = not in_single
out.append(ch)
i += 1
continue
if ch == '"' and not in_single:
in_double = not in_double
out.append(ch)
i += 1
continue
if ch == "?" and not in_single and not in_double:
out.append("%s")
i += 1
continue
out.append(ch)
i += 1
return "".join(out)
def adapt_sql(sql: str) -> str:
"""Make a SQLite-shaped statement runnable on PostgreSQL."""
sql = sqlite_schema_to_postgres(sql)
sql = _ON_CONFLICT_PAREN.sub("ON CONFLICT (", sql)
used_ignore = bool(_INSERT_OR_IGNORE.search(sql))
sql = _INSERT_OR_IGNORE.sub("INSERT INTO", sql)
if used_ignore and re.search(r"ON\s+CONFLICT", sql, re.IGNORECASE) is None:
stripped = sql.rstrip()
ended = stripped.endswith(";")
if ended:
stripped = stripped[:-1].rstrip()
stripped = f"{stripped} ON CONFLICT DO NOTHING"
sql = stripped + (";" if ended else "")
return replace_placeholders(sql)
def rewrite_catalog_sql(sql: str) -> tuple[str, tuple | None] | None:
"""Rewrite SQLite catalog queries. Returns (sql, extra_params) or None."""
match = _PRAGMA_TABLE.match(sql)
if match:
return (
"""
SELECT column_name AS name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
ORDER BY ordinal_position
""",
(match.group(1).lower(),),
)
if _SQLITE_MASTER_TABLES.match(sql):
return (
"""
SELECT tablename AS name
FROM pg_catalog.pg_tables
WHERE schemaname = 'public'
""",
None,
)
if _SQLITE_MASTER_SQL.match(sql):
return ("SELECT NULL AS sql WHERE FALSE", None)
return None
def split_sql(script: str) -> list[str]:
"""Split a SQL script into statements, respecting quotes."""
statements: list[str] = []
buf: list[str] = []
in_single = False
in_double = False
i = 0
while i < len(script):
ch = script[i]
if ch == "'" and not in_double:
if in_single and i + 1 < len(script) and script[i + 1] == "'":
buf.append("''")
i += 2
continue
in_single = not in_single
buf.append(ch)
i += 1
continue
if ch == '"' and not in_single:
in_double = not in_double
buf.append(ch)
i += 1
continue
if ch == ";" and not in_single and not in_double:
stmt = "".join(buf).strip()
if stmt:
statements.append(stmt)
buf = []
i += 1
continue
buf.append(ch)
i += 1
tail = "".join(buf).strip()
if tail:
statements.append(tail)
return statements

View File

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""One-shot SQLite → PostgreSQL copy for the laptop/home journal instance.
Probe on a copy, never on the only backup. Does not send data to providers.
Media files are copied separately into the destination media root.
"""
from __future__ import annotations
import argparse
import os
import shutil
import sqlite3
import sys
from pathlib import Path
from sql_compat import postgres_connect_kwargs
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql"
DEFAULT_SQLITE = Path(__file__).resolve().parent / "data" / "kansho.sqlite"
DEFAULT_MEDIA = Path(__file__).resolve().parent / "data" / "media"
# Parent tables first — matches schema.sql CREATE order.
TABLES = [
"schema_migrations",
"tiers",
"features",
"tier_limits",
"profiles",
"sessions",
"user_feature_restrictions",
"user_feature_usage",
"ai_prompts",
"usage_sessions",
"conversations",
"messages",
"threads",
"conversation_threads",
"spaces",
"thread_spaces",
"derived_records",
"handoffs",
"external_refs",
"re_grounding_events",
"identity_mappings",
"identity_review_proposals",
"journal_days",
"journal_drafts",
"journal_entries",
"journal_entry_versions",
"journal_draft_source_refs",
"journal_entry_version_source_refs",
"media_assets",
"writing_profiles",
"writing_profile_sources",
"writing_profile_facets",
"writing_profile_traits",
"writing_profile_trait_refs",
"writing_profile_suggestions",
"interaction_profiles",
"interaction_preferences",
"interaction_suggestions",
"writing_profile_evidence",
"writing_profile_reviews",
"writing_profile_versions",
"provider_settings",
"journal_generation_selection",
"generation_guidelines",
"app_settings",
"debug_runs",
]
def _sqlite_path() -> Path:
env = (os.environ.get("KANSHO_DB_PATH") or "").strip()
if env:
return Path(env)
data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip()
if data_dir:
return Path(data_dir) / "kansho.sqlite"
return DEFAULT_SQLITE
def _media_root() -> Path:
env = (os.environ.get("KANSHO_MEDIA_ROOT") or "").strip()
if env:
return Path(env)
data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip()
if data_dir:
return Path(data_dir) / "media"
return DEFAULT_MEDIA
def _connect_pg():
import psycopg
kwargs = postgres_connect_kwargs()
if "conninfo" in kwargs:
return psycopg.connect(kwargs["conninfo"])
return psycopg.connect(**kwargs)
def _pg_columns(conn, table: str) -> list[str]:
rows = conn.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
ORDER BY ordinal_position
""",
(table,),
).fetchall()
return [row[0] for row in rows]
def _sqlite_rows(sqlite_path: Path, table: str) -> list[dict]:
conn = sqlite3.connect(str(sqlite_path))
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(f"SELECT * FROM {table}").fetchall()
return [dict(row) for row in rows]
except sqlite3.OperationalError:
return []
finally:
conn.close()
def _copy_media(src: Path, dest: Path) -> int:
if not src.is_dir():
print(f"No media directory at {src}")
return 0
dest.mkdir(parents=True, exist_ok=True)
count = 0
for path in src.rglob("*"):
if not path.is_file():
continue
relative = path.relative_to(src)
target = dest / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
count += 1
return count
def migrate(sqlite_path: Path, media_src: Path, media_dest: Path, replace: bool) -> dict[str, tuple[int, int]]:
if not sqlite_path.is_file():
raise SystemExit(f"SQLite not found: {sqlite_path}")
pg = _connect_pg()
stats: dict[str, tuple[int, int]] = {}
try:
existing = pg.execute("SELECT COUNT(*) FROM profiles").fetchone()[0]
if existing and not replace:
raise SystemExit(
f"PostgreSQL already has {existing} profiles. Pass --replace after a probe copy, not on the only backup."
)
if existing and replace:
print(f"Replacing {existing} existing Postgres profiles")
for table in reversed(TABLES):
pg.execute(f"TRUNCATE TABLE {table} CASCADE")
for table in TABLES:
rows = _sqlite_rows(sqlite_path, table)
columns = _pg_columns(pg, table)
if not columns:
print(f" skip {table} (missing in Postgres schema)")
stats[table] = (len(rows), 0)
continue
if not rows:
print(f" {table}: empty")
stats[table] = (0, 0)
continue
usable = [col for col in columns if col in rows[0]]
placeholders = ", ".join(["%s"] * len(usable))
col_sql = ", ".join(usable)
sql = f"INSERT INTO {table} ({col_sql}) VALUES ({placeholders})"
values = [tuple(row.get(col) for col in usable) for row in rows]
with pg.cursor() as cur:
cur.executemany(sql, values)
count = pg.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table}: {len(rows)}{count}")
stats[table] = (len(rows), int(count))
pg.commit()
except Exception:
pg.rollback()
raise
finally:
pg.close()
copied = _copy_media(media_src, media_dest)
print(f"Media files copied: {copied}{media_dest}")
return stats
def verify(stats: dict[str, tuple[int, int]]) -> None:
failed = False
print("\nVerification")
for table, (src, dest) in stats.items():
mark = "ok" if src == dest else "MISMATCH"
if src != dest:
failed = True
print(f" {mark:8} {table:36} sqlite={src:5} postgres={dest:5}")
if failed:
raise SystemExit("Row counts do not match")
print("Row counts match")
def main() -> None:
parser = argparse.ArgumentParser(description="Copy Kanshō SQLite data into PostgreSQL")
parser.add_argument("--sqlite", type=Path, default=_sqlite_path())
parser.add_argument("--media-src", type=Path, default=_media_root())
parser.add_argument("--media-dest", type=Path, default=Path(os.environ.get("KANSHO_MEDIA_DEST") or "/app/data/media"))
parser.add_argument("--confirm", action="store_true", help="Required. Refuse a silent copy.")
parser.add_argument("--replace", action="store_true", help="Truncate Postgres tables first")
args = parser.parse_args()
if not args.confirm:
raise SystemExit("Refusing to copy personal data without --confirm")
print(f"SQLite source: {args.sqlite}")
print(f"Media source: {args.media_src}")
print(f"Media dest: {args.media_dest}")
stats = migrate(args.sqlite, args.media_src, args.media_dest, args.replace)
verify(stats)
print("Import complete. Check entry/message counts and one media GET before Prod.")
if __name__ == "__main__":
main()

7
backend/startup.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
set -e
echo "=== Kanshō backend startup ==="
python /app/db_init.py
echo "=== starting uvicorn ==="
exec uvicorn main:app --host 0.0.0.0 --port 8000

View File

@ -0,0 +1,75 @@
"""SQL dialect adapter for the SQLite/Postgres dual backend."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sql_compat import adapt_sql, split_sql, sqlite_schema_to_postgres, use_postgres
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
os.environ.pop("KANSHO_DB_BACKEND", None)
os.environ.pop("KANSHO_DB_PATH", None)
os.environ.pop("DB_HOST", None)
os.environ.pop("DATABASE_URL", None)
expect(use_postgres() is False, "default engine is sqlite")
os.environ["KANSHO_DB_PATH"] = "/tmp/isolated.sqlite"
os.environ["DB_HOST"] = "postgres"
expect(use_postgres() is False, "KANSHO_DB_PATH keeps tests on sqlite")
os.environ["KANSHO_DB_BACKEND"] = "postgres"
expect(use_postgres() is True, "explicit postgres backend wins")
os.environ["KANSHO_DB_BACKEND"] = "sqlite"
expect(use_postgres() is False, "explicit sqlite backend wins")
os.environ.pop("KANSHO_DB_BACKEND", None)
os.environ.pop("KANSHO_DB_PATH", None)
expect(use_postgres() is True, "DB_HOST alone selects postgres")
os.environ.pop("DB_HOST", None)
translated = sqlite_schema_to_postgres("created TEXT NOT NULL DEFAULT (datetime('now'))")
expect("CURRENT_TIMESTAMP::text" in translated, "schema datetime default")
expect("datetime('now')" not in translated, "no sqlite datetime left in schema")
ignore = adapt_sql("INSERT OR IGNORE INTO tiers (id) VALUES (?)")
expect("INSERT INTO tiers" in ignore, "insert or ignore becomes insert")
expect("ON CONFLICT DO NOTHING" in ignore, "conflict do nothing appended")
expect("%s" in ignore and "?" not in ignore, "placeholders converted")
upsert = adapt_sql(
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
)
expect("ON CONFLICT (key)" in upsert, "on conflict parenthesis spacing")
expect(upsert.count("ON CONFLICT") == 1, "existing on conflict not duplicated")
expect("%s" in upsert, "upsert placeholders")
quoted = adapt_sql("SELECT '?' AS q, name FROM profiles WHERE id = ?")
expect("'?'" in quoted, "question mark inside quotes kept")
expect(quoted.endswith("%s") or quoted.rstrip().endswith("%s"), "trailing placeholder converted")
parts = split_sql("CREATE TABLE a (id TEXT); CREATE TABLE b (id TEXT);")
expect(parts == ["CREATE TABLE a (id TEXT)", "CREATE TABLE b (id TEXT)"], "split two statements")
schema = (ROOT / "schema.sql").read_text(encoding="utf-8")
pg_schema = sqlite_schema_to_postgres(schema)
expect("datetime('now')" not in pg_schema, "translated schema has no sqlite datetime")
expect("CURRENT_TIMESTAMP::text" in pg_schema, "translated schema uses timestamp text default")
expect(len(split_sql(pg_schema)) > 40, "schema splits into many statements")
from sqlite_to_postgres import TABLES
tables = re.findall(r"CREATE TABLE IF NOT EXISTS (\w+)", schema)
expect(tables == list(TABLES), "import table order matches schema.sql")
print("sql compat: OK")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,84 @@
# Server development stack: /home/lars/docker/kansho-dev
# No fixed container_name — Compose prefixes with the project name.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: "${DB_NAME:-kansho_dev}"
POSTGRES_USER: "${DB_USER:-kansho_dev}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}"
volumes:
- dev-kansho-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kansho_dev} -d ${DB_NAME:-kansho_dev}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- dev-kansho-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
KANSHO_DB_BACKEND: postgres
KANSHO_ENV: "${KANSHO_ENV:-development}"
KANSHO_DATA_DIR: /app/data
KANSHO_MEDIA_ROOT: /app/data/media
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: "${DB_NAME:-kansho_dev}"
DB_USER: "${DB_USER:-kansho_dev}"
DB_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}"
APP_URL: "${APP_URL:-https://dev.kansho.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kansho.jinkendo.de,http://192.168.2.49:3096,http://localhost:3096}"
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
volumes:
- dev-kansho-media:/app/data/media
ports:
- "${KANSHO_BACKEND_PORT:-8096}:8000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')",
]
interval: 10s
timeout: 5s
retries: 18
start_period: 90s
restart: unless-stopped
networks:
- dev-kansho-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ""
ports:
- "${KANSHO_FRONTEND_PORT:-3096}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
- dev-kansho-network
volumes:
dev-kansho-db-data:
dev-kansho-media:
networks:
dev-kansho-network:
driver: bridge

87
docker-compose.yml Normal file
View File

@ -0,0 +1,87 @@
# Production stack on the Raspberry Pi: /home/lars/docker/kansho
# Secrets live in the host .env next to this file, never in Git.
services:
postgres:
image: postgres:16-alpine
container_name: kansho-db-prod
environment:
POSTGRES_DB: "${DB_NAME:-kansho}"
POSTGRES_USER: "${DB_USER:-kansho}"
POSTGRES_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}"
volumes:
- kansho-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kansho} -d ${DB_NAME:-kansho}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- kansho-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: kansho-api
environment:
KANSHO_DB_BACKEND: postgres
KANSHO_ENV: "${KANSHO_ENV:-production}"
KANSHO_DATA_DIR: /app/data
KANSHO_MEDIA_ROOT: /app/data/media
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: "${DB_NAME:-kansho}"
DB_USER: "${DB_USER:-kansho}"
DB_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}"
APP_URL: "${APP_URL:-https://kansho.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kansho.jinkendo.de}"
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
volumes:
- kansho-media:/app/data/media
ports:
- "${KANSHO_BACKEND_PORT:-8005}:8000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')",
]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
restart: unless-stopped
networks:
- kansho-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ""
container_name: kansho-ui
ports:
- "${KANSHO_FRONTEND_PORT:-3006}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
- kansho-network
volumes:
kansho-db-data:
kansho-media:
networks:
kansho-network:
driver: bridge

134
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,134 @@
# Deployment Kanshō
**Stand:** 2026-09-07
**Server:** Raspberry Pi 5 (`192.168.2.49`) — gleicher Host wie Mitai/Shinkan/Kairo, **eigene** Compose-Projekte
**Runner:** Gitea Actions (`/home/lars/gitea-runner/`)
**Repo:** `http://192.168.2.144:3000/Lars/Kansho.git`
Kanonische Runtime-Entscheidungen: `docs/architecture/technical/runtime_and_deploy.md`.
Datenübernahme SQLite → Postgres: `backend/sqlite_to_postgres.py` (Probe auf Kopie, `--confirm`).
---
## Port- und Pfad-Übersicht
| | Production | Development |
|---|------------|-------------|
| **Git-Branch** | `main` | `develop` |
| **Server-Verzeichnis** | `/home/lars/docker/kansho` | `/home/lars/docker/kansho-dev` |
| **Frontend-Port** | 3006 | 3096 |
| **Backend-Port** | 8005 | 8096 |
| **PostgreSQL** | nur Compose-Netz, DB `kansho` | nur Compose-Netz, DB `kansho_dev` |
| **Domain** | kansho.jinkendo.de | dev.kansho.jinkendo.de |
| **Compose** | `docker-compose.yml` | `docker-compose.dev-env.yml` |
Prod-Frontend ist **3006**, nicht 3005: auf dem Pi lauscht Bookstack bereits auf `0.0.0.0:3005`. Dev 3096/8096 und Prod-API 8005 waren frei.
Lokal ohne Docker bleibt Windows: Frontend **5188**, Backend **8018**, SQLite unter `backend/data/`.
---
## Einmalige Server-Einrichtung
```bash
mkdir -p /home/lars/docker/kansho /home/lars/docker/kansho-dev
cd /home/lars/docker/kansho-dev
git clone http://192.168.2.144:3000/Lars/Kansho.git .
git checkout develop
cp .env.example .env
# DB_PASSWORD und Provider-Keys setzen
cd /home/lars/docker/kansho
git clone http://192.168.2.144:3000/Lars/Kansho.git .
git checkout main
cp .env.example .env
# Prod-DB_PASSWORD und Provider-Keys setzen
```
Host-nginx: `nginx/kansho.conf` und `nginx/kansho-dev.conf` nach `/etc/nginx/sites-available/`, dann `nginx/certbot-setup.sh`. DNS für beide Hostnamen auf den Reverse-Proxy.
Gitea: Actions aktivieren. Derselbe Pi-Runner wie die Schwesterprodukte (`ubuntu-latest`).
Watchtower bleibt aus.
---
## Gitea Actions
| Workflow | Trigger | Zweck |
|----------|---------|--------|
| `deploy-dev.yml` | Push `develop` | Deploy nach `/home/lars/docker/kansho-dev`, Health `localhost:8096` |
| `deploy-prod.yml` | Push `main` | Deploy nach `/home/lars/docker/kansho`, Health `localhost:8005` |
| `test.yml` | Push `develop`/`main`, PR `develop` | Backend-Tests isoliert SQLite im Container plus Frontend-Build. Keine Live-Provider-Keys. |
Prod nur über Merge `develop``main`, kein direkter Prod-Schreibzugriff.
---
## Datenübernahme (persönlich, Klasse A/B)
1. Phase-A-SQLite auf dem Heimrechner ist führend, bis der Dev-Import grün ist.
2. Schema auf der Zielinstanz durch normalen Container-Start anlegen (leeres Postgres).
3. Import auf **Dev zuerst**:
```bash
# Zip oder laufende SQLite + Medien in den Dev-Container legen, dann:
docker compose -f docker-compose.dev-env.yml exec -T backend \
python sqlite_to_postgres.py --confirm \
--sqlite /tmp/kansho.sqlite \
--media-src /tmp/media \
--media-dest /app/data/media
```
Windows-Vorbereitung (Kopie, nicht das einzige Backup):
```powershell
.\scripts\backup-local.ps1 create
# Archiv prüfen, dann SQLite+Medien auf den Pi kopieren — nicht nach Gitea.
```
4. Abnahme Dev: Profilanzahl, Journal-Days/Entries, Messages, ein Medien-GET, Gateway fail-closed, `KANSHO_ENV=production` ohne Klartext-Detect.
5. Erst dann derselbe Import nach Prod (`--confirm`, leeres Volume). `--replace` nur auf einer bewussten Kopie.
`debug.persist_traces` nach dem Import prüfen.
---
## Postgres-Backup (Server)
Kein Ersatz durch `backup-local.ps1` (das bleibt der SQLite-Weg).
```bash
# Dump
cd /home/lars/docker/kansho
docker compose exec -T postgres pg_dump -U kansho kansho > "/home/lars/backups/kansho-$(date -u +%Y%m%d).sql"
# Medienvolume
docker run --rm -v kansho_kansho-media:/src -v /home/lars/backups:/dst alpine \
tar -C /src -czf /dst/kansho-media-$(date -u +%Y%m%d).tgz .
```
Restore-Übung vor dem ersten Prod-Dialogbestand nach dem Cutover wiederholen. Dump und Medien nicht an Provider und nicht in öffentliche Remotes.
---
## Manuelles Deploy
```bash
# Development
cd /home/lars/docker/kansho-dev
git fetch origin develop && git reset --hard origin/develop
docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend
docker compose -f docker-compose.dev-env.yml up -d --wait
curl -sf http://localhost:8096/api/health
# Production
cd /home/lars/docker/kansho
git fetch origin main && git reset --hard origin/main
docker compose build --no-cache backend frontend
docker compose up -d --wait
curl -sf http://localhost:8005/api/health
```
Laptop-Checkout nach erfolgreichem Heim-Restore und Dev-Import nur noch Archiv. Keine parallelen Schreibzugriffe auf denselben persönlichen Bestand.

View File

@ -79,6 +79,15 @@ Kein Thread-State-Machine-Kapitel in v0.1. Die fachlich genannten Zustände (`Op
Entsprechen den Interview-Leitfragen Phase F1/F4 und werden hier nicht vorab beantwortet: kanonische IDs, Backlinks, welche Strukturen nur in Obsidian existieren, Schema für Reflection Memory und Knowledge Delta.
## 6a. Persistenzgrenzen Server vs. lokal (2026-09-07)
Additiv, ohne das Fachschema vorzuziehen:
- Windows-Checkout und `test-mvp.ps1`: SQLite unter `backend/data/` (gitignoriert).
- Docker auf dem Pi: PostgreSQL 16, eigene Instanz je Umgebung, Medien im Compose-Volume `kansho-media` / `dev-kansho-media`.
- Identity-Mappings bleiben Klasse A in der Local Trusted Zone (jetzt: Pi-Postgres, nicht Provider).
- Kein Shared-Schema mit Mitai.
## 7. Querverweise
- `memory_storage_and_offline.md`, `integrations_technical.md`, `privacy_gateway.md`, `platform_extensibility.md`

View File

@ -137,7 +137,7 @@ Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachlich
6. `../../work_orders/home_environment_setup.md`
7. fachlich: `../functional/guardrails.md` (Datenklassen beim Kopieren von Backup und `.env`)
Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel.
Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel. Betrieb auf dem Pi: `docs/DEPLOYMENT.md`.
## 4. Ladeprinzip

View File

@ -155,13 +155,12 @@ Entschieden (nicht neu verhandeln):
- Kein Auto-Rollback der Datenbank
- Kanshō-Ports **nicht** Mitai `3002`/`8002`/`3099`/`8099` kopieren
Offen, vor Compose festlegen:
Offen, vor Compose festlegen — **beantwortet 2026-09-07:**
1. Läuft Kanshō auf demselben Pi wie Mitai, als **eigenes** Compose-Projekt?
2. Eigenes Postgres (bevorzugte Richtung: ja, eigene Instanz/DB `kansho`, kein Shared-Schema mit Mitai)?
3. Dev-Domain / Prod-Domain / Host-Pfade (Hypothese: `dev.kansho.jinkendo.de` / `kansho.jinkendo.de`)?
4. Dev-Ports auf dem Pi, lokal bleiben 5188/8018 für den Windows-Checkout ohne Docker.
5. Ob Welle 2 zuerst Compose **mit SQLite-Volume** als Zwischenstand nutzt oder direkt Postgres + Migrationsadapter.
1. Läuft Kanshō auf demselben Pi wie Mitai, als **eigenes** Compose-Projekt? **Ja.** `/home/lars/docker/kansho` und `kansho-dev`.
2. Eigenes Postgres? **Ja**, `kansho` / `kansho_dev`, kein Shared-Schema.
3. Dev-Domain / Prod-Domain / Host-Pfade? **`dev.kansho.jinkendo.de` / `kansho.jinkendo.de`**, Ports 3096/8096 und 3006/8005. Lokal bleiben 5188/8018. Prod-UI nicht 3005 (Bookstack auf dem Pi).
4. Welle 2 SQLite im Volume oder direkt Postgres? **Direkt Postgres.** SQLite bleibt lokal und für Tests.
Empfohlene Reihenfolge in Welle 2:
@ -173,6 +172,8 @@ Empfohlene Reihenfolge in Welle 2:
6. Gitea-Workflows erst, wenn Runner-Pfade existieren (`runtime_and_deploy.md` §4).
7. Watchtower später prüfen, nicht in diesem Transfer.
Diese Schritte sind im Repository angelegt (`docker-compose*.yml`, `.gitea/workflows/`, `backend/db_init.py`, `backend/sqlite_to_postgres.py`). Pi-Bootstrap und Cutover: `docs/DEPLOYMENT.md`.
---
## 7. Datenschutz beim Transfer

View File

@ -37,7 +37,7 @@ Lokal, eine Instanz, ein Profil-Nutzer nach Setup:
Proxy: Vite leitet `/api` an 8018. CORS erlaubt `localhost:5188`.
**Abweichung vom technischen Zielrahmen:** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice läuft auf SQLite. Lokal zulässig; Prod-Pfad offen. Transfer der Urlaubsinstanz: zuerst SQLite-Restore auf dem Heimrechner, danach Compose/Postgres. Siehe `environment_handover.md` und `runtime_and_deploy.md` §7.2.
**Abweichung vom technischen Zielrahmen (lokal):** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice auf dem Windows-Checkout bleibt SQLite (`5188`/`8018`). **Additiv 2026-09-07:** Docker/Server nutzt PostgreSQL 16 (`KANSHO_DB_BACKEND=postgres`). Stores behalten SQLite-förmiges SQL; `sql_compat.py` übersetzt zur Laufzeit. Nummerierte Dateien ab `backend/migrations/022_*.sql`. Siehe `runtime_and_deploy.md` und `docs/DEPLOYMENT.md`.
Health: `GET /api/health`.
@ -101,6 +101,8 @@ Prompts in der DB, nicht im Anwendungscode: `mvp.dialogue_turn`, `mvp.journal_re
Schema: `backend/schema.sql`. Isolation über `profile_id`.
**Additiv 2026-09-07:** Dieselbe `schema.sql` ist Greenfield für Postgres (Dialekt nur `datetime('now')``CURRENT_TIMESTAMP::text` in `db_init.py`). SQLite-History 001021 wird auf leerem Postgres als applied erfasst, nicht als ALTER nachgespielt. Persönliche Daten kommen nicht über den Alltagspfad, sondern über `sqlite_to_postgres.py`.
**Rahmen / Layer 0:** `profiles`, Auth-`sessions`, `usage_sessions`, `conversations`, `messages`, Thread-/Space-Hüllen, `derived_records` (ungenutzt für Journal-Writes), `identity_mappings`, Prompt-/Feature-/Provider-Tabellen. **Additiv 2026-08-28:** `app_settings` (Instanzschalter, derzeit `debug.persist_traces`) und `debug_runs` (Admin-Testspur je Schritt, Profil-isoliert, keine Mapping-Tabelle).
**Journal-Slice:**

View File

@ -110,14 +110,14 @@ Mitai-Prompt-Engine-Muster (ein Executor, Registry, Admin-Konfiguration) wird ü
| Referenz | Mitai-Rahmen weitgehend | entschieden |
| Fachliche IA / Startroute | nicht durch den Rahmen vorwegnehmen | offen (Fach-Arbeitsstand) |
| Mandantenmodell | keines | entschieden |
| Konkrete Ports/Domains | nicht aus Mitai kopieren | offen |
| Konkrete Ports/Domains | lokal 5188/8018; Server 3006/8005 und 3096/8096; `kansho.jinkendo.de` | entschieden 2026-09-07 |
| Gemeinsame UI-Bibliothek der Familie | eigene Kopie des Musters, kein Shared-Package vorausgesetzt | bevorzugte Richtung |
## 9. Offene Fragen
1. Soll der Rahmen später in ein gemeinsames Jinkendo-Paket extrahiert werden oder als kopiertes Muster in Kanshō leben?
2. Welche Domain und welche Host-Ports gelten für Dev/Prod?
3. Wird Nginx als eigener Container beibehalten oder das Vite-Preview nur für lokale Entwicklung genutzt?
2. ~~Welche Domain und welche Host-Ports gelten für Dev/Prod?~~ **Entschieden 2026-09-07:** `kansho.jinkendo.de` / `dev.kansho.jinkendo.de`, Ports 3006/8005 und 3096/8096. Prod-Frontend nicht 3005 (Bookstack).
3. ~~Wird Nginx als eigener Container beibehalten oder das Vite-Preview nur für lokale Entwicklung genutzt?~~ **Entschieden:** Frontend-Nginx im Compose-Container (Prod/Dev-Server); Vite nur lokal.
## 10. Querverweise

View File

@ -20,7 +20,7 @@ Muster der Familie: zwei Umgebungen, zwei Branches.
| Development | `develop` | Auto-Deploy nach Push |
| Production | `main` | Auto-Deploy nach Merge |
**Status: entschieden** als Betriebsmuster. Konkrete Domains, Host-Pfade und **Produktions-Ports** sind **offen** und werden nicht aus Mitai (`3002`/`8002`, `3099`/`8099`, `bodytrack/`) kopiert.
**Status: entschieden** als Betriebsmuster. Host, Pfade, Domains und Ports sind seit 2026-09-07 festgelegt (gleicher Raspberry Pi wie die Schwesterprodukte, eigene Compose-Projekte, keine Mitai-`bodytrack/`-Pfade).
Lokale Entwicklung (ohne Docker) verwendet eigene Ports, nicht die Vite-/FastAPI-Defaults und nicht die Ports anderer lokaler Repos:
@ -31,7 +31,19 @@ Lokale Entwicklung (ohne Docker) verwendet eigene Ports, nicht die Vite-/FastAPI
Nicht verwenden: 5173, 5174, 4000, 4001, 8000.
Hypothese für spätere Benennung: `kansho.jinkendo.de` / `dev.kansho.jinkendo.de`, analog zur Foundation-Tabelle. Nicht festgelegt.
Server (Raspberry Pi 5, `192.168.2.49`):
| | Development | Production |
|---|---|---|
| Branch | `develop` | `main` |
| Host-Pfad | `/home/lars/docker/kansho-dev` | `/home/lars/docker/kansho` |
| Domain | `dev.kansho.jinkendo.de` | `kansho.jinkendo.de` |
| Frontend-Port | 3096 | 3006 |
| Backend-Port | 8096 | 8005 |
| Postgres | eigene Instanz `kansho_dev`, nicht nach außen | eigene Instanz `kansho`, nicht nach außen |
| Compose | `docker-compose.dev-env.yml` | `docker-compose.yml` |
Operative Schritte: `docs/DEPLOYMENT.md`. Gitea intern: `http://192.168.2.144:3000/Lars/Kansho.git`.
## 2. Container
@ -69,9 +81,9 @@ push/PR nach Tests → test.yml (pytest, Frontend-Build)
merge in main → deploy-prod.yml
```
**Status: bevorzugte Richtung.** Workflow-Dateien erst anlegen, wenn Code und Runner-Pfade existieren.
**Status: entschieden.** Workflows liegen unter `.gitea/workflows/` (`deploy-dev.yml`, `deploy-prod.yml`, `test.yml`). Muster analog Kairo: `git reset --hard`, `build --no-cache`, Health `GET /api/health`.
Kanshō-Repo liegt bereits auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet.
Kanshō-Repo liegt auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet. Der Pi-Runner (`ubuntu-latest`) ist derselbe wie bei Mitai/Shinkan/Kairo.
## 5. Was Deploy nicht übernimmt
@ -86,17 +98,20 @@ Kanshō-Repo liegt bereits auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichte
|---|---|---|
| Compose + Postgres + nummerierte SQL-Migrationen | ja | entschieden |
| develop → Dev, main → Prod | ja | entschieden |
| Ports/Domains/Hostpfade | Prod offen; lokal Frontend 5188 / Backend 8018 | lokal festgelegt, Prod offen |
| Gitea Workflows | Mitai-Muster | bevorzugte Richtung |
| Ports/Domains/Hostpfade | lokal 5188/8018; Server 3096/8096 und 3006/8005; `*.kansho.jinkendo.de` | entschieden 2026-09-07 |
| Host | gleicher Raspberry Pi wie Mitai/Shinkan/Kairo, eigene Compose-Projekte `kansho` / `kansho-dev` | entschieden 2026-09-07 |
| Postgres | eigene Instanz je Umgebung (`kansho` / `kansho_dev`), kein Shared-Schema | entschieden 2026-09-07 |
| Gitea Workflows | `.gitea/workflows/` analog Kairo | entschieden 2026-09-07 |
| Auto-Rollback | nein | verworfen |
| Urlaubs-Laptop ohne Docker/SQLite | Ist bis Transfer Welle 1 | dokumentiert 2026-09-07 |
| Dual-Backend | SQLite lokal/Tests; Postgres im Container | entschieden 2026-09-07 |
| Urlaubs-Laptop ohne Docker/SQLite | Phase A auf dem Heimrechner restored | dokumentiert 2026-09-07 |
| Transfer vor Postgres | SQLite-Restore auf dem Heimrechner zuerst | entschieden als Reihenfolge |
## 7. Offene Fragen
1. Läuft Kanshō auf demselben Raspberry-Pi-Host wie Mitai, mit eigenen Compose-Projekten?
2. Gemeinsames oder separates Postgres?
3. Backup-Rhythmus und Restore-Übung vor erstem persönlichen Dialogdatenbestand. Die Urlaubs-SQLite-Restore auf dem Heimrechner (Welle 1) ist diese Übung für den bestehenden Datenbestand; Produktions-Backup für Postgres bleibt offen.
1. ~~Läuft Kanshō auf demselben Raspberry-Pi-Host wie Mitai, mit eigenen Compose-Projekten?~~ **Entschieden:** ja, Pi `192.168.2.49`, Pfade `/home/lars/docker/kansho` und `kansho-dev`.
2. ~~Gemeinsames oder separates Postgres?~~ **Entschieden:** eigene Instanz je Umgebung, kein Shared-Schema mit Mitai.
3. Backup-Rhythmus nach dem Prod-Cutover. Die Urlaubs-SQLite-Restore auf dem Heimrechner (Welle 1) ist die Übung für den bestehenden Datenbestand. Postgres-Dump plus Medien: `docs/DEPLOYMENT.md` und `scripts/backup-postgres.sh`. Erste Restore-Übung auf Dev vor dem Prod-Import.
### 7.1 Lokales Backup für die Urlaubs-Testphase (2026-08-25)
@ -111,15 +126,15 @@ Kein Ersatz für Produktions-Backup, Docker oder Verschlüsselung.
Die Urlaubsinstanz lief ohne Docker auf SQLite (`backend/data/kansho.sqlite`, Ports 5188/8018).
**Reihenfolge entschieden:** zuerst Kontinuität (Clone + SQLite-Restore + Smoke), danach Betriebsrahmen (Compose, Postgres 16, Gitea-Runner). Postgres bleibt Ziel; `backend/db.py` ist weiterhin SQLite. Ein direkter Sprung „Laptop-SQLite nach Prod-Postgres“ ohne Zwischen-Restore ist verworfen.
**Reihenfolge entschieden:** zuerst Kontinuität (Clone + SQLite-Restore + Smoke), danach Betriebsrahmen (Compose, Postgres 16, Gitea-Runner). Dual-Backend: SQLite lokal/Tests, Postgres im Container (`KANSHO_DB_BACKEND=postgres`). Ein direkter Sprung „Laptop-SQLite nach Prod-Postgres“ ohne Zwischen-Restore ist verworfen. Import: `backend/sqlite_to_postgres.py --confirm`, zuerst Dev, dann Prod.
**Additiv 2026-09-07 (Transportweg):** Auf dem Urlaubs-Laptop dürfen weder USB-Stick noch NAS verbunden werden. Der einzige Weg für Datenbank und persönliche Journaldaten ist das **selbst gehostete** Gitea (`gitea.stommer.de`, nur eigene Infrastruktur). Das ist eine einmalige Transportentscheidung, kein Produktregelwechsel: Provider, OpenRouter und öffentliche Remotes bleiben ausgeschlossen. Provider-Keys und `backend/.env` bleiben **außerhalb** Git.
Aktuelles Transportartefakt: `transfer/kansho-laptop-20260907.zip` (SHA256 `EBF7455D5688588E8C6E50C05D16200CE191D79FB7CEACDBA9C25460B7497B07`). Nach erfolgreichem Restore auf dem Zielrechner das Verzeichnis `transfer/` aus dem Arbeitsbaum entfernen.
Transportartefakt 2026-09-07: `transfer/kansho-laptop-20260907.zip` (SHA256 `EBF7455D5688588E8C6E50C05D16200CE191D79FB7CEACDBA9C25460B7497B07`). Nach erfolgreichem Restore auf dem Heimrechner das Verzeichnis `transfer/` aus dem Arbeitsbaum entfernen. Die Git-Historie behält das Zip.
Kanonisches Session-Handover: `environment_handover.md`. Aufträge: `../../work_orders/laptop_closeout.md`, `../../work_orders/home_environment_setup.md`.
Kanonisches Session-Handover: `environment_handover.md`. Aufträge: `../../work_orders/laptop_closeout.md`, `../../work_orders/home_environment_setup.md`. Betrieb: `docs/DEPLOYMENT.md`.
Noch nicht im Repository: Dockerfiles, Compose, `.gitea/workflows/`, Postgres-Adapter. Mitai unter `C:\dev\mitai` bleibt Muster, nicht Quelle für Ports oder `bodytrack/`-Pfade.
Compose, Dockerfiles, `.gitea/workflows/` und der Postgres-Adapter liegen im Repository. Mitai unter `C:\Dev\mitai-jinkendo` bleibt Muster, nicht Quelle für Ports oder `bodytrack/`-Pfade.
## 8. Querverweise

View File

@ -156,3 +156,9 @@ Nach Phase A zwingend, nach B/C erneut:
3. Ob Daten SQLite oder Postgres führen
4. Restore-Nachweis (Anzahlen, ein Medienbeispiel ohne Inhalt zu zitieren)
5. Offene Punkte für die nächste Session
## Stand 2026-09-07 (Heimrechner)
Phase A: Restore aus `transfer/kansho-laptop-20260907.zip` nach `backend/data/` (1 Profil, 2 Spaces inkl. `Kroatien 2026`, 17 Days/Entries, 20 Conversations, 379 Messages, 70 Mediendateien). Health `GET /api/health` ok. `backend/.env` neu aus Example (Keys vom Nutzer nachzutragen). `transfer/` danach aus dem Tree genommen.
Phase BD im Repository: Compose, Dual-Backend, Workflows, `docs/DEPLOYMENT.md`. Pi-Verzeichnisse und Host-nginx sind einmalig auf `192.168.2.49` anzulegen. Persönlicher Postgres-Import zuerst nach Dev, dann Prod.

15
frontend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

26
frontend/nginx.conf Normal file
View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=10s ipv6=off;
client_max_body_size 80m;
location ^~ /api/ {
set $docker_backend_svc backend;
proxy_pass http://$docker_backend_svc:8000$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
location / {
try_files $uri $uri/ /index.html;
}
}

23
nginx/certbot-setup.sh Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Let's Encrypt for kansho.jinkendo.de and dev.kansho.jinkendo.de
# Prerequisites: host nginx installed, ports 80/443 reachable, DNS A/AAAA in place.
set -e
EMAIL="${CERTBOT_EMAIL:-lars@stommer.de}"
echo "=== Let's Encrypt for Kanshō ==="
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx \
-d kansho.jinkendo.de \
-d dev.kansho.jinkendo.de \
--email "$EMAIL" \
--agree-tos \
--non-interactive \
--redirect
echo "Check renewal: sudo certbot renew --dry-run"
echo "Timer: sudo systemctl status certbot.timer"

50
nginx/kansho-dev.conf Normal file
View File

@ -0,0 +1,50 @@
# Kanshō development vhost. Install as /etc/nginx/sites-available/kansho-dev.
server {
listen 80;
server_name dev.kansho.jinkendo.de;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name dev.kansho.jinkendo.de;
ssl_certificate /etc/letsencrypt/live/dev.kansho.jinkendo.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.kansho.jinkendo.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 80m;
location /api/ {
proxy_pass http://127.0.0.1:8096;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
location / {
proxy_pass http://127.0.0.1:3096;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
}

54
nginx/kansho.conf Normal file
View File

@ -0,0 +1,54 @@
# Kanshō Host nginx (outside Compose)
# Install as /etc/nginx/sites-available/kansho and symlink into sites-enabled.
# TLS via nginx/certbot-setup.sh. Reverse-proxy targets are the Pi publish ports.
server {
listen 80;
server_name kansho.jinkendo.de;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name kansho.jinkendo.de;
ssl_certificate /etc/letsencrypt/live/kansho.jinkendo.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kansho.jinkendo.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 80m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
location /api/ {
proxy_pass http://127.0.0.1:8005;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
location / {
proxy_pass http://127.0.0.1:3006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
}

View File

@ -0,0 +1,25 @@
#!/bin/sh
# Postgres dump + note for the media volume. Run on the Pi next to Compose.
# Usage: ./scripts/backup-postgres.sh [prod|dev]
set -e
ENV_NAME="${1:-prod}"
STAMP=$(date -u +%Y%m%d-%H%M%S)
BACKUP_ROOT="${KANSHO_PG_BACKUP_DIR:-$HOME/backups/kansho}"
mkdir -p "$BACKUP_ROOT"
if [ "$ENV_NAME" = "dev" ]; then
APP_DIR="${KANSHO_DEV_DIR:-/home/lars/docker/kansho-dev}"
COMPOSE="docker compose -f docker-compose.dev-env.yml"
DB_USER="${DB_USER:-kansho_dev}"
DB_NAME="${DB_NAME:-kansho_dev}"
else
APP_DIR="${KANSHO_PROD_DIR:-/home/lars/docker/kansho}"
COMPOSE="docker compose"
DB_USER="${DB_USER:-kansho}"
DB_NAME="${DB_NAME:-kansho}"
fi
cd "$APP_DIR"
$COMPOSE exec -T postgres pg_dump -U "$DB_USER" "$DB_NAME" > "$BACKUP_ROOT/kansho-$ENV_NAME-$STAMP.sql"
echo "Wrote $BACKUP_ROOT/kansho-$ENV_NAME-$STAMP.sql"
echo "Copy the media volume separately; see docs/DEPLOYMENT.md."

View File

@ -0,0 +1,29 @@
# Wrapper for backend/sqlite_to_postgres.py (personal data, require -Confirm).
[CmdletBinding()]
param(
[string]$Sqlite,
[string]$MediaSrc,
[string]$MediaDest,
[switch]$Confirm,
[switch]$Replace
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$root = Split-Path -Parent $PSScriptRoot
$py = Join-Path $root "backend\.venv\Scripts\python.exe"
if (-not (Test-Path $py)) {
$py = "python"
}
$env:PYTHONUTF8 = "1"
$argsList = @((Join-Path $root "backend\sqlite_to_postgres.py"))
if ($Sqlite) { $argsList += @("--sqlite", $Sqlite) }
if ($MediaSrc) { $argsList += @("--media-src", $MediaSrc) }
if ($MediaDest) { $argsList += @("--media-dest", $MediaDest) }
if ($Confirm) { $argsList += "--confirm" }
if ($Replace) { $argsList += "--replace" }
& $py @argsList
exit $LASTEXITCODE

View File

@ -1,30 +0,0 @@
---
title: "Kanshō Einmaliger Laptop-Datentransfer über Gitea"
status: "Transportartefakt, nach Restore entfernen"
date: "2026-09-07"
---
# Einmaliger Datentransfer (Laptop 2026-09-07)
Dieses Verzeichnis ist **kein** Produkt-Datenspeicher. Es existiert, weil auf dem Urlaubs-Laptop weder USB-Stick noch NAS verbunden werden darf. Der Transport läuft über das selbst gehostete Gitea (`gitea.stommer.de`), das nur in der eigenen Infrastruktur erreichbar ist.
## Inhalt
- `kansho-laptop-20260907.zip` SQLite (Backup-API) + Journalmedien, Manifest, Checksummen
- **Nicht enthalten:** `backend/.env`, Provider-Keys, TLS-Zertifikate
Das Archiv enthält persönliche, **unverschlüsselte** Journalinhalte, Identity-Mappings und ggf. Debug-Spuren. Es ist Klasse A/B. Nicht an Provider, nicht öffentlich klonen, nicht in fremde Remotes spiegeln.
## Restore auf dem Zielrechner
Backend aus. Danach:
```powershell
.\scripts\backup-local.ps1 restore -Archive .\transfer\kansho-laptop-20260907.zip -Confirm -Replace
```
`.env` neu anlegen aus `backend/.env.example` und denselben Provider-Keys (Passwortmanager / OpenRouter). Das Admin-Login steckt im SQLite-Hash, nicht in der `.env`.
## Nach erfolgreichem Restore
Dieses Verzeichnis aus dem Arbeitsbaum entfernen und in einem eigenen Commit nach Gitea schieben. Die Git-Historie behält die Datei; das ist bewusst der Preis dieses Transportwegs.

Binary file not shown.