diff --git a/.project/concepts/tech-stack/_overview.md b/.project/concepts/tech-stack/_overview.md index 7d55522..1a52fe1 100644 --- a/.project/concepts/tech-stack/_overview.md +++ b/.project/concepts/tech-stack/_overview.md @@ -3,7 +3,7 @@ type: _overview summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands triggers: keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json] - paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"] + paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", "backend/Dockerfile", "docker-compose*.yml", "backend/services/base-path.js", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"] --- # Tech stack @@ -18,4 +18,4 @@ The project's stack and tooling. Guiding principle: **Bun-first** — Bun replac - [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell) - [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook - [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks) -- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev +- [[knowledge_deployment]] — the Coolify test stack (one base URL, backend under /api), both images, first-boot traps, prod served from src/, `portless` in dev diff --git a/.project/concepts/tech-stack/knowledge_deployment.md b/.project/concepts/tech-stack/knowledge_deployment.md index 2750bc4..f16947d 100644 --- a/.project/concepts/tech-stack/knowledge_deployment.md +++ b/.project/concepts/tech-stack/knowledge_deployment.md @@ -1,14 +1,32 @@ --- type: knowledge -summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper -last_checked: 2026-08-17 +summary: Deployment — the Coolify test stack (app + backend + Redis, shared Fuseki), one base URL with the backend under /api (Strip Prefixes OFF), both images' build traps, first-boot traps, the data-layer git pin, the shared wallet as env config +last_checked: 2026-10-19 --- # Deployment & infra -Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end. +## The Coolify test stack -## Dockerfile +`docker-compose.coolify.yml` is what Coolify (build pack *Docker Compose*) builds and runs from the git repository, on the test server only for now: `frontend` (the root `Dockerfile`, below), `backend` (`backend/Dockerfile`, Moleculer + `@activitypods/app`) and a per-stack Redis. Fuseki is the one shared with the other ActivityPods apps of the Coolify instance (`shared-infra`): only the backend joins the external `coolify` network to reach it, with a pair of datasets of its own named from `DATASET_PREFIX` and the backend's service name (a second non-preview stack on the same Fuseki needs its own prefix). The variables Coolify must carry are listed at the top of the compose file; `SERVICE_FQDN_*` / `SERVICE_NAME_*` are generated by Coolify. + +**One base URL per environment** (user decision 2026-10-19, so a pull-request preview lives on a single host, like the sibling apps): the app at `https://festipod.dev.reconnexion.coop/`, the backend on the same host under `/api` — its Domains field is `https:///api,https:///.well-known`. Coolify's **Strip Prefixes must be OFF** (it is on the sibling apps): the backend mounts its routes under `/api` itself, read from `SEMAPPS_HOME_URL=https:///api/`, and the front end is pointed at `VITE_BACKEND_URL=https:///api`. With the prefix stripped every backend request is refused (404). + +**The backend is base-path aware** (`backend/services/base-path.js`), because SemApps is: it mounts every route it adds under the base URL's path (LDP resources, the app actor and its inbox, public keys, access needs, webhooks, `/nodeinfo/2.1`) but keeps the `/.well-known` ones (`context.jsonld`, `nodeinfo`, `solid`, `void`) at the origin root. Festipod's own routes (`/app`, `/as/actor/app`, `/catalog`) are mounted under the base path the same way, the public-surface allow-list matches paths relative to it (the two `/.well-known` endpoints against the full path), and the Solid-OIDC `client_id` is the requested URI rebuilt from the base URL as written — so it equals, byte for byte, the actor URI the backend mints and the `${backendUrl}/as/actor/app` the front end derives. At the root (`http://localhost:3003`, local dev) every decision is identical to the pre-base-path code. To verify: `bun test backend/services` (with and without `SEMAPPS_HOME_URL=https:///api/`). + +**First-boot traps.** +- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment; changing them later means deleting the two datasets and redeploying. +- On a cold first boot, `GET app` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment. + +**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too. + +## Backend image + +`node:22-slim`, `pnpm install --frozen-lockfile --prod`, `pnpm start` (tsx over the Moleculer runner). Two things it needs that a local install hides: +- **pnpm 10 skips dependency build scripts unless approved**: `backend/package.json` approves `bcrypt`, `sharp` and `sodium-native` (`pnpm.onlyBuiltDependencies`). Without it the image builds and the container exits at start (`sharp-linux-x64.node` missing; bcrypt would fail next). A local `node_modules` may carry the binaries from an earlier manual build, which is how this stayed invisible. +- **sharp must not depend on GitHub at build time**: its installer downloads prebuilt libvips and binaries from GitHub, and that download timed out on every in-build attempt (2026-10-19) and failed the build. The image installs `libvips-dev` (so sharp detects a system libvips and compiles itself) and sets `npm_config_nodedir=/usr/local` (node-gyp uses the image's own headers). bcrypt still tries GitHub first and falls back to compiling, slower but working offline. Cost: a larger image (a multi-stage build keeping only `libvips42` at runtime would drop several hundred MB — not done). + +## App image (root Dockerfile) A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]): - `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source. @@ -31,9 +49,7 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm **`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated. -## CI/CD - -**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies. +## The NextGraph-era deployment **A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA). diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 0000000..b4a2ada --- /dev/null +++ b/docker-compose.coolify.yml @@ -0,0 +1,96 @@ +# Docker Compose definition used by Coolify to build and run Festipod straight from the +# git repository: one stack for the branch it follows (the test environment). The images +# are built on the Coolify server from the two Dockerfiles of this repository (the app at +# the root, the backend in /backend); nothing is pulled from a registry. +# +# Before `docker compose build` and `up`, Coolify writes a .env file next to this file +# with the variables defined in its UI plus generated ones. The generated ones used here: +# SERVICE_FQDN_BACKEND / SERVICE_FQDN_FRONTEND host of each service, as set in its +# Domains field +# SERVICE_NAME_BACKEND / SERVICE_NAME_FESTIPOD_REDIS service names (Coolify suffixes +# them with "-pr-" in a preview) +# A value starting with `${` becomes an editable variable in Coolify (with its default); +# the other `${...}` references are only interpolated by docker compose. +# +# One base URL per environment, as for the other ActivityPods apps on this Coolify (it is +# what lets a pull request preview live on a single host): the frontend's domain serves the +# app, and the backend is served on the same host under `/api` and `/.well-known` — its +# Domains field is `https:///api,https:///.well-known`. The proxy must pass those +# paths through unchanged — Coolify's "Strip Prefixes" OFF, as on the other apps: the backend +# mounts its routes under `/api` itself, from its base URL, and keeps its `/.well-known` ones +# at the origin root (backend/services/base-path.js). With the prefixes stripped, every +# backend request would be refused (404). +# +# The backend registers its app actor in its main dataset on the first boot, with the +# domain it had at that time: set the domains in Coolify BEFORE the first deployment. If +# they change later, delete the two datasets in Fuseki and redeploy. +# +# Fuseki is the one shared with the other apps (shared-infra, in the "infra" project): the +# backend joins the "coolify" network to reach it, with its own pair of datasets. Redis +# runs inside the stack (on the stack's private network only, under a name that cannot be +# confused with the shared one). + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: always + depends_on: + - festipod-redis + expose: + - '3003' + # Coolify adds the stack's own network to every service; only the backend also needs + # the shared one, where Fuseki lives. + networks: + - coolify + environment: + SEMAPPS_APP_NAME: ${APP_NAME:-Festipod} + SEMAPPS_APP_DESCRIPTION: ${APP_DESCRIPTION:-Points de rencontre sur les événements publics} + SEMAPPS_APP_LANG: ${APP_LANG:-en,fr} + SEMAPPS_HOME_URL: https://${SERVICE_FQDN_BACKEND}/api/ + SEMAPPS_FRONT_URL: https://${SERVICE_FQDN_FRONTEND} + SEMAPPS_PORT: '3003' + SEMAPPS_SHAPE_REPOSITORY_URL: ${SHAPE_REPOSITORY_URL:-https://shapes.activitypods.org/} + SEMAPPS_SPARQL_ENDPOINT: ${SPARQL_ENDPOINT} + SEMAPPS_JENA_USER: ${JENA_USER:-admin} + SEMAPPS_JENA_PASSWORD: ${JENA_PASSWORD} + # A pair of datasets named after DATASET_PREFIX and the backend's service name, e.g. + # festipod-backend / settings-festipod-backend (…-backend-pr- in a preview). Two + # non-preview stacks on the same Fuseki would share them: give the second its own + # DATASET_PREFIX (or MAIN_DATASET / SETTINGS_DATASET). + SEMAPPS_MAIN_DATASET: ${MAIN_DATASET:-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}} + SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME: ${SETTINGS_DATASET:-settings-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}} + SEMAPPS_REDIS_CACHE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/0 + SEMAPPS_QUEUE_SERVICE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/1 + + frontend: + build: + context: . + dockerfile: Dockerfile + restart: always + depends_on: + - backend + expose: + - '3000' + # Read by the server on every request to /festipod-config.json, not inlined at build + # time: changing one needs a restart, not a rebuild. + environment: + PORT: '3000' + VITE_BACKEND_URL: https://${SERVICE_FQDN_BACKEND}/api + # The Pod provider the login screen offers in one click; any other can be typed in. + VITE_POD_PROVIDER_URL: ${POD_PROVIDER_URL:-https://mypod.store} + + festipod-redis: + image: redis:7-alpine + restart: always + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis-data:/data + +volumes: + redis-data: + +networks: + coolify: + external: true