toga-ai 1.0.121 → 1.0.123
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/1.0/apps/worker/INDEX.md +2 -1
- package/knowledge/1.0/apps/worker/features/compass-ma-sales-order-exception-report.md +82 -0
- package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +3 -8
- package/knowledge/2.0/apps/_underscore/features/email-template-sending.md +1 -1
- package/knowledge/2.0/apps/api2/features/scripted-api-post-body-args.md +3 -7
- package/knowledge/INDEX.md +2 -1
- package/knowledge/registry.json +154 -19
- package/knowledge/standalone/apps/forward/INDEX.md +6 -0
- package/knowledge/standalone/apps/forward/architecture.md +121 -0
- package/knowledge/standalone/apps/forward/features/encrypted-link-handler.md +70 -0
- package/package.json +1 -1
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
|
|
6
|
-
| [Compass
|
|
6
|
+
| [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
|
|
7
|
+
| [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
|
|
7
8
|
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
8
9
|
| [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
|
|
9
10
|
| [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Compass MA Sales Order Exception Report
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: worker
|
|
5
|
+
project: Worker
|
|
6
|
+
client: compass-usa
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php
|
|
13
|
+
related: []
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Summary
|
|
17
|
+
A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%`
|
|
18
|
+
sales orders whose corresponding Office Depot (ODP) sales order has **not** been created in
|
|
19
|
+
NetSuite yet. It is a catch-the-stragglers report: orders that should have flowed to NetSuite
|
|
20
|
+
but haven't. Runs against the Compass client DB (`db_prod_compass` / `Client_Compass`).
|
|
21
|
+
|
|
22
|
+
## Key files / entry points
|
|
23
|
+
- `worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php` — the
|
|
24
|
+
whole job: one main SELECT, a per-row NetSuite recheck, and a PhpSpreadsheet Excel email.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
1. **Main query** walks a two-hop bridge chain to connect the customer order to the vendor order:
|
|
28
|
+
Compass SO (`number LIKE 'MA%'`) → `SalesOrders_PurchaseOrders` → Compass PO →
|
|
29
|
+
`PurchaseOrders_SalesOrders` → **ODP SO** (`customerId = OFFICE_DEPOT`) →
|
|
30
|
+
`SalesOrders_PurchaseOrders` → ODP PO. Note the two bridge tables are used in **opposite
|
|
31
|
+
directions** at each hop — `SalesOrders_PurchaseOrders` for SO→PO, `PurchaseOrders_SalesOrders`
|
|
32
|
+
for PO→SO. It also inner-joins the ODP SO's address, state, items, item→PO-item bridge, PO
|
|
33
|
+
items, and vendor items — so a row with any of those missing silently drops out.
|
|
34
|
+
2. **Filter:** `MA%` Compass SO, ODP SO `customerId = App_Client_Compass::CUSTOMER_ID__OFFICE_DEPOT`
|
|
35
|
+
(= 1), and `OfficeDepotSalesOrders.c_netsuiteInternalSalesOrderId IS NULL`.
|
|
36
|
+
3. **Per-row NetSuite recheck:** for each surviving row it looks for an **Agilant**-customer
|
|
37
|
+
(`CUSTOMER_ID__AGILANT` = 3) sales order tied to that ODP PO number. If found → it back-fills
|
|
38
|
+
`c_netsuiteInternalSalesOrderId` on the ODP SO and skips the row (drops it off the report
|
|
39
|
+
permanently). If not found → it writes the row to the Excel.
|
|
40
|
+
4. Emails the workbook to operations only if at least one data row was written.
|
|
41
|
+
|
|
42
|
+
## Data model
|
|
43
|
+
- `Client_Compass.SalesOrders` — the ODP sales order is the one keyed on (`id AS salesOrderId`,
|
|
44
|
+
`customerId = 1` Office Depot). Relevant columns: `c_netsuiteInternalSalesOrderId` (NULL until
|
|
45
|
+
the order exists in NetSuite) and `salesOrderStageId`.
|
|
46
|
+
- `Client_Compass.SalesOrderStages` — `5 = Pending Billing`, `6 = Pending Billing/Partially
|
|
47
|
+
Fulfilled`, `7 = Billed`, `8 = Canceled`, `9 = Closed`.
|
|
48
|
+
- Bridges: `SalesOrders_PurchaseOrders` (salesOrderId, purchaseOrderId) and
|
|
49
|
+
`PurchaseOrders_SalesOrders` (purchaseOrderId, salesOrderId) — distinct tables, distinct
|
|
50
|
+
directions.
|
|
51
|
+
|
|
52
|
+
## Client variations
|
|
53
|
+
Compass USA only — this is a Compass-specific integration cron.
|
|
54
|
+
|
|
55
|
+
## Gotchas / known issues
|
|
56
|
+
- **Two removal levers, and only one used to exist.** Historically the *only* way an order left
|
|
57
|
+
the report was `c_netsuiteInternalSalesOrderId` becoming non-NULL (set by the per-row recheck
|
|
58
|
+
when the NetSuite order is found). An order **closed/canceled on Office Depot's end** never gets
|
|
59
|
+
a NetSuite order, so it never gets that id, so it stuck on the report forever. The fix (2026-06)
|
|
60
|
+
added a **stage-based exclusion** so canceling/closing the ODP SO removes it.
|
|
61
|
+
- **To remove an order, mark the OFFICE DEPOT sales order — not the Compass SO.** The query
|
|
62
|
+
filters on `OfficeDepotSalesOrders.salesOrderStageId`; the Compass SO's stage is never read.
|
|
63
|
+
Setting the Compass `MA%` order's stage does nothing.
|
|
64
|
+
- **NULL-safe stage filter is mandatory.** Active orders normally have `salesOrderStageId = NULL`.
|
|
65
|
+
In SQL `NULL NOT IN (8, 9)` evaluates to *unknown* (not true), so a bare
|
|
66
|
+
`salesOrderStageId NOT IN (8, 9)` would drop **every NULL-stage order** — i.e. almost the whole
|
|
67
|
+
report. The condition must be `(salesOrderStageId IS NULL OR salesOrderStageId NOT IN (8, 9))`,
|
|
68
|
+
and it must be parenthesized because `AND` binds tighter than `OR`.
|
|
69
|
+
- **Don't fake the NetSuite id to hide an order.** Writing a bogus `c_netsuiteInternalSalesOrderId`
|
|
70
|
+
removes it from the report but corrupts the field for anyone who reads it later. Use the stage
|
|
71
|
+
lever instead.
|
|
72
|
+
- `$inProduction = true` switches the DB link to `db_prod_compass`; set false to test against
|
|
73
|
+
`db_beta_compass`.
|
|
74
|
+
|
|
75
|
+
## Change history
|
|
76
|
+
- 2026-06-18 — Added stage-based exclusion so orders closed/canceled on ODP's end drop off the
|
|
77
|
+
report: `(salesOrderStageId IS NULL OR salesOrderStageId NOT IN (8, 9))` in the main query.
|
|
78
|
+
Removing specific stuck orders is then a data change (set the ODP SO's `salesOrderStageId` to
|
|
79
|
+
8/9), not a code change. (bala)
|
|
80
|
+
|
|
81
|
+
## Related docs
|
|
82
|
+
- [Compass USA profile](../../../clients/compass-usa/profile.md)
|
|
@@ -10,9 +10,7 @@ updated: 2026-06-18
|
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/crons/toga2/compass/update_salesorder_status_from_odp.php
|
|
13
|
-
- worker/crons/toga2/compass/workflow/test_partial_in_transit_email.php
|
|
14
13
|
- worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
|
|
15
|
-
- worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
|
|
16
14
|
related: []
|
|
17
15
|
---
|
|
18
16
|
|
|
@@ -30,8 +28,6 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
|
|
|
30
28
|
- `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron.
|
|
31
29
|
- `worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php`
|
|
32
30
|
— Compass Canada prod cron.
|
|
33
|
-
- `*/workflow/test_partial_in_transit_email.php` — read-only prod test scripts for each client
|
|
34
|
-
(email goes only to a test recipient, no DB writes). Use these to preview rendering.
|
|
35
31
|
- Shared helper set in each cron: `getOrderFulfillmentData`, `buildItemRowsHtml`,
|
|
36
32
|
`buildSectionHeaderHtml`, `buildPackageBlockHtml`, `buildTrackingUrl`, `buildOrderItemsHtml`.
|
|
37
33
|
|
|
@@ -106,14 +102,13 @@ the tracking number as emailed (it retries next run).
|
|
|
106
102
|
`getOrderFulfillmentData` — same query as Compass USA. Canada was originally on the ASN chain
|
|
107
103
|
because it had **zero `ItemFulfillmentItems`**; if that line-bridge data is not populated for a
|
|
108
104
|
Canada order, `getOrderFulfillmentData` returns no packages and the cron falls back to the
|
|
109
|
-
full email.
|
|
105
|
+
full email.
|
|
110
106
|
- **Line bridge is moving-forward only** — it began populating ~2026-06-12; pre-cutoff tracking
|
|
111
107
|
numbers are intentionally out of scope (driving-query date cutoff).
|
|
112
108
|
- The driving query still uses the ASN chain (to find shipped orders + tracking) for **both**
|
|
113
109
|
clients — that is correct and unrelated to the package-contents source.
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
`RecordScripts`/`AclRecordScripts` registration (prod done, beta pending) — see related docs.
|
|
110
|
+
- **Not yet deployed.** These crons depend on the api2 POST scripted-API change and its
|
|
111
|
+
`RecordScripts`/`AclRecordScripts` registration — see related docs.
|
|
117
112
|
|
|
118
113
|
## Change history
|
|
119
114
|
|
|
@@ -87,7 +87,7 @@ can keep using `sendEmail($api, ...)`.
|
|
|
87
87
|
(previously the inactive case returned `false` and an SMTP failure was swallowed entirely).
|
|
88
88
|
Any fire-and-forget caller now propagates that exception, which is intended (so an email is
|
|
89
89
|
never silently marked as sent). Blast radius is every client, including the Compass/Quad
|
|
90
|
-
`SalesOrder`/`ApprovalDecision` order-placed emails
|
|
90
|
+
`SalesOrder`/`ApprovalDecision` order-placed emails.
|
|
91
91
|
|
|
92
92
|
## Change history
|
|
93
93
|
|
|
@@ -65,13 +65,9 @@ None — this is engine behavior. Per-client access is controlled by `AclRecordS
|
|
|
65
65
|
- **Normal CRUD is unaffected.** The POST scripted block only fires when a POST Record Script
|
|
66
66
|
is registered for the route; otherwise the request falls through to the usual create path
|
|
67
67
|
(`if (!$isUsingScriptedCall && empty($routePairs))` / `locateRecord`).
|
|
68
|
-
- **Registration is environment
|
|
69
|
-
(client) rows must exist in every environment the API reads
|
|
70
|
-
|
|
71
|
-
**beta still needs them** before the POST path dispatches there.
|
|
72
|
-
- **Deploys with `_underscore`.** api2 pulls `_underscore` at deploy; `http://api2` and
|
|
73
|
-
`api.beta.togahub.com` resolve to deployed boxes (`/var/app/current`), not a local checkout —
|
|
74
|
-
so testing the change requires deploying it, not just editing locally.
|
|
68
|
+
- **Registration is per-environment.** The `RecordScripts` (Core) + `AclRecordScripts`
|
|
69
|
+
(client) rows must exist in every environment the API reads; a missing POST `RecordScripts`
|
|
70
|
+
row means the POST path silently falls through to normal CRUD.
|
|
75
71
|
- **CI commit policy.** Subject must be `TRUE-<ticket>: <Subject>` (≤80 chars, capitalized,
|
|
76
72
|
imperative, no trailing period, more than one word).
|
|
77
73
|
|
package/knowledge/INDEX.md
CHANGED
|
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
5
5
|
## 1.0 framework
|
|
6
6
|
|
|
7
7
|
- **library** (Library) _(framework core)_ — 4 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
|
|
8
|
-
- **worker** (Worker) —
|
|
8
|
+
- **worker** (Worker) — 9 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
|
|
9
9
|
- **togadesk** (TOGa Desk) — 7 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
|
|
10
10
|
- **togaview** (TOGa View) — 5 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
|
|
11
11
|
- **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
|
|
@@ -29,6 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
29
29
|
## standalone framework
|
|
30
30
|
|
|
31
31
|
- **togatech** (TOGA Technology Website) — 1 doc(s) → [standalone/apps/togatech/INDEX.md](standalone/apps/togatech/INDEX.md)
|
|
32
|
+
- **forward** (Forwarder) — 2 doc(s) → [standalone/apps/forward/INDEX.md](standalone/apps/forward/INDEX.md)
|
|
32
33
|
|
|
33
34
|
## Clients
|
|
34
35
|
|
package/knowledge/registry.json
CHANGED
|
@@ -1,21 +1,156 @@
|
|
|
1
1
|
[
|
|
2
|
-
{
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
2
|
+
{
|
|
3
|
+
"repo": "_underscore",
|
|
4
|
+
"project": "_Underscore",
|
|
5
|
+
"framework": "2.0",
|
|
6
|
+
"role": "core",
|
|
7
|
+
"dependsOn": []
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"repo": "worker2",
|
|
11
|
+
"project": "Worker",
|
|
12
|
+
"framework": "2.0",
|
|
13
|
+
"role": "app",
|
|
14
|
+
"dependsOn": []
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"repo": "api2",
|
|
18
|
+
"project": "API",
|
|
19
|
+
"framework": "2.0",
|
|
20
|
+
"role": "app",
|
|
21
|
+
"dependsOn": []
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"repo": "dbchanges2",
|
|
25
|
+
"project": "Database Changes",
|
|
26
|
+
"framework": "2.0",
|
|
27
|
+
"role": "core",
|
|
28
|
+
"dependsOn": []
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"repo": "library",
|
|
32
|
+
"project": "Library",
|
|
33
|
+
"framework": "1.0",
|
|
34
|
+
"role": "core",
|
|
35
|
+
"dependsOn": []
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"repo": "worker",
|
|
39
|
+
"project": "Worker",
|
|
40
|
+
"framework": "1.0",
|
|
41
|
+
"role": "app",
|
|
42
|
+
"dependsOn": []
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"repo": "toga2-supply",
|
|
46
|
+
"project": "TOGa Supply",
|
|
47
|
+
"framework": "2.0",
|
|
48
|
+
"role": "app",
|
|
49
|
+
"dependsOn": [
|
|
50
|
+
"api2"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"repo": "saml",
|
|
55
|
+
"project": "SAML SSO Gateway",
|
|
56
|
+
"framework": "2.0",
|
|
57
|
+
"role": "app",
|
|
58
|
+
"dependsOn": []
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"repo": "toga2-view",
|
|
62
|
+
"project": "TOGa View Frontend",
|
|
63
|
+
"framework": "2.0",
|
|
64
|
+
"role": "app",
|
|
65
|
+
"dependsOn": [
|
|
66
|
+
"api2"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"repo": "togadesk",
|
|
71
|
+
"project": "TOGa Desk",
|
|
72
|
+
"framework": "1.0",
|
|
73
|
+
"role": "app",
|
|
74
|
+
"dependsOn": []
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"repo": "togaview",
|
|
78
|
+
"project": "TOGa View",
|
|
79
|
+
"framework": "1.0",
|
|
80
|
+
"role": "app",
|
|
81
|
+
"dependsOn": []
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"repo": "toga2-hub",
|
|
85
|
+
"project": "TOGa Hub",
|
|
86
|
+
"framework": "2.0",
|
|
87
|
+
"role": "app",
|
|
88
|
+
"dependsOn": [
|
|
89
|
+
"api2"
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"repo": "togatech",
|
|
94
|
+
"project": "TOGA Technology Website",
|
|
95
|
+
"framework": "standalone",
|
|
96
|
+
"role": "app",
|
|
97
|
+
"dependsOn": []
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"repo": "webhook",
|
|
101
|
+
"project": "Webhook",
|
|
102
|
+
"framework": "1.0",
|
|
103
|
+
"role": "app",
|
|
104
|
+
"dependsOn": [
|
|
105
|
+
"library"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"repo": "walmarttechservices",
|
|
110
|
+
"project": "Walmart Tech Services",
|
|
111
|
+
"framework": "1.0",
|
|
112
|
+
"role": "app",
|
|
113
|
+
"dependsOn": [
|
|
114
|
+
"library"
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"repo": "talos",
|
|
119
|
+
"project": "TOGa IQ",
|
|
120
|
+
"framework": "2.0",
|
|
121
|
+
"role": "app",
|
|
122
|
+
"dependsOn": [],
|
|
123
|
+
"language": "python"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"repo": "test",
|
|
127
|
+
"project": "Test",
|
|
128
|
+
"framework": "1.0",
|
|
129
|
+
"role": "app",
|
|
130
|
+
"dependsOn": [
|
|
131
|
+
"library"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"repo": "voice-to-voice",
|
|
136
|
+
"project": "TOGa Voice",
|
|
137
|
+
"framework": "2.0",
|
|
138
|
+
"role": "app",
|
|
139
|
+
"dependsOn": [],
|
|
140
|
+
"language": "python"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"repo": "ai-bdr",
|
|
144
|
+
"project": "AI-BDR",
|
|
145
|
+
"framework": "2.0",
|
|
146
|
+
"role": "app",
|
|
147
|
+
"dependsOn": []
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"repo": "forward",
|
|
151
|
+
"project": "Forwarder",
|
|
152
|
+
"framework": "standalone",
|
|
153
|
+
"role": "app",
|
|
154
|
+
"dependsOn": []
|
|
155
|
+
}
|
|
21
156
|
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# forward (Forwarder) — standalone knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [Forwarder Architecture](architecture.md) | Forwarder is a tiny **standalone PHP application** that powers TOGA's short, branded redirect domains. | forward/forward.ini, forward/index.php, forward/.htaccess, forward/.platform/httpd/conf.d/rewritemap.conf, forward/.ebextensions/rewritemap.config, forward/composer.json |
|
|
6
|
+
| [Encrypted-Link Handler](features/encrypted-link-handler.md) | A `index.php` feature in [Forwarder](../architecture.md) for redirect domains whose URL path carries an **encrypted token** that must be decoded before redirect | forward/index.php |
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Forwarder Architecture
|
|
3
|
+
framework: "standalone"
|
|
4
|
+
repo: forward
|
|
5
|
+
project: Forwarder
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- forward/forward.ini
|
|
13
|
+
- forward/index.php
|
|
14
|
+
- forward/.htaccess
|
|
15
|
+
- forward/.platform/httpd/conf.d/rewritemap.conf
|
|
16
|
+
- forward/.ebextensions/rewritemap.config
|
|
17
|
+
- forward/composer.json
|
|
18
|
+
related: []
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Forwarder Architecture
|
|
22
|
+
|
|
23
|
+
Forwarder is a tiny **standalone PHP application** that powers TOGA's short, branded
|
|
24
|
+
redirect domains. The pattern: point a short **CNAME** (e.g. `powerbi.togatech.com`,
|
|
25
|
+
`feedback.agilantsolutions.com`) at the Forwarder app running on **AWS Elastic Beanstalk**
|
|
26
|
+
(Amazon Linux 2023 / Apache), and Forwarder issues an HTTP redirect to the real
|
|
27
|
+
destination. It does **not** use either PHP framework (1.0 `App_` or 2.0 `_underscore`),
|
|
28
|
+
which is why it lives under the `standalone/` knowledge partition.
|
|
29
|
+
|
|
30
|
+
Local path on dev machines is tracked in Claude memory (`repo-path-forward`), not here.
|
|
31
|
+
|
|
32
|
+
## Two layers: zero-compute map vs. light PHP compute
|
|
33
|
+
|
|
34
|
+
Forwarder has two distinct redirect mechanisms. The vast majority of traffic never
|
|
35
|
+
touches PHP at all.
|
|
36
|
+
|
|
37
|
+
### 1. Zero-compute redirects (the common case) — Apache `RewriteMap` + `forward.ini`
|
|
38
|
+
|
|
39
|
+
The `.htaccess` at the repo root drives a plain-text lookup table:
|
|
40
|
+
|
|
41
|
+
- `forward.ini` is an Apache **`txt` RewriteMap** of `source destination` lines:
|
|
42
|
+
- `source` = full host or `host/path` (no scheme, no trailing slash)
|
|
43
|
+
- `destination` = full target URL (query strings allowed)
|
|
44
|
+
- Blank lines and `#` comments are ignored.
|
|
45
|
+
- The `RewriteMap forwardmap txt:/var/www/html/forward.ini` directive **cannot** live in
|
|
46
|
+
`.htaccess` (must be server/VirtualHost scope), so it is deployed separately (see
|
|
47
|
+
Deployment below).
|
|
48
|
+
- **Match precedence in `.htaccess`:**
|
|
49
|
+
1. Exact `HTTP_HOST + REQUEST_URI` match → `302` (flags `NE,QSA`).
|
|
50
|
+
2. Else `HTTP_HOST`-only match → `302`, appending the original path + query to the
|
|
51
|
+
destination.
|
|
52
|
+
3. Else, if the request is not a real file/dir, fall through to `index.php`.
|
|
53
|
+
- **Operational win:** editing `forward.ini` takes effect **immediately** — Apache
|
|
54
|
+
re-reads the txt map on the next request after a graceful reload (`sudo systemctl
|
|
55
|
+
reload httpd`); no redeploy or restart needed. Most "add a redirect" requests are a
|
|
56
|
+
one-line edit to `forward.ini`.
|
|
57
|
+
- **Authoring rule:** put the most **specific** `host/path` lines **before** the general
|
|
58
|
+
`host`-only line, or the general line matches first and the path line is never reached
|
|
59
|
+
(documented in the `forward.ini` header).
|
|
60
|
+
- **Health check:** `.htaccess` short-circuits to `200 OK` for `ELB-HealthChecker`
|
|
61
|
+
user-agents and localhost / private-IP hosts (`127.*`, `10.*`, `172.*`, `192.168.*`),
|
|
62
|
+
so AWS ELB health checks pass without a map entry.
|
|
63
|
+
|
|
64
|
+
### 2. Light compute (the exception) — `index.php`
|
|
65
|
+
|
|
66
|
+
`index.php` is reached only when no map entry matched. It handles three cases:
|
|
67
|
+
|
|
68
|
+
- **Encrypted-link handler** — for a hardcoded `$encryptedDomains` allowlist (PCMatic /
|
|
69
|
+
Newegg / `forward.*` agent-download domains), it extracts the encrypted path segment
|
|
70
|
+
(by a per-domain `offset`: `0` = first segment, `1` = second), runs `decrypt()`, then
|
|
71
|
+
`302`s to the domain's configured base URL with the decrypted value `urlencode`d
|
|
72
|
+
appended. Invalid/empty/tampered input returns a styled `400`. See the
|
|
73
|
+
[encrypted-link-handler](features/encrypted-link-handler.md) feature doc.
|
|
74
|
+
- **Local directory handler** — if the first host label matches a directory in the repo
|
|
75
|
+
(e.g. `sos.*` → `/sos/`), it redirects to `/<subdomain>/` and serves that dir's
|
|
76
|
+
`index.html`. Used for the bundled `sos/` Splashtop SOS download page.
|
|
77
|
+
- **404** — otherwise returns a styled `404` echoing the unmatched `host + uri`.
|
|
78
|
+
|
|
79
|
+
## Deployment (Elastic Beanstalk / Apache)
|
|
80
|
+
|
|
81
|
+
- Runs on **Elastic Beanstalk**, Amazon Linux 2023, Apache `httpd`.
|
|
82
|
+
- The `RewriteMap` definition is deployed to `/etc/httpd/conf.d/rewritemap.conf` two ways
|
|
83
|
+
for cross-platform safety:
|
|
84
|
+
- `.platform/httpd/conf.d/rewritemap.conf` — the **Amazon Linux 2+/2023** mechanism
|
|
85
|
+
(current).
|
|
86
|
+
- `.ebextensions/rewritemap.config` — legacy **Amazon Linux 1** `files:` fallback.
|
|
87
|
+
Both can coexist; AL2+ ignores the `.ebextensions` file path managed by `.platform`,
|
|
88
|
+
and AL1 ignores `.platform` entirely.
|
|
89
|
+
- `forward.ini` deploys to `/var/www/html/forward.ini` (the path the map points at).
|
|
90
|
+
- `composer.json` declares a `Togatech\Forward\` PSR-4 autoload over `src/` but has **no
|
|
91
|
+
dependencies** and no `src/` is shipped today — the app is effectively a single
|
|
92
|
+
`index.php` plus the Apache config.
|
|
93
|
+
|
|
94
|
+
## The `decrypt()` scheme
|
|
95
|
+
|
|
96
|
+
`decrypt()` in `index.php` is a **home-rolled** symmetric scheme, used only to obfuscate
|
|
97
|
+
agent-download tokens in URLs — **not** real encryption and not for protecting secrets:
|
|
98
|
+
|
|
99
|
+
1. URL-safe base64 normalize (`-_` → `+/`); the **last char** is a stored integrity hash.
|
|
100
|
+
2. base64-decode the remainder; the **first byte** is the key-start seed.
|
|
101
|
+
3. Regenerate a keystream from the seed (`i += i` per step, `chr(i % 255)`) and XOR it
|
|
102
|
+
against the payload to recover the plaintext.
|
|
103
|
+
4. Verify by recomputing the first char of `base64(hmac_sha256(payload, key))` and
|
|
104
|
+
comparing to the stored hash; mismatch → empty string (treated as failure → `400`).
|
|
105
|
+
|
|
106
|
+
Treat this as obfuscation only. Anything genuinely sensitive must not rely on it.
|
|
107
|
+
|
|
108
|
+
## Notes / gotchas
|
|
109
|
+
|
|
110
|
+
- **No restart needed for new redirects** — edit `forward.ini`, reload httpd. This is the
|
|
111
|
+
single most useful operational fact about this app.
|
|
112
|
+
- Specific-path map lines must precede host-only lines (see `forward.ini` header).
|
|
113
|
+
- New **encrypted** domains require a code change (`$encryptedDomains` in `index.php`),
|
|
114
|
+
not just a `forward.ini` edit.
|
|
115
|
+
- `index.php` only runs on a map miss, so a bad/over-broad `forward.ini` host-only line
|
|
116
|
+
can shadow paths you expected PHP to handle.
|
|
117
|
+
|
|
118
|
+
## Change history
|
|
119
|
+
|
|
120
|
+
- 2026-06-18 (jcardinal) — Initial architecture documentation; registered `forward`
|
|
121
|
+
(Forwarder, standalone) in the knowledge base.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Encrypted-Link Handler
|
|
3
|
+
framework: "standalone"
|
|
4
|
+
repo: forward
|
|
5
|
+
project: Forwarder
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- forward/index.php
|
|
13
|
+
related:
|
|
14
|
+
- standalone/apps/forward/architecture.md
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Encrypted-Link Handler
|
|
18
|
+
|
|
19
|
+
A `index.php` feature in [Forwarder](../architecture.md) for redirect domains whose URL
|
|
20
|
+
path carries an **encrypted token** that must be decoded before redirecting — used for
|
|
21
|
+
remote-support / agent-download links (PCMatic, Newegg, generic `forward.*` agent
|
|
22
|
+
downloads) where the trailing value (e.g. a user identifier) is obfuscated in the path.
|
|
23
|
+
|
|
24
|
+
## When it runs
|
|
25
|
+
|
|
26
|
+
Only when the Apache `RewriteMap` (`forward.ini`) produced **no match** and the request
|
|
27
|
+
falls through to `index.php`, and the lowercased `HTTP_HOST` is a key in the hardcoded
|
|
28
|
+
`$encryptedDomains` allowlist near the top of `index.php`.
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
`$encryptedDomains` maps a host to `['url' => <base>, 'offset' => <int>]`:
|
|
33
|
+
|
|
34
|
+
- `url` — the destination base URL; the decrypted value is `urlencode`d and appended.
|
|
35
|
+
- `offset` — which **0-based path segment** holds the encrypted token:
|
|
36
|
+
- `0` → `host.com/ENCRYPTED`
|
|
37
|
+
- `1` → `host.com/prefix/ENCRYPTED`
|
|
38
|
+
|
|
39
|
+
Adding a new encrypted domain is a **code change** (edit `$encryptedDomains`), not a
|
|
40
|
+
`forward.ini` edit.
|
|
41
|
+
|
|
42
|
+
## Flow
|
|
43
|
+
|
|
44
|
+
1. Split the request path on `/`; take the segment at `offset` as the token.
|
|
45
|
+
2. Empty token → styled **`400` Invalid Link**.
|
|
46
|
+
3. `decrypt($token)` (see below); failure (empty return) → styled **`400` Decryption
|
|
47
|
+
Failed**.
|
|
48
|
+
4. Success → `header('Location: ' . $base . urlencode($decrypted), 302)`.
|
|
49
|
+
|
|
50
|
+
## The `decrypt()` algorithm
|
|
51
|
+
|
|
52
|
+
Custom symmetric scheme — **obfuscation, not real encryption**:
|
|
53
|
+
|
|
54
|
+
1. Restore URL-safe base64 (`-_` → `+/`). The **final character** is a stored 1-char
|
|
55
|
+
integrity hash; strip and keep it.
|
|
56
|
+
2. base64-decode the rest. The **first byte** is the key-start seed; the remainder is the
|
|
57
|
+
payload.
|
|
58
|
+
3. Regenerate a keystream from the seed — `for (i = seed, j = 0; j < len; i += i, j++)
|
|
59
|
+
key .= chr(i % 255)` — and XOR it byte-for-byte against the payload to recover the
|
|
60
|
+
plaintext.
|
|
61
|
+
4. Integrity check: recompute `substr(base64_encode(hash_hmac('SHA256', payload, key,
|
|
62
|
+
true)), 0, 1)` and compare to the stored hash char. Mismatch → return `''` (→ `400`).
|
|
63
|
+
|
|
64
|
+
Because it is home-rolled and uses only a single hash character for integrity, treat it
|
|
65
|
+
strictly as link obfuscation. Do not use it to protect anything sensitive.
|
|
66
|
+
|
|
67
|
+
## Change history
|
|
68
|
+
|
|
69
|
+
- 2026-06-18 (jcardinal) — Initial documentation of the encrypted-link handler during
|
|
70
|
+
Forwarder onboarding into the knowledge base.
|
package/package.json
CHANGED