# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 # One image, three processes (spec §13). Node 24 LTS, nothing but the app in the final stage, and # a non-root user that owns only the generated-code directories it writes. # # The `@conusai-kit` and `@conusai-sdk` scopes are served by the ConusAI vendor registry. Its URL # and token arrive as a BuildKit secret the install stage copies to the user-level ~/.npmrc, the # only config pnpm trusts for credentials, and never reach a layer # (docs/how-to/private-package-registries.md). FROM node:24-slim AS deps ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ PNPM_HOME=/pnpm \ CI=1 WORKDIR /app # The manifests alone, so a source change does not re-resolve the dependency graph. COPY package.json pnpm-workspace.yaml .npmrc pnpm-lock.yaml ./ COPY config/package.json ./config/ COPY modules/package.json ./modules/ COPY apps/web/package.json ./apps/web/ COPY apps/worker/package.json ./apps/worker/ RUN corepack enable pnpm # A customer repository commits pnpm-lock.yaml (decision AT-D6), and the install is frozen # against it: an image is built from exactly the dependency graph the gate ran. # # The credential arrives under one secret id per build path: plain `npmrc` for a preset # single-image build, `conusai_npmrc` for a compose build (the platform prefixes its compose # secrets so a tenant's own can never collide). Mount both and take whichever was delivered — # a build that asked for only one id got an empty mount on the other path, and pnpm fell back # to the public registry where the vendor scopes 404 (stage, deployment 4, 2026-10-20). # # The trap removes the copied credential before the shell exits: a file left behind would be # committed to the cached deps layer, token and all. RUN --mount=type=secret,id=conusai_npmrc,target=/run/secrets/conusai_npmrc,required=false \ --mount=type=secret,id=npmrc,target=/run/secrets/npmrc,required=false \ trap 'rm -f /root/.npmrc' EXIT; \ if [ -s /run/secrets/conusai_npmrc ]; then \ cp /run/secrets/conusai_npmrc /root/.npmrc; \ elif [ -s /run/secrets/npmrc ]; then \ cp /run/secrets/npmrc /root/.npmrc; \ fi; \ pnpm install --frozen-lockfile --ignore-scripts FROM deps AS build WORKDIR /app COPY . . # An explicit first-party deployment variant; the shipped scaffold remains unchanged. # Selection requests scopes. Actual platform services and grants require owner approval. ARG CONUSAI_APP_MODULE_PROFILE=default RUN case "$CONUSAI_APP_MODULE_PROFILE" in \ default) ;; \ full) for module in activities archive catalog crm inventory invoicing ledger documents knowledge agent guides support; do \ pnpm kit module:on "$module" || exit; \ done ;; \ *) echo 'unsupported application module profile' >&2; exit 64 ;; \ esac # config, modules and apps/worker emit JavaScript; apps/web builds the Start shell (`vite build` to # apps/web/dist). pnpm runs them in dependency order, so the libraries are built before what imports them. RUN pnpm -r run build \ && if [ "$CONUSAI_APP_MODULE_PROFILE" = full ]; then pnpm openapi; fi # The anchor catalogue (`data-guide` and `guideAnchor` literals), then the code-guides bundle # kit:guides # conusai.json declares as guides.bundle: public content the platform reads out of this image # kit:guides # and publishes after a healthy deploy (ADR-045); no publication credential is ever in it. # kit:guides RUN pnpm kit guides:anchors --out guides-anchors.json # kit:guides RUN node apps/worker/dist/guides-bundle.js /app/.conusai/guides-bundle.json guides-anchors.json # kit:guides # The image keeps what it runs and nothing else: the formatter, the type-checker and the test # runner are build-time tools. The workspace links the three processes resolve stay, because they # are dependencies and not devDependencies. RUN pnpm prune --prod --ignore-scripts FROM node:24-slim AS runtime ENV NODE_ENV=production \ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 WORKDIR /app # node:24-slim ships a `node` user (uid 1000). Application code stays root-owned, so a # compromised process cannot rewrite it; only the codegen output directories are writable. # # The image keeps node_modules: apps/web/dist/server is not standalone. Vite leaves the kit and every # other package external, and server.mjs imports them at run time from the install above. COPY --from=build --chown=root:root /app /app # Git checkouts created under the service's private umask can carry 0700 directories # and 0600 files into the build. COPY preserves those modes: root can build them, # but node cannot even resolve the server's packages through an unsearchable parent (KI-210). # Keep code root-owned and deny group/other writes while allowing runtime reads # and directory traversal. X preserves executable files without making data executable. RUN chmod -R a+rX,go-w /app \ && mkdir -p /app/convex/_generated /app/better-auth/functions/_generated /app/better-auth/functions/betterAuth/_generated \ && chown -R node:node /app/convex/_generated /app/better-auth/functions/_generated /app/better-auth/functions/betterAuth/_generated USER node # Every process is started by compose.yaml with its own command; there is no default, because a # container that silently starts the wrong one is worse than one that does not start. CMD ["node", "-e", "console.error('start this image with one of the commands in compose.yaml: web, worker or migrate'); process.exit(64)"]