toga-ai 1.0.436 → 1.0.438

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.
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Tools (1.0 Internal-Tools App) Architecture](architecture.md) | **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind simple interfaces, gated by Client_True staff persona. | tools/index.php, tools/_/app/framework.php, tools/_/app/frameworkindex.php, tools/assets/img/favicon/favicon.ico, tools/assets/img/favicon/favicon-32x32.png, tools/assets/img/favicon/favicon-16x16.png, tools/assets/img/favicon/apple-touch-icon.png, tools/_/app/auth.php, tools/_/app/nav.php, tools/common/header.php, tools/common/footer.php, tools/mvc/get.php, tools/mvc/_TEMPLATE/get.php, tools/docs/ADDING_A_TOOL.md |
6
+ | [CloudFront Client Setup](features/cloudfront-client-setup.md) | An SSO-gated admin tool at **`/devops/cloudfront-clients`** in the Tools 1.0 app that onboards a client onto **CloudFront + Route 53 across multiple AWS account | tools/_/app/devops/cloudfront.php, tools/mvc/devops/cloudfront-clients/get.php, tools/mvc/devops/cloudfront-clients/post.php, tools/assets/js/cloudfront-clients.js, tools/assets/css/cloudfront-clients.css, tools/_/app/nav.php, tools/_/app/frameworkindex.php, tools/config.production.ini |
6
7
  | [Design Demo Admin](features/design-demo-admin.md) | A self-serve admin UI at **`/design`** in the SSO-protected **Tools** app that lets the design team publish self-contained "Claude Design" HTML exports as **ver | tools/_/app/design/github.php, tools/mvc/design/get.php, tools/mvc/design/post.php, tools/assets/css/design.css, tools/assets/js/design.js, tools/_/app/frameworkindex.php, tools/_/app/nav.php, tools/composer.json |
7
8
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | The first two tools shipped in the Tools app, both under the **Developers** folder and gated to personas **Development Team** / **TOGa Technology**. | tools/mvc/developers/uuid/get.php, tools/mvc/developers/password/get.php |
8
9
  | [Tools MVC — Routing, CSRF & App_Database Access Patterns](features/mvc-data-access-patterns.md) | The load-bearing 1.0 (`App_`) framework conventions a developer needs when adding a page to the Tools app — URL routing, CSRF, and DB access through `App_Databa | tools/_/app/nav.php, tools/mvc/get.php |
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-26
9
+ updated: 2026-07-24
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/index.php
@@ -44,8 +44,10 @@ dashboard stylesheet's `header{position:fixed;width:100vw}` rule and cause horiz
44
44
  (the top header bar was removed for exactly this reason). A nav action's `route` maps **1:1** to
45
45
  `mvc/<route>/get.php`. Auth fails **closed** and never auto-creates users; the dev bypass is
46
46
  double-gated (`[internal] dev_mode` AND `App_Registry::inDevMode()`). `config.*.ini`
47
- (incl. `config.prod.ini`) is committed with plaintext production secrets — a known, team-accepted
48
- risk (see Known issues); never add more secrets and treat these as compromised if leaked.
47
+ (incl. `config.prod.ini`) is committed with plaintext production secrets that were **publicly
48
+ web-readable** until the front-controller deny blocks added 2026-07-24 (see Known issues);
49
+ rotation of every exposed secret is still owed. Never add more secrets, and keep config files
50
+ denied at the front controller / out of the web root.
49
51
 
50
52
  ## Boot & structure
51
53
 
@@ -86,10 +88,15 @@ Create `mvc/<folder>/<tool>/get.php` (copy `mvc/_TEMPLATE/get.php`), add one ent
86
88
 
87
89
  ## Known issues / security
88
90
 
89
- - **Plaintext secrets committed to git.** `config.*.ini` (incl. `config.prod.ini`) contains
90
- plaintext production secrets (RDS master password, SMTP, Payeezy, NetSuite). The team has
91
- **accepted this for now** (developer decision, 2026-06-25). Future remediation: git-ignore the
92
- config files and rotate all exposed credentials. (Location only — no values recorded here.)
91
+ - **Plaintext secrets were publicly web-readable (fixed 2026-07-24; rotation still owed).**
92
+ `config.*.ini` (incl. `config.prod.ini`) holds plaintext production secrets (RDS master
93
+ password, SMTP, Payeezy, NetSuite). The front controller routed through `index.php` on a
94
+ `RewriteCond !-f` rule **only**, which is not access control — a direct
95
+ `GET /config.production.ini` served the file **verbatim**. Fixed 2026-07-24 by adding
96
+ `<FilesMatch>` `Require all denied` blocks (sensitive extensions incl. `.ini`, and any
97
+ `config.*`) — see the [1.0 back-end security standard](../../standards/backend-php.md).
98
+ **Action still owed:** rotate every secret in `config.production.ini` — the file was
99
+ reachable, so treat all of it as compromised. (Location only — no values recorded here.)
93
100
  - **SSO initiation + replay defense are open items** — see `features/saml-sso-auth.md`.
94
101
  - **Framework-level Sentry error reporting is an open gap.** Prod printed "Sentry is not
95
102
  installed" because `sentry/sentry` was missing from `composer.json` (added `^4.10`; sibling
@@ -99,6 +106,11 @@ Create `mvc/<folder>/<tool>/get.php` (copy `mvc/_TEMPLATE/get.php`), add one ent
99
106
  composer package must `require_once` the autoloader itself.
100
107
 
101
108
  ## Change history
109
+ - 2026-07-24 — Fixed the config-file web-root exposure: `config.*.ini` was publicly readable via
110
+ the `!-f`-only front controller (`GET /config.production.ini` served it verbatim); added
111
+ `<FilesMatch>` deny blocks referencing the new 1.0 back-end security standard. Softened the
112
+ Critical-rules note from "team-accepted risk" to fixed-but-rotation-owed. Every secret in
113
+ `config.production.ini` still owes rotation (jcardinal)
102
114
  - 2026-07-24 — Added the SSO-gated **Design Demo Admin** tool at `/design` (`App_Design_Github`
103
115
  + `mvc/design/*` + namespaced assets + a `design` nav group), which publishes versioned demos
104
116
  into the `forward` repo over the GitHub API (see `features/design-demo-admin.md`). Noted the
@@ -0,0 +1,181 @@
1
+ ---
2
+ title: CloudFront Client Setup
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-24
10
+ owners: [jcardinal]
11
+ files:
12
+ - tools/_/app/devops/cloudfront.php
13
+ - tools/mvc/devops/cloudfront-clients/get.php
14
+ - tools/mvc/devops/cloudfront-clients/post.php
15
+ - tools/assets/js/cloudfront-clients.js
16
+ - tools/assets/css/cloudfront-clients.css
17
+ - tools/_/app/nav.php
18
+ - tools/_/app/frameworkindex.php
19
+ - tools/config.production.ini
20
+ related:
21
+ - ../architecture.md
22
+ - ../features/persona-gated-navigation.md
23
+ - ../features/saml-sso-auth.md
24
+ - ../features/design-demo-admin.md
25
+ - ../../standards/backend-php.md
26
+ ---
27
+
28
+ ## Summary
29
+
30
+ An SSO-gated admin tool at **`/devops/cloudfront-clients`** in the Tools 1.0 app that
31
+ onboards a client onto **CloudFront + Route 53 across multiple AWS accounts** in one run.
32
+ For each client slug × selected distribution it adds `{slug}.{baseDomain}` as a CloudFront
33
+ **Alternate Domain Name** and creates the matching Route 53 **A + AAAA alias** records
34
+ pointing at the distribution. It automates a repetitive, multi-account, error-prone manual
35
+ onboarding step. Gated to the **`Development Team`** persona; has a **Dry-run** preview
36
+ mode, a per-run slug cap, and an audit log line per non-dry-run attempt.
37
+
38
+ ## Key files / entry points
39
+
40
+ - `_/app/devops/cloudfront.php` — class **`App_Devops_Cloudfront`** (all-static): the
41
+ single source of truth for account/distribution topology, AWS SDK client construction,
42
+ and all CloudFront + Route 53 domain logic.
43
+ - `mvc/devops/cloudfront-clients/get.php` — route `/devops/cloudfront-clients` (page shell).
44
+ - `mvc/devops/cloudfront-clients/post.php` — AJAX action endpoint (`action=process`).
45
+ - `assets/js/cloudfront-clients.js`, `assets/css/cloudfront-clients.css` — self-guarded on
46
+ the `.cloudfront-clients-tool` root element, registered globally in
47
+ `_/app/frameworkindex.php`.
48
+ - `_/app/nav.php` — new **`devops`** nav group, persona `['Development Team']`.
49
+ - `config.production.ini` — documented, **commented placeholder** `[cloudfront_<accountId>]`
50
+ credential sections (location only — **no secret values** are stored in the knowledge base
51
+ or authored into tracked config here).
52
+
53
+ Follows the same tool pattern as [Design Demo Admin](../features/design-demo-admin.md):
54
+ `get.php` + `post.php` + a static `App_` class + per-tool namespaced js/css + one nav entry.
55
+ The AWS SDK **v3** is vendored at `tools/vendor/autoload.php`; client construction mirrors
56
+ `App_Talos_S3::client()` (explicit autoload-require guard, `class_exists` check, `try/catch`,
57
+ `['ok' => ...]` array returns).
58
+
59
+ ## How it works
60
+
61
+ ### Topology is a single class constant
62
+
63
+ `App_Devops_Cloudfront::CLOUDFRONTS` is the **only** source of truth for what can be
64
+ onboarded, shaped as:
65
+
66
+ ```
67
+ [ '<awsAccountId>' => [ '<distributionId>' => '<baseDomain>' ] ]
68
+ ```
69
+
70
+ Adding or removing a distribution is done **solely** by editing this constant — no other code
71
+ change is required.
72
+
73
+ ### Per slug × distribution
74
+
75
+ 1. Build the FQDN `{slug}.{baseDomain}`.
76
+ 2. **CloudFront alias:** `GetDistribution` → append the FQDN to `Aliases.Items` and bump
77
+ `Aliases.Quantity` → `UpdateDistribution` with `IfMatch = ETag`.
78
+ 3. **Route 53 records:** create **A** and **AAAA** alias records targeting the distribution's
79
+ DNS name. `AliasTarget.HostedZoneId` is the fixed AWS CloudFront constant
80
+ **`Z2FDTNDATAQYW2`**; `EvaluateTargetHealth = false`.
81
+
82
+ ### Route 53 zone lookup — by name, not list-all
83
+
84
+ Zone resolution uses **`ListHostedZonesByName`** with the most-specific candidate walking down
85
+ to the registrable apex (e.g. `compass.dev.sandbox.togasupply.com` → the `togasupply.com`
86
+ zone). This jumps straight to the zone regardless of how many zones the account holds. The
87
+ earlier `ListHostedZones` (list-all + longest-suffix match) approach **silently found
88
+ nothing** and was replaced.
89
+
90
+ ### Record creation is INSERT-ONLY (never UPSERT)
91
+
92
+ Existing A/AAAA records are read first (`existingAliasTypes` via `ListResourceRecordSets`); the
93
+ tool creates **only the missing types** with the `CREATE` action. An existing record (e.g. a
94
+ live production DNS entry) is **always left untouched**. UPSERT is deliberately never used —
95
+ it would silently repoint a live production record.
96
+
97
+ ### Throttling + consistency gating
98
+
99
+ - **Adaptive retries:** the SDK client is built with
100
+ `'retries' => ['mode' => 'adaptive', 'max_attempts' => 10]` because CloudFront
101
+ `UpdateDistribution` is aggressively rate-limited and a run fires many calls (was hitting
102
+ `Throttling: Rate exceeded`).
103
+ - **Route 53 is gated on the CloudFront alias step succeeding for that item.** If the alias
104
+ fails (throttled, or `CNAMEAlreadyExists`), the Route 53 step is **skipped** so DNS is never
105
+ created pointing at a distribution that is not serving that hostname. Skips are **per item**;
106
+ the overall run continues past failures.
107
+
108
+ ### Safety rails
109
+
110
+ - **Dry-run (preview only)** mode performs no writes.
111
+ - **`MAX_SLUGS_PER_RUN = 100`** caps a single run.
112
+ - Every **non-dry-run** attempt writes an **audit log** line including the acting user's email
113
+ (`App_Auth::currentUser()`).
114
+
115
+ ## Cross-account credential model
116
+
117
+ Every account — **including** the production account `654654170868` that owns all Route 53
118
+ zones — resolves to its **own** `[cloudfront_<accountId>]` config section through the single
119
+ seam method **`credentialsForAccount()`**. Do **not** reuse the existing `[aws]` key for the
120
+ prod account: `[aws]` is a **cross-account** key that only reaches the `654654170868` SQS queue
121
+ from another account — it is **not an identity in `654654170868`**.
122
+
123
+ On the actual EB server the **`ElasticBeanstalk-EC2-Instance-Profile`** role *is* in
124
+ `654654170868`, so the prod account can use the instance role (no config key needed) once that
125
+ role is granted: `route53:ListHostedZonesByName`, `route53:ChangeResourceRecordSets`,
126
+ `route53:ListResourceRecordSets`, `cloudfront:GetDistribution`, `cloudfront:UpdateDistribution`.
127
+
128
+ ## Access control
129
+
130
+ Behind Tools SSO; the nav action and page are gated to the **`Development Team`** persona (see
131
+ [persona-gated-navigation](../features/persona-gated-navigation.md) and
132
+ [saml-sso-auth](../features/saml-sso-auth.md)).
133
+
134
+ ## Client variations
135
+
136
+ None — this is a shared internal DevOps tool. It *operates on* client slugs, but no single
137
+ client owns or overrides it.
138
+
139
+ ## Gotchas / known issues
140
+
141
+ - **`NoSuchDistribution` / zone-not-found means the WRONG ACCOUNT, not a permission problem.**
142
+ Permission failures return `AccessDenied`. A `NoSuchDistribution` or a missing
143
+ `togasupply.com` zone (when both demonstrably exist) means the call **authenticated into the
144
+ wrong account** — here, using the cross-account `[aws]` key against `654654170868`. Fix the
145
+ credential resolution, not the IAM policy. This is the key diagnostic for this tool.
146
+ - **A CloudFront CNAME can live on only one distribution.** Re-adding an FQDN already attached
147
+ to a different distribution returns **`CNAMEAlreadyExists`**; the item is skipped (and Route 53
148
+ is skipped with it).
149
+ - **Alternate domain names require a covering ACM certificate.** A wildcard cert covers only a
150
+ single label — `*.togasupply.com` does not cover `a.b.togasupply.com`. Ensure the distribution
151
+ has a cert covering the FQDN before adding it.
152
+ - **Route 53 CloudFront alias records use the fixed `HostedZoneId` `Z2FDTNDATAQYW2`** (an AWS
153
+ global constant for all CloudFront alias targets — not the zone's own ID).
154
+ - **1.0 promotes undefined-index warnings to a thrown `ErrorException`** that would abort the
155
+ whole "never fatally abort" run — all AWS-response array access is guarded. The SDK
156
+ client-builder `catch` is widened to **`\Throwable`** because the constructor can throw
157
+ `InvalidArgumentException`, not just `AwsException`.
158
+ - **Credential exposure caveat.** This tool wields the prod IAM key, which amplified the impact
159
+ of the separate config-file web-root exposure fixed this session — see the 1.0 back-end
160
+ security standard and the Tools architecture Known issues. Every secret in
161
+ `config.production.ini` still owes rotation because the file was publicly readable.
162
+
163
+ ## Change history
164
+
165
+ - 2026-07-24 — Built the CloudFront Client Setup tool (`App_Devops_Cloudfront` +
166
+ `/devops/cloudfront-clients` MVC route + namespaced assets + a `devops` nav group). Topology
167
+ is the `CLOUDFRONTS` class constant; per slug×distribution it adds a CloudFront alternate
168
+ domain name and Route 53 A+AAAA alias records (fixed `Z2FDTNDATAQYW2`). Cross-account
169
+ credentials resolve per-account via `credentialsForAccount()` (never reuse `[aws]` for the prod
170
+ account 654654170868 — instance role is used there). Route 53 lookup switched to
171
+ `ListHostedZonesByName`; record creation is INSERT-ONLY (never UPSERT); SDK adaptive retries
172
+ for CloudFront throttling; Route 53 gated on the alias step succeeding. Dry-run, per-run slug
173
+ cap (100), and per-attempt audit logging. (jcardinal)
174
+
175
+ ## Related docs
176
+
177
+ - [Tools Architecture](../architecture.md)
178
+ - [Design Demo Admin](../features/design-demo-admin.md)
179
+ - [Back-End Coding Standards (1.0)](../../standards/backend-php.md)
180
+ </content>
181
+ </invoke>
@@ -5,7 +5,7 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-09
8
+ updated: 2026-07-24
9
9
  owners: [jcardinal, rgirish, mhammontree]
10
10
  files: []
11
11
  related:
@@ -453,6 +453,45 @@ $order->location = App_Page::getVarIfSet('installation');
453
453
 
454
454
  * Always use HTTPS to encrypt data in transit.
455
455
 
456
+ ### Web-root file exposure — deny config/secret files at the front controller
457
+
458
+ A front controller whose only guard is a rewrite that skips existing files —
459
+ `RewriteCond %{REQUEST_FILENAME} !-f` — is **not access control**. `!-f` means "if the
460
+ requested path is a real file on disk, let Apache serve it directly rather than routing it
461
+ through `index.php`." Any file that physically sits under the web root (config, `.env`,
462
+ logs, SQL dumps, backups) is therefore served **verbatim** on a direct request. A plain
463
+ `GET /config.production.ini` returns the raw file, secrets and all. This is a real breach
464
+ class, not a theoretical one — it was found live in a 1.0 app this session.
465
+
466
+ Deny sensitive files explicitly at the front controller, in addition to (not instead of)
467
+ keeping them out of the web root:
468
+
469
+ ```apache
470
+ # Deny by sensitive extension
471
+ <FilesMatch "\.(ini|env|log|sql|sh|bak|dist)$">
472
+ Require all denied
473
+ </FilesMatch>
474
+
475
+ # Deny anything named config.* (config.production.ini, config.prod.ini, …)
476
+ <FilesMatch "^config\.">
477
+ Require all denied
478
+ </FilesMatch>
479
+ ```
480
+
481
+ Rules:
482
+
483
+ * **Keep config/secret files outside the web root where at all possible** — a file the web
484
+ server cannot reach cannot be served. Deny blocks are defense in depth for files that
485
+ must live inside the tree.
486
+ * **The `!-f` rewrite is a router optimization, never a security boundary.** Do not rely on
487
+ it to hide anything.
488
+ * **Anything that was ever web-readable is compromised and must be rotated** — assume it was
489
+ fetched. Rotate every credential in an exposed file; a deny rule added after the fact does
490
+ not un-expose what was already reachable.
491
+ * **Audit every 1.0 app's front controller** for this pattern. If a `.htaccess`/vhost routes
492
+ through `index.php` on `!-f` alone with no `FilesMatch` deny blocks, it is exposed — add
493
+ the deny blocks and treat any secret-bearing file under the root as leaked.
494
+
456
495
  ### Authentication and Authorization
457
496
 
458
497
  * Use the framework's `App_Auth` / `App_Acl` for authentication and role-based access control; do not roll your own session/permission checks.
@@ -9,8 +9,9 @@
9
9
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
10
10
  | [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
11
11
  | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
12
- | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php |
12
+ | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php, _underscore/Query.php |
13
13
  | [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
14
+ | [TOGa IQ Sprint Dashboard API (Record Scripts)](features/sprint-dashboard-api.md) | The internal **TOGa IQ sprint dashboard** is served in production by **six api2 Record Scripts** on `_Model_Team_Sprint` (`_underscore/Model/Team/Sprint.php`), | _underscore/Model/Team/Sprint.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql, dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql |
14
15
  | [Surface action-state via the surface=<slug> request option (M2M-safe)](features/surface-meta-option.md) | An opt-in V2 engine request option, `surface=<slug>`, that attaches per-record UI action state (`isVisible`/`isEnabled`) to a GET response **under `meta.surface | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Surface.php |
15
16
  | [TableView row-filtering via apiWhereClause (options.where grammar, end to end)](features/tableview-apiwhereclause-row-filtering.md) | `TableViews.apiWhereClause` (TEXT, nullable) is the sanctioned, code-free way to restrict or exclude rows from a 2.0 table view. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TableView.php, toga2-supply/src/api/toga.ts |
16
17
  | [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 |
@@ -6,12 +6,14 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-23
9
+ updated: 2026-07-24
10
10
  owners: ["kyalamarthi"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Team/Sprint.php
14
+ - _underscore/Query.php
14
15
  related:
16
+ - ./sprint-dashboard-api.md
15
17
  - ./scripted-api-post-body-args.md
16
18
  - ./tableview-apiwhereclause-row-filtering.md
17
19
  - ../architecture.md
@@ -35,7 +37,13 @@ register one). For the POST + JSON-request-body variant (large argument values),
35
37
  Motivating example: `_Model_Team_Sprint::committedTile()` — a read-only dashboard tile over
36
38
  the internal TOGa IQ sprint (`Team` schema) data. **Decision recorded:** internal
37
39
  sprint/dashboard read endpoints are served from **api2 via a Record Script**, not by building
38
- a bespoke controller or standing the data up inside the talos / TOGa IQ app.
40
+ a bespoke controller or standing the data up inside the talos / TOGa IQ app. This contract was
41
+ subsequently validated by productionizing the whole sprint dashboard into six Record Scripts —
42
+ see [TOGa IQ Sprint Dashboard API](./sprint-dashboard-api.md) for the concrete endpoint set.
43
+
44
+ **The model class lives in `_underscore`, not api2.** `_Model_Team_Sprint` is
45
+ `_underscore/Model/Team/Sprint.php`, pulled in at deploy — api2 has **no `Model/` directory**
46
+ of its own. Write the static method in `_underscore`, not in the api2 repo.
39
47
 
40
48
  ## The method contract
41
49
 
@@ -51,9 +59,17 @@ Rules the engine enforces (see `getRecordScriptPhpMethod()` + the dispatch in
51
59
  - **Remaining parameters are named** and are filled from the **query string**. Each query-string
52
60
  key maps to the same-named PHP parameter; unmatched named args land in a `...$args` variadic
53
61
  if the method declares one. (`transactionId` and `api` keys are reserved and never mapped.)
54
- - **The return value is the envelope `data`.** Return an object/array; do not echo, and do not
55
- return the bare success/error envelope — the engine wraps it.
62
+ - **The return value becomes the envelope `data` — but nested under the route segment.** The
63
+ engine does `$outData[$requestedUuid] = method(...)` then `return (object)$outData`
64
+ (`V2.php` ~:4121, :6305), so the script's payload lands **under the script's route-segment
65
+ key**, not flat. `GET /v2/sprints/tile` yields `data: { tile: {…} }`, not `data: {…}`;
66
+ `campaigns/jobs` yields `data: { jobs: … }`. **A consumer must unwrap `data[<route>]`.** The
67
+ method itself still returns the raw object/array — do not echo, and never build the
68
+ success/error envelope yourself.
56
69
  - Invocation is effectively `$model::$phpMethod(...$args)`.
70
+ - **No current-sprint (or any per-request context) middleware.** Unlike a hand-built app, the
71
+ `/v2` engine has no middleware that pre-resolves a "current" entity for a script. Each Record
72
+ Script must resolve its own context (e.g. the current sprint) inside the method.
57
73
 
58
74
  ## Running SQL inside a Record Script
59
75
 
@@ -66,7 +82,11 @@ then `->fetchRow()`) rather than going through model CRUD. Two framework realiti
66
82
  arguments arrive straight from the query string (user input), so this sanitization is
67
83
  mandatory — an unsanitized value in the query text is a SQL-injection hole. Treat
68
84
  `\_Database::escape()` + `(int)` casting as the required substitute for the prepared
69
- statements `_Query` does not offer.
85
+ statements `_Query` does not offer. For a query-string arg drawn from a **fixed set** (an
86
+ enum-like `?category=`), the safest handling is a **value allowlist** — map the arg to a
87
+ trusted server-side constant (e.g. the sprint dashboard maps `?category=` to the
88
+ `_Model_Team_Task::WORK_TYPE__*` constants) and reject anything unrecognized, so no raw
89
+ user value ever reaches the SQL text.
70
90
  - **Query cache for "live" tiles.** A dashboard tile that must reflect current data should call
71
91
  `\_Database::useQueryCache(false)` before the query and **restore the previous flag**
72
92
  afterward (capture the value it returns and set it back), so it doesn't serve stale cached
@@ -104,6 +124,18 @@ grant under the per-client `Client_<Name>` folder. See the
104
124
  [api2 architecture — metadata CRUD engine](../architecture.md) for the surrounding Records
105
125
  metadata model.
106
126
 
127
+ **Registration conventions** (confirmed against
128
+ `Core/… - TrackingNumberReprintRecordScript.sql` + its `Client/… - …ScriptAcl.sql`):
129
+
130
+ - Write the migrations **id-agnostic** — resolve FKs by looking the parent up **by route**,
131
+ and guard every insert with a **`NOT EXISTS`** subquery on its natural key so the file is
132
+ re-runnable and environment-agnostic (auto-increment ids differ per environment).
133
+ - `Core.Records` carries the human-facing `name` / `description`.
134
+ - The `AclRecordScripts` grant is a **CLIENT-DB** table. Put it in the specific
135
+ **`Client_<Name>`** folder for the tenant that should get the script — an internal-only
136
+ endpoint goes in `Client_True`. The generic **`Client/`** folder runs against **every**
137
+ tenant DB, so putting an ACL grant there hands the endpoint to all clients.
138
+
107
139
  ## Data source note (core DB hosts/credentials)
108
140
 
109
141
  The core cluster hosts and credentials this endpoint reads through are **not** documented here;
@@ -137,6 +169,15 @@ an `AclRecordScripts` grant in each client DB.
137
169
 
138
170
  ## Change history
139
171
 
172
+ - 2026-07-24 — Refined the engine contract from productionizing the sprint dashboard (verified
173
+ in `V2.php` + `_underscore/Query.php`): the return value **nests under the route-segment key**
174
+ (`data[<route>]`, not flat — consumers must unwrap); there is **no current-sprint/context
175
+ middleware** (each script resolves its own context); the `_Model` class lives in
176
+ **`_underscore`** (pulled at deploy — api2 has no `Model/` dir). Added registration
177
+ conventions (id-agnostic resolve-by-route + `NOT EXISTS` guards; the `AclRecordScripts` grant
178
+ belongs in the tenant-specific `Client_<Name>` folder, never generic `Client/`, or it grants
179
+ every tenant) and the value-allowlist pattern for enum-like query args. Linked the new
180
+ [Sprint Dashboard API](./sprint-dashboard-api.md). (kyalamarthi)
140
181
  - 2026-07-23 — Initial documentation of the Record Script authoring contract (static
141
182
  `fn(&$api, ...namedParams)` on the record model; query-string → named args; return → `/v2`
142
183
  envelope `data`), the SQL specifics (`_Query` has no bound params → `_Database::escape()` +
@@ -149,6 +190,8 @@ an `AclRecordScripts` grant in each client DB.
149
190
 
150
191
  ## Related docs
151
192
 
193
+ - [TOGa IQ Sprint Dashboard API](./sprint-dashboard-api.md) — the concrete six-endpoint set
194
+ that productionized this contract.
152
195
  - [POST + JSON-body args for scripted APIs](./scripted-api-post-body-args.md) — the POST /
153
196
  JSON-request-body variant for large argument values.
154
197
  - [api2 architecture](../architecture.md) — the metadata-driven /v2 CRUD engine that Record
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: TOGa IQ Sprint Dashboard API (Record Scripts)
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-24
10
+ owners: ["kyalamarthi"]
11
+ files:
12
+ - _underscore/Model/Team/Sprint.php
13
+ - api2/Component/Api/V2/V2.php
14
+ - dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql
15
+ - dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql
16
+ related:
17
+ - ./record-scripts.md
18
+ - ../architecture.md
19
+ - ../../worker2/features/team-sprint-management.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ The internal **TOGa IQ sprint dashboard** is served in production by **six api2 Record
25
+ Scripts** on `_Model_Team_Sprint` (`_underscore/Model/Team/Sprint.php`), reading the `Team`
26
+ schema on the core cluster. This replaced the prior session's local Node/React + Express
27
+ (mysql2) stand-in — the prototype only ever proved the metric SQL; this is the real `/v2`
28
+ path. **No api2 code was written**: the endpoints are the generic `/v2` engine plus the
29
+ model methods (which live in `_underscore`) plus metadata rows seeded via dbchanges2.
30
+
31
+ Producer/consumer split: **worker2** (`_Worker_Team_Sprint`) captures ClickUp sprint data
32
+ into `DB_TEAM`; **api2** serves it read-only through these Record Scripts. The dashboard is
33
+ internal-only — its `AclRecordScripts` grant is scoped to the **True** (staff) tenant.
34
+
35
+ For the general authoring/registration contract these follow, see
36
+ [Record Scripts](./record-scripts.md). For the metric definitions the SQL implements, see
37
+ [Team Sprint Management](../../worker2/features/team-sprint-management.md).
38
+
39
+ ## The six endpoints
40
+
41
+ All are `public static` methods on `_Model_Team_Sprint`, each run as `new \_Query($sql,
42
+ \_underscore::DB_TEAM)` returning a raw object the `/v2` engine envelopes. Route is
43
+ `GET /v2/sprints/<scriptRoute>` (record route `sprints` → `_Model_Team_Sprint`).
44
+
45
+ | phpMethod | Purpose |
46
+ |-----------|---------|
47
+ | `current` | Resolve the current sprint (never hardcoded — see below). |
48
+ | `sprintTile` | A single KPI tile; selected by **`?category=`** (Committed / Conditional / Stretch / Unplanned). |
49
+ | `statusBreakdown` | Status-rollup pie (the SWITCH-into-five-buckets, default → Review). |
50
+ | `workTypeBreakdown` | Work-type pie — `SUM(sprintPointsNow) GROUP BY workTypeNow`. |
51
+ | `pointsByDev` | Sprint points by developer (Tasks ⋈ Tasks_Developers ⋈ Developers). |
52
+ | `burndown` | Burndown over working days (CCU vs CCUS), `dayNumber` 1-based. |
53
+
54
+ ## Envelope nesting — data is under the route-segment key
55
+
56
+ The engine nests a scripted return value **under the script's route segment**, it is not
57
+ flattened: `GET /v2/sprints/tile` returns `data: { tile: {…} }`, and `GET /v2/sprints/current`
58
+ returns `data: { current: {…} }`. A consumer must unwrap `data[<route>]`, not read `data`
59
+ directly. (Same shape as the live `campaigns/jobs` script → `data: { jobs: … }`.) This is the
60
+ single biggest gotcha for the frontend cutover.
61
+
62
+ ## Current-state (Power BI) scoring — deliberate, not canonical
63
+
64
+ These endpoints reproduce the **Power BI current-state** numbers the dashboard was validated
65
+ against: done = `dtDone IS NOT NULL` and category from `workTypeNow`. They do **not** use the
66
+ canonical **at-lock** sprint scoring (`workTypeAtLock` + `statusNow IN (STATUS_IN__DONE)`) used
67
+ by the model's `_score*` / `_reliabilityScore` helpers. Reconciling current-state vs canonical
68
+ for a leadership view is a **separate, deliberate decision left open** — do not assume these
69
+ tiles agree with sprint scores. See the two-definitions section in
70
+ [Team Sprint Management](../../worker2/features/team-sprint-management.md).
71
+
72
+ Verified against live prod (sprint 83): the **Conditional** KPI tile reads `workTypeAtLock`
73
+ (72 pts) while the work-type pie reads `workTypeNow` (67 pts) — a real at-lock-vs-now
74
+ divergence, by design, not a bug.
75
+
76
+ ## SQL safety (no bound params in `_Query`)
77
+
78
+ `_Query` has no `?`/`:name` binding, so injection safety is by construction, matching the
79
+ model's existing `_score*` helpers:
80
+
81
+ - The sprint number is `(int)`-cast.
82
+ - The `?category=` query-string value is **not interpolated raw** — it is mapped through a
83
+ value allowlist to the `_Model_Team_Task::WORK_TYPE__*` constants; an unrecognized category
84
+ is rejected, never placed into SQL.
85
+ - Every other column/value in the query text is a trusted server-side constant.
86
+
87
+ Work-type values in the `Team` schema are stored **UPPERCASE** (`COMMITTED` / `CONDITIONAL`
88
+ / `STRETCH` / `UNPLANNED`); the productionized code sources them from the
89
+ `_Model_Team_Task::WORK_TYPE__*` constants rather than mixed-case literals (the prototype's
90
+ `'Committed'` only worked because the collation is case-insensitive).
91
+
92
+ ## Registration (two dbchanges2 files)
93
+
94
+ Registered as metadata, not a code deploy (see [Record Scripts](./record-scripts.md) for the
95
+ full contract). Two migrations, both **id-agnostic resolve-by-route + `NOT EXISTS` guards** so
96
+ they are re-runnable and environment-agnostic:
97
+
98
+ - `dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql` — one `Core.Records` row
99
+ (`route 'sprints'` → `_Model_Team_Sprint`, with `name`/`description`, `aclDatabase = 'CORE'`
100
+ because `Team` is co-located on the core cluster) + six `Core.RecordScripts` rows.
101
+ - `dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql` — the
102
+ `Client.AclRecordScripts` grant. It is a **CLIENT-DB** table, so it lives in the per-client
103
+ **`Client_True`** folder (roles **1 / 3 / 4**). Putting it in the generic `Client/` folder
104
+ would grant the internal dashboard to **every** tenant.
105
+
106
+ ## Data source note
107
+
108
+ The core-cluster hosts/credentials the `Team` reads flow through live in api2 `Config/*.ini`
109
+ (`[database]`) — never in knowledge or code.
110
+
111
+ ## Open items (frontend cutover)
112
+
113
+ - Frontend `fetchEnvelope` must unwrap `data[<route>]` (see nesting above).
114
+ - Replace the prototype's empty-placeholder `AUTH_TOKEN` with real Bearer-JWT auth, and
115
+ reconcile the ACL role (True 1/3/4) to the dashboard's auth identity.
116
+ - Verify `Tasks_Developers` has a `UNIQUE (taskId, developerId)` key plus sprint/workType
117
+ indexes.
118
+ - `pointsByDev` filters to a first-name allowlist (excludes non-roster devs) — keep that
119
+ allowlist in sync with the roster.
120
+
121
+ ## Change history
122
+
123
+ - 2026-07-24 — Productionized the TOGa IQ sprint dashboard as six api2 Record Scripts on
124
+ `_Model_Team_Sprint` (`current`, `sprintTile ?category=`, `statusBreakdown`,
125
+ `workTypeBreakdown`, `pointsByDev`, `burndown`), replacing the local Express/mysql2
126
+ prototype with no api2 code change. Documented the **route-segment envelope nesting**
127
+ (`data[<route>]`), the deliberate Power BI current-state scoring (Conditional tile
128
+ `workTypeAtLock` 72 pts vs work-type pie `workTypeNow` 67 pts on sprint 83), UPPERCASE
129
+ work-type values sourced from `_Model_Team_Task::WORK_TYPE__*`, the `?category=` allowlist
130
+ as the `_Query` injection defense, and the two-file dbchanges2 registration (Core records
131
+ + `Client_True` ACL grant to roles 1/3/4). (kyalamarthi)
132
+
133
+ ## Related docs
134
+
135
+ - [Record Scripts](./record-scripts.md) — the authoring/registration contract these follow.
136
+ - [Team Sprint Management & Reporting](../../worker2/features/team-sprint-management.md) — the
137
+ worker2 producer and the metric definitions the SQL implements.
138
+ - [api2 architecture](../architecture.md) — the metadata-driven `/v2` engine.
139
+ </content>
140
+ </invoke>
@@ -186,9 +186,11 @@ All sprint state lives in the Team database (`_underscore::DB_TEAM`). Core table
186
186
  **Column vocabularies & datetime columns (dashboard-relevant).** For current-state reporting the
187
187
  exact values matter:
188
188
 
189
- - **`workTypeNow` / `workTypeAtLock`** — enum `COMMITTED / CONDITIONAL / STRETCH / UNPLANNED`.
190
- MySQL string comparison is **case-insensitive** by default, so `'Committed'`/`'STRETCH'`/etc.
191
- all match regardless of case.
189
+ - **`workTypeNow` / `workTypeAtLock`** — enum `COMMITTED / CONDITIONAL / STRETCH / UNPLANNED`,
190
+ stored **UPPERCASE** in prod. MySQL string comparison is **case-insensitive** by default, so
191
+ a mixed-case literal (`'Committed'`) still matches — but production code should source these
192
+ from the **`_Model_Team_Task::WORK_TYPE__*` constants** rather than typing literals (the api2
193
+ sprint-dashboard Record Scripts do this).
192
194
  - **`statusNow` / `statusAtLock` / `statusAtEnd`** — free-form varchar, values stored
193
195
  **lower-case**: `complete`, `to do`, `in progress`, `stage review`, `hotfix review`,
194
196
  `back-end review`, `ui review`, `on hold`, `roadblocked`, `awaiting client`, `rework`,
@@ -319,7 +321,9 @@ Points = `sprintPointsNow`; each task is assigned **one** segment via a SWITCH e
319
321
  3. else → the task's **`workTypeNow`** value (Committed / Conditional / Unplanned).
320
322
 
321
323
  Joined `Tasks → Tasks_Developers → Developers` and grouped by developer. Because the join is
322
- many-to-many, each assigned developer is credited the task's **full** `sprintPointsNow`.
324
+ many-to-many, each assigned developer is credited the task's **full** `sprintPointsNow`. The
325
+ chart also filters developers to a **first-name allowlist** (the active roster), so non-roster
326
+ developers who happen to be assigned are excluded from the per-dev breakdown.
323
327
 
324
328
  ### Sprint Burndown (over working days)
325
329
  Plotted over **working days only** (Mon–Fri, ~10 per two-week sprint; call the count `N`,
@@ -350,12 +354,21 @@ middleware defaults every tile/chart endpoint to it when no `?sprint=` is suppli
350
354
  > [per-client database connections](../../_underscore/features/per-client-database-connections.md))
351
355
  > by a local Node/React + Express (mysql2) prototype that stands in for the api2 `/v2` engine and
352
356
  > returns the standard api2 envelope. The prototype's DB credentials live in an **uncommitted
353
- > `.env`** (never in the repo or this doc). When productionized, the tiles become api2 Record
354
- > Scripts registered via **dbchanges2** — see
355
- > [Record Scripts](../../api2/features/record-scripts.md).
357
+ > `.env`** (never in the repo or this doc). The dashboard has since been **productionized**:
358
+ > the tiles/charts are now six api2 Record Scripts on `_Model_Team_Sprint`, registered via
359
+ > **dbchanges2** and granted to the True tenant — see
360
+ > [TOGa IQ Sprint Dashboard API](../../api2/features/sprint-dashboard-api.md) and the general
361
+ > [Record Scripts](../../api2/features/record-scripts.md) contract. The Express stand-in is
362
+ > retired.
356
363
 
357
364
  ## Change history
358
365
 
366
+ - 2026-07-24 — Productionized the dashboard: the metric definitions above are now served by six
367
+ api2 Record Scripts on `_Model_Team_Sprint` (see
368
+ [Sprint Dashboard API](../../api2/features/sprint-dashboard-api.md)), retiring the Express
369
+ prototype. Refined the data model: work-type values are stored **UPPERCASE** and should be
370
+ sourced from the `_Model_Team_Task::WORK_TYPE__*` constants; the Sprint-Points-By-Dev chart
371
+ filters to a **first-name roster allowlist**. (kyalamarthi)
359
372
  - 2026-07-24 — Documented the full **Power BI dashboard metric definitions (DAX → SQL)** for the
360
373
  TOGa IQ sprint dashboard: KPI tiles by work type (with the non-uniformity that **Conditional
361
374
  filters on `workTypeAtLock`** while Committed/Stretch/Unplanned filter on `workTypeNow`), the
@@ -13,13 +13,13 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
13
13
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
14
14
  - **test** (Test) — 13 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
15
15
  - **toga** (TOGa) — 2 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
16
- - **tools** (Tools) — 9 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
16
+ - **tools** (Tools) — 10 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
20
  - **_underscore** (_Underscore) _(framework core)_ — 38 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 31 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
- - **api2** (API) — 16 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
22
+ - **api2** (API) — 17 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)
@@ -6,12 +6,13 @@ apps:
6
6
  - tools
7
7
  - worker2
8
8
  - dbchanges2
9
+ - api2
9
10
  project: _Underscore
10
11
  client: true
11
12
  type: profile
12
13
  status: active
13
- updated: 2026-06-29
14
- owners: [jcardinal]
14
+ updated: 2026-07-24
15
+ owners: [jcardinal, kyalamarthi]
15
16
  files: []
16
17
  related:
17
18
  - clients/true/features/users-personas-data-model.md
@@ -28,6 +29,11 @@ by staff role (e.g. the planned Toolbox app) reads its `Users` / `Personas` mode
28
29
  - **Client identifier:** `True`
29
30
  - **SSO mapper:** `_Model_True_ClientAuthentication` (base; matches by email from NameID)
30
31
 
32
+ The internal **TOGa IQ sprint dashboard** is served to True from **api2** as six Record
33
+ Scripts on `_Model_Team_Sprint` (over the `Team` schema), with the `AclRecordScripts` grant
34
+ scoped to True roles 1/3/4 — hence `api2` is in this client's `apps`. See
35
+ [Sprint Dashboard API](../../2.0/apps/api2/features/sprint-dashboard-api.md).
36
+
31
37
  The **Tools** app (1.0; repo `tools`) authenticates True users via **SSO** through
32
38
  `saml.togahub.com` and reads the `Client_True` DB **read-only** to gate its internal tooling by
33
39
  staff persona (see `1.0/apps/tools/`). Its gateway return domain is registered in `Core.Domains`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.436",
3
+ "version": "1.0.438",
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",