toga-ai 1.0.854 → 1.0.855
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/package.json +1 -1
- package/skills/order-for/SKILL.md +209 -0
package/package.json
CHANGED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: order-for
|
|
3
|
+
description: Everything about the Compass "Order For" feature, where user A places an order on behalf of user B. Covers the Users_DelegatorUsers table, the permission gate in _Model_Client_User and V2.php, the originalUserId audit column, the delegation-list GET call in toga2-commerce, the delegated token switch through /auth/delegator, and the ACL rows for Compass and Compass Canada. Use this skill whenever the user asks about "order for", "order on behalf of", delegation, delegator users, Users_DelegatorUsers, originalUserId, c_orderSubmittedById, the Order For dropdown, or why a delegation is being refused. Load it before changing any of those files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Compass "Order For"
|
|
7
|
+
|
|
8
|
+
User A places an order for user B. B must be on A's list. The list is
|
|
9
|
+
`Client_<Tenant>.Users_DelegatorUsers`. The server is the gate, never the UI.
|
|
10
|
+
|
|
11
|
+
Built and verified on local 2026-09-21 against Client_Compass. Ticket TRUE-81848.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The data
|
|
16
|
+
|
|
17
|
+
`Users_DelegatorUsers` lives in **every** client database
|
|
18
|
+
(`dbchanges2/Client/2026-09-18a - UsersDelegatorUsersTable.sql`):
|
|
19
|
+
|
|
20
|
+
| column | meaning |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `userId` | the person doing the ordering |
|
|
23
|
+
| `delegatorUserId` | the person being ordered for |
|
|
24
|
+
|
|
25
|
+
Read it as "userId may order for delegatorUserId". Unique key on the pair, so a
|
|
26
|
+
row cannot be added twice. Model: `_Model_Client_Users_DelegatorUser`.
|
|
27
|
+
|
|
28
|
+
**The table ships empty.** There is no admin screen and no import. Until rows are
|
|
29
|
+
inserted, every dropdown is empty and every delegation is refused. That is correct
|
|
30
|
+
behaviour, not a bug.
|
|
31
|
+
|
|
32
|
+
Core metadata (`dbchanges2/Core/2026-09-18a`): record **362**, route
|
|
33
|
+
`user-delegator-users`, `aclDatabase = CLIENT`, RecordFields **2604** id, **2605**
|
|
34
|
+
uuid, **2606** userId, **2607** delegatorUserId. Registered as an inherent child of
|
|
35
|
+
record 2 (Users), which is what makes `userDelegatorUsers` readable nested under a
|
|
36
|
+
user.
|
|
37
|
+
|
|
38
|
+
## The gate
|
|
39
|
+
|
|
40
|
+
Two checks, both server side, both against the same table.
|
|
41
|
+
|
|
42
|
+
**1. Minting the encrypted pair.** `_Model_Client_User::encryptedUser()` calls
|
|
43
|
+
`canUserOrderForOtherUser()`. Self is always allowed so switching back works. Anyone
|
|
44
|
+
not on the list gets a 403 and no pair.
|
|
45
|
+
|
|
46
|
+
**2. Issuing the token.** `api2/Component/Api/V2/V2.php`, `case 'delegator'` sets
|
|
47
|
+
`$isDelegatorAuthRequest = true` as its first statement, then falls through into
|
|
48
|
+
`case 'encrypted'` where the pair is checked again. This second check matters because
|
|
49
|
+
the encrypted pair has **no expiry and no MAC** (`_String::encryptWithKey`, AES-256-CBC
|
|
50
|
+
keyed on `Core.Parameters.API_SECRET_REFRESH_TOKEN`), so a captured pair could
|
|
51
|
+
otherwise be replayed forever.
|
|
52
|
+
|
|
53
|
+
**3. Refresh.** `/auth/refresh` re-checks an active delegation, so deleting a row or
|
|
54
|
+
deactivating either account ends the session instead of only blocking new switches.
|
|
55
|
+
|
|
56
|
+
The flag defaults to **false**, not true. A forged or expired bearer leaves
|
|
57
|
+
`$this->delegator` null, and a fail-open default would have minted a token.
|
|
58
|
+
|
|
59
|
+
## The audit trail
|
|
60
|
+
|
|
61
|
+
`Logs_<Client>.Api.originalUserId` (`dbchanges2/Logs_Client/2026-09-18a`), nullable,
|
|
62
|
+
sits after `userId`. `V2.php` fills it at all three places it writes a client log row,
|
|
63
|
+
plus the CloudWatch entry.
|
|
64
|
+
|
|
65
|
+
- `userId` = who the token is for (the person being ordered for)
|
|
66
|
+
- `originalUserId` = who actually signed in
|
|
67
|
+
|
|
68
|
+
NULL means no delegation. Verified on order **SA136321**: `createdByUserId` Dawn
|
|
69
|
+
Anderson, log row `userId 124256` / `originalUserId 127046` (Dev Team).
|
|
70
|
+
|
|
71
|
+
Outbound logs (`_ApiRequest`, `_SoapRequest`, `direction = OUT`) never set either
|
|
72
|
+
column. There is no signed-in user on a call TOGa makes out to NetSuite or UPS.
|
|
73
|
+
|
|
74
|
+
## Front end
|
|
75
|
+
|
|
76
|
+
### Getting the list
|
|
77
|
+
|
|
78
|
+
`toga2-commerce/src/pages/Cart/api/CartApi.ts`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const people = await fetchDelegationList(user.uuid);
|
|
82
|
+
// [{ uuid, name, email }, ...]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pass the **signed-in user's own uuid**. One call, no paging, the whole list arrives at
|
|
86
|
+
once. Type `DelegationOption` is in `src/pages/Cart/types.ts`.
|
|
87
|
+
|
|
88
|
+
Two rules that are easy to get wrong:
|
|
89
|
+
|
|
90
|
+
1. **Call it before switching the token.** Once the token belongs to the other person,
|
|
91
|
+
the signed-in user's own record reads as a 403. Cache the list you already loaded.
|
|
92
|
+
2. **Never request `id`.** Asking for `id` makes the whole GET fail with a 403 for
|
|
93
|
+
these roles. Use `uuid`.
|
|
94
|
+
|
|
95
|
+
### Switching the token
|
|
96
|
+
|
|
97
|
+
Order For is only real if the token changes. The steps, same as View As in
|
|
98
|
+
`src/components/NavIcons/NavIconList.tsx`:
|
|
99
|
+
|
|
100
|
+
1. `GET /users/encryptedUser?uuid=<target>` returns `{client, user}` ciphertext.
|
|
101
|
+
2. `POST /auth/delegator` with that pair returns the delegated token pair.
|
|
102
|
+
3. `beginDelegation(tokens, originalUserUuid)` from `src/api/sessionTokens.ts` parks
|
|
103
|
+
the signed-in person's own pair and makes the delegated pair active.
|
|
104
|
+
4. `restoreOriginalTokens(originalUserUuid)` puts them back, no new login needed.
|
|
105
|
+
|
|
106
|
+
In the JWT, `id.client.user` is the target and `id.delegator` is the real human.
|
|
107
|
+
|
|
108
|
+
**If you skip the switch and only keep the pick as a form value, none of the backend
|
|
109
|
+
work runs.** `$api->delegator` is never set, `originalUserId` stays NULL, the
|
|
110
|
+
`Users_DelegatorUsers` check never fires, and the user browses their own catalog and
|
|
111
|
+
prices while "ordering for" someone else.
|
|
112
|
+
|
|
113
|
+
There is one parked-token slot shared with View As, so the two features cannot be
|
|
114
|
+
nested. Guard whichever one you are building against the other.
|
|
115
|
+
|
|
116
|
+
## ACL
|
|
117
|
+
|
|
118
|
+
Read only on purpose. A role that can write its own row here can self-grant the right
|
|
119
|
+
to order for anyone in the tenant, which defeats the whole gate.
|
|
120
|
+
|
|
121
|
+
| | Compass Base | Manager |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| US `Client_Compass` | role 7 | role 8 |
|
|
124
|
+
| Canada `Client_CompassCanada` | role **8** | role **9** |
|
|
125
|
+
|
|
126
|
+
**Canada role ids are different from US.** Do not copy the US file and rename it.
|
|
127
|
+
|
|
128
|
+
`appId` 5 is TOGa Commerce. The row expression is scoped to the caller, not `1`:
|
|
129
|
+
|
|
130
|
+
```sql
|
|
131
|
+
CASE WHEN Users_DelegatorUsers.userId = AclUser.id THEN 1 ELSE 0 END
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`user-delegator-users` is a real route, so a plain `1` would let any user call
|
|
135
|
+
`GET /v2/user-delegator-users` and list who every colleague can order for. `AclUser`
|
|
136
|
+
is joined by `_Model_True::isPermitted` on the logged-in user.
|
|
137
|
+
|
|
138
|
+
Canada also needed `Core.RecordScripts` id **16** (`encryptedUser`) granted to its
|
|
139
|
+
roles 8 and 9. Canada shipped with only role 1 (Base), so a normal Canada user got a
|
|
140
|
+
403 on every pick. US Compass already had it.
|
|
141
|
+
|
|
142
|
+
## Traps that cost real time
|
|
143
|
+
|
|
144
|
+
- **`empty()` and `isset()` always lie on a `_Model` field.** `_Model::initialize()`
|
|
145
|
+
unsets every declared property and `_Model` has no `__isset`, so `empty($api->user->id)`
|
|
146
|
+
is always true. Use `(int)$x->id > 0`. This silently made the gate refuse everyone,
|
|
147
|
+
including a user acting as themself, and both reviewers and `php -l` passed it.
|
|
148
|
+
- **An ACL permission with no logic group denies everything.** `buildSqlExpression()`
|
|
149
|
+
returns `(0)`. `allowRead = 1` on its own grants nothing. You need the
|
|
150
|
+
`AclRecordExpressions` row, an `AclLogicGroups` row, and the link row.
|
|
151
|
+
- **Core rows are ignored for a CLIENT record.** Record 362 is `aclDatabase = CLIENT`,
|
|
152
|
+
and every lookup in `V2.php` (3236, 4057, 6502, 7307) switches on that and reads only
|
|
153
|
+
the matching bucket. Anything you put in `Core.AclRecordPermissions` for 362 is dead.
|
|
154
|
+
- **Core cannot name an api2 class.** Use `$api->response::DEFINED_MESSAGE_ERROR_UNAUTHORIZED`
|
|
155
|
+
from inside `_underscore`, not `\api\_Component_Api_V2_Response`.
|
|
156
|
+
- **Never join Core and `Client_<Tenant>` in one query.** Separate prod clusters.
|
|
157
|
+
|
|
158
|
+
## Still open
|
|
159
|
+
|
|
160
|
+
- **Nothing seeds `Users_DelegatorUsers`.** No admin screen, no import.
|
|
161
|
+
- **`c_orderSubmittedById` is not stamped.** The column, `CustomRecordFields` id 40 on
|
|
162
|
+
record 14, and `_Model_Compass_SalesOrder:51` all already exist from Compass 1.0. It
|
|
163
|
+
is a plain `FIELD_INTEGER`, not a foreign key, so api2 will not resolve a `{uuid}` and
|
|
164
|
+
the client cannot set it. It belongs in `_Model_Compass_SalesOrder::prePost()` (line
|
|
165
|
+
445, interceptor id 24, already active as PRE on POST for record 14), read from
|
|
166
|
+
`$api->delegator->user`. Leave NULL when there is no delegation, matching 1.0's
|
|
167
|
+
`convertcarttoorder.php`. Note the old data is dirty: of 112,096 Compass orders 8,118
|
|
168
|
+
have the field set and 7,174 of those equal `createdByUserId`, because the 1.0 guard
|
|
169
|
+
compared a manager id against a contact id.
|
|
170
|
+
- **Role 3 (API) has no grant on record 362 in any `Client_*` database**, only the dead
|
|
171
|
+
Core one, so no integration can read or write the table through api2.
|
|
172
|
+
- **`/auth/encrypted` still accepts a delegation pair with no bearer** and records no
|
|
173
|
+
delegator. Narrowed because Commerce no longer calls it; toga2-desk still does for
|
|
174
|
+
client switching.
|
|
175
|
+
|
|
176
|
+
## Files
|
|
177
|
+
|
|
178
|
+
| repo | file |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `_underscore` | `Model/Client/Users/DelegatorUser.php` |
|
|
181
|
+
| `_underscore` | `Model/Client/User.php` (`canUserOrderForOtherUser`, `getRequestingUser`, `encryptedUser`) |
|
|
182
|
+
| `_underscore` | `Model/Client/Logs/Api.php` (`originalUserId`) |
|
|
183
|
+
| `api2` | `Component/Api/V2/V2.php` |
|
|
184
|
+
| `dbchanges2` | `Client/2026-09-18a - UsersDelegatorUsersTable.sql` |
|
|
185
|
+
| `dbchanges2` | `Core/2026-09-18a - UserDelegatorUserRecords.sql` |
|
|
186
|
+
| `dbchanges2` | `Client_Compass/2026-09-18a - UserDelegatorUsersReadAcl.sql` |
|
|
187
|
+
| `dbchanges2` | `Client_CompassCanada/2026-09-18a - UserDelegatorUsersReadAcl.sql` |
|
|
188
|
+
| `dbchanges2` | `Logs_Client/2026-09-18a - ApiOriginalUserId.sql` |
|
|
189
|
+
| `toga2-commerce` | `src/pages/Cart/api/CartApi.ts` (`fetchDelegationList`) |
|
|
190
|
+
| `toga2-commerce` | `src/pages/Cart/types.ts` (`DelegationOption`) |
|
|
191
|
+
| `toga2-commerce` | `src/components/OrderForDropdown/` (the UI) |
|
|
192
|
+
| `toga2-commerce` | `src/api/sessionTokens.ts` (token parking) |
|
|
193
|
+
|
|
194
|
+
## Checking a refusal
|
|
195
|
+
|
|
196
|
+
```sql
|
|
197
|
+
SELECT d.id, u.id AS orderingUserId, CONCAT(u.firstName,' ',u.lastName) AS orderingUser,
|
|
198
|
+
du.id AS forId, CONCAT(du.firstName,' ',du.lastName) AS orderedFor
|
|
199
|
+
FROM Users_DelegatorUsers d
|
|
200
|
+
JOIN Users u ON u.id = d.userId
|
|
201
|
+
JOIN Users du ON du.id = d.delegatorUserId;
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Then confirm the log stamped both people:
|
|
205
|
+
|
|
206
|
+
```sql
|
|
207
|
+
SELECT id, dtStamp, method, route, responseCode, userId, originalUserId
|
|
208
|
+
FROM Logs_Compass.Api ORDER BY id DESC LIMIT 20;
|
|
209
|
+
```
|