Skip to content

feat(console): one image for every instance, published beside the worker, and a service of the compose file (FRU-222) - #290

Merged
Mheaus merged 1 commit into
mainfrom
mathieu/fru-222-host-la-console-pour-un-auto-hebergeur
Oct 13, 2026
Merged

Mheaus merged 1 commit into
mainfrom
mathieu/fru-222-host-la-console-pour-un-auto-hebergeur

Conversation

@sakuga-agent

@sakuga-agent sakuga-agent Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

For Mathieu to read: CI, publication and the compose file. Merging this to main publishes a new package, ghcr.io/sakuga-software/fruitback-console, at the next run of release-image.yml. Nothing was published, dispatched or pushed to a registry from this branch.

Refs FRU-222 (not Closes: the sign-in by the link of the first admin cannot be measured before FRU-221, and the image is not published yet)

Three things to check before merging

  1. The Cloud. Dokploy builds this Dockerfile for app.fruitback.com with VITE_FRUITBACK_API and gives the container no variable. That path is kept and tested in CI, and the policy it serves is the same (below). Two things change for it all the same: the base image is nginx:1.29-alpine-slim with apk upgrade, and nginx runs as user 101, still on port 80. Docker lets any user open that port (measured here: uid 101 listens on 80); not measured on Dokploy itself.
  2. The package is new. A package that GitHub creates is private by default: fruitback-console will need the visibility and the link to the repository that fruitback-worker has, or docker compose --profile console cannot pull it.
  3. docker compose up does not change. The console is behind a profile, like the database it needs.

The choices, the simplest each time

  • A second image, not the console inside the worker (arbitrated in FRU-184): the worker holds the keys of the trackers.
  • The address of the worker is one variable, read when the container starts: FRUITBACK_API. entrypoint.sh writes it in /config.js, which the page loads in <head> before its bundle (app/instance.ts), and its origin in the connect-src of the policy. No rebuild, no envsubst on the bundle.
  • Two origins by default, not one. The compose file publishes the worker on 8080 and the console on 8081; behind a proxy, two names of one domain (api. and app.). Reason: it is what the Cloud runs and what the worker is written and tested for. The worker answers the origin of FRUITBACK_CONSOLE_URL and no other, with credentials, and the session cookie is the worker's, SameSite=Strict, which a browser sends between two names of one site. One name with two paths would need a proxy that knows every path of the worker; nobody measured it (FRU-200), so it is not offered.
  • The compose file reads no new address: the console is given FRUITBACK_PUBLIC_URL, which the worker already has. Two new variables only, both for Compose: FRUITBACK_CONSOLE_IMAGE and FRUITBACK_CONSOLE_PORT.
  • The console and the worker are published by separate jobs that do not wait for each other: a red console does not hold back a fix of the worker.

What changes

  • apps/console/Dockerfile, entrypoint.sh (replaces check-api.sh), nginx.conf, nginx.conf.template:
    • FRUITBACK_API is held to the letters of an address (scheme, host, port, path) before anything is written: it goes into a header and into a script. No address, or what is not one, stops the container with a sentence and exit 1.
    • The policy names the origin of the worker: a source with a path would allow that one path.
    • User 101:101, an nginx.conf with no user line, everything written under /tmp; the entry scripts of the base image are removed. FRUITBACK_CONSOLE_LISTEN (default 80) for a runtime that keeps low ports to root.
    • nginx:1.29-alpine-slim + apk upgrade: the release gate would have refused the image on the base of before (Trivy found fixable HIGH findings in pcre2, nghttp2-libs, tiff of nginx:1.29-alpine; on this image: 0).
    • server_tokens off.
  • apps/console/app/instance.ts (+ test), transport.ts, root.tsx (one <script src="/config.js"> in <head>; no screen is touched), public/config.js (sets nothing: pnpm dev and the E2E suite are as before).
  • .github/workflows/ci.yml, job docker image only (nothing added to e2e): the image built with no address and started for two workers; eight refusals; not root, no source file, nothing writable outside /tmp; a read-only root with no capability; the Cloud build started with no variable; the console profile of the compose file.
  • .github/workflows/release-image.yml: check-console (both architectures, booted, Trivy) and publish-console (needs: [ci, check-console], the if of publish, the same tags, SBOM, provenance, attestation). Every action is pinned by SHA, the ones the worker's jobs use.
  • docker-compose.yml: a console service, profiles: [console], one published port, read_only, tmpfs: /tmp, cap_drop: ALL, no-new-privileges, two variables and no secret. .env.example, compose.test.ts (five new cases), docs/self-hosting.md (a short section; the whole guide is FRU-227), docs/decisions/image.md, docs/decisions/console.md, SECURITY.md.

Measured, on images built from this branch (Docker Desktop, arm64)

  • Size: 10.2 MB (docker image inspect), of which 1.7 MB are the files of the console. With the full nginx:1.29-alpine base it was 26.2 MB.
  • Two workers, one image: started with https://api.acme.dev and with http://localhost:8080/, each serves connect-src 'self' <its own origin> and globalThis.__FRUITBACK__ = { api: "<its own address>" }, the slash removed.
  • Headers read on a page: Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-…' ×4; style-src 'self' 'unsafe-inline'; connect-src 'self' <worker>; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self', Cache-Control: no-store, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Server: nginx. On /config.js: application/javascript, no-store, nosniff.
  • Refused, exit 1, with the sentence: no address; https://a.example; script-src *; a quote; "};alert(1);//; user:pw@; a query; javascript:; two lines.
  • Not root: id answers uid=101(nginx), every nginx process is nginx, nothing is writable outside /tmp. It serves on 80 as that user, and it starts and is healthy on 8080 with --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges.
  • No source, no secret: no .ts, .tsx, .map, package.json, lockfile, .env or node_modules in the image; its environment holds FRUITBACK_API and FRUITBACK_CONSOLE_LISTEN.
  • The Cloud build, before and after, both built with VITE_FRUITBACK_API=https://api.fruitback.com and started with no variable: the same headers on every route, but for two things: Server: nginx in place of nginx/1.29.8, and one of the four script hashes (the inline script that names the files of the build, which changed with root.tsx). connect-src and every other directive are the same.
  • From an empty directory, under env -i: the compose file and a .env from the example, docker compose --profile postgres --profile console up -d --wait: worker, database and console healthy. In a real Chromium, the sign-in screen of the console served by the image reads its address from /config.js, calls the worker (GET /auth/providers 200, GET /console/me 401, both with Access-Control-Allow-Origin of the console and credentials), and reports no violation of the policy; a fetch of another origin planted in the page is blocked by connect-src (the control). The worker answers 403 origin-not-allowed to another origin.
  • A console with no FRUITBACK_PUBLIC_URL in that directory: up --wait fails in two seconds and the log holds the sentence.
  • zizmor 1.30.1 offline on .github/workflows: no finding. The three new shell steps of ci.yml were extracted and run here as written: exit 0.
  • typecheck, lint, format, pnpm test (7 projects): green.

Not verified

  • A sign-in to the end. Without FRU-221 (the link of the first admin) a stack with no mail provider signs nobody in. So the session cookie was not seen in a browser on this stack, and in particular not on http://localhost, where the cookie is Secure with no TLS: Chromium and Firefox are documented to accept it there, Safari is not. Two names of one domain over HTTPS is what the Cloud runs.
  • Dokploy: the console of the Cloud was not rebuilt or redeployed; only its build and its start with no variable are reproduced, in CI and here.
  • linux/amd64 was not built here (the machine is arm64): check-console builds and boots both, and it has not run.
  • release-image.yml itself has not run with these jobs: no dispatch, no push.
  • One name with two paths, and Safari (FRU-200).

🤖 Generated with Claude Code


Sakuga Review · Score de confiance : 4/5

Fusion possible : aucun défaut bloquant trouvé dans l'image, l'entrypoint ou la lecture de l'adresse côté bundle ; seules trois vérifications négatives de la CI ne peuvent jamais échouer.

Constats

  1. P2 Les vérifications négatives ! grep ne font jamais échouer l'étape .github/workflows/ci.yml:468 ▶
Résumé

La PR fait de la console une image unique et publiée. entrypoint.sh valide FRUITBACK_API, puis écrit /tmp/fruitback/config.js et la politique CSP. Le bundle lit l'adresse via apiFrom, et le compose ajoute un service console sous profil. J'ai vérifié : le motif de validation (la vérification multi-ligne rattrape grep ligne à ligne), l'absence de caractère spécial pour sed, l'ordre de chargement de /config.js avant le module, le repli sur l'adresse du build pour le Cloud, le healthcheck avec le port variable, les tests de compose.test.ts face au fichier, et la dépendance de publish-console à ci. Une seule réserve dans ci.yml : sous bash -e, une commande niée par ! ne déclenche jamais errexit.

Diagramme
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["docker-compose.yml / docker run"] --> B["entrypoint.sh"]
  B --> C["/tmp/fruitback/config.js"]
  B --> D["console.conf (CSP connect-src)"]
  D --> E["nginx"]
  C --> E
  E --> F["root.tsx script /config.js"]
  F --> G["instance.ts apiFrom"]
  G --> H["transport.ts API"]
Loading
Non relu
  • apps/console/csp.mjs : non montré, impossible de vérifier comment il traite la nouvelle balise <script src="/config.js"> (hash d'un script vide ou erreur).
  • Déploiement Dokploy du Cloud : impossible de confirmer que le runtime laisse l'utilisateur 101 ouvrir le port 80 comme le Docker du runner CI.
  • Comportement réel de nginx alpine-slim en non-root (journaux vers /dev/stderr, droits sur /var/cache/nginx) : seulement vérifiable par les jobs CI.

Revues (2) · Dernier commit relu : feat(console): one image for every instance, pu... · Niveau heavy · anthropic/claude-opus-5.5 · 0,543 $

…ker, and a service of the compose file (FRU-222)

The address of the worker was compiled into the console. The container now reads FRUITBACK_API
when it starts, writes it in /config.js, which the page loads before its bundle, and its origin in
the Content-Security-Policy. The value is held to the letters of an address first; no address, or
what is not one, stops the container with a sentence. The build argument of the Cloud is still
taken, as the address of a container that is given none.

The image runs as user 101 on the slim nginx base with its packages upgraded, writes under /tmp
only, and holds nothing of the repository but the built files.

release-image.yml gets two jobs for it, check-console and publish-console, behind the gates of the
worker. docker-compose.yml gets a console service behind a profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ✅ Rien de bloquant · Mergeable en l'état : je n'ai trouvé aucun défaut certain dans le diff. Les points non vérifiés (csp.mjs, les tests de la console, les références à check-api.sh supprimé) restent à confirmer par l'équipe.

Niveau lite · anthropic/claude-haiku-5.5 · 0,012 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

@sakuga-review sakuga-review Bot added the review: 4/5 Score de confiance de Sakuga Review (5 : prête à merger) label Oct 13, 2026
@sakuga-agent

sakuga-agent Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@sakuga-review review heavy

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ⚠️ Rien de bloquant · Fusion possible : aucun défaut bloquant trouvé dans l'image, l'entrypoint ou la lecture de l'adresse côté bundle ; seules trois vérifications négatives de la CI ne peuvent jamais échouer.

Niveau heavy · anthropic/claude-opus-5.5 · 0,543 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

Comment thread .github/workflows/ci.yml
@Mheaus
Mheaus added this pull request to stack #297 October 13, 2026 17:46
@Mheaus
Mheaus merged commit 7a60d79 into main Oct 13, 2026
13 checks passed
@Mheaus
Mheaus deleted the mathieu/fru-222-host-la-console-pour-un-auto-hebergeur branch October 13, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: 4/5 Score de confiance de Sakuga Review (5 : prête à merger)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant