12 Commits
Author SHA1 Message Date
jschoubben 187f83259e Join G's version form: version 0.1
mesh/merge-gate waiting in the build queue since 08:56
mesh/delivery-group group feat/47-G-join11 checking: composed again for its members' new heads
mesh/delivery ready: it is merged on the Approve
No version is published for it (builder_versions answers none), so §583 gives 0.1.

Builds: design 47 G A-335, §583

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-13 02:29:52 +01:00
mesh-tools 48a55bef93 Merge pull request 'Feature 57 (config and provisioning): release photos' (#5) from release/57-config-and-provisioning into nox-mesh 2026-10-16 13:04:21 +00:00
mesh-tools aaf6fe4d53 Keep the photo application's env files' text in files/, as templates (feature 57), reviewed
mesh/merge-gate head aaf6fe4d53d7 merged into trunk 31c0c5e6bcad — pass: builds photos, sent nowhere; no bus step; 1 wait(s) for a person; every machine co…
mesh/repo-check head aaf6fe4d53d7 merged into trunk 31c0c5e6bcad — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): nox-mesh's version judged it; th…
mesh/delivery delivered
2026-10-16 09:41:32 +00:00
mesh-admin 31c0c5e6bc Merge pull request 'A merge check of its own (hq ADR 0238)' (#4) from feat/a-merge-check-of-its-own into nox-mesh
mesh/delivery delivered
2026-10-06 21:00:42 +00:00
jochen 33ab118bcc Check the photo application before a change merges into what the mesh builds (hq ADR 0237)
The repository's layer of the mesh's merge check (mesh/repo-check): the three images'
own builds — server, client and admin client installed from their lock files and built —
before the merge instead of after it.
2026-10-06 22:12:13 +02:00
mesh-admin 8a49517528 Merge pull request 'The photos kept with the object and document stores are irreplaceable (hq ADR 0233)' (#3) from feat/a-module-declares-the-data-it-holds into nox-mesh 2026-10-06 15:05:52 +00:00
jochen a4e581e5eb Say the photos kept with the object and document stores are irreplaceable (hq ADR 0233)
Only the application knows how precious what it stores with its providers is; saying it holds
those providers to that class.
2026-10-06 16:47:49 +02:00
jschoubben 38ae0c8c89 Merge pull request 'The photo app's definition under today's rules, so the mesh builds it from here again' (#3) from fix/the-photo-app-under-todays-rules into nox-mesh
mesh/delivery delivered
2026-10-04 14:52:56 +00:00
jschoubben 063662b926 The photo app's definition under today's rules, so the mesh builds it from here again
Since 2026-10-04 the mesh held photos from the catalogue's stub — a server and an admin client —
which dropped the eef and filip sites and the api, flip and filip-api routes. This definition could
not simply be rebuilt: it named photos-api.novox.be and its own host paths, and transcribed the
bucket. Endpoints are named and every route names one; the client is told the api's composed name;
paths are the mesh's ${dir:state}; the bucket is the one the store derives (hq ADR 0202), the same
mesh-novox-photos the data lives in.
2026-10-04 16:30:24 +02:00
jschoubben 3f0d622db0 The mongo credential authenticates against its own database, not admin
The provisioner creates the granted user inside the granted database —
mongo's own db-scoped convention — so authSource is the database's name.
Found by testing the credential before the cutover window, not during it.
2026-09-26 01:06:31 +02:00
jschoubben 961226dc7f Serve the flip and filip-api aliases; the store is the granted bucket
The hand-authored proxy serves flip.novox.be beside filip.novox.be and
filip-api.novox.be beside photos-api.novox.be; the module must contribute
all six names before those files can retire.

MINIO_BUCKET names what the s3-bucket grant actually scopes the key to:
the provisioner derives mesh-novox-photos from the grant account and
ignores the contributed bucket name — the objects move there in the
cutover window, the pattern nextcloud set.
2026-09-26 01:02:55 +02:00
jschoubben 08d5c118d3 Become a nox mesh module
The mesh builds all three images from this repository and a commit
(novox/hq ADR 0069) — the Drone pipeline that built outside Docker and
copied its output in retires with the predecessor. One module carries the
API and every client instance: eef and filip are the same built client
under two names, which is a second route contribution, not a second
module (ADR 0015 — an application is one repository).

The clients drop nginx and its bash startup script: the node the API
already needs serves the static build too (client-server/serve.mjs —
files, the SPA fallback, env-config.js from the environment), so the
whole module stands on one declared base.

What the machine serves does not move: the same four public names, the
same four machine ports. MongoDB, the bucket and every credential arrive
as mesh grants instead of hand-set environment; SUPER_ADMIN_KEY stops
being a default written in code and becomes a minted secret.
2026-09-25 20:50:19 +02:00
8 changed files with 408 additions and 56 deletions
+19 -24
View File
@@ -1,26 +1,21 @@
FROM nginx:1.15.2-alpine
# The photos admin client, built by the mesh from this repository (novox/hq ADR 0069). The same
# shape as client/Dockerfile — see the notes there; only the source directory differs.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY ./conf /etc/nginx
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY admin-client/package.json admin-client/package-lock.json ./
RUN npm ci
COPY admin-client/public ./public
COPY admin-client/src ./src
COPY admin-client/.env ./.env
RUN npm run build
# Static build
COPY ./build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY scripts/env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
FROM ${NODE_BASE}
ENV NODE_ENV=production
COPY client-server/serve.mjs /serve.mjs
COPY --from=build /build/build /site/
COPY admin-client/.env /site/.env
CMD ["node", "/serve.mjs"]
+80
View File
@@ -0,0 +1,80 @@
// The client sites' server: static files, an SPA fallback, and env-config.js from the
// environment. Node built-ins only — the runtime base already carries node for the API, so a
// second web server (and the shell its startup script needed) would be two more moving parts to
// serve files the first one serves fine.
//
// What the nginx arrangement this replaces did, it does: every unknown path without an extension
// falls back to index.html (client-side routing), nothing is cached (the site is tiny and a stale
// index.html after a deploy is the only caching bug this app has ever had), and env-config.js is
// regenerated from the container's environment at start — which is how one built image serves
// under any API URL.
import { createServer } from "node:http";
import { readFileSync, writeFileSync, createReadStream, statSync } from "node:fs";
import { join, resolve, extname } from "node:path";
const root = resolve(process.env.HTML_ROOT ?? "/site");
const port = Number(process.env.PORT ?? 80);
// .env names the variables the site reads; the environment overrides their values. Written once
// at start, exactly as the env.sh this replaces did.
const pairs = [];
for (const line of readFileSync(join(root, ".env"), "utf8").split("\n")) {
const at = line.indexOf("=");
if (at < 1) continue;
const name = line.slice(0, at).trim();
pairs.push(` ${name}: ${JSON.stringify(process.env[name] ?? line.slice(at + 1).trim())},`);
}
writeFileSync(join(root, "env-config.js"), `window._env_ = {\n${pairs.join("\n")}\n}\n`);
const types = {
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
};
createServer((request, response) => {
const asked = decodeURIComponent(new URL(request.url, "http://x").pathname);
// resolve() collapses any ".." before the prefix check, so a path cannot escape the root.
let path = resolve(join(root, asked));
if (!path.startsWith(root)) {
response.writeHead(403).end();
return;
}
let served;
try {
served = statSync(path);
if (served.isDirectory()) {
path = join(path, "index.html");
served = statSync(path);
}
} catch {
// Not a file: a client-side route, answered by the app itself — unless it asked for a file
// by extension, where index.html would be a wrong answer dressed as a right one.
if (extname(asked) !== "") {
response.writeHead(404, { "Content-Type": "text/plain" }).end("not found\n");
return;
}
path = join(root, "index.html");
served = statSync(path);
}
response.writeHead(200, {
"Content-Type": types[extname(path)] ?? "application/octet-stream",
"Content-Length": served.size,
"Cache-Control": "no-cache",
});
createReadStream(path).pipe(response);
}).listen(port, () => console.log(`serving ${root} on :${port}`));
+25 -24
View File
@@ -1,26 +1,27 @@
FROM nginx:1.15.2-alpine
# The photos client, built by the mesh from this repository (novox/hq ADR 0069): the react build
# happens here rather than in a CI step that copied its output in.
#
# One base, named rather than pinned (novox/hq issue 044), declared in module.json's `build.on`:
# the site is compiled and served by the same node the API already needs. Serving is
# client-server/serve.mjs — static files, the SPA fallback, and env-config.js regenerated from the
# container's environment at start, which is how one built image serves under any API URL. The
# nginx-and-bash arrangement this replaces did the same with two more moving parts.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY ./conf /etc/nginx
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/public ./public
COPY client/src ./src
COPY client/.env ./.env
RUN npm run build
# Static build
COPY ./build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY scripts/env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
FROM ${NODE_BASE}
ENV NODE_ENV=production
COPY client-server/serve.mjs /serve.mjs
COPY --from=build /build/build /site/
COPY client/.env /site/.env
CMD ["node", "/serve.mjs"]
+1
View File
@@ -0,0 +1 @@
REACT_APP_API_URL=https://${bound:route:name-api}
+11
View File
@@ -0,0 +1,11 @@
NODE_ENV=production
PORT=9000
MONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}
MONGO_DB=${bound:mongodb-database:as}
MINIO_ENDPOINT=${bound:s3-bucket:at}
MINIO_PORT=${bound:s3-bucket:port}
MINIO_BUCKET=${bound:s3-bucket:bucket}
MINIO_ACCESSKEY=${bound:s3-bucket:as}
MINIO_SECRET=${secret:s3-bucket}
MINIO_USE_SSL=false
SUPER_ADMIN_KEY=${secret:admin-key}
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# mesh-check-toolchain: typescript
#
# The photo application's own check (novox/hq ADR 0237): the second layer of a pull request's merge check,
# `mesh/repo-check`, run by the build seat in the mesh's TypeScript toolchain — whose Node is the release
# this module's images are built on (module.json `build.on`, NODE_BASE). The gate — the manifest through
# the controller the mesh runs, every machine composed with it — is the build seat's first layer
# (`mesh/merge-gate`), run because the mesh's module graph builds photos from this branch.
#
# What the three images' builds do, done before the merge rather than after it: the server, the client and
# the admin client each installed from its lock file and built, and the client's file server parsed.
#
# **Said, never passed silently**: nothing is started here — the server wants its object and document
# stores — and the application has no tests of its own.
set -eu
for part in server client admin-client; do
echo "--- $part: installed from its lock file and built"
(cd "$part" && npm ci --no-audit --no-fund --loglevel=error && CI=false npm run build)
done
node --check client-server/serve.mjs
# The file text (novox/hq ADR 0349 §2): no file resource keeps its text inline in module.json; each names its
# template in files/, which the build packs into the one archive named files that the mesh reads the text from.
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("module.json", "utf8"));
const bad = [];
for (const r of m.resources || []) {
if (r.type !== "file") continue;
if ("content" in r) bad.push(`resource ${r.id} keeps its text inline: keep it in files/ and name it as the template`);
else if (typeof r.template !== "string" || !r.template.startsWith("files/") || !fs.existsSync(r.template))
bad.push(`resource ${r.id} names no template in files/ that is there: ${r.template}`);
}
const packs = ((m.build || {}).artifacts || []).filter((a) => a.kind === "archive" && a.from === "files" && a.name === "files").length;
if (packs !== 1) bad.push(`the build packs files/ into ${packs} archive(s) named files, not one`);
if (bad.length) { for (const b of bad) console.error(b); process.exit(1); }
console.log("every file takes its text from a template in files/");
'
echo "NOT RUN HERE: the server (it wants its object and document stores); the application has no tests"
+207
View File
@@ -0,0 +1,207 @@
{
"module": "photos",
"version": "0.1",
"capabilities": [
"container-runtime"
],
"requires": [
"s3-bucket",
"mongodb-database",
"route"
],
"contributes": {
"mongodb-database": {
"name": "photos"
},
"route": {
"api": {
"label": "photos-api",
"endpoint": "api"
},
"admin": {
"label": "photos",
"endpoint": "admin"
},
"eef": {
"label": "eef",
"endpoint": "eef"
},
"filip": {
"label": "filip",
"endpoint": "filip"
},
"flip": {
"label": "flip",
"endpoint": "filip"
},
"filip-api": {
"label": "filip-api",
"endpoint": "api"
}
}
},
"binds": {
"s3-bucket": "${dir:state}/store.json",
"mongodb-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"s3-bucket": "${dir:state}/store.secret",
"mongodb-database": "${dir:state}/database.secret"
},
"own-secrets": {
"admin-key": "${dir:state}/admin-key.secret"
},
"listens": [
{
"name": "api",
"port": 9102,
"protocol": "tcp",
"from": "mesh",
"why": "the photos API over http, which every client site calls; reached by the proxy here under the api and filip-api routes. The machine side of the 9102:9000 mapping"
},
{
"name": "admin",
"port": 8102,
"protocol": "tcp",
"from": "mesh",
"why": "the admin client site over http; reached by the proxy here under the photos route"
},
{
"name": "filip",
"port": 8103,
"protocol": "tcp",
"from": "mesh",
"why": "the filip client site over http; reached by the proxy here under the filip and flip routes"
},
{
"name": "eef",
"port": 8104,
"protocol": "tcp",
"from": "mesh",
"why": "the eef client site over http; reached by the proxy here under the eef route"
}
],
"data": {
"kept-by": {
"s3-bucket": {
"class": "irreplaceable",
"why": "the photos themselves, uploaded by the people the sites serve: the operator's ranking (2026-10-06) — whatever their own backups, the mesh protects what it holds"
},
"mongodb-database": {
"class": "irreplaceable",
"why": "the albums, captions and order the photos are shown in"
}
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"template": "files/server.env"
},
{
"id": "client-env",
"type": "file",
"path": "${dir:state}/client.env",
"mode": "0644",
"template": "files/client.env"
},
{
"id": "net",
"type": "network",
"name": "photos"
},
{
"id": "server",
"type": "container",
"name": "photos-server",
"artifact": "server",
"network": "photos",
"env-file": [
"${dir:state}/server.env"
],
"ports": [
"9102:9000"
],
"secrets-in-environment": "the application's own code reads MONGO_URL, MINIO_SECRET and SUPER_ADMIN_KEY from the environment (server/src/config.js); converting is this repository's change, tracked but not blocking the mesh conversion"
},
{
"id": "admin",
"type": "container",
"name": "photos-admin",
"artifact": "admin-client",
"network": "photos",
"env-file": [
"${dir:state}/client.env"
],
"ports": [
"8102:80"
]
},
{
"id": "eef",
"type": "container",
"name": "photos-eef",
"artifact": "client",
"network": "photos",
"env-file": [
"${dir:state}/client.env"
],
"ports": [
"8104:80"
]
},
{
"id": "filip",
"type": "container",
"name": "photos-filip",
"artifact": "client",
"network": "photos",
"env-file": [
"${dir:state}/client.env"
],
"ports": [
"8103:80"
]
}
],
"build": {
"on": [
{
"arg": "NODE_BASE",
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
}
],
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "server/Dockerfile"
},
{
"name": "client",
"kind": "image",
"from": "client/Dockerfile"
},
{
"name": "admin-client",
"kind": "image",
"from": "admin-client/Dockerfile"
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+25 -8
View File
@@ -1,10 +1,27 @@
FROM node:21.2.0
# The photos API, built by the mesh from this repository (novox/hq ADR 0069): the build happens
# here rather than in a CI step that copied its output in — an image the mesh can rebuild from a
# commit is one whose contents that commit fully determines.
#
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's `build.on`,
# so the copy the mesh holds answers it. One base for both stages — the runtime stage installs
# production dependencies itself (sharp is a native module rollup cannot bundle), so it needs npm
# exactly as the build stage does.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Create app directory
WORKDIR /usr/src/photos-server.novox.be
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/rollup.config.mjs ./
COPY server/src ./src
RUN npm run build
# Copy build output
COPY ./dist ./
COPY ./node_modules ./node_modules
CMD ["node", "server.cjs", "--enable-source-maps"]
FROM ${NODE_BASE}
ENV NODE_ENV=production
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /build/dist ./dist
CMD ["node", "dist/server.cjs", "--enable-source-maps"]