standdown 0.1.0 → 0.2.0

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 (40) hide show
  1. package/README.md +221 -18
  2. package/dist/{chunk-25A7QG5Z.mjs → chunk-26BG7FFX.mjs} +3 -3
  3. package/dist/{chunk-25A7QG5Z.mjs.map → chunk-26BG7FFX.mjs.map} +1 -1
  4. package/dist/{chunk-SEZFAOUU.mjs → chunk-JKQTHHIP.mjs} +49 -4
  5. package/dist/chunk-JKQTHHIP.mjs.map +1 -0
  6. package/dist/{chunk-SVN6UWA4.mjs → chunk-VE7YBR5A.mjs} +142 -24
  7. package/dist/chunk-VE7YBR5A.mjs.map +1 -0
  8. package/dist/content.cjs +188 -28
  9. package/dist/content.cjs.map +1 -1
  10. package/dist/content.d.cts +11 -4
  11. package/dist/content.d.ts +11 -4
  12. package/dist/content.mjs +18 -8
  13. package/dist/content.mjs.map +1 -1
  14. package/dist/index.cjs +140 -26
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +3 -3
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.mjs +3 -7
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/policies.cjs +18 -11
  21. package/dist/policies.cjs.map +1 -1
  22. package/dist/policies.d.cts +98 -86
  23. package/dist/policies.d.ts +98 -86
  24. package/dist/policies.mjs +18 -12
  25. package/dist/policies.mjs.map +1 -1
  26. package/dist/{session-C5gH-LaQ.d.ts → session-DA9hkYlD.d.ts} +10 -1
  27. package/dist/{session-n0px3Agb.d.cts → session-iosT1mEL.d.cts} +10 -1
  28. package/dist/{stores-CELX9aHN.d.ts → stores-Behkya7C.d.ts} +1 -1
  29. package/dist/{stores-CKCdKDdX.d.cts → stores-DfovpSqe.d.cts} +1 -1
  30. package/dist/{types-A9OJ7SWj.d.cts → types-DgKvVCmQ.d.cts} +53 -7
  31. package/dist/{types-A9OJ7SWj.d.ts → types-DgKvVCmQ.d.ts} +53 -7
  32. package/dist/webext.cjs +191 -28
  33. package/dist/webext.cjs.map +1 -1
  34. package/dist/webext.d.cts +11 -4
  35. package/dist/webext.d.ts +11 -4
  36. package/dist/webext.mjs +22 -9
  37. package/dist/webext.mjs.map +1 -1
  38. package/package.json +1 -1
  39. package/dist/chunk-SEZFAOUU.mjs.map +0 -1
  40. package/dist/chunk-SVN6UWA4.mjs.map +0 -1
package/dist/content.cjs CHANGED
@@ -21,8 +21,24 @@ function detect(signals, policies) {
21
21
  }
22
22
  const advertiserHost = currentUrl.hostname.toLowerCase();
23
23
  const matched = [];
24
+ const selfExemptScopes = [];
24
25
  let selfMatch = false;
25
26
  for (const policy of policies) {
27
+ const disabledHostRule = (policy.detection.disableHosts ?? []).find(
28
+ (rule) => domainRuleMatchesUrl(rule, advertiserHost)
29
+ );
30
+ if (disabledHostRule) {
31
+ matched.push(
32
+ matchedRule(
33
+ policy,
34
+ "disabled-host",
35
+ `${disabledHostRule.kind}:${disabledHostRule.pattern}`,
36
+ advertiserHost,
37
+ disabledHostRule.comment ?? "host disabled for this network"
38
+ )
39
+ );
40
+ continue;
41
+ }
26
42
  const scopedSelfMatch = hasScopedSelfExemption(
27
43
  currentUrl,
28
44
  signals.selfPatterns,
@@ -36,6 +52,10 @@ function detect(signals, policies) {
36
52
  selfMatch = true;
37
53
  }
38
54
  if (scopedSelfMatch) {
55
+ selfExemptScopes.push({
56
+ policyId: policy.id,
57
+ networkId: policy.network.id
58
+ });
39
59
  continue;
40
60
  }
41
61
  matched.push(
@@ -48,7 +68,14 @@ function detect(signals, policies) {
48
68
  advertiserHost: matched[0].advertiserHost,
49
69
  reason: matched[0].reason
50
70
  } : void 0;
51
- return strongest ? { matched, selfMatch, strongest } : { matched, selfMatch };
71
+ const base = { matched, selfMatch };
72
+ if (strongest) {
73
+ base.strongest = strongest;
74
+ }
75
+ if (selfExemptScopes.length > 0) {
76
+ base.selfExemptScopes = selfExemptScopes;
77
+ }
78
+ return base;
52
79
  }
53
80
  function classifyReferrer(signals, advertiserHost) {
54
81
  const candidate = signals.initiator ?? signals.referrer;
@@ -165,16 +192,19 @@ function matchedRule(policy, kind, rule, advertiserHost, reason) {
165
192
  };
166
193
  }
167
194
  function kindPriority(kind) {
168
- if (kind === "redirect-domain") {
195
+ if (kind === "disabled-host") {
169
196
  return 0;
170
197
  }
171
- if (kind === "landing-param") {
198
+ if (kind === "redirect-domain") {
172
199
  return 1;
173
200
  }
174
- if (kind === "cookie") {
201
+ if (kind === "landing-param") {
175
202
  return 2;
176
203
  }
177
- return 3;
204
+ if (kind === "cookie") {
205
+ return 3;
206
+ }
207
+ return 4;
178
208
  }
179
209
  function paramRuleMatches(rule, params) {
180
210
  return rule.anyOf.some(
@@ -286,6 +316,9 @@ function validatePolicy(value) {
286
316
  optionalArray(detection.initiatorRules, "policy.detection.initiatorRules").forEach(
287
317
  validateInitiatorRule
288
318
  );
319
+ optionalArray(detection.disableHosts, "policy.detection.disableHosts").forEach(
320
+ validateDomainRule
321
+ );
289
322
  const standdown = object(policy.standdown, "policy.standdown");
290
323
  literal(standdown.scope, "advertiser", "policy.standdown.scope");
291
324
  if (standdown.sessionRule !== "session-or-min" && standdown.sessionRule !== "inactivity-window") {
@@ -311,12 +344,6 @@ function validatePolicy(value) {
311
344
  if (activation.mode !== "user-click" && activation.mode !== "never") {
312
345
  throw new TypeError("policy.activation.mode is invalid");
313
346
  }
314
- if (activation.maxPromptsPerJourney !== void 0) {
315
- nonNegativeFiniteNumber(
316
- activation.maxPromptsPerJourney,
317
- "policy.activation.maxPromptsPerJourney"
318
- );
319
- }
320
347
  for (const referrerClass of optionalArray(
321
348
  activation.allowedReferrerClasses,
322
349
  "policy.activation.allowedReferrerClasses"
@@ -460,11 +487,13 @@ var StanddownSession = class {
460
487
  #store;
461
488
  #auditLog;
462
489
  #maxAuditEntries;
490
+ #selfExemptionScope;
463
491
  #readOnlyAuditLog = [];
464
492
  constructor(store, opts) {
465
493
  this.#store = store;
466
494
  this.#auditLog = opts?.auditLog ?? true;
467
495
  this.#maxAuditEntries = Math.max(0, opts?.maxAuditEntries ?? 1e3);
496
+ this.#selfExemptionScope = opts?.selfExemptionScope ?? "policy";
468
497
  }
469
498
  async ingest(signals, policies) {
470
499
  const advertiserHost = hostFromUrl2(signals.url);
@@ -490,38 +519,49 @@ var StanddownSession = class {
490
519
  }
491
520
  return this.#withState(signals.now, (state) => {
492
521
  pruneExpiredSessions(state, signals.now);
493
- if (detection.strongest) {
494
- const matchedPolicies = policiesForDetection(policies, detection);
522
+ let effective = detection;
523
+ if (this.#selfExemptionScope === "session" && advertiserHost) {
524
+ recordSessionExemptions(state, advertiserHost, detection, signals.now);
525
+ effective = applySessionExemptions(
526
+ advertiserHost,
527
+ detection,
528
+ state.exemptions?.[advertiserHost]
529
+ );
530
+ }
531
+ if (effective.strongest) {
532
+ const matchedPolicies = policiesForDetection(policies, effective);
495
533
  if (matchedPolicies.length === 0) {
496
534
  return {
497
535
  decision: failClosedDecision("matched-policy-missing"),
498
- detection
536
+ detection: effective
499
537
  };
500
538
  }
501
539
  const record = upsertSessionRecord(
502
540
  state,
503
- detection.strongest.advertiserHost,
504
- detection.strongest.policyId,
541
+ effective.strongest.advertiserHost,
542
+ effective.strongest.policyId,
505
543
  matchedPolicies,
506
544
  signals.now
507
545
  );
508
546
  return {
509
547
  decision: decisionFromRecord(record, signals.now, {
510
- reason: detection.strongest.reason,
548
+ reason: effective.strongest.reason,
511
549
  referrerClass: classifyReferrer(signals, record.advertiserHost)
512
550
  }),
513
- detection
551
+ detection: effective
514
552
  };
515
553
  }
516
554
  const activeDecision = advertiserHost ? activeDecisionForHost(state, advertiserHost, signals.now) : void 0;
555
+ const sessionExempted = detection.strongest !== void 0 && effective.strongest === void 0;
517
556
  return {
518
557
  decision: activeDecision ?? {
519
558
  standDown: false,
520
- reason: detection.selfMatch ? "self-exempted-no-active-standdown" : "no-active-standdown",
559
+ reason: sessionExempted ? "self-exempted-session" : detection.selfMatch ? "self-exempted-no-active-standdown" : "no-active-standdown",
521
560
  behaviors: [],
522
- referrerClass: advertiserHost ? classifyReferrer(signals, advertiserHost) : "other"
561
+ referrerClass: advertiserHost ? classifyReferrer(signals, advertiserHost) : "other",
562
+ ...signals.signalCoverage === "partial" ? { degraded: true } : {}
523
563
  },
524
- detection
564
+ detection: effective
525
565
  };
526
566
  }, "ingest");
527
567
  }
@@ -690,6 +730,66 @@ function upsertSessionRecord(state, advertiserHost, primaryPolicyId, policies, n
690
730
  state.sessions[key] = baseRecord;
691
731
  return baseRecord;
692
732
  }
733
+ function recordSessionExemptions(state, advertiserHost, detection, now) {
734
+ const scopes = detection.selfExemptScopes;
735
+ if (!scopes || scopes.length === 0) {
736
+ return;
737
+ }
738
+ if (activeDecisionForHost(state, advertiserHost, now) !== void 0) {
739
+ return;
740
+ }
741
+ const key = normalizeHost(advertiserHost);
742
+ if (state.exemptions === void 0) {
743
+ state.exemptions = {};
744
+ }
745
+ const exemptions = state.exemptions;
746
+ const existing = exemptions[key];
747
+ const policyIds = new Set(existing?.policyIds ?? []);
748
+ const networkIds = new Set(existing?.networkIds ?? []);
749
+ for (const scope of scopes) {
750
+ policyIds.add(scope.policyId);
751
+ networkIds.add(scope.networkId);
752
+ }
753
+ exemptions[key] = {
754
+ advertiserHost: key,
755
+ policyIds: [...policyIds],
756
+ networkIds: [...networkIds],
757
+ grantedAt: existing?.grantedAt ?? now
758
+ };
759
+ }
760
+ function applySessionExemptions(advertiserHost, detection, record) {
761
+ if (!record) {
762
+ return detection;
763
+ }
764
+ const host = normalizeHost(advertiserHost);
765
+ const policyIds = new Set(record.policyIds);
766
+ const networkIds = new Set(record.networkIds);
767
+ const filtered = detection.matched.filter((match) => {
768
+ if (match.kind === "disabled-host") {
769
+ return true;
770
+ }
771
+ if (normalizeHost(match.advertiserHost) !== host) {
772
+ return true;
773
+ }
774
+ return !(policyIds.has(match.policyId) || networkIds.has(match.networkId));
775
+ });
776
+ if (filtered.length === detection.matched.length) {
777
+ return detection;
778
+ }
779
+ const strongest = filtered[0] ? {
780
+ policyId: filtered[0].policyId,
781
+ advertiserHost: filtered[0].advertiserHost,
782
+ reason: filtered[0].reason
783
+ } : void 0;
784
+ const next = { matched: filtered, selfMatch: detection.selfMatch };
785
+ if (strongest) {
786
+ next.strongest = strongest;
787
+ }
788
+ if (detection.selfExemptScopes) {
789
+ next.selfExemptScopes = detection.selfExemptScopes;
790
+ }
791
+ return next;
792
+ }
693
793
  function policiesForDetection(policies, detection) {
694
794
  if (detection.strongest === void 0) {
695
795
  return [];
@@ -801,6 +901,11 @@ function cloneAuditEntry(entry) {
801
901
  if (entry.detection.strongest !== void 0) {
802
902
  detection.strongest = { ...entry.detection.strongest };
803
903
  }
904
+ if (entry.detection.selfExemptScopes !== void 0) {
905
+ detection.selfExemptScopes = entry.detection.selfExemptScopes.map(
906
+ (scope) => ({ ...scope })
907
+ );
908
+ }
804
909
  if (entry.detection.failClosedReason !== void 0) {
805
910
  detection.failClosedReason = entry.detection.failClosedReason;
806
911
  }
@@ -925,6 +1030,7 @@ function dropSessionBoundRecords(state, now) {
925
1030
  delete next.sessions[host];
926
1031
  }
927
1032
  }
1033
+ delete next.exemptions;
928
1034
  return next;
929
1035
  }
930
1036
  function createSessionId() {
@@ -958,10 +1064,36 @@ function parseState(value) {
958
1064
  for (const [host, record] of Object.entries(value.sessions)) {
959
1065
  sessions[host] = parseSessionRecord(record);
960
1066
  }
961
- return {
1067
+ const state = {
962
1068
  sessions,
963
1069
  auditLog: value.auditLog.map(parseAuditEntry)
964
1070
  };
1071
+ if (value.exemptions !== void 0) {
1072
+ if (!isRecord(value.exemptions)) {
1073
+ throw new Error("invalid standdown state");
1074
+ }
1075
+ const exemptions = {};
1076
+ for (const [host, record] of Object.entries(value.exemptions)) {
1077
+ exemptions[host] = parseExemptionRecord(record);
1078
+ }
1079
+ state.exemptions = exemptions;
1080
+ }
1081
+ return state;
1082
+ }
1083
+ function parseExemptionRecord(value) {
1084
+ if (!isRecord(value)) {
1085
+ throw new Error("invalid exemption record");
1086
+ }
1087
+ const { advertiserHost, policyIds, networkIds, grantedAt } = value;
1088
+ if (typeof advertiserHost !== "string" || typeof grantedAt !== "number" || !Array.isArray(policyIds) || !policyIds.every((id) => typeof id === "string") || !Array.isArray(networkIds) || !networkIds.every((id) => typeof id === "string")) {
1089
+ throw new Error("invalid exemption record");
1090
+ }
1091
+ return {
1092
+ advertiserHost,
1093
+ policyIds: [...policyIds],
1094
+ networkIds: [...networkIds],
1095
+ grantedAt
1096
+ };
965
1097
  }
966
1098
  function parseSessionRecord(value) {
967
1099
  if (!isRecord(value)) {
@@ -1019,7 +1151,7 @@ function parseAuditEntry(value) {
1019
1151
  return entry;
1020
1152
  }
1021
1153
  function cloneState(state) {
1022
- return {
1154
+ const next = {
1023
1155
  sessions: Object.fromEntries(
1024
1156
  Object.entries(state.sessions).map(([host, record]) => [
1025
1157
  host,
@@ -1028,6 +1160,19 @@ function cloneState(state) {
1028
1160
  ),
1029
1161
  auditLog: state.auditLog.map(cloneAuditEntry2)
1030
1162
  };
1163
+ if (state.exemptions) {
1164
+ next.exemptions = Object.fromEntries(
1165
+ Object.entries(state.exemptions).map(([host, record]) => [
1166
+ host,
1167
+ {
1168
+ ...record,
1169
+ policyIds: [...record.policyIds],
1170
+ networkIds: [...record.networkIds]
1171
+ }
1172
+ ])
1173
+ );
1174
+ }
1175
+ return next;
1031
1176
  }
1032
1177
  function cloneSessionRecord(record) {
1033
1178
  const next = {
@@ -1063,6 +1208,11 @@ function cloneAuditEntry2(entry) {
1063
1208
  if (entry.detection.strongest !== void 0) {
1064
1209
  next.detection.strongest = { ...entry.detection.strongest };
1065
1210
  }
1211
+ if (entry.detection.selfExemptScopes !== void 0) {
1212
+ next.detection.selfExemptScopes = entry.detection.selfExemptScopes.map(
1213
+ (scope) => ({ ...scope })
1214
+ );
1215
+ }
1066
1216
  if (entry.detection.failClosedReason !== void 0) {
1067
1217
  next.detection.failClosedReason = entry.detection.failClosedReason;
1068
1218
  }
@@ -1097,10 +1247,7 @@ function createContentStanddown(opts) {
1097
1247
  const windowLike = opts.window ?? currentWindow();
1098
1248
  const now = opts.now ?? Date.now;
1099
1249
  const store = opts.store ?? createContentStore(windowLike, opts, now);
1100
- const session = new StanddownSession(
1101
- store,
1102
- opts.auditLog === void 0 ? void 0 : { auditLog: opts.auditLog }
1103
- );
1250
+ const session = new StanddownSession(store, contentSessionOptions(opts));
1104
1251
  let disposed = false;
1105
1252
  let pending = false;
1106
1253
  const restoreHistory = patchHistory(windowLike, scheduleEvaluation);
@@ -1181,7 +1328,10 @@ function collectContentSignals(opts = {}) {
1181
1328
  }
1182
1329
  const signals = {
1183
1330
  url: windowLike.location.href,
1184
- now: now()
1331
+ now: now(),
1332
+ // The content plane never observes redirect chains, so a non-stand-down
1333
+ // here can miss redirect-only attribution.
1334
+ signalCoverage: "partial"
1185
1335
  };
1186
1336
  const referrer = windowLike.document.referrer;
1187
1337
  const cookieNames = cookieNamesFromString(windowLike.document.cookie);
@@ -1205,6 +1355,16 @@ function cookieNamesFromString(cookie) {
1205
1355
  }
1206
1356
  return cookie.split(";").map((part) => part.trim()).filter((part) => part.length > 0).map((part) => part.split("=")[0]?.trim() ?? "").filter((name) => name.length > 0);
1207
1357
  }
1358
+ function contentSessionOptions(opts) {
1359
+ const sessionOpts = {};
1360
+ if (opts.auditLog !== void 0) {
1361
+ sessionOpts.auditLog = opts.auditLog;
1362
+ }
1363
+ if (opts.selfExemptionScope !== void 0) {
1364
+ sessionOpts.selfExemptionScope = opts.selfExemptionScope;
1365
+ }
1366
+ return Object.keys(sessionOpts).length > 0 ? sessionOpts : void 0;
1367
+ }
1208
1368
  function createContentStore(windowLike, opts, now) {
1209
1369
  if (windowLike === void 0) {
1210
1370
  return new UnavailableStateStore("content window unavailable");