The summary above already shows the total; the line stays only beside the
local stack's simulated payment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PIX and card choices appeared under the cart, on the same page as the
customer's details. "Ir para o pagamento" now sends the order and opens
/pagamento, step 3 of the progress bar: the server's order summary, then PIX
or card, each opening below. The cart keeps only the sending progress and its
errors; a changed cart is sent again instead of offering the old quote.
Portal links open the payment page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proxy in front of production caches .js and .css for hours. After the
last release the Site got the new index.html with the old site-flow.js,
which wrote to an element the new page no longer has; the error left
"Adicionar ao carrinho" disabled. The web build now addresses every local
script and stylesheet by a hash of its content, replacing the hand-kept
?v= markers, so a new release always loads its own files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pickup notice under the delivery options and the invoice and retention
note under the purchase summary are gone. PORTAINER.md records the R2 CORS
policy the browser's direct uploads need: without it the preflight is
refused and checkout fails with a NetworkError before payment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every quote waited for an operator before it could be paid, so an order
placed at night waited for the morning. A cart the Site priced is now
approved when the quote is created, through the same server pricing the
operator's approval uses (app/quote_review.py). Orders above
QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site
could not analyse still wait for review; the Kanban shows which quotes were
approved automatically and why the others wait.
The grade is still computed in the browser (roadmap 3.2, 3.9), so the
discount remains a customer-supplied value until the server computes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app/jadlog.py prices one package through Jadlog's Simulador de Frete as the
API manual v2.3 describes it; app.jadlog_probe prices test weights to six
regions on the client's account to confirm token, account and contract.
Tested against a fake transport. The roadmap records the Jadlog data and the
Mercado Pago account setup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The production compose hard-coded the fake payment adapter; it now takes
PAYMENT_ADAPTER and the MP_* settings from the stack's environment, so the
sandbox can run with test credentials. A signed notification about a payment
Mercado Pago does not have, such as the panel's "Simular notificação", is
acknowledged instead of answering 500 and being retried; any other lookup
failure still raises.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator guide is now built from docs/guias/operador/ by
docs/guias/imprimir.sh, with the corrections on Tiny and the WhatsApp
notices. The roadmap records the guide, the history fix found while
writing it, and the Week-2 report as sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A move back undoes an operator's mistake and its reason is internal. The
customer's history now omits back moves and shows a reason only for a
correction; the smoke test checks both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client already sends WhatsApp notices from Tiny's order situação
(Tiny webhook -> middleware -> n8n). With TINY_STATUS_UPDATES on, a paid
order is set to "Aprovada" once and a finished pickup order to "Pronto
para envio"; pickup orders carry the client's pickup forma de envio
(TINY_FORMA_ENVIO_RETIRADA). The ready event now carries the order and
the Tiny id from the sale's receipt. Off by default until go-live, when
n8n stops sending the DTFIMP designer message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connecting now asks for offline_access, retrying once without it if Tiny
refuses the scope. Renewal failures are stored: a refused refresh token
marks the connection lost and is not sent again (the Kanban previously
still said "conectado"), a transient failure shows as a warning until the
next renewal, and a session grant with under 12 hours left is flagged.
Tiny errors on the callback return to the Kanban instead of a 422.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grade check and the layout preview run on short timers. When the
item went to the cart inside that window, no product was open and both
threw in the customer's browser, which also failed the browser tests
intermittently. Each now returns when no product is open. The cart test
waits for the empty state, which is painted on the next animation frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Testar conexão" now also reads the four configured Tiny products and
requires each to be active. app/tiny_probe.py runs from the worker console
to list products, confirm the configured ids, and create one marked test
order through the worker's own delivery path, proving the duplicate guard
by search before a second delivery. Nothing is sent without --confirmar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator guide covers the Kanban flow from quote review to finished
order; the Site guide walks through the customer journey, prices and the
current state of each integration. The roadmap records the Kanban and Site
redesigns, the guides, and what is left for the last day of Week 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each cart item has a visible "Remover" button instead of a faint ×, and
carts with two or more items get "Esvaziar carrinho". Both show a
"Desfazer" notice for 8 seconds, so a wrong click costs nothing. The
browser test covers remove and undo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Artworks on the left; on the right a box that stays in view with the live
sheet, the grade, the price per metre, the metres charged, the total, the
resolution note and "Adicionar ao carrinho". The separate quality and
preview panels, the second sheet preview, the per-row mini sheets, the
summary box and the repeated findings list are gone: each piece of
information now appears once.
Each artwork row carries at most one hint (resolution first, otherwise a
width that saves film), the ready-sheet/loose-artwork choice is a toggle
beside the title, the upload area is one bar and the tips are collapsed.
The box only shows the item the page already priced, so it always matches
the cart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home, each product's Montagem and the cart now have their own
addresses (/artes-avulsas, /arquivo-por-metro, /uv-artes-avulsas,
/uv-arquivo-por-metro, /carrinho) and show only their own content, with
Back, Forward, reload and direct links working as in any store. They stay
one document so uploaded artworks survive moving between pages; nginx
serves index.html for these addresses.
"Adicionar ao carrinho" puts the item in the cart and opens it, and an
empty cart says so. Portal quote links open in the cart. Also fixes the
"57 cm" line break on the ready-sheet option, returns "Novo pedido" to
the home, and says PDF depends on the product.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New landing (hero with a sheet preview, four steps, product cards priced
from the checkout table, price table and benefits), a progress bar that
follows the order through Montagem, Dados e entrega and Pagamento, the
live sheet beside the artworks, and a running total bar on phones.
The cart's saved-in-browser note no longer takes a grid column, which had
pushed the order into a narrow strip and the summary below it. The
previous look is kept in tag ui-v1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves: an order can go back one stage (BACK in app/runtime.py) with an
internal reason, flagged in the history as movements.back. The customer is
not notified and approved finals stay; "production started" and "ready" are
now enqueued once per order, so undoing and redoing a move sends nothing
twice. Dragging only goes forward and highlights the allowed column. Move
errors are in Portuguese.
Lists: the send log, payments (open, resolved as history, all) and quotes
are paged on the server with a total, 20 rows by default (10/20/50/100),
first/previous/page/next/last. The send log filters by destination, status,
event and order. Older finished orders load on demand. The board no longer
carries the send log or payment rows, only the open-payment count.
Kanban: Pagamentos and Integrações are separate tabs; messages are brief,
bottom notifications that clear themselves; wording is shorter.
Full CI integration sequence passes locally, with new checks for undo, paging
and filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kanban: tabs for production, quote review and payments/integrations; compact
cards with products, metres, print-file status, delivery and time in stage;
an order panel with stage progress, one main action, a correction reason in
place, items with a preview drawn from the approved layout, final-file
approval and the history as a timeline. Quote review gets a list and a pane;
payment issues resolve in place; integrations show their real state, Tiny's
connection with a read-only "Testar conexão", and a readable send log. The
previous Kanban is kept in git tag ui-v1 and is no longer served.
Production wording: the customer portal no longer says it is a local test
environment outside the local stack; the checkout no longer tells customers
to use the Kanban or shows internal stage codes; sign-in, session, quota and
print-file messages are Portuguese and never say "local". A simulated freight
price is refused outside the local stack until a real freight provider
exists, so production only offers pickup.
The browser suite drives the new tabs and panel and still checks the whole
upload, quote, payment and production journey. Full CI sequence passes locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release job enforced the production source preflight, which blocks while
the payment and messaging adapters are fake. They still are, by design, so
since 2026-09-23 no release could succeed and production kept running older
images while main moved on.
Pushes to main that pass validation, the integration suite and the scans now
build, scan and publish the images. Nothing is deployed automatically:
production changes when the stack is pulled and redeployed in Portainer. A
manual run also calls the Portainer webhook when one is configured. The
preflight stays in the scan job, advisory unless ENFORCE_PRODUCTION_PREFLIGHT
is true, in which case a blocked preflight stops publishing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.
Card payment: Mercado Pago's Card Payment Brick on the Site when
MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's
secure fields. Each card attempt has its own idempotency key, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.
Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.
No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.
Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.
Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.
Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Week 2 work that did not need client inputs.
Print files (1.4): each paid item gets a PDF the width of the film and the
length of the approved layout, with every copy at its reviewed position,
rotation and mirror. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.
Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.
Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.
Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.
MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.
Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was no inbound payment path at all: a button called a fake synchronously
and wrote an order. A real provider does the opposite — it charges, then tells
us, repeatedly, out of order, and sometimes long afterwards.
POST /api/payments/webhook verifies the signature before the body is parsed, so
an unsigned or tampered delivery is refused and recorded without touching an
order. Verified deliveries are stored under the provider's own event id with a
unique constraint, and applied inside the same transaction that marks them
processed: a repeat is a no-op, a crash is retried rather than half-applied.
An approval whose amount disagrees with the reviewed quote does not become an
order. Underpayment would ship artwork nobody paid for, and overpayment means
something a person should look at.
Order creation moved to app/payments.py so the webhook and the local development
checkout share one implementation and cannot drift. That also closes 3.5: the
charge happens inside the transaction that persists the order, rather than
before it.
The adapter contract is create/verify/parse. FakePayment implements it with a
real HMAC scheme so the whole path is exercised now, by tests/payment_test.py:
unsigned, tampered, underpaid, duplicate, re-sent, unknown reference, and
non-approved statuses. Connecting Mercado Pago is one adapter; no service code
changes.
PAYMENT_WEBHOOK_SECRET is optional in production on purpose. Required would
break the next Portainer render, and a guessable default would be worse than
either: with no secret configured the adapter verifies nothing and therefore
accepts nothing, which is the right state until a provider is connected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirteen files at the repository root, seven of them documents. Only README.md
earns a place there; the rest are now in docs/ beside the meeting notes, the
client roadmap and the historical material.
The compose files stay. docker-compose.yml is the path the dtf-cloud Portainer
stack reads, so moving it would break deployment, and Docker resolves a compose
file's relative build contexts against its own directory, so moving the other
two would silently break every build. Both reasons are now written down where
someone would otherwise try it.
Correcting references turned up a live fault: the Portainer stack creation
instructions still named deploy/stack.yaml as the compose path. That file was
removed, so anyone recreating the stack from these instructions would have
failed. It names docker-compose.yml now, with the reason it stays at the root.
ROADMAP.md keeps the paths its closed findings were written with, and says so at
the top. Those entries record where a fault was when it was found; rewriting
them to match a later layout would make the record less true, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
local/ held six unrelated things under a name that stopped being true once it
became the production runtime: the service, the frontend, the tests, the ops
commands, the container definitions and the dependency lock, 65 files with
nothing to tell them apart.
app/ the service: api/ routers, core/ for identity, database, models,
prices and secret loading, and the worker, bootstrap and schema
tests/ the twelve suites, no longer inside the shipped package
ops/ backup, readiness, dependency audit, security summary
infra/ Dockerfiles, gateway templates, ClamAV and storage configuration,
the requirements and their hash lock
web/ the Site, Kanban and portal pages with their scripts
deploy/Dockerfile.api now copies app/ alone, so the tests stop shipping to
production; the local image still carries them, because the suites run inside
the stack's network.
Five kinds of reference had to follow, and each was found by something different
rather than by reading. Imports of the form "from . import db" survived a rewrite
that only matched "from .db import". Tests kept relative imports of modules that
had left the package. A mock.patch target names its module in a string, where no
import rewriting can see it. The browser test resolves a fixture by path. And the
release gate's markers pointed at local/runtime.py and local/worker.py, which is
the decay its new marker test exists to catch — it caught it.
Verified from docker compose down -v: the stack starts, all six integration
suites, both browser suites and the twenty-nine unit tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Site's page sat at the repository root while its scripts lived in
local/static, a split with no reason behind it. They are together in web/ now,
with the page as index.html, which is also what the image serves.
app.py held the adapters, the configuration, the shared query helpers and
nineteen routes; customer.py held fourteen more but could not import from it
without a cycle, so it was wired by passing nine callables into install_routes.
Configuration and shared helpers move to local/runtime.py, the rules for
attaching artwork to an order move to local/artwork.py where a customer
correction and an operator final-file set can share them, and the routes become
seven routers under local/api. app.py is 48 lines that create the application,
apply the middleware and include them. Routers import downwards only.
Three faults came out of the extraction and are worth recording, because each
passed a check that looked sufficient. ast reports a function's line at the def,
so every decorator on the line above fell outside the extracted range: twelve
routes and the security middleware were defined but never registered, and the
files still imported and parsed cleanly. Names the old closure renamed on the
way in, and a Jsonb import, were missing in three modules. A name-resolution
pass over every new module found those; the route count matching the original
exactly, 32, is what confirmed the first.
The release gate's marker for the fake payment adapter pointed at app.py and the
adapter moved to runtime.py, so the gate passed while the condition it guards was
unchanged. That is the same silent decay 2.5 set out to fix. A test now asserts
every marker still matches something in its file, so the next move fails loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit used git add -A and swept in four binaries that were
deliberately untracked: the week-1 client report as .docx and .pdf, a duplicate
of it under output/documents, and imagem-teste.jpg, an input dropped in to test
with. None of them are the repository's to version. They are untracked here,
left on disk, and covered by .gitignore so the mistake cannot repeat.
Three files had no sensible home. The meeting notes sat at the repository root
under a 78-character name with spaces and accents, the roadmap generator lived
in tmp/ — a directory otherwise ignored as scratch — and its output in output/,
which is otherwise generated evidence. They are now docs/reuniao-2026-09-09-
anotacoes.pdf, tools/generate_dtf_report.py and docs/roadmap-cliente.pdf, with
CONTEXT.md and ROADMAP.md updated to match and a docs/README.md saying what each
document is for.
.gitignore no longer needs four rules to keep one generator out of an ignored
directory; tmp/ is scratch again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dtf-site.html held commercial rules, the nesting engine, PDF analysis, the cart
and every handler in a single inline script, 42% of the runtime code in one
file, and the money logic lived in the middle of it.
It is now nine files under local/static, cut at the section markers the original
author left, so no function was split across a boundary: config, product modes,
upload, sheet analysis, PDF, quality, packing, cart, flow. They load as classic
scripts in the original order and share one global scope, so evaluation is
exactly what it was; the extraction was checked byte-identical against the
original before the tags replaced it. dtf-site.html is 1,394 lines of markup and
style.
With no inline script left anywhere, the policy no longer needs a hash
allowlist: script-src is now 'self' alone, which is stronger than what it
replaced and cannot drift as the page changes.
Three things depended on the old shape and were updated rather than worked
around. The pricing parity test read the ladder out of the HTML and now reads it
from site-config.js, still proving the server agrees with what the customer is
shown. The isolated artwork test served four hardcoded script paths and now
serves any script that resolves inside local/static, so the next file added does
not silently 404. The CSP assertion checked the whole policy for 'unsafe-inline'
and now checks the script-src directive alone, since style-src legitimately
carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
portal/, kanban/ and agente/ were 2,034 lines implementing the original
Tiny-first model: token upload links, a second SQLite Kanban, a factory agent.
Nothing imported or started any of it, and several endpoints took the acting
user from the request body with no authentication at all. Their real cost was
that a reader arriving at this repository found two Kanbans and two portals and
had to work out which one was real. The root schema.sql and .env.exemplo went
with them: both code paths load local/schema.sql, and having .env.exemplo beside
.env.example differing by one letter was a trap rather than a convenience.
The documents describing that model are archived rather than deleted. They
record decisions and reasoning the current documents do not repeat, so they are
worth keeping as background, with a header saying plainly that they are not
instructions.
README.md keeps its business case — the capacity figures and the cost argument
are still the reason this project exists — but now states where the prototype
documentation begins and that the code it describes is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 4.1 indexes were added beside the existing uploads_owner, which sits partway
through schema.sql, so CREATE INDEX ... ON dtf_local.order_files ran before that
table was created and bootstrap aborted with UndefinedTable. db-init then
restarted on failure without ever completing, and everything waiting on it timed
out.
Every local run passed because those volumes already had the tables. Only a
clean database exposes it, which is what CI has and my checks did not.
All eleven indexes now sit at the end of the file, after every table, with an
assertion in the change that each indexed table is created before its index.
Verified from docker compose down -v: the stack starts, bootstrap completes,
eleven indexes exist, and the full suite passes.
Recorded as ROADMAP 5.11: nothing exercises the schema against an empty
database, which is the only way this class of fault appears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five items that needed no decisions.
Indexes: the schema indexed only uploads(owner), so the worker's once-a-second
outbox poll scanned a table that only grows, and every per-customer and
per-order lookup did the same. Ten indexes now follow queries the application
actually issues, and no more, since each one is paid for on every write. The
outbox and live uploads use partial indexes so they stay the size of the backlog
rather than of all history. Confirmed against the database that the planner
chooses them.
Board: /api/operator/board returned every order ever created. Finished orders
are terminal, so they were pure growth. It now returns everything still in
progress however old, plus a window of recent finished ones and the true
finished total, and the Kanban column says "50 de 213" rather than letting the
count read as an all-time figure. An operator cannot lose a card they could act
on.
Dependencies: the root requirements.txt was the prototype's, pinned by wildcard,
listing packages this system does not use, next to the hash-locked lock file.
Deleted. pip was pinned as a runtime dependency, which installed a package
manager into the read-only production image; nothing depended on it, so it is
gone from both the direct list and the lock, and the base image's pip performs
the hash-enforced install.
Retention copy: the Site told customers their artwork was kept 90 days with 12
months of history, and invited them to reorder without uploading again. Files
are kept 30 days. The copy now matches the policy and drops the promise the
system cannot keep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One OPERATOR_EMAIL and OPERATOR_PASSWORD served the whole factory, so every card
movement recorded the same name and the movement history could not answer who
did what. Traceability was one of the things the project set out to provide.
Accounts live in dtf_local.operators, authenticated with the same scrypt hashing
as customer accounts and with comparable work whether or not the account exists,
so absence is not observable by timing. Administration is a CLI in the API
container, like the schema migration: list, add, password, disable, enable.
Passwords are read from the terminal rather than an argument so they stay out of
shell history and the process list, and disabling deletes that operator's open
sessions instead of leaving them valid for the rest of the eight-hour window.
Migration is the part that could hurt: an empty table means 503 and a factory
locked out of its Kanban. OPERATOR_EMAIL and OPERATOR_PASSWORD seed the first
account, and only when that email is absent, so a password changed through the
CLI survives a redeploy carrying a stale environment variable. The first attempt
at this silently did nothing, because db-init receives its own small environment
and had neither variable; both compose files now pass them to it.
Verified against a running stack: bootstrap seeds the existing credential, that
credential still logs in unchanged, a second operator authenticates separately,
wrong passwords and unknown accounts are rejected alike, and disabling revokes
an open session immediately.
Roles are left out on purpose. The separation of duties the meeting described
governs rework authorisation, which this system does not implement, so a role
model would have no consumer to serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Site pulled pdf.js 3.11.174 from cdnjs with no integrity attribute, and the
policy trusted the whole of cdnjs.cloudflare.com for both script-src and
worker-src. Anything that host served would have executed, and a customer
measuring a PDF sheet depended on it being reachable.
Vendor both files instead of pinning a hash: it removes the dependency rather
than constraining it, and lets the policy name only 'self'. Provenance and
SHA-256 digests are recorded in local/static/vendor/README.md, verified on
download against the SRI digests cdnjs publishes for that release.
cdnjs is now absent from script-src, worker-src and connect-src in both gateway
templates. Workers are 'self' plus blob:, which the Site needs for the worker it
constructs itself.
Verified in a browser against the running stack: pdf.js loads from /vendor/, the
blob worker starts, and a real seven-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.
The version is deliberately unchanged. 3.11.174 is old, but its known eval path
is already closed by isEvalSupported:false, and upgrading is an API change that
needs its own testing rather than riding along with this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/stack.yaml arrived in the first commit and was never deployed. Portainer
runs the repository's docker-compose.yml. Keeping both meant two definitions
drifting apart, with the documentation naming the one nobody used, which is how
the credential question came up at all.
The hardening it offered is narrower than it looks: Docker secrets keep values
out of docker inspect and the Portainer console, but local/secrets.py loads them
into the process environment regardless, and anyone able to read docker inspect
can already read the secret files. With a single Portainer user, the benefit that
remains does not outweigh maintaining a divergent copy.
local/secrets.py stays: inert against the deployed file, and it lets a stack
switch to Docker secrets later without touching code. The preflight and its tests
degrade cleanly when no such stack is present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PORTAINER.md called deploy/stack.yaml the production stack. The deployed file is
docker-compose.yml, which supplies eight credentials as plain environment
variables where stack.yaml uses Docker secrets. That puts the database password,
operator password and the R2 secret key in the container environment, readable
through docker inspect and the Portainer stack editor.
Recorded as ROADMAP 2.12 with the three options rather than changed here:
altering how production receives credentials is not a quiet change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suites connected to localhost:<published port>, which works for a developer
but not on a containerised runner: published ports live in the host's network
namespace, so the runner container gets connection refused.
Run them from inside the stack instead, against the gateway by service name.
SITE_BASE_URL and SITE_HOST_HEADER make that possible without weakening what is
under test: the Host stays "localhost", so the gateway's host check and
TrustedHostMiddleware see exactly what a localhost run produces, and the tests
that deliberately send their own Host still override it.
S3_PUBLIC_ENDPOINT has to agree, because presigned URLs are signed against it
and the signature covers the host, so it cannot be rewritten afterwards. CI
points the whole stack at http://storage:9000 so the URLs it hands out are
reachable by whoever follows them.
The browser suites still need Chrome to reach the stack from the runner, which
the same namespace split prevents. They now check reachability and skip with a
warning instead of failing with a bare connection error; recorded as ROADMAP
5.10, since they are the only coverage for the artwork editor.
Verified both ways: the six suites pass inside the network, and an unchanged
developer localhost run still passes, as do both browser suites locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The production gateway does not face the internet: nginx-proxy-manager owns
80/443 on the host and proxies to it. So $remote_addr inside the gateway is that
proxy, and overwriting X-Forwarded-For with it discarded the customer address
the proxy had already recorded. Every request would have been attributed to one
internal address, which is exactly the fault 2.1 set out to fix, reintroduced in
production only.
Use real_ip to take the customer address from the proxy's header, trusting only
private networks. A request that reaches the published port directly from the
internet is not trusted, so its header is ignored and $remote_addr stays the
real peer: the anti-spoofing property is kept.
Also downgrade 2.9. TLS is not missing, it is terminated by that proxy. The gap
is that the repository never says so, which would break every session cookie if
the stack moved to a host without one.
Validated with nginx -t against the rendered production configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8000 was Portainer's Edge tunnel, not a stray process. The first attempt at a
fix picked 18080/18081, which are the production dtf-cloud stack's own defaults
in docker-compose.yml: it would have passed only while that stack was down and
collided again the moment it came back.
Use 28080/28081/28000/29000/29001, clear of Portainer (8000, 9443), both
production stack definitions (18080/18081 and 8080/8081) and the usual MinIO
ports. The occupants are listed in the workflow so the next person choosing a
port can see what is taken.
Ephemeral ports would remove the guesswork but do not work here: the published
port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP when the
containers start, so it has to be known before they run.
Full suite verified on the new block, including the browser end-to-end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration job failed with "Bind for 0.0.0.0:8000 failed: port is already
allocated". The runner shares the host's Docker daemon, so every published port
is claimed on the machine itself, where other services already listen. Port 8000
was the first collision; 8080, 8081, 9000 and 9001 were equally exposed.
MinIO's ports were hardcoded, and S3_PUBLIC_ENDPOINT was pinned to
localhost:9000 independently, so moving storage would have broken the presigned
URLs the browser fetches. Both now derive from STORAGE_PORT and move together.
CI runs on 18080/18081/18000/19000/19001. Local defaults are unchanged.
Verified by running the whole stack and the full suite on exactly those ports,
including the browser end-to-end, which downloads through a presigned URL and so
proves the storage endpoint followed the port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration job failed starting the scanner:
error mounting ".../local/clamd.conf" to rootfs at "/etc/clamav/clamd.conf":
not a directory
The files are in the repository, so this was not a missing checkout. A
containerised CI runner shares the host's Docker daemon, so "./local/clamd.conf"
resolves to a workspace path that exists inside the runner but not on the host
where the daemon creates the mount. The daemon makes an empty directory there
and the container cannot start. Only the bind-mounting services were affected,
which is why PostgreSQL and MinIO came up first.
Build the scanner and storage-init images with their configuration copied in, so
compose.local.yaml no longer bind-mounts anything from the host and works
regardless of how the runner reaches the daemon. Both bases stay overridable
through CLAMAV_IMAGE and MINIO_IMAGE.
The production stack is unaffected: it ships clamd.conf as a Swarm config, which
the manager reads at deploy time.
Verified from a clean slate: the stack starts, the scanner runs the baked
configuration, storage provisioning runs from the baked script, and the full
suite passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration job failed on the runner with "pull access denied for
minio/minio ... may require 'docker login'". Docker Hub now refuses anonymous
pulls of minio/minio: an unauthenticated manifest request returns 401
UNAUTHORIZED, while library/postgres returns 200, which is why only MinIO
failed. It worked locally only because this machine is logged in to Docker Hub.
quay.io serves the same release anonymously, and it is the same image: both
registries resolve to image ID sha256:a1ea29fa2835. MINIO_IMAGE overrides it for
anyone mirroring into their own registry.
Verified by deleting the Docker Hub copy locally and starting the stack from
quay alone, then running the full suite against it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The production images built on mutable tags with --pull, so the same commit
could produce different bases, and neither Dockerfile upgraded its OS packages
even though the local ones did. The published API image carried 56 HIGH and 3
CRITICAL findings, 15 of them with an upstream fix available.
Pin both bases by digest and upgrade OS packages in the production images. That
removes all 3 CRITICAL and 13 of the 15 fixable findings. The remaining two,
msgpack and setuptools, come from a third-party SBOM; neither package is
importable or listed by pip in the built image, which I confirmed rather than
taking the previous report's word for it.
The web image could not be fixed this way: the official 1.28 line pins
nginx=1.28.3-r1 in /etc/apk/world, so apk upgrade leaves five HIGH findings in
place even though Alpine ships 1.28.3-r7. Moving to nginx:alpine (1.31.6)
clears them completely; 1.29-alpine scans worse, at 37 HIGH. Same uid 101 and
the same template entrypoint, and the local images now use the same pinned
bases so the integration suite exercises what ships. Full suite passes on
nginx 1.31.6, including the browser end-to-end.
With both images at zero CRITICAL, the image scan now blocks on CRITICAL and
reports HIGH, instead of reporting everything. PYTHON_BASE_IMAGE and
NGINX_BASE_IMAGE are wired through to the builds so a base can move forward
without editing the repository, which is what PORTAINER.md already promised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>