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`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
400
|
-
controller now echoes the request `Origin` into `Access-Control-Allow-Origin`
|
|
401
|
-
`
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
here
|
|
405
|
-
|
|
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`)
|
|
461
|
-
`
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
(
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
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