toga-ai 1.0.462 → 1.0.464

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.
@@ -6,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-23
10
- owners: [jcardinal]
9
+ updated: 2026-07-28
10
+ owners: [jcardinal, mhammontree]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
13
  - worker/crons/toga2/aig/sync_togasupply_aig.php
@@ -90,6 +90,17 @@ bool $enabled2to1, bool $enabled1to2)`. When `enabled2to1`:
90
90
  - Pages 2.0 **`/contacts`** (for AIG, only those with `c_togaCustomerId IS NULL`) and upserts each
91
91
  into the 1.0 client DB `Customers`/`Addresses` via `syncContactToToga1Customer`, then **writes
92
92
  the new 1.0 id back** to the 2.0 contact's `c_togaCustomerId` (PUT `/contacts/{uuid}`).
93
+ - **Email storage (`syncContactToToga1Customer`, TRUE-79401):** the 1.0 `Customers.emailAddress`
94
+ column stores the contact's **full** list of 2.0 email addresses joined by the class constant
95
+ `CUSTOMER_EMAIL_DELIMITER = '; '`, not just the primary. The list is built from
96
+ `array_column((array)($togaContact->contactEmailAddresses ?? []), 'emailAddress')` →
97
+ trim / `array_filter` / `array_unique` → `implode` → `App_Database::sqlEscape` (escape **after**
98
+ imploding); an empty collection falls back to the single primary email (no regression). A
99
+ separately-escaped `$primaryEmailAddress` is retained **only** for the customer-dedupe lookup
100
+ (`WHERE Customers.emailAddress LIKE '$primaryEmailAddress'`) so storing a list doesn't break
101
+ record matching — dedupe is authoritatively keyed on **`c_togaCustomerId`**, with the primary-email
102
+ `LIKE` as a legacy fallback. Consuming the `contactEmailAddresses.emailAddress` collection required
103
+ adding it to the `/contacts` fetch `fields` whitelist (see gotcha).
93
104
  - Pages 2.0 **`/entitlements`** without a `c_togaServiceRequestId` and inserts 1.0
94
105
  `ServiceRequests` (+ a `Contacts` row if needed) via `syncEntitlementToToga1ServiceRequest`.
95
106
  - The 1→2 direction (`enabled1to2`) is a stub (not yet implemented).
@@ -154,11 +165,37 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
154
165
  the `App_Model` layer; follow the surrounding escaping discipline when modifying.
155
166
  - **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`;
156
167
  a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
168
+ - **A reverse hasMany collection is only returned if you add it to the fetch `fields` whitelist.**
169
+ The `/contacts` fetch requests an **explicit** `fields` list from the 2.0 metadata API; a reverse
170
+ hasMany collection (e.g. `contactEmailAddresses.emailAddress`) is **not** returned unless it is
171
+ added to that list — so consuming a related collection means editing **both** the fetch field list
172
+ **and** the consuming code. (The general 2.0-side metadata behavior — reverse relations resolved via
173
+ the child model's `FOREIGNKEY_MODEL` and named as the camelCase plural of the child model — is a
174
+ framework-level fact captured separately.)
175
+ - **`TOGA_AIG.Customers.emailAddress` is `varchar(255)`** (V1 legacy, latin1_swedish_ci) — wide
176
+ enough to hold several `'; '`-joined emails, so TRUE-79401 needed **no** schema/dbchanges migration
177
+ (the earlier fear that it was ~VARCHAR(55) was wrong). `TOGA_AIG` is the 1.0 legacy DB;
178
+ `Client_Aig` is the 2.0 prod tenant.
179
+ - **The multi-email path is not yet exercised (open, TRUE-79401).** As of 2026-07-28 all 20
180
+ `Client_Aig.ContactEmailAddresses` rows are one-per-contact, and **no** api2/`_underscore` code
181
+ references `UserDefined3`/`UserDefined4` or creates `ContactEmailAddresses` rows. Whether an api2
182
+ intake mapping is still needed depends on how Staples / SA.com sends the extra emails: if they POST
183
+ nested `contactEmailAddresses` records to the V2 API the library change is complete; if they send
184
+ flat `UserDefined3`/`UserDefined4` fields expecting us to map them into email rows, an api2 intake
185
+ mapping must be built. Open question owned by **Paulina**.
157
186
  - **PHP 7.2** target (prod worker/library) — no arrow functions, typed properties, `??=`, `match`.
158
187
  Lint with `C:\xampp7\php\php.exe -l`.
159
188
 
160
189
  ## Change history
161
190
 
191
+ - 2026-07-28 — TRUE-79401: `syncContactToToga1Customer` now stores a contact's **full** email list in
192
+ 1.0 `TOGA_AIG.Customers.emailAddress` as a `CUSTOMER_EMAIL_DELIMITER` (`'; '`)-joined, deduped,
193
+ escaped string (was primary-only) for AIG/Staples support lookup. Added
194
+ `contactEmailAddresses.emailAddress` to the `/contacts` fetch `fields` whitelist; kept a separate
195
+ escaped primary email for the dedupe `LIKE` (dedupe stays keyed on `c_togaCustomerId`); empty list
196
+ falls back to primary. No migration needed (`Customers.emailAddress` is `varchar(255)`). Scope stayed
197
+ library-only. Multi-email path not yet exercised — payload shape (nested `contactEmailAddresses` vs
198
+ flat `UserDefined3/4`) is open, owner Paulina. (mhammontree)
162
199
  - 2026-06-23 — Initial documentation of the `App_Api_Toga2` transport (auth/token caching, options
163
200
  DSL) and the 1.0↔2.0 bridge routines `syncWithToga` (Contacts/Entitlements → Customers/ServiceRequests)
164
201
  and `syncWithTogadesk` (bi-directional ticket/repair-order/people/asset sync with dual checkpoint
@@ -18,5 +18,6 @@
18
18
  | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql |
19
19
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
20
20
  | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TrackingNumber.php |
21
- | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | |
21
+ | [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
22
+ | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
22
23
  | [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
@@ -163,6 +163,13 @@ region-aware DB host selection; CloudWatch agent ships the JSONL log file;
163
163
  `long_gateway_timeout.conf` sets `ProxyTimeout 1800`/`Timeout 1800` — **the fix for the
164
164
  "exactly 60 second" 504** (EB Apache→PHP-FPM defaults to 60s); `enforce_https.conf`.
165
165
 
166
+ `.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh` +
167
+ `ebs/register_instance_to_shared_application_load_balancer.php` register a **non-production**
168
+ instance into the ALB target group whose name equals the EB environment name (production skipped;
169
+ always exits 0). See
170
+ [the worker2 reference implementation](../worker2/features/alb-target-group-auto-registration.md) —
171
+ api2's copy is the **unhardened original** and must be brought up to it.
172
+
166
173
  ## CI — commit message policy
167
174
 
168
175
  `.github/workflows/true-devteam-requirements.yml` enforces **`TRUE-{ticket}: {Subject}`** —
@@ -180,6 +187,18 @@ build. These must be **rotated** (treat the committed tokens as compromised) and
180
187
  Parameter Store / EB env properties. **Flag this if you touch config or deploy.** (Location +
181
188
  remediation only — do not record the token value anywhere.)
182
189
 
190
+ **Committed IAM access key (found 2026-07-28).** An IAM access key id + secret access key are
191
+ hardcoded as PHP constants in `ebs/register_instance_to_shared_application_load_balancer.php`
192
+ (**lines 29–30**) and are in **committed git history** (`git log -S`; commit subject
193
+ *"Auto-registering to target groups"*) — exposed to every reader of the repo, every clone, and every
194
+ EB bundle. **Remediation:** deactivate + delete the key in IAM, audit CloudTrail for its use, and
195
+ replace the block with the instance-profile pattern worker2 now uses. **Rotation, not history
196
+ rewrite, closes the exposure**; a history rewrite is a separate sign-off-required decision because
197
+ the team git rule forbids force-pushing `_main` in application repos. The same file also passes the
198
+ IMDSv2 token on the `curl` command line (visible in `ps`/`/proc/<pid>/cmdline`, 6h TTL) and falls
199
+ back silently to IMDSv1 — fix both alongside the key. *(Location + remediation only; no key material
200
+ is recorded anywhere.)*
201
+
183
202
  ## Known issues / accepted risks
184
203
 
185
204
  Open items a maintainer should know before changing this tier. None are "bugs to fix right now" —
@@ -213,6 +232,13 @@ they are the known sharp edges. Do not re-discover these from scratch.
213
232
  8. **JWT signing-secret rotation accepts current + previous.** During the overlap window a token
214
233
  signed with the retired secret still validates. Revocation is therefore not immediate —
215
234
  don't rely on rotation alone to lock out a compromised token.
235
+ 9. **Committed IAM access key in the ALB-registration script, not yet rotated (found 2026-07-28).**
236
+ `ebs/register_instance_to_shared_application_load_balancer.php` lines 29–30, present in committed
237
+ git history. Treat as compromised: deactivate + delete the key, audit CloudTrail, and move to the
238
+ EC2 instance profile as
239
+ [worker2 does](../worker2/features/alb-target-group-auto-registration.md). The same script's
240
+ IMDSv2 handling (token on the `curl` command line, silent IMDSv1 fallback) must be fixed with it.
241
+ See the Security note above.
216
242
 
217
243
  ## When making changes here
218
244
 
@@ -230,6 +256,7 @@ they are the known sharp edges. Do not re-discover these from scratch.
230
256
  or `execute()`.
231
257
 
232
258
  ## Change history
259
+ - 2026-07-28 — Documented the previously unrecorded `060_register_instance_to_shared_application_load_balancer` postdeploy hook pair in the Deployment section (non-prod self-registration into the same-named ALB target group; production skipped), and recorded a **committed IAM access key** in `ebs/register_instance_to_shared_application_load_balancer.php` as a security note + Known issue #9 — location, line range, commit subject, and remediation only (rotate, audit CloudTrail, move to the instance profile; history rewrite is a separate sign-off). Flagged api2's copy as the unhardened original vs. the new worker2 reference implementation. (jcardinal)
233
260
  - 2026-07-28 — Added a consolidated **Known issues / accepted risks** section (8 items), absorbing the previously free-floating deferred raw-exception-disclosure follow-up as item 1, so the tier's sharp edges (unrotated committed secrets, pre-execute phase still outside the main guard, local Logs DB name mismatch, permissive CORS, unpinned `_underscore` build clone, untested `V2.php` monolith, JWT rotation overlap window) are in one place instead of scattered. Recorded that `DB_CACHE` is resolved by name (`Databases.name = 'Cache'`), never by a hardcoded id, which differs per Core instance. (jcardinal)
234
261
  - 2026-07-28 — Added gotcha: the request-logger's auto-generated `Api.transactionId` (millisecond timestamp `Y-m-d H:i:s.v`, UNIQUE) collides under concurrent same-millisecond nested writes → MySQL 1062 → HTTP 500; platform-wide, observed on the Compass/Veyer ASN feed (`sourceIp 34.232.23.158`). Distinct from the client-supplied `transactionId`/EV-5 uniqueness contract. Fix direction: uuid the logged id or retry-on-1062. (bala)
235
262
  - 2026-07-27 — Sharpened the committed-secret note: the plaintext GitHub PAT lives in the per-env **`.ebextensions/git.*.json`** files (used by the `prebuild/git.sh` clone hook to pull `_underscore`), must be rotated and moved to SSM / EB env properties (location + remediation only, no value). (mhammontree)
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: V2 reverse hasMany collections must be named in the fetch fields whitelist
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: [mhammontree]
11
+ files:
12
+ - api2/Component/Api/V2/V2.php
13
+ related:
14
+ - nested-fk-acl-embedding.md
15
+ - ../../../../1.0/apps/library/features/toga2-api-client-and-bridge.md
16
+ ---
17
+
18
+ ## What it is
19
+
20
+ In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that
21
+ foreign-key back to a parent (e.g. a Contact's many `ContactEmailAddresses`) — is **not** included
22
+ in a response by default. It is only serialized when the caller **explicitly names it in the fetch
23
+ `fields` list**. A parent GET that does not list the reverse collection comes back with that key
24
+ absent — no error, no warning `message`, still HTTP 200 / `isSuccess: true`. This is a framework-level
25
+ fact about how the 2.0 metadata layer resolves and names reverse relations; it is the general form of
26
+ the AIG-specific `contactEmailAddresses.emailAddress` gotcha in the 1.0 `App_Api_Toga2` bridge.
27
+
28
+ ## How it works
29
+
30
+ - **Reverse relations are metadata-resolved, not implicit.** The V2 engine builds the set of
31
+ embeddable relations for a model from the model metadata. A **forward** FK (parent points at one
32
+ child) resolves from the parent model's own FK field. A **reverse** hasMany (many children point
33
+ back at this parent) is resolved by scanning **child** models for a `FOREIGNKEY_MODEL` constant that
34
+ targets this model — the engine walks the child side to discover "who points at me".
35
+ - **Naming convention — camelCase plural of the child model.** A discovered reverse collection is
36
+ exposed under the **camelCase plural of the child model name**. So children modeled as
37
+ `ContactEmailAddress` (with `FOREIGNKEY_MODEL` → `Contact`) surface on the parent as
38
+ `contactEmailAddresses`; a nested field of that collection is dotted, e.g.
39
+ `contactEmailAddresses.emailAddress`. Get the pluralization/casing exactly right — a mismatched name
40
+ is treated as an unknown field and simply yields nothing rather than an error.
41
+ - **Whitelist-gated serialization.** The engine only serializes a reverse collection that appears in
42
+ the requested `fields` list (for the library options DSL, the nested-array form
43
+ `['contactEmailAddresses' => ['emailAddress']]`). Omit it and the collection is silently skipped —
44
+ it is opt-in, not opt-out. There is no default depth that pulls reverse hasMany collections in for
45
+ you.
46
+ - **Consuming a reverse collection is therefore a two-part change.** Any code that wants to read a
47
+ related collection off a parent must (1) add the collection (and the specific child fields) to the
48
+ **fetch `fields` whitelist**, and (2) add the **consuming code** that reads it. Adding only the
49
+ consumer leaves the key absent at runtime; adding only the field silently fetches data nobody uses.
50
+
51
+ ## Relationship to ACL embedding
52
+
53
+ This whitelist gate is **orthogonal to** the child-record ACL gate documented in
54
+ [nested-fk-acl-embedding.md](nested-fk-acl-embedding.md). Naming a reverse collection in `fields` is
55
+ necessary but not sufficient: each embedded child record is still re-checked against its own
56
+ `AclRecordPermissions`, and an ungranted child is silently dropped. A missing nested collection can be
57
+ caused by **either** an omitted `fields` entry **or** a missing child-record ACL grant — check both.
58
+
59
+ ## Gotchas
60
+
61
+ - **Silent, not erroring.** An unlisted (or misspelled) reverse collection produces no `messages[]`
62
+ entry — the symptom is purely a missing key in `data`. Confirm the exact camelCase-plural name from
63
+ the child model before assuming a data problem.
64
+ - **The 1.0 bridge hit this concretely.** TRUE-79401 needed
65
+ `contactEmailAddresses.emailAddress` added to the `/contacts` fetch `fields` list in
66
+ `App_Api_Toga2` before the 1.0 sync could read a contact's full email list — see
67
+ [App_Api_Toga2 — TOGa2 API Client & bridge](../../../../1.0/apps/library/features/toga2-api-client-and-bridge.md).
68
+
69
+ ## Change history
70
+
71
+ - 2026-07-28 — Captured the framework-level rule (split out of the TRUE-79401 library gotcha): V2
72
+ reverse hasMany collections are resolved via the child model's `FOREIGNKEY_MODEL`, exposed under the
73
+ camelCase plural of the child model, and are **only** serialized when explicitly named in the fetch
74
+ `fields` whitelist — otherwise the key is silently absent (200 / `isSuccess:true`, no message).
75
+ Orthogonal to, and stacks with, the child-record ACL embedding gate. (mhammontree)
76
+ </content>
77
+ </invoke>
@@ -6,9 +6,11 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-07-28
10
10
  owners: ["jcardinal", "mhammontree"]
11
- files: []
11
+ files:
12
+ - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
+ - api2/ebs/register_instance_to_shared_application_load_balancer.php
12
14
  related: []
13
15
  ---
14
16
 
@@ -106,6 +108,15 @@ debugging the code. (Seen 2026-06-18 chasing a "missing FPDF / old code still ru
106
108
  beta that was really the LB still pointing at a terminated instance's replacement that was never
107
109
  registered.)
108
110
 
111
+ **Automated for non-production tiers that carry the `060` hook.** `api2` and (as of 2026-07-28)
112
+ `worker2` ship a postdeploy hook pair that makes a **non-production** instance register *itself*
113
+ into the target group whose name equals the EB environment name — so on those tiers the manual
114
+ step above is only needed if the hook is absent, misnamed, or silently failed (it always exits 0).
115
+ Production is deliberately skipped and still registers by the existing process. See
116
+ [deploy-time auto-registration to the shared ALB target group](../../worker2/features/alb-target-group-auto-registration.md),
117
+ which is also the reference implementation — **api2's copy has known security defects**, including
118
+ a committed IAM access key.
119
+
109
120
  ### On-instance composer install (when a dep is missing post-deploy)
110
121
  If a Composer dep is missing on the running instance (e.g. `Class "FPDF" not found` because
111
122
  `composer.lock` wasn't committed), you can install it on the box over SSH/PuTTY — but
@@ -128,6 +139,7 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
128
139
  ```
129
140
 
130
141
  ## Change history
142
+ - 2026-07-28 — Manual LB re-registration is now automated on **non-production** tiers carrying the `.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh` hook pair (api2 already had it; ported to worker2 this session). Linked the new worker2 feature doc as the reference implementation and flagged that api2's copy has known security defects. (jcardinal)
131
143
  - 2026-07-27 — Noted that not every EB env pulls `_underscore` from its `_<env>` branch: **`API-Sandbox-Dev`** re-clones `_underscore` from the branch in `.ebextensions/git.sandbox-dev.json` (**`_sandbox-dev`**), overwriting the pipeline copy — so a `_beta` merge doesn't reach it. Added the `Logs_<Client>.Api` server-side request-log note as the reliable way to debug a beta env without Sentry (TRUE-79533). (mhammontree)
132
144
  - 2026-07-14 — Documented the branch model: app repos (`_underscore`/`api2`/`toga2-supply`) deploy from long-lived `_beta`/`_production` branches (not `_main`); api2 pulls `_underscore`'s `_<env>` branch at EB build; `dbchanges2` has only `_main` and its migrations are applied per-env by the team process (not by a code deploy). (mhammontree)
133
145
  - 2026-06-18 — Added two EB-instance gotchas surfaced during the TOGa Supply beta/prod label deploys: (1) terminated instances must be **manually re-registered** as LB targets (we don't pay for auto-registration) — until then the new code never serves traffic and looks like deploy-lag; (2) on-instance `composer require` stopgap syntax (no space after the colon, fix root cause by committing `composer.lock`). (mhammontree)
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Worker (worker2) Architecture](architecture.md) | Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that processes background jobs. | worker2/Controller/Index.php, worker2/Worker/, worker2/LambdaFunctions/, _underscore/Worker.php |
6
+ | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
6
7
  | [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
7
8
  | [ClickUp Connectivity Watchdog](features/clickup-connectivity-watchdog.md) | A cron watchdog that emails when the ClickUp integration looks disconnected during business hours. | worker2/Worker/Clickup/Health.php, worker2/Database/ClickupHealthWatchdog.sql |
8
9
  | [ClickUp Design Sprint Automation (Final Design Outcome)](features/clickup-design-sprint-automation.md) | `_Worker_Clickup_Design` is meant to drive the design-sprint workflow in ClickUp via the API, replacing a set of native ClickUp automations. | worker2/Worker/Clickup/Design.php, worker2/Worker/Clickup.php, worker2/Controller/ClickupDesignTest.php, _underscore/Component/Api/Clickup/Clickup.php |
@@ -15,6 +15,7 @@ files:
15
15
  - _underscore/Worker.php
16
16
  related:
17
17
  - ./features/creating-worker-actions.md
18
+ - ./features/alb-target-group-auto-registration.md
18
19
  - ../_underscore/features/async-query-execution.md
19
20
  ---
20
21
 
@@ -31,6 +32,18 @@ processes background jobs. It's a `_underscore` 2.0 app (`index.php` is just
31
32
  **MySQL is the source of truth; SQS is delivery only.** All job state lives in
32
33
  `Core.WorkerJobs`. Every worker invocation reads from that table and writes its result back.
33
34
 
35
+ **Production vs. non-production inbound differ.** The SQS/Lambda job pipeline above describes
36
+ **production**. **Non-production worker2 environments do not incorporate SQS at all** — they exist
37
+ for **manual invocation over HTTP** (hence `index.php` dispatch and `.platform/httpd/conf.d/`, and
38
+ no `cron.yaml`). That is precisely why non-prod instances must be reachable through the shared ALB;
39
+ see [deploy-time auto-registration to the shared ALB target group](./features/alb-target-group-auto-registration.md).
40
+ SQS may be added to non-prod later, but is not planned. **Do not assume a non-prod worker2 job ever
41
+ passes through SQS** — a queue-based reproduction of a prod issue will not work there.
42
+
43
+ **Associated open risk:** because non-prod is HTTP-invoked and now ALB-reachable, the shared-ALB
44
+ listener rules and security groups must restrict non-prod to internal/VPN sources, and the
45
+ HTTP-triggered job endpoints must enforce auth.
46
+
34
47
  ## AWS infrastructure
35
48
 
36
49
  | Component | Notes |
@@ -260,6 +273,11 @@ the MySQL-first design was departed from **on purpose** here.
260
273
 
261
274
  ## Change history
262
275
 
276
+ - 2026-07-28 — Corrected the inbound model: **non-production** worker2 environments are not
277
+ SQS-driven at all; they are manually invoked over HTTP (no `cron.yaml`; `index.php` +
278
+ `.platform/httpd/conf.d/`), which is why they must be reachable through the shared ALB.
279
+ Production remains SQS-driven. Recorded the resulting non-prod exposure risk (listener/SG
280
+ restriction + endpoint auth). (jcardinal)
263
281
  - 2026-07-28 — Added **Cron actions must return a result on BOTH paths**: a new cron action is
264
282
  expected to return a structured result for a success as well as a failure, because both are
265
283
  recorded against the job and the successful results are what auditing reads (a completed run that
@@ -0,0 +1,162 @@
1
+ ---
2
+ title: Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: [jcardinal]
11
+ files:
12
+ - worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
+ - worker2/ebs/register_instance_to_shared_application_load_balancer.php
14
+ - worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh
15
+ - worker2/.platform/hooks/prebuild/_shared/041-write-region.sh
16
+ - worker2/.platform/hooks/postdeploy/015_install_composer.sh
17
+ - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
18
+ - api2/ebs/register_instance_to_shared_application_load_balancer.php
19
+ related:
20
+ - ../architecture.md
21
+ - ../../api2/workflows/codepipeline-codeconnections-deploy.md
22
+ - ../../api2/architecture.md
23
+ ---
24
+
25
+ ## Summary
26
+
27
+ TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally
28
+ **not** added to its environment's ALB target group — a fresh or replacement instance serves no
29
+ traffic until someone registers it by hand (see the manual-registration gotcha in the
30
+ [api2 deploy workflow](../../api2/workflows/codepipeline-codeconnections-deploy.md)). This hook
31
+ pair automates that step for **non-production** environments: on every deploy the instance
32
+ registers **itself** into the target group whose **name exactly equals the EB environment name**.
33
+
34
+ Originally built in `api2`; ported to `worker2` on 2026-07-28 with three security hardenings
35
+ (instance-profile credentials, native-PHP IMDSv2, fail-closed token handling). **`worker2` is now
36
+ the reference implementation — `api2`'s copy has known defects, see below.**
37
+
38
+ Non-prod `worker2` environments need this because they are reached **over HTTP for manual job
39
+ invocation**, not through SQS.
40
+
41
+ ## How it works
42
+
43
+ 1. **`.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh`**
44
+ - Reads `get-config environment -k ENVIRONMENT`; if it is `production`, **skips** entirely.
45
+ Production instances are registered by the existing production process, not by this hook.
46
+ - Otherwise invokes `ebs/register_instance_to_shared_application_load_balancer.php`.
47
+ - **Always `exit 0`.** A registration failure must never brick a deploy.
48
+ 2. **`ebs/register_instance_to_shared_application_load_balancer.php`**
49
+ - Reads the instance id and region from
50
+ `/var/app/current/storage/instance-id.txt` and `storage/region.txt` (written earlier by the
51
+ `_shared/040-write-instance-id.sh` / `041-write-region.sh` prebuild hooks), falling back to a
52
+ direct **IMDSv2** lookup.
53
+ - Reads the EB environment name via `get-config container -k environment_name`.
54
+ - Uses the AWS SDK (`aws/aws-sdk-php`) ELBv2 client to `DescribeTargetGroups` and selects the
55
+ target group whose **`TargetGroupName` is an exact string match** for the environment name —
56
+ this naming equality *is* the wiring convention; there is no tag or config lookup.
57
+ - Calls `RegisterTargets` with this instance id.
58
+
59
+ ### Hook ordering is load-bearing — why `060`
60
+
61
+ `060` sorts **after** everything it depends on:
62
+
63
+ | Hook | Provides |
64
+ |---|---|
65
+ | `prebuild/_shared/040-write-instance-id.sh` | `storage/instance-id.txt` |
66
+ | `prebuild/_shared/041-write-region.sh` | `storage/region.txt` |
67
+ | `postdeploy/015_install_composer.sh` | `vendor/` (the AWS SDK) |
68
+
69
+ Renumber it below `015` and the SDK autoloader does not exist yet.
70
+
71
+ ### Porting prerequisites (all already satisfied in worker2)
72
+
73
+ The port needed **no adaptation** because `worker2` already had: `aws/aws-sdk-php` in
74
+ `composer.json`; **byte-identical** `_shared/040-write-instance-id.sh` and `041-write-region.sh`;
75
+ an identical `015_install_composer.sh`; and the same `get-config environment -k ENVIRONMENT`
76
+ convention already used by `.ebextensions/git.php`. Check these four before porting the pair to
77
+ any other 2.0 tier.
78
+
79
+ ## Credentials — instance profile only (decision, 2026-07-28)
80
+
81
+ **Deploy-time AWS credentials come from the EC2 instance profile. Never from source, never from
82
+ EB environment properties.** The `worker2` version passes **no `credentials`** to the SDK client
83
+ so the default provider chain resolves the instance profile.
84
+
85
+ **Why EB environment properties were rejected as a fallback:** they are not a secret store. They
86
+ are returned by `elasticbeanstalk:DescribeConfigurationSettings`, displayed in plaintext in the EB
87
+ console and `eb config`, and exported into the process environment — so any `var_dump($_ENV)`,
88
+ `phpinfo()`, or debug handler leaks them. If a static key is ever genuinely unavoidable, fetch it
89
+ at runtime from **SSM Parameter Store SecureString** or Secrets Manager.
90
+
91
+ ### Required IAM on the EB EC2 instance profile — exactly two actions
92
+
93
+ - **`elasticloadbalancing:DescribeTargetGroups`** — `Resource: "*"` is unavoidable (ELBv2
94
+ `Describe*` has no resource-level permissions). Constrain it with a condition on
95
+ `aws:RequestedRegion`.
96
+ - **`elasticloadbalancing:RegisterTargets`** — scope to the **non-production target-group ARN
97
+ pattern**.
98
+
99
+ > **Do not use `targetgroup/*/*`.** On a *shared* ALB that lets any instance carrying this profile
100
+ > insert any instance into any target group — a traffic-hijack primitive.
101
+
102
+ Do **not** grant `DeregisterTargets`, any `Modify*`, or `elasticloadbalancing:*`.
103
+
104
+ ## IMDSv2 must not be fetched by shelling out to `curl`
105
+
106
+ Passing the IMDSv2 session token as a **`curl` command-line argument** exposes it in
107
+ `/proc/<pid>/cmdline` and in `ps` output to every other process on the instance. The `worker2`
108
+ implementation instead:
109
+
110
+ - uses a **native PHP stream context** (`stream_context_create` + `file_get_contents`) so the
111
+ token never enters `argv`;
112
+ - sets the token TTL to **60s instead of 21600s (6h)** — this is a one-shot, sub-second fetch;
113
+ - **fails closed**: if no token can be obtained it aborts, rather than falling back to an
114
+ unauthenticated IMDSv1 request (a silent downgrade on any instance with `HttpTokens=optional`).
115
+
116
+ ## Gotchas
117
+
118
+ - **Production is skipped by design.** Adding a prod environment to this hook is a separate
119
+ decision, not a config tweak.
120
+ - **Naming equality is the contract.** If the target group is not named *exactly* the EB
121
+ environment name, the hook silently registers nothing (and still exits 0). Check the target
122
+ group name first when a non-prod env is unreachable after a deploy.
123
+ - **Exit 0 hides failures.** Registration problems will not show in deploy status — read
124
+ `/var/log/eb-hooks.log` on the instance.
125
+ - **The hook turns on reachability; it does not secure it.** The target group and listener already
126
+ exist by convention, but because this hook is what actually puts non-prod instances behind the
127
+ shared ALB, the **listener rules and security groups must restrict non-prod to internal/VPN
128
+ sources**, and the HTTP-triggered job endpoints must enforce authentication. Open risk, tracked
129
+ here deliberately.
130
+
131
+ ## api2 divergence — three defects to fix (do not copy the api2 original)
132
+
133
+ `api2` carries the same hook pair under the same two filenames. Its version predates the
134
+ hardening above and has:
135
+
136
+ 1. **Committed IAM access key — security incident (2026-07-28).** An IAM access key id and secret
137
+ access key are hardcoded as PHP constants in
138
+ `api2/ebs/register_instance_to_shared_application_load_balancer.php` (**lines 29–30**) and are
139
+ present in **committed git history** (found with `git log -S`; commit subject *"Auto-registering
140
+ to target groups"*). They are exposed to anyone with read access to `api2`, to every clone, and
141
+ to every EB application bundle built from the repo.
142
+ **Remediation:** (a) deactivate then delete the key in IAM — rotation, not history rewrite, is
143
+ what closes the exposure; (b) audit CloudTrail for its use; (c) replace the credential block
144
+ with the instance-profile pattern above. **Rewriting git history is a separate, sign-off-required
145
+ decision** — the team git rule forbids force-pushing `_main` in application repos.
146
+ *(Location + remediation only. Never record the key id, the secret, or any fragment.)*
147
+ 2. **IMDSv2 token passed on the `curl` command line** — visible in `ps` / `/proc/<pid>/cmdline`,
148
+ with a 6-hour TTL.
149
+ 3. **Silent IMDSv1 fallback** when no token is obtained — downgrades below the IMDSv2 posture.
150
+
151
+ This hook pair is also **not yet described in the api2 architecture doc's Deployment section**,
152
+ which lists the other hooks but not `060` — pending an elevated-doc update.
153
+
154
+ ## Change history
155
+
156
+ - 2026-07-28 — Ported the hook pair from `api2` into `worker2` (new files `060_…​.sh` +
157
+ `ebs/register_instance_to_shared_application_load_balancer.php`) so non-production worker2
158
+ environments are reachable through the shared ALB for manual HTTP invocation. Hardened vs. the
159
+ api2 original: instance-profile credentials only (EB env properties explicitly rejected), native
160
+ PHP stream-context IMDSv2 with a 60s TTL and fail-closed behaviour, and a two-action least-privilege
161
+ IAM policy. Recorded the api2 committed-IAM-key incident (location + remediation only) and the
162
+ non-prod ALB exposure risk. (jcardinal)
@@ -18,8 +18,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
  ## 2.0 framework
19
19
 
20
20
  - **_underscore** (_Underscore) _(framework core)_ — 40 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
- - **worker2** (Worker) — 33 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
- - **api2** (API) — 18 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
21
+ - **worker2** (Worker) — 34 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
+ - **api2** (API) — 19 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
25
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
@@ -5,11 +5,12 @@ apps:
5
5
  - _underscore
6
6
  - api2
7
7
  - dbchanges2
8
+ - library
8
9
  project: API
9
10
  client: aig
10
11
  type: profile
11
12
  status: active
12
- updated: 2026-06-19
13
+ updated: 2026-07-28
13
14
  owners: ["mhammontree"]
14
15
  files: []
15
16
  related:
@@ -35,6 +36,10 @@ go out under the Staples Protection Plan brand and link to `staplesprotection.to
35
36
  NetSuite item/SO/invoice traits).
36
37
  - **`dbchanges2`** — `Client_Aig/` schema + reference-data migrations (e.g. the SaleItem code
37
38
  catalog in `Client_Aig.Items`).
39
+ - **`library` (1.0)** — the `App_Api_Toga2` bridge syncs 2.0 AIG contacts/entitlements down into the
40
+ legacy 1.0 `TOGA_AIG` DB (`Customers`/`ServiceRequests`), run by a worker cron every ~10 min. Note
41
+ the two AIG databases: **`TOGA_AIG`** = V1 legacy sync target; **`Client_Aig`** = V2 prod tenant. See
42
+ [App_Api_Toga2 bridge](../../1.0/apps/library/features/toga2-api-client-and-bridge.md).
38
43
 
39
44
  See [entitlement-intake.md](features/entitlement-intake.md) for how a payload becomes an
40
45
  entitlement and how to load new sale-item codes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.462",
3
+ "version": "1.0.464",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",