agilentics / boiler

Conventions

This file is the point of the skeleton. The code exists mostly to prove these rules are real; if you follow one thing here, follow this file.

The layering rule

app/endpoints/   façade  - authenticate, deserialise, serialise. Nothing else.
app/core/        logic   - every decision about what may be read or written.
db/              models  - SQLAlchemy declarations and nothing else.

Two invariants hold this together. Both are easy to break by accident, and both are what make the core testable without a running web server:

  1. Nothing in app/core imports db_session. Every core function takes its session as the first argument. That is what lets tests/test_auth.py call core functions directly against an in-memory SQLite with no Flask anywhere.

  2. Nothing in app/core raises an HTTP error. The core raises Invalid, Forbidden, NotFound, Conflict (see app/core/context.py). The façade maps them to status codes through the STATUS table in app/endpoints/crud.py. Add a new error class there and add its status to that table in the same edit.

A core function's signature is (session, principal, ...), it calls principal.require(Scope.X) before it does anything, and it returns model objects rather than JSON. app/core/orgs.py is the worked example — read it before writing your first core module.

Multi-tenancy

Anything a customer owns carries org_id, and queries for it go through scope_to_org(query, Model, principal). That function is the single place the tenancy rule is written down.

Principal.__post_init__ refuses to build a customer-facing principal with no organisation, so the leak fails at construction rather than being relied on to be filtered downstream. Only system callers span organisations, and that is declared in SPANNING_SURFACES rather than implied.

When a caller asks for another tenant's row, raise NotFound, never Forbidden — "that exists but is not yours" confirms a competitor is a customer.

Adding a route

  1. Write the logic in app/core/<thing>.py, taking (session, principal, ...).
  2. Write the façade in app/endpoints/<thing>.py, decorated with @endpoint.
  3. Register it in app/routes.py.

It is protected automatically — require_session is a before_request guard built as an allowlist. To make a route reachable signed-out, add its path to PUBLIC_PATHS in app/endpoints/crud.py and write a comment saying what defends it instead (a single-use token, a signature header, enumeration safety). Every existing entry has one; that comment is the review.

Machine-to-machine surfaces (API tokens, provider webhooks) authenticate themselves inside the endpoint and return early from the guard before the CSRF check — they hold no cookie, so there is no ambient credential for a forged request to ride.

Sign-in paths

Every one of them ends at the same two functions: auth.principal_for_user resolves (user, org, role) → Principal, and session._begin_session starts the cookie. _begin_session clears the session first — a pre-existing session id must not survive a sign-in, or a fixed cookie becomes an authenticated one.

app/endpoints/oauth.py (Google) is the worked example for adding another. Two rules it follows and a new one must too:

Configuration

app.yaml            a host, an id, a public address, a flag   -> committed
env_secrets.yaml    anything that authenticates               -> never committed

env_secrets.yaml is gitignored and rendered by CI from repository secrets. app.yaml pulls it in with includes:.

An unset config variable means the feature is off, never a crash. The app must run end to end — locally and in CI — with no account registered at any third party. SMTP_HOST in settings.py is the worked example: unset, mail is logged instead of sent. Copy that stance for every integration you add, and .strip() every secret so a trailing newline from a piped value is not baked into a comparison.

Never interpolate a secret into a shell string. The CI render step writes them through a quoted heredoc with json.dumps for a reason — an unquoted one let the shell expand a password containing $, which arrived three characters short and silently broke outbound mail for three days.

Database

Tests

The suite runs on in-memory SQLite and needs no Postgres. Two fixtures: session for core-unit tests, app_client for façade tests. Mark anything that genuinely needs a live database @pytest.mark.integration.

Prefer a core-unit test: it is faster, and it only passes if the layering rule above is still intact.

Environments

main deploys to int automatically on merge; a v* tag promotes an int-tested commit to production. The tag must be an ancestor of main — CI enforces it. Int is a separate App Engine service in the same project, with its own database and its own secret file.

Comments

Comments here say why, not what — what the surprising constraint was, what broke last time, what the alternative was and why it lost. A comment restating the code is noise; a comment recording a decision is why this file is short.