agentleFS
DevN-grDevN-gr/multi-tenant-fullstack-crud-skill/SKILL.md

crud-stack

Skill2 starsChanged 2 months agoMITView on GitHub

Build or extend a multi-tenant, role-scoped, audited web app on a generic-CRUD architecture — Express + Sequelize behind one crudThat factory with per-model controller hooks, a no-build-step vanilla JS frontend, a shared rules module both sides run, and a three-container Docker/Traefik/MySQL deployment. Use when starting a new project of this shape, or when adding a model, controller, route, view, test or environment variable to one. Covers the CRUD factory and its hooks, the access model, project layout, the three test suites, and the deployment. Also covers going live: at a first deployment, a question about how to deploy or make the app publicly accessible, or an MVP about to be used, it offers — as questions, never unasked — push-to-main automatic deployment (GitHub Actions running the suites, then SSH to a VPS that rebuilds with Docker Compose and rolls back on a failed healthcheck), a public landing page presenting what the product actually does, and a no-sign-up demo: one button provisions a seeded throwaway tenant, signed in, deleted whole after a day.

  • Reads credentials
  • Deletes or force-pushes
  • In this repositoryOne other file it gives its agents.

The file

Show its rules and text

We split this skill into the rules it gives an agent

29 rules. About 0 are common, 13 are distinctive, and 16 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈13

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 16

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Start a new controller from controllers/template.controller.js (documents every hook) or from controllers/base.js (already applies the …

    line 78
  2. Very specific:

    Search: ?search=term, across searchableFields for that role

    line 118
  3. Distinctive:

    If you add a hook that compares a body value to something already loaded, assume the body has been coerced and do not convert again.

    line 133
  4. Very specific:

    Write access rules as an explicit if/else over req.user.user_type, local to the controller.

    line 149
  5. Very specific:

    Use a status only where the client must act differently: 422 with a code for a rule the user can fix, 403 for a capability they will never …

    line 155
  6. Very specific:

    No migrations. models/index.js applies the schema on boot with sync() — force: true in development and test, alter elsewhere — inside …

    line 183
  7. Very specific:

    Write the controller, register it in controllers/index.js, add a row to RESOURCES in app.js.

    line 216
  8. Distinctive:

    Add a test that fails before the change and passes after.

    line 218
  9. Very specific:

    Escape everything interpolated into HTML with U.esc(): No exceptions.

    line 267
  10. Very specific:

    Add the <script> tag to app.html, before js/app.js.

    line 281
  11. Very specific:

    Add the file to the FILES list in tests/run-tests.js — it asserts every route resolves to a real view and that no role is offered a route …

    line 284
  12. Distinctive:

    Never let a client-side check be the only one.

    line 297
  13. Distinctive:

    Test names are documentation: When an assertion changes because the rule changed, the name says which rule and why.

    line 360
  14. Distinctive:

    Every change ships with coverage for what it changed: new behaviour gets a test that fails before and passes after.

    line 365
  15. Very specific:

    Which services are the app and which are the platform: Only the first group survives.

    line 474
  16. Very specific:

    Not the ones that look risky — all of them: acme_web, acme_db, acme_internal, acme_db_data, acme_security.

    line 499
  17. Distinctive:

    Default json-file logging is unbounded, and on a shared box a chatty container does not fill *your* disk, it fills *the* disk — and the …

    line 578
  18. Distinctive:

    Only they can do these:

    line 623
  19. Very specific:

    Add the include: block, then run docker compose config before up, which costs a second and touches nothing running.

    line 629
  20. Distinctive:

    Ask in one message rather than three, with what each one costs, and take "just the deploy" for an answer.

    line 676
  21. Distinctive:

    Read the repository for what it can answer.

    line 729
  22. Very specific:

    Not the running of the tests.

    line 757
  23. Very specific:

    If minutes genuinely matter, the honest cheaper option is deploy-only CI plus a local pre-push hook.

    line 767
  24. Very specific:

    Keep the repository copy as the reviewable source and scp it up.

    line 802
  25. Distinctive:

    After the landing page, because it is the button on it.

    line 1005
  26. Distinctive:

    "What should it show?": If the product's claim is that what you see depends on who you are, one role demonstrates nothing.

    line 1014
  27. Very specific:

    force: true, or it is not deleted: Every model here is paranoid, so an ordinary destroy writes a deletedAt and keeps the row.

    line 1029
  28. Distinctive:

    Say the terms before the button, not after: No sign-up, what is in it, and that it is deleted tomorrow, so please do not put real customer …

    line 1077
  29. Distinctive:

    If the interface is not in English:

    line 1093

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

The CRUD stack

An architecture for line-of-business web apps: multi-tenant, role-scoped, audited, and deployable as three containers. It trades framework features for a surface small enough that one person can hold all of it — no build step, no migrations, no hand-written CRUD, no action routes.

samples/ holds a working skeleton of the whole thing. Read the file named in each section rather than reconstructing it from this page; the comments in those files are the reasoning, and most of them are bugs that were paid for.


The shape

frontend/     the browser: index.html (the public landing page, served at
              `/`), app.html (the application, at `/app.html`), css/, js/,
              js/views/
shared/       utils.js and rules.js — loaded by the browser AND require()d by
              the server. One implementation of the domain rules, not two.
backend/      the API: Express + Sequelize, MySQL in production, SQLite locally
tools/        static server + API proxy, smoke test, checks
tests/        the frontend unit suite
docs/         the user manual and its screenshots
ops/          the VPS half of push-to-main: the deploy script, its test
              harness, and the runbook for whoever has shell on the box

Three containers: web (static files + proxy), api, db. The web container proxies /v1/api to the API, so the browser only ever talks to one origin — which is why the session cookie simply works, with no CORS preflight on every request and no third-party-cookie policy to fall foul of.


The backend

Everything is crudThat

backend/routes/crud.js is a generic CRUD factory. Almost every resource is served by it, and behaviour comes from the controller it is mounted with:

guarded.use('/customers', crudThat(db.Customer, controllers.customer));

The verbs POST /, GET /, GET /:id, PUT /:id, DELETE /:id are the whole surface.

Do not hand-write CRUD endpoints and do not add action routes. POST /tasks/:id/cancel is a second authorization surface, and it is the one that gets forgotten. What an operation means lives in a hook: booking, moving, reassigning, completing and cancelling are all one POST and one PUT, told apart by what the body carries.

The only hand-written routes are transitions rather than resources — signing in, provisioning a tenant, moving data in or out. What each of those produces is a resource and is read back through crudThat like everything else.

app.js holds a RESOURCES table: one row per resource, declared outside the factory so the tests can walk model ↔ controller pairs without booting a server. Read that table and you have read the API.

Controller hooks

Start a new controller from controllers/template.controller.js (documents every hook) or from controllers/base.js (already applies the tenant pin and the audit trail). The worked example is controllers/customer.controller.js.

HookRunsUse it for
extraFilters(req, res)read, update, delete and createThe authorization boundary. Extra where conditions per role; scope.DENY refuses the model outright.
createDefaultAssociations(req, res)before createSet ownership, fill defaults, strip client-supplied fields, refuse with a reason.
beforeUpdate(req, res, body, filter)before the writeRe-check rules against the proposed row, and read the "from" values the audit trail needs. Return false to refuse.
afterUpdate / afterDeleteafter the write, awaitedThe rest of the operation, not a notification.
onCreated(req, res, row)after createDependent rows, the audit entry.
beforeSend(req, res, result)before the responseFigures a list shows but did not load.
hiddenFields(req, res)every readColumns this role may not see — masked in the body, and unfilterable and unsortable as a consequence.
readOnlyFields(req, res)update onlyColumns nobody may set from outside. Does not apply to create.
allowedIncludes(req, res)when ?include= is sentThe association aliases this role may eager-load. Absent means none.
searchableFields(req, res)when ?search= is sentColumns the term matches, own or $Alias.column$.
deleteFilters(req, res)delete onlyNarrows further than extraFilters.
onCreateError … onDeleteErroron failureA custom status and body. Without one the framework maps the fault itself.

Four things about hooks that are load-bearing:

  1. readOnlyFields does not apply to create. The whole body reaches Model.create. Strip in createDefaultAssociations what must not be set.
  2. To refuse a create, set req.body.id = -1 and answer from onCreateError. That is the framework's documented deny signal.
  3. beforeSend receives Sequelize instances. Assigning a property to one is silently lost — toJSON() serialises dataValues and nothing else. Use setDataValue.
  4. extraFilters also bounds create, evaluated in memory against the proposed row. A condition naming the primary key can never be satisfied by a new row, which is exactly what makes scope.DENY and "your own record, only" refusals work.

Query parameters readMany understands

  • Filtering: ?column=value, ?by_<column>_<op>=value with eq|ne|gt|lt|lte|gte|like|in, ?in_<column>=1,2,3
  • Sorting: ?sort_by=col&sort_direction=ASC|DESC, both repeatable
  • Pagination: ?offset=&limit=; ?with_count=true → { data, meta }; ?count_only=true → the meta alone. Counts use distinct: true.
  • Search: ?search=term, across searchableFields for that role
  • Also: ?include=Alias[,Alias], ?show_deleted=true

A column hiddenFields masks cannot be filtered or sorted on either, not only searched — a filter answers "is there a row where this is true" one bit at a time, which is the same column read slowly. Both paths answer 400.

Types on the wire

crud.js coerces both the query string and the request body to the types the model declares. This is not tidiness: hooks reason about the body before the write, in JavaScript, and a browser sends every id as text. MemberId: "7" compared against a row holding 7 finds no clash, and the first invariant is walked straight past by sending a string.

If you add a hook that compares a body value to something already loaded, assume the body has been coerced and do not convert again.

The access model

Three files, and the division between them is the point:

  • services/capabilities.js — what each role may do. One table, sent to the browser from /auth/me so there is one definition rather than two that can disagree. Hiding a button is a courtesy.
  • services/scope.js — the boundaries every query is pinned to: the tenant, always, and a second scope inside it. DENY is { id: -1 }, a condition that matches nothing.
  • each controller's extraFilters — the enforcement, as a where the client cannot influence.

Write access rules as an explicit if/else over req.user.user_type, local to the controller. A rule hidden in a shared helper is a rule nobody reviews.

Refuse by returning nothing, not by returning 403. A role asking for a record it may not read must get exactly what it would get for a record that does not exist, or the endpoint becomes an oracle for whether one is there. Use a status only where the client must act differently: 422 with a code for a rule the user can fix, 403 for a capability they will never have.

A failure is never a 200. Without an onXError hook the framework maps the fault: 409 conflict, 422 invalid / invalid_reference, 500 server_error. Every code it can produce needs a message in the frontend's ERROR_TEXT, added in the same change as the code.

Every handler and middleware is wrapped. Express 4 turns a rejected async handler into an unhandled rejection, and Node exits the process on one. Use wrap or asyncRouter from utils/async-handler.js, or the error middleware is decorative.

The audit trail

services/audit.js composes into a controller through beforeUpdate, which runs before the write so the "from" values are still readable. One row per changed field. Use a label from the FIELD table rather than a free string — the filter dropdown, the seed and the tests all read that one vocabulary. Rows whose from equals to are dropped automatically, so pass the whole field list and let it filter.

The trail is a second copy of the data, and it is the copy people forget: a role refused a model must be refused its audit rows too. See controllers/audit_entry.controller.js.

Models and the schema

No migrations. models/index.js applies the schema on boot with sync() — force: true in development and test, alter elsewhere — inside db.init(), which bin/www awaits before it listens. A container is never up-but-not-ready.

sync({ alter }) does not relax NOT NULL. A column that starts allowing null goes on refusing it everywhere the table already existed. This works on every fresh database and fails on every deployed one, and no test can see it because the suites rebuild from nothing. relaxNullability() reconciles that one gap, in the loosening direction only.

SQLite's pool is one connection deep. Anything that queries from inside a transaction must be handed that transaction, or it waits for a connection the transaction is holding — for ever, with no error. Resolve lookups before the transaction opens where you can.

get({ plain: true }) returns dataValues by reference unless there are custom getters, eager-loaded includes, or clone: true. A "snapshot" taken without clone is a live view, and mutating the row it came from rewrites it.

Adding a model — checklist

  1. models/<name>.model.js, exporting (sequelize, DataTypes, db) => { … return X; }, with paranoid: true and OrganizationId if it is tenant-scoped.
  2. Declare indices for the queries it will actually serve. Every controller's defaultSortingColumn must be a contiguous run at either end of some index — at the front the index is the order, at the back the columns in front are the scope. A sort with nothing behind it is a filesort over the whole tenant: invisible on seeded data, expensive on real data. test/indices.test.js enforces this generically.
  3. Register it in models/index.js, add the associations, add it to TENANT_SCOPED if it carries an organization.
  4. Write the controller, register it in controllers/index.js, add a row to RESOURCES in app.js.
  5. Add a test that fails before the change and passes after.

The frontend

No frameworks and no build step. Classic scripts, each attaching one global, loaded in dependency order from app.html:

U → (js/dom.js extends U) → Rules → API → Store → C → Views.* → App

index.html — the landing page — loads none of them. It is a separate document that renders with no session and no request, and it stays that way because the suite evaluates js/landing.js in an empty context.

ES5 syntax and var in frontend/js/ and shared/ — they run unbuilt in whatever browser the viewer has, and shared/ also runs under Node. Modern syntax is fine in backend/, tests/ and tools/.

A view is load(params) then render(params, data). load fetches exactly the slice that screen shows and returns a promise; render is synchronous and returns an HTML string. App awaits the load, paints a skeleton meanwhile, and shows an error state if it fails — one message for data that never arrived, another for a render that threw. Neither leaves the skeleton up, because a screen that hangs forever reads as a slow one. DOM work lives in mount(root, data).

Store is the only thing that talks to the server. Views never call API directly. Reads come from the slice the view's own load fetched; writes return promises completed with App.after(promise, message, modal), which waits, reports failure, and re-renders. A write that re-reads what it changed uses Store.loadMore, never Store.load — a plain load builds a fresh slice and fills only what it asked for, discarding every other collection the screen was holding.

A screen may only count what its load fetched. A view that shows an aggregate over rows it did not load computes it over an empty slice and renders a confident zero — worse than a blank, because nothing looks wrong. Either load the rows or have the server send the figure with the row, in a grouped query in beforeSend.

A value the interface could not work out has to be said, not shown. «—», an empty state, never the raw absence. U.money(undefined) is NaN and '' + undefined is a word the reader cannot interpret; both are a missing key behind the screen, which is why the browser suites fail any page containing undefined, NaN or [object Object].

Escape everything interpolated into HTML with U.esc(). No exceptions.

Record ids are strings in the browser and integers on the server. Every id a browser holds arrives as text — data-id, <select> values, the URL hash — so Store converts each one once in its mappers and everything on that side compares strings. The server converts back at the API boundary.

Dates are "YYYY-MM-DD" strings, times are "HH:MM" strings, and time arithmetic happens in minutes via U.t2m / U.m2t. Never construct a Date for scheduling maths — a timezone must never reach a slot.

Adding a view

  1. Create frontend/js/views/<name>.js.
  2. Add the <script> tag to app.html, before js/app.js.
  3. Register the route in App.ROUTES and the menu entry in App.NAV for each role that gets it.
  4. Add the file to the FILES list in tests/run-tests.js — it asserts every route resolves to a real view and that no role is offered a route it lacks the capability for.

shared/

shared/ is DOM-free and must stay that way: it runs under Node in the backend suite, and that is what makes the rules testable on both sides. The browser-only half of U lives in frontend/js/dom.js, which extends the same global. A helper that touches document goes there.

The server is the authority. The browser runs Rules for instant feedback while somebody types; the server runs the same Rules and refuses regardless. Never let a client-side check be the only one.


The test suites

npm test                  # frontend units — tests/run-tests.js
cd backend && npm test    # the API        — backend/test/*.test.js
npm run smoke             # browser        — tools/smoke.js, against a live stack
npm run check             # environment-variable parity
npm run verify            # all of the above

Which suite:

  • A rule, a calculation, a helper → tests/run-tests.js if it lives in shared/ or frontend/js/, backend/test/ if it lives on the server.
  • Anything about who may see or do what → backend/test/access.test.js, over real HTTP, per role. The frontend cannot prove an access rule; it can only prove that it does not offer the button.
  • Framework behaviour → backend/test/crud.api.test.js, against an isolated model with no app boot, auth or seeds.
  • Anything that depends on a setting → its own file, with its own NODE_CONFIG set at the top before anything reads the configuration. node:test gives each file a process, which is why demo.test.js can boot with the demo on and demo.disabled.test.js with it off, and neither has to mutate configuration at runtime — node-config freezes it anyway.
  • Anything that needs rendering → tools/smoke.js.
  • The deploy script → ops/test-deploy.sh, which needs neither Docker nor a VPS: a real git repository in a temporary directory and a stubbed docker on PATH.
  • The public surface — what / serves, what the landing page loads, what its button does — → section F of tests/run-tests.js for the parts that need no browser, and tools/smoke.js for the button itself. Both, because the page a stranger meets first is the page nobody on the team reloads.

A denial is asserted as an empty result, not a status code. A test that accepts a 403 lets through the regression that turns the endpoint into an oracle.

Forms and uploads are driven, not reasoned about. A form is not finished until a browser has filled it in and submitted it — not the endpoint behind it, and not the function that builds the request. The rule is paid for: an upload route declared PUT on the server and sent as POST by the browser answered 404 for every file, while both unit suites passed — one called the endpoint correctly, the other never issued the request at all. From inside the browser, a route that does not exist and a route that refuses look identical.

So: a new form, file input or multi-step dialog gets a step in smoke.js that fills it in, submits it, and asserts the result. Anything crossing the wire in a shape the rest of the app does not use — a raw body, a verb that is not POST, an added header — is driven rather than reasoned about.

The browser suite fails on any console error, uncaught exception or failed request. Keep the console clean rather than filtering the assertion. Steps that provoke a failure on purpose are wrapped in expectingFailure(), which forgives exactly the noise from that step. Do not widen it.

Rendering is asynchronous, so waiting for "a page with no skeleton" is wrong — the previous screen satisfies that too. Wait on data-render-seq, which the app bumps after each completed paint.

Test names are documentation. When an assertion changes because the rule changed, the name says which rule and why. When a test exists because something was once broken, say so. Never delete or weaken a failing assertion to make the suite green.

Every change ships with coverage for what it changed: new behaviour gets a test that fails before and passes after; a bug fix gets a test that reproduces the bug first; changed behaviour updates the existing test to assert the new rule.


Configuration

Settings live in backend/config/*.json, read through config-dir.js — never process.env directly, so one file per environment says what the app believes rather than the shell it happened to start in. Anything that varies per deployment, and everything secret, arrives as an environment variable named in custom-environment-variables.json and reaches the code as an ordinary key: c.get('auth.jwt_secret').

Adding, renaming or removing one is a single change that touches all of these, because every partial state is silent:

  1. config/custom-environment-variables.json — the mapping. Without it the variable is set in the environment and read by nobody.
  2. config/default.json (or the per-environment file) when there is a sane default. When there is not, the code refuses to boot and says which variable is missing — that is the right pattern for anything secret. A credential with a working default is how a known password ends up on a public address.
  3. .env.example — documented, not just listed: what it is for, what a plausible value looks like, what happens if it is left blank.
  4. The environment: block of the compose service that reads it.

npm run check:env fails on each partial state. Removing a variable is the same list in reverse, in one change; renaming one is a removal and an addition — leaving the old name mapped "for compatibility" means two names for one setting and no way to tell which is live.

A setting that follows from the shape of the deployment rather than an operator's choice — NODE_ENV, COOKIE_SECURE, APP_URL, MAIL_TRANSPORT — is fixed in the compose file and named in .env.example's closing block, so it is documented where it cannot be set. Giving one an .env entry invites somebody to answer a question that is not theirs: a MAIL_TRANSPORT=console on a deployed box sends every invite to a log file.

NODE_ENV selects both the dialect (models/index.js) and the settings file. Add the two together: an environment with a database entry and no config file runs on default.json and says so only in a warning.


Deployment

Both images build from the repository root, because shared/ is copied into each of them:

docker build -f backend/Dockerfile .
docker build -f frontend/Dockerfile .
docker compose up -d --build          # the whole stack
docker compose -f docker-compose.dev.yml up --build   # local, on MySQL
  • api — node:22-bookworm-slim, dependencies installed with npm ci --omit=dev before the source is copied so a source change does not reinstall them. --omit=dev also leaves out sqlite3: production is MySQL, and production cannot run on SQLite by construction. Runs as a non-root user and writes nothing to disk. Its HEALTHCHECK hits /health, and because bin/www does not listen until the schema is applied, "connection refused" means genuinely not ready rather than ready-but-broken.
  • web — the same base image serving frontend/, shared/ and docs/ through tools/server.js, and proxying /v1/api to the API. Same mappings in development and in the image, so a URL that works locally works deployed — there is no build step to paper over a difference.
  • db — MySQL, on an internal: true network with no route in or out, and deliberately not published to the host.
  • Traefik at the edge: TLS from Let's Encrypt, HTTP redirected to HTTPS (the session cookie is Secure, so plain HTTP would silently drop it rather than merely be unencrypted), plus HSTS, frameDeny, nosniff and a referrer policy as reusable middlewares.

The local stack runs on MySQL, not SQLite — SQLite is the test dialect, and a local stack that never touches MySQL cannot catch a MySQL-only problem. It also runs a mail catcher, so invite and reset flows can be driven end to end.

bin/www handles SIGTERM: in-flight requests finish and the pool closes cleanly, so a deploy does not leave a half-written transaction.

Deploying onto a box that already has an edge

A VPS this app has to itself takes docker-compose.yml above. A VPS that already runs other people's sites takes samples/docker-compose.shared.yml instead — which is why "does this box run anything else?" is a question to ask before writing either one. "Shared" here means one Docker daemon serving several unrelated applications: :80 and :443 are taken, a Traefik or nginx-proxy or Caddy already terminates TLS against one certificate store, there may be a db, a web and a redis that belong to somebody else, and a docker compose down in the wrong directory takes their site off the internet along with yours.

That file is docker-compose.yml minus the edge, plus a namespace. It is a second file rather than an edit, because the full one is still right for a box this app has to itself.

What is dropped, and it is worth saying in a comment why: the proxy and its :80/:443 binding, the ACME resolver and its certificate volume, any log shipper or metrics stack, and every ports: mapping without exception.

What to establish first, because none of it is guessable and most of it is about their box:

  1. Which services are the app and which are the platform. Only the first group survives. Here: web, api, db stay; traefik and letsencrypt go.
  2. What the host's edge is called — the network its proxy is on, the HTTPS entrypoint name, the certificate resolver name. Take all three as variables, never as literals. A compose-managed network is <project>_<key>, so it is rarely just web.
  3. What names are already taken: docker ps -a --format '{{.Names}}', docker volume ls, docker network ls, docker image ls.
  4. Whether the file will be include:d into the host's compose file or run as its own project. Ask — it decides what collides. Included is the usual answer where one operator runs the box.
  5. What the app needs from outside: the browser reaching one container, and the app reaching an SMTP relay. Nothing not on that list gets a route in either direction — that is the entire security argument for putting an app on somebody else's box.
  6. Whether anything of the host's mounts the Docker socket. Theirs is theirs. You do not get to add a second mount, and you do not ship the log pipeline: a container reading /var/run/docker.sock and /var/lib/docker/containers sees every container on the box. On your own VPS that is observability; on a shared one it is exfiltration with a dashboard.

Prefix every name the file introduces

Not the ones that look risky — all of them: acme_web, acme_db, acme_internal, acme_db_data, acme_security. Take the prefix from the project, never from the role: acme_db, not app_db, because app is the name every application thinks it has.

It is not obvious because the namespaces have different scopes, and the ones that collide most quietly are the ones that look safest:

WhatScopeA collision
service namethe merged compose projectLoud, at config time: services.web conflicts with imported resource. The best case here.
container_namethe whole daemonLoud, but possibly on the wrong side — their redeploy failing weeks later with your name in the error.
image tagthe whole daemonSilent. Your --build retags their web:latest; their next up starts your app under their domain.
volumethe including projectSilent. Two apps asking for db_data share one MySQL data directory.
network name:the whole daemon, verbatimJoining the wrong internal puts your database where their PHP container can reach it.
Traefik router / service / middlewarethe whole TraefikSilent. Two routers named web: one wins, the other host 404s. The explanation is in their log.
published portthe host's port spaceLoud — and the reason to publish nothing.

Both halves of that are verified rather than remembered. The same file unprefixed, included into a project that has its own web, fails with services.web conflicts with imported resource. Prefixed everywhere except the volume, it resolves with exit 0 and no warning — and both databases come out pointing at one hostproject_db_data.

Three networks, and the third is the interesting one

  • acme_edge — external: true, name: ${TRAEFIK_NETWORK}. External means you join it and never own it, so down on your app cannot remove the network the rest of the box is routed through. Only the one container the browser talks to is attached.
  • acme_internal — internal: true. No route in, no route out. The database and anything else nobody outside should reach. The host's other sites are not on it, which is the difference between deployed on a shared box and sharing one.
  • acme_egress — an ordinary bridge with exactly one container on it, for the service that must dial out. It NATs outbound; since nothing else is attached, nothing on the box can reach in through it. Wider than internal by one service and one direction. Skip it only if nothing in the app ever dials out — and check, because mail almost always does.

Traefik labels go on exactly one service, with the router, the service and every middleware named for the project, and the middlewares defined in your own labels rather than asked of the host's configuration: the file should bring what it needs and require nothing of theirs but the three names. Everything else gets no labels, no ports and no router; if the browser needs the API it is proxied through the exposed container, which is also what keeps one hostname, one certificate and one origin.

What bites

  1. name: in an included file is ignored, and volumes take the host's project prefix. Verified: a volume acme_db_data pulled into a project named hostproject resolves to hostproject_acme_db_data. So the prefix is the only thing keeping it separate — and the volume's real name depends on the directory the host deploys from. Rename or move that directory and the app comes up with an empty database and no error. If the data must survive that, declare the volume external: true with an explicit name and create it once by hand.
  2. A container on two networks makes Traefik guess, and it guesses wrong. Without traefik.docker.network it may route to the address on the internal network, which has no route in. The symptom is a 504 with nothing in your logs, because the request never arrived. Set it from the same variable as the network, and treat it as mandatory the moment a container is on more than one.
  3. Relative paths in an included file resolve against that file's directory; env_file: resolves against the including file's. Verified: context: . came out as the absolute path of the included file's own directory, so the checkout your file lives in is the one that gets built, wherever the host's compose file sits. That is what you want, and the opposite of what most people assume.
  4. internal: true breaks outbound mail at the first invite, not at boot. An internal network has no gateway, so a container alone on one cannot resolve an SMTP relay, let alone reach it. Nothing fails at startup; the first password reset returns a 500. That is what the egress network is for.
  5. Do not copy stsPreload into the shared file. Preload asks browsers to force HTTPS on a domain and its subdomains; on a shared box the parent domain is somebody else's and their plain-HTTP sibling host is not yours to break. Keep stsSeconds and stsIncludeSubdomains.
  6. Every service gets a logging: block with max-size and max-file. Default json-file logging is unbounded, and on a shared box a chatty container does not fill your disk, it fills the disk — and the first thing to fall over is whoever writes next.
  7. Never run a bare docker compose down in a shared project directory, and say so in the file's header: it stops the host's services too. The verbs are up -d --build <your services> and stop <your services>, named explicitly — which is also why ops/<project>-deploy.sh takes a SERVICES array rather than acting on the whole project. On a shared box that array holds the prefixed names and COMPOSE_DIR points at the host's directory.
  8. Publish no ports, then say how to reach the database anyway — docker compose exec acme_db …. A 3306:3306 on a shared box is a MySQL on the public internet, and it is always added "temporarily".
  9. An optional heavy service goes behind a profiles: key. A migration or import tool that runs twice a year should not sit in somebody else's RAM for the other 363 days. This stack has none, which is why the file has no profiles: — do not add one for a service that always runs.

Prove it without the host, and without a daemon

TRAEFIK_NETWORK=edge_net docker compose -f docker-compose.shared.yml config

mkdir -p /tmp/h && cat > /tmp/h/docker-compose.yml <<'EOF'
name: hostproject
include:
  - path: /abs/path/docker-compose.shared.yml
    env_file: ./acme.env
EOF
docker compose -f /tmp/h/docker-compose.yml config

Then read the output, not your input, and check four things: every volume, container, network and Traefik label carries the prefix; the edge network is external: true; no service has ports:; and the build contexts point at your checkout rather than the host's directory. Give the host project its own web and db while you are there — a test where nothing could have collided has proved nothing.

Then diff the environment block against docker-compose.yml. A variable in one and not the other is a deployment that behaves differently for reasons nobody will find, and npm run check reads every docker-compose*.yml in the root, so it covers the new file automatically.

The host-side steps

Only they can do these:

  1. Find the three edge names on the box and put them in the env file.
  2. Copy .env.example to a project-named file beside the host's compose file — acme.env, not .env, which their project already owns and reads implicitly.
  3. Add the include: block, then run docker compose config before up, which costs a second and touches nothing running. It catches a service-name conflict outright — but a missing variable is only a warning. Verified: with TRAEFIK_NETWORK unset, config exits 0 and the edge network comes out as external: true with no name at all, which silently becomes a lookup for a network called acme_edge that does not exist on that box, and the failure lands at up instead. So read the resolved network name out of the output rather than trusting the exit code — in a wall of "variable is not set" warnings, the one that matters does not stand out.
  4. docker compose up -d --build acme_web acme_api, naming the services.
  5. Point DNS at the box only once the containers are healthy. The ACME challenge needs the record, but a certificate issued to a 504 is a rate limit spent for nothing.

And say what the shared file does not carry: no log shipping and no metrics, so docker logs or the host's own stack; and backups are now the host's problem, where a volume hiding under someone else's project prefix is easy to miss in a backup script.


When the MVP is ready

Three things become relevant at the same moment and none of them is yours to decide: how it deploys, what strangers see first, and whether they can try it without being asked for an e-mail address.

The moment is any of these:

  • a first deployment;
  • a question about how to deploy, how to put it on a domain, or how to make the app publicly accessible;
  • the app reaching an MVP somebody is about to start using.

Offer all three, one question each, and build none of them unasked. Each costs something to keep: CI minutes, a public page that has to stay true as the product changes, and a table unauthenticated visitors can write to.

In this order, because each one leans on the one before:

  1. Automatic deployment — below. A landing page that points at nothing is worse than no landing page.
  2. The landing page — the front door, and somewhere for the demo's button to live.
  3. The demo tenant — the strongest thing a landing page can offer, and the one that needs the other two first.

Ask in one message rather than three, with what each one costs, and take "just the deploy" for an answer. A product with one real user and no landing page is a normal product.


Automatic deployment

samples/.github/workflows/deploy.yml and samples/ops/ are a push-to-main pipeline for this stack: the suites run on a GitHub-hosted runner and, only if they pass, one SSH connection tells the VPS to rebuild at the commit that was verified.

When to offer it — and what to ask first

At the moments above, and at each one ask rather than build. How a product ships is the operator's decision, not a detail to be tidied up on their behalf.

Two questions before writing any of it:

  1. "Do you want a push to main to deploy itself — the suites on a runner first, and the VPS only if they pass?" If the answer is no, the answer is no; docker compose up -d --build by hand is a legitimate way to run a small product.
  2. "Does this VPS run only this app, or other things as well?" Assume other things until told otherwise, because everything you put on that box has to be named for the project if so: acme-deploy.sh and .acme-deploy.lock, never deploy.sh and .deploy.lock. Two projects sharing one lock file is a collision that shows up as one project's deploy mysteriously waiting on another's, months later. The same answer decides whether Traefik is yours to restart (it is not, if it fronts anything else), whether docker image prune is safe box-wide, and whether a Host github.com block in ~/.ssh/config would break somebody else's pull. It also decides which compose file is deployed at all: a box that already has an edge takes docker-compose.shared.yml — see Deploying onto a box that already has an edge above.

Say what the gate costs before building it. Public repositories get Actions minutes free; private ones get 2,000 a month on Free and 3,000 on Pro, then about $0.008 a minute on Linux:

curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/OWNER/REPO   # 200 public, 404 private

This stack's gate is roughly four to six minutes a run — two npm cis, three suites and a real Chrome. Multiply by how often they actually merge, say the number, and let them decide. Revisit it against Settings → Billing after a week rather than the estimate.

Find these out before writing the workflow

Read the repository for what it can answer; ask for the rest. Every one of these has a wrong default that fails on the first run:

  1. The command they deploy with today, verbatim. It encodes the directory, the compose file and which services get rebuilt.
  2. Which compose file the VPS actually uses. A repository carries several (docker-compose.yml, .dev.yml); the one that matters is the one in the deploy directory, which may include: a file from the checkout. And note that compose service names and container names are different things — this stack's services are api and web, its containers acme_api and acme_web. docker compose up takes the first, docker inspect the second, and on a shared box docker inspect api can answer about somebody else's container.
  3. The project's own definition of done. Here it is npm run verify, so the CI steps are its parts — split into one named step each, so a red run names the suite instead of saying "verify".
  4. The runtime, from node -v and from FROM in every Dockerfile. They differ in this stack, and that difference is the single most likely way a first run fails. See below.
  5. Whether the suites want environment variables — or break on them.
  6. The HEALTHCHECK directives. Both Dockerfiles have one; reuse them as the post-deploy gate rather than writing a second probe to be wrong.

Why the runner, and never the VPS

The honest answer, because it will be asked: the tests do not run in Docker on either side, and the VPS already has Docker, so what is the runner for?

  • Not the running of the tests. It is the clean checkout — which catches the file that was never git added, and that is the save that actually happens — plus npm ci from the lockfile rather than months-old node_modules, and the fact that it runs when somebody is in a hurry, which is the whole difference from a local run that is optional.
  • Never move the suites onto the production VPS, and never put a self-hosted runner there. It means Chrome and devDependencies installed next to production data, suites competing with live traffic for CPU, and — fatally — tests running after the code is on the box. A gate that fires post-arrival is not a gate.
  • If minutes genuinely matter, the honest cheaper option is deploy-only CI plus a local pre-push hook. Say that it is bypassable with --no-verify and still runs against a dirty tree, and let them choose.

The two files

The workflow (samples/.github/workflows/deploy.yml) runs on push: branches: [main] — a merge is a push — plus workflow_dispatch for a redeploy by hand. concurrency: {group: production, cancel-in-progress: false} so deploys queue rather than truncate: a half-applied compose up is worse than a slow one. The deploy job needs: verify, which is the only thing making any of it a gate, and runs exactly one command: ssh user@host "<sha>".

The server script (samples/ops/acme-deploy.sh) is pinned to the CI key as a forced command, and:

  • takes the commit from SSH_ORIGINAL_COMMAND and refuses anything that is not 40 lower-case hex characters. That is what makes handing a private key to GitHub defensible — the key deploys a commit and can do nothing else.
  • deploys the SHA that was verified, not origin/main: when a second merge lands mid-build, origin/main is no longer what the suites passed against.
  • flocks, so two deploys cannot interleave over one checkout. The lock lives in the deploy directory, outside the checkout, where git reset cannot reach it.
  • git fetch + git reset --hard <sha>, never git pull. The checkout is a deployment artefact, not a workspace: a pull that stops on a local edit stops the deploy with it, and a merge commit made on the server exists nowhere else.
  • waits on the containers' own healthchecks, then on failure resets to the previous commit, rebuilds and exits non-zero — a bad push costs a slow deploy, not an outage.
  • prunes dangling images older than a week. Never -a: that command is box-wide, and on a shared VPS -a deletes every image without a running container, including the one another app is about to restart from.

Keep the repository copy as the reviewable source and scp it up. Do not symlink the server's copy into the checkout — bash reads a script as it executes it, and this one git resets the tree it would be living in.

What bites

  1. Pin CI to the version the suite is developed on, not the one the container serves — and check both. backend/package.json runs node --test 'test/**/*.test.js', and Node's own glob expansion landed in Node 21: on node:20 that exact command answers Could not find 'test/**/*.test.js' and exits 1. This stack is why the check is here — the images ran node:20 while the suite needed 21, so pinning CI to the Dockerfile "for consistency" was precisely the wrong instinct. The images are on node:22 now and the two agree, which is the state to keep them in: read node -v and every FROM line before writing the workflow, and when they have drifted, pin CI to the suite and move the image separately, in its own change with its own deploy.
  2. A suite that arranges its own configuration breaks when you help it. config/test.json gives the suites a silent logger, bcrypt at 4 rounds and the in-memory mail transport, and custom-environment-variables.json maps environment over config in every environment, test included. So an env: block carrying MAIL_TRANSPORT or LOG_LEVEL into the verify job overrides exactly what the assertions are written against. This stack's suites need no secrets: they generate their own. Write no env: block.
  3. The VPS can git pull by hand and cannot from CI. The manual pull works because an interactive session forwards an ssh-agent; a forced command has none. Symptom: Permission denied (publickey), exit 128. Fix: a read-only deploy key on the VPS, pointed at per repository with git config core.sshCommand, never a global Host github.com block in ~/.ssh/config — that box hosts other services. Test it the way CI will, with the agent removed, because an interactive test passes and proves nothing:

    env -u SSH_AUTH_SOCK git -C ~/deployment/acme fetch --prune origin && echo OK
  4. Re-running a failed run replays the same commit. A fix to the workflow therefore needs a new push — the workflow file is part of the commit being re-run. Say this before they reach for the re-run button. A fix to the server script is different: scp it up and the re-run works.
  5. Generating the CI key and authorising it is one command chain, not two. Given separately, the second can be run alone against a .pub that is not there, appending a line with a command and no key — after which every connection, including theirs, is read against a broken authorized_keys. Join them with &&, and give them the check and the undo:

    ssh-keygen -l -f ~/.ssh/authorized_keys        # one fingerprint per parseable line
    sed -i '/,restrict *$/d' ~/.ssh/authorized_keys
  6. known_hosts must match the hostname the workflow dials, exactly. Build the line from the host's own key file rather than a keyscan — no network, nothing to intercept — and strip keyscan's # banners if one is used:

    printf '%s %s\n' "$(hostname -f)" "$(cut -d' ' -f1,2 /etc/ssh/ssh_host_ed25519_key.pub)"

    A non-standard port takes the form [host]:2222 ssh-ed25519 AAAA…. A rotated host key then fails the deploy closed, which is the feature.

  7. An environment: url: built from an expression is silently skipped — "Skip setting environment url as environment 'production' may contain secret". It is a leak guard. Hardcode the literal or drop the line.
  8. In the script, prefer explicit if blocks to cleverness. set -e with case and && chains is not worth the puzzle; call the rollback directly rather than depending on an ERR trap firing from inside a loop, and remember arithmetic (( x >= y )) returns 1 when false — safe inside if, a landmine anywhere else. Install the rollback trap after the first mutation: if git fetch fails, nothing has changed and there is nothing to roll back.

Prove the script before anyone trusts it

samples/ops/test-deploy.sh drives the real script with a real git repository (a bare upstream and a clone, so the fetch/reset/rollback logic is genuinely under test) and a stubbed docker on PATH whose answers come from environment variables. Adapt it with the script; it covers:

PathExpected
a valid SHAdeploys, exit 0, checkout at that commit
bash -i, origin/main; rm -rf /, main, a short or upper-case hex stringrefused with a message, exit 1, checkout untouched
no command at allfalls back to origin/main
a container that comes up unhealthyrolls back to the previous commit, exit 1
a commit that does not buildrolls back, and the rebuild of the good commit succeeds
a container stuck in startingtimes out, rolls back
two deploys at oncethe second waits on the lock instead of racing

Assert the refusal message, not merely a non-zero exit: most of those strings are not valid git revisions either, so a script with the guard deleted still fails — later, by accident, and for the wrong reason.

Handing the server side over

These need a person with shell on the box, in this order, each with its check. samples/ops/README.md is that runbook, written to be handed over:

  1. scp the script up, chmod +x, and run it by hand once — it should deploy and report healthy before any CI exists.
  2. The deploy key for GitHub, and core.sshCommand, verified with env -u SSH_AUTH_SOCK.
  3. The CI key, generated and pinned to the forced command in one chain:

    ssh-keygen -t ed25519 -f /tmp/ci -N '' -C 'github-actions-deploy' && \
    printf 'command="%s/deployment/acme-deploy.sh",restrict %s\n' "$HOME" "$(cat /tmp/ci.pub)" \
      >> ~/.ssh/authorized_keys

    restrict removes pty, agent and port forwarding. Then cat /tmp/ci for the secret and shred -u /tmp/ci /tmp/ci.pub.

  4. Repository secrets DEPLOY_SSH_KEY (the whole private key, headers included), DEPLOY_HOST, DEPLOY_USER, DEPLOY_KNOWN_HOSTS; variable DEPLOY_PORT if SSH is not on 22.
  5. Worth naming while they are in there: make verify a required status check on pull requests, so a red suite cannot reach main at all.

After the first green run

Say what is still open rather than implying it is finished. Usually:

  • suites the gate does not cover, and why;
  • the version skew between CI and the containers, or a runtime that is EOL;
  • README.md and the ops documentation, which do not yet mention auto-deploy, the deploy key or the secrets — this architecture treats an operator-facing change as one that is documented in the same change;
  • the real minute usage, from Settings → Billing after a week, so the cost decision is made on a number rather than an estimate.

The landing page

samples/frontend/index.html, css/landing.css and js/landing.js are a public front door for this stack: / serves the landing page and the application moves to /app.html.

When to offer it — and what to ask first

At the MVP moment above, and never as a surprise. Three questions, and each answer changes what gets written:

  1. "Shall I put a public page on /, with the app at /app.html?" Some products should not have one at all — an internal tool, a private beta, a single customer. There, / stays the app and the answer is no.
  2. "What does it do today?" Write the list from the code, read it back, and let them cut it. A feature list written from the roadmap is a support ticket per line, and the person answering those tickets is them.
  3. "Is it meant to be found?" A page that should not be indexed yet is a different page: no sitemap, noindex on both documents, and no link from anywhere public.

Why it is a second document, not a route

The app boots by asking /auth/me who you are. A marketing page that did that would answer 401 to every visitor, log two console errors, and paint its hero after a round trip. So the landing page loads none of the eleven scripts app.html loads, and tests/run-tests.js evaluates js/landing.js in an empty context to keep it that way — the day it needs U or Store is the day it has quietly become a second copy of the app shell.

It shares the tokens and nothing else: css/styles.css first for the variables, then css/landing.css for the layout. A landing page with its own palette is one rebrand away from not matching the thing it is selling.

What bites

  • / is what the healthcheck fetches. Both containers probe it and expect 200. Moving the front door means moving the mapping in tools/server.js in the same change, and the suite asserts that whatever / resolves to is a file that exists — this has already cost one deployment, where / answered 404 while every real request worked, the web container was reported unhealthy for ever, and the deploy read that as a failed release and rolled back.
  • noindex on app.html, not on the landing page. A search result that drops somebody on a sign-in form helps nobody. robots.txt says the same thing to a crawler that reads it — and .txt needs a MIME entry, or it is served as application/octet-stream and offered as a download.
  • Every asset must exist. The browser suite fails on any failed request, and the landing page is the one document a stranger loads first. A missing hero image is a failed run, which is the correct severity.
  • No session detection. The session cookie is httpOnly, so this page cannot know whether somebody is signed in, and inferring it from the readable CSRF cookie is a guess that is wrong for exactly the people it would help. Link to the app and let the app decide.
  • Product language. This is the most visible localised surface in the repository; the same rule as every other visible string.
  • No form that has nowhere to go. An e-mail capture with no list behind it and a pricing table the product cannot honour are both worse than the white space they fill.

The demo tenant

samples/backend/routes/demo.js, services/demo.js and seed/demo.js: one unauthenticated POST provisions a whole tenant, seeded and signed in, and a day later it is deleted — every row of it.

When to offer it — and what to ask first

After the landing page, because it is the button on it. Three questions:

  1. "Do you want an unauthenticated stranger writing to your production database?" That is what this is, said plainly. The answer is often yes — it is the difference between a product somebody can evaluate and a form that asks for their e-mail address first — but it must be their yes.
  2. "How long should one live?" A day is the default and it is a good one: long enough to come back after lunch, short enough that a mistake is yesterday's problem.
  3. "What should it show?" If the product's claim is that what you see depends on who you are, one role demonstrates nothing. This stack seeds the whole cast and puts a switcher in the banner.

The rules it is built on

  • It is an ordinary tenant, not a mode. Same tables, same scope filters, same controllers, one boolean column. Nothing anywhere says if (demo). The tenant boundary that keeps two customers apart is what keeps a demo away from real data, and it is already tested per role over real HTTP.
  • Expiry is enforced on the request, not by the timer. middleware/tenant.js refuses an expired tenant before any controller sees it. If the sweeper were the only thing standing between an expired demo and its data, a crashed timer would silently extend every demo for ever and nothing would look wrong until somebody noticed a month-old tenant still serving.
  • force: true, or it is not deleted. Every model here is paranoid, so an ordinary destroy writes a deletedAt and keeps the row. Without the flag, "deleted after a day" is false in the only way that matters, and the rows accumulate for ever in tables nobody looks at. The suite asserts the counts with paranoid: false, because that is the only read that can see the difference. Delete children before parents; the foreign keys are real.
  • The door is metered. A ceiling on how many can be alive at once, and a per-address speed bump. Neither is a security control and both are written down as what they are: the ceiling bounds a bad afternoon, and the cooldown forgets as soon as the sweeper deletes the row it was reading.
  • No password, so there is no second way in. Demo accounts are created with a null password — passwords.verify compares a null hash against a dummy and returns false, so /auth/login cannot succeed for one however it is asked. Their addresses are unique per tenant and end in .invalid, because e-mail is unique platform-wide and the second concurrent visitor would otherwise collide with the first.
  • The seed runs in production. npm ci --omit=dev installs no devDependencies, so a demo seeded with @faker-js/faker — the package the test suite seeds with — works in every suite and crashes on the first real visitor. And every create takes the caller's transaction: on SQLite, whose pool is one connection, a query that does not take it waits for a connection the transaction is holding, for ever.
  • The data obeys the product's own rules. These rows go in through the ORM, not through the controllers, so nothing checks them. A demo whose own data breaks the invariants it advertises is worse than no demo.
  • Off by default, and 404 when off. A 403 tells an unauthenticated caller that this deployment has a demo endpoint and that it is merely closed, which is an invitation to keep asking. Same reasoning as a record somebody may not read.

What bites

  • CSRF, from a page that does not load the API client. The API demands the header from any request carrying a session cookie. The second press of the demo button — and every press by somebody whose demo is still open — carries one, so the landing page has to read the readable CSRF cookie and echo it itself. Omitting credentials instead dodges the check and throws away the Set-Cookie that IS the session.
  • req.ip is only the visitor behind a proxy. trust proxy is set, which is right behind Traefik and wrong the moment the app is exposed directly: the header is then the client's to write.
  • The session must not outlive the tenant. Cap the refresh token at the tenant's expiry and purge the tokens with the rows, or a browser spends a month presenting a token for a tenant nobody can find.
  • The switcher's cast travels with the principal, not from /users: the role somebody switches to may not be able to list the role they came from, and a switcher that cannot get back is a demo that dead-ends on its second click.
  • Say the terms before the button, not after. No sign-up, what is in it, and that it is deleted tomorrow, so please do not put real customer data in it. That sentence is the difference between a demo and a trap.
  • The IP address is personal data with a one-day life. It is collected for the rate limit, hidden from every role but the platform's, and deleted with the tenant.
  • Demo tenants show up wherever tenants are listed. The platform account's organization list is the obvious one; anything that counts tenants or customers is the next. is_demo is an ordinary column, so the generic query API already answers ?is_demo=false — this is a default to choose rather than code to write.

Language and localisation

If the interface is not in English:

WhereLanguage
Every visible string, and e-mail to usersthe product's language
Code, identifiers, commentsEnglish
API error codes and JSON keysEnglish
Tests and test namesEnglish
README, agent docsEnglish

The API answers in stable English codes (member_busy, short_payment); the frontend maps them to display text in App.ERROR_TEXT and the views. A server that returned display text would put the interface's vocabulary in the wrong repository, and could not be localised without a deploy. Conversely, never let an English string reach a localised interface — including error text, empty states, tooltips, aria-labels and dialog buttons.


Definition of done

  • npm test passes, with a test covering the change
  • cd backend && npm test passes, likewise
  • npm run smoke passes with a clean console
  • Anything a user types into or uploads through was driven in a real browser, end to end
  • npm run check passes
  • README.md reflects the change — feature list, access-model matrix, project layout, scope and limits
  • A setting added, renamed or removed → mapping, .env.example and the compose files updated together
  • A change to how it deploys → the workflow, ops/<project>-deploy.sh and ops/test-deploy.sh move together, and bash ops/test-deploy.sh passes
  • A new user-facing feature → the landing page's list still describes what the product does today, and still names nothing it does not
  • A change to what / serves → the mapping in tools/server.js, the healthcheck's expectation and the browser suite move together
  • A new model or column → the demo seed fills it, and the sweeper's purge order deletes it with force: true
  • Visible strings are in the product's language; code, comments and tests are English
  • Checked at 390px and desktop, in both themes

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.