standdown 0.2.2 → 0.2.3

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.
Files changed (2) hide show
  1. package/ADOPTING.md +298 -0
  2. package/package.json +3 -2
package/ADOPTING.md ADDED
@@ -0,0 +1,298 @@
1
+ # Adopting `standdown` in an extension that already stands down
2
+
3
+ This is the **brownfield** migration guide. It is for an extension that *already*
4
+ has homegrown stand-down / affiliate-suppression logic and wants to move that
5
+ logic onto the [`standdown`](https://www.npmjs.com/package/standdown) library
6
+ without ever losing revenue in the switch.
7
+
8
+ > If you are adding stand-down to an extension that has **none** today, you want
9
+ > the greenfield "install standdown" skill instead — that is a clean install.
10
+ > This guide is the harder case: there is a working, revenue-critical decision
11
+ > path in production, and the job is to replace it *provably* rather than *hopefully*.
12
+
13
+ It is tool-agnostic. Point any coding agent (Claude Code, Cursor, Copilot, etc.)
14
+ at it. The one rule that overrides everything below: **you are migrating logic
15
+ that decides whether a partner keeps a commission. A wrong "activate" hijacks a
16
+ sale that someone else already earned. Treat every step as revenue-critical and
17
+ default to the existing behavior whenever the new behavior is uncertain.**
18
+
19
+ The migration runs in five phases:
20
+
21
+ 1. **DETECT** — find the existing stand-down logic.
22
+ 2. **MAP** — translate each homegrown construct to a `standdown` API concept.
23
+ 3. **SHADOW** — run `standdown` in observe-only mode beside the real path, reconcile every divergence, *then* cut over behind a flag.
24
+ 4. **GUARD** — hold the library's invariants as hard constraints the whole way.
25
+ 5. **VERIFY** — characterization tests, the audit grade, and no-fail-open assertions before you delete anything.
26
+
27
+ Throughout, the worked example is a real migration: a browser
28
+ extension whose server-driven stand-down policy covered CJ, Rakuten, Impact,
29
+ eBay, and a handful of merchant blocks (Home Depot, AliExpress, Shein), with
30
+ `ignore_param` self-exemption and a whole-cookie-string matcher.
31
+
32
+ ---
33
+
34
+ ## Phase 1 — DETECT the existing stand-down logic
35
+
36
+ You cannot migrate what you have not found. Stand-down logic is rarely in one
37
+ file called `standdown.ts`; it hides in redirect gates, cookie sniffers,
38
+ "disable on these domains" lists, and param allowlists. Sweep the codebase for
39
+ all of it before mapping anything.
40
+
41
+ **Grep for these signals** (case-insensitive, whole repo, including the
42
+ background worker, content scripts, and any server that ships policy to the
43
+ extension):
44
+
45
+ | What you're looking for | Grep patterns |
46
+ | --- | --- |
47
+ | Affiliate **click-id params** | `cjevent`, `cjdata`, `irclickid`, `irgwc`, `awc`, `ranSiteID`, `ranEAID`, `ranMID`, `sscid`, `clickid`, `afsrc` |
48
+ | **Network redirect domains** | `linksynergy`, `anrdoezrs`, `dpbolvw`, `jdoqocy`, `kqzyfj`, `qksrv`, `awin1`, `shareasale`, `commission-junction` |
49
+ | **Cookie checks** | `document.cookie`, `lsclick_mid`, `linkshare`, `cje`, `cjevent_dc`, `im_ref`, cookie-name/`includes(` scans |
50
+ | **Self-exemption / own attribution** | `ignore_param`, `ignore-stand-down`, `self`, your own `PID=`, `SID=`, `siteID`, publisher-owned click ids |
51
+ | **Merchant / host blocks** | `disable_domains`, `disableHosts`, `blocklist`, `safari_popup_disable_domains`, allowlist/denylist of hosts |
52
+ | **The decision itself** | `stand down`, `standdown`, `stand-down`, `suppress`, `shouldActivate`, `shouldStandDown`, `allowlist`, `redirect` gates around cookie writes |
53
+
54
+ **Produce a detection inventory** — one row per distinct rule, capturing:
55
+
56
+ - the **network or merchant** it concerns,
57
+ - the **exact current behavior** (what makes it fire, what it does when it fires),
58
+ - **where** it lives (file:line), and
59
+ - any **TTL / persistence** (session flag? cookie duration? nothing?).
60
+
61
+ Worked example (an adopting extension): the sweep surfaced a server-fetched policy plus a
62
+ hand-copied `FALLBACK_POLICY` in the background worker, a `standDownHelper.ts`
63
+ that lowercased the **entire** `document.cookie` string and did `.includes()`,
64
+ an `ignore_param` self-exemption per network, a `standDownCookieDuration` of 60
65
+ minutes for CJ, and `disable_domains` lists for eBay, Home Depot, AliExpress,
66
+ and Shein. Note especially the two things that are easy to miss: the **cookie
67
+ matcher looked at values, not just names**, and `disable_domains` used
68
+ **substring** `includes()` (so `'ebay.'` also matched `rebay.com`).
69
+
70
+ Do not skip anything as "obviously equivalent." The divergences that cost
71
+ revenue are exactly the ones that look equivalent at a glance.
72
+
73
+ ---
74
+
75
+ ## Phase 2 — MAP homegrown constructs to the `standdown` API
76
+
77
+ `standdown` is a **data-driven policy engine**: you describe each network as a
78
+ `StanddownPolicy` (detection rules + stand-down behavior), hand the array to an
79
+ adapter, and it returns a `Decision`. Your job here is a translation table, not
80
+ new logic. Map each inventory row to exactly one library concept.
81
+
82
+ ### The mapping table
83
+
84
+ | Homegrown construct | `standdown` concept | Notes |
85
+ | --- | --- | --- |
86
+ | Affiliate params that trigger stand-down (`cjevent`, `ranSiteID`, …) | `detection.landingParams` | Grouped `anyOf` / `allOf`; `{ name }` alone = presence, add `value` + `match:'equals'` for value checks. |
87
+ | Network redirect/rotator hostnames (`linksynergy`, `anrdoezrs`) | `detection.redirectDomains` | `{ pattern, kind:'suffix' }`. Observed only by the webext adapter's `webRequest` plane. |
88
+ | Cookie checks | `detection.cookiePatterns` | **NAME-ONLY.** `{ name, match:'exact'\|'substring' }`. Values are never inspected — see the divergence note below. |
89
+ | Your **own** attribution params (`ignore_param`) | `selfPatterns` + `selfExemptionScope` | `{ name, value?, match?, networkId }`. Scope controls how long the exemption sticks — this is the highest-risk mapping. |
90
+ | "Never operate on this host" merchant blocks (`disable_domains`) | `detection.disableHosts` | `{ pattern, kind:'suffix'\|'regex' }`. Unconditional, strongest match, **not liftable** by any self-exemption. |
91
+ | Per-network minimum stand-down window (`standDownCookieDuration`) | `standdown.minDurationMs` with `sessionRule:'session-or-min'` | Calibrate to the production number. |
92
+ | Session vs persisted stand-down | adapter `storage: 'session'` \| `'local-ttl'` | `local-ttl` survives a `sessionStorage` clear within a sliding 24h envelope. |
93
+
94
+ ### Worked example: an adopting extension's config
95
+
96
+ The inventory mapped cleanly onto the bundled packs plus one custom
97
+ merchant-block policy:
98
+
99
+ ```ts
100
+ import { createContentStanddown } from 'standdown/content';
101
+ import { cjPolicy, impactPolicy, rakutenPolicy, ebayEpnPolicy } from 'standdown/policies';
102
+
103
+ // disable_domains -> a custom policy carrying ONLY detection.disableHosts.
104
+ // (...id, network, standdown, activation, metadata omitted for brevity...)
105
+ const hostMerchantBlocks = {
106
+ // id: 'host-merchant-blocks', network: {...}, standdown: {...}, activation: {...}, metadata: {...},
107
+ detection: {
108
+ disableHosts: [
109
+ { pattern: '(^|\\.)ebay\\.[a-z.]+$', kind: 'regex' }, // replaces disable_domains ['ebay.com','ebay.']
110
+ { pattern: 'homedepot.com', kind: 'suffix' },
111
+ { pattern: 'aliexpress.com', kind: 'suffix' },
112
+ { pattern: 'aliexpress.co.uk', kind: 'suffix' },
113
+ { pattern: 'shein.com', kind: 'suffix' }, // suffix also covers m.shein.com
114
+ { pattern: 'shein.co.uk', kind: 'suffix' }, // and m.shein.co.uk
115
+ ],
116
+ },
117
+ } as const;
118
+
119
+ const standdown = createContentStanddown({
120
+ policies: [cjPolicy, impactPolicy, rakutenPolicy, ebayEpnPolicy, hostMerchantBlocks],
121
+ selfPatterns: [
122
+ { name: 'ranSiteID', value: 'EXAMPLESITEID', match: 'contains', networkId: 'rakuten' },
123
+ { name: 'cp', value: '_examplebrand', match: 'contains', networkId: 'cj' },
124
+ { name: 'PID', value: 'CJ0000000001', match: 'equals', networkId: 'cj' },
125
+ { name: 'PID', value: 'CJ0000000002', match: 'equals', networkId: 'cj' },
126
+ ],
127
+ selfExemptionScope: 'policy', // per-navigation, faithful to the adopter's ignore_param — NEVER 'session' here
128
+ publisherSites: ['example.com'],
129
+ storage: 'session', // or 'local-ttl' to honor CJ's 60-minute minDurationMs across a sessionStorage clear
130
+ auditLog: true,
131
+ onDecision: (d) => {/* namespaced shadow key / analytics only — see Phase 3 */},
132
+ });
133
+ ```
134
+
135
+ Notes that generalize to any migration:
136
+
137
+ - **`disable_domains` is not a network** — do not invent a policy pack for it.
138
+ Merchant blocks are `detection.disableHosts` on a custom policy. Home Depot,
139
+ AliExpress, and Shein each become a `suffix` rule; a suffix rule already
140
+ matches subdomains, so `shein.com` covers `m.shein.com` with no extra entry.
141
+ - **`ignore_param` maps to `selfPatterns`, and the scope is the whole ballgame.**
142
+ The adopter's `ignore_param` was *per-navigation*, so `selfExemptionScope: 'policy'`
143
+ (the default) is faithful. `'session'` would add self-click stickiness the adopter
144
+ never had and could let the extension **activate** on a later param-less visit
145
+ where the adopter stood down — a more-permissive change that hijacks a sale. Only use
146
+ `'session'` if the homegrown code actually persisted the exemption.
147
+ - **Match the current fleet, not the "complete" library.** The bundled
148
+ `amazonPolicy` always stands down on Amazon; if the extension currently stays
149
+ *active* on Amazon (`ALLOW_AMAZON=true`), **exclude** it. Same for any host the
150
+ extension deliberately still operates on (an adopter's retired Wayfair block): omit
151
+ the `disableHosts` entry until the business decides to reinstate it. Broader
152
+ bundled packs (`universal`, `awin`, `shareasale`) are safe-stricter but will
153
+ disagree with current behavior a lot — keep them **opt-in** for the first cut.
154
+
155
+ ---
156
+
157
+ ## Phase 3 — SHADOW mode before cutover (this is the whole point)
158
+
159
+ **Never big-bang-replace revenue logic.** The existing decision path is the
160
+ ground truth for money that is already being earned; the new one is a hypothesis
161
+ until proven. A direct swap bets real commissions on that hypothesis being
162
+ correct on the first try, across every network, merchant, and edge case you
163
+ found in Phase 1 — and the failure mode is silent (a hijacked sale looks like a
164
+ normal activation). So you run the new engine in the dark first and only promote
165
+ it once it agrees with reality on purpose.
166
+
167
+ ### The protocol
168
+
169
+ 1. **Baseline grade.** Run the audit grader against the *current* extension and
170
+ record its letter grade. This is the bar the migration must **meet or beat** —
171
+ never regress it.
172
+
173
+ 2. **Shadow observe.** Wire `standdown` in alongside the existing path, computing
174
+ a `Decision` for every navigation but **taking no action on it**. The real
175
+ path still decides. Emit each shadow decision to a **namespaced** shadow key /
176
+ analytics channel (via `onDecision` or your own logging) next to what the real
177
+ path did. Nothing the user experiences changes.
178
+
179
+ 3. **Reconcile divergences.** Compare shadow vs. real on live traffic and triage
180
+ every disagreement into one of:
181
+ - **safe-stricter** — `standdown` stands down where the old path activated.
182
+ No revenue risk (you can only *lose a competing activation*, never hijack).
183
+ Accept, or note as expected.
184
+ - **dangerous-more-permissive** — `standdown` would activate where the old
185
+ path stood down. **This is the failure class that hijacks sales. It must be
186
+ driven to zero before cutover**, usually by adding a `disableHosts` entry or
187
+ tightening a `selfPattern`.
188
+ - **needs-human-decision** — a genuine behavior change with a business
189
+ tradeoff (see the adopter cases below). Escalate; do not silently pick.
190
+
191
+ 4. **Flagged cutover.** Only once dangerous-more-permissive divergences are zero
192
+ and the grade is ≥ baseline: move the real decision behind an
193
+ **off-by-default flag** that swaps the old path for `standdown`. Roll it
194
+ forward gradually. Keep the shadow comparison running so a regression is
195
+ visible immediately.
196
+
197
+ 5. **Delete old code — last.** Only after the flag has been fully on in
198
+ production and stable do you remove the homegrown logic. Until then it stays
199
+ as the instant rollback.
200
+
201
+ ### Worked example: the divergences an adopter had to reconcile
202
+
203
+ - **Cookie name-vs-value (safe-stricter, accepted).** The old matcher hit on a
204
+ cookie **name or value**; `standdown` matches **names only**. Both live adopter
205
+ tokens (`lsclick_mid`, `linkshare`) are real cookie *names*, so name-only still
206
+ catches them — name-only merely drops the old code's value-substring
207
+ over-matches. No fix.
208
+ - **eBay unconditional block (dangerous-more-permissive, MUST FIX).** The old
209
+ `disable_domains ['ebay.com','ebay.']` stood down on *every* eBay host;
210
+ `ebayEpnPolicy` alone only stands down on eBay tracking params/referrers, so a
211
+ param-less eBay page would **activate**. Fix: add the eBay `disableHosts` regex
212
+ shown in Phase 2. That restores the unconditional block and makes it unliftable.
213
+ - **`ignore_param` scope (dangerous-more-permissive if mis-set).** Covered above:
214
+ pin `selfExemptionScope: 'policy'`.
215
+ - **Self-click lift gap (needs-human-decision).** The old `ignore_param` actively
216
+ *cleared* an already-active CJ 60-minute stand-down so an adopter self-click could
217
+ re-win attribution. `standdown` is **monotone** — it never lifts an active
218
+ stand-down (Invariant, Phase 4). This is safe for never-hijacking but costs
219
+ adopter self-attribution in the CJ overlap window. Config cannot close it; a human
220
+ must accept the loss or build an out-of-library special case.
221
+ - **Amazon / Wayfair (needs-human-decision).** Both resolved by *matching current
222
+ behavior*: exclude `amazonPolicy`, omit the Wayfair block.
223
+
224
+ ---
225
+
226
+ ## Phase 4 — GUARD the invariants (agent constraints)
227
+
228
+ These are the library's public commitments. In a migration they double as **hard
229
+ constraints on you, the agent**. If any change you are about to make would
230
+ violate one, stop and flag it instead.
231
+
232
+ - **No network call in the decision path (I1).** The decision must be local and
233
+ synchronous. The old code may have *fetched* its policy (the adopter fetched
234
+ `/api/stand-down-policy`); the migrated path takes a **statically imported**
235
+ `policies` array. Any policy freshness mechanism (signed refresh, a separate
236
+ shadow fetch) must run **outside** the decision path and must never edit the
237
+ live decision inline. Adding `webRequest`/`declarativeNetRequest` capture is a
238
+ manifest change requiring separate sign-off — do not slip it in.
239
+ - **Fail toward standing down (I3).** Unknown, ambiguous, malformed, or storage-
240
+ error states must **suppress activation**, never activate. This is the opposite
241
+ of the old code's fail-**open** to a stale `FALLBACK_POLICY`. When wiring the
242
+ content adapter, treat a `degraded` non-stand-down as a stand-down
243
+ (`decision.standDown || decision.degraded`) — partial signal coverage fails closed.
244
+ - **Monotone updates only (I4).** Stand-downs only broaden/lengthen; an active
245
+ stand-down is never lifted, and policy refresh may not edit activation rules.
246
+ Do not reintroduce "clear the stand-down" behavior to match old self-click code
247
+ — that is the human-decision gap above, not a bug to patch.
248
+ - **Cookie NAMES only (I2).** Never port a rule that depends on a cookie *value*.
249
+ If a genuine signal lives *only* in a value and never a name, `standdown`
250
+ cannot express it — flag it; do not try to smuggle the value into `Signals`.
251
+ - **No user profiling / no remote code (I2, I6).** Signals exclude user identity;
252
+ policies are data, never `eval`'d or fetched-and-executed.
253
+
254
+ ---
255
+
256
+ ## Phase 5 — VERIFY before you delete
257
+
258
+ Do not remove the homegrown path on faith. Prove equivalence-or-better first.
259
+
260
+ - [ ] **Characterization tests.** Before touching anything, write tests that pin
261
+ the *current* extension's decision on a representative URL/cookie/referrer for
262
+ every network and merchant in the Phase 1 inventory. These are the executable
263
+ spec of "what we do today." The migrated path must pass them (or the delta must
264
+ be a signed-off human decision, not an accident).
265
+ - [ ] **Shadow divergence report is clean.** Zero **dangerous-more-permissive**
266
+ divergences on live traffic. Every remaining disagreement is classified
267
+ safe-stricter or an approved human decision.
268
+ - [ ] **Audit grade ≥ baseline.** Run the `audit/` grader (`npx tsx
269
+ grade/grade.ts /path/to/unpacked-extension`) against the built extension and
270
+ confirm the letter grade meets or beats the Phase-3 baseline. An **A / A+**
271
+ means it respects existing attribution on every tested network *and* still
272
+ activates when allowed. A **C (inert cap)** means it stopped activating at all —
273
+ over-suppression, which is safe for revenue but means you've shipped dead code;
274
+ investigate. An **F** means it hijacked attribution somewhere — the flag must
275
+ not go on.
276
+ - [ ] **No fail-open assertions.** Add tests asserting that malformed input,
277
+ storage errors, and unknown networks resolve to `standDown: true` (or a
278
+ suppressed activation), and that a `degraded` decision is treated as
279
+ stand-down. The migration must not have reintroduced any fail-open path.
280
+ - [ ] **Flag is off by default** and the old code still present as rollback until
281
+ the flag has been fully on and stable in production.
282
+
283
+ Only when every box is checked and the flag has soaked at 100% do you return to
284
+ Phase 3, step 5 and delete the homegrown logic.
285
+
286
+ ---
287
+
288
+ ## Reference
289
+
290
+ - `README.md` — full API: `selfPatterns` / `selfExemptionScope`,
291
+ `detection.disableHosts`, `landingParams` / `redirectDomains` /
292
+ `cookiePatterns`, degraded decisions, signed policy refresh, and the public
293
+ invariants (I1–I7).
294
+ - `POLICIES.md` — bundled network packs and citations. `allPolicies` is the
295
+ verified set; `experimentalPolicies` are opt-in.
296
+ - `audit/README.md` — the conformance grader (the letter grade used in Phase 5).
297
+ - The greenfield "install standdown" skill — for extensions with no existing
298
+ stand-down logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standdown",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Affiliate stand-down, done right, for browser extensions.",
5
5
  "author": "Dupe (https://dupe.com)",
6
6
  "repository": {
@@ -27,7 +27,8 @@
27
27
  "dist",
28
28
  "README.md",
29
29
  "LICENSE",
30
- "POLICIES.md"
30
+ "POLICIES.md",
31
+ "ADOPTING.md"
31
32
  ],
32
33
  "exports": {
33
34
  ".": {