diff --git a/.env.example b/.env.example index a8548f3..590cabf 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,16 @@ -SERVER_IP=0.0.0.0 -PROXY_PORT=3456 -FRONTEND_PORT=2080 -PB_PORT=8090 +# Runtime env for the SvelteKit + Hono app. +# +# The app's own loopback URLs are constants in code (PROXY_URL / PB_ENDPOINT); +# ports live in config.ts for the proxy. Only these are real env vars: + +# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123. PB_EMAIL= PB_PASSWORD= -DEBUG_RECORD_ID= -PUBLIC_PB_URL= +# Public: the dev machine's IP where PB + the dev proxy run. Change this when +# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it. +# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225. +SERVER_IP=192.168.1.225 + +# docker-compose (staging) — host-side deploy config, never baked into the image. +PORT=3001 # public port to publish (nginx container listens on 3001) +PB_DATA=./pb_data # where to persist PocketBase data on the host diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87538b6..d2e198b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -286,21 +286,22 @@ Real-time right-slideout chat panel (TopNav chat icon, slideout on desktop / ful ## 7. Environment Variables -### Frontend + Hono container +### Current (this repo) ``` -PUBLIC_PB_URL=https://pb.chores.app.com -PB_ADMIN_EMAIL=admin@chores.app -PB_ADMIN_PASSWORD= -STRIPE_SECRET_KEY=sk_live_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx -DONATION_MODAL_INTERVAL=30 +# .env (dev; loaded by vite for the frontend + tsx --env-file for the proxy) +PB_EMAIL=debug@famchamp.dev # PB superuser (server-only; pb-admin + proxy) +PB_PASSWORD=debug123 +SERVER_IP=192.168.1.225 # dev machine IP (Tailscale IP when away) — change when it changes ``` +- Ports live in root `config.ts` (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`) — the proxy reads them; SvelteKit never imports `config.ts`. +- SvelteKit env vars are declared in `frontend/src/env.ts` (`PROXY_URL`, `SERVER_IP`, `PB_EMAIL`, `PB_PASSWORD`) and read via `$app/env/public` / `$app/env/private`. +- Compose/deploy: `PORT` (public port, default `3001`), `PB_DATA` (host data dir). `PUBLIC_PB_URL` no longer exists (docker bakes `/pb`; browser PB URL derives from `SERVER_IP` in dev). +- Not yet wired: `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`. -### PocketBase container -``` -PB_SUPERUSER_EMAIL=you@email.com -PB_SUPERUSER_PASSWORD= -``` +### Dev vs Prod PocketBase data (⚠️) +- **Dev (`pnpm dev`)**: permanent dev PB = container **`pb-dev`** at `:8090`, data in host `./pb_data`. This is what the code talks to via `SERVER_IP:8090`. +- **Prod/docker**: the app container bundles its **own internal PB**, published loopback-only at `127.0.0.1:8091`, and also bind-mounts `./pb_data`. +- Never recreate `pb-dev` with a fresh volume — restore it with `-v "$PWD/pb_data:/pb_data"` (full command in `RULES.md`). --- diff --git a/MEMORY.md b/MEMORY.md index 6d56102..0a91687 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -159,3 +159,21 @@ - **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up). + +### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only + +- **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts. +- **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset). +- **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`. +- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts` used `import.meta.env.SERVER_IP` → undefined.) +- **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code. +- **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks). +- **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`. +- **Build checks**: proxy + frontend `pnpm build` clean. + +### 2026-08-06 — Dev PB data incident (pb-dev) — see RULES.md "Dev vs Prod PocketBase data" + +- **Symptom**: `pnpm dev` proxy migrate failed; PB superuser auth returned `HTTP 500 "Something went wrong"`; could not log into the PB admin UI. +- **Root cause**: the permanent dev PB container **`pb-dev`** (publishes `:8090`, data in host `./pb_data`) had a **broken bind mount** — it was serving an empty throwaway store, so the `debug@famchamp.dev` superuser didn't exist. The real `data.db` was on the host but the container wasn't seeing it. +- **Fix**: recreated `pb-dev` with the mount correctly attached (`-v "$PWD/pb_data:/pb_data"`, `pocketbase serve --http=0.0.0.0:8090 --dir=/pb_data`). Superuser auth then returned 200 on both `127.0.0.1:8090` and the Tailscale `SERVER_IP:8090`. +- **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both. diff --git a/RULES.md b/RULES.md index 4e35f49..8fa821d 100644 --- a/RULES.md +++ b/RULES.md @@ -6,20 +6,37 @@ - `deviceToken` stored as SHA-256 hash; never log raw tokens ## Config -- Use `.env` at root for runtime config values (ports, secrets). `.env.example` committed as template. -- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`), reads from env with fallback defaults. -- Never commit `.env` files (already in `.gitignore`). +- **SvelteKit must never import `config.ts`.** `config.ts` at root owns the three service **ports** for the Hono proxy (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). +- SvelteKit env vars are declared in `frontend/src/env.ts` via `defineEnvVars`, read with `$app/env/public` / `$app/env/private`. Loopback URLs are code constants; the only real env vars are the PB creds and `SERVER_IP`. +- Runtime env values live in `.env` at root (symlinked to `frontend/.env`; loaded by the proxy dev via `tsx --env-file=../.env`). `.env.example` is the committed template. Never commit `.env`. +- `.env` currently holds only: `PB_EMAIL`, `PB_PASSWORD`, `SERVER_IP`. Ports come from `config.ts`, not `.env`. ## Ports - Frontend: `2080` - Proxy: `3456` -- Container external: `3001` (port `3000` is reserved) +- Container external: `3001` (port `3000` is reserved) — chosen at deploy via compose `PORT` - Must not use port `3000` for anything. ## Docker -- Prod: `docker/Dockerfile` (multi-stage + nginx) -- Dev: `docker/Dockerfile.dev` (PocketBase) -- Nginx routes: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`) +- Prod: `docker/Dockerfile` (multi-stage + nginx, bundles internal PB) +- Dev PB: `docker/Dockerfile.dev` (standalone PocketBase) +- Nginx routes: `/api/*` → Hono (`:3456`), `/pb/api/*` → PB (`:8090`), `/pb/` → 404 (admin UI kept internal), `/*` → SvelteKit (`:2080`) +- `docker-compose.yaml`: public port is `${PORT:-3001}:3001`; internal PB published loopback-only as `127.0.0.1:8091:8090`. + +## ⚠️ Dev vs Prod PocketBase data (READ BEFORE TOUCHING CONTAINERS) +- **Permanent dev PB** = container **`pb-dev`**, publishes **`:8090`**, data in host **`./pb_data`** (root-owned). This is what `pnpm dev` talks to (via `SERVER_IP:8090`). +- **Docker app** (`famchamp-app-1`) has its **own internal PB**, published **`:8091`** (loopback), and it **also bind-mounts `./pb_data` → `/app/pb_data`**. So `pb-dev` and the docker app currently **share the same host data dir** — two PB processes on one store. Keep this in mind; the docker version is not the dev target. +- **NEVER `docker rm`/recreate `pb-dev` with a fresh volume** — that wipes the permanent dev PB. To restore it, recreate with its data intact: + ``` + docker rm -f pb-dev + docker run -d --name pb-dev \ + -v "$PWD/pb_data:/pb_data" \ + -p 8090:8090 \ + pb-dev \ + pocketbase serve --http=0.0.0.0:8090 --dir=/pb_data + ``` +- Superuser creds: `debug@famchamp.dev` / `debug123` (fallback baked in code). If superuser auth returns HTTP 500, the container is almost certainly serving an **empty store** (broken/missing mount) — restore the mount, don't reseed a new store. +- `SERVER_IP`: same LAN = `192.168.1.225`, away = your Tailscale IP (e.g. `100.103.22.104`). Both hit the same `pb-dev:8090`. Update `.env` when your IP changes. ## Monorepo - SvelteKit in `frontend/`, Hono in `proxy/` @@ -27,8 +44,8 @@ - Decisions tracked in `MEMORY.md` ## Environment Variables -- `FRONTEND_PORT` — frontend server port -- `PROXY_PORT` — Hono proxy port -- `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` — PocketBase admin -- `STRIPE_SECRET_KEY` — Stripe -- `DONATION_MODAL_INTERVAL` — donation modal frequency +- `SERVER_IP` — dev machine IP where PB + proxy run (browser `pocketbase.ts`, `pb-admin`, proxy all read it). Dev only; prod ignores it. +- `PB_EMAIL` / `PB_PASSWORD` — PB superuser (fallback `debug@famchamp.dev`/`debug123`) +- `PROXY_URL` — public env in `env.ts` (default `http://127.0.0.1:3456`); SSR → proxy +- Compose/deploy: `PORT` (public port, default `3001`), `PB_DATA` (host data dir) +- Not currently wired (future): `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL` diff --git a/config.d.ts b/config.d.ts deleted file mode 100644 index de2e0af..0000000 --- a/config.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export declare const FRONTEND_PORT = "2080"; -export declare const PROXY_PORT = "3456"; -export declare const SERVER_IP = "192.168.1.225"; -export declare const PB_PORT = "8090"; -export declare const PB_EMAIL = "debug@famchamp.dev"; -export declare const PB_PASSWORD = "debug123"; -export declare const DEBUG_RECORD_ID = "0747qjl16m6o529"; -export declare const PUBLIC_PB_URL = "http://192.168.1.225:8090"; -//# sourceMappingURL=config.d.ts.map \ No newline at end of file diff --git a/config.d.ts.map b/config.d.ts.map deleted file mode 100644 index 5f4b6bd..0000000 --- a/config.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,SAAS,CAAC;AACpC,eAAO,MAAM,UAAU,SAAS,CAAC;AACjC,eAAO,MAAM,SAAS,kBAAkB,CAAC;AACzC,eAAO,MAAM,OAAO,SAAS,CAAC;AAC9B,eAAO,MAAM,QAAQ,uBAAuB,CAAC;AAC7C,eAAO,MAAM,WAAW,aAAa,CAAC;AACtC,eAAO,MAAM,eAAe,oBAAoB,CAAC;AACjD,eAAO,MAAM,aAAa,8BAAmC,CAAC"} \ No newline at end of file diff --git a/config.js b/config.js deleted file mode 100644 index db8caba..0000000 --- a/config.js +++ /dev/null @@ -1,9 +0,0 @@ -export const FRONTEND_PORT = "2080"; -export const PROXY_PORT = "3456"; -export const SERVER_IP = "192.168.1.225"; -export const PB_PORT = "8090"; -export const PB_EMAIL = "debug@famchamp.dev"; -export const PB_PASSWORD = "debug123"; -export const DEBUG_RECORD_ID = "0747qjl16m6o529"; -export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`; -//# sourceMappingURL=config.js.map diff --git a/config.js.map b/config.js.map deleted file mode 100644 index d3e247a..0000000 --- a/config.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC;AACpC,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC;AACjC,MAAM,CAAC,MAAM,SAAS,GAAG,eAAe,CAAC;AACzC,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC;AAC9B,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;AAC7C,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAC;AACjD,MAAM,CAAC,MAAM,aAAa,GAAG,UAAU,SAAS,IAAI,OAAO,EAAE,CAAC"} \ No newline at end of file diff --git a/config.ts b/config.ts index 570e90a..8070cf8 100644 --- a/config.ts +++ b/config.ts @@ -1,8 +1,5 @@ +// Single source of truth for the three service ports (dev/build-time only, +// used by the Hono proxy). Runtime URLs are set via env (see proxy/src/env.ts). export const FRONTEND_PORT = "2080"; export const PROXY_PORT = "3456"; -export const SERVER_IP = "192.168.1.225"; export const PB_PORT = "8090"; -export const PB_EMAIL = "debug@famchamp.dev"; -export const PB_PASSWORD = "debug123"; -export const DEBUG_RECORD_ID = "0747qjl16m6o529"; -export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`; diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..bb3f9fa --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + app: + build: + context: . + dockerfile: docker/Dockerfile + ports: + - "3001:80" # nginx / app (public) + - "8091:8090" # PocketBase admin UI (loopback only — SSH tunnel) + volumes: + - "./pb_data:/app/pb_data" # PB DB persistence + environment: + PB_EMAIL: debug@famchamp.dev + PB_PASSWORD: debug123 + restart: unless-stopped diff --git a/docker/Dockerfile b/docker/Dockerfile index 62ed052..cdeb2da 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,7 @@ +# ── Build stage ────────────────────────────────────────── FROM node:22-alpine AS builder +# Browser → PB goes through nginx at /pb (same origin) — fixed for docker. +# ENV PUBLIC_PB_URL=/pb RUN corepack enable WORKDIR /app COPY . . @@ -6,8 +9,12 @@ RUN pnpm install --frozen-lockfile RUN pnpm --filter frontend build RUN pnpm --filter proxy build +# ── Runtime stage ──────────────────────────────────────── FROM node:22-alpine -RUN corepack enable && apk add --no-cache nginx +# Browser → PB goes through nginx at /pb (same origin). PB_ENDPOINT (server → +# PB) is a loopback constant in the app code, not an env var. +# ENV PUBLIC_PB_URL=/pb +RUN corepack enable && apk add --no-cache nginx wget unzip ca-certificates WORKDIR /app COPY --from=builder /app/frontend/build ./frontend/build COPY --from=builder /app/proxy/dist ./proxy/dist @@ -15,8 +22,15 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/pnpm-lock.yaml ./ COPY docker/nginx.conf /etc/nginx/http.d/default.conf COPY docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENV FRONTEND_PORT=2080 -ENV PROXY_PORT=3456 -EXPOSE 3001 +# Bundle PocketBase (same version as Dockerfile.dev) into the app container. +RUN wget -qO /tmp/pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.25.8/pocketbase_0.25.8_linux_amd64.zip \ + && unzip -o /tmp/pb.zip -d /usr/local/bin/ \ + && rm /tmp/pb.zip +RUN chmod +x /entrypoint.sh /usr/local/bin/pocketbase +# ENV FRONTEND_PORT=2080 +# ENV PROXY_PORT=3456 +# Public entrypoint. The host port is chosen at deploy time via compose PORT; +# EXPOSE here is documentation only (nginx listens on 3001). PB (8090) stays +# internal — never published from the image. +EXPOSE 3000 8090 CMD ["/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1119021..69e6ae3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,7 +1,33 @@ #!/bin/sh set -e -PORT=${FRONTEND_PORT:-2080} node /app/frontend/build/index.js & -PROXY_PORT=${PROXY_PORT:-3456} node /app/proxy/dist/index.js & +# PocketBase data lives on the mounted volume (compose maps ./pb_data → /app/pb_data). +PB_DATA=/app/pb_data + +# Seed a platform superuser from env (idempotent — no-op if it already exists). +# If PB_EMAIL/PB_PASSWORD aren't set, skip and rely on manual web setup. +if [ -n "$PB_EMAIL" ] && [ -n "$PB_PASSWORD" ]; then + echo "[entrypoint] Ensuring PocketBase superuser..." + pocketbase superuser upsert "$PB_EMAIL" "$PB_PASSWORD" --dir="$PB_DATA" || true +fi + +# Start PocketBase (internal only; published via loopback for admin UI). +pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" & + +# Wait for PB to be healthy before starting the proxy (which runs migrate). +echo "[entrypoint] Waiting for PocketBase..." +for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then + echo "[entrypoint] PocketBase is healthy." + break + fi + sleep 1 +done + +# Start the app (frontend + proxy). The proxy auto-runs schema migration. +# FRONTEND_PORT/PROXY_PORT are set via ENV in the Dockerfile; adapter-node +# reads PORT, the proxy reads PROXY_PORT. +node /app/frontend/build/index.js & +node /app/proxy/dist/index.js & nginx -g 'daemon off;' diff --git a/docker/nginx.conf b/docker/nginx.conf index a3be060..cf676f5 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,9 +1,9 @@ server { - listen 3001; + listen 80; server_name _; location / { - proxy_pass http://127.0.0.1:2080; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -11,6 +11,7 @@ server { proxy_cache_bypass $http_upgrade; } + # App's Hono proxy (/api/*). location /api/ { proxy_pass http://127.0.0.1:3456; proxy_http_version 1.1; @@ -19,4 +20,25 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + # Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the + # /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything + # else under /pb/) is intentionally NOT proxied, keeping it internal. + location /pb/api/ { + proxy_pass http://127.0.0.1:8090/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_buffering off; + proxy_read_timeout 3600s; + } + + # Everything else under /pb/ (including /pb/_ admin UI) → 404. + location /pb/ { + return 404; + } } diff --git a/frontend/package.json b/frontend/package.json index 76763d1..74ce426 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,9 @@ "vite": "8.0.16" }, "dependencies": { - "pocketbase": "^0.27.0" + "@hiseb/confetti": "^2.0.2", + "chart.js": "^4.4.0", + "pocketbase": "^0.27.0", + "qrcode": "^1.5.4" } } diff --git a/frontend/src/env.ts b/frontend/src/env.ts index afc4f39..3333744 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -1,8 +1,19 @@ import { defineEnvVars } from '@sveltejs/kit/hooks'; +// Default when the env var isn't set, so a missing value never crashes startup. +const withDefault = (value: string) => ({ + '~standard': { + version: 1, + vendor: 'famchamp', + validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value }) + } +} as const); + export const variables = defineEnvVars({ - DEBUG_RECORD_ID: {}, - SERVER_IP: {public: true}, - PB_PORT: {public: true}, - PUBLIC_PB_URL: {public: true} + // SSR → Hono proxy (loopback, proxy runs on the same host as SSR). + PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') }, + SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, + // PB superuser creds (server-only). + PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, + PB_PASSWORD: { public: false, schema: withDefault('debug123') } }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index da5c37f..5fdfb11 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,6 @@ import type { Handle } from '@sveltejs/kit'; -const COOKIE_NAME = 'session'; +const COOKIE_NAME = 'famdon-sesh'; export const handle: Handle = async ({ event, resolve }) => { const raw = event.cookies.get(COOKIE_NAME); diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index b1dca22..aa67da2 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -1,8 +1,6 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; - -const BASE_URL = typeof window === 'undefined' - ? `http://${SERVER_IP}:${PROXY_PORT}` - : ''; +// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in +// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead. +const BASE_URL = ''; async function memberFetch( method: string, diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index 0599ee5..44af859 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -1,7 +1,7 @@ import PocketBase from 'pocketbase'; -import { PUBLIC_PB_URL } from '$app/env/public'; - -export const pb = new PocketBase(PUBLIC_PB_URL); +import { SERVER_IP } from '$app/env/public'; +const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`; +export const pb = new PocketBase(PB_ENDPOINT); pb.autoCancellation(false); export function initPbFromCookie() { diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 087b4e7..9dae4dc 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,8 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; - -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +import { PROXY_URL } from '$app/env/public'; export function getSession(event: RequestEvent) { return event.locals.session; @@ -17,7 +15,7 @@ export function requireAuth(event: RequestEvent) { } export async function signup(email: string, password: string, famName: string, parentName?: string) { - const res = await fetch(`${HONO_URL}/api/admin/signup`, { + const res = await fetch(`${PROXY_URL}/api/admin/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, famName, parentName }), @@ -29,7 +27,7 @@ export async function signup(email: string, password: string, famName: string, p export async function login(email: string, password: string) { console.log(email); - const res = await fetch(`${HONO_URL}/api/admin/login`, { + const res = await fetch(`${PROXY_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), @@ -40,7 +38,7 @@ export async function login(email: string, password: string) { } export async function joinMember(inviteCode: string, name: string, deviceToken: string) { - const res = await fetch(`${HONO_URL}/api/members/join`, { + const res = await fetch(`${PROXY_URL}/api/members/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteCode, name, deviceToken }), diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index 1bc10aa..be3974d 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -1,7 +1,5 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; import type { RequestEvent } from '@sveltejs/kit'; - -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +import { PROXY_URL } from '$app/env/public'; function sessionHeaders(event: RequestEvent): Record { const s = event.locals.session; @@ -19,7 +17,7 @@ async function request( body?: unknown, headers?: Record ) { - const res = await fetch(`${HONO_URL}${path}`, { + const res = await fetch(`${PROXY_URL}${path}`, { method, headers: headers || { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts index 4c109f8..91582f2 100644 --- a/frontend/src/lib/server/pb-admin.ts +++ b/frontend/src/lib/server/pb-admin.ts @@ -1,6 +1,6 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts'; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; +import { SERVER_IP } from '$app/env/public'; +export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`; let token: string | null = null; let tokenExpiry = 0; diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index c10848e..fc30ec7 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -67,7 +67,7 @@ class FamStore { private initPromise: Promise | null = null; - async init(famId: string) { + async init(famId: string, initialFam?: Fam) { if (this.initialized && this.famId === famId) return; // Wait for any in-flight init to finish first @@ -82,8 +82,11 @@ class FamStore { this.initPromise = (async () => { try { + // fams is superadmin-only (non-realtime, one-way writes). It is always + // fetched server-side by the layout load and passed in — never via the + // unauthenticated client PB SDK. + this.fam = initialFam || this.fam || ({} as Fam); const [ - famRes, membersRes, templatesRes, assignedRes, @@ -93,7 +96,6 @@ class FamStore { rewardsRes, seasonsRes ] = await Promise.all([ - pb.collection('fams').getOne(famId) as Promise, pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise< Member[] >, @@ -119,7 +121,6 @@ class FamStore { Season[] > ]); - this.fam = famRes; this.members = membersRes; this.templates = templatesRes; this.assigned = assignedRes; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 323289a..47a08f0 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,6 +1,7 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import { PROXY_URL } from '$app/env/public'; +import { pbAdmin } from '$lib/server/pb-admin'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; async function paydayCheck(famId: string, headers: Record) { try { @@ -77,5 +78,16 @@ export async function load(event) { } } - return { session, isParent, role: session?.role || 'child', famId, chat, deviceToken }; + return { + session, + isParent, + role: session?.role || 'child', + famId, + chat, + deviceToken, + // fams is superadmin-only (non-realtime). Fetched server-side for both roles. + fam: famId + ? await pbAdmin.getOne('fams', famId).catch(() => null) + : null + }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 4281a1d..6f6c092 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -46,7 +46,7 @@ onMount(() => { initPbFromCookie(); - if (page.data.famId) famStore.init(page.data.famId); + if (page.data.famId) famStore.init(page.data.famId, page.data.fam); const chat = page.data.chat; if (chat?.famId && chat?.actor) { chatStore.init({ diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts index 789cc1c..7cb0711 100644 --- a/frontend/src/routes/[fam]/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -1,9 +1,8 @@ import { fail, redirect } from '@sveltejs/kit'; import { hono } from '$lib/server/hono'; -import { memberApi } from '$lib/client/api'; -import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts'; +import { PROXY_URL } from '$app/env/public'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export async function load(event) { const session = event.locals.session; @@ -101,7 +100,11 @@ export async function load(event) { let chores: any = {}; try { - chores = await memberApi.myChores(deviceToken, data.famId); + const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, { + method: 'POST', + headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId } + }); + chores = await choresRes.json(); } catch {} return { diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts index 71ec924..576fdd0 100644 --- a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -1,8 +1,8 @@ import { redirect } from '@sveltejs/kit'; import { hono } from '$lib/server/hono'; -import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; +import { PROXY_URL } from '$app/env/public'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export async function load(event) { const session = event.locals.session; diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte index acfb995..9c96629 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -1,8 +1,6 @@ - -

Debug Dashboard

- -
-
-

Hono

- {#if records} -

{records.hono_count}

- {/if} - -
- - -
- - diff --git a/frontend/src/routes/join/[code]/[member]/+page.server.ts b/frontend/src/routes/join/[code]/[member]/+page.server.ts index 9b1b6b6..c8db6a1 100644 --- a/frontend/src/routes/join/[code]/[member]/+page.server.ts +++ b/frontend/src/routes/join/[code]/[member]/+page.server.ts @@ -1,8 +1,8 @@ import { fail, redirect } from '@sveltejs/kit'; import { setDeviceTokenCookie } from '$lib/server/auth'; -import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; +import { PROXY_URL } from '$app/env/public'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export const actions = { default: async (event) => { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c289082..4613c50 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,7 +2,6 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; -import { SERVER_IP, FRONTEND_PORT, PROXY_PORT } from '../config.ts'; export default defineConfig(() => { return { @@ -25,11 +24,13 @@ export default defineConfig(() => { fs: { allow: ['.', './node_modules', '../node_modules'] }, - allowedHosts: [SERVER_IP], - port: FRONTEND_PORT, + // Dev only: allow access via any host/LAN IP without hardcoding it. + allowedHosts: true, + port: 2080, proxy: { '/api': { - target: `http://${SERVER_IP}:${PROXY_PORT}`, + // The vite dev server and Hono proxy run on the same host. + target: `http://127.0.0.1:3456`, changeOrigin: true } } diff --git a/package.json b/package.json index 4f0975d..2c82df9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "famchamp-monorepo", "private": true, + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev", "start": "pnpm dev", "build": "pnpm -r build" }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "version": "0.2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f98732..4094779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,9 +10,18 @@ importers: frontend: dependencies: + '@hiseb/confetti': + specifier: ^2.0.2 + version: 2.2.0 + chart.js: + specifier: ^4.4.0 + version: 4.5.1 pocketbase: specifier: ^0.27.0 version: 0.27.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 devDependencies: '@sveltejs/adapter-node': specifier: next @@ -66,6 +75,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 tsx: specifier: ^4.19.0 version: 4.22.4 @@ -249,6 +261,9 @@ packages: cpu: [x64] os: [win32] + '@hiseb/confetti@2.2.0': + resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -271,6 +286,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -630,6 +648,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} @@ -638,18 +664,40 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -661,6 +709,12 @@ packages: devalue@5.8.1: resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -690,11 +744,19 @@ packages: picomatch: optional: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -702,6 +764,10 @@ packages: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -786,6 +852,10 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -806,6 +876,22 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -813,6 +899,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pocketbase@0.27.0: resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==} @@ -887,10 +977,22 @@ packages: engines: {node: '>=14'} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -905,6 +1007,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -913,6 +1018,14 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + svelte-check@4.6.0: resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==} engines: {node: '>= 18.0.0'} @@ -1012,6 +1125,24 @@ packages: vite: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -1127,6 +1258,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hiseb/confetti@2.2.0': {} + '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -1150,6 +1283,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1392,24 +1527,54 @@ snapshots: acorn@8.17.0: {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-query@5.3.1: {} axobject-query@4.1.0: {} + camelcase@5.3.1: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + cookie@1.1.1: {} + decamelize@1.2.0: {} + deepmerge@4.3.1: {} detect-libc@2.1.2: {} devalue@5.8.1: {} + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -1454,13 +1619,22 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + graceful-fs@4.2.11: {} hono@4.12.27: {} + is-fullwidth-code-point@3.0.0: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1518,6 +1692,10 @@ snapshots: locate-character@3.0.0: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1530,10 +1708,24 @@ snapshots: obug@2.1.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pngjs@5.0.0: {} + pocketbase@0.27.0: {} postcss@8.5.15: @@ -1555,8 +1747,18 @@ snapshots: prettier@3.8.4: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + readdirp@4.1.2: {} + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -1603,6 +1805,8 @@ snapshots: dependencies: mri: 1.2.0 + set-blocking@2.0.0: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -1611,6 +1815,16 @@ snapshots: source-map-js@1.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1689,4 +1903,33 @@ snapshots: optionalDependencies: vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + zimmerframe@1.1.4: {} diff --git a/proxy/package.json b/proxy/package.json index 679e3a3..8062a0e 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -3,10 +3,10 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "seed": "tsx scripts/seed.ts" + "dev": "tsx watch --env-file-if-exists=../.env src/index.ts", + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js", + "start": "node dist/index.js", + "seed": "tsx --env-file-if-exists=../.env scripts/seed.ts" }, "dependencies": { "@hono/node-server": "^1.13.0", @@ -14,6 +14,7 @@ }, "devDependencies": { "@types/node": "^26.0.0", + "esbuild": "^0.28.1", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts index 8678367..66681e1 100644 --- a/proxy/scripts/seed.ts +++ b/proxy/scripts/seed.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "../src/env.ts"; interface FieldDef { name: string; @@ -121,8 +119,8 @@ async function main() { ids.fams = await createCollection(token, { name: "fams", type: "base", - listRule: "", - viewRule: "", + listRule: null, + viewRule: null, createRule: null, updateRule: null, deleteRule: null, diff --git a/proxy/src/env.ts b/proxy/src/env.ts new file mode 100644 index 0000000..f78ecf2 --- /dev/null +++ b/proxy/src/env.ts @@ -0,0 +1,14 @@ +// Runtime env for the proxy. Same dev/prod split as the frontend: dev connects +// to the dev machine's PocketBase at SERVER_IP, prod connects to the +// container-internal loopback PB. Ports come from config.ts (single source). +// +// Env loading: dev uses `tsx --env-file=../.env` (see package.json) like vite +// does for the frontend; prod (docker) injects env via compose and has no .env, +// so SERVER_IP is unset → loopback below. +const SERVER_IP = process.env.SERVER_IP; + +export const PB_ENDPOINT = SERVER_IP + ? `http://${SERVER_IP}:8090` + : `http://127.0.0.1:8090`; +export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev"; +export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123"; diff --git a/proxy/src/index.ts b/proxy/src/index.ts index 88b48a8..8ec0a59 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -3,6 +3,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { pb } from "./pb.ts"; import { migrate } from "./migrate.ts"; +import { PROXY_PORT } from "../../config.ts"; import { weekStart as tzWeekStart, addDaysStr, @@ -2453,11 +2454,10 @@ app.get("/api/chat/me", async (c) => { // ── Start server ───────────────────────────────────────── -const port = parseInt(process.env.PROXY_PORT || "3456", 10); -const SERVER_IP = "192.168.1.225"; +const port = parseInt(process.env.PROXY_PORT || PROXY_PORT, 10); serve({ fetch: app.fetch, port }, async (info) => { - console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`); + console.log(`Hono proxy listening on 0.0.0.0:${info.port}`); try { await migrate(); } catch (e) { diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts index f42db8a..bccda2e 100644 --- a/proxy/src/migrate.ts +++ b/proxy/src/migrate.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; let token: string | null = null; @@ -32,7 +30,7 @@ async function getCollection(name: string): Promise { return data?.items?.[0] || null; } -async function createCollection(col: any): Promise { +async function createCollection(col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections`, { method: "POST", @@ -46,6 +44,7 @@ async function createCollection(col: any): Promise { if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); console.log(` ✓ Created collection: ${col.name}`); + return data?.id || null; } async function updateCollection(id: string, col: any): Promise { @@ -64,9 +63,239 @@ async function updateCollection(id: string, col: any): Promise { console.log(` ✓ Updated collection: ${col.name || id}`); } +// ── Field helpers (mirror proxy/scripts/seed.ts) ── +function text(name: string, required = false) { + return { name, type: "text", required }; +} +function uniqueText(name: string) { + return { name, type: "text", required: true, unique: true }; +} +function number(name: string, required = false) { + return { name, type: "number", required }; +} +function bool(name: string) { + return { name, type: "bool" }; +} +function date(name: string) { + return { name, type: "date" }; +} +function jsonField(name: string) { + return { name, type: "json" }; +} +function select(name: string, values: string[], required = false) { + return { name, type: "select", required, values, maxSelect: 1 }; +} +function rel(name: string, collectionId: string, required = false) { + return { + name, + type: "relation", + required, + collectionId, + maxSelect: 1, + cascadeDelete: false, + }; +} +function colDef(name: string, fields: any[]): any { + return { + name, + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields, + }; +} + +// Bootstrap the full base schema on a fresh PocketBase (docker first boot). +// Idempotent — skips collections that already exist. +async function ensureSchema(): Promise { + if (await getCollection("fams")) { + console.log("[migrate] Base schema already present — skipping bootstrap."); + return; + } + console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); + + // Pass 1: independent + const famsId = (await createCollection( + colDef("fams", [ + text("name", true), + uniqueText("slug"), + text("inviteCode"), + text("stripeCustomerId"), + jsonField("featureFlags"), + jsonField("seasons"), + ]), + ))!; + // fams is sensitive → superadmin-only (proxy/server reads). Not public. + await updateCollection(famsId, { viewRule: null, listRule: null }); + await createCollection( + colDef("bonus_templates", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive", "collaborative"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + select("period", ["schedule", "daily", "weekly", "monthly"]), + ]), + ); + + // Pass 2: reference fams + await createCollection( + colDef("settings", [rel("famId", famsId, true), text("webhookUrl")]), + ); + const membersId = (await createCollection( + colDef("members", [ + rel("famId", famsId, true), + text("name", true), + text("color"), + text("deviceToken"), + text("deviceTokenHint"), + ]), + ))!; + const choreTemplatesId = (await createCollection( + colDef("chore_templates", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + ]), + ))!; + await createCollection( + colDef("fam_admins", [ + rel("famId", famsId, true), + text("userId", true), + text("email", true), + text("name"), + text("color"), + ]), + ); + const bonusConfigsId = (await createCollection( + colDef("bonus_configs", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive", "collaborative"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + rel("memberId", membersId), + select("period", ["schedule", "daily", "weekly", "monthly"]), + select("status", ["active", "completed"], true), + ]), + ))!; + + // Pass 3: nested deps + await createCollection( + colDef("weekly_history", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ]), + ); + await createCollection( + colDef("rewards", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("bonusConfigId", bonusConfigsId), + text("label", true), + number("value", true), + select("rewardType", ["cash", "prize", "points"], true), + select("status", ["unclaimed", "requested", "claimed"], true), + select("claimable", ["immediate", "payday"], true), + text("settleDate"), + date("claimedAt"), + date("requestedAt"), + text("date"), + ]), + ); + const assignedChoresId = (await createCollection( + colDef("assigned_chores", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("templateId", choreTemplatesId, true), + select("frequency", ["daily", "weekly"], true), + select("type", ["points", "money"], true), + number("value", true), + text("customName"), + jsonField("seasonIds"), + ]), + ))!; + await createCollection( + colDef("seasons", [ + rel("famId", famsId, true), + text("name", true), + text("color"), + bool("active"), + date("autoDisable"), + date("autoStart"), + ]), + ); + await createCollection( + colDef("completions", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("assignedChoreId", assignedChoresId, true), + date("date"), + date("completedAt"), + ]), + ); + await createCollection( + colDef("messages", [ + rel("famId", famsId, true), + select("authorType", ["admin", "member"], true), + text("authorId", true), + text("authorName", true), + text("authorColor"), + text("content", true), + date("createdAt"), + ]), + ); + await createCollection( + colDef("chat_typing", [ + rel("famId", famsId, true), + text("actorId", true), + select("actorType", ["admin", "member"], true), + text("authorName", true), + text("authorColor"), + bool("typing"), + ]), + ); + console.log("[migrate] Base schema bootstrapped."); +} + export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); + await ensureSchema(); + + // ── 0. Lock fams to superadmin-only (sensitive; read via proxy/server) ── + { + const famsCol = await getCollection("fams"); + if (famsCol) { + const rules = { viewRule: null, listRule: null }; + if (famsCol.viewRule !== null || famsCol.listRule !== null) { + console.log("[migrate] Locking fams collection to superadmin-only..."); + await updateCollection(famsCol.id, rules); + } else { + console.log(" ↳ fams already superadmin-only"); + } + } + } + // ── 1. Create bonus_configs if missing ── const existing = await getCollection("bonus_configs"); if (!existing) { diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts index 4ffac0d..b745054 100644 --- a/proxy/src/pb.ts +++ b/proxy/src/pb.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; let adminToken: string | null = null; let tokenExpiry = 0;