Metadata-Version: 2.1
Name: create-star-app
Version: 1.38.3
Summary: Flask architecture scaffolding tool — generates core server files for the star app architecture.
Author-email: "Adrian Anton D. Ladia" <ladiaadrian@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/AALadia/create-star-app
Project-URL: Repository, https://github.com/AALadia/create-star-app
Project-URL: Issues, https://github.com/AALadia/create-star-app/issues
Keywords: flask,scaffolding,boilerplate,code-generator
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"

# create-star-app

Flask architecture scaffolding tool that generates core server files for the star app architecture.

## What it does

`create-star-app new`, run in the project's folder (a new, empty repository), checks the machine, then scaffolds that folder and names the project after it (`star-blog-maker` becomes `starBlogMaker`): a Flask server (MongoDB, JWT, Pub/Sub, pydantic models), a Next.js client (create-next-app with MUI and Firebase), its own `server/venv` with the requirements installed, the generated routes and TypeScript client (`AppCreator.py`), `CLAUDE.md` and `.claude/`, and an initial git commit. `create-star-app cloud-setup` then deploys it: GCP (Cloud Run, Cloud Build, Pub/Sub), Atlas, Firebase sign-in, GitHub and Vercel. Every step reports `ok`, `warn`, `failed` or `skipped`, and re-running the same command finishes what is left without touching what you changed.

## Installation

```bash
pip install -e .
```

## Getting started in 5 minutes

**The one command is `create-star-app`.** Run it in an empty folder (or a new repository) to create a project, and run it again inside the project for everything else: it shows a menu (run the app locally, finish local setup, deploy to the cloud, check this machine, add a second service, add the dead-letter dashboard, show the cloud status, tear down the cloud) with the next sensible step as the default. Every question explains itself and shows a recommended default: press Enter if unsure. The subcommands and flags under "Usage" do the same without questions.

You need Python 3.10+, Node.js 20.9+ (with npm and npx), git and a local MongoDB on `127.0.0.1:27017`. `doctor` checks all of them and prints the install line for your OS.

1. Install create-star-app from this checkout (see Installation above).
2. Check the machine and fix what can be fixed:

   ```bash
   create-star-app doctor --fix
   ```

   `--fix` installs `json-schema-to-typescript@15` after asking. In a terminal on Windows and macOS it also offers each missing tool, one at a time ("MongoDB isn't installed. Install it now? [Y/n]"): Node.js, git, MongoDB (then starts it; on Windows the service start asks for administrator permission), mongosh and the Database Tools, Java 21 and the Firebase CLI, through `winget install --id <id> -e --accept-source-agreements --accept-package-agreements`, Homebrew (`brew install ...`; without Homebrew it prints Homebrew's install line) or `npm install -g firebase-tools`. Afterwards it re-reads PATH (the registry PATH and the known install folders), so the tool is found without a new terminal; when it still is not, it says to open one. The same offer comes in the machine check of an interactive `new` and before `create-star-app dev` (MongoDB, Java). It never installs without a yes: under `--yes`, without a terminal and on Linux (where it would need sudo) it only prints the commands, and it never installs Python. A failed or cancelled install prints the command and carries on.
3. Create the project's repository and open its folder. With GitHub Desktop: File > New repository, named for example `my-shop` (a README, a .gitignore and a license are fine), then open a terminal in that folder. Without GitHub: `mkdir my-shop && cd my-shop`.
4. Scaffold the folder:

   ```bash
   create-star-app
   ```

   It asks two things, the business timezone (default: this computer's) and the local server port (default 5000), and at the end "Deploy it to the cloud now? [y/N]", explaining both answers: yes uses MongoDB Atlas, Firebase Auth and Storage, Vercel (Next.js) and Cloud Run built by Cloud Build from each GitHub push, and runs the cloud setup below right away; no keeps everything on this computer (local MongoDB, Flask, the Next.js dev server, Firebase sign-in and storage in emulators) and asks "Run the app locally now? [Y/n]". You can deploy any time later from the `create-star-app` menu. The project's name comes from the folder's name, camelCased (`my-shop` becomes `myShop`, `star-blog-maker` becomes `starBlogMaker`); nothing asks for it. Everything is created in this folder, never in a deeper one. The repository is kept: its history, its remote, its README, LICENSE and `.gitattributes`. Its `.gitignore` gains the scaffold's lines it lacks (`*.env` above all), and the scaffold is committed on top of its first commit. It takes a few minutes, mostly `npm install`. A step that fails says why and what to do. Fix it and run `create-star-app` again in the folder (Finish local setup) to finish what is left.
5. Run it from the project root.

   **Windows** (PowerShell), server and client in two terminals:

   ```powershell
   cd my-shop
   $env:ENVIRONMENT = 'localdev'
   server\venv\Scripts\python.exe server\app.py
   ```

   ```powershell
   cd my-shop\client
   npm run dev
   ```

   In Git Bash, use `ENVIRONMENT=localdev server/venv/Scripts/python.exe server/app.py` instead.

   **macOS and Linux**:

   ```bash
   cd my-shop
   ENVIRONMENT=localdev server/venv/bin/python server/app.py
   ```

   ```bash
   cd my-shop/client && npm run dev
   ```

6. Open http://localhost:3000. In localdev the starter page has a dev sign-in. The first user to sign in becomes superAdmin.

Always start the server from the project root, never from `server/`: it reads files by paths relative to the root.

## Run it locally

`create-star-app dev`, from anywhere inside the project, runs the whole app on this computer in one terminal. No Firebase, Vercel or Google Cloud account is needed:

```bash
create-star-app dev                 # Ctrl-C stops everything
create-star-app dev --no-persist    # forget the local users and files on exit
```

It starts three programs from the project root, each line of output prefixed with `firebase`, `flask` or `next`:

| Program | What runs | Where |
|---|---|---|
| `firebase` | the Firebase Emulator Suite: Auth and Storage (`firebase emulators:start --only auth,storage --project <id>`, through the global `firebase` CLI, else `npx --yes firebase-tools@15`) | Auth 127.0.0.1:9099, Storage 127.0.0.1:9199, emulator UI http://localhost:4000 |
| `flask` | `server/venv/.../python server/app.py` with `ENVIRONMENT=localdev` and `FIREBASE_AUTH_EMULATOR_HOST` / `FIREBASE_STORAGE_EMULATOR_HOST` on 127.0.0.1 | API http://localhost:<port> |
| `next` | `npm run dev` in `client/` with `NEXT_PUBLIC_ENVIRONMENT=localdev` | app http://localhost:3000 |

- **Sign-in goes through the Auth emulator.** Until `cloud-setup` runs, the Firebase project id in `server/sharedConstants.py` is `demo-<name>` (e.g. `demo-myshop`; `.firebaserc` says the same). Firebase runs a `demo-` project only in the emulators, with no account. "Sign in with Google" opens the emulator's own page, where you add a test account. `client/lib/firebase.ts` connects to the emulators whenever `NEXT_PUBLIC_ENVIRONMENT` is `localdev` (`client/.env.local`; `dev` adds the line when it is missing), also after `cloud-setup` has written the real project id: local runs never sign in to the real project.
- **The server accepts the emulator's tokens only locally.** The Auth emulator signs nothing (its ID tokens are `alg: none`). `AuthHandler.py` accepts one only when `ENVIRONMENT` is `localdev` and `FIREBASE_AUTH_EMULATOR_HOST` is `127.0.0.1:<port>` or `localhost:<port>`, and it still checks the audience, issuer, subject and expiry. A deployed service refuses them even with the variable set, and logs a warning at start-up when it is set. `dev` sets the variable; a server started by hand (above) needs `FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099` too for an emulator sign-in.
- **Storage follows `storage.rules`.** The emulator enforces the same rules `cloud-setup` deploys: a signed-in user reads and deletes only under `users/<uid>/` and uploads there only a JPEG or PNG under 10 MB; everything else is denied. The file says how to widen it.
- **Users and files are kept.** On Ctrl-C, `dev` saves the emulators' data to `.firebase-emulator-data/` (`firebase emulators:export`) and imports it on the next start. It saves only once the emulators said they were ready, into a staging folder that replaces the old data only when the export succeeded, so a quick Ctrl-C never loses saved users. A project without a client (`--skip-client`) gets the emulators and the API only. The folder holds the local users' e-mails: it is gitignored (`dev` adds the lines to an older project's `.gitignore` when git does not ignore it), never committed (`forbidden_commit_path`) and never uploaded to Vercel (`.vercelignore`). `doctor` warns when the installed firebase-tools is not the pinned major (15). `--no-persist` neither imports nor saves.
- **Before it starts anything**, `dev` checks mongod on 27017, Java (below), that ports <port>, 3000, 9099, 9199 and 4000 are free, the server venv, the client's `node_modules` and the generated `server/app.py`, and prints the fix for each problem. Ctrl-C stops all three (on Windows each runs in its own process group and gets CTRL_BREAK); one that has not stopped after 10 seconds is killed with its children. When one exits by itself, `dev` stops the other two.
- **Java**: the emulators need a JDK, 21 or newer (firebase-tools 15 refuses older ones). `create-star-app doctor` warns without it: `winget install Microsoft.OpenJDK.21`, `brew install openjdk@21` or `sudo apt install openjdk-21-jre`. The Firebase CLI needs no login for the emulators.

## Deploy in 15 minutes

`create-star-app cloud-setup` takes the project from your laptop to a Cloud Run service (`https://<service>-<hash>.run.app`), a Vercel client, an Atlas database and a GitHub-triggered Cloud Build. It is resumable: run it again after any stop and finished steps are only checked.

**Try it on a throwaway project first.** Scaffold `create-star-app new throwaway`, deploy it, then `create-star-app cloud-teardown --yes`. Do this before you point it at a project you care about. Your first run also settles the open items under "Checked on the first real run".

### 1. Create the accounts

| Account | Why | Sign up |
|---|---|---|
| Google, with billing | Cloud Run, Cloud Build, Pub/Sub, Firebase. Cloud Run needs a billing account even inside the free tier, where it costs $0. A new account gets free-trial credit. | https://cloud.google.com/free |
| MongoDB Atlas | the database. The free M0 cluster needs no card. | https://www.mongodb.com/cloud/atlas/register |
| GitHub | the repository Cloud Build and Vercel deploy from | https://github.com/signup |
| Vercel | the Next.js client. Hobby is free. | https://vercel.com/signup |

Firebase uses the Google account. `cloud-setup` pauses and links you to the right page when an account is missing, but doing this first saves a restart.

### 2. Install the tools

| Tool | Windows | macOS | Linux |
|---|---|---|---|
| gcloud | `winget install Google.CloudSDK` | `brew install --cask google-cloud-sdk` | https://cloud.google.com/sdk/docs/install |
| GitHub CLI | `winget install GitHub.cli` | `brew install gh` | https://cli.github.com |
| Firebase CLI | `npm install -g firebase-tools@15` | same | same |
| Atlas CLI | `winget install MongoDB.MongoDBAtlasCLI` | `brew install mongodb-atlas-cli` | https://www.mongodb.com/docs/atlas/cli/current/install-atlas-cli/ |
| Vercel CLI | `npm install -g vercel` | same | same |

Then update gcloud, since `cloud-setup` needs 500.0.0 or newer:

```bash
gcloud components update
```

Open a new terminal afterwards so the new tools are on PATH. You do not have to sign in to each CLI yourself: at the first one that is signed out, `cloud-setup` asks "Sign in to <tool> now? [Y/n]" and runs its login in your terminal (`gcloud auth login`, `gh auth login --web`, `firebase login`, `atlas auth login`, `vercel login`), then checks again. On "n", or when the login does not finish, it pauses with the command and the sign-up page instead.

### 3. Deploy

From the project root, run `create-star-app` and pick **Deploy to the cloud** (or answer yes to "Deploy it to the cloud now?" at the end of `new`; `create-star-app cloud-setup` is the same). It works in this order, and nothing is created before you say Start:

1. **Sign in.** For each signed-out CLI: "Sign in to <tool> now? [Y/n]", and its login runs in your terminal.
2. **The settings, all at once**, section by section, each with a one-line explanation and a recommended default ("[1] (press Enter if unsure)"):
   - Google Cloud: the GCP project (create a new one with the suggested id, or use one of yours from the list), the server location (a city list, default nearest your business timezone), the billing account (only with several).
   - MongoDB Atlas: the organization (only with several) and the project (create, or use an existing one); the cluster region follows the server location.
   - GitHub: the owner (you or one of your organizations) and the repository name (default the folder's name; an existing `origin` is used as it is).
   - Vercel: the team (only with several).
   - A dead-letter dashboard: e-mail alerts (y/N, then the Gmail address and its app password, typed hidden).
3. **A summary** of what will be created or used, and the browser steps still ahead (the Cloud Build GitHub App, Google sign-in in Firebase, a billing account if you have none). Then "Start? [Y/n]". On n nothing is saved and nothing is created.
4. **The run.** The answers go to `star-app.json` (the Gmail app password only to `server/cloud.env`), and every step runs; the only stops are the browser steps, each saying what to click and waiting for Enter. A re-run shows the saved answers in the summary (the project and the server location as fixed once something exists) and asks only "Start?".
5. **Next.** In a service without one: "Add the dead-letter dashboard now? [y/N]".

To look before you leap: `create-star-app doctor --cloud` (the cloud CLIs, versions and sign-ins, read-only) and `create-star-app cloud-setup --dry-run --offline` (every command it would run; changes nothing; it asks nothing).

`cloud-setup` has about 32 steps. `create-star-app cloud-setup --list` names them and shows their status. A real run pushes `main` to a new private GitHub repository. It pauses if you have uncommitted changes, so commit or discard them first.

**Reference: what each answer is recorded as.** The flags under "Flags, re-runs and exit codes" pre-answer a question (it is then not asked); `--step`, `--from` and `--list` skip the interview, and a step then asks its own question only when it needs one:

| Question | Asked when | Recorded as |
|---|---|---|
| "GCP project: 1) Create a new project (<suggested id>)  2) Use an existing project" (Enter: 1); for 2, your ACTIVE projects as "name (id)" or "type a project id", checked (ACTIVE, you are Owner) and asked again if not | no `--project-id` and none recorded | `cloud.gcp.projectId` (an existing one with `projectCreated: false`, so `cloud-teardown` keeps it) |
| "Sign in to <tool> now? [Y/n]" | a CLI is signed out | (the CLI's own login) |
| "Create the GitHub repository under which account?" (you, or one of your organizations) | no `--gh-owner`, and you belong to an organization | `cloud.github.owner` |
| "Create the Vercel project in which team?" | no `--vercel-scope`, and `vercel teams ls` lists more than one | `cloud.vercel.scope` |
| "Create the Atlas project in which organization?" | no `--atlas-org-id`, and you belong to several | `cloud.atlas.orgId` |
| "Atlas project: 1) Create <name>  2) Use <project> (<id>) ..." (Enter: 1) | Atlas projects exist in the organization | `cloud.atlas.projectId` (an existing one with `projectCreated: false`) |
| "Link which billing account?", "Create the project in which organization?" | several to choose from | (GCP itself) |
| "Add the dead-letter dashboard now? [y/N]" | at the end of a finished run, in a service with no dashboard among its siblings | runs `create-star-app new deadletter --kind dead-letter --dir ../deadletter --link .`, then prints `next: cd ../deadletter && create-star-app cloud-setup` |

A numbered question takes Enter for the default shown in brackets and asks again after a number out of range. With `--yes`, `--non-interactive`, `--dry-run` or without a terminal nothing is asked and the flags and defaults apply as before (a new project id is suggested, the only GitHub owner is you, ...). The flags still work and skip their question.

When it finishes, `verify` has checked `/startupCheck`, the predeploy job, a build triggered by a push and the client. The summary prints the Cloud Run URL and the Vercel domain.

### Pauses in the browser

A few things the CLIs cannot do. In a terminal, `cloud-setup` prints what to do and the page to open, then waits for Enter and checks again. Type `q` to stop. With `--non-interactive`, or without a terminal, it stops at once with exit code 75 and the same command continues after you do the step.

| Step | When | The page asks you to |
|---|---|---|
| `auth.*` | a CLI is signed out | run the CLI's login command and finish it in the browser |
| `gcp.billing` | the Google account has no open billing account | create one. With one account it is linked for you, with several you pick. |
| `cloudbuild.connection` | always, the first time | authorize the Cloud Build GitHub App and install it on `<owner>/<repo>` |
| `cloudbuild.repository` | only if the App cannot see the repository | open Configure next to Google Cloud Build and add the repository |
| `firebase.googleSignIn` | always, the first time | in Authentication > Sign-in method, choose Google, enable it, pick a support email and Save |
| `vercel.project` | the project's Root Directory is not `client` | set Root Directory to `client` in Settings > Build and Deployment and Save |
| `vercel.project` | the production domain answers 401 | turn Vercel Authentication off for Production (Deployment Protection) and Save |
| `vercel.project` | `vercel git connect` fails | connect the GitHub repository in Settings > Git |

### Where the secrets live

- `server/cloud.env` holds `MONGO_URI`, `MONGO_DUMP_URI` and `JWT_SECRET_KEY` (and the dashboard's Gmail values). It stays on your machine. `cloud-setup` refuses to write it unless git ignores it, so it is never committed.
- `star-app.json` holds ids and names only (project, service, trigger, Atlas project), never a secret. It is committed.
- The same values are set on the Cloud Run service. Lost `cloud.env`? `cloud-setup` reads them back from the service or rotates a lost database password.
- Printed commands and logs show `***` in place of a secret value.

### Flags, re-runs and exit codes

| Flag | Meaning |
|---|---|
| `--dry-run` | run the reads, print the writes, change nothing |
| `--dry-run --offline` | skip the reads too: prints every write, needs no sign-in |
| `--list` | list the steps and their status |
| `--step NAME` | run only that step |
| `--from NAME` | start at that step |
| `--yes` | answer yes to confirmations |
| `--non-interactive` | never wait for input; a browser step exits 75 |
| `--project-id`, `--region`, `--gh-owner`, `--vercel-scope`, `--atlas-org-id` | optional: choose the ids instead of answering the questions above; an already recorded project id changes only with `--yes` |
| `--no-shared` | with siblings, keep this service apart (see "Add a second service") |

A re-run checks each finished step with one read and skips it; a missing or changed resource is applied again. After a failure, fix the cause and run the same command. Use `--step` to repeat one step, for example `--step verify`.

| Exit code | Meaning |
|---|---|
| 0 | all steps done |
| 1 | a step failed or is blocked; the summary names it and the command to run |
| 2 | bad input, nothing was written |
| 75 | waiting on a browser step; do it and run the same command again |
| 130 | Ctrl-C |

### Tear it down

```bash
create-star-app cloud-teardown --dry-run   # lists everything it would delete
create-star-app cloud-teardown --yes
```

`--yes` is required. In a terminal you also type the project id. By default it deletes what `cloud-setup` created, in reverse order: the Vercel project, the Cloud Build trigger, repository link and connection, the GCP project, and the Atlas cluster and project. It keeps anything that existed before (an adopted project is reported as kept) and it keeps the GitHub repository.

| Flag | Effect |
|---|---|
| `--keep-project` | keep the GCP project and the Atlas project and cluster; delete only this service's resources in them |
| `--delete-github-repo` | also delete the GitHub repository |

It lists every job and topic it will delete by exact name before it asks. With siblings it keeps all shared resources and unwires the sibling services (see "Add a second service").

## Add a second service

A Star App can be several services in several repos. They call each other, share one login, and run in one GCP project on one Atlas cluster.

```bash
create-star-app new --link ../myShop          # in the new, empty billing folder (or repository), next to myShop
create-star-app new billing --link ../myShop   # or from the folder holding both: ./billing, linked to ./myShop
create-star-app link ../billing                # or, inside an existing project: link another one
```

What is shared and what stays per service once both are deployed:

| Shared by all linked services | Per service |
|---|---|
| the GCP project, billing, APIs and Artifact Registry repo | the git repository and `server/cloud.env` |
| the Atlas project and cluster (an M0 allows 500 connections in all) | the Cloud Run service, its `-predeploy` job and its Pub/Sub topics and push subscriptions |
| the Firebase project and web app, so one Google sign-in works everywhere | the service accounts `<svc>-run`, `<svc>-push`, `<svc>-build` |
| the Cloud Build GitHub connection | the Atlas database (named after the project) and its two users `<svc>-app` and `<svc>-reader` |
| `deadLetterTopic` | the Cloud Build trigger `<svc>-main` and the Vercel project (its domain is added to the shared Firebase list) |

How it fits together:
1. `new billing --link ../myShop` (or `link`) changes both repos on disk and commits neither itself, except that `new` includes its own side in its first commit.
2. Run `cloud-setup` in whichever service you deploy first. It creates the shared resources. The second service's `cloud-setup` adopts them, asks once to confirm (`--yes` skips it), and creates only its own.
3. The last step, `siblings.wire`, connects the two running services: each gets the other's URL and `PUBSUB_ENABLED_*` flag, accepts the other's service account in `callerAuth.py` (committed and pushed in that repo), and gets the push subscriptions for the topics it consumes. Run it again from either repo with `cloud-setup --step siblings.wire`.
4. `--no-shared` keeps one service apart with its own GCP project, cluster and Firebase project. It is remembered in the record.

The details follow.

### What `new --link` and `link` do

`new --link` takes the next free port of 5000, 6000, 7000, ... that neither project (nor any sibling of the first) uses, unless `--port` says otherwise, and checks the name and port before writing anything. `link` (run by `new --link` before its initial commit, so that commit holds the new repo's side) changes both repos and commits neither itself:

- `server/services/<other>.py` (class `<Other>`, `dev=http://127.0.0.1:<its port>/`, its Cloud Run service name, `localRepoDirName` = its folder) and its line in `services/AllServices.py`. The file is created once; a later `link` updates only its `dev=` port, `cloudRunServiceName` and `localRepoDirName` when the other's record or folder changed. A hand-edited `AllServices.py` the edit cannot place is left alone: the two lines to add are printed and the exit code is 1.
- `PUBSUB_ENABLED_<OTHER>=false` in `server/.env` (an existing value is never changed), the local-ports table in `CLAUDE.md` (between `<!-- >>> star-app per-repo: local ports -->` and its end marker; without the markers the table is printed instead), and the other's runtime service account in `callerAuth.py`'s `SERVICE_CALLER_ACCOUNTS` once its `star-app.json` has one (restated in `test_callerAuth.py`'s `EXPECTED_SERVICE_CALLER_ACCOUNTS`, which its test compares).
- `star-app.json`: `siblings[]` (`projectName`, `dir`, `port`, `serviceName`, `envName`, `kind`) and `cloud.shared`, the GCP / Atlas / Firebase / Cloud Build ids copied from the project that ran `cloud-setup` first, so the other adopts them (`--no-shared` skips the copy). Two projects already deployed to different GCP projects are refused: tear one down, or keep them apart with `--no-shared`.
- `AppCreator.py` in both repos with each one's venv Python (`--no-appcreator` skips it; MongoDB must listen on 27017), then `git status --short` of both and the next steps.
- `--remove-example` also drops `exampleService` (its file, import, list entry and `.env` line) where `exampleService.py` is still exactly as scaffolded; a resumed `new` does not bring it back.

A conflict (same name, same port, a port a sibling of the other side uses, two services with the same `PUBSUB_ENABLED_*` name, a folder without `star-app.json`, a folder name outside `A-Za-z0-9._-`, a `cloud.serviceName` that is no Cloud Run service name) is exit 2 and changes nothing. A `runtimeServiceAccount` that is not `<name>@<project>.iam.gserviceaccount.com` is skipped with a warning. `link` is pairwise and idempotent: for a third service, link it to each of the others. With a dead-letter dashboard (`kind: "dead-letter"`) only the port table, `siblings[]` and the dashboard's runtime account (second entry of the service's `PUSH_SERVICE_ACCOUNTS`) are written: services never call the dashboard.

`link` runs the other checkout's `AppCreator.py` with its venv Python, and `git status` there: link only checkouts you trust. Before pushing the link commit to a service that is already deployed, run `create-star-app cloud-setup --step cloudrun.service` in it (it sets `PUBSUB_ENABLED_<OTHER>=false` on the service; without it the new revision fails at startup); `--step siblings.wire` comes once both services are deployed. After committing, refresh the Pub/Sub map (`cd server && python pubSubMap.py --write` in each repo) and create the mock data the printed commands name.

### What `cloud-setup` does for a linked service

Once two projects are linked (`create-star-app link ../other`), they share one GCP project, one Atlas cluster, one Firebase web app and one Cloud Build GitHub connection. Whichever runs `create-star-app cloud-setup` first creates them; the second one adopts them:

- at the start of every run, `cloud-setup` re-reads each sibling's `star-app.json`, validates every value it will use (a Cloud Run URL, env names, service name, project ids, region, Atlas ids, a layout inside the sibling's folder; a record of the wrong shape is never used), and refreshes `cloud.shared` from the sibling that created the resources. The first adoption prints the sibling's path and the ids and needs a y (or `--yes`). Two siblings in different projects (or this one in another project than a sibling) stop the run with exit 2: tear one down, or keep this service apart with `--no-shared`, which is remembered (`cloud.sharedMode = "own"`) and is refused on a record that already adopted ids until they are cleared;
- the shared steps (project, billing, APIs, Artifact Registry, Atlas project and cluster, Firebase project, Google sign-in, the Cloud Build connection) only confirm with one read and never create; a missing shared resource is BLOCKED with the command to run in the service that owns it;
- each service creates its own two Atlas users on the shared cluster, scoped to its own database (`<service>-app` with `readWrite@<projectName>`, `<service>-reader` with `read@<projectName>`, `databaseName=<projectName>`; a user with other roles is set back to its one role with `atlas dbusers update --role`, its password kept); a sibling's users are never touched;
- `firebase.webApp` writes the shared web app's config into this repo's `client/lib/firebase.ts`; `firebase.authorizedDomains` adds this service's Vercel domain to the shared list;
- the Cloud Run env gets `<OTHER>_PROD_URL` and `PUBSUB_ENABLED_<OTHER>=true` for every deployed sibling in the same GCP project (always both); `PUBSUB_ENABLED_<OTHER>=false` for one not deployed, torn down or in another project; a sibling whose record cannot be read here keeps its live values;
- the last step, `siblings.wire` (also `cloud-setup --step siblings.wire` in the older sibling), sets our two env vars on each deployed sibling's service and theirs on ours. In each repo's `callerAuth.py` it adds the other's `<svc>-run` account to `SERVICE_CALLER_ACCOUNTS` and, when the other publishes to it (the other's `pubsub_models.json`), the publisher's `<svc>-push` account to `PUSH_SERVICE_ACCOUNTS` after `[0]` (a push subscription's token is minted for the publisher's push account), mirrors every edit in `test_callerAuth.py`'s `EXPECTED_*` block, and commits and pushes only those two files in that repo (after a confirmation naming the accounts, or `--yes`). Before any write, both repos must be on main, fetched and level with origin/main (a wiring commit whose push failed is pushed first) with the two callerAuth files unchanged (else a pause shows their diff; nothing is stashed); an account is taken only when it is the one cloud-setup makes for that service in this project. It then runs `pubSubAdmin.py --project P --service S --yes` in each publisher's repo for the push subscriptions and checks both `/startupCheck`s. Both triggered builds are followed in one loop; a failed or unfinished one is reported with its log URL and the sibling is not recorded, so a re-run checks again. The step's check is live (both services' env vars, both callerAuth tuples and mirrors, a push subscription per production topic); `cloud.siblingsWired` (on either side) records each wired sibling.

All services share the cluster's connections (500 on an M0) (`min-instances=0` and one gunicorn worker per service keep each one low). `cloud-teardown` in a service with siblings keeps everything shared, deletes only this service's resources and push subscriptions, and first removes our env vars (`--remove-env-vars`) and our runtime and push accounts (with their `test_callerAuth.py` mirrors) from each sibling (edit, commit and push after a confirmation), then clears `cloud.siblingsWired`. It never drops this service's database on the shared cluster; it prints the command to drop it.

## Add the dead-letter dashboard

Every push subscription a Star App creates forwards a message to `deadLetterTopic` after 5 failed deliveries. The dead-letter dashboard is the service that receives them: one per GCP project, shared by all its services.

```bash
create-star-app new deadletter --kind dead-letter --link ../myShop   # from the folder holding both
create-star-app link ../billing        # inside ./deadletter, once for each further service
```

At a glance:
- **What siblings get.** `cloud-setup` adds the dashboard's runtime account as `PUSH_SERVICE_ACCOUNTS[1]` in each sibling's `callerAuth.py` (committed and pushed there, mirrored in `test_callerAuth.py`). The sibling must redeploy before "Retry Prod" reaches it. `[0]` stays the sibling's own push account.
- **Optional e-mail.** `cloud-setup` asks once for a Gmail address and an app password (`GMAIL_SMTP_USER`, `GMAIL_SMTP_PASSWORD`). An empty answer skips it and the e-mail is only logged. The values stay in `server/cloud.env` and on the service.
- **The subscription.** `deadLetterTopicProduction` pushes to `<url>/createDeadLetter` with ack deadline 180 s. The dashboard runs with concurrency 1 and a 300 s Cloud Run request timeout. It has no dead-letter policy of its own, so a message it keeps failing on is dropped after 7 days.
- **Monitoring.** Alert on `pubsub.googleapis.com/subscription/oldest_unacked_message_age` for `deadLetterTopicProduction`, for example above one hour.
- **The fleet's own `deadletter` service is left alone.** `cloud-setup` never takes over a Cloud Run service it did not create. If one exists under the name, pick another: `create-star-app new <name> --kind dead-letter`.

`NAME` defaults to `deadletter`, so `create-star-app new --kind dead-letter` alone also works. Inside a repository folder made for the dashboard, `create-star-app new --kind dead-letter --link ../myShop` scaffolds that folder and names it after the folder. From inside a service, use `--dir ../deadletter` (what `cloud-setup`'s closing hint prints): `new` never creates a project inside another one.

It is the base scaffold plus the `templates/kinds/dead-letter/` overlay (`star-app.json` records `"kind": "dead-letter"`; a kind is chosen once). What you get:

- `/createDeadLetter` (`PubSubRequests.py`, logic in `deadLetters.py`) stores every push in `DeadLetters` and answers 200, a malformed one too (kept as text), so nothing loops. The one exception is a redelivery while another request still holds a fresh claim on the replay-once-then-email step: it gets 503, so Pub/Sub retries it until the claim is stale and the redelivery takes over. It reads `originalTopicPath` (every Star App publisher sets it) and Pub/Sub's `CloudPubSubDeadLetterSourceSubscription` (short id), `...SubscriptionProject` and `...DeliveryCount`: that subscription's push endpoint is where a replay goes (one `get_subscription` read; a failed read keeps none and says why; without the attribute, every push subscription of the original topic, never auto-replayed).
- The page (`client/app/page.tsx`): compact rows by status (the payload and the endpoints' answers load on demand), Retry Prod, Retry Local (a loopback URL, only while the dashboard runs locally), Retry all pending, Close, and who is emailed. The starter todo page moves to `/starter`, and its routes answer in localdev only.
- Replay tokens (`replayAuth.py`): Retry Prod sends an ID token minted for exactly the endpoint, by the dashboard's runtime account, which each sibling lists second in its `callerAuth.PUSH_SERVICE_ACCOUNTS`. It never runs from a local dashboard, follows no redirect, and asks before replaying a dead letter whose push callerAuth did not verify. A new dead letter is replayed by itself once, only when callerAuth verified the push that delivered it. The dashboard's own `PUSH_SERVICE_ACCOUNTS` hold its push account only, so a replay token can never create a dead letter.
- Roles `canViewDeadLetters`, `canReplayDeadLetter`, `canCloseDeadLetter`, `canReceiveNewDeadLetterEmails` (the first user is superAdmin and has them all); optional Gmail email (`GMAIL_SMTP_USER` / `GMAIL_SMTP_PASSWORD`, else it logs); `job_purgeClosedDeadLetters.py` (a dry run unless `--apply`); its own tests (`test_createDeadLetter.py`, `test_deadLetterRoutes.py`, `test_replayAuth.py`, `test_roles_deadLetter.py`).

The subscription `deadLetterTopicProduction` (push to `<url>/createDeadLetter` with the dashboard's push account, no dead-letter policy of its own) and the siblings' `PUSH_SERVICE_ACCOUNTS` entry are cloud-setup's job; until then the dashboard runs locally like any project. `server/ARCHITECTURE.md`, "The dead-letter dashboard", has the flow and the IAM it needs.

`create-star-app cloud-setup` in the dashboard runs the shared-mode steps plus:

- `gcp.serviceAccounts`: `deadletter-run` gets `roles/pubsub.viewer` only (endpoint lookup). It gets no publisher role (a replay is an HTTP POST, never a republish) and no `roles/run.invoker`: every service is public, and a project-wide invoker may include `run.jobs.run`.
- `local.edits`: the dashboard's own `PUSH_SERVICE_ACCOUNTS` is its push account alone.
- `secrets.email` (before `cloudrun.service`): asks once, in a terminal, for `GMAIL_SMTP_USER` and `GMAIL_SMTP_PASSWORD` (a Google app password; the password is not echoed). An empty answer, `--yes` or no terminal skips it, and the e-mail is then log-only. The values live only in `server/cloud.env` and on the service, never in `star-app.json`. Only one of the two in `cloud.env` is reported as partial. `server/cloud.env` quotes a value that would otherwise read back changed (a leading quote, ` #`, surrounding spaces) and refuses one it cannot write back exactly.
- `cloudrun.service`: the dashboard is deployed with `--timeout=300` (its Cloud Run request timeout) and `--concurrency=1` (one sync gunicorn worker: a burst of pushes starts more instances instead of queueing), both checked for drift, and records `cloud.gcp.serviceCreated`. An existing service of the dashboard's name that does not run as the account cloud-setup makes for it, or whose image is on none of this repo's paths (the recorded GitHub repo's or the bootstrap image's) while no provenance says cloud-setup deployed it, (the fleet's own `deadletter`) is never taken over; the step stops and asks for another name (`create-star-app new <name> --kind dead-letter`).
- `deadletter.subscription` (after `cloudrun.service`): creates `deadLetterTopic` when missing, then `gcloud pubsub subscriptions create deadLetterTopicProduction --topic=deadLetterTopic --push-endpoint=<url>/createDeadLetter --push-auth-service-account=deadletter-push@P --push-auth-token-audience=<url>/createDeadLetter --ack-deadline=180 --message-retention-duration=7d --expiration-period=never --min-retry-delay=10s --max-retry-delay=600s`, with no dead-letter policy, and records `cloud.deadletter.subscriptionCreated`. gunicorn's worker timeout (`--timeout 120` in the Dockerfile) is the real ceiling of one request, and `receive()` is bounded well inside it: the cert fetch 15 s, the endpoint lookup 10 s in all, the ID token 5 s, one replay 40 s, the e-mail 15 s (a thread it waits for), Mongo 5 s, 90 s in all. The ack deadline, 180 s, adds 60 s to gunicorn's 120 s: Pub/Sub's clock starts when it sends the push, so a cold start, the wait for an instance and a slower google-auth metadata back-off come on top. `notifiedAt` is set before the e-mail goes out, so a killed request never sends it twice. Its own subscription's ack, retention, retry and dead-letter-policy drift is updated (`--clear-dead-letter-policy` when it has one). One that another dashboard made (another push host or account, and not recorded as ours), or one with a filter or unwrapped pushes, stops the step with the delete command.
- `siblings.wire`: each deployed sibling gets the dashboard's runtime account as `PUSH_SERVICE_ACCOUNTS[1]` (never `[0]`; one at a later index moves to `[1]`), mirrored in `test_callerAuth.py`, committed and pushed in that repo after the git preflight (confirmation unless `--yes`); no env vars change. The account must be the one cloud-setup makes for the dashboard. A sibling is recorded wired only when its latest build did not fail. It prints the siblings that must still redeploy before "Retry Prod" reaches them.
- `verify`: after `/startupCheck`, it publishes `{"test":true}` with `originalTopicPath=projects/P/topics/createStarAppSmokeTest_<svc>` to `deadLetterTopic` through the Pub/Sub REST API (the JSON's `"` cannot pass a `gcloud.cmd` argument on Windows), polls `gcloud logging read` for the dashboard's `received` line with that message's id, and asks you to confirm the row in the dashboard. `--step verify` repeats the smoke test only while it has not passed. A failure names `cloud-setup --step siblings.wire`, which comes after verify.
- `cloud-teardown`: `deadLetterTopicProduction` goes first, dropping its unacked dead letters, but only when it is ours; `deadLetterTopic` stays while any other subscription uses it as its topic or dead-letter topic; each sibling loses the dashboard's account from `PUSH_SERVICE_ACCOUNTS`, never its `[0]` (edit, commit and push prompt).

Without a dead-letter policy, a message the dashboard keeps failing on is dropped after 7 days. Alert on `pubsub.googleapis.com/subscription/oldest_unacked_message_age` for `deadLetterTopicProduction` (for example above one hour).

`create-star-app doctor --cloud`, in the dashboard or a project that lists one, reads `gcloud pubsub topics get-iam-policy deadLetterTopic` (the Pub/Sub service agent needs `roles/pubsub.publisher`) and each source subscription's `get-iam-policy` (`roles/pubsub.subscriber`). In the dashboard it also warns when `deadletter-run` holds `roles/pubsub.publisher` on the project or on `deadLetterTopic`. It prints the exact fix command (add or remove the binding), and an unreadable policy as "could not be read"; it never applies a fix.

## Usage

```
create-star-app [new] [NAME] [--dir P] [--port N] [--timezone N] [--region R] [--yes]
                [--force] [--force-templates | --force-templates-all] [--skip-doctor] [--skip-client] [--no-claude]
                [--link PATH] [--kind service|dead-letter]
create-star-app link PATH [--remove-example] [--no-appcreator] [--no-shared]
create-star-app doctor [--fix] [--json] [--cloud] [--project P]
create-star-app cloud-setup [--dry-run [--offline]] [--yes] [--non-interactive] [--step NAME | --from NAME | --list]
                [--project-id ID] [--region R] [--gh-owner O] [--vercel-scope T] [--atlas-org-id ID] [--no-shared]
create-star-app cloud-teardown [--dry-run] [--yes] [--keep-project] [--delete-github-repo]
create-star-app --version
```

**`new`** (the bare `create-star-app` is an interactive `new`):

- `new` with no NAME inside a project (any folder under the one holding `star-app.json`) resumes it with the values in `star-app.json`; a flag that disagrees with the record is refused.
- `new` with no NAME anywhere else scaffolds the current folder (`--dir P`: that folder) and never creates a deeper one. The project name is the folder's name split on `-`, `_`, `.` and spaces and camelCased (`star-blog-maker` -> `starBlogMaker`, `my_shop` -> `myShop`, `MyShop` -> `myShop`); it is printed and never asked for. A folder name that gives no valid name (it starts with a digit, is reserved, has no letters or is too long) is exit 2 before anything is written: rename the folder. Run it at a repository's root, not in a subfolder.
- The name rules: camelCase, starts with a letter, letters and digits only, at most 39 characters; `admin`, `local`, `config`, `test`, `client`, `server`, `venv` and `node_modules` are reserved. The npm package name is its lowercase.
- The folder may be empty or a fresh repository: only `.git`, `.gitattributes`, `.gitignore`, `README.md` / `README`, `LICENSE` / `LICENSE.md` / `LICENSE.txt`, `.github/` and `.vscode/` (no symlinks or junctions), and git must agree: its last commit tracks only those paths and `git status` shows no change (an untracked allowed file such as a new README is fine). A submodule is never fresh. Anything else is refused unless `--force` is given, and `--force` itself is refused (exit 2) for your home folder, a drive root, or a folder holding `.ssh`, `.aws` or `AppData`.
- In a fresh repository nothing is re-initialized: its history and remote stay; a branch other than `main` is renamed with `git branch -m main` only when there is no remote, no local `main` already and `.git` is a folder (otherwise it stays, with a warning); README, LICENSE and `.gitattributes` are never overwritten (a template of the same name is reported as `[kept]`). On the run that creates the project, its `.gitignore` keeps every line, gains the template's missing ones under `# create-star-app`, and ends with the secret ignores (`.env`, `*.env`, `.env.*`, `keys/`, `venv/`, `.vercel/`) so they win over any earlier `!` line (a `!.env.example` line no longer takes effect: rename the example file, e.g. `env.example`, to commit it). Later runs leave `.gitignore` alone. When the history ever added a file that looks like a secret, the git step names it (purge it before you push).
- The initial commit stages only the scaffold's paths (`star-app.json`, `.gitignore`, `server/`, `client/` and the other files it writes, never `git add -A`) and runs with `-c core.hooksPath=`, so no hook of the repository runs. Before it, git must ignore `server/.env`, `server/cloud.env`, `client/.env.local`, `server/keys/`, `server/venv/` and `.vercel/`; a `.gitignore` line that hides a file the project needs (GitHub's Python template has `lib/`, which matches `client/lib/`) gets a re-include such as `!/client/lib/`; and a staged path matching `*.env`, `.env.*`, `*.pem`, `keys/`, `venv/`, `.vercel/` or `node_modules/` is unstaged and nothing is committed. In a repository with history, the commit goes on top of it.
- `new NAME` creates `./NAME/`, except that it scaffolds the current folder when the folder is a fresh repository or its name derives to NAME. The project is then named after the folder; a NAME that differs (in case or separators, or entirely) is not used, and the name used is printed. Inside any other git repository or a project it is exit 2: run `new` with no name to scaffold the folder, or pass `--dir` for a folder outside it. `--dir P` scaffolds into `P`, which must not be inside another repository or project (`--dir ../deadletter` from inside a service is fine). In an empty folder outside any repository, `new NAME` still creates `./NAME/`.
- The GitHub repository `cloud-setup` creates is named after the folder, lowercased (`star-blog-maker`, `my_shop`, `my.shop`; the kebab of the project name when the folder's name has other characters), recorded at scaffold time as `cloud.github.repo` (on a resume too, until the first commit). A repository that already has an `origin` remote records nothing: `cloud-setup` adopts that remote's owner and name. Only `https://github.com/OWNER/REPO`, `git@github.com:OWNER/REPO` and `ssh://git@github.com/OWNER/REPO` origins are adopted; any other origin stops `github.repo` (a token in the URL is never printed). A repository name with capital letters is refused with a rename hint, because Cloud Build's `$REPO_NAME` keeps the case and the Artifact Registry image path must be lowercase: rename it on GitHub, then `git remote set-url origin <new url>`.
- In a terminal `new` asks two sections, and a bad answer is asked again: **App** (the project name it derived, then "Business timezone", default this computer's UTC offset such as `UTC+8`; a computer whose offset is not a whole hour gets UTC+8 and is told why) and **Local development** ("Server port", default 5000, or the next free one with `--link`). It does not ask for a region: `cloud-setup` asks for the server location and moves the files that name it. `--timezone` (whole hours -12..14: `8`, `-5`, `UTC+8`), `--port` (1024-65535) and `--region` (a GCP region; default `asia-east1`) still pre-answer them; under `--yes` or without a terminal nothing is asked and the defaults are 8, 5000 and asia-east1.
- Before writing anything, the machine checks of `doctor` run: a blocker (no Python venv, Node, npm, npx or git) stops the run with exit 2; in a terminal a fixable check is offered (y/N). `--skip-doctor` continues anyway.
- `--kind dead-letter` scaffolds the dead-letter dashboard (see "Add the dead-letter dashboard"; NAME then defaults to `deadletter`); `--kind service`, the default, is the base app. A resumed project keeps the kind in its `star-app.json`, and another `--kind` is refused.
- `--skip-client` leaves out `client/` (re-run without it later); `--no-claude` copies nothing from `~/.claude`.
- The steps: `record` (`star-app.json`, `.star-app/`), `dirs`, `client` (create-next-app 16.1.6; a `client/` without `package.json` is moved to `.star-app/trash/` and recreated), `client-deps` (npm install), `templates`, `claude` (`~/.claude/agents|context|templates` and the global deny rules; an invalid `~/.claude/settings.json` is a warning), `git` (`git init -b main`; in a repository that was there before, the branch check above), `venv` (`server/venv`, made with the Python running create-star-app), `pip`, `appcreator` (with the venv's Python; fails at once when nothing listens on 27017; skipped on a re-run that changed no server file), `commit` (only when every step finished and git `user.name`/`user.email` are set) and the next steps.

**`doctor`** checks the machine (and the project, inside one): Python, venv, Node, npm, npx, json2ts 15, git and its identity, the npm registry, mongod (and whether it is a replica set), the Mongo tools, the cloud CLIs (gcloud, firebase, atlas, vercel, gh), Docker, the ports and `~/.claude`; in a project the venv and its packages, `server/.env`, the client's `node_modules`, the generated files and the Firebase placeholders. `--fix` fixes only local things: the venv, pip, the global json2ts (after a y/N), `server/.env` (created, or missing lines appended; existing keys never change), `npm install` in `client/`, AppCreator. It never starts a service and never touches GCP, Atlas or Vercel. `--json` prints the report as JSON; inside a project the last report is kept in `.star-app/local.json`.

**`cloud-setup`**, **`cloud-teardown`** and **`doctor --cloud`** are described under "Deploy in 15 minutes". `doctor --cloud` is read-only. It adds the cloud CLIs with their versions and sign-ins, whether the resources `star-app.json` names still exist, and, for linked and dead-letter projects, the Pub/Sub forwarding permissions. It prints the fix command and never runs it.

**Exit codes**:

| Code | Meaning |
|---|---|
| 0 | done (warnings allowed) |
| 1 | `new`: the scaffold was written but a step failed or was skipped, and the summary names it and the resume command. `doctor`: a check failed. `cloud-setup` and `cloud-teardown`: a step failed or is blocked. |
| 2 | bad input or a blocker, before anything was written |
| 75 | `cloud-setup` only: waiting on a browser step; run the same command again |
| 130 | Ctrl-C |

**Who owns a file.** A file is one of two kinds, and `create-star-app new` treats them differently on a re-run.

| Kind | Examples | On a re-run |
|---|---|---|
| User-owned, created once | `server/.env`, `objects.py`, `ApiRequests.py`, `roles.py`, `services/*`, `actions/*`, `client/app/page.tsx`, `CLAUDE.md` | never rewritten, so your work and the JWT secret stay |
| Managed | shared tests, `cloudbuild.yaml`, `tools/*`, the generated helpers | rewritten only while unchanged since create-star-app wrote it; a file you edited is reported as `[kept]` |

`--force-templates` overwrites the edited managed files. `--force-templates-all` also rewrites the user-owned ones, keeping the old copies in `.star-app/trash/<time>/`, and rotates the JWT secret, which signs everyone out.

**What a re-run may change.** Files the project owns are created once and never rewritten (so `server/.env` and its JWT secret stay): `server/.env`, `client/.env.local`, `client/lib/firebase.ts`, `server/sharedConstants.py`, `server/ApiRequests.py`, `server/PubSubRequests.py`, `server/objects.py`, `server/roles.py`, `server/AuthHandler.py`, `server/services/*`, `server/actions/*`, `server/conftest.py`, `server/AppCreator.py`, `server/route_config.py`, `server/pubSub.py`, `server/mongoDb.py`, `server/AppConfig.py`, `server/utils.py`, `server/Dockerfile`, `server/requirements.txt`, `client/app/page.tsx`, `client/app/layout.tsx`, `client/lib/*.tsx`, `client/lib/*.ts`, `client/app/components/*`, `CLAUDE.md`, `.gitignore`, `.vscode/tasks.json`, `githubGuide.md`, `.coderabbit.yaml`, `.firebaserc`, `storage.rules`, `firebase.json` (a `*` covers everything below that folder, except the shared `server/services/__init__.py` and `server/services/__request.py`); `.claude/settings.json` only gains missing deny rules. Every other file is managed: `.star-app/files.json` holds the hash of each write, and a re-run rewrites a managed file only when it is missing or unchanged since; a modified one is listed as `[kept] <path> (modified locally; --force-templates to overwrite)` and a symlink as `[refused]`. `--force-templates` overwrites the modified managed files; `--force-templates-all` also rewrites the project-owned ones, keeping the old copies in `.star-app/trash/<time>/` (a new JWT secret included, which signs everyone out). AppCreator re-runs while `.star-app/appcreator-pending` exists (a server file changed since its last successful run). The initial commit is skipped (with what to do) when git does not ignore a secret path, when `.gitignore` hides a file the project needs, when a forbidden path would be staged, or when `--force` scaffolded into a folder that held other files (`.star-app/forced-nonempty` remembers it, so a re-run never commits there either).

**`star-app.json`** (project root, committed) records `projectName`, `npmName`, `port`, `timezone`, `gcpRegion`, the folder layout, the create-star-app versions that created and last changed the project, `kind` (absent means `service`), `siblings[]` once linked, and the `cloud` section `cloud-setup` fills (project, service, trigger and Atlas ids, a status per step, `*Created` flags that tell `cloud-teardown` what it may delete, `shared`, `siblingsWired`); never a secret. It is the only file `cloud-setup` and `link` read to find a project. Secrets go to `server/cloud.env`. `.star-app/` (ignored by its own `.gitignore`) holds `files.json`, `local.json` and `trash/`.

## Checked on the first real run

The cloud steps were written from the CLIs' documentation and tested against scripted output. These behaviours are not yet observed on a real account. The first run on a throwaway project settles each one, and then this list is deleted. When one is wrong, the step stops with the command and what it saw.

- **Next.js client.** `create-next-app@16.1.6` accepts `--no-react-compiler`; Node 20.9 is the right floor.
- **gcloud.** Version 500 is the first with GA `--startup-probe`, `--cpu-boost` and `builds connections`. `gcloud beta services identity create` works for Pub/Sub. `builds submit --service-account` needs no extra staging-bucket permission. `triggers update --update-substitutions` works. `$REPO_NAME` is set on second-generation repositories.
- **Firebase CLI.** `login:list --json` and `projects:list --json` have the shape the code reads. `apps:create --non-interactive` does not stop at the Hosting prompt. `apps:sdkconfig --json` has the expected shape.
- **Atlas CLI.** `atlas auth whoami` reports the sign-in. `atlas setup --projectId`, `availableRegions` and the region map are right. `dbusers create` accepts the role token `readWrite@<db>`. `connectionStrings describe` has the field the code reads. `atlas clusters watch` behaves on a delete.
- **Vercel CLI.** `vercel link --yes` creates the project. The CLI cannot set Root Directory, so that stays a browser step. `vercel git connect --yes`, `vercel project inspect` (JSON) and `vercel project rm` behave as assumed.
- **Dead-letter dashboard.** `gcloud pubsub subscriptions update` accepts `--push-auth-token-audience`, `--expiration-period=never`, `--message-retention-duration` and `--clear-dead-letter-policy` together. `roles/pubsub.viewer` includes `topics.subscriptions.list`. The unquoted Logging filter values in the smoke test are read as intended.

## Development

```bash
pip install -e ".[dev]"
python -m pytest            # the CLI's own suite: no network, no real process
python -m create_star_app.cli --help
```

## Shared files

This repo is the source of truth for the server files every Star App project keeps identical: the generic tests (`test_file_sizes.py`, `test_route_coverage.py`, `test_mock_drift.py`, ...), `servicePortCheck.py`, `responseModelCheck.py`, `sharedConstantsTS.py`, the Pub/Sub map tools, `sharedFileCheck.py` itself, `pubSubAdmin.py`, the `tools/` job scripts, the deploy build (`cloudbuild.yaml`, `tools/updateJobsFromService.py`, `test_cloudbuild.py`), the deploy gate `checkPubSubTopics.py` and the predeploy job `job_predeploy.py` (`test_job_predeploy.py`), and the test-run tooling (`testRamGate.py`, `testServices.py`, `services/__request.py`, `test_xdist_workers.py`, `test_testServices.py`). `fleet/sharedFiles.json` lists them, each `exact` or `regions` (only the `# >>> star-app shared: <name>` ... `# <<< star-app shared: <name>` regions are shared). A `# >>> star-app per-repo: <name>` block inside either keeps each project's own content. A project's `server/sharedFileCheck.py` and `test_sharedFiles.py` compare its copies with `../create-star-app` at its committed HEAD, rendered as scaffold.py renders them; with no checkout there, they skip with a reason.

To change a shared file, or fold back a fix made in one project:

1. Edit the template's `.tmpl` (a literal `$` is `$$`) or the `fleet/` file. `cd server && python sharedFileCheck.py --template-worktree` in a project previews the result.
2. Commit it here.
3. Run `cd server && python sharedFileCheck.py --write` in every fleet project and commit there.

Never edit one project's copy alone. `fleet/` sits outside the `create_star_app` package (package data is `templates/*`), so the manifest and the fleet-only files (`apiNamespace.py`, `test_objects_package_exports.py`) never ship to PyPI.

Not enforced yet, because the copies have diverged (converge one, then add it to the manifest; until then fold back by hand): `AppCreator.py`, `conftest.py`, `route_config.py`, `pubSub.py`, `mongoDb.py`, `AppConfig.py`, `roles.py`, `AuthHandler.py`, `utils.py`, `services/__abstractService.py`, `services/__getIsProductionEnv.py` and `services/AllServices.py`, the typed-response test files, `Dockerfile` and `requirements.txt`.

## Changelog

### 1.38.3 — "Cannot find service" means not created yet

- **cloudrun.service and cloudrun.predeployJob read gcloud's "Cannot find service [x]" / "Cannot find job [x]" as missing** and deploy, instead of blocking with "could not read Cloud Run service". The one not-found test the steps and cloud-teardown share (`common_b.not_found`) now knows "Cannot find service" / "Cannot find job" as well as gcloud's `NOT_FOUND`, Atlas's `PROJECT_NOT_FOUND` / `CLUSTER_NOT_FOUND` and Vercel's "not found". It never takes a refusal (`PERMISSION_DENIED`, "does not have permission ... (or it may not exist)"), a CLI that did not run (rc -1: not on PATH, timed out), or a gcloud crash ("The system cannot find the file specified") for a missing resource.
- **cloud-teardown never reports a resource `[gone]` when its CLI did not run** (missing or timed out): it is `[FAILED]`, not deleted, and the run exits 1.
- **gcp.project never replaces or re-labels a recorded project.** A failed `gcloud projects describe` (an auth refresh, the network) of a project star-app.json says exists (its number recorded, `projectCreated` true or false, or the step done) is BLOCKED with the error instead of MISSING, so cloud-setup cannot create a second project. A create never overwrites `projectCreated` true or false with "creating" (an adopted project stays adopted, so cloud-teardown keeps it), and a recorded id that turns out taken is an error, not a fresh id; only an id suggested in this run is swapped.

### 1.38.2 — no gcloud beta component needed

- **`gcp.serviceAccounts` makes the Pub/Sub service agent through the Service Usage API** (`POST https://serviceusage.googleapis.com/v1beta1/projects/<projectNumber>/services/pubsub.googleapis.com:generateServiceIdentity`, with the gcloud account's bearer token, polling the long-running operation) instead of `gcloud beta services identity create`. gcloud's beta component is often not installed, and gcloud's own attempt to install it fails without an administrator on an SDK installed with winget under Program Files, which stopped the step. An agent that exists already (`service-<projectNumber>@gcp-sa-pubsub.iam.gserviceaccount.com`) is accepted. cloud-setup no longer runs any `gcloud beta` or `gcloud alpha` command.

### 1.38.1 — e-mail addresses compare case-insensitively, readable choice lists, `pubSubAdmin.py` shared

- **`gcp.project` accepts a project you own when IAM lists your address in another case** (`user:Ladia@gmail.com` for the account gcloud reports as `ladia@gmail.com`): it was refused as "not its owner", in the step and in the interview's "Use which project?". Every account and IAM member comparison in cloud-setup and `doctor --cloud` now goes through `cloud/steps/util.py` (`has_member`, `same_email`, `email_key`): the project owner check, the service accounts and their project and account-level bindings, the Pub/Sub publisher binding, the dead-letter forwarding check (`doctor --cloud dead-letter-iam`), the dashboard's push-slot refusal, the dashboard service's runtime account, and the public `allUsers` invoker. What cloud-setup writes into IAM bindings keeps its case.
- **Choice lists show names, never a bare id**: each numbered pick reads "<name> (<id>)" (the id alone when the read gives no name) and records only the id: the billing account (interview and `gcp.billing`, gcloud's `displayName`; two accounts of the same name still differ by id), the GCP organization (`displayName`), the Atlas organization when the project create needs one, the Atlas M0 region (the server-location city, e.g. "Taiwan (EASTERN_ASIA_PACIFIC)") and the Vercel team (its name from `vercel teams ls`). The GCP project, Atlas project, server location and GitHub owner picks already did.
- **`pubSubAdmin.py` is a shared fleet file** (exact in the fleet manifest, managed in a new project instead of project-owned), so the fleet runs the one `test_pubSubAdmin_noninteractive.py` pins: the `--project P --service S --yes` / `--dry-run` mode, exit 1 on failures, and the Pub/Sub service agent's publisher role on the dead-letter topic (the fleet copies granted it on the source topic; those old grants are harmless and are not removed). The dead-letter topic's policy is read once a run and written only when the grant is missing (each subscription's likewise), and a write refused for a stale etag (ABORTED, a concurrent run) is retried once on a fresh read. `--project P` takes P's App Engine default account (`P@appspot.gserviceaccount.com`, the fleet's push account) as well as `<name>@P.iam.gserviceaccount.com`, and still refuses any other project's. Its scan for `@pubSubDecorator` functions still walks all of `server/` (any subfolder may hold them) but skips `venv`, `node_modules`, `__pycache__` and every dot-folder (`.venv`, `.pytest_cache`, ...), so a virtualenv inside `server/` is never parsed. The region comes from `AppConfig().gcpRegion`; a project whose `AppConfig.py` predates it adds `self.gcpRegion = os.getenv('GCP_REGION', '<region>')`.
- **cloud-setup offers to install or update the CLIs it drives**, before the interview, as `new` does (one [Y/n] question each, then the PATH refresh; the preflight step checks again): a missing gcloud (winget `Google.CloudSDK` / `brew install --cask google-cloud-sdk`), gh (winget `GitHub.cli` / `brew install gh`), Atlas CLI (winget `MongoDB.MongoDBAtlasCLI` / `brew install mongodb-atlas-cli`) or Vercel CLI (`npm install -g vercel`); a gcloud older than 500.0.0 ("gcloud is <v>, older than 500.0.0. Update it now?" runs `gcloud components update --quiet`; a winget-installed SDK may need an elevated terminal, and a failure prints the command); a firebase-tools of another major, or one too slow to print its version, gets `npm install -g firebase-tools@15`. `doctor --fix` (with `--cloud`) offers the same; `new` offers only the local tools (Node, git, MongoDB, Java, firebase-tools for the emulators), never the deploy CLIs. Only in a terminal, never with `--yes`, `--non-interactive` or `--dry-run`; Linux prints the commands only.
- **A login prints what to answer the CLI's own questions first** (the interview's sign-in and the `auth.*` steps; not in a dry run): `atlas auth login`'s default organization and project (any answer: cloud-setup asks for the Atlas project itself later, by name) and output format (plaintext, just press Enter: cloud-setup asks for JSON on its own commands); a default project gcloud asks for or suggests (any answer: cloud-setup passes `--project`); gh's account, Git protocol and Git credentials (GitHub.com, HTTPS, Yes, so the push uses that sign-in); Firebase's usage reporting (either answer).
- **`sharedFileCheck.py` validates the scheduler service account block** of `create_or_update_job.ps1` like the region block: `# >>> star-app per-repo: scheduler service account` may hold only one `$SchedulerServiceAccount = "..."` line, empty, `"$Project@appspot.gserviceaccount.com"` or `"<name>@<project>.iam.gserviceaccount.com"`; anything else is drift and `--write` takes the template's line back.

**Upgrading to 1.38.1.** Fleet repos: `cd server && python sharedFileCheck.py --write` takes the shared `pubSubAdmin.py` and the new `sharedFileCheck.py`. `pubSubAdmin.py` reads `AppConfig().gcpRegion`, so it must exist: add `self.gcpRegion = os.getenv('GCP_REGION', 'asia-east1')` (your region) to `AppConfig.py` where it is missing.

### 1.38.0 (draft) — one command: `new` scaffolds the folder it runs in, a project menu, `cloud-setup` asks up front, `dev` runs it locally with the Firebase emulators

- **Missing tools are offered for install** (`doctor.offer_installs`, the `tools.INSTALLERS` table): one [Y/n] question per missing tool in the machine check of `new`, in `doctor --fix` and before `dev`; winget on Windows, Homebrew on macOS, npm for the Firebase CLI; MongoDB is started afterwards and PATH re-read from the registry and the known install folders. Never under `--yes`, without a terminal, on Linux or for Python.
- **Run it locally with no cloud account: `create-star-app dev`** (see "Run it locally") starts the Firebase Auth and Storage emulators, Flask and Next.js in one terminal, after checking mongod, Java 21+ and the ports. A new project signs in to a `demo-<name>` Firebase project (`sharedConstants.FIREBASE_PROJECT_ID`, `.firebaserc`) through the Auth emulator; `firebase.json`, `.firebaserc` and `storage.rules` are new project-owned scaffold files (`.firebaserc` and `sharedConstants.py` get the project's real Firebase id when star-app.json or sharedConstants already has one), and `.firebase-emulator-data/` is gitignored. `client/lib/firebase.ts` connects to the emulators in localdev. `AuthHandler.py` accepts the emulator's unsigned tokens only in localdev with `FIREBASE_AUTH_EMULATOR_HOST` on a loopback host (`emulatorTokensAccepted`), never in a cloud environment. `doctor` has a `java` check (a warning; a blocker for `dev`), and `doctor.DEV` holds `dev`'s pre-checks. cloud-setup has a new step before `firebase.webApp` (so the web app's config carries the bucket), `firebase.storage`: the default Storage bucket (Firebase Storage API, in the Cloud Run region; a console pause if the API refuses), `storage.rules` deployed with `firebase deploy --only storage`, `.firebaserc` naming the project and `cloud.firebase.storageBucket`; in shared mode the bucket and its rules are the creator's. Existing projects keep their files (user-owned); copy `firebase.json`, `.firebaserc`, `storage.rules` and the new `client/lib/firebase.ts` and `server/AuthHandler.py` from a fresh scaffold to use the emulators.

- **`create-star-app new` scaffolds the current folder** and names the project after it (`star-blog-maker` -> `starBlogMaker`, `names.project_name_from_folder`). The name prompt is gone; a folder name that gives no valid project name is exit 2 with a message to rename the folder. Inside a project it still resumes. The way to start is now: create the repository (GitHub Desktop, `gh repo create --clone`, or `mkdir`), open its folder, run `create-star-app new`.
- **A fresh repository is scaffolded in place** without `--force`: `.git`, `.gitattributes`, `.gitignore`, a README, a LICENSE, `.github/` and `.vscode/` may be there, git must agree (HEAD tracks only those, no change in `git status`), and a submodule or a link out of the folder is refused. Its history and remote are kept (no `git init`); an unpublished branch other than `main` is renamed with `git branch -m main`, never over an existing `main` or in a linked worktree; README, LICENSE and `.gitattributes` are never overwritten; on the first run the `.gitignore` keeps its lines, gains the scaffold's missing ones under `# create-star-app` and ends with the secret ignores, so no `!` line can expose a secret; a history that ever added secret-looking files is reported; the initial commit lands on top of the existing history (`.star-app/adopted-repo` marks it pending).
- **The scaffold commit is scoped**: it stages only the scaffold's own paths (never `git add -A`), runs no repository hook (`-c core.hooksPath=`), re-includes a file the repository's `.gitignore` hides (`!/client/lib/` for GitHub's Python template), and refuses to commit while git does not ignore a secret path or when a forbidden path (`*.env`, `.env.*`, `*.pem`, `keys/`, `venv/`, `.vercel/`, `node_modules/`, `.firebase-emulator-data/`) is staged. Every template target the repository's `.gitignore` hides is re-included (a `*.bat` line no longer hides `server/tools/*.bat`); folders are probed without a trailing slash, because git 2.46 for Windows calls `dir/` ignored under a CRLF `.gitignore` with a blank line. Paths are read and unstaged by their raw names (`-z`, a NUL pathspec list), and the secret rules ignore letter case (`X.ENV`, `cert.PEM`, `Keys/`). `--force` is remembered in `.star-app/forced-nonempty` and in `star-app.json` (`scaffold.forcedNonempty`): such a folder is never committed by create-star-app (once you commit `star-app.json` yourself, the warning stops), and `--force` is refused for the home folder, a drive root and a folder holding `.ssh`, `.aws` or `AppData`. A `.gitignore` that was there before the scaffold is merged until the first commit (`.star-app/gitignore-merge`), even when the first run stopped early. `cloud-setup`'s `github.repo` checks staged and tracked files against the same list (`setup_record.forbidden_commit_path`), refuses an origin whose push URL (`pushurl`, `pushInsteadOf`) is another repository, and pushes nothing while the history (any branch) ever added a secret-looking file: it pauses in a terminal with the `git filter-repo` fix, and stops otherwise. `new NAME --dir .` names the project after the folder, like `new NAME` in a fresh repository.
- **`new NAME` never nests**: inside a git repository or a project it is exit 2 before anything is written, naming `new` with no name (and the name the folder gives) and `--dir`. `new NAME` in a fresh repository, or in a folder whose name derives to NAME, scaffolds that folder under the folder's name and prints it. Outside any repository `new NAME` and `new NAME --dir P` work as before; `--dir` is refused inside another repository or project. `new --kind dead-letter` in a repository folder scaffolds that folder. `cloud-setup`'s closing dead-letter hint now runs `new deadletter --kind dead-letter --dir ../deadletter --link .` (the dashboard is a sibling folder), and so do the dry-run goldens.
- **`new` asks App, then Local development**: the business timezone (default this computer's whole-hour UTC offset, else UTC+8 with the reason) and the server port. The GCP region question is gone from `new`; `cloud-setup` asks "Server location" (a city list, Taiwan (asia-east1) ... São Paulo (southamerica-east1) or a typed region, default nearest the business timezone; Hong Kong is marked: no free Atlas cluster there) once, before anything exists in the cloud, never in shared mode. When it differs from what `new` rendered, GCP_REGION in server/.env, AppConfig's default, `$Region` in the three job .ps1 tools and star-app.json `gcpRegion` follow it (only those strings change; `.star-app/files.json` stays in step, so a later `new` sees the tools untouched; `regions.apply_region`). `--region` does the same without the question.
- **Shared .ps1 tools: `$Region` is per-repo.** `create_or_update_job.ps1`, `run_job_once.ps1` and `update_all_jobs.ps1` wrap their `$Region` line in `# >>> star-app per-repo: region`, so `sharedFileCheck.py` masks it and `--write` keeps each repo's region. `sharedFileCheck.py` (shared) does not just mask that block: it may hold exactly one line `$Region = "<gcp region>"`, and anything else in it is drift (`--write` then takes the template's line back), so no code can hide in a masked block of a script run against production. `create_or_update_job.ps1`'s `# >>> star-app per-repo: scheduler service account` block is validated the same way: exactly one line `$SchedulerServiceAccount = "..."`, empty, `$Project@appspot.gserviceaccount.com` or `<name>@<project>.iam.gserviceaccount.com`. Fleet repos: run `cd server && python sharedFileCheck.py --write` once to take the markers and the new `sharedFileCheck.py` (the region line is rendered from the template, asia-east1; edit it back if a repo deploys elsewhere).
- **Recorded values are checked when read**: `cloud-setup` and `cloud-teardown` stop with exit 2 ("star-app.json <key> is not valid: <reason>") on a `gcpRegion`, `cloud.gcp.region`, `cloud.gcp.projectId`, `cloud.github.owner`, `cloud.vercel.scope` or `cloud.atlas.orgId` that its flag's rule would refuse, and a resumed `new` refuses an invalid `gcpRegion` or `projectName`, so a crafted committed star-app.json never reaches a CLI argument or a rendered `.ps1` file. Project names refuse the Windows device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9). The dashboard's `new` from the end of `cloud-setup` runs from the parent folder with absolute `--dir`/`--link` and `python -P` (3.11+). The interview checks the Gmail app password when it is typed (asked again if `server/cloud.env` cannot hold it; e-mail off when git does not ignore `server/cloud.env`), and a failure while saving the answers says where it stopped and what was saved.
- **`create-star-app` is the only command to remember.** Inside a project the bare command is a menu (run the app locally, finish local setup, deploy, doctor --fix, a second service, the dead-letter dashboard, the cloud status, teardown), whose default follows the state (finish local setup while it is unfinished, then run the app locally until the cloud is set up, then the cloud status); without a terminal it prints the commands and exits 2. A finished `new` asks "Deploy it to the cloud now? [y/N]" with one line on what each answer means: yes runs `cloud-setup` in the same process, no offers "Run the app locally now? [Y/n]" (the `dev` command).
- **Review round 5.** A resumed `new` refuses a `port` or `timezone` in star-app.json that is not a whole number in range (both are rendered as code). `cloud-setup` checks this project's own cloud values with the same rule table as a sibling's (`shared.value_problems`; nothing may start with `-`). An error inside the interview is one line and exit 2 (75 for a browser pause), and "Use which project?" offers "create a new project instead" as its Enter default. A `.gitignore` line hiding a folder outside server/ and client/ (`.vscode/`) is opened only for the scaffold's files (`!/.vscode/`, `/.vscode/*`, `!/.vscode/tasks.json`); the merge marker goes once the templates step merged. A signed-out CLI's settings show as "decided after you sign in to <tool>". The menu builds absolute paths from the project root. A resumed `new` of a deployed project does not ask to deploy; the dashboard created at the end of `cloud-setup` (`new --no-deploy-offer`) is offered "Deploy the dashboard now? [y/N]" by the parent instead; on Python 3.10 it runs from an empty temp folder.
- **Push-time safety (cloud-setup's `github.repo`):** a new repository is created without `--push` and, like an adopted one, pushed only after its push URLs are checked; a `git log` that fails while checking the history for secrets stops the push ("could not check the history for secrets"); `.env.example`, `.env.sample` and `.env.template` (any case) count as harmless example files.
- **`cloud-setup` interviews first** (`cloud/interview.py`): sign-in phase, every setting (GCP project, server location, billing, Atlas organization and project, GitHub owner and repository name, Vercel team, the dashboard's e-mail), a summary with the browser steps ahead, "Start? [Y/n]", then the run, in which no step asks again (`ctx.interviewed`). The step-level questions below remain for `--step` / `--from` runs.
- **`cloud-setup` asks instead of needing flags** (see "Deploy" under "Deploy in 15 minutes"): the GCP project (new or one of yours), signing a CLI in for you (its login runs attached to the terminal, `Shell.attached`), the GitHub owner, the Vercel team, the Atlas organization and an existing Atlas project, and at the end the dead-letter dashboard. Each question is asked once, in a terminal only, and recorded (`cloud.github.owner`, `cloud.vercel.scope`, `cloud.atlas.orgId` and the project ids are new or newly written keys); `--yes`, `--non-interactive` and `--dry-run` ask nothing, so the dry-run goldens are unchanged. The numbered questions (`steps/util.pick`) take Enter for the default, re-ask a number out of range and can offer "type a value". `cloud-teardown` asks "Keep the GCP project and delete only this app's resources? [y/N]" and "Also delete the GitHub repo? [y/N]" in a terminal when `--keep-project` / `--delete-github-repo` are not given, before the typed project id (like that confirmation, they are asked even with the `--yes` a teardown requires).
- **One name for the folder and the GitHub repository**: `new` records `cloud.github.repo` (the folder's name lowercased when it is lowercase words joined by `.`, `_` or `-`, else the kebab of the project name) when the repository has no remote, and `cloud-setup`'s `github.repo` creates that repository; an existing `origin` is adopted as before, now only from a strictly parsed github.com URL (anything else is blocked, and a token in the URL is masked). Projects without the record default to the folder's name too (`names.default_github_repo`; it used to be the lowercased project name, the same for a folder named like the project).

**Upgrading to 1.38.0.**
1. Fleet repos (the `create-star-app` checkout at `../create-star-app`): `cd server && python sharedFileCheck.py --write` once. It takes the new `sharedFileCheck.py` (the validated `region` block) and the `# >>> star-app per-repo: region` markers of `create_or_update_job.ps1`, `run_job_once.ps1` and `update_all_jobs.ps1`; the region line is rendered as asia-east1, so a repo that deploys elsewhere edits that one line back.
2. A project scaffolded with 1.36.0 or 1.37.0 keeps its user-owned files, so to use the emulators (`create-star-app dev`) copy these from a fresh scaffold of the same name and port: `server/AuthHandler.py` (emulator tokens in localdev only), `client/lib/firebase.ts` (connects to the emulators in localdev), `server/sharedConstants.py` (its `FIREBASE_PROJECT_ID`: keep your real id if cloud-setup already set one), and the new `firebase.json`, `.firebaserc` and `storage.rules`; add `.firebase-emulator-data/` to `.gitignore`. Then run `create-star-app` in the project: the menu takes it from there.
3. An existing `cloud-setup` project gets the new `firebase.storage` step on its next `cloud-setup` run; star-app.json values are now checked when read, so fix any the run names ("star-app.json <key> is not valid").

### 1.37.0 (draft) — `cloud-setup`, linked services, the dead-letter dashboard

**Deploy**
- **`create-star-app cloud-setup`** takes a project to a deployed service in about 15 minutes (see "Deploy in 15 minutes"). Its steps cover sign-in, the GCP project, billing, APIs, the three service accounts, Artifact Registry, an Atlas M0 cluster and users, the secrets in `server/cloud.env`, the GitHub repository, Cloud Run (service, startup probe, `<service>-predeploy` job), Pub/Sub topics, Cloud Build (connection, repository link, trigger `<service>-main`), Firebase (web app, Google sign-in, authorized domains), Vercel and an end-to-end `verify`. It is resumable (`--step`, `--from`, `--list`), previews with `--dry-run [--offline]`, pauses for the browser steps (exit 75 when it cannot wait) and records everything but secrets in `star-app.json`.
- **`create-star-app cloud-teardown`** deletes only what `cloud-setup` created (the `*Created` flags in `star-app.json`): an adopted GCP project, Atlas project or cluster, Vercel project or Cloud Build connection is kept. Jobs and topics go by exact name, all listed before the confirmation. `--keep-project` keeps the GCP project and the Atlas project and cluster and deletes only this service's resources in them (service, jobs, topics, service accounts, `<service>-app` and `<service>-reader`). `--delete-github-repo` is opt-in.
- **`create-star-app doctor --cloud`** adds read-only checks for the cloud CLIs, the resources in `star-app.json` and the dead-letter forwarding permissions.
- **`server/tools/createOrUpdateJob.py`** (new shared file, exact, with `test_createOrUpdateJob.py`) creates or updates one Cloud Run job with the service's image, env vars (`--env-vars-file`) and service account, and optionally its Cloud Scheduler trigger. `create_or_update_job.ps1` keeps its pickers and review step and calls it, and `cloud-setup` uses it for `<service>-predeploy`. `updateJobsFromService.py` gains `describeService`, `serviceEnv` and `jobCommandArgs` (shared with the new tool), and its `gcloud()` refuses an argument holding `" % ^ & | < > !` or a line break when gcloud is a `.cmd` shim.
- **Scheduler identity.** `--scheduler-service-account` sets it. Without it, an existing scheduler keeps its account and a new one gets the service's runtime account, which needs `roles/run.invoker` on the job.
- **Atlas users are per service**: `<service>-app` with `readWrite@<projectName>` (`MONGO_URI`) and `<service>-reader` with `read@<projectName>` (`MONGO_DUMP_URI`); no shared `reader`.
- **`.gitignore`** gains `.vercel/` (the Vercel project is linked at the repository root, Root Directory `client`).

**Linked services**
- **`create-star-app link PATH` and `new NAME --link PATH`** (see "Add a second service") wire two projects both ways: service files, `AllServices.py`, `.env` flags, the new `CLAUDE.md` local-ports block, `callerAuth.py` accounts, `star-app.json` `siblings[]` and `cloud.shared`, and AppCreator in both. They are idempotent and commit nothing. `test_route_response_models.py`'s sample publisher now takes the first registered service as its consumer instead of importing `exampleService`, so a project without the example still passes.
- **Shared-mode `cloud-setup`**: the second service adopts the first one's GCP project, Atlas cluster, Firebase web app and Cloud Build connection (`cloud.shared`, refreshed from the siblings at every run; `--no-shared` opts out), gets its own two Atlas users scoped to its own database, and ends with the new step `siblings.wire` (env vars, `SERVICE_CALLER_ACCOUNTS`, the publisher's push account in the consumer's `PUSH_SERVICE_ACCOUNTS`, the `test_callerAuth.py` mirrors, push subscriptions). `cloud-teardown` keeps the shared resources and unwires the siblings.
- **`pubSubAdmin.py`** gains a non-interactive mode, `--project P --service S --yes` (or `--dry-run`): no pickers and no prompt, the full plan applied, a consumer the service has not enabled (`PUBSUB_ENABLED_*` not true) skipped, a missing `deadLetterTopic` created instead of failing every subscription, the last-used cache left alone. A service env `GOOGLE_CLOUD_PROJECT_ID` of another project or a `PUSH_SERVICE_ACCOUNTS[0]` outside the project stops it. In both modes an auth or transient GCP failure now exits 1 (it used to print and exit 0), and the Pub/Sub service agent's publisher role goes on the dead-letter topic (it went on the source topic). Without flags it otherwise behaves as before. `pubSubAdmin.py` is not in the manifest yet: copy the new `main()` by hand. **`test_pubSubAdmin_noninteractive.py`** (new shared file, exact) pins the mode and needs that `pubSubAdmin.py`, so take both together.
- **`test_callerAuth.py`** (shared, exact): `test_thisReposAccounts_areTheExpectedOnes` compares the two `EXPECTED_*` mirrors first. It skips its non-empty `PUSH_SERVICE_ACCOUNTS` check only while the `star-app.json` at the repository root (the parent of `server/`, read directly, no upward search) names no GCP project (`cloud.gcp.projectId`) or records `cloud.tornDown`, so a new project's own suite passes. A repo without `star-app.json` (every fleet repo) keeps the check.

**Dead-letter dashboard**
- **Project kinds** (`new --kind dead-letter`, see "Add the dead-letter dashboard"): `templates/kinds/<kind>/` overlays the base scaffold with whole files, patches (`@@@ replace` / `@@@ append` hunks that must apply exactly) and new files, under the same ownership rules. `star-app.json` gains `kind` (`service` when absent). The dashboard is ported from DeadLetterDashboard onto today's conventions (response models, roles on every route, the generated client, the unified Snackbar).
- **`cloud-setup` in the dashboard**: the new step `deadletter.subscription`, the dashboard's variants of `siblings.wire`, `verify` and `cloud-teardown`, the runtime account's roles, and `doctor --cloud`'s `dead-letter-iam` check. A step can belong to one project kind (`Step.kinds`); the runner leaves it out of other projects entirely.
- **AppCreator** (not shared; copy the `createDeadLetter` branch by hand if a repo has that receiver): the generated `createDeadLetter` route reads the push envelope defensively (a missing or mistyped part is None, a payload that does not decode arrives as its text, a non-string messageId is ignored) and binds `originalTopicPath` and `sourceSubscription` (`CloudPubSubDeadLetterSourceSubscription`) by parameter name. The fleet's own `deadletter` service is unchanged.

**MongoDB connections and the dump script**
- **One real MongoDB client per cloud process** (the services now share one Atlas Flex cluster, which caps connections; each has its own database and user). `mongoDb.py`: a `connect` parameter on `mongoDb()` reaches every `MongoClient`; `PubSubMockDb` is built with `connect=False`; a missing or empty `MONGO_URI` fails fast; a second `mainDb()` / `TestDb()` / `PubSubMockDb()` call returns early (`_initialized`) instead of building and leaking another client. `pubSub.publishMessage` refuses `publishToPubSubMockDb` in every cloud environment (it refused only cloudprod), read at publish time. `test_route_response_models.py` pins both, and `conftest.py` also scrubs `MONGO_DUMP_URI`. `ARCHITECTURE.md` and `CLAUDE.md` gain the connection budget (about 6 monitor sockets per connected client on a 3-member set plus pool sockets on demand, times instances, gunicorn workers and job tasks; the single sync worker stays single; min-instances above 0 holds its share; no `maxPoolSize`; cloud-setup's `--max-instances=10`).
- **`test_mongoDb_cloud_clients.py`** (new shared test, exact): in both cloud environments exactly two clients are built (`db` connecting at once, `pubSubMockDb` with `connect=False`), no `testDb`, `tz_aware` everywhere, no client attribute read during import, both environments read `MONGO_URI`, and a missing or empty one fails fast. Two per-repo blocks hold what differs: `REPO_ENV` (other variables mongoDb reads at import) and a repo's own pins. The fail-fast match accepts both the template's message and the fleet's `... for clouddev`.
- **`dumpProdDataToLocalDev.cmd`** (not shared; copy by hand) takes its source from the first of three: `MONGO_DUMP_URI` from the environment, the `MONGO_DUMP_URI=` line of `server\cloud.env` (written by cloud-setup), or `MONGO_DUMP_HOST` + `MONGO_DUMP_USER` (environment, else the script's defaults, empty in a new project). With none it names all three and exits 1. On the host and user path it builds no URI with a password: mongodump asks for the password itself (masked). With a connection string the password is on mongodump's command line, so that URI is a read-only user's. The script starts with `setlocal`, always dumps with `--db` (the cluster is shared), prints `Dumping from <host> as <user>` and never the password, and clears the connection string before mongosh and mongorestore run. The dump folder is named from the rendered project name, so a failed lookup can no longer make `rmdir /s /q` target the shared `C:\backup\mongodump` parent.

**Upgrading a fleet repo from 1.36.0.** In every repo (the `create-star-app` checkout at `../create-star-app`):
1. `cd server && python sharedFileCheck.py --write` takes the changed shared files: `tools/createOrUpdateJob.py` and its test, `tools/create_or_update_job.ps1` and `.bat`, `tools/updateJobsFromService.py`, `test_callerAuth.py`, `test_mongoDb_cloud_clients.py` and `test_pubSubAdmin_noninteractive.py`.
2. The fleet's `test_mongoDb_cloud_clients.py` copies have no per-repo markers yet. Add them to SalesApp's and ecommerce's copies first, or `--write` drops their own pins (SalesApp's no-`accountingDb` test with `REPO_ENV = {'MONGO_URI_ACCOUNTING': None}`, ecommerce's built-once test). accountingSystem and warehouse lose nothing.
3. Scheduler identity: in `create_or_update_job.ps1`'s per-repo block set `$SchedulerServiceAccount = "$Project@appspot.gserviceaccount.com"`, or grant the runtime account `roles/run.invoker` on each scheduled job. When you run `createOrUpdateJob.py` directly with `--schedule`, pass `--scheduler-service-account`.
4. By hand, not shared yet: `pubSubAdmin.py` (the new `main()`), `AppCreator.py` (the `createDeadLetter` branch, only where a repo has that receiver), `mongoDb.py` (the `_initialized` guards; ecommerce has them), `pubSub.py` (the cloud-wide `publishToPubSubMockDb` refusal) and `dumpProdDataToLocalDev.cmd` (the `--username` prompt).
5. Each fleet dump script's prompt yields an empty password under Windows PowerShell 5.1, which `powershell` runs: ecommerce's (`server/tools/`) uses `Read-Host -MaskedInput`; SalesApp's and warehouse's (`server/`) and accountingSystem's (`Utils/`) use `ConvertFrom-SecureString -AsPlainText`, also PowerShell 7 only, and then put the plain password on PowerShell's command line to URL-encode it. Each keeps its own `MONGO_DUMP_HOST` / `MONGO_DUMP_USER` defaults.

An existing project needs no `star-app.json` for `doctor`. `link` and `cloud-setup` read it and stop without one ("no star-app.json here or above"), so write one for the project first (copy the shape from a fresh scaffold of the same name, port, timezone and region). Before a first `link`, add the local-ports block to `CLAUDE.md` (a fresh scaffold's "Local services" section, markers included), or `link` prints the table instead of writing it. In `test_route_response_models.py`, `_samplePublisher` takes `serviceGetter.listOfServices[0]` as its consumer (a fresh scaffold's version) if you remove `exampleService`.

### 1.36.0 (draft) — `new` and `doctor`, faster Cloud Run cold starts

- **`create-star-app new` and `create-star-app doctor`** (see Usage). The bare command is still the interactive scaffold. `new` validates its input (name, port, timezone, region), checks the machine first and writes nothing on a blocker, scaffolds into `./NAME` (a non-empty folder is refused), runs twelve steps with an honest summary and exit code (it used to print "Project scaffolded successfully!" and exit 0 after failures), installs the requirements into a new `server/venv` and runs `AppCreator.py` with that venv's Python (the first run used whatever `python` was on PATH and failed on a clean machine), makes the initial commit, and can be re-run inside the project to resume without rewriting a file you changed or rotating the JWT secret (`.star-app/files.json`, `--force-templates`, `--force-templates-all`). create-next-app runs with list arguments, `--skip-install`, `--no-react-compiler` and `--disable-git` (no nested `client/.git`). New: `star-app.json` at the project root (`projectName`, `npmName`, `port`, `timezone`, `gcpRegion`, layout; never a secret) and `.star-app/` for local state. `python -m pytest` runs the CLI's own suite.
- **clouddev connects to Atlas**: `mongoDb.py`'s clouddev branch built `MongoClient(None)` (localhost); both cloud environments now read `MONGO_URI` and refuse to start without it (`'MONGO_URI environment variable is not set'`). `test_route_response_models.py` checks the refusal for clouddev too.
- **`AppConfig.databaseName`** is required in cloudprod: AppConfig refuses to start without it (`databaseName environment is not set for cloudprod environment`, as accountingSystem already does), where it silently fell back to `test<name>`; elsewhere it still defaults to `test<name>`. The unused `getUseReplicaSet` (it read an attribute nothing set) is gone. `mongoDb.delete` refuses an empty query in clouddev too (it now reaches Atlas). `ARCHITECTURE.md`: the clouddev row, `databaseName` required in cloudprod, `MONGO_URI` for both cloud environments, the dump tool's `MONGO_DUMP_URI` and the Database Tools on PATH, the app timezone as a fixed offset with no DST; the json2ts hints name `json-schema-to-typescript@15`; `.gitignore` adds `.env.*`.

- **`mongoDb.checkReplicaSet` makes no network call in the cloud** (Atlas is always a replica set: it returns True) and, locally, asks the server `self.client` is connected to instead of a new 5 s `MongoClient` to localhost, which cost every db object 5 s at a Cloud Run cold start. `test_mongoDb_replicaSet.py` (new shared test) pins both, over a bare `mongoDb` with a fake client plus one spy over the local server.
- **The deploy gate loads neither the app nor the databases.** `checkPubSubTopics.py` (the deploy gate; see the predeploy job below) is now a shared file (exact): it reads the topics from `pubsub_models.json`, exits 1 when an AST scan of the shipped sources disagrees with it, and passes them to `pubSub.checkPubSubTopics(topics)`. `pubSub.py` no longer imports `mongoDb` at import time, which built every db object and its indexes: `pubSubMockDb` is a stand-in that imports `mongoDb.pubSubMockDb` on first use (`pubSub.pubSubMockDb` and `from pubSub import pubSubMockDb` keep working), and `PubSubMockDb` is imported only for type checking. Importing the gate dropped from 1.1-3.7 s to 0.8-1.0 s locally across the fleet.
- **The checks moved out of the cold start, into a predeploy Cloud Run job.** `job_predeploy.py` (new shared file, exact, with a per-repo `indexes` block holding `buildIndexes()`) runs `checkPubSubTopics.main()` and then the repo's index builds against its main database, exiting non-zero on any failure. `cloudbuild.yaml` gains two steps between `push` and `deploy-service`: `update-predeploy-job` (`updateJobsFromService.py --only=$_SERVICE_NAME-predeploy`: just that job gets the new image and the service's env vars; a missing job, a `*-once` or foreign-named one, or one on another image repository is exit 2) and `run-predeploy-job` (`gcloud run jobs execute $_SERVICE_NAME-predeploy --region=$_DEPLOY_REGION --wait`). A red job fails the build before the deploy, so the old revision keeps serving. The build bakes its commit into the image (`--build-arg COMMIT_SHA`, `ENV BUILD_COMMIT_SHA` in the Dockerfile) and runs the job with `--update-env-vars=EXPECTED_COMMIT_SHA=$COMMIT_SHA`; `job_predeploy.py` exits 1 first on a difference, so an overlapping build that re-imaged the job in between cannot pass another build's check. `--only` also sets the job's command (`python <its job_*.py>`) and `--max-retries=0`. Only the build runs the check: a console deploy, a manual `gcloud run deploy` or a revision rollback skips it. `update-jobs` is unchanged and updates the predeploy job once more, to the same image and env vars. The **Dockerfile** CMD is now `exec gunicorn -b 0.0.0.0:8080 --timeout 120 app:app` alone (the 30 s default timeout could kill a worker while it imported the app), and **`mongoDb.mainDb`** builds no index at import in the cloud (it did, in a daemon thread, on every cold start and in every job); locally it still does, and so does `TestDb`. `test_job_predeploy.py` (new shared test) and `test_cloudbuild.py` pin it.
- **`pubSub.checkPubSubTopics` fails closed**: a lookup that cannot be made (no credentials, a client that cannot be built) is now `ERROR` and exit 1, where it printed a WARNING and let the deploy through. `_checkIfTopicExists` reads only NotFound as a missing topic; any other `get_topic` error raises `TopicLookupError`, listed as "could not check topic X: <error>", and fails the check too. It still returns at once outside the cloud.
- **An HTTP startup probe holds Cloud Run's startup until the app has loaded.** The services bill per request (CPU throttled outside startup and requests), and gunicorn binds port 8080 before its worker imports app.py, so the default TCP probe passed in under a second and the import then ran on throttled CPU (SalesApp took 5 minutes with no traffic; ecommerce's first requests waited 7-30 s). AppCreator writes a plain `GET /startupCheck` route into app.py (`_startup_check_block`: `{'status': 'ok'}`, no route_config, JWT, caller check or database), which answers only once app.py has fully imported, and `cloudbuild.yaml`'s `deploy-service` step passes `--startup-probe=httpGet.path=/startupCheck,httpGet.port=8080,periodSeconds=1,timeoutSeconds=1,failureThreshold=120`. A revision that never answers within 120 tries (120 s, the same as gunicorn's `--timeout 120`) fails the deploy, so the old revision keeps serving. `test_startupCheck.py` (new shared test) pins the route; `test_cloudbuild.py` pins the probe.

### Upgrading a 1.35.0 project to 1.36.0

0. **Before merging, once per service** (the first build after the merge fails at `update-predeploy-job` while the job is missing): run `server/tools/create_or_update_job.bat`, pick the project and the service, pick `job_predeploy.py` and press Enter at the schedule prompt (no schedule). It creates the job `<service>-predeploy` with the service's current image and env vars and the command `python job_predeploy.py`; the current image need not contain the file, since the build points the job at the new image before it runs it. Nothing runs it until then.

1. In `mongoDb.py`, take `checkReplicaSet` from a fresh scaffold (no `uri` parameter: True when `getIsCloudEnvironment()`, else `isMaster` on `self.client`) and drop the argument from its callers; `cd server && python sharedFileCheck.py --write` takes `test_mongoDb_replicaSet.py`.
2. In `pubSub.py`, take from a fresh scaffold the `_LazyPubSubMockDb` stand-in in place of `from mongoDb import PubSubMockDb, pubSubMockDb` (`PubSubMockDb` moves under `if TYPE_CHECKING:` and is quoted in `createMockData`'s signature) and `checkPubSubTopics(topics=None)`. `sharedFileCheck.py --write` takes `checkPubSubTopics.py`, which reads the committed `server/pubsub_models.json`; a `test_checkPubSubTopics.py` that ran the script's imports to fill the registry must check the manifest instead, as accounting's does.
3. `cd server && python sharedFileCheck.py --write` takes `job_predeploy.py`, `test_job_predeploy.py`, `cloudbuild.yaml`, `test_cloudbuild.py`, `tools/updateJobsFromService.py`, `checkPubSubTopics.py` and `pubSubMapSection.py`. Then fill `job_predeploy.py`'s per-repo `indexes` block with the repo's own index build: `buildIndexes()` returns 0 when every index the code relies on is in place, non-zero (or raises) otherwise.
4. By hand (per repo): the Dockerfile CMD becomes `exec gunicorn -b 0.0.0.0:8080 --timeout <N> app:app` with no pre-start script (keep the repo's own timeout; `test_cloudbuild.py` checks the `exec gunicorn -b 0.0.0.0:8080 ` prefix). `mainDb` builds its indexes only when `not AppConfig().getIsCloudEnvironment()`; a module-level `testDb` is made with `_makeTestDb()` (None in the cloud). `pubSub.checkPubSubTopics`: its `except Exception` prints an `ERROR` and calls `sys.exit(1)`; take `TopicLookupError`, `_printUncheckedTopics`, `_checkIfTopicExists` and the check's loop from a fresh scaffold. The Dockerfile gets `ARG COMMIT_SHA` and `ENV BUILD_COMMIT_SHA=$COMMIT_SHA` before the CMD. A repo test that pins the old CMD (`python checkPubSubTopics.py && ...`) or the WARNING-and-pass on a client error is retired.
5. Take `_startup_check_block` from a fresh scaffold's `AppCreator.py` (and its call in `generateAPI`, between `_compression_block` and `_error_handler_block`), rerun `ENVIRONMENT=localdev python AppCreator.py`, and check that app.py gains only the `startupCheck` route. `sharedFileCheck.py --write` takes `test_startupCheck.py` and the probe in `cloudbuild.yaml` / `test_cloudbuild.py`. The probe is set on the service by the first build after the merge (`gcloud run services update ... --startup-probe=...`); a route-coverage or public-route test that reads the ApiRequests routes does not see `startupCheck`, which is no ApiRequests method. Rollback: `services update` keeps the probe once a build has set it, so from then on an image without `/startupCheck` (a git revert of the probe, a console or manual deploy of an older image, a hotfix branch cut from an older main) fails the probe and is not deployed; set it back first with `gcloud run services update <service> --startup-probe=tcpSocket.port=8080` (or `--startup-probe=""` to drop it). Routing traffic back to an existing older revision works, since each revision keeps its own probe.

6. In `mongoDb.py` (not a shared file, by hand): replace the `cloudprod` and `clouddev` branches of `mongoDb.__init__` with one `elif AppConfig().getIsCloudEnvironment():` branch holding the former cloudprod body (it reads `MONGO_URI` and raises `'MONGO_URI environment variable is not set'`). Before the next clouddev deploy, the user sets `MONGO_URI` (and `databaseName`, if it is not `test<name>`) on the clouddev service.
7. In `AppConfig.py` (by hand), only after the user has confirmed that `databaseName` is set on every cloudprod Cloud Run service, its `<service>-*` jobs and the `<service>-predeploy` job (otherwise the next deploy fails to start). The user runs this read-only check, which prints env var names only, never their values (Claude and agents never print env values): `gcloud run services describe <service> --region=<region> --project=<project> --format="value(spec.template.spec.containers[0].env.name)"` and, per job, `gcloud run jobs describe <job> --region=<region> --project=<project> --format="value(spec.template.spec.template.spec.containers[0].env.name)"`; `databaseName` must be in each list. Then `MongoDb.__init__` sets `self.databaseName = os.getenv('databaseName') or None` and raises `'databaseName environment is not set for cloudprod environment'` when it is None under cloudprod (else `'test' + self.projectName`); `getUseReplicaSet` is deleted (grep for callers first). In `mongoDb.py`, `delete` refuses `query == {}` under `getIsCloudEnvironment()`. In `test_route_response_models.py`, `test_newDatabaseClient_inACloudEnvironment_isRefused` gains `'clouddev'`.
8. Optional, by hand: take the `ARCHITECTURE.md` environment rows, the `MONGO_URI` / `databaseName` rows and the dump paragraph from a fresh scaffold, and the `json-schema-to-typescript@15` hints in `CLAUDE.md` and `AppCreator.py`.
9. Optional: `create-star-app doctor` works on a 1.35 project as it is, with the default layout. A `star-app.json` is only needed from 1.37.0 on (`link`, `cloud-setup`).

### 1.35.0 — parallel test workers, a RAM gate, sibling servers started by the tests

- **`pytest -n auto`** (pytest-xdist 3.8.0, now in `requirements.txt`): `conftest.py` gives every xdist worker its own database, the worker id before `Scratch` (`test<project>_gw0Scratch`; the scratch guard is unchanged), and sends a test file to one worker (`--dist loadfile`, unless `--dist` is passed). Plain `pytest` stays one process.
- **`testRamGate.py`** (new shared file, stdlib only): `-n auto` takes as many workers as the available memory holds, and every run, serial too, is refused before any test starts (exit code 75) when its workers do not fit, naming the other pytest and `pubSubMockDataGenerator` processes that hold the memory. Formula, measured numbers and knobs (`PYTEST_WORKER_GB`, `PYTEST_RAM_RESERVE_GB`, `PYTEST_MAX_WORKERS`, `PYTEST_WAIT_FOR_RAM`, `PYTEST_SKIP_RAM_CHECK`) in its docstring; a repo whose workers take more sets its own `TEST_WORKER_GB` in conftest's per-repo block.
- **`testServices.py`** (new shared file) and **`services/__request.py`**: in a localdev test run, the first call to a sibling's dev URL starts that sibling's server when nothing answers there, from `../<localRepoDirName>` with its `server/venv` python, on database `test<its projectName>For<ours>Scratch` (or conftest's per-repo `TEST_SERVICE_DATABASES`), and the run kills it when it ends. Nothing is declared per service. A per-port lock lets one worker start it; on Windows it sits in the run's kill-on-close Job Object, so it dies with a crashed or killed run too; a recorded server whose run is gone is killed at the next start, only while its PID still has the recorded creation time. A server already up is used and left running; a sibling with no checkout or python is left down with one warning. Knobs: `PYTEST_NO_AUTOSTART_SERVICES=1`, `PYTEST_SERVICE_ROOT_<NAME>`, `PYTEST_SERVICE_PYTHON_<NAME>`, `PYTEST_SERVER_GB`.
- **`test_xdist_workers.py`** and **`test_testServices.py`** (new shared files) pin the worker databases, the gate's decision and refusal, the server start/reuse/sweep decisions, the hook's conditions and that sibling calls go only through `services/__request.py`.

### Upgrading a 1.34.0 project to 1.35.0

1. Take the shared files (`cd server && python sharedFileCheck.py --write`) and `pip install pytest-xdist==3.8.0` (add it to `requirements.txt`).
2. Port the three `conftest.py` blocks from a fresh scaffold into yours: "One database per xdist worker" before the scratch guard, the `STAR_TEST_SHELL_ENV_KEYS` record after the first pop of the cloud database variables, and the RAM refusal plus the "Parallel workers (pytest-xdist), the RAM gate and the test servers" section after `pytest_configure`. Set the per-repo `TEST_SERVICE_DATABASES` and `TEST_WORKER_GB`.
3. Run the suite serially and with `-n auto`; a test that fails only in parallel wrote into the repo, used a fixed port or shared module state: fix its setup.

### 1.34.0 — Cloud Run jobs follow the service

- **`cloudbuild.yaml`** (new shared file, repo root): the push-to-`main` build, in the repo instead of inline in the Cloud Build trigger. Steps `build` (`docker build server --build-arg BUILD_CONFIG=cloudbuild.yaml`), `push`, `deploy-service` (the same `gcloud run services update` the Cloud Run wizard's inline build ran) and `update-jobs`. Identical in every repo: every repo-specific value is a trigger substitution (`_SERVICE_NAME`, `_DEPLOY_REGION`, `_AR_*`, `REPO_NAME`, `_TRIGGER_ID`).
- **`tools/updateJobsFromService.py`** (new shared file, stdlib only): gives every Cloud Run job named `<service>-*` (not `*-once`, and only jobs already on the service's image repository) the image that just went live and the service's env vars, through `--env-vars-file`, so multiline values such as `FIREBASE_PRIVATE_KEY` survive. A secret-ref env var or an empty environment refuses (exit 2); a failed job update fails the build (exit 1) after trying the others. A `job_*.py` without a job is a WARNING; new jobs and schedules stay manual (`create_or_update_job.bat`). It prints env names and counts, never values, and writes to GCP: Claude never runs it.
- **`test_cloudbuild.py`** (new shared file, no DB): pins the four steps, the image expression, the allowed substitutions, the absence of any service name, the Dockerfile guard, and the script against a fake gcloud.
- **`Dockerfile`** (per repo): `ARG BUILD_CONFIG` and a `RUN` that exits 1 unless it is `cloudbuild.yaml`, before the first `COPY`. A trigger still on its inline build fails at `docker build`, before anything ships, instead of deploying a service whose jobs keep the old image. A local `docker build` needs `--build-arg BUILD_CONFIG=cloudbuild.yaml`. New **`server/.dockerignore`** keeps `.env`, venvs and `keys/` out of a local build.
- **`tools/update_all_jobs.ps1`** keeps its pickers and review summary but previews with `updateJobsFromService.py --dry-run` and syncs by running it, instead of its own comma-joined `--set-env-vars` loop (which mangled multiline values and also touched `*-once` jobs). Its exit code is the script's.
- **Docs**: ARCHITECTURE.md "GCP Cloud Run Jobs" (automatic refresh, the guard, switching a trigger), CLAUDE.md "Deploys".
- **`checkPubSubTopics.py`** (per repo) reads the topics and their prod flag from the committed `server/pubsub_models.json` and imports only `pubSub`, instead of importing every publisher module (the whole app, a second time before gunicorn loads it: roughly double the cold start). `pubSub.checkPubSubTopics(topics=None)` takes those pairs; with none it still reads the registry. The **`Dockerfile`** CMD runs it before gunicorn (`sh -c "python checkPubSubTopics.py && gunicorn ..."`): it stays outside app.py because gunicorn binds the port before a worker imports the app, so Cloud Run's TCP startup probe would pass before an in-app check ran. A missing manifest, or one `pubSubModels.parseManifest` refuses for this project, fails the start, and so does a stale one: the script compares its `(fn, prodPublish)` pairs with `pubSubMapSection.publisherProdFlags` (the map's AST publisher scan of the shipped sources, about 0.3 s) and exits 1 on any difference, since the build runs no tests.
- **`test_response_compression.py`** drops its two start-up subprocess tests (`test_killSwitch_isReadFromTheEnvironmentAtStartup`, `test_killSwitch_isLoggedOnceAtStartup`) and their helpers: each spawned fresh `app.py` imports, the slowest part of the suite's compression file. `test_killSwitch_turnsCompressionOff` still pins that `app.config['COMPRESS_DISABLED']` turns compression off; how app.py reads `COMPRESS_DISABLED` from the environment, and its `compressionDisabled` log line, are no longer tested.
- **`test_generated_client_is_fresh.py` is gone** (template and `fleet/sharedFiles.json` entry): nothing checks any more that the committed schemas and `ServerRequests.ts` match a fresh AppCreator run, so rerun AppCreator after a model or route change and commit what it writes. `test_client_typecheck.py` stays. The json2tsBatch.js comment, the test_client_typecheck.py docstring, ARCHITECTURE.md and CLAUDE.md no longer point at it.
- **`test_unused_response_fields.py::test_everyResponseFieldIsReadByTheClient` skips for 7 days after a pass.** A pass (never a failure or a skip) writes `server/.testStamps/unusedResponseFields.json` (`{"lastPassedAt": <ISO 8601 UTC>}`, one per checkout or worktree, so a fresh worktree runs it; `server/.testStamps/` is in the scaffold's `.gitignore`). A run deletes it before the analyzer starts (`clear_pass`), so a failed or interrupted run deletes it and the next run runs again. A missing, unreadable or malformed stamp, or one dated in the future, runs it; `RUN_UNUSED_RESPONSE_FIELDS=1` (or true, yes, on) always does. The skip message gives the pass time in the project's timezone. The fast tests always run, and new ones pin the skip decision. When it runs, it still fails, never skips, without `npm install`.
- **`test_callerAuth.py` drops `test_exactlyTheServiceOnlyRoutes_requireAServiceCaller`**, which ran AppCreator's whole route code generation (`_all_functions_code('app')`, about 12 to 21 s). `test_theCommittedApp_requiresAServiceCallerOnExactlyTheServiceOnlyRoutes` is now the service-only guard: the same `_assertServiceRoutesGuarded` asserts on the committed app.py, so a route made serviceOnly without rerunning AppCreator still fails it. The push-handler tests are unchanged.
- **`responseModelCheck.py` no longer slows every app start.** `route_config` runs `assertResponseModel` at decoration, for every route, in every environment (each Cloud Run worker start), and `responseModelProblems` worded each field's `file:line` up front, through `inspect.getsourcelines`, which re-parses the model's whole module each call (thousands of calls per app import, most of its 25 to 65 s locally). The per-field location is now built only for a problem, and `_where` is cached. The same problems are found, with the same messages.
- **`AppConfig.py` (per repo) no longer re-runs `services/*.py` on every `AppConfig()`.** `PubSubConfig` executed every services file again on each call (about 8 to 14 ms; ecommerce's suite builds an AppConfig about 12,600 times, and every publish and transaction builds one), and `AppConfig.__init__` ran it twice (in most repos once through `Environment`'s `super().__init__()` and once by name; in accounting twice by name). The `(serviceName, PUBSUB_ENABLED_* variable)` pairs are now found once per process by `_serviceEnvVarNames()`, which imports the service modules (reusing the ones the app already imported) instead of running them again; the variables' values are still read on every init, with the same "not set" and "Invalid" errors. Every base calls `super().__init__()` and `AppConfig.__init__` is just `super().__init__()`, so each base runs once. `Environment` checks `ENVIRONMENT` before its `super().__init__()`, so an invalid one is the error even when the `PUBSUB_ENABLED_*` variables are unset too (accounting already did this; the other repos raised the missing-variable error first). A call now costs about 0.003 ms.
- **Service objects no longer ping their sibling when they are built** (`services/__abstractService.py`, per repo). Each `services/*.py` object did `self.isOnline = self.ping()`, an HTTP HEAD to the sibling (its prod URL on Cloud Run) at import, on every worker start. `isOnline` is now a property that pings on its first read and keeps the answer; the `isOnline` field is gone from `model_dump()`. The dead `__main__` block that built the abstract `AbstractService` is gone, and the `servicePortCheck.py` and AppCreator comments no longer point at an import-time `model_dump()` print (accounting printed two).

### Upgrading a 1.33.0 project to 1.34.0

1. `cd server && python sharedFileCheck.py --write` takes `cloudbuild.yaml`, `tools/updateJobsFromService.py`, `test_cloudbuild.py` and the new `tools/update_all_jobs.ps1`.
2. By hand (both are per repo): add the `ARG BUILD_CONFIG` guard from a fresh scaffold's `server/Dockerfile` after `WORKDIR`, before the first `COPY`; add `server/.dockerignore`, re-including by name (`!keys/<file>.json`) any tracked file under `keys/` the app opens at runtime. Run `test_cloudbuild.py`.
3. Merge to `main`. That push's build fails on purpose at `docker build` (the trigger still runs its inline build); the old revision keeps serving.
4. Switch the trigger to the file, once (the user runs it; `gcloud builds triggers update github --build-config` fails with INVALID_ARGUMENT on gcloud 493): `gcloud builds triggers describe <TRIGGER> --project=<P> --format=yaml > $env:TEMP	rigger.yaml` (PowerShell; `%TEMP%	rigger.yaml` in cmd: never in the repo, so it cannot be committed), delete its `build:` block, add `filename: cloudbuild.yaml`, move any `build.substitutions` to a top-level `substitutions:` map (drop `createTime` / `resourceName` if the import complains), then `gcloud builds triggers import --project=<P> --source=$env:TEMP	rigger.yaml`. Check with `gcloud builds triggers describe <TRIGGER> --project=<P> --format="yaml(filename,substitutions,build)"`: `filename: cloudbuild.yaml`, the substitutions, no `build:`.
5. Rerun the failed build (or push again). It goes green with an `update-jobs` step, and every `<service>-*` job carries the service's image.
6. `cd server && python sharedFileCheck.py --write` also takes `pubSubMapSection.py` (`publisherProdFlags`) and `pubSubModels.py` (`collectPublishers` now refuses a registry `publishToProdPubSub` the map reads otherwise; rerun AppCreator if it stops there). By hand (per repo): take a fresh scaffold's `server/checkPubSubTopics.py`, give the repo's `pubSub.checkPubSubTopics` the `topics` parameter, and make the Dockerfile CMD run `python checkPubSubTopics.py && exec gunicorn ...` (keep the repo's own gunicorn flags and any other pre-start step). With the cloud guard in `pubSub.checkPubSubTopics`, a local `python checkPubSubTopics.py` runs only the stale-manifest check. Retire any repo test that runs the old script's imports to fill the registry.
7. `cd server && python sharedFileCheck.py --write` also takes the shorter `test_response_compression.py` and the new `tools/json2tsBatch.js` comment and `test_client_typecheck.py` docstring. Delete `server/test_generated_client_is_fresh.py` (the manifest no longer lists it) and its mentions in the project's CLAUDE.md and ARCHITECTURE.md.
8. `sharedFileCheck.py --write` also takes the 7-day skip in `test_unused_response_fields.py`. Add `server/.testStamps/` to the project's `.gitignore`, and mention the skip and `RUN_UNUSED_RESPONSE_FIELDS=1` where its CLAUDE.md describes the gate.
9. `sharedFileCheck.py --write` also takes the shorter `test_callerAuth.py`.
10. `sharedFileCheck.py --write` also takes the faster `responseModelCheck.py`.
11. By hand (per repo): take a fresh scaffold's `_serviceEnvVarNames()` and `PubSubConfig` into `server/AppConfig.py`, add `super().__init__()` to `Project.__init__`, and make `AppConfig.__init__` call only `super().__init__()` (keep any check after it). In `services/__abstractService.py`, replace the `isOnline` field with the `_isOnline` PrivateAttr and the `isOnline` property; delete `self.isOnline = self.ping()` from every `services/*.py` and any `print(<service>.model_dump())`. `sharedFileCheck.py --write` takes the `servicePortCheck.py` docstring.

### 1.33.0 — caller auth

- **`callerAuth.py`** (new shared file, with `test_callerAuth.py`): the routes a machine calls check who calls them. AppCreator puts `@requirePushCaller` under the route of every generated Pub/Sub push handler and `@requireServiceCaller` under every `@route_config(serviceOnly=True)` route. Until now any caller who knew a service's run.app URL could POST a forged push (mark orders paid, overwrite customers) or call a service-to-service route. Both now require `Authorization: Bearer <Google-signed ID token>`:
  - RS256, signed by a cert Google serves (cached an hour, refetched at most once a minute, the last good certs kept through a failed fetch for 6 hours), unexpired, from accounts.google.com, with `email_verified`;
  - a push: `aud` is exactly `https://<the request's host><path>` (what pubSubAdmin.py configures), the email in `PUSH_SERVICE_ACCOUNTS`;
  - a service call: `aud` is `https://<the request's host>` (a trailing `/` ignored), the email in `SERVICE_CALLER_ACCOUNTS`.
  A refusal is a 401 `{"message": "Unauthorized", "status": 401}`, logged as `callerRejected` with the reason, function and User-Agent, never the token. Skipped in localdev only. `CALLER_AUTH_MODE=report` on a service logs a refusal and still runs the route while `CALLER_AUTH_REPORT_UNTIL` (a date, through that day UTC, or a datetime with its offset, at most 30 days ahead) has not passed; unset, unreadable or past, or any other mode, enforces. Report mode logs a WARNING at startup. `callerVerified()` tells a route whether this request's caller passed the check (not merely let through). The two account tuples are callerAuth.py's per-repo block, empty in a new project (every caller refused outside localdev).
- **`route_config(serviceOnly=True)`**: a route only another service calls, i.e. every route in `actions/ServiceToServiceActions.py` and no other (`test_callerAuth.py` pins both ways). It takes no `jwtRequired`, `createAccessToken` or `roleAccess`.
- **`services/__request.py`**: `_authHeaders(baseUrl)` sends `Authorization: Bearer <ID token>` for the sibling's URL (its audience) when the service runs in the cloud and the URL is not loopback, fetched from the metadata server (`google.oauth2.id_token.fetch_id_token`) and reused until 5 minutes before it expires. A failed fetch fails the call. A local run sends none (a user's credentials cannot mint one).
- **`pubSubAdmin.py`** no longer asks for the push service account: it uses `callerAuth.PUSH_SERVICE_ACCOUNTS[0]`, so the subscriptions and the handlers can never disagree, and exits while it is empty.
- **`sharedConstantsTS.py`** (new shared file, not in SalesApp, which has no shared constants): `renderSharedConstantsTS` and its secret and JSON guards move out of `AppCreator.py`, which imports it (so `from AppCreator import renderSharedConstantsTS` still works) and keeps `writeSharedConstantsTS`. The caller-auth and error-handling work took AppCreator.py past `test_file_sizes.py`'s 1,500 lines. The generated serverConstants.ts is unchanged.

### Upgrading a 1.32.0 project to 1.33.0

1. `cd server && python sharedFileCheck.py --write` takes `callerAuth.py`, `test_callerAuth.py`, `services/__request.py`, `test_service_request.py` and `sharedConstantsTS.py`. In `AppCreator.py`, delete the shared-constants section from `_TS_BANNER` through `renderSharedConstantsTS` and replace `import sharedConstants` with `from sharedConstantsTS import renderSharedConstantsTS`. Fill callerAuth.py's per-repo block: `PUSH_SERVICE_ACCOUNTS` (first: the account your push subscriptions' OIDC tokens use, `gcloud pubsub subscriptions list --format="value(pushConfig.oidcToken.serviceAccountEmail)"`), `SERVICE_CALLER_ACCOUNTS` (the runtime accounts of the siblings and jobs that call you).
2. In `route_config.py`, take the `serviceOnly` parameter, its validation and `func.serviceOnly`.
3. In `AppCreator.py`, take from a fresh scaffold: the two `callerAuth` imports (APP_IMPORTS, APP_PUBSUB_IMPORTS), `serviceOnly` in `_route_meta` and `_validate_route_meta`, and `caller_decorator` in `_function_code` (and the `{caller_decorator}` before `{jwt_decorator}`).
4. In `pubSubAdmin.py`, take `__pushServiceAccount()` in place of `__selectServiceAccount`.
5. Mark every route in `actions/ServiceToServiceActions.py` `serviceOnly=True`, then run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
6. Roll out: set `CALLER_AUTH_MODE=report` and `CALLER_AUTH_REPORT_UNTIL=<date the report phase ends, at most 30 days ahead>` on every service, deploy every service (callers then send tokens), redeploy the jobs that call a sibling, read the `callerRejected` log lines until only callers you mean to cut off remain, then remove both (enforcement also starts by itself after that date).

### 1.32.0 — error handling

- **`routeErrors.py`** (new shared file, with `test_route_errors.py`) decides how every generated handler answers a failure and what it logs. The except block of each route and push handler is now `failure = routeFailure(e, status=403|400)`, one `logging.log(failure.level, {<its log entry>, 'traceback': failure.traceback})` and `return failure.response()`:
  - `PermissionError`: 403 with its message; a `ValueError`: 400 with its message (a route raises one on purpose, so its text is user-facing); a pydantic `ValidationError`: 400 naming the failing fields and why, never the values (its str() quotes the input);
  - `mongoDb.OutdatedDataError` (new; the `_version` lock, still "Your data is outdated...", not a ValueError): 400;
  - a sibling's 400 with an envelope message (`ServiceError`): 400 with that message; any other `ServiceError` or `ServiceUnavailable`: 503 "<Service> is unavailable right now. Please try again. (error id X)";
  - a push handler whose envelope never parsed: 400 "Malformed Pub/Sub push";
  - **anything else: a real HTTP 500**, "Something went wrong on our side. Please try again. (error id X)". It was a 400 carrying `str(e)`, so no route could answer 5xx (Cloud Run's error metrics stayed at zero in an outage) and the exception text (a stored document's values in a ValidationError, a sibling's URL) reached the user and the log.
  Each failure is logged ONCE (no more `traceback.print_exc()` beside the log line): a caller's error as a WARNING, a server fault as an ERROR. The traceback is the stack and the exception type with the error id, never the exception's message, chained causes or locals; a push handler's response carries the same one. The error log keeps its keys (function, event, requestKeys or messageId/messageKeys, traceback), and GET routes now log their failures too.
- **A dict, model or list param must arrive as that JSON shape**: next to the scalar guard, the generated handler refuses any other value (a 400 "Invalid <param>"; None only for an Optional param or one with a default), so a bad shape never reaches the route as a TypeError, which is now a 500 (`_shape_of_annotation`, `_shape_params`).
- **app.py answers an exception raised outside a route's try** (serializing the response, a hand-written blueprint) through the same `routeFailure` (`@app.errorhandler(Exception)`, `_error_handler_block`), logged once; an `HTTPException` (404, 405) is answered as Flask would.
- `routeErrors.validationMessage` names a dict key in a field's location as `*` (only declared field names and list indexes stay): a key is the caller's data.
- `str(ServiceError)` carries no sibling message (only `.message` does, which routeFailure reads for a 400).
- **The generated TS client hands a 5xx to the page as status 0** with the server's message (`if (res.status >= 500) ...` after the body is read), so `ApiFailure` stays `0 | 400 | 401 | 403`. `AppCreator.STATUS_SERVER_ERROR` is 500.
- **`services/__request.py` is one HTTP client for sibling calls** (now a shared file, with `test_service_request.py`). Every call goes through one module-level `requests.Session`, and fails with one of two exceptions, never a raw `requests` one:
  - `ServiceError` (a `requests.HTTPError`, `.response` kept): the sibling answered a non-2xx status. It carries `service`, `route`, `status` and `message`, the `message` of the sibling's ApiResponse envelope (or None);
  - `ServiceUnavailable` (a `requests.RequestException`): no answer (refused, unreachable, timed out), raised `from None`.
  Neither puts the sibling's URL in its text. Code that catches `requests.HTTPError` or `requests.RequestException` still catches them. A 200 that is not JSON is a `ServiceError` too.
- **Every sibling call has a timeout**: `request(..., timeout=DEFAULT_TIMEOUT)`, `(5, 60)` seconds (connect, read), so a read can never hang forever and a slow sibling call is not cut short. A service class passes its own to tighten it (ecommerce keeps `(5, 18)` on all three of its services).
- `request()` takes `service=` (each service class passes its `cloudRunServiceName`, named in the errors) and `headers=` (added to the request's headers). `_authHeaders(baseUrl)` is the one place a header every call carries is added, merged last, so a caller's `headers=` can never replace one it sets (a service token). It still returns the sibling's whole JSON envelope.

**Deploy order (a fleet)**: a sibling's 400 message is passed on to the user. Until every service runs routeErrors, an old sibling still answers any exception with a 400 carrying its raw text, which would reach the caller's user. Deploy the callees first (accounting, warehouse, SalesApp) and the caller (ecommerce) last, or all of them close together.

### Upgrading a 1.31.0 project to 1.32.0

1. `cd server && python sharedFileCheck.py --write` takes `services/__request.py`, `test_service_request.py`, `routeErrors.py` and `test_route_errors.py`.
2. In each `services/<name>.py`, pass `service=self.cloudRunServiceName` to `request(...)` (and `timeout=` where the service needs a tighter one). Code that unwrapped a raw `requests.HTTPError` by hand still works (`e.response`), and can read `e.message` instead.
3. In `mongoDb.py`, add `class OutdatedDataError(Exception)` from a fresh scaffold and raise it in `update()` on a version mismatch, in place of the plain `Exception` (same message).
4. In `AppCreator.py`, take from a fresh scaffold: the `from routeErrors import routeFailure` import in place of `import traceback` (app.py and appPubSub.py), `STATUS_SERVER_ERROR`, the `error_log` / `push_arg` / `envelope_arg` lines and the two except branches in `_function_code`, `_shape_of_annotation`, `_shape_params` and the `shape_guard` (passed from `_all_functions_code`), `_error_handler_block` and its call in `generateAPI`, and the `if (res.status >= ...)` line after `data_decl` in `_ts_method_code`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
5. Every `raise Exception('<message for the user>')` (or another non-ValueError type) a route relies on to tell the user something becomes a 500 with the generic message: raise a `ValueError` instead. Check first that no `except ValueError` between the raise and the route would start catching it.

### 1.31.0 — ResponseModel: what a route sends is never a database model

- **`objects.ResponseModel`** (new base class): the responseModel of every route, the payload model of every Pub/Sub publisher (its route's responseModel or its `PUBSUB_PAYLOAD_MODELS` entry), and every model nested in either, all the way down. It is never a `MongoModel`, `AppModel`, `StoredDocumentKeys` or `*View`, and inherits from `ResponseModel`s only. Its fields are built from allowed leaves only (str, int, float, bool, None, datetime, date, a Literal, an Enum or a ResponseModel, through Optional, unions, `list[...]` and `dict[str, ...]`; never `Any`, a bare `dict` or `list`, `object`, a TypedDict, a dataclass, a tuple or a set), and it declares no `@computed_field`. It has `populate_by_name` and the copy-on-write empty-string coercion. PubSubRequests push handlers stay exempt.
- **route_config refuses a non-ResponseModel `responseModel` when the route is decorated** (the import fails, naming the route and every offender with its file:line): only a ResponseModel, or the RootModel of a bare cross-repo route (CROSS_REPO_BARE_RESPONSE_ROUTES), whose whole tree holds. `@pubSubDecorator` takes no model of its own: a publisher's model is its route's responseModel or its `PUBSUB_PAYLOAD_MODELS` entry, which `assertPublisherModels` checks.
- **A ResponseModel declares exactly what the route sends.** Under the localdev response check (`validate_route_response`'s context) it rejects every key that is not a field's serialized key (its alias, else its name: a field's name is refused when it has an alias), at any depth, as pydantic's `extra='forbid'` would (`<path>.<key>: Extra inputs are not permitted`, never the value), reported next to the other mismatches. Production still never validates a response and returns it unchanged; a ResponseModel validated outside the check ignores an extra key.
- **`server/responseModelCheck.py`** (new shared file): `responseModelProblems(model)` names every class and field of a response tree that breaks the rule (not a ResponseModel, a ResponseModel with a non-ResponseModel pydantic base, a leaf that is not allowed, a computed field), with its path and file:line, looking through a bare `RootModel`; `assertResponseModel` is route_config's check; `publisherModelProblems(projectRoot)` does it for every publisher `pubSubModels.collectPublishers` finds, and `assertPublisherModels` is AppCreator's refusal. `models_in(annotation)` is the one helper that finds the models an annotation holds (pubSubModels uses it too). **AppCreator refuses to generate** while either finds one (`validateRoutes` for the routes, right after the Pub/Sub manifest for the publishers), and **`test_responseModelsAreResponseModels`** (test_route_response_models.py) asserts it.
- **`projection_of(Model)`** replaces `_projection_of`: a nested ResponseModel (alone, in a list, a union of them) is read field by field as dotted keys, any other field whole, and `_id: 0` is added when the model declares no `_id` (Mongo returns it otherwise). A field that may be null, or a list whose items may be, is read whole: a dotted projection would drop a null or a legacy scalar instead of returning it. A nested ResponseModel in a shape no projection can express (a dict of them, a list of lists, a union with a scalar), or one with no fields, makes it raise. **`projectedCopy(Model, doc)`** cuts a document down to what the model declares at every depth, through lists, tuples, dict values, Annotated and unions (a union member chosen by its discriminator, else as the first member the value validates as, never by merging members' keys; a null or legacy scalar is kept): for a document the route built, or read whole. `ProjectedSubdocument` is gone.
- **`narrowing.py` is retired** (and dropped from the shared-file manifest): route_config no longer prunes a response (its two `narrowing` per-repo blocks are gone), since a ResponseModel lists only what is sent. `Omitted`, `omitted`, `kept`, `omit_computed`, `omitted_tree`, `prune_omitted` and `omitted_projection` go with it. `conftest.modelKeys` counts every declared field again.
- **Samples**: `UserResponse` (with `RoleResponse`) replaces `ClientUser` / `ClientUserView`: `loginWithGoogle` returns `projectedCopy(UserResponse, signInIdentity(...))` and `devLogin` reads with `projection_of(UserResponse)`. `DevUserRow`, `DevUserListResponse`, `RoleListResponse`, `HandshakeResponse`, `NoContentResponse`, `SampleTodoRow` and `SampleTodoListResponse` are ResponseModels. The client's `AuthContext` and `storedShapes.ts` take `UserResponse`. The fields sent are unchanged.
- **Whole-document rows are derived** (test_route_response_models.py `_isWholeDocument`, which replaces the hand list `WHOLE_DOCUMENT_MODELS`): a ResponseModel sends a stored document whole when, for a stored model (a MongoModel in objects.py) with a field that is no scalar, it copies every such field whole and at least half of its scalar ones.
- **The response check's mismatch message drops pydantic's `function-wrap[...]` loc segments** (a union member's validator).
- **A field typed `None` is refused**, except a never-sent guard, `x: SkipJsonSchema[None] = None`: a key that never goes out (absent or null), kept out of the TS type, never read by `projection_of` and dropped by `projectedCopy` at every depth and in every shape it walks, whatever it stores; a route that builds its dict without `projectedCopy` and leaves a value in it fails the check. `projectedCopy` on a top-level RootModel cuts its value through its root; `projection_of` refuses a RootModel (read it and cut it with `projectedCopy`).
- **Nothing in a response hides from the TS type**: SkipJsonSchema anywhere in a field (a union member, a list item) is refused, except on that guard and on a `SECRET_RESPONSE_KEYS` key (which every egress strips); so are `exclude` and a callable `json_schema_extra`. A field is read under one key, its alias else its name: a different `serialization_alias` or `validation_alias` (AliasChoices included) is refused.
- **`db.readResponse(Model, query, collection)` / `db.readResponses(...)` / `db.aggregateResponses(Model, pipeline, collection)`** (mongoDb.py) read with the model's projection and cut every document with `projectedCopy`: the read a route makes to answer with what it read. `aggregateResponses` appends `{'$project': ...}` to the pipeline (the caller's list is not changed). `readResponse` reads one document (None when none matches) and `readResponses` refuses `findOne` and `countOnly`. All three take `readWhole=(...)`, top-level keys read whole (a declared field a legacy-shape repair needs as stored, in place of its dotted keys, or an undeclared key the route reads for its own use; never a never-sent guard), and `prepare=fn(doc)`, run on every document as read, before the cut (a repair, an enrichment, a check that raises; it returns the document, and None is a TypeError). A sub-document read whole (a `readWhole` key, or one that may be null) may hold no never-sent guard at any depth: the read raises, so a guard's stored value is never even read. `db.projectionStage(Model, readWhole)` is a `$project` for the middle of an `aggregateResponses` pipeline (before a `$group` that pushes the rows): only `aggregateResponses` expands it, and `db.aggregate` refuses it. These three are the only code that reads a nested ResponseModel that may be null whole (`_projection_of_for_cut`, private to them; `test_cutProjection_isReachedOnlyThroughTheReadAndCutHelpers` fails on any other module that names it): `projection_of(Model)` takes the model alone and refuses such a model at any depth. The samples read with them.
- **A union of two or more ResponseModels declares a str discriminator** (`Field(discriminator='<tag field>')` on the field, or `Annotated[A | B, Field(discriminator=...)]` inside a list or a dict): `responseModelProblems` refuses an untagged one at any depth, naming its path and file:line (a callable `Discriminator` is no str tag). A union whose members carry two or more different models in lists or dicts (`list[A] | list[B]`, `A | dict[str, B]`) can carry no tag and is always refused: write `list[A | B]` with a discriminator. A model unioned with any dict (`A | dict[str, X]`) is refused too: both accept a dict, so a value that fails the model would go out whole as the dict; as a backstop, the cut never sends a dict value as a dict member beside a model, but cuts it to the model's keys. Unions are read through one walk (`_union_of`), so the standard optional form `Annotated[A | B, Field(discriminator='kind')] | None` (and inside a list, and a RootModel's own `Field(discriminator=)`) is checked and cut by its tag; an inner union's tag is carried outward only through None (`Annotated[A | B, tag] | C` or `... | list[C]` is an untagged union, refused), and every member of a tagged union declares the tag field as a Literal, and the tag field is read under its alias (else its name), its Literal through Optional and Annotated. One ResponseModel with scalars, None or containers needs no tag.
- **A union value is cut as the member its tag names**, never one chosen by validation; a value whose tag names no member, or a value of a union refused above, is cut down to the keys any member model declares (a list to nothing), never sent whole. A union with one model is cut as that model when the value validates as it, else through the dict or list member it validates as; a list no member accepts is cut to nothing.

### Upgrading a 1.30.0 project to 1.31.0

1. In `objects.py` (or `objects/base.py`), add `ResponseModel` and its helpers (`_sent_keys`, `_union_of`, `_members`, `_model_members`, `_holds_a_response_model`, `_may_be_null`, `_dotted_models`, `projection_of`, `_projection_of_for_cut`, `_projection`, `projectedCopy`, `_str_tag`, `_tag_of`, `_cut`, `_cut_union`, `_adapter`, `_carries_several_models`, `_literal_values`, `_tag_value`, `_member_for`) from a fresh scaffold, and to `mongoDb.py` `_ProjectionStage`, `_cutResponse` and the `readResponse` / `readResponses` / `projectionStage` / `aggregateResponses` methods (with `aggregate`'s refusal of a projection stage), and delete `ProjectedSubdocument` and `_projection_of`. Replace every `_projection_of(` with `projection_of(`; a read whose model may hold a null sub-document goes through the read-and-cut helpers (an aggregation through `aggregateResponses`). Give every union of two or more response models a str discriminator.
2. `cd server && python sharedFileCheck.py --write` takes `responseModelCheck.py`. In `AppCreator.py`, take from a fresh scaffold the `responseModelCheck` import, the `responseModelProblems` check at the end of `_validate_route_meta`'s ApiRequests branch, and the `assertPublisherModels` step after `buildManifest` in `__main__`. In `route_config.py`, replace the decorator's BaseModel check with `assertResponseModel(func.__name__, responseModel)` (wire it last: every route must be a ResponseModel by then, or the app does not import). In `test_route_response_models.py`, take the `test_responseModelsAreResponseModels` and `responseModelProblems` tests.
3. Turn every route's responseModel, and every publisher's payload model, into a ResponseModel all the way down: a `Client<Name>` model becomes a ResponseModel listing the fields it SENT (its Omitted fields left out, its kept nested models their own ResponseModels), named for what it is (`ClientSalesOrder` becomes `SalesOrderResponse`, say). A computed field a response sends becomes a field the route fills. Each route then returns exactly those fields: `projection=projection_of(Model)` on its read, or `projectedCopy(Model, doc)` on what it built, or an explicit dict. Watch a normaliser that validates a whole stored sub-document: read it whole, normalise, then cut it down.
4. A publisher's payload must not change (consumers parse it with models generated from it): compare `pubsub_models.json` before and after (only model names may change), run `test_pubSubContracts.py` and `test_pubSubModels.py`, and `python pubSubModels.py --push` then commit in each consumer when a name changed.
5. Delete `server/narrowing.py`, the two `narrowing` blocks of `route_config.py` and the `narrowing` import of `conftest.modelKeys`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`, retype the client where a schema was renamed, and run the full suite, `test_unused_response_fields.py`, tsc and the build.

### 1.30.0 — response compression, client type-check gates, unused-response-field gate

- **app.py compresses its responses** with flask-compress (`flask-compress==1.25` in `requirements.txt`, with its brotli and, below Python 3.14, backports.zstd pinned beside it: `Brotli==1.2.0`, `backports.zstd==1.7.0`, all with Python 3.10 wheels). A JSON or CSV body of 500 bytes or more is sent as brotli at quality 4, else gzip at level 6, when the request's `Accept-Encoding` allows it, with `Vary: Accept-Encoding`; a smaller body, an error response, another mimetype and a request without `Accept-Encoding` go out as they are. AppCreator turns flask-compress's own hook off (`COMPRESS_REGISTER = False`) and generates `_compressResponse`, which leaves `NO_COMPRESSION_ENDPOINTS` alone.
- **Kill switch**: `COMPRESS_DISABLED=1` (or `true`, `yes`, `on`, any case) in the environment, read when app.py starts, turns compression off, and app.py logs `{'event': 'compressionDisabled'}` once at start-up. Set COMPRESS_DISABLED=1 on the Cloud Run service to turn compression off without a code deploy.
- **The routes that issue an access token are never compressed** (`createAccessToken`, BREACH: the token rides next to sign-in data the caller sent). The sliding `jwtToken` of the other routes is compressed: the client sends its token in an `Authorization` header from localStorage, never in a cookie, so a cross-site page cannot make a victim's browser send the authenticated requests BREACH needs. `AppCreator._compression_block` explains it; ARCHITECTURE.md has a paragraph.
- **`test_response_compression.py`** (new shared test) pins the settings, that a large JSON or CSV body is compressed and decodes to the same content, that small, error and unaccepted responses are not, that `NO_COMPRESSION_ENDPOINTS` is exactly the createAccessToken routes and that none of them is compressed, and that `COMPRESS_DISABLED` turns it all off (and is logged). Its start-up subprocess scrubs every cloud database variable from the child's environment. (The start-up subprocess tests were removed in 1.34.0.)
- **AppCreator compiles the schemas in one node process**: `server/tools/json2tsBatch.js` (new shared file) runs json-schema-to-typescript, the global library behind the json2ts CLI, over every `<Name>Schema.json` at once. The output is the CLI's, byte for byte (checked on all 895 schemas of the fleet), in seconds rather than a process per file. It pins the library's major version (`EXPECTED_MAJOR = 15`) and fails, naming both versions, on another; it writes the .ts files only once every schema compiled. AppCreator passes node's message on in the error; missing node fails with the install hint. The stale-schema deletion is unchanged.
- **`test_generated_client_is_fresh.py`** (new shared test) regenerates `server/schemas/*.json`, the client's `schemas/*.ts` and `CLIENT_API_DIR` (ServerRequests.ts and its companions) into a temp folder, with AppCreator's folder constants monkeypatched, and fails on any working-tree file that differs (CRLF/LF ignored), is missing, or that no model generates any more; hand-written files in CLIENT_API_DIR are ignored. It refuses a vacuous comparison (an empty output folder, no ServerRequests.ts or ApiResponse.ts, schema JSON and TS counts that differ). It writes nothing into the repo. (Removed in 1.34.0.)
- **`test_client_typecheck.py`** (new shared test) runs `npx --no -- next typegen` (Next 15.5 and later) and `npx --no -- tsc --noEmit --pretty false --project tsconfig.json` in the client project (the folder with tsconfig.json at or above `CLIENT_SCHEMAS_DIR`) and fails on any error, listed as file:line:col, code and message (capped, with the total). No baseline. The `--` is required: `npx --no tsc ... -p <dir>` makes npx take `-p` as its own --package option and never run tsc.
  A green run does not prove a removed field is safe: casts into client interfaces, string column names, stored JSON and Python callers get past tsc.
- **`server/clientNode.py`** (new shared file) holds what the client gates share: the client project folder, the node/npx and node_modules checks with their "npm install" hint (it also requires node_modules/next when package.json depends on next), running a command with a timeout, and `capped()` for long lists.
- Both gates need node and the client's node_modules and fail, never skip, without them; neither touches a database. CLAUDE.md's Frontend commands gain the type-check line.
- **`test_unused_response_fields.py`** (new shared test) fails while any route sends a field no hand-written client code reads, and on every dynamic-access site that hides which fields a response-typed value needs (`Object.keys/entries/values`, `JSON.stringify`, a rest element, a flow into a loose type). No baseline, no allowlist. It runs `client/scripts/unusedResponseFields.ts` (new shared file, ts-morph; SalesApp keeps it at `client/salesapp/scripts/`), which follows each `RouteResponseModels` type through the client and lists the unread property paths. A route is excluded only when code outside client/ reads its response: it publishes to Pub/Sub (its response is the payload), or a sibling calls it over HTTP (`<client>.request('<route>', ...)`). The brief says how to trim the top route. Needs node and the client's `ts-morph` and `tsx` (pinned exactly as dev dependencies: `ts-morph@28.0.0`, `tsx@4.23.15`; the scaffold installs them).
  The analyzer runs from the client project (client/salesapp/ in SalesApp), so the gate passes every `--skip` relative to that folder, and a self-test checks each one names an existing path there. A sibling that cannot be read fails the gate. Only while there is no fleet yet (the checked-in `pubsub_map.json` lists no repo but this one) is a sibling that was never checked out and is not in that map skipped (a fresh scaffold's example service: no sibling yet); once the map lists another repo, a missing sibling fails ("clone it or pass --repo"), mapped or not. A route published to two topics keeps both reasons.
- **`server/actions/ServiceToServiceActions.py`** (new, per project): `class ServiceToServiceActions`, a mixin combined into `ApiRequests` like the others, holds the routes another service calls over HTTP (a sibling repo, a job, a script). Their response is a contract with that service, so the unused-response-field gate skips every route defined there (reason "called by another service (ServiceToServiceActions)"). It still scans the siblings for HTTP calls, now as enforcement: a route a sibling calls that is not in that file fails ("move it there"), and so does a route there that no sibling calls (stale). Analyzer problems of an excluded route still fail. The scaffold writes it empty (with `server/actions/__init__.py`). When the gate lists unused fields, its brief first asks whether a route is called by another service, and its trim how-to ends with the short form.
- **`server/narrowing.py`** (new shared file): `Omitted` / `omitted()` mark a field a client-facing response model (`Client<Name>`, a subclass of the stored model) omits, so it drops out of the generated TS; `omitted_tree`, `prune_omitted` and `omitted_projection` (with `readWhole` for a field a route validates before pruning) derive the response pruning and the Mongo projection from the same marks; `omit_computed` drops an inherited computed field; `kept` retypes a kept nested field; `is_omitted` is the predicate. **route_config prunes every response** with `prune_omitted(result, omitted_tree(responseModel))` before its localdev check, in two `# >>> star-app per-repo` blocks (route_config.py is not a shared file).
- **`sharedFileCheck.py`**: a manifest entry may name `onlyProjects` (the only projects that have the file; never a fresh scaffold), the counterpart of `exceptProjects`.
- **`objects.py` has `NoContentResponse`**, the responseModel of a route whose result is its status and message alone: no client code reads data from it (`test_unused_response_fields.py`), so it returns `{}`.
- **A fresh scaffold's unused-response-field gate starts green.** The todo mutations (`createTodo`, `markTodoAsCompleted`, `markTodoAsUnfinished`, `deleteTodo`) and `getUserRoleTypes` answer `NoContentResponse` (`{}`): the starter page reads none of them (`DeletedTodoResponse` and `UserRoleTypesResponse` are gone). `loginWithGoogle` and `devLogin` answer `ClientUserView` (`ClientUser` omits `needToBeLinked` and checks only the id; the view omits `_version`). `client/lib/storedShapes.ts` (new, per project) has `storedUser` / `StoredUser`, and `AuthContext` stores the user field by field (`userData` is a `StoredUser`). The starter page has a localdev dev sign-in (`devGetUserList`, `devLogin`), as the fleet's login pages do. `conftest.modelKeys` skips Omitted fields. `test_pubSubModels.py`: a project with no push handler passes `test_everyHandlerParsesItsGeneratedMessage[(none)]` (the map leaves `handlers` out at its default).
- **The handshake sample no longer sends `projectName`** (the starter page shows the scaffolded name instead).
- **The Pub/Sub map types a module publisher's dict literal with its `PUBSUB_PAYLOAD_MODELS` model**, as it already did a route's with its `responseModel`: the unknown values of the returned dict take the model's types, and the payload names the model (`dictLiteral <Model>`). So a module publisher can build its payload as a plain dict, checked by `route_config.check_route_response` in localdev only, without the map losing its types. Regenerate the map (`cd server && python pubSubMap.py --write-all`).
- **`deleteAllDataInDatabaseForDevEnvironment` runs in localdev only** (`getisLocalDevEnvironment`, was `getIsDevEnvironment`, which lets clouddev through): it empties every collection, and clouddev runs on Cloud Run against Atlas. Every sign-in without proof was localdev-only already. `test_db_wipe_localdev_only.py` (new shared test; ecommerce covers its wipe in its own test_identity_bypass_localdev_only.py) checks it refuses under clouddev, localprod, cloudprod and an unknown ENVIRONMENT, over a fake database.

### Upgrading a 1.29.0 project to 1.30.0

1. Add `flask-compress==1.25`, `Brotli==1.2.0` and `backports.zstd==1.7.0; python_version < "3.14"` to `server/requirements.txt` and install them.
2. In `AppCreator.py`, take from a fresh scaffold the `from flask_compress import Compress` import line, the `COMPRESS_*` lines (the `COMPRESS_DISABLED` kill switch included) and `compress = Compress(app)` after `CORS(...)` in the Flask config, `_compression_block`, and its call in `generateAPI` (between the functions and the main block). Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
3. `cd server && python sharedFileCheck.py --write` takes `test_response_compression.py`, `test_client_typecheck.py`, `clientNode.py` and `tools/json2tsBatch.js`. Run them.
4. In `AppCreator.generateTypeScriptSchemas`, replace the per-file `json2ts` loop with the batch call from a fresh scaffold (the `node` check, then one captured `subprocess.run([node, tools/json2tsBatch.js, input_dir, output_dir])` that raises with node's stderr). Install `json-schema-to-typescript@15` globally.
5. Install the client (`cd client && npm install`) and fix every error `test_client_typecheck.py` lists. A Next.js 14 client has no `next typegen`: if it imports images, commit a `.d.ts` with `/// <reference types="next" />` and `/// <reference types="next/image-types/global" />`, since next-env.d.ts is gitignored.
6. `cd client && npm install --save-dev --save-exact ts-morph@28.0.0 tsx@4.23.15`; `sharedFileCheck.py --write` (step 3) also takes `narrowing.py`, `test_unused_response_fields.py` and `client/scripts/unusedResponseFields.ts`. Add the two `narrowing` blocks to `route_config.py` from a fresh scaffold (the import, and the prune before `check_route_response`). Then make `test_unused_response_fields.py` green: for each route it lists, a `Client<Name>` model with `Omitted` fields (or read the field by name in the client where something needs it), never shrinking a stored `*View` model.
7. In the handshake route and `HandshakeResponse`, drop `projectName` (a client that shows it can use the name it was scaffolded with).
8. Add `class NoContentResponse(BaseModel)` (an empty model, with the docstring from a fresh scaffold) to `objects.py`, or to `objects/__init__.py` through its own module, and use it as the `responseModel` of a route no client reads data from, returning `{}`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
9. Take `server/actions/ServiceToServiceActions.py` (and `server/actions/__init__.py` if you have no `actions/` package) from a fresh scaffold, import it in `ApiRequests.py` and add `ServiceToServiceActions` to `class ApiRequests`'s bases. Move every route a sibling calls over HTTP into it unchanged (same name, URL, behaviour and response; `test_unused_response_fields.py` names them), then run `cd server && ENVIRONMENT=localdev python AppCreator.py`. Add to CLAUDE.md's Architecture: routes another service calls over HTTP go in `actions/ServiceToServiceActions.py`.
10. In `mongoDb.py`, gate `deleteAllDataInDatabaseForDevEnvironment` on `AppConfig().getisLocalDevEnvironment()`, and run `cd server && python sharedFileCheck.py --write`, which takes `test_db_wipe_localdev_only.py`. Check the project's other dev-only paths (dev sign-in, a client-sent identity, any wipe) the same way: localdev only, never `getIsDevEnvironment`.

### 1.29.0 — generated Pub/Sub message models

- **`pubSubModels.py`** (new shared tool, with `test_pubSubModels.py`; pure stdlib, Python 3.10). A publisher commits `server/pubsub_models.json`: the serialization schema of every topic's payload model (its route's `responseModel`, or a `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}` entry for a publisher that is no route), with secret keys stripped. A consumer renders the topics it consumes into `server/objects_<publisherProjectName>.py`: one pydantic class per object schema, fields and types only, and a `<Fn>Message` per topic. Never edit one, and keep logic out of it. `python pubSubModels.py` checks (exit 1 when a generated file is stale); `--pull` (`--write`) regenerates them from each sibling's committed manifest; `--push` pulls in every same-branch consumer checkout (it never commits); `--write-manifest` writes the manifest (it imports the app). A sibling's manifest is untrusted input (validated, bounded, rendered through `ascii()` literals), a sibling's code never runs, and a hand-written file at a target path is never overwritten.
- **AppCreator**: (B1) it pulls the generated models before it imports the app, since the handlers import them; (B2) it builds the manifest right after `validateRoutes()` (a publisher without a payload model stops the run) and writes it right before the map. `--constants-only` does neither.
- **Handler gate**: `test_everyHandlerParsesItsGeneratedMessage[<handler>]` requires each push handler to parse its whole message once with its generated `<Fn>Message` (`.model_validate(data)` or `(**data)`; named keywords such as `strict=True` are allowed) and to read the raw payload only in a single-type `except ValidationError:` fallback on that parse. The rule is `pubSubModels.handlerProblems`. `test_ifAllPubSubMockDataParsesWithGeneratedModels` (in the shared "Static contract tests" region of `test_pubSubContracts.py`) parses every mock message addressed to the repo with its generated model, as stored and as sent on the wire (`json.loads(pubSub.convertToJson(payload))`), and names the failing field.
- **Map**: a parse with a generated model is one root row, and reads of its nullable sub-models and lists (attributes, forwards, dumps) are tracked, so an unguarded read of a nullable one is a `nullable` issue.
- **Serializer guard**: the manifest refuses a payload model (at any depth) whose `field_serializer`, `model_serializer`, `PlainSerializer` or `WrapSerializer` has no return type, since the serialization schema would then state the declared type instead of what is sent.
- **`route_config.check_route_response(func, result)`**: the localdev response check for a payload published outside the route (a hand-publish site) or by a publisher that is no route. Its model is `func.responseModel`, else the `PUBSUB_PAYLOAD_MODELS` entry of func's module.
- **`PUBSUB_MESSAGE_MODELS` is retired**: defining it in any form is a hatch error, and the scaffold no longer defines it. `PubSubRequests.py`'s docstring example parses generated messages.
- **Deploy order**: see `ARCHITECTURE.md`, "Deploying strict consumers".

### Upgrading a 1.28.0 project to 1.29.0

1. `cd server && python sharedFileCheck.py --write`: it takes `pubSubModels.py`, `test_pubSubModels.py`, the map tools, `test_pubSubMap.py`, `test_sharedFiles.py` and the shared regions of `test_pubSubContracts.py`.
2. Apply the not-yet-shared parts by hand, from a fresh scaffold. In `AppCreator.py`: `_pubSubStep`, the pull before the app import (B1), and `buildManifest` / `writeManifest` around the generators (B2). In `route_config.py`: `check_route_response` and `_payload_model`. Then call `check_route_response(<publisher>, payload)` at every hand-publish site.
3. Give every publisher a payload model: a `responseModel` on its `@route_config`, or `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}` in the module of a publisher that is no route. Build its return value through that model, and give every serializer in a payload model a return type.
4. `cd server && ENVIRONMENT=localdev python AppCreator.py`: it writes `server/pubsub_models.json`, pulls `objects_<publisher>.py` for every publisher that has a manifest, and refreshes the map. Commit them. In a fleet, adopt in every publisher first, then run `--push` from it (or `--pull` in each consumer) and commit each consumer.
5. Migrate every handler, one or a few per commit, with its tests run each time:
   - parse with `message = <Fn>Message.model_validate(data)` and read only from `message`;
   - move the consumer logic into helpers that take the message and build what they store through this repo's own models (never a dump of the message: it does no empty-string coercion and carries the publisher's `_version`); a dump that reaches the database is python-mode `model_dump(by_alias=True, exclude_unset=True)`;
   - where the old handler acted on only a few fields, keep that action in an `except ValidationError:` fallback with the old lenient reads, so drift elsewhere in the payload does not drop it;
   - tests feed full payloads the publisher can produce.
6. Delete `PUBSUB_MESSAGE_MODELS` and its comment from `PubSubRequests.py`. Regenerate the map in every repo (`--write-all`), commit, and deploy in the order below.

**This fleet** (ecommerce, accounting, warehouse, salesApp): before ANY strict consumer deploys, back up `SalesOrders` (`mongodump --collection SalesOrders`), then run ecommerce's `server/migrate_legacy_order_shapes.py` against production, first as a dry run and then with `--apply`. Also run the prod-dump scan on each repo's locally restored `test<project>` database:
- SalesOrders with a numeric `freight` or a top-level `freightDocuments`, a flat `approvalHold`, a string `deliveryDate`, a missing `customerName` or `contactDetails`, or a fractional `itemsOrderList.quantity`;
- for every date-time path in each manifest, a stored value that is not a BSON date.

Fix each finding first (each data fix approved on its own), then deploy publishers before their consumers.

### 1.28.0 — response check in localdev only

- **`route_config` checks a response in localdev only**: there (and so in tests) a mismatch still raises `ValueError`, with the mutation guard. Every other environment skips the check entirely (no validation, no deep copy, no log), because validating every response cost every production call time. The `responseModelMismatch` warning, its `loc` masking (`_masked_loc` and its helpers) and the `logging`/`annotationLeaves` imports they needed are gone; the route's value is still returned unchanged.
- **conftest**: the `responseCheckWarnsOnly` fixture is now `responseCheckOff` (it turns the localdev check off for a test that pins a shape no model describes), and `mismatchRecords` is gone.
- **`test_route_response_models.py`**: the tests outside localdev pin that the check never validates, copies or logs; the warning and masking tests are gone.

### Upgrading a 1.27.0 project to 1.28.0

Take `_check_response_model`, the wrapper's check call and the imports from a fresh scaffold's `route_config.py`. In `conftest.py`, rename the `responseCheckWarnsOnly` fixture to `responseCheckOff` (and every test that uses it), take its comment from the scaffold and delete `mismatchRecords`. In `test_route_response_models.py`, take the scaffold's `_assertCheckSkipped` helper (it needs `from types import SimpleNamespace`) and its `...skippedOutsideLocaldev`, `test_check_neverValidatesNorCopiesOutsideLocaldev` and `test_responseCheckOff_fixtureTurnsTheCheckOff` tests in place of the warning tests, which you delete with any helper only they used.

### 1.27.0 — strict Pub/Sub contract check

- **Compat is strict**: every payload/handler mismatch (`undeclared`, `nullable`, `literal`, `numericNarrowing`, `forbiddenExtra`) fails `test_pubSubMap.py` and `python pubSubMap.py` in both repos of the flow, and nothing suppresses one: fix it at its source, the publisher's payload model or the consumer's handling (`--explain <handler>` shows both sides). `PUBSUB_CONTRACT_GAPS` is retired: defining it in any form, even `{}`, is a hatch error. `PUBSUB_PAYLOAD_MODELS` stays; it declares a model and suppresses nothing (1.29.0 retires `PUBSUB_MESSAGE_MODELS`). A flow's verdict is `ok`, `issues` or `unverified` (`knownGap` and `notEnforced` are gone), and the map's `formatVersion` is 3.

### Upgrading a 1.26.0 project to 1.27.0

Run `cd server && python sharedFileCheck.py --write` (it takes the new map tools and `test_pubSubMap.py`), then delete `PUBSUB_CONTRACT_GAPS` and its comment from `PubSubRequests.py` (going on to 1.29.0, delete `PUBSUB_MESSAGE_MODELS` and its comment too). Run `python pubSubMap.py`, fix every compat issue it reports at its source (in whichever repo owns it), then regenerate and commit the map in every repo of the fleet (`--write-all` regenerates them at once).

### 1.26.0 — Pub/Sub map and shared-file check

- **Pub/Sub map**: `pubSubMap.py` (with `pubSubMapScan.py`, `pubSubMapContract.py` and `pubSubMapSection.py`; pure stdlib, Python 3.10, no app import) writes `server/pubsub_map.json` and `server/PUBSUB_MAP.md`: every `@pubSubDecorator` publisher in the fleet, each consumer's handler, the payload paths each handler reads, and whether the publisher's types satisfy them. Siblings are found through every repo's `services/*.py` (`localRepoDirName`) and read at a committed git ref (this branch's name, else `main`); a missing one falls back to the checked-in snapshot and gets a gitignored `server/services/<name>.notFound.md` note (`--write` and the AppCreator hook refuse while a sibling this repo needs is neither checked out nor in the snapshot). `python pubSubMap.py` exits 0 only when the map is fresh and clean for this repo, equivalent to `test_pubSubMap.py` (1 = stale or a failing problem, 2 = an error); `--write` regenerates, `--explain <topic|handler|publisherFn>` prints live `file:line`, and `--repo name=path[@ref]` / `--worktree-siblings` override where siblings are read.
- **`--write-all`** regenerates the map here and in every sibling checkout on the same branch, with this repo's generator in-process (a sibling's code is never run). It skips other-branch, detached, missing and not-yet-adopted siblings with a reason, never stages or commits, and ends by listing the repos to commit.
- **`test_pubSubMap.py`** fails on a stale map, a consumer with no handler, a handler with no publisher, a message contract it cannot resolve, a publisher `pubSubAdmin.py` cannot see, a hand `publishMessage` on a topic that is not a decorated function's, a dead escape hatch or gap, and a compat issue (`undeclared`, `nullable`, `literal`, `numericNarrowing`, `forbiddenExtra`) on a consumer that enforces compat. Static and type-level only: the `test_pubSubContracts.py` replay stays the ground truth.
- **AppCreator** runs `writeMap()` at the end of a full generation (never with `--constants-only`), so a new scaffold's first run writes the initial map; a hard map error stops the run.
- **`PubSubRequests.py`** defines empty `PUBSUB_MESSAGE_MODELS` (a hatch 1.29.0 retires: a handler parses its generated message instead) and `PUBSUB_CONTRACT_GAPS` (`{'<handler>': {'<path>': {'<issue>': '<reason>'}}}`), so a new project enforces compat from the start (1.27.0 retires `PUBSUB_CONTRACT_GAPS`: compat is always enforced). A publisher's module may declare `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}`.
- **`sharedFileCheck.py`** and **`test_sharedFiles.py`** hold the files `fleet/sharedFiles.json` lists to this repo's committed HEAD (see "Shared files"). `--write` takes the template's version, `--template-worktree` compares with uncommitted template work, `--template <dir>` names another checkout.
- **Markers**: `test_pubSubContracts.py` marks its "Shared AST helpers" and "Static contract tests" regions, and `test_ifAllServicesMatchConsumerProjectName.py` a per-repo `SERVICES_WITHOUT_MOCK_DATA_YET` block.
- **`.gitignore`** ignores `server/services/*.notFound.md`.
- **`fleet/`** (new, outside the package): `sharedFiles.json` and the fleet-only `apiNamespace.py` and `test_objects_package_exports.py`.

### Upgrading a 1.25.0 project to 1.26.0

1. Mark the shared parts. In `server/test_pubSubContracts.py`, wrap the "Shared AST helpers" block and the static tests in `# >>> star-app shared: Shared AST helpers` / `# <<< star-app shared: Shared AST helpers` and `# >>> star-app shared: Static contract tests` / `# <<< star-app shared: Static contract tests`, and move anything project-specific (a wipe helper, a collection list) below them. In `test_ifAllServicesMatchConsumerProjectName.py`, wrap the `SERVICES_WITHOUT_MOCK_DATA_YET` line in `# >>> star-app per-repo: SERVICES_WITHOUT_MOCK_DATA_YET` / `# <<< star-app per-repo: SERVICES_WITHOUT_MOCK_DATA_YET`.
2. Copy `server/sharedFileCheck.py` from a fresh scaffold (not the raw `.tmpl`, which escapes `$`), check this repo out at `../create-star-app`, and run `cd server && python sharedFileCheck.py --write`: it writes the map tools and both new tests. Without a checkout, copy `pubSubMap.py`, `pubSubMapScan.py`, `pubSubMapContract.py`, `pubSubMapSection.py`, `test_pubSubMap.py` and `test_sharedFiles.py` from the fresh scaffold too.
3. Add the `writeMap` hook to the end of `AppCreator.py`'s `__main__`, the `server/services/*.notFound.md` line to `.gitignore`, and the two dicts (with their comments) to `PubSubRequests.py`. Defining `PUBSUB_CONTRACT_GAPS` turns compat enforcement on: fix, or record with a reason, every issue `python pubSubMap.py` reports, or leave the dict out until you can. Going on to 1.27.0, add neither dict if you are also taking 1.29.0 (it retires both); otherwise add only `PUBSUB_MESSAGE_MODELS`: 1.27.0 retires `PUBSUB_CONTRACT_GAPS`.
4. Run `cd server && python pubSubMap.py --write` and commit `pubsub_map.json` and `PUBSUB_MAP.md`. In a fleet, regenerate and commit them in every repo, with the same branch name checked out in each (siblings are read at this repo's branch name if they have it, else at `main`): `--write-all` regenerates every sibling checkout on the branch at once, and the commits stay manual.

### 1.25.0 — mock drift test

- **`test_mock_drift.py`** fails while a test's fake database returns a hand-written document. It scans every git-listed test file under `server/` (and `conftest.py`) for a db fake (`<...>.<db method>.return_value` / `.side_effect`, `MagicMock(return_value=...)` on a db method, `patch('<...>.db.<db method>', return_value=...)`, `patch.object(<...>, '<db method>', return_value=...)`) whose value is a dict literal, a `dict(...)` call, a list, tuple or `iter(...)` of them, or a name the same function (else the module) binds to one. Such a fake keeps its old shape when a model renames or removes a field, so the test passes against data the real database never returns. The failure lists each fake and how to fix it.
- **`fakeDoc(Model, **fields)`** in `conftest.py` builds a fake document through its model: it validates the fields, refuses any key the model does not declare (nested models and lists of them included) and returns the dump by alias (`_id`, `_version`, defaults). A value the model rewrites (`''` -> `None`) arrives rewritten; set it on the returned dict to feed a raw one.
- **Exemption**: a deliberately raw document (a legacy shape a normalization test repairs, a malformed record) keeps its dict and ends the fake's statement, or the statement binding the name, with `# raw-doc: <reason>` (a reason of at least 10 characters).
- **Known limits** (convert these by hand): documents returned from the body of a `def` used as a `side_effect`, and documents passed into a helper as parameters. Not scanned: anything in `venv`, `.venv`, `node_modules` or `__pycache__`.

### Upgrading a 1.24.0 project to 1.25.0

Copy `server/test_mock_drift.py` from a fresh scaffold (it is the same in every project; from 1.26.0 on, `cd server && python sharedFileCheck.py --write` takes it), and paste the `# --- Fake documents ---` block (`_declaredKeys`, `_unknownKeys`, `fakeDoc`) from its `server/conftest.py` into yours below `modelKeys`, adding `from pydantic import BaseModel, RootModel`. Run the test: an existing project usually fails at first. Convert each fake it lists to `fakeDoc(<Model>, ...)` (a repo factory such as `unitDoc` can wrap it), or mark a deliberate one `# raw-doc: <reason>`. Change test setup only, never an assert; a test that fails once its fake goes through the model was hiding a real mismatch, so report it.

### 1.24.0 — file size test

- **`test_file_sizes.py`** fails while any hand-written source file outside `client/` (`.py .ts .tsx .js .jsx .mjs .cjs .css`) is over 1,500 lines. The failure is a brief for the smallest oversized file: an outline with line ranges, how to split that kind of file (`ApiRequests.py` into `server/actions/<Name>Actions.py` mixins, an oversized `*Actions.py` into topic mixins, `objects.py` into an `objects/` package), the rules (move code only, retarget patches, never raise the limit or change an assert) and the checks that mark the split done (unchanged generated code, unchanged test count, full suite). Not checked: the client folder, files AppCreator generates (paths read from AppCreator.py), files whose name starts with `test`, and anything in `venv`, `.venv`, `node_modules`, `.next` or `__pycache__`.
- **`test_route_coverage.py`** fails when a route (a public `@route_config` method on ApiRequests or a PubSubRequests push handler) is not called by any test, as `api.<route>(...)` or a request to `/<route>`, outside `pytest.raises`. It also fails when a test calls a route but asserts nothing (no `assert`, no `pytest.raises`, no `assert*` helper or mock `assert_*`).

### Upgrading a 1.23.0 project to 1.24.0

Copy `server/test_file_sizes.py` from a fresh scaffold (it is the same in every project; from 1.26.0 on, `cd server && python sharedFileCheck.py --write` takes it) and run it. An existing project usually fails at first; split the files it names one at a time.

### 1.23.0 — typed API responses

Ports the typed-responses redesign (ecommerce, SalesApp, accounting, warehouse) into the templates:

- **Every route declares a `responseModel`** (`@route_config(responseModel=<objects.py model>)`); AppCreator refuses to generate without one (enforcement on from the start; `PubSubRequests` handlers are exempt). `route_config` checks every response against it: it raises in localdev (so in tests) and elsewhere logs `responseModelMismatch` with `type`/`loc` only (dict keys masked as `<key>`), never values, and never copies outside localdev.
- **Typed client**: `ServerRequests.ts` methods return `Promise<ApiResponse<Model>>`; the generated `ApiResponse.ts` is the `ApiSuccess<T>` / `ApiFailure` union built from the `STATUS_*` constants; `RouteResponseModels`, `RouteRequestBodies` and `PublicRoute` maps; imports derived from the routes; no `any`.
- **AppCreator**: schemas from the cached real `objects` module (BaseModel classes only, no silent skips), closed `$defs`, `$ref` siblings wrapped in `allOf`, the `X1` collision guard, stale-schema deletion, `json2ts` via `subprocess.run(check=True)`, `validateRoutes()` before any write, param guards (no date/datetime/time, `= None` needs Optional, renderable containers only, `objects.py` classes only), the secret-key guard, and the `CROSS_REPO_BARE_RESPONSE_ROUTES` allowlist (empty).
- **Generated handlers** bind the request body unconditionally (a body that is not a JSON object reads as {}, so a route's params are None and it answers with its own envelope, never a 500), refuse an object or a list for a scalar param (a `{"$ne": null}` never reaches a query), log which of their own params a POST sent (never values), and a login route logs no request data. Pub/Sub push handlers parse the envelope inside the try (a malformed push is a 400) and log the messageId and the message's keys.
- **route_config** refuses `roleAccess` without `jwtRequired=True`; the mismatch log masks int dict keys too.
- **Local runs bind 127.0.0.1**; `LOCAL_BIND_HOST=0.0.0.0` in `server/.env` opens the server to the LAN, with a startup warning. Scaffolding writes a random `JWT_SECRET_KEY`, and app.py refuses one under 32 characters (or the old placeholder) outside localdev. Flask's `MAX_CONTENT_LENGTH` is 16 MB.
- **mongoDb.py** prints a query's keys, never its values.
- **objects.py**: copy-on-write empty-string coercion, `StoredDocumentKeys`, `validate_route_response`, `_projection_of` / `ProjectedSubdocument` / `projectedCopy`, typed sample models (`SampleTodoView`, the `SampleTodoRow` row model, response wrappers). (Superseded in 1.31.0: a response is a `ResponseModel`, read with `projection_of` and cut with `projectedCopy`; a `*View` is never a response.)
- **Sign-in**: `loginWithGoogle` verifies the Firebase ID token (google-auth with a cached cert set: an RS256 token with a known key id only, at most one fetch attempt a minute, the last good certs kept for 6 hours while Google is unreachable, an answer of the wrong shape refused), requires a verified email and the google.com provider, and accepts a raw identity in localdev only; `devLogin` / `devGetUserList` are localdev only. The project id is `FIREBASE_PROJECT_ID` in `sharedConstants.py`, read by the server and `client/lib/firebase.ts`; while it is the placeholder, sign-in fails closed with a warning. The first superAdmin is an atomic one-time claim. The client's `AuthContext.signInWithGoogle` sends `{ idToken }`.
- **utils**: strict `appDayStart`, `appDayBounds`, `parseIsoInstant`.
- **Tests**: `conftest.py` refuses non-localdev and non-`*Scratch` databases, unsets the cloud DB variables (again after AppConfig loads `.env`) and installs `_LocalOnlyMongoClient` (sync, async and `pymongo.synchronous`) before any import, and holds the sign-in fixtures; new `test_route_response_models.py`, `test_auth_surface.py`, `test_route_responses.py` and `test_route_responses_http.py`.
- **Client**: narrows on `status === 200` with the generated types, `eslint.config.mjs` bans importing `schemas/ApiResponseSchema`, and the starter is lint-clean (`npm run lint`: 0 errors); `DevClickToComponent` renders in development only.
- **Docs**: CLAUDE.md, ARCHITECTURE.md, DO_NOT_DO.md, `.coderabbit.yaml`, `.vscode/tasks.json` and a project section appended to `.claude/context/TYPE_SAFETY.md`.
- **Breaking for the sample routes**: `getRoles` answers `{roles}`, `getUserRoleTypes` `{userRoleTypes}`, `devGetUserList` `{users}`, `getTodos` `{todos}`; `FirebaseUserObject` moved to `objects.py` and `AuthHandler.getOrCreateUser` became `signInIdentity`.
- **Deferred**:
  - `json_schema_serialization_defaults_required` (T5) together with validation-mode request-param TS types (T5b). Routes return raw dicts and the model rules allow omittable fields (`X = Field(None)`), so a defaults-required serialization schema would type keys the route can leave out as always present. A model always returned as a full `model_dump()` may opt in per model (documented in `objects.py`).
  - A guard against `@computed_field` on response models. pydantic lists a computed field as required in the serialization schema, so the TS type declares it, but a route returning a raw dict never sends it. The existing repos use computed fields, so it is documented (DO_NOT_DO.md, `objects.py`) rather than enforced.
  - Requiring an allowlist of bootstrap superAdmin emails outside localdev (the first sign-in on an empty Users still becomes superAdmin, now through an atomic claim).

### Upgrading a 1.22.0 project to 1.23.0

The scaffold overwrites every template file in place, so an existing project upgrades by hand. In this order:

a. **Server infrastructure.** Re-scaffold into a scratch directory with the same name and port, then copy over `route_config.py`, `AppCreator.py`, `conftest.py`, `utils.py` (the date parsers and annotation walkers), `AuthHandler.py`, the base of `objects.py` (from the top through `ApiResponse`, plus `UserView`, `FirebaseUserObject`), the `mongoDb.py` print changes, `sharedConstants.FIREBASE_PROJECT_ID`, the `'Bootstrap'` entry in `pubSubMockDataGenerator.py`'s `TEST_COLLECTIONS`, `services/__getIsProductionEnv.py`, and the four new `test_*.py` files. Copy the sign-in routes of `ApiRequests.py` (`loginWithGoogle`, `devGetUserList`, `devLogin` and the `_requireLocalDev` helper) and keep your own models and other routes.
b. **A `responseModel` on every route.** Add `responseModel=<objects.py model>` to every `ApiRequests` route: a `*View(StoredDocumentKeys, Model)` for a stored document, a row model with `projection=_projection_of(Row)` for a projected read, a `...Response` wrapper otherwise. AppCreator refuses to generate until every route has one. Give `roleAccess` routes `jwtRequired=True`. (Superseded in 1.31.0: every responseModel is a `ResponseModel`, read with `projection_of(Model)` and cut with `projectedCopy`; see the 1.30.0 to 1.31.0 upgrade.)
c. **Wrap bare answers.** A route returning a list or a primitive now returns `{'key': value}`; update its callers (the sample routes: `getRoles` → `{roles}`, `getUserRoleTypes` → `{userRoleTypes}`, `devGetUserList` → `{users}`, `getTodos` → `{todos}`). Never reshape a `@pubSubDecorator` route or a route another service reads.
d. **Route params.** Replace `datetime`/`date` params with an ISO `str` parsed by `utils.appDayStart` / `appDayBounds` / `parseIsoInstant`, a bare `dict`/`list` with a model or a typed container, and a `= None` default without `| None`.
e. **Sign-in.** Put the Firebase project id in `sharedConstants.FIREBASE_PROJECT_ID` (or the `FIREBASE_PROJECT_ID` env var), have the client send `loginWithGoogle({ idToken })` (`AuthContext.signInWithGoogle`), and deploy the backend and frontend together: the old raw-identity login is refused outside localdev.
f. **Secrets and local runs.** Give every deployed environment a `JWT_SECRET_KEY` of at least 32 random characters before deploying (app.py refuses a weaker one outside localdev). Local runs now bind `127.0.0.1`; set `LOCAL_BIND_HOST=0.0.0.0` in `server/.env` only if devices on your network must reach it.
g. **Client and tests.** Copy `client/eslint.config.mjs`, `client/lib/AuthContext.tsx`, `client/lib/firebase.ts` and `client/app/layout.tsx`, run `ENVIRONMENT=localdev python AppCreator.py`, fix every `npx tsc --noEmit` error by narrowing on `res.status === 200` (no `res.data as X`), and run the tests with `ENVIRONMENT=localdev databaseName=test<projectName>Scratch pytest` (conftest refuses any other database).
