toga-ai 1.0.1107 → 1.0.1108
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.
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +5 -1
- package/knowledge/2.0/apps/_underscore/features/autoincrement-record-numbering.md +4 -2
- package/knowledge/2.0/apps/_underscore/features/error-reporting-issue-event.md +14 -4
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-key-authentication-and-scoping.md +26 -3
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +4 -1
- package/knowledge/2.0/apps/api2/features/nested-relationship-writes.md +3 -2
- package/knowledge/2.0/apps/worker2/architecture.md +5 -1
- package/knowledge/2.0/apps/worker2/features/committed-secrets-and-config-debt.md +3 -1
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +2 -0
- package/knowledge/2.0/standards/framework-rules.md +16 -1
- package/knowledge/clients/true/profile.md +2 -1
- package/package.json +1 -1
|
@@ -6,10 +6,11 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-22
|
|
10
10
|
owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- _underscore/Model/Core/Record.php
|
|
13
14
|
- _underscore/Component/Acl/ReadExpression/ReadExpression.php
|
|
14
15
|
- dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql
|
|
15
16
|
- _underscore/Model/Core/Page.php
|
|
@@ -590,6 +591,8 @@ readable/writable fields from `AclFieldPermissions`. The row-level READ expressi
|
|
|
590
591
|
[notification engine](./notification-engine.md); change it in one place. `_Model_Core_Page::meta()` reads the same
|
|
591
592
|
tables to return per-page ACL to the frontend.
|
|
592
593
|
|
|
594
|
+
**Checking one row outside a V2 request:** `_Model_Core_Record::isActionPermittedOnRow(&$api, {recordId, primaryKeyId, action})` (`_underscore/Model/Core/Record.php`, 2026-10-22) is a read-only check that mirrors V2: READ / DELETE run `isPermitted` on every permission; only UPDATE has the "no logic group = plain grant" shortcut; roles come from the JWT (+ Public for Core records), plus app id and indirect access. Use it instead of re-coding the chain; change it together with V2.
|
|
595
|
+
|
|
593
596
|
> **⚠ There is NO ACL/metadata caching layer — the load is per-request and there is nothing to bust.**
|
|
594
597
|
> `buildLookups()` re-reads the ACL/metadata tables on **every** request, so an inserted/updated
|
|
595
598
|
> `AclCustomFieldPermissions` / `CustomRecordFields` / `AclFieldPermissions` row takes effect on the
|
|
@@ -946,6 +949,7 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
|
|
|
946
949
|
and every repo is on the **same branch** so the generated model matches the DB.
|
|
947
950
|
|
|
948
951
|
## Change history
|
|
952
|
+
- 2026-10-22 — Added `_Model_Core_Record::isActionPermittedOnRow` (one-row ACL check mirroring V2) (TRUE-82919). (jcardinal)
|
|
949
953
|
- 2026-10-20 — Client→Core joins are field-checked with core roles: `settingId.uuid` needed a Core Public read grant on `Core.Settings` (TRUE-82934). (jcardinal)
|
|
950
954
|
- 2026-10-18 — Row-level READ expression moved to shared `_Component_Acl_ReadExpression` (V2 + notification engine). TRUE-82907 (jcardinal)
|
|
951
955
|
- 2026-10-18 — Client ACL rows now carry `capabilityId`; linked the new Capabilities doc (TRUE-82880). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-21
|
|
10
10
|
owners: ["bala", "rgirish", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model.php
|
|
@@ -41,7 +41,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
|
|
|
41
41
|
2. **Which table:** from the model's `const DATABASE`: `DB_CORE` → `Core.RecordNumbers`; `DB_CLIENT` → that client's; anything else throws.
|
|
42
42
|
3. **Which row:** walk class + parents against `Core.Records.model` (stored with leading backslash), cached per class. Client overrides (`_Model_Compass_Canada_SalesOrder`) use the base row. No row → plain `Exception` (fail loud, no self-seeding).
|
|
43
43
|
4. **Allocate:** `UPDATE RecordNumbers SET nextNumber = LAST_INSERT_ID(nextNumber + 1) WHERE recordId = X` on a separate sequence connection, committed at once (`_Database::DB_CORE_SEQUENCE` `'CoreSequence'` / `DB_CLIENT_SEQUENCE` `'ClientSequence'`). Number = insertId − 1; prefix read on the same connection.
|
|
44
|
-
5. **Sequence connections** follow their source alias on `register()`; dropped/closed when the source is re-pointed or re-registered with other settings (a stale ClientSequence never uses another tenant's counter). `connect()` falls back to the source DB when Core/Client is not registered by alias (worker2 prod).
|
|
44
|
+
5. **Sequence connections** follow their source alias on `register()`; dropped/closed when the source is re-pointed or re-registered with other settings (a stale ClientSequence never uses another tenant's counter). `connect()` falls back to the source DB when Core/Client is not registered by alias (worker2 prod). The source → sequence map is `private static function sequenceDatabases(): array` in `Database.php`; it adds only aliases the app has `defined()` (was a class constant `SEQUENCE_DATABASES` — see Gotchas).
|
|
45
45
|
6. **Format:** `prefix . nextNumber` — counting in order, no padding, no hyphen. Prefix validated in PHP: 1–8 letters A–Z except O, I, B (look like 0, 1, 8); L allowed. No CHECK constraint ([dbchanges2 rule 15](../../dbchanges2/architecture.md)).
|
|
46
46
|
7. **Hand-set number wins** (counter untouched). `forSearching` with null number skips the field. 1062 retry up to 5, only when the dup is the number this save generated.
|
|
47
47
|
|
|
@@ -66,6 +66,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
|
|
|
66
66
|
- **Old `MAX()+1` race (why this exists):** concurrent creates got one number → 1062 → HTTP 400 `EV-10`, record lost (Prudential SR, TRUE-82209).
|
|
67
67
|
- **Core seed fails on a local stray `RecordFields` row** for `clients.number` (one local had id 2598, not in prod/dev-sandbox/repo) → guarded insert of 2740 hits unique `recordId_field`. Delete the stray row locally; the file fails loud on drift on purpose.
|
|
68
68
|
- **Reproducing a race needs distinct rows per call** (different contact + serial) and a gate-file release ([V2 request retry](../../api2/features/v2-deadlock-retry.md)); sandbox-dev `max_connections = 90` → bursts ~10.
|
|
69
|
+
- **⚠ The first `SEQUENCE_DATABASES` class constant took apiproxy down (fixed 2026-10-21, TRUE-83035, commit `77adb007`, PR `_underscore#793`).** It named `_underscore::DB_CORE` / `DB_CLIENT`, which apiproxy does not define → `Undefined constant _underscore::DB_CORE` (Database.php:52) on the first `_Database` static use, every request. Now a method that skips undefined aliases. Rule: [_underscore architecture](../architecture.md) Gotchas → app-only constants in core.
|
|
69
70
|
- **Run `php -l Model.php Database.php` after merge conflicts on env branches** — a past merge dropped `_Database::$_aliases` → beta outage.
|
|
70
71
|
|
|
71
72
|
## Verification
|
|
@@ -80,6 +81,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
|
|
|
80
81
|
- [NetSuite SalesOrder outbound push](../../worker2/features/netsuite-salesorder-outbound-push.md)
|
|
81
82
|
|
|
82
83
|
## Change history
|
|
84
|
+
- 2026-10-21 — Sequence map moved from class constant to `sequenceDatabases()` (only defined aliases) after it fataled apiproxy. TRUE-83035 (jcardinal)
|
|
83
85
|
- 2026-10-18 — `_production` merged into `toga25`; Scope + Core Client models on plain `FIELD_AUTOINCREMENT`; Scopes seeded `SC` 100020. TRUE-82907 (jcardinal)
|
|
84
86
|
- 2026-10-18 — Final design: per-client `RecordNumbers` table (client DBs + Core), counting in order, old series continued under new prefixes, Compass Canada SAC; dropped the platform-wide scrambled `Core.Records` counter. TRUE-82715. (jcardinal)
|
|
85
87
|
- 2026-10-13 — Platform-wide scrambled counter in `Core.Records` (dropped before release). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-21
|
|
10
10
|
owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala", "apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Error.php
|
|
@@ -629,9 +629,12 @@ cannot. The agreed design still holds on the *sink* (shared Core Logs DB, not pe
|
|
|
629
629
|
*who escalates* (the worker2 cron owns escalation and notification only).
|
|
630
630
|
|
|
631
631
|
The cost of a direct write is that **each app must have the Core Logs connection registered**.
|
|
632
|
-
The capture path
|
|
633
|
-
`
|
|
634
|
-
|
|
632
|
+
The capture path **skips** capture (sets `lastCaptureFailure`, returns `null`) rather than
|
|
633
|
+
fatalling when the logs DB is absent — two guards, in order: `defined('_underscore::DB_LOGS')`
|
|
634
|
+
(the app's `_.php` may not declare it), then
|
|
635
|
+
`isset(_Database::$_registers[_underscore::DB_LOGS])`. Only api2, saml and worker2 define
|
|
636
|
+
`DB_LOGS`; every other 2.0 app (e.g. apiproxy) records **nothing** in `Logs.Issue`/`Event` — its
|
|
637
|
+
errors reach only the server PHP error log.
|
|
635
638
|
|
|
636
639
|
#### Why api2 is in scope at all
|
|
637
640
|
|
|
@@ -687,6 +690,13 @@ read you feed it — `_Error` promotes a *"read property on int"* warning into a
|
|
|
687
690
|
|
|
688
691
|
## Gotchas
|
|
689
692
|
|
|
693
|
+
- **⚠ `captureException()` fataled in every app without `DB_LOGS` (fixed 2026-10-21, TRUE-83035).**
|
|
694
|
+
From 2026-07-30 (`176c35fe`/`be86f740`) to the fix, `Error.php` read `_underscore::DB_LOGS` with
|
|
695
|
+
only an `isset($_registers[...])` guard. `isset()` does **not** protect: evaluating an undefined
|
|
696
|
+
class constant is a fatal `Error`. On apiproxy every error crashed inside the handler
|
|
697
|
+
(`Undefined constant _underscore::DB_LOGS in .../_underscore/Error.php:373`, from
|
|
698
|
+
`exceptionHandler` and `shutdownHandler`), hiding the real first error. Rule: see the
|
|
699
|
+
`defined()` gotcha in the [_underscore architecture](../architecture.md).
|
|
690
700
|
- **`Logs.Issue` starts 2026-08-03 on prod — there are no rows before it.** The table went live with
|
|
691
701
|
that migration, so **any 500 older than 2026-08-03 has no Issue row at all**: looking its
|
|
692
702
|
`error.id` up returns nothing, and the `Logs.Api` row is the only record you get. An empty result
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary |
|
|
4
4
|
|-----|---------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | Backend for the public TOGa 2.0 API — metadata/ACL-driven REST JSON (`/v2`) + a cXML gateway, both over `_underscore` `_Model`; open for the response-envelope c |
|
|
6
|
-
| [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer
|
|
6
|
+
| [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer, auditing what a key di |
|
|
7
7
|
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | How `_Model` prePost/postPost/prePut/postPut hooks are dispatched by the api2 /v2 engine only when a metadata row exists — the missing-row silent no-op, the wro |
|
|
8
8
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | The scatter-gather engine that returns one entitled user's records across many client DBs from a single GET (triggered by a `client` query option); open when wo |
|
|
9
9
|
| [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | How supplier cXML ShipNotices become `/v2/advance-shipping-notices` writes, plus the per-API tracking-bridge provisioning and cross-tenant carrier trap; open wh |
|
|
@@ -6,18 +6,23 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["bala"]
|
|
9
|
+
updated: 2026-10-22
|
|
10
|
+
owners: ["bala", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- _underscore/Component/Api/Toga/Toga.php
|
|
14
|
+
- worker2/Worker/Workflow/Run.php
|
|
15
|
+
- _underscore/Model/Client/TicketNote.php
|
|
13
16
|
related:
|
|
17
|
+
- ../../standards/framework-rules.md
|
|
18
|
+
- ../../worker2/features/creating-worker-actions.md
|
|
14
19
|
- ../architecture.md
|
|
15
20
|
- ./request-logging.md
|
|
16
21
|
- ../../_underscore/features/acl-permission-chain.md
|
|
17
22
|
- ../../../../clients/compass-usa/workflows/granting-api-access.md
|
|
18
23
|
---
|
|
19
24
|
|
|
20
|
-
How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer
|
|
25
|
+
How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer, auditing what a key did, or making a worker job call api2.
|
|
21
26
|
|
|
22
27
|
## Summary
|
|
23
28
|
|
|
@@ -44,6 +49,19 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
|
|
|
44
49
|
|
|
45
50
|
**The grant chain still applies.** An `AclRecordPermissions` row on its own grants nothing — the request fails with `No ACL Logic Groups defined for ACL Record Permissions`. Full rules, tables, checklist: [ACL Permission Chain](../../_underscore/features/acl-permission-chain.md).
|
|
46
51
|
|
|
52
|
+
**A worker2 job calling api2 as an API record** (team rule: [machine callers use an API record, never a fake user](../../standards/framework-rules.md)). Reference: `worker2/Worker/Workflow/Run.php`.
|
|
53
|
+
|
|
54
|
+
1. Seed one `Client_<Client>.Apis` row per client (fixed `name`, random v4 `uuid` + secret, `isActive = 1`) and link it to a role through `Apis_Roles`. Set `maxAuthsPerHour` high (Workflow uses 1000): every queued job is a new process and signs in once.
|
|
55
|
+
2. In the job, call `_Database::registerClientDatabases` first, then load the row by `name` + `isActive`. Never take "the first active `Apis` row" — `Worker/Startech.php` and `Netsuite/SalesOrder.php` do, and break when a client adds a second key.
|
|
56
|
+
3. Call `_Component_Api_Toga::send(clientUuid, apiUuid, secret, ...)` → `POST /auth/api {client, api, secret}`.
|
|
57
|
+
4. Result: the JWT carries `id.client.api.uuid`; roles = `Apis_Roles`; Activity shows `Apis.name` as the actor; `Logs_<Client>.Record` rows get `apiId`.
|
|
58
|
+
|
|
59
|
+
**What changes for an API caller.** api2 does not call `_Model::setRequestUserId()`, so `_Model::getRequestUserId()` is `null`. Every gate that needs a signed-in user behaves differently — check each one the job hits:
|
|
60
|
+
- `ServiceRequest` stage/reason rules and `ServiceRequestNote` `MESSAGE_NEEDS_USER` refuse or skip.
|
|
61
|
+
- `TicketNote` accepts an authorless note from an API caller (since 2026-10-22); it still refuses an unauthenticated request.
|
|
62
|
+
- Author-stamped note models (`authorUserId`, see [note authorship](../../_underscore/features/note-record-authorship.md)) store `NULL`.
|
|
63
|
+
- Cross-client calls reject API callers.
|
|
64
|
+
|
|
47
65
|
**Auditing what a key actually did.** `Logs_<Client>.Api` carries `apiId`, `dtStamp`, `method`, `route`, `responseCode`, `queryString`, `requestPayload`, `responsePayload`. **Filter on `apiId`** to get one consumer's exact calls and responses. Note the client-vs-core log split (failed writes and pre-scope auth failures land in core `Logs`): [V2 Request Logging](./request-logging.md).
|
|
48
66
|
|
|
49
67
|
## Gotchas
|
|
@@ -58,6 +76,7 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
|
|
|
58
76
|
- **Roles are replaced, not merged.** Auditing a key means reading `Apis_Roles` and nothing else. An API key never picks up any other role.
|
|
59
77
|
- **A shared role across keys means the key identifies the caller but does not limit it.** If several `Apis` rows point at the same roles, every one can do everything those roles allow, regardless of the integration's published documentation.
|
|
60
78
|
- **Never record a secret value in a doc or a committed SQL file.** Document only where it lives (the `Client_<Client>.Apis` row) and hand it over out of band.
|
|
79
|
+
- **⚠ Debug mode used to print the secret.** `_Component_Api_Toga` `authenticate` / `authenticateUser` dumped secrets, passwords and tokens in debug mode — removed 2026-10-22 (params now `#[\SensitiveParameter]`), and the `_ApiRequest` debug dump redacts `Authorization`. Still printed in debug: payloads, raw responses, `Cookie` and `X-Api-*` headers. Never share a debug-mode log.
|
|
61
80
|
- **Do not copy the `authority` field into caller documentation.** The response envelope's `authority` reports the **internal** server that handled the request (observed: `api1.togahub.com`, `api-writer.togahub.com`). Callers must always use `https://api.togahub.com/v2`.
|
|
62
81
|
|
|
63
82
|
## Related
|
|
@@ -66,3 +85,7 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
|
|
|
66
85
|
- [V2 Request Logging](./request-logging.md)
|
|
67
86
|
- [ACL Permission Chain](../../_underscore/features/acl-permission-chain.md)
|
|
68
87
|
- [Granting Compass API access](../../../../clients/compass-usa/workflows/granting-api-access.md)
|
|
88
|
+
- [Creating worker actions](../../worker2/features/creating-worker-actions.md)
|
|
89
|
+
|
|
90
|
+
## Change history
|
|
91
|
+
- 2026-10-22 — worker2 job → api2 as an `Apis` record (lookup by name, `maxAuthsPerHour`); API caller has no request user id; `TicketNote` accepts authorless API notes; debug dump no longer prints secrets (TRUE-82919). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-22
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -350,6 +350,8 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
|
|
|
350
350
|
- **Registration is per-environment.** Like `RecordFields` / `RecordScripts` / ACL rows, this is metadata — "works in prod, not in beta/dev-sandbox" is the signature of drift, not of a bad deploy. See the [non-prod metadata drift repair workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
|
|
351
351
|
- **⚠ An in-request read-after-write in an interceptor works on beta and fails 100% in production.** Reads default to the **reader** connection, which cannot see the request's own uncommitted row; dev-sandbox points writer and reader at the same endpoint, so it hides the bug. Pin with `setIsReadHostEnabled(false)` (restore in `finally`).
|
|
352
352
|
- **`internalApiRequest` has no `data` envelope** (`$response->salesOrders`, not `$response->data->salesOrders`), and `minDepth` cannot deepen a POST interceptor's payload — the bump happens after serialization. A wrong read path here is a **silent no-queue with a clean 201**.
|
|
353
|
+
- **`internalApiRequest` payload shape now matches a real HTTP JSON body (since 2026-10-22).** It used `_Array::toObject`, which turned every nested LIST into an object, so `is_array($payload->ticketNotes)` failed only on internal requests (the Ticket resolve-note rule refused internal resolves). `toRequestObject` / `toRequestShape` in `V2.php` now give: assoc array → object, list stays list, empty nested `[]` stays `[]`. Callers checked (Compass, Adyen, ItemFulfillment, Growrk, Ticket merge/convert) — none relied on the old shape.
|
|
354
|
+
- **⚠ A post interceptor has no after-commit hook** — it runs inside the open transaction. For work that must start after the commit, add a sibling of `V2::dispatchNotifications()` (runs after data + log commit; e.g. `dispatchWorkflowEvents()`), see [notification engine](../../_underscore/features/notification-engine.md). Locally, debug-mode `runTask` is a synchronous HTTP POST to worker2, which can deadlock on locks the outer request still holds.
|
|
353
355
|
- **Anything a post interceptor queues must survive the transaction boundary** — queue only the uuid; the worker reads with a bounded wait-and-retry until the commit lands. A `catch` around `runTask` must `_Error::captureException` (not only `error_log`). See [runTask parameters are ids only](../../worker2/features/creating-worker-actions.md#-runtask-parameters-are-idsuuids-only--never-record-objects).
|
|
354
356
|
- **Never emit output from an interceptor.** A stray `echo`/`print_r` corrupts the `/v2` `{isSuccess,…,data}` envelope (this really happened in `ItemFulfillment.php` — see the carrier-shipping-labels doc).
|
|
355
357
|
- **Interceptors are a client-behavior extension point.** Per api2's architecture doc, client specifics belong in `_Model_<Slug>_X` overrides + interceptor hooks, **not** in `V2.php`.
|
|
@@ -378,5 +380,6 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
|
|
|
378
380
|
- 2026-10-15 — PRE → POST state hand-off via `$api->passThrough`; detect a real field change, not a resent one (TRUE-82727). (bala)
|
|
379
381
|
- 2026-10-16 — Command keys: strip in `prePut`, park on `passThrough`, act in `postPut`; empty body → `{}` (TRUE-82849). (jcardinal)
|
|
380
382
|
- 2026-10-18 — Write intent that writes another record via internal request (caller ACL, rollback, `addNote` after the call) (TRUE-82876). (jcardinal)
|
|
383
|
+
- 2026-10-22 — `internalApiRequest` keeps nested lists as arrays (was all objects); no after-commit hook in post interceptors → sibling of `dispatchNotifications()` (TRUE-82919). (jcardinal)
|
|
381
384
|
- 2026-10-17 — "Action table" pattern for audited actions; write intents before a stage gate; new interceptor rows ship in a file run after the code deploy (TRUE-82889). (jcardinal)
|
|
382
385
|
- 2026-10-19 — Lookup de-duplicates rows per record / PRE|POST / method (highest `minDepth` wins); client overrides must call `parent::` (TRUE-82915). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-22
|
|
10
10
|
owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
|
|
@@ -69,7 +69,7 @@ Same back-reference machinery as the UPDATE-path injection above; treat them as
|
|
|
69
69
|
3. `PUT /v2/contacts/{uuid}` with `primaryContactEmailAddress:{uuid}` / `primaryContactPhoneNumber:{uuid}`.
|
|
70
70
|
Result: duplicate email got its own row (2939 on new contact 2984); the other contact's row 2431 stayed on 2476; both pointers set. The UPDATE-path null-echo defect above did not reproduce here — still check the echo.
|
|
71
71
|
|
|
72
|
-
**POST passes the request options (incl. `fields=`) to every nested inherent-child create** (`processRoutePairs` ~6352-6369 passes `httpOptions` through). A root-only `fields=uuid,firstName,lastName` fails on the children with **EV-8** "field does not exist" (`identifiers.field` = the root field) and the whole request rolls back. On a POST with nested children send no `fields`; use `depth=2` (`getFullModelData` returns inherent children at depth >= 2, ~9714-9760).
|
|
72
|
+
**POST passes the request options (incl. `fields=`) to every nested inherent-child create** (`processRoutePairs` ~6352-6369 passes `httpOptions` through). A root-only `fields=uuid,firstName,lastName` fails on the children with **EV-8** "field does not exist" (`identifiers.field` = the root field) and the whole request rolls back. On a POST with nested children send no `fields`; use `depth=2` (`getFullModelData` returns inherent children at depth >= 2, ~9714-9760). The POST response ignores `fields` anyway: it returns the full record at the default depth; only `depth=-1` returns just the `uuid`.
|
|
73
73
|
|
|
74
74
|
**Matching is "link-AND-UPDATE", not "link" — a matched row is mutated from the payload.** A nested related object that **matches** an existing record does not merely link to it — the engine also **writes the payload's fields onto that existing row**. So partner-supplied data can **mutate shared catalog rows**. **Measured (beta `Client_Aig`, 2026-08-17):** the nested `unit.item` matched `Client_Aig.Items` id **1** by `partNumber`, and the payload's `modelNumber` was then written onto it — `Items` id 1 `modelNumber` went from `NULL` to a test value, `dtUpdated 2026-08-17 10:41:17`.
|
|
75
75
|
> **"Link vs. create" is really "link-and-update vs. create". Matching is not read-only.**
|
|
@@ -213,6 +213,7 @@ behaviour — it bites any write to a nested child, not one feature. Worked exam
|
|
|
213
213
|
- **`fields=` on a POST with nested children fails EV-8 and rolls back the whole request** — the options reach each child create. Use `depth=2` instead.
|
|
214
214
|
|
|
215
215
|
## Change history
|
|
216
|
+
- 2026-10-22 — POST response ignores `fields` (full record at default depth; `depth=-1` = uuid only) (TRUE-82919). (jcardinal)
|
|
216
217
|
- 2026-10-16 — Safe contact+email/phone create pattern (child arrays, depth=2, then PUT primary `{uuid}`); why primary FKs match by value (MATCH_UPSERT on identifier only); POST options incl. `fields=` reach nested children → EV-8 + rollback (TRUE-82887). (jcardinal)
|
|
217
218
|
- 2026-10-16 — Corrected: a sent identifier field (contacts.customerId 1651) joins the match, so a mismatched contact is duplicated, not overwritten; nested-write ACL = top-level create + per-field write, EV-12 on a gap (TRUE-82841). (jcardinal)
|
|
218
219
|
- 2026-10-16 — Listed the parent FKs whose nested contact can CREATE a Contact (each must send `customer`); MATCH_UPSERT overwrites a matched contact's customer (TRUE-82841). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-21
|
|
10
10
|
owners: [jcardinal, ajean, rgirish, bala]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Controller/Index.php
|
|
@@ -48,6 +48,10 @@ Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** app that pro
|
|
|
48
48
|
| API Gateway `WorkerWebhookIngestion` | `webhook.togahub.com` → Lambda. Lambda Function URLs are blocked by an AWS Org SCP, so API Gateway is the only public entry point. |
|
|
49
49
|
| Outbound (egress) IP | All prod outbound traffic leaves from **`34.232.23.158`** (same IP is the `sourceIp` on api2 `Logs_<Tenant>.Api` rows for worker2-made calls). Prod instances have **no public IP**. They run in the shared subnet `subnet-0e135d4093ab60b2f` (`true-production-private-web-1c`, VPC `vpc-08030daf1c2dad280`), owned by the network AWS account `339712893798`. Its `0.0.0.0/0` route goes to NAT `nat-0e2c874a7a3740e7a`, which is **not visible** from the `toga-production` SSO profile (`describe-nat-gateways` returns NotFound); changing it needs network-account access (Jeff). Any third party that allowlists worker2 needs this IP. |
|
|
50
50
|
|
|
51
|
+
## Calling api2 from worker2
|
|
52
|
+
|
|
53
|
+
Worker jobs call api2 as an **API record** (`Apis` + API-key auth), never as a fake `Users` row. Rule: [framework-rules](../../standards/framework-rules.md) → *Machine callers use an API record, never a fake user*.
|
|
54
|
+
|
|
51
55
|
## No SQL migration files in this repo
|
|
52
56
|
|
|
53
57
|
**This repo contains PHP and Python only. Never create `.sql` schema-migration files here.** All table creation, `ALTER` statements, index changes, and seed data — including for `Core.WorkerJobs` and `Core.CronJobs` — go in the `dbchanges2` repo using its `YYYY-MM-DD<letter> - <Description>.sql` naming convention.
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-22
|
|
10
10
|
owners: ["kyalamarthi", "ajean", "mhammontree", "jcardinal", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/.gitignore
|
|
@@ -49,6 +49,7 @@ the publish on a credential literal.
|
|
|
49
49
|
| AWS access key (`[talos]` group) | `worker2/Config/*.ini` `[talos]` | Open — not part of TRUE-82705; rotate + replace with the instance role. |
|
|
50
50
|
| Compass PEOPLE-file SFTP password (`cgsftp`) | `worker2/Worker/Client/Compass/PeopleFile.php` constant `SFTP_PASSWORD` | Open — move to Secrets Manager, rotate. |
|
|
51
51
|
| Other accounts on the **same** SFTP server (1.0 copies) | NYCDOE `FTP_PASSWORD` in `library/app/asnprocessor/{apple,lenovo,lexmark,acer}.php`; Staples in `worker/crons/sync/staples/{sync_staples_cxml.php,staples_asn_netsuite.php}` (worker2's copy is the `[sftp_staples]` ini group) | Open — move to Secrets Manager, rotate. The 2.0 NYCDOE import already reads `sftp/nycdoe/<mfr>`; rotate each account in all its places at once. Server: [Shared SFTP server](./shared-sftp-server-source-ip-ban.md). |
|
|
52
|
+
| Elite api2 API secret (`Client_Elite.Apis` row) | `worker2/Worker/Elite.php:7` (hardcoded) | Open (found 2026-10-22, TRUE-82919) — rotate the `Apis` secret and load the row by name at run time, as `Worker/Workflow/Run.php` does. Ticket not yet decided. |
|
|
52
53
|
| PayPal webhook signature verification **off** in production | `worker2/Config/production.ini:182` | Impact already written up — a caller who knows the URL can create a paid SalesOrder. See [Rate PayPal subscription webhook](../../../../clients/rate/features/paypal-subscription-purchase-webhook.md). |
|
|
53
54
|
|
|
54
55
|
### Secrets stored at runtime in `Core.WorkerJobs` (not committed, same exposure)
|
|
@@ -97,6 +98,7 @@ the publish on a credential literal.
|
|
|
97
98
|
- [Rate PayPal Subscription Purchase & Webhook Pipeline](../../../../clients/rate/features/paypal-subscription-purchase-webhook.md)
|
|
98
99
|
|
|
99
100
|
## Change history
|
|
101
|
+
- 2026-10-22 — Added the hardcoded Elite API secret in `Worker/Elite.php` (TRUE-82919). (jcardinal)
|
|
100
102
|
- 2026-10-19 — Added the shared SFTP server passwords: Compass `PeopleFile.php` (worker2) and the 1.0 NYCDOE/Staples copies (TRUE-82898). (bala)
|
|
101
103
|
- 2026-10-12 — Moved the `[cloud]` AWS key to Fixed (deleted, TRUE-82705); listed the `[talos]` AWS key as still open (jcardinal)
|
|
102
104
|
- 2026-10-06 — WorkerJobs runtime exposures: trace arguments (`zend.exception_ignore_args` devops option), NYCDOE FTP passwords in old job params (mhammontree)
|
|
@@ -369,6 +369,7 @@ Debug-path guards (`_underscore/Worker.php`, 2026-10-20):
|
|
|
369
369
|
- **Non-2xx throws** (the worker answers 200 on every path; 5xx = the job fataled). Timeout 300 s per hop; nested waits stack, so the top can time out while lower levels still run — check `WorkerJobs`. Two related debug-mode side effects to expect: `_Component_Api_Toga` calls
|
|
370
370
|
`$api->debug(true)`, and `ApiRequest.php` **echoes an HTML dump** — which produces
|
|
371
371
|
*"http_response_code(): Cannot set response code - headers already sent"* inside the worker.
|
|
372
|
+
- **False 500:** a job whose child job prints output before `worker2/Controller/Index.php` (~L130) sets `http_response_code(200)` is reported as HTTP 500 even when it worked — and the non-2xx makes the parent `runTask` throw. Check `WorkerJobs` / the data before chasing the "failure". The dump no longer prints auth secrets (see [API-key auth](../../api2/features/api-key-authentication-and-scoping.md#gotchas)).
|
|
372
373
|
|
|
373
374
|
### An ABSENT `WorkerJobs` row is ambiguous — don't read it as "never queued"
|
|
374
375
|
|
|
@@ -552,6 +553,7 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
|
|
|
552
553
|
commit-before-SQS transaction pattern that the worker relies on.
|
|
553
554
|
|
|
554
555
|
## Change history
|
|
556
|
+
- 2026-10-22 — Debug mode: child-job output before `http_response_code(200)` reads as a false 500 (TRUE-82919). (jcardinal)
|
|
555
557
|
- 2026-10-22 — Gotcha: vendor named args must match vendor param names; PHP 8.5 vendor tree. (snaredla)
|
|
556
558
|
- 2026-10-20 — Debug-mode `runTask` capped at depth 5 (`X-Toga-Worker-Depth`), throws on non-2xx, 300 s timeout; local ini needs `[api] worker` (TRUE-82934). (jcardinal)
|
|
557
559
|
- 2026-10-08 — Direct POST needs the worker key in `Authorization`, runs synchronously, writes no `WorkerJobs` row. (rgirish)
|
|
@@ -5,7 +5,7 @@ project: _Underscore
|
|
|
5
5
|
client: shared
|
|
6
6
|
type: standard
|
|
7
7
|
status: active
|
|
8
|
-
updated: 2026-10-
|
|
8
|
+
updated: 2026-10-21
|
|
9
9
|
owners: [jcardinal, ajean, apeterson, mhammontree]
|
|
10
10
|
files: []
|
|
11
11
|
related:
|
|
@@ -78,6 +78,21 @@ match a parameter name on the target method.
|
|
|
78
78
|
Direct calls bypass retry logic, error handling, SQS visibility timeout, WorkerJobs tracking and
|
|
79
79
|
monitoring. Even in development, dispatch through the queue — do not invoke the method directly.
|
|
80
80
|
|
|
81
|
+
## Machine callers use an API record, never a fake user
|
|
82
|
+
|
|
83
|
+
Machine / system callers (worker2 jobs, crons, engines, integrations) authenticate to api2 as an
|
|
84
|
+
**API record** (`Apis` table + API-key auth), never as a `Users` row. Users are only for real people.
|
|
85
|
+
|
|
86
|
+
- Never create a "system" / "service" / fake `Users` record (no-password user, `@system.invalid`
|
|
87
|
+
email, etc.) to run background work.
|
|
88
|
+
- Grants for machine work go to the API record (`Apis_Roles`), not to a user or to a role a fake
|
|
89
|
+
user holds.
|
|
90
|
+
- Incident: TRUE-82919's first draft added a `workflow@system.invalid` `Users` row per client for
|
|
91
|
+
the workflow run engine. It was replaced with an API record.
|
|
92
|
+
|
|
93
|
+
How API-key auth and scoping work:
|
|
94
|
+
[api-key-authentication-and-scoping](../apps/api2/features/api-key-authentication-and-scoping.md).
|
|
95
|
+
|
|
81
96
|
## API response envelope (api2 only)
|
|
82
97
|
|
|
83
98
|
Every action method in `api2` controllers must return an array with exactly these keys:
|
|
@@ -20,11 +20,12 @@ apps:
|
|
|
20
20
|
- toga25-desk
|
|
21
21
|
- toga25-blox
|
|
22
22
|
- toga25-iq
|
|
23
|
+
- apiproxy
|
|
23
24
|
project: _Underscore
|
|
24
25
|
client: true
|
|
25
26
|
type: profile
|
|
26
27
|
status: active
|
|
27
|
-
updated: 2026-10-
|
|
28
|
+
updated: 2026-10-21
|
|
28
29
|
owners: [jcardinal, kyalamarthi, tcox, rgirish, mhammontree, akhokhani]
|
|
29
30
|
files: []
|
|
30
31
|
related:
|
package/package.json
CHANGED