toga-ai 1.0.1002 → 1.0.1004

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/knowledge/1.0/apps/library/INDEX.md +1 -0
  2. package/knowledge/1.0/apps/library/features/app-cloud-aws-credentials.md +63 -0
  3. package/knowledge/1.0/apps/library/features/error-capture-1-0.md +5 -3
  4. package/knowledge/1.0/apps/tools/features/cloudfront-client-setup.md +5 -2
  5. package/knowledge/1.0/apps/worker/architecture.md +2 -2
  6. package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
  7. package/knowledge/2.0/apps/_underscore/features/cloud-s3-helpers.md +23 -3
  8. package/knowledge/2.0/apps/_underscore/features/cloud-secrets-manager.md +7 -4
  9. package/knowledge/2.0/apps/_underscore/features/event-publish-sqs.md +15 -5
  10. package/knowledge/2.0/apps/api2/architecture.md +2 -2
  11. package/knowledge/2.0/apps/api2/workflows/codepipeline-codeconnections-deploy.md +6 -1
  12. package/knowledge/2.0/apps/saml/architecture.md +3 -3
  13. package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
  14. package/knowledge/2.0/apps/toga2-commerce/architecture.md +6 -6
  15. package/knowledge/2.0/apps/toga2-commerce/features/catalog-cache-freshness.md +106 -120
  16. package/knowledge/2.0/apps/toga2-commerce/features/force-logout-on-deployment.md +7 -6
  17. package/knowledge/2.0/apps/toga25-supply/features/force-logout-on-deployment.md +4 -3
  18. package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
  19. package/knowledge/2.0/apps/worker2/features/committed-secrets-and-config-debt.md +9 -2
  20. package/knowledge/2.0/apps/worker2/features/cross-account-aws-access.md +23 -14
  21. package/knowledge/2.0/standards/backend-php.md +14 -16
  22. package/knowledge/INDEX.md +3 -1
  23. package/knowledge/clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md +5 -1
  24. package/knowledge/registry.json +18 -0
  25. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  | [Address Validation Gateway (App_Api_OfficeDepot::validateAddress + USPS fallback)](features/address-validation-gateway.md) | The shared 1.0 address-validation gateway (`App_Api_OfficeDepot::validateAddress` → USPS fallback); open when standardizing/verifying a typed address before sav |
7
7
  | [API Request Logging & Retention (App_ApiTransaction → Logs.API / Logs_<Tenant>)](features/api-request-logging-and-retention.md) | Where every outbound API call is logged (1.0 `Logs.API` vs the 2.0 `Logs_<Tenant>` cluster), how the routing is chosen, and the 30-day retention trim; open befo |
8
8
  | [Where a new App_ class goes — the app/ folder IS a behavioral contract](features/app-class-placement-base-contracts.md) | Where a new `App_` class goes — each `app/` folder's base class carries real inherited behavior; open before creating any class under `app/model|client|api`. |
9
+ | [1.0 AWS credentials — App_Cloud assumes WorkloadsRuntime (no IAM keys)](features/app-cloud-aws-credentials.md) | How every 1.0 AWS call (App_Cloud CLI + SDK, deploy scripts, `setup_git_libraries.php`) gets credentials by assuming `WorkloadsRuntime` from the EC2 instance ro |
9
10
  | [App_Sso — Reusable 1.0 SSO Initiation (SP-initiated SAML via saml.togahub.com)](features/app-sso-initiation.md) | Reusable 1.0 SP-initiated SAML SSO via `saml.togahub.com` (`App_Sso::initiate`); the canonical doc to follow for any "add SSO to `<1.0 app>`" request. |
10
11
  | [Carrier detection from a tracking number (App_Misc) — patterns, check digits, shipping-method fallback](features/carrier-detection-from-tracking-number.md) | How 1.0 decides which carrier a tracking number belongs to (`App_Misc`, `library/app/misc.php`): certain vs shared-shape patterns, the FedEx check digit, and th |
11
12
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | 1.0 cron check-in/out lifecycle via `App_Framework::cronInitialization()`/`cronFinished()` → `Common.CronJobExecutions`; open to monitor a 1.0 cron or read its |
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: "1.0 AWS credentials — App_Cloud assumes WorkloadsRuntime (no IAM keys)"
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-12
10
+ owners: [jcardinal]
11
+ files:
12
+ - library/app/cloud.php
13
+ - library/app/systemmonitor/rdsproductioncluster.php
14
+ - worker/ebs/cron.worker.php
15
+ - toga/ebs/register_instance_to_shared_application_load_balancer.php
16
+ - tools/ebs/check_s3fs.php
17
+ related:
18
+ - ../../../../2.0/apps/worker2/features/cross-account-aws-access.md
19
+ - ../../../../2.0/apps/_underscore/features/cloud-s3-helpers.md
20
+ - ../../tools/workflows/deploy-to-elastic-beanstalk-al2023.md
21
+ - ../architecture.md
22
+ ---
23
+
24
+ How every 1.0 AWS call (App_Cloud CLI + SDK, deploy scripts, `setup_git_libraries.php`) gets credentials by assuming `WorkloadsRuntime` from the EC2 instance role; open before touching 1.0 AWS access or EB deploy hooks.
25
+
26
+ ## Summary
27
+
28
+ No long-lived IAM keys anywhere (TRUE-82705, 2026-10-12 — the old 502 IAM-user key leaked and was deleted). 1.0 code assumes role `WorkloadsRuntime` via STS using the EB EC2 instance role. Default account = legacy **502614707982** (same access the deleted key had; `toga-private`, `toga-public`, `agilant-as2`, `asifiles` live there). Role model, accounts, trusts, permissions: [cross-account AWS access](../../../../2.0/apps/worker2/features/cross-account-aws-access.md#the-workloadsruntime-role-model). 2.0 twin: [`_Cloud` credentials](../../../../2.0/apps/_underscore/features/cloud-s3-helpers.md#credentials).
29
+
30
+ ## How it works
31
+
32
+ ### App_Cloud (`library/app/cloud.php`)
33
+ - `App_Cloud::getCredentialProvider($awsAccountId = AWS_ACCOUNT_ID_LEGACY)` → memoized SDK provider per account. Session name `library-app-cloud`.
34
+ - STS client credentials = `CredentialProvider::memoize(CredentialProvider::instanceProfile())` — **never the default chain** (see Gotchas).
35
+ - Local dev: if `AWS_PROFILE` is set, that profile (SSO) is used directly, no assume.
36
+ - `initialize()` rebuilds `App_Cloud::$preCommands` on **every** call: exports `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` for the CLI. Refreshes when < 900 s left (`AWS_CLI_CREDENTIALS_MIN_SECONDS_LEFT`). Long crons must call `initialize()` (any App_Cloud CLI method does) — never cache `$preCommands`.
37
+ - `addSqsMessage()` assumes into the account named in the queue URL (1.0 worker queue + 2.0 `WorkerProductionQueue` are in 868).
38
+ - `library/app/systemmonitor/rdsproductioncluster.php`, `worker/crons/infrastructure/cloudwatch.php`, `worker/crons/maintenance/migrate_s3_files.php` use App_Cloud — no own credentials.
39
+
40
+ ### Standalone deploy scripts (no library loaded)
41
+ `worker/ebs/cron.worker.php`; `ebs/register_instance_to_shared_application_load_balancer.php` in `toga`, `store`, `vision`, `api`, `toga2-commerce`; `tools/ebs/check_s3fs.php`. Pattern:
42
+ 1. `unset` every `AWS_*` env var, then `aws sts assume-role --role-arn arn:aws:iam::502614707982:role/WorkloadsRuntime ...`.
43
+ 2. Export the 3 values with `escapeshellarg()` as a command prefix.
44
+ 3. On failure: `error_log` and continue — never abort a crontab install or deploy.
45
+ `cron.worker.php` also validates the instance id (`/^i-[0-9a-f]{8,17}$/`) before terminate.
46
+
47
+ ### `s3://asifiles/system/setup_git_libraries.php` (not in any git repo)
48
+ Downloaded by **every** legacy EB deploy (`.ebextensions/020_setup_git_libraries.config`, `wget`); clones `library`, `_underscore`, etc. after a CodePipeline lookup. Uses the same assume-role pattern (session `setup-git-libraries`). Edit = download, change, re-upload **with `--acl public-read`** (wget is anonymous).
49
+
50
+ ### 1.0 worker deploy
51
+ CodePipeline `Worker` (502, us-west-2) sources `_production` of **both** `agilantsolutions/worker` and `agilantsolutions/library` → EB `agilant-worker`. A library-only merge to `_production` redeploys the worker.
52
+
53
+ ## Gotchas
54
+
55
+ - **Never use the default credential chain for the STS client.** Old EB env properties (`agilant-worker` has `AWS_ACCESS_KEY_ID`/`AWS_SECRET_KEY`) still carry the dead key, and the default chain picks env vars first → `InvalidClientTokenId`. Always `instanceProfile()`.
56
+ - **Never put `$cmd` in an exception or log message.** It contains `$preCommands` = live temporary credentials.
57
+ - **CLI temp creds are visible in argv (`ps`).** Open follow-up: CLI profile with `credential_source=Ec2InstanceMetadata`.
58
+ - **`setup_git_libraries.php` failing is silent.** When the key died its pipeline lookup failed and **library was not updated on deploy** — the old library kept running. If a library change "didn't deploy", check this script first. It was public-read with the key inside — a likely leak path; never put a credential in it.
59
+ - Verify assume-role in CloudTrail (`AssumeRole` on `WorkloadsRuntime`, by session name: `library-app-cloud`, `setup-git-libraries`, `register-instance`, `worker-deploy`).
60
+ - Open, not fixed (developer owns): remove `AWS_ACCESS_KEY_ID`/`AWS_SECRET_KEY` from `agilant-worker` EB env properties.
61
+
62
+ ## Change history
63
+ - 2026-10-12 — Created: removed the static IAM key from all 1.0 code/config; App_Cloud, deploy scripts and `setup_git_libraries.php` assume `WorkloadsRuntime` (TRUE-82705) (jcardinal)
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-18
9
+ updated: 2026-10-12
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - library/app/error/capture.php
@@ -121,10 +121,12 @@ Two things are specifically 1.0:
121
121
  - **⚠ A grouped `Logs.Issue` row can show a STALE error id — read the live api2 transaction log for the current error.** Two facts combine to mislead: (1) `Event.errorMessage` is **`varchar(255)`**, so a long API response is **truncated** — often before the trailing error id; and (2) `Logs.Issue.subject` / `Issue.errorMessage` hold the **first-occurrence** text, so a long-lived issue keeps showing the error id from whenever it was first seen even after the per-call error has changed. Observed 2026-08-17: issue `3H` still displayed `EO-1` / `3G-6` (the original undefined-method fatal) while the current per-call failure was actually `EV-11` (an FK 1451 on a DELETE). **To see what is failing *now*, read the live api2 transaction log (`Logs_<client>.Api.responsePayload`) for the specific call — not the grouped `Issue` row.**
122
122
  - **Any PHP warning inside capture code would kill the request.** 1.0's `App_Error::handleError()` routes **every** PHP warning into `handleException()`, which renders the red box and calls `exit()` — so a single warning raised while capturing (an unreachable IMDS endpoint is the everyday case: `file_get_contents` warns before returning `false`) terminates the request or cron mid-run while reporting an unrelated error. 2.0's equivalent handler *throws*, which a `catch` absorbs. `captureException()` therefore sets `App_Error::setThrowExceptionsEnabled(false)` for its own duration and restores it in `finally`. This disables the **handler**, not real throws — `App_Query` raises a plain `throw new Exception` on DB errors, so genuine failures still reach the catch. **This is a general 1.0 trap for any code that must not escalate**, not just error capture.
123
123
  - **No typed properties anywhere in `library/`.** 1.0 runs **PHP 7.2**; a deploy failed with `syntax error, unexpected '?'` on `private static ?string $memoryReserve`. Nine typed properties across `error.php`, `error/capture.php`, and `exception/business.php` were converted to untyped with `@var` docblocks. Parameter and return types are fine (7.0/7.1).
124
- - **An early `return` can make a fallback unreachable.** Two bugs in the first EB-environment attempt: a `return` when `get-config` was not executable skipped the `describe-tags` fallback entirely — i.e. the fallback was dead on the only platform that needed it — and the CLI call omitted `App_Cloud::$preCommands`, so it did not authenticate the way every other 1.0 AWS call does. **Unverified:** whether that IAM identity actually holds `ec2:DescribeTags`.
124
+ - **An early `return` can make a fallback unreachable.** Two bugs in the first EB-environment attempt: a `return` when `get-config` was not executable skipped the `describe-tags` fallback entirely — i.e. the fallback was dead on the only platform that needed it — and the CLI call omitted `App_Cloud::$preCommands`, so it did not authenticate the way every other 1.0 AWS call does. (`$preCommands` now assumes `WorkloadsRuntime`, which has `ec2:*` — see [1.0 AWS credentials](./app-cloud-aws-credentials.md).)
125
125
  - **Fingerprint scheme changes re-group existing issues once.** Moving to file+function frames and the class+entryPoint fallback changes hashes, so open issues re-fingerprint on first occurrence after deploy. Deliberate; merge old hashes onto the curated Issue with the Tools merge-fingerprint box.
126
126
  - **⚠ The legacy pre-Sentry handler dumps the entire config on any error it renders.** `library/app/DELETE_DOA_PRE-SENTRY_error.php` is a dead-named but still-reachable handler whose `htmlBacktrace()` does `print_r($entry['args'])` for **every** frame. On 2026-08-05 a real error page on **https://towfoundation.togaview.com** rendered ~26 pages containing the whole config array — production DB host/user/password, SES SMTP credentials, NetSuite credentials, Payeezy API key/secret/merchant token, SSL Store token — plus ticket PII. The current `library/app/error.php` is safe (it dumps only under `App_Registry::inDevMode()`), but **both** handlers gate on `inDevMode()`, so the affected host was **evaluating as dev mode in production**. Two actions: (1) verify `inDevMode()` per host in production before trusting any error page not to leak, and (2) delete the legacy handler. Rotation of every exposed credential **was escalated to Jeff on 2026-08-05; confirmation that it was completed is still owed** — treat those credentials as compromised until confirmed. (No values are recorded here or anywhere in the KB.)
127
- - **Pre-existing committed secrets (location only — not fixed, out of scope).** `library/app/cloud.php` holds a hardcoded **AWS access key id and secret access key** as class constants/statics (~lines 23–24), used by `App_Cloud::$preCommands` for every 1.0 AWS CLI call. Unrelated to this work. Treat as compromised: move to SSM like the other parameters and rotate.
127
+
128
+ ## Change history
129
+ - 2026-10-12 — Removed the committed-AWS-key gotcha: key deleted, `App_Cloud` assumes `WorkloadsRuntime` (TRUE-82705) (jcardinal)
128
130
 
129
131
  ## Related
130
132
 
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-12
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/_/app/devops/cloudfront.php
@@ -84,7 +84,10 @@ Behind Tools SSO; the nav action and page are gated to the **`Development Team`*
84
84
  - **Alternate domain names require a covering ACM certificate.** A wildcard cert covers only a single label — `*.togasupply.com` does not cover `a.b.togasupply.com`. Ensure the distribution has a cert covering the FQDN before adding it.
85
85
  - **Route 53 CloudFront alias records use the fixed `HostedZoneId` `Z2FDTNDATAQYW2`** (an AWS global constant for all CloudFront alias targets — not the zone's own ID).
86
86
  - **1.0 promotes undefined-index warnings to a thrown `ErrorException`** that would abort the whole "never fatally abort" run — all AWS-response array access is guarded. The SDK client-builder `catch` is widened to **`\Throwable`** because the constructor can throw `InvalidArgumentException`, not just `AwsException`.
87
- - **Credential exposure caveat.** This tool wields the prod IAM key, which amplified the impact of the separate config-file web-root exposure fixed this session — see the 1.0 back-end security standard and the Tools architecture Gotchas. Every secret in `config.production.ini` still owes rotation because the file was publicly readable.
87
+ - **Credential exposure caveat.** The static IAM key this tool used is deleted (2026-10-12); AWS access now comes from the instance role — see [1.0 AWS credentials](../../library/features/app-cloud-aws-credentials.md). Every other secret in `config.production.ini` still owes rotation because the file was publicly readable (Tools architecture Gotchas).
88
+
89
+ ## Change history
90
+ - 2026-10-12 — Static IAM key deleted; caveat now points at the instance-role credentials doc (TRUE-82705) (jcardinal)
88
91
 
89
92
  ## Related
90
93
  - [Tools Architecture](../architecture.md)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-12
10
10
  owners: [jcardinal, sking, bala]
11
11
  files:
12
12
  - worker/index.php
@@ -171,4 +171,4 @@ Container commands set up the box per deploy: apache tuning, force HTTP→HTTPS,
171
171
  - **Timezone is `America/Chicago`** everywhere; cron expressions are in that zone, but several `togadesk/` UMA jobs are commented with their UTC equivalents — read the `name` carefully.
172
172
  - **Two stale-worker cutoffs exist** (270 s in the heartbeat, 7 min in the deploy script) and the `$desiredWorkers` list is duplicated in `ebs/cron.worker.php` and `App_Worker` — keep them aligned when changing roles.
173
173
  - **Per-role Elastic IPs** mean a role's outbound IP is stable across instance replacement — relevant for partner allow-lists (EDI/SFTP endpoints).
174
- - **⚠ Committed secrets.** Several files contain **live credentials checked into the repo**: AWS access keys (`ebs/cron.worker.php`, and in a comment in `crons/worker/worker_heartbeat.php`), GitHub PATs (`ebs/git.json`), and a Sentry DSN (`_/app/framework.php`). Rotate and move to environment variables / SSM Parameter Store. (Values deliberately not reproduced here.)
174
+ - **⚠ Committed secrets.** Live credentials checked into the repo: GitHub PATs (`ebs/git.json`) and a Sentry DSN (`_/app/framework.php`). Rotate and move to env/SSM. The AWS keys (`ebs/cron.worker.php`, and the `crons/worker/worker_heartbeat.php` comment) are gone (2026-10-12): the deploy script now assumes `WorkloadsRuntime` — see [1.0 AWS credentials](../library/features/app-cloud-aws-credentials.md). The 1.0 worker deploys from CodePipeline `Worker` (502, us-west-2), which sources `_production` of both worker and library. (Values not reproduced.)
@@ -16,7 +16,7 @@
16
16
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind Fulfill & Ship: buying/persisting a carrier label (UPS/FedEx) and creating the NetSuite Item Fulfillment; open for label, reprint, retu |
17
17
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | Minimum recipe + traps for running real 2.0 framework code (`_Model`/`_Query`/`_Database`) from a bare `php script.php`; open before writing a CLI harness/backf |
18
18
  | [Client Users require a contactId — the postPost backfill interceptor & every creation path](features/client-user-contactid-required.md) | Every `Client_<Tenant>.Users` row must have a non-null `contactId`; the shared `_Model_Client_User::postPost` interceptor guarantees it for api2-created users, |
19
- | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` S3 helpers (copy/get/delete/list) with per-bucket region override and a 5 GB single-PUT limit; open before doing S3 access from 2.0 code. |
19
+ | [_Cloud credentials (WorkloadsRuntime) + S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` credentials (assumes `WorkloadsRuntime`, no IAM keys) and S3 helpers (copy/get/delete/list, per-bucket region, 5 GB single-PUT limit); open before any |
20
20
  | [_Cloud Secrets Manager helper (_Cloud::getSecret)](features/cloud-secrets-manager.md) | `_Cloud::getSecret` reads AWS Secrets Manager (own default-provider auth, never throws, per-process cache); open before pulling secret material at request time. |
21
21
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Project-local `_Component_*`/`_Model_*` must declare the app namespace AND live in a `<Name>/<Name>.php` directory, or the autoloader fatals at runtime; open wh |
22
22
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('prop', false)` does NOT rescue a MISSING GROUP (only a missing prop) — guard with `_Config::isGroupSet()`; open before reading per-environmen |
@@ -1,22 +1,24 @@
1
1
  ---
2
- title: _Cloud S3 helpers (copy / get / delete / list)
2
+ title: _Cloud credentials (WorkloadsRuntime) + S3 helpers (copy / get / delete / list)
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-12
10
10
  owners: ["jcardinal", "bala", "rgirish"]
11
11
  files:
12
12
  - _underscore/Cloud.php
13
13
  related:
14
+ - ../../worker2/features/cross-account-aws-access.md
15
+ - ../../../../1.0/apps/library/features/app-cloud-aws-credentials.md
14
16
  - ../../worker2/features/oneuptime-worker2-monitoring.md
15
17
  - ../../worker2/features/compass-vip-support-importer.md
16
18
  - ../../worker2/features/s3-folder-retention-cleanup.md
17
19
  ---
18
20
 
19
- `_Cloud` S3 helpers (copy/get/delete/list) with per-bucket region override and a 5 GB single-PUT limit; open before doing S3 access from 2.0 code.
21
+ `_Cloud` credentials (assumes `WorkloadsRuntime`, no IAM keys) and S3 helpers (copy/get/delete/list, per-bucket region, 5 GB single-PUT limit); open before any AWS access from 2.0 code.
20
22
 
21
23
  ## Summary
22
24
 
@@ -24,6 +26,17 @@ related:
24
26
 
25
27
  ## How it works
26
28
 
29
+ ### Credentials
30
+
31
+ No static key (TRUE-82705, 2026-10-12). Role model + accounts: [cross-account AWS access](../../worker2/features/cross-account-aws-access.md#the-workloadsruntime-role-model). 1.0 twin: [App_Cloud credentials](../../../../1.0/apps/library/features/app-cloud-aws-credentials.md).
32
+ - `_Cloud::getCredentialProvider(?string $awsAccountId = null)` → memoized, self-refreshing SDK provider for `WorkloadsRuntime` in that account (default `AWS_ACCOUNT_ID_DEFAULT` = 502614707982; session `_underscore-cloud`; `[cloud] workloads_role_name` / `workloads_role_session` override). Pass it as `'credentials'` to any SDK client — worker2 `VipSupport` and `ZoomInfo` do.
33
+ - The STS client uses `CredentialProvider::instanceProfile()` (memoized), never the default chain. If `AWS_PROFILE` is set (local dev), that profile is used directly.
34
+ - `initialize()` rebuilds `_Cloud::$preCommands` on every call (exports `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` for the CLI); refreshes when < 900 s left (`AWS_CLI_CREDENTIALS_MIN_SECONDS_LEFT`).
35
+ - `addSqsMessage()` assumes into the account in the queue URL (`getAccountIdFromSqsQueueUrl()`).
36
+ - `getParameter()` returns `''` when credentials fail, so `_Database` falls back to its backup host instead of fataling.
37
+
38
+ ### S3 helpers
39
+
27
40
  - **Entry point:** `_underscore/Cloud.php` — `_Cloud::getS3Objects($s3BucketName, $s3BucketPath = '', ?string $awsRegion = null)`.
28
41
  - `getS3Objects()` uses the AWS SDK `S3Client->listObjectsV2` and transparently paginates (`listObjectsV2` caps at 1000 keys per page), returning entries shaped:
29
42
  - `Key` — object key
@@ -37,11 +50,18 @@ The config-default region is **us-east-1**. Buckets in another region must pass
37
50
 
38
51
  ## Gotchas
39
52
 
53
+ - **Never put a CLI `$cmd` in an exception or log** — it starts with `$preCommands` (live temp creds).
54
+ - **Never use the default credential chain for STS.** Old EB env properties can still hold the dead key; env vars win in the default chain → `InvalidClientTokenId`.
55
+ - **Deploy order:** `_underscore` is pulled at deploy, and api2/worker2/saml ini no longer carry a key — merge `_underscore` to `_production` **before** them.
56
+
40
57
  - Callers get every matching key regardless of count; pagination is handled internally, so do not add your own `ContinuationToken` loop on top.
41
58
  - A bucket outside the config-default us-east-1 (e.g. `agilant-as2` in us-west-2) throws `AuthorizationHeaderMalformed` unless you pass `$awsRegion`.
42
59
  - **⚠ `copyFileToS3` is a single `putObject` — it CANNOT upload a file larger than 5 GB.** Verified 2026-09-10 at `_underscore/Cloud.php:49`: the method issues one `putObject` call, and S3 caps a single PUT at **5 GB**. There is **no `MultipartUploader` anywhere in `_underscore` or `worker2`**. Anything that could produce a large artifact — a database dump above all — will fail on this helper once it crosses the limit. If you need large-object upload, add `MultipartUploader` explicitly; do not assume this helper scales.
43
60
  - **`getFileFromS3` (the GET method) is single-region.** It builds the `S3Client` with region `_Config::cloud('aws_region')` (the worker's own region) and only swallows HTTP 404. Reading an object from a bucket in a different region raises a `301 PermanentRedirect`, which it re-throws. To read a cross-region (or public) S3 object, use a direct HTTPS download instead, or construct an `S3Client` with the bucket's own region. (Unlike `getS3Objects()`, `getFileFromS3` has no region-override parameter.)
44
61
 
62
+ ## Change history
63
+ - 2026-10-12 — Added Credentials: `_Cloud` assumes `WorkloadsRuntime` via the instance role; static `[cloud]` key removed (TRUE-82705) (jcardinal)
64
+
45
65
  ## Related
46
66
 
47
67
  - [OneUptime worker2 monitoring](../../worker2/features/oneuptime-worker2-monitoring.md)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-12
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - _underscore/Cloud.php
@@ -27,7 +27,7 @@ related:
27
27
  Entry point: `_underscore/Cloud.php` — `_Cloud::getSecret(string $secretName, ?string $awsRegion = null): ?array`.
28
28
 
29
29
  1. Builds a `SecretsManagerClient` and calls `GetSecretValue` for `$secretName`.
30
- 2. **Auth is the AWS default provider chain** — the EC2 instance role in prod, a local profile/env in dev. It deliberately does **NOT** use the shared static `_Config::cloud` access key that the S3 helpers use. Reason: keep the blast radius small — leaking that one shared key must not also expose the SAML SP private keys. Grant the reading role its own Secrets Manager permission instead.
30
+ 2. **Auth is the AWS default provider chain** — the EC2 instance role in prod, a local profile/env in dev. It does **not** assume `WorkloadsRuntime` like the other `_Cloud` methods (see [_Cloud credentials](./cloud-s3-helpers.md#credentials)) — the reading instance role gets its own Secrets Manager grant, so the broad workloads role never needs access to the SAML SP private keys.
31
31
  3. `$awsRegion` defaults to `null`; pass the secret's region explicitly (SAML passes `'us-east-1'`).
32
32
  4. Returns the secret's JSON decoded to an **assoc array**. For the SSL secrets the keys are `certificate`, `privateKey`, `certificateChain` (optional), `passphrase` (optional).
33
33
  5. **Caches per PHP process** (function-static) — repeated reads of the same secret in one request hit the cache, not the API.
@@ -36,8 +36,11 @@ Entry point: `_underscore/Cloud.php` — `_Cloud::getSecret(string $secretName,
36
36
  ## Gotchas
37
37
 
38
38
  - **Returns `null` on failure, does not throw.** A caller MUST null-check the result; a silent `null` means "secret unavailable", not "empty secret".
39
- - **The role needs its own Secrets Manager grant.** Because it does not use the `_Config::cloud` key, an instance/role with no `secretsmanager:GetSecretValue` gets `null` for every secret. The `saml-production` EB instance role needed an inline policy added (see the SAML architecture Deployment notes).
40
- - **A committed AWS access key + secret were found in a code comment in `Cloud.php` `initialize()` and removed (2026-09-15).** Treat that key as leaked — it must be rotated separately from this change. Never store credential VALUES in source or comments; reference secret names only.
39
+ - **The role needs its own Secrets Manager grant.** Because it uses the instance role directly, an instance/role with no `secretsmanager:GetSecretValue` gets `null` for every secret. The `saml-production` EB instance role needed an inline policy added (see the SAML architecture Deployment notes).
40
+ - Never store credential VALUES in source or comments; reference secret names only. (A key found in a `Cloud.php` comment 2026-09-15 was the leaked `[cloud]` key — deleted 2026-10-12.)
41
+
42
+ ## Change history
43
+ - 2026-10-12 — Static `[cloud]` key is gone; wording now contrasts the instance role with `WorkloadsRuntime` (TRUE-82705) (jcardinal)
41
44
 
42
45
  ## Related
43
46
 
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [rgirish]
9
+ updated: 2026-10-12
10
+ owners: [rgirish, jcardinal]
11
11
  files:
12
12
  - _underscore/Event.php
13
13
  related:
@@ -29,8 +29,8 @@ response.
29
29
 
30
30
  `publish(object &$api, string $httpMethod, array $routeVars)`:
31
31
 
32
- 1. Reads `aws_event_queue_url` / `aws_event_queue_region` from `_Config::cloud()`; returns
33
- early if no queue URL is configured.
32
+ 1. Reads `aws_event_queue_url` / `aws_event_queue_region` from `_Config::cloud()` (no
33
+ default — see Gotchas).
34
34
  2. Determines the affected record's `recordRoute`, `recordId`, `uuid`.
35
35
  3. Derives `parentRoute` / `parentUuid` from `$routeVars` when the URL is a nested resource
36
36
  (`count($routeVars) >= 3` → `parentRoute = $routeVars[0]`, `parentUuid = $routeVars[1]`).
@@ -38,10 +38,17 @@ response.
38
38
  parentRoute, parentUuid, dtEvent }` and enqueues via `_Cloud::addSqsMessage()`.
39
39
 
40
40
  SQS credentials come from `_Config::cloud('aws_event_queue_access_key_id' /
41
- '..._secret_access_key')` — values live in config/cloud, never in code or this doc.
41
+ '..._secret_access_key')` — a static key, only in `api2/Config/beta.ini` (open: replace with
42
+ the `WorkloadsRuntime` provider, see [_Cloud credentials](./cloud-s3-helpers.md#credentials)).
43
+ Never put the values in a doc.
42
44
 
43
45
  ## Gotchas
44
46
 
47
+ - **⚠ Event publishing to SQS silently fails in every env except beta.** `aws_event_queue_*`
48
+ is read with no default, and only `beta.ini` sets it, so `_Config::cloud()` throws; the
49
+ `try/catch (\Throwable)` logs and moves on. Known (2026-10-12, TRUE-82705); developer chose to
50
+ leave it. Before relying on `record:changed` outside beta, add the keys or a default.
51
+
45
52
  - **`recordRoute` must come from `$routeVars[0]`, not `$api->record->route`.** Post-`POST`
46
53
  interceptors can swap `$api->record` to a *different* model before `publish()` runs (e.g.
47
54
  `service-requests`' `postPost` switches `$api->record` to the `service-request-bundles`
@@ -54,6 +61,9 @@ SQS credentials come from `_Config::cloud('aws_event_queue_access_key_id' /
54
61
  published events. Cast first: `$data = (array) $api->response->data;` then
55
62
  `$topRecord = (object) $data[array_key_first($data)];` before reading `->id` / `->uuid`.
56
63
 
64
+ ## Change history
65
+ - 2026-10-12 — Recorded that publishing throws (caught) outside beta: `aws_event_queue_*` has no default; static queue key still open (TRUE-82705) (jcardinal)
66
+
57
67
  ## Related
58
68
 
59
69
  - [websocket SQS fan-out](../../../../standalone/apps/websocket/features/sqs-fanout.md)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-10-12
10
10
  owners: [jcardinal, bala, mhammontree, dfranks]
11
11
  files:
12
12
  - api2/Controller/Index.php
@@ -115,7 +115,7 @@ Same shape as other 2.0 tiers. `.ebextensions/php_include_underscore.config` add
115
115
 
116
116
  ### Security note (committed secrets)
117
117
 
118
- - `Config/production.ini` (and dev configs) contain **plaintext production credentials** — Core/Client/Logs DB passwords, **AWS access key id + secret**, and third-party API keys (FedEx, UPS, PayPal, NetSuite, Cal.com, AIG, OptimumDesk, VAPI). The **`.ebextensions/git.*.json`** files (per-env — e.g. `git.json`, `git.sandbox-dev.json`) each carry a **plaintext GitHub Personal Access Token**, used by the `.platform/hooks/prebuild/git.sh` clone hook to pull `_underscore` at build. These must be **rotated** (treat the committed tokens as compromised) and moved to SSM Parameter Store / EB env properties. **Flag this if you touch config or deploy.** (Location + remediation only — do not record the token value anywhere.)
118
+ - `Config/production.ini` (and dev configs) contain **plaintext production credentials** — Core/Client/Logs DB passwords and third-party API keys (the `[cloud]` AWS key was removed and deleted 2026-10-12; `beta.ini` still holds the event-queue AWS key — open) (FedEx, UPS, PayPal, NetSuite, Cal.com, AIG, OptimumDesk, VAPI). The **`.ebextensions/git.*.json`** files (per-env — e.g. `git.json`, `git.sandbox-dev.json`) each carry a **plaintext GitHub Personal Access Token**, used by the `.platform/hooks/prebuild/git.sh` clone hook to pull `_underscore` at build. These must be **rotated** (treat the committed tokens as compromised) and moved to SSM Parameter Store / EB env properties. **Flag this if you touch config or deploy.** (Location + remediation only — do not record the token value anywhere.)
119
119
  - **Committed IAM access key (found 2026-07-28).** An IAM access key id + secret access key are hardcoded as PHP constants in `ebs/register_instance_to_shared_application_load_balancer.php` (**lines 29–30**) and are in **committed git history** (`git log -S`; commit subject *"Auto-registering to target groups"*) — exposed to every reader, clone, and EB bundle. **Remediation:** deactivate + delete the key in IAM, audit CloudTrail for its use, replace the block with the instance-profile pattern worker2 now uses. **Rotation, not history rewrite, closes the exposure**; a history rewrite is a separate sign-off-required decision because the team git rule forbids force-pushing `_main` in application repos. The same file also passes the IMDSv2 token on the `curl` command line (visible in `ps`/`/proc/<pid>/cmdline`, 6h TTL) and falls back silently to IMDSv1 — fix both alongside the key. *(Location + remediation only; no key material recorded.)*
120
120
 
121
121
  ### EB "Degraded" health alerts vs. the ALB's own view (read before triaging)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-12
10
10
  owners: ["jcardinal", "mhammontree", "bala", "apeterson"]
11
11
  files:
12
12
  - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
@@ -87,6 +87,8 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
87
87
 
88
88
  ## Gotchas
89
89
 
90
+ - **Prod approval gates differ by pipeline (868).** `api-production-1` and `saml-production` have a manual **Approve** stage; `Worker-Production` does **not** — a worker2 `_production` merge deploys at once. `_underscore` is pulled at deploy, so merge an `_underscore` change before the api2/worker2/saml change that needs it.
91
+
90
92
  - **⚠ The `_underscore` SOURCE ACTION has its own branch trigger — and a green pipeline hides a wrong one.** The Source stage's **`_underscore` action carries its own `BranchName`/branch trigger, configured in AWS and independent of the api2 environment it deploys to.** Nothing in the repo reveals it. A **second**, separate way for an environment to run the wrong framework branch — the first being `.ebextensions/git.<env>.json`. Both must be right. **Incident 2026-08-25 (`ApiSandboxClient`, multi-hour investigation):** the `_underscore` source action was pointed at **`_production`**, so the environment ran `_production` framework code, while: `git.sandbox-client.json` was **correct** (no `branch` key), so the one config you can `grep` exonerated itself; the pipeline UI displayed the **`_sandbox-client` merge commit `2b523788`**; the pipeline reported **"All actions succeeded"**; the prebuild clone deletes `.git`, so the instance carries **no commit id**. Two apparently unrelated live bugs (a Quad approval-email 500 and a Quad tooltip falling back to Core defaults) were the **same** stale box; `git merge-base --is-ancestor <fix> _production` proved neither fix was on the branch actually deployed. **Triage rule:** a green pipeline showing the right commit proves the *source artifact* was fetched — it does **not** prove which **branch** each source action watches. Read the source action config:
91
93
 
92
94
  ```bash
@@ -116,6 +118,9 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
116
118
  - **Credentials live in the committed `Config/*.ini` files (finding, 2026-08-10).** Both `worker2` and `api2` carry **live secrets in `Config/*.ini` files committed to those repos** — NetSuite OAuth `clientId`/`certificateId`, AWS keys, database passwords, a Talos agent key. Document only **where** they live, never the values. **Recommendation (not actioned):** move them to **EB environment properties or SSM Parameter Store** and treat anything currently committed as rotate-on-exposure. Related: api2's ALB auto-registration copy also carries a committed IAM access key (see the auto-registration note above).
117
119
  - **On-instance composer install (when a dep is missing post-deploy).** If a Composer dep is missing on the running instance (e.g. `Class "FPDF" not found` because `composer.lock` wasn't committed), you can install it on the box over SSH/PuTTY — but `composer require setasign/fpdf:^1.8` must have **no space** after the colon (`fpdf: ^1.8` parses `^1.8` as a separate package and errors), and run it as the web app user / `chown` the result so the webserver can read `vendor/`. The real fix is to **commit `composer.lock`** so EB's `composer install` picks it up — the on-instance install is a stopgap that a redeploy wipes.
118
120
 
121
+ ## Change history
122
+ - 2026-10-12 — Added prod Approve-stage gotcha and `_underscore`-first merge order (TRUE-82705) (jcardinal)
123
+
119
124
  ## Related
120
125
 
121
126
  - [ENVIRONMENT drives the `_underscore` branch](../features/environment-variable-drives-underscore-branch.md)
@@ -6,7 +6,7 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-28
9
+ updated: 2026-10-12
10
10
  owners: ["rgirish", "jcardinal"]
11
11
  files:
12
12
  - saml/index.php
@@ -93,13 +93,13 @@ Identity mapping lives in `_underscore/Model/<ClientIdentifier>/ClientAuthentica
93
93
 
94
94
  - **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026).** Any code that registers DBs then writes must call `_Database::transactionCommit()` before exiting — otherwise MySQL silently rolls back all writes on connection close. `/acs` calls `transactionCommit()` after `getAuthenticatedSsoUser()` succeeds and before the redirect. See `_underscore/Database.php:48`.
95
95
  - **SAML signature verification is commented out — repo's top security priority.** The IdP X509 cert verification block in `/acs` is disabled, so the gateway accepts any well-formed status-success `SAMLResponse` without proving it was signed by the expected IdP. The only remaining gate is the encrypted RelayState, which authenticates the *originating TOGa request* — not the *asserting IdP*. Threat: an attacker who can craft/replay a `SAMLResponse` (or a malicious/compromised IdP) can forge an identity assertion for any user. Re-enabling per-IdP X509 signature verification is the single highest-impact security fix. The per-client IdP verification certs already exist in source as base64 blobs (Compass, Office Depot, Prudential, RUMCSI, Agilant) — they are what a re-enabled verifier would load; verification is per-client and each IdP's signing cert must match.
96
- - **Plaintext secrets in source — move to AWS SSM Parameter Store.** Checked-in credentials, all live and to be rotated: `saml/Config/production.ini` holds a **DB password, an AWS access key id + secret, and NetSuite credentials** in cleartext; `saml/Controller/Index.php` hardcodes the **Sentry DSN**; `.ebextensions/git.json` carries a **GitHub PAT** (same leak class as `apiproxy`). Migrate to AWS SSM Parameter Store (SecureString), load at runtime; source tree should reference parameter names only, never literal credential values.
96
+ - **Plaintext secrets in source — move to AWS SSM Parameter Store.** Checked-in credentials, all live and to be rotated: `saml/Config/production.ini` holds a **DB password and NetSuite credentials** (its AWS key was removed 2026-10-12; AWS access = instance role → `WorkloadsRuntime`) in cleartext; `saml/Controller/Index.php` hardcodes the **Sentry DSN**; `.ebextensions/git.json` carries a **GitHub PAT** (same leak class as `apiproxy`). Migrate to AWS SSM Parameter Store (SecureString), load at runtime; source tree should reference parameter names only, never literal credential values.
97
97
  - **Stale test scaffolding in `/acs` — remove it.** A large commented-out block in `saml/Controller/Index.php` hardcodes captured real `SAMLResponse` payloads and a synthetic `RelayState` for Office Depot / Compass testing. It is inert (commented out) but captured assertions living in source are a confidentiality/hygiene risk — delete it.
98
98
  - **RelayState is still not replay-proof, even after the TTL fix.** The check was written backwards (`$relayState->time <= time() + MAX_TIME_TO_LIVE`, true for every past timestamp) and is now `abs(time() - $relayState->time) <= MAX_TIME_TO_LIVE` on `TRUE-81993` / `_sandbox-client`. **That is hygiene, not a control:** `_String::encryptWithKey()` is AES-256-CBC with **no MAC and an attacker-supplied IV**, so the `time` field can be rewritten without the key, and there is no nonce or single-use marking. Fix direction is AES-256-GCM or an HMAC checked with `hash_equals()` before decrypt. See [/acs assertion trust gaps](features/acs-assertion-trust-gaps.md).
99
99
  - **`/acs` must redirect only to a URL rebuilt by `safeRedirectUrl()`.** `_Page::headerLocationRedirect()` is a bare `header('Location: ')`, and the `?saml=` payload exchanges for real tokens at `POST /auth/encrypted-user-uuid` — so the destination is a credential delivery target. Redirect to the **return value**, never to the stored `Core.Domains.domain`: appending `'?saml='` to the stored string lets a stored path or query steal the handoff. A `Core.Domains` row (owned by the right client + environment) **is** the authorization — there is deliberately no host allowlist; do not add one. On `TRUE-81993` / `_sandbox-client`, not yet on `_production`.
100
100
  - **`/meta` cert lines must be `trim()`-ed (CRLF).** The `.crt` files in `_underscore/Assets/ssl` are CRLF-encoded; cert-format code does `explode("\n", ...)` and indents each line, so without a per-line `trim()` a stray `\r` ends up on every base64 line inside `<ds:X509Certificate>`. Trim each line in both signing and encryption blocks.
101
101
  - **SP encryption certs load from Secrets Manager at request time.** `/acs` holds no encryption key material on disk (`_underscore/Assets/ssl/` no longer carries the SP encryption key). Rotation = edit the `ENCRYPTION_SECRET_NAMES` list + the secrets themselves, not a redeploy of on-disk `.crt`/`.key` files.
102
102
  - **Non-prod SAML depends on prod KMS/Secrets Manager; one SP key serves every env** — a non-prod leak is a prod key leak. `ENCRYPTION_SECRET_NAMES` must hold full ARNs (short names resolve only in the caller's account).
103
- - **Committed AWS key must be rotated.** A hardcoded AWS key was removed from `_underscore/Cloud.php`; because it was committed to git history it is compromised — rotate it. Still open: plaintext creds in `production.ini` and the hardcoded Sentry DSN.
103
+ - **The leaked AWS key is deleted (2026-10-12, TRUE-82705).** `_Cloud` now assumes `WorkloadsRuntime` — see [_Cloud credentials](../_underscore/features/cloud-s3-helpers.md#credentials). `saml-production` has a manual Approve stage, so merge `_underscore` to `_production` first. Still open: plaintext DB/NetSuite creds in `production.ini`, and the hardcoded Sentry DSN.
104
104
  - **SLS not implemented.** `/sls` falls through to the default banner.
105
105
  - **`set_exception_handler(null)` at top of `saml()`.** Unhandled exceptions output raw PHP errors. All exceptions from `getAuthenticatedSsoUser()` are caught and sent to Sentry.
@@ -9,7 +9,7 @@
9
9
  | [Cart Order For user — Add New User duplicate guard + approving-manager rule](features/cart-order-for-user-and-approving-manager.md) | Cart "Order For" user section: the Add New User duplicate check (username OR email), who the Order For list can find, and the config-driven rule that blocks che |
10
10
  | [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | How the cart total (subtotal/shipping/tax/total) is computed and where shipping cost flows from; open when a cart total or shipping charge looks wrong. |
11
11
  | [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | Current state of the Cart form (field-config-driven, section-hardcoded, two dual-mode forms) plus the DECIDED-but-unbuilt config-driven refactor to the toga2.5 |
12
- | [Catalog cache freshness — the persisted query cache and the per-query staleTime overrides](features/catalog-cache-freshness.md) | The persisted React Query cache (`localStorage["commerce"]`) plus the per-query `staleTime` overrides that still serve prices up to 24h old, how to opt a query |
12
+ | [Catalog cache freshness — the persisted query cache, per-query staleTime overrides, and focus refetch](features/catalog-cache-freshness.md) | The persisted React Query cache (`localStorage["commerce"]`), the per-query `staleTime` overrides that served prices up to 24h old, focus refetch, how to opt a |
13
13
  | [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | Storefront category tile order is one DB column (`AssortmentItems.sortOrder` ASC) with a NULL-sorts-first trap; open when asked to reorder items on a category p |
14
14
  | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
15
15
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-21
9
+ updated: 2026-10-13
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/main.tsx
@@ -39,7 +39,7 @@ Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM
39
39
 
40
40
  **Multi-tenant:** one codebase serves several clients (**COMPASS**, **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from the **hostname** and drives three independent systems — API base URL, theme, per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and [client-fields](features/client-fields.md).
41
41
 
42
- **Critical rules:** Tenant is derived from hostname only (`hostname.toUpperCase().split(".")[0]`) — wrong host = wrong tenant, and `localhost` falls back (fields→COMPASS, theme→DEFAULT). The single `QueryClient` uses a **24h default `staleTime`** and persists the whole cache to **`localStorage["commerce"]`**, so catalog AND config/copy (FIELDS) can be up to 24h stale for a returning user — verify any data/copy change after `localStorage.removeItem('commerce')` or logout, never on a warm browser. `AuthLayout` never remounts, so `refetchOnMount` fires once per session — route-change refreshes must be wired explicitly. The registry French language key is **`fr-CA`**, not `fr` — branch on `startsWith("fr")`. api2 returns the standard `{success,data,errors}` envelope; do not break the shape.
42
+ **Critical rules:** Tenant is derived from hostname only (`hostname.toUpperCase().split(".")[0]`) — wrong host = wrong tenant, and `localhost` falls back (fields→COMPASS, theme→DEFAULT). The single `QueryClient` (`src/lib/queryClient.ts`) has default `staleTime: 0` but persists the whole cache to **`localStorage["commerce"]`**, so a returning user paints the old value first, and a per-query `staleTime` override keeps it stale (up to 24h on `_production`; removed on `TRUE-82427`, pending merge) — see [catalog cache freshness](features/catalog-cache-freshness.md); verify any data/copy change after `localStorage.removeItem('commerce')` or logout, never on a warm browser. `AuthLayout` never remounts, so `refetchOnMount` fires once per session — route-change refreshes must be wired explicitly. The registry French language key is **`fr-CA`**, not `fr` — branch on `startsWith("fr")`. api2 returns the standard `{success,data,errors}` envelope; do not break the shape.
43
43
 
44
44
  ## Tech stack (verified versions, package.json)
45
45
 
@@ -63,7 +63,7 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
63
63
  ## Bootstrap & provider stack
64
64
 
65
65
  - `src/main.tsx` — initializes **Sentry** (browser tracing + replay), mounts `<App/>` in React `StrictMode`, imports global CSS.
66
- - `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a `createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
66
+ - `src/App.tsx` — wraps the single `QueryClient` from `src/lib/queryClient.ts` (kept there so non-React code such as the 401 handler can reach it) with a `createSyncStoragePersister` (localStorage key **`"commerce"`**, build-id `buster`, `throttleTime: 0`). Provider nesting, **outermost → innermost**:
67
67
 
68
68
  ```
69
69
  PersistQueryClientProvider (client=queryClient, persister → localStorage "commerce")
@@ -75,7 +75,7 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
75
75
  ```
76
76
 
77
77
  - `App` runs a mount effect managing **edit-order mode**: reads a `localStorage.synced` flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned outside `/cart`; subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
78
- - The cache is **persisted**: query results survive refreshes. Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
78
+ - The cache is **persisted**: query results survive refreshes and paint before the refetch. Invalidate deliberately with `queryClient.invalidateQueries(...)` when freshness matters; freshness rules live in [catalog cache freshness](features/catalog-cache-freshness.md).
79
79
 
80
80
  ## Routing (`src/routes.tsx`, createBrowserRouter)
81
81
 
@@ -135,7 +135,7 @@ Most use the `persist` middleware (localStorage). Verified files:
135
135
  | `useSettingsErrorStore` | Settings validation errors (suppresses data queries when set) |
136
136
 
137
137
  ### React Query (`src/queries/`, `src/hooks/useApiQuery.ts`, `useApiMutation.ts`)
138
- - Global default `staleTime` 24h; per-query overrides (user/persona data fetched fresh on login).
138
+ - Global default `staleTime: 0`. `refetchOnWindowFocus` is `false` on `_production`, React Query's default `true` on `TRUE-82427` (pending merge). A per-query `staleTime` / `refetchOnWindowFocus` beats the default — see [catalog cache freshness](features/catalog-cache-freshness.md).
139
139
  - Cache persisted to localStorage (`"commerce"`); rehydrated on load.
140
140
  - Page queries are typically `enabled` on `isAuthenticated && user?.uuid && !settingsModalEnabled`.
141
141
 
@@ -192,7 +192,7 @@ The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors
192
192
  ## Gotchas
193
193
 
194
194
  - **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit invalidation for data that must refresh on navigation.
195
- - **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the events that should bust it. **This also masks config/copy deploys:** `FIELDS` is served *through* the `["clientFields", …]` query, so a returning user rehydrates the old labels from `localStorage["commerce"]` and sees stale copy for up to 24h after a build that contains the new JSON. Verify any FIELDS change after `localStorage.removeItem('commerce')` or a logout, never on a warm browser. A build-version `buster` in `persistOptions` would fix this at deploy time but invalidates every persisted query app-wide — **open team decision, not implemented.**
195
+ - **Persisted cache paints old data first** → catalog, pricing and `FIELDS` copy (served through the `["clientFields", …]` query) come from `localStorage["commerce"]` until the refetch lands; per-query `staleTime` overrides stretch that (see [catalog cache freshness](features/catalog-cache-freshness.md)). The build-id `buster` is live on `_production` (`e546db3d`) and drops the persisted cache on a new build. Verify any FIELDS or catalog change after `localStorage.removeItem('commerce')` or a logout, never on a warm browser.
196
196
  - **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS, theme → DEFAULT). Use the per-tenant dev scripts.
197
197
  - **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase (`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as `en` / **`fr-CA`** (not `fr`). Any language branch must use `startsWith("fr")` — a `=== "fr"` comparison silently never matches. See the client-fields doc.
198
198
  - **Tailwind: not every `<n>px` name is a token.** `max-h-300px` does not exist (300px is only on the height/width scales) — write `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist. A non-existent class silently renders unstyled.