toga-ai 1.0.821 → 1.0.823

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.
@@ -61,4 +61,5 @@
61
61
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V |
62
62
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, |
63
63
  | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- |
64
+ | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
64
65
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. |
@@ -0,0 +1,178 @@
1
+ ---
2
+ title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-09-16
10
+ owners: ["rgirish"]
11
+ files: []
12
+ related:
13
+ - 2.0/standards/ssl-certificate-trust-model.md
14
+ - 2.0/apps/saml/workflows/rotating-the-saml-sp-certificate.md
15
+ - 1.0/apps/tools/features/cloudfront-client-setup.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains
21
+ (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, togaiq) before they
22
+ expire. This is the browser-facing TLS cert on CloudFront and ALBs — **not** the SAML SP
23
+ credential (that is a pinned bilateral cert, see
24
+ [Rotating the SAML SP Certificate](../../saml/workflows/rotating-the-saml-sp-certificate.md)).
25
+
26
+ This runbook was written from the 2026-09-16 rotation in AWS account **654654170868**, where
27
+ one IMPORTED multi-domain cert (14 SANs, copied into us-east-1, us-west-2 and eu-west-1) served
28
+ 4 CloudFront distributions and 12 ALB listeners and expired the same day.
29
+
30
+ **The three things that bite you:**
31
+ 1. **Elastic Beanstalk keeps its own saved cert setting**, separate from the ALB listener. Fixing
32
+ the listener is not enough — the next deploy puts the old cert straight back.
33
+ 2. **CloudFront holds exactly ONE cert per distribution.** There is no staging. The swap *is* the
34
+ cutover, and it takes 5-15 minutes.
35
+ 3. **An ACM wildcard matches exactly one label.** `*.togasupply.com` covers
36
+ `elite.togasupply.com` but **not** `compass.beta.togasupply.com`.
37
+
38
+ **Root cause to avoid repeating:** an **IMPORTED** ACM cert never auto-renews. Always replace with
39
+ **Amazon-issued, DNS-validated** ACM certs, which renew themselves.
40
+
41
+ ## When to run this
42
+
43
+ - A cert is nearing expiry (ACM emails, or your own audit finds it).
44
+ - You are moving off an imported cert onto Amazon-issued certs.
45
+ - Any time you suspect a cert is expired — run the audit in step 1 regardless; it is cheap.
46
+
47
+ ## Step 1 — Audit EVERYTHING, not just the cert you know about
48
+
49
+ Do **not** just follow the expiring cert's `InUseBy` list. Enumerate **all** CloudFront
50
+ distributions and **all** ALB listeners in **every** region, resolve each attached cert's
51
+ `NotAfter`, and flag anything expiring before your cutoff.
52
+
53
+ This is how the 2026-09-16 rotation found a **second** cert that had already expired six months
54
+ earlier (2026-03-24) and was still the only cert on 2 ALB listeners and 2 CloudFront
55
+ distributions — 4 live togadesk hosts had been serving cert errors since March and nobody noticed.
56
+
57
+ Also audit **every Elastic Beanstalk environment's saved config**, including suspended ones. A
58
+ suspended environment with no live listener can still hold a stale cert ARN that comes back when
59
+ it is rebuilt.
60
+
61
+ **Check each CloudFront distribution's origins before you plan work on it.** One distribution in
62
+ this rotation had a real alias and live DNS but all 3 origins pointed at load balancers that no
63
+ longer existed — a dead leftover. Do not spend change-window time on it.
64
+
65
+ ## Step 2 — Decide the cert shape: one cert per domain
66
+
67
+ Replace a bundled multi-domain cert with **one cert per domain** (root + wildcard, e.g.
68
+ `togahub.com` + `*.togahub.com`). Reasons:
69
+
70
+ - A 14-SAN request exceeds ACM's default 10-domain quota.
71
+ - One domain's problem no longer takes down all seven.
72
+ - Each distribution only needs the domain it actually serves.
73
+
74
+ All new certs must be **Amazon-issued and DNS-validated** so they auto-renew. This is case 2 of
75
+ the [SSL Certificate Trust-Model Strategy](../../../standards/ssl-certificate-trust-model.md):
76
+ AWS terminates the TLS, so use ACM public, non-exportable, auto-renewing.
77
+
78
+ ## Step 3 — Request the certs in the right regions
79
+
80
+ Region rules:
81
+
82
+ - **CloudFront only reads certs from `us-east-1`.** No exceptions.
83
+ - **An ALB reads certs from its own region.** A togahub ALB in us-west-2 needs a us-west-2 cert;
84
+ the same domain on an eu-west-1 ALB needs a separate eu-west-1 cert.
85
+
86
+ Check ACM first — unused per-domain certs may already exist (3 did in this rotation). Then request
87
+ only what is missing.
88
+
89
+ ## Step 4 — Validate by DNS in Route 53
90
+
91
+ Add the validation CNAMEs to Route 53. Certs for the **same domain in different regions share ONE
92
+ validation record** — the CNAME name and value are identical, so 6 certs across 3 regions needed
93
+ only 5 CNAMEs.
94
+
95
+ Validation completes in about 2 minutes once the records are live. Wait for `ISSUED` before
96
+ touching anything.
97
+
98
+ ## Step 5 — Stage the ALB listeners first (safe, additive)
99
+
100
+ `aws elbv2 add-listener-certificates` is **purely additive**. A listener holds many certs and picks
101
+ by SNI, so adding the new cert alongside the old one changes nothing for live traffic. Do this
102
+ ahead of the change window for every listener.
103
+
104
+ ## Step 6 — THE ONE PEOPLE MISS: update the Elastic Beanstalk saved config
105
+
106
+ Adding a cert to an ALB listener does **not** update the EB environment's saved setting
107
+ `aws:elbv2:listener:443 / SSLCertificateArns`. After all 12 listeners were fixed and verified
108
+ healthy in this rotation, 12 EB environments still named the **old** cert in their saved config.
109
+
110
+ Nothing breaks right away — the listener serves traffic. But the next **deploy, restart, rebuild or
111
+ scaling event** re-applies the saved config and puts the expired cert back. A listener-level audit
112
+ does not catch this.
113
+
114
+ ```
115
+ aws elasticbeanstalk update-environment \
116
+ --environment-name <env> \
117
+ --option-settings "Namespace=aws:elbv2:listener:443,OptionName=SSLCertificateArns,Value=<new-arn>"
118
+ ```
119
+
120
+ Observed behaviour: this cleanly **replaced** the old cert on the listener (leaving only the new
121
+ one), and every environment stayed Green/Ok through the update — no traffic disruption.
122
+
123
+ **Gotcha:** an EB environment whose CloudFormation stack is in `DELETE_FAILED` cannot be updated at
124
+ all — `update-environment` is refused. That needs separate stack cleanup.
125
+
126
+ ## Step 7 — Check alias coverage before any CloudFront swap
127
+
128
+ CloudFront **rejects** an update if any alias on the distribution is not covered by the new cert.
129
+ Check every alias against the new cert's SANs **programmatically** — this is where the one-label
130
+ wildcard rule bites: `*.togasupply.com` does not cover `compass.beta.togasupply.com`. Beta and
131
+ gamma environments generally need their own certs for this reason.
132
+
133
+ ## Step 8 — Swap CloudFront, smallest blast radius first
134
+
135
+ `ViewerCertificate.ACMCertificateArn` is a **single string**, not a list. There is no
136
+ "add new alongside old" for CloudFront: the swap is the cutover, and it deploys in 5-15 minutes.
137
+
138
+ Order used, lowest risk first:
139
+
140
+ 1. Distributions that are **already broken** (nothing to lose).
141
+ 2. Single-alias distributions.
142
+ 3. Mid-size (9 aliases).
143
+ 4. The largest last (the 21-alias distribution fronting 21 named enterprise clients).
144
+
145
+ Back up each distribution's config before every swap — `aws cloudfront get-distribution-config`,
146
+ and keep the **ETag**; you need it to update.
147
+
148
+ **Hard edge:** once the old cert's expiry moment has passed there is no useful CloudFront rollback,
149
+ because rolling back restores an expired cert. Only forward.
150
+
151
+ ## Step 9 — Verify on the live hosts
152
+
153
+ ```
154
+ echo | openssl s_client -servername HOST -connect HOST:443 2>/dev/null \
155
+ | openssl x509 -noout -issuer -enddate
156
+ ```
157
+
158
+ Also run it **without** `-servername` to check the no-SNI / default-cert path — that path can still
159
+ be serving the old cert after everything else looks fine.
160
+
161
+ Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, and every
162
+ EB saved config should name a cert with a future `NotAfter`.
163
+
164
+ ## Gotchas
165
+
166
+ - **IMPORTED certs never auto-renew.** If the audit shows `Type: IMPORTED`, it is a future outage.
167
+ - **A cert copied into 3 regions is 3 separate certs** — all 3 expire at once, and all 3 need
168
+ replacing.
169
+ - **EB saved config is the silent landmine.** See step 6.
170
+ - **CloudFront takes one cert; ALBs take many.** Two completely different risk profiles in the same
171
+ rotation — plan them separately.
172
+ - **Wildcards match one label only.** Verify alias coverage with code, not by eye.
173
+ - **Expired certs hide.** Sweep the whole account; do not trust `InUseBy`.
174
+
175
+ ## Change history
176
+ - 2026-09-16 — Created from the account 654654170868 rotation: replaced an expiring 14-SAN IMPORTED
177
+ cert with 6 per-domain Amazon-issued DNS-validated certs, relinked 12 ALB listeners, 6 CloudFront
178
+ distributions and 11 EB environment configs; documented the EB saved-config trap. (rgirish)
@@ -64,12 +64,21 @@ lands in `api()`.
64
64
 
65
65
  `api()` is the protocol router and the **transaction/logging boundary**:
66
66
  - **CORS (2026-09-16):** **reflects the caller's `Origin` header** back in
67
- `Access-Control-Allow-Origin` (per-request echo) instead of a wide-open `*`, and sends
68
- `Access-Control-Allow-Credentials: true`; still returns 200 for the `OPTIONS` preflight. A
69
- wildcard `*` cannot be combined with credentialed requests, so echoing the Origin is what lets
70
- a future cookie/session flow work without reopening the hole but it is **not** an allowlist:
71
- every Origin is still reflected. Tighten to a real allowlist before adding any cookie/session
72
- auth here.
67
+ `Access-Control-Allow-Origin` when `$_SERVER['HTTP_ORIGIN']` is present and matches a strict
68
+ shape regex `^https?://[A-Za-z0-9.\-]+(:\d+)?$` (with the `D` end-anchor flag); otherwise it
69
+ falls back to `*`. It also emits `Vary: Origin`. This block sits **before** the `OPTIONS`
70
+ preflight short-circuit (still 200 for `OPTIONS`). The reflected value must be the **whole
71
+ origin `scheme://host[:port]` (e.g. `http://compass.togacommerce`)** because the browser
72
+ compares it byte-for-byte; do **not** strip the scheme or "clean" `HTTP_ORIGIN` (it is an
73
+ origin, not a URL with a path). Why: some higher-security client networks strip or reject a
74
+ wildcard `*`, so a client's app fails with a CORS "no Access-Control-Allow-Origin" error
75
+ **inside** their network while working fine outside it. **Security:** reflecting any
76
+ well-formed origin is effectively equivalent to `*` here because auth is **Bearer-token, never
77
+ cookies** — no new exposure; the regex is a strict allowlist that blocks header injection, and
78
+ PHP `header()` also rejects CR/LF. **`Access-Control-Allow-Credentials` is deliberately NOT set
79
+ — do NOT add it on top of this reflection without a cookie-auth security review; that
80
+ combination would be a cross-site data-theft hole.** `Vary: Origin` stops a shared cache
81
+ serving one origin's header to another.
73
82
  - **`/health`:** 200 + empty object (EB/LB probe — keep it working).
74
83
  - **Sentry:** initialized per request; EC2 instance metadata attached.
75
84
  - **Core DB bootstrap (now guarded, 2026-07-23):** registers `Core` (`DB_CORE`), preferring a
@@ -396,13 +405,15 @@ they are the known sharp edges. Do not re-discover these from scratch.
396
405
  `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`); a local Logs DB whose actual schema name
397
406
  differs throws `Unknown database`. This is environment config, not a code bug — fix the row or
398
407
  the local schema name, don't patch the bootstrap.
399
- 5. **CORS reflects the caller's `Origin` (not an allowlist) (updated 2026-09-16).** The front
400
- controller now echoes the request `Origin` into `Access-Control-Allow-Origin` and sends
401
- `Access-Control-Allow-Credentials: true` (was a wide-open `Access-Control-Allow-*`). This
402
- retires the old "wide-open CORS" risk for the current bearer-token auth, but it is **still not
403
- a restriction** every Origin is reflected. If any cookie/session-backed auth is ever added
404
- here, replace the Origin echo with a real hardcoded allowlist first; reflecting the Origin
405
- while sending credentials would otherwise be an exploitable hole.
408
+ 5. **CORS reflects the caller's `Origin`, falling back to `*` (updated 2026-09-16).** The front
409
+ controller now echoes the request `Origin` into `Access-Control-Allow-Origin` when it matches a
410
+ strict shape regex, else falls back to `*`, and adds `Vary: Origin` (was a wide-open
411
+ `Access-Control-Allow-*`). Safe **only** because auth is Bearer-token, never cookie/session
412
+ `Access-Control-Allow-Credentials` is intentionally **unset**, and reflecting the origin is
413
+ effectively equivalent to `*` here (no new exposure). It is a shape check, **not** a trusted-
414
+ origin allowlist every well-formed Origin is reflected. If any cookie/session-backed auth is
415
+ ever added here, do not set credentials and re-tighten CORS to a real hardcoded allowlist first;
416
+ origin-reflection + credentials would be a cross-site data-theft hole.
406
417
  6. **`_underscore` is cloned at build from a moving branch** (`_<ENVIRONMENT>`), not pinned to a
407
418
  commit. Two deploys of the same api2 commit can produce different runtime behavior. Check the
408
419
  framework branch state when triaging an "it worked yesterday" regression.
@@ -457,13 +468,17 @@ they are the known sharp edges. Do not re-discover these from scratch.
457
468
 
458
469
  ## Change history
459
470
  - 2026-09-16 — **CORS now reflects the caller's `Origin`** instead of a wide-open
460
- `Access-Control-Allow-*`: the front controller (`Controller/Index.php`) echoes the request
461
- `Origin` into `Access-Control-Allow-Origin` and sends `Access-Control-Allow-Credentials: true`,
462
- still returning 200 for the `OPTIONS` preflight. Updated the front-controller CORS bullet and
463
- **retired the old "CORS is fully permissive" wide-open risk** (Known issue #5) — reframed to
464
- note the header is now a per-request Origin echo, **not** an allowlist, so it must still be
465
- replaced with a hardcoded allowlist before any cookie/session-backed auth is added here.
466
- (jcardinal)
471
+ `Access-Control-Allow-*`: the front controller (`Controller/Index.php`, `_Controller_Index::api()`,
472
+ before the `OPTIONS` short-circuit) echoes the request `Origin` into `Access-Control-Allow-Origin`
473
+ when `HTTP_ORIGIN` matches `^https?://[A-Za-z0-9.\-]+(:\d+)?$` (`D` flag), else falls back to `*`,
474
+ and adds `Vary: Origin` (whole origin incl. scheme browser compares byte-for-byte). Motivation:
475
+ some higher-security client networks strip/reject a wildcard `*`, so a client failed with a CORS
476
+ "no Access-Control-Allow-Origin" error inside their network while it worked outside. Retires Known
477
+ issue #5. `Access-Control-Allow-Credentials` deliberately **NOT** set (auth is Bearer-token, never
478
+ cookies — reflection is equivalent to `*` here with no new exposure; adding credentials on top
479
+ would be a cross-site data-theft hole and needs a cookie-auth review). Reviewed clean by
480
+ php-reviewer (valid PHP 8.2) and cso (SAFE TO SHIP). Note: the code change is a working-tree edit,
481
+ not yet committed. (jcardinal)
467
482
  - 2026-09-01 — Recorded a **sixth EB-`Degraded` cause — a client 4xx BURST**: EB Enhanced Health
468
483
  counts application/ELB 4xx toward env health via two default-Enabled `ConfigDocument` rules
469
484
  (`ApplicationRequests4xx`, `ELBRequests4xx`, namespace
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  ## 2.0 framework
21
21
 
22
- - **_underscore** (_Underscore) _(framework core)_ — 85 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
+ - **_underscore** (_Underscore) _(framework core)_ — 86 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
23
  - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.821",
3
+ "version": "1.0.823",
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",