standdown 0.1.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.
@@ -0,0 +1,1733 @@
1
+ 'use strict';
2
+
3
+ // src/validation.ts
4
+ var BEHAVIORS = /* @__PURE__ */ new Set([
5
+ "suppress-prompts",
6
+ "no-cookie-write",
7
+ "no-redirect",
8
+ "no-background-tracking"
9
+ ]);
10
+ var PARAM_MATCHES = /* @__PURE__ */ new Set(["exists", "equals", "contains"]);
11
+ var COOKIE_MATCHES = /* @__PURE__ */ new Set(["exact", "substring"]);
12
+ var DOMAIN_KINDS = /* @__PURE__ */ new Set(["suffix", "regex"]);
13
+ var REFERRER_CLASSES = /* @__PURE__ */ new Set([
14
+ "own-site",
15
+ "organic",
16
+ "direct",
17
+ "advertiser-internal",
18
+ "other"
19
+ ]);
20
+ var ACTIVATION_REFERRER_CLASSES = /* @__PURE__ */ new Set(["own-site", "organic", "direct"]);
21
+ function validatePolicy(value) {
22
+ const policy = object(value, "policy");
23
+ string(policy.id, "policy.id");
24
+ literal(policy.schemaVersion, 3, "policy.schemaVersion");
25
+ string(policy.policyVersion, "policy.policyVersion");
26
+ const network = object(policy.network, "policy.network");
27
+ string(network.id, "policy.network.id");
28
+ string(network.name, "policy.network.name");
29
+ if (network.policyUrl !== void 0) {
30
+ urlString(network.policyUrl, "policy.network.policyUrl");
31
+ }
32
+ const detection = object(policy.detection, "policy.detection");
33
+ optionalArray(detection.advertiserHosts, "policy.detection.advertiserHosts").forEach(
34
+ validateDomainRule
35
+ );
36
+ optionalArray(detection.landingParams, "policy.detection.landingParams").forEach(
37
+ validateParamRule
38
+ );
39
+ optionalArray(detection.redirectDomains, "policy.detection.redirectDomains").forEach(
40
+ validateDomainRule
41
+ );
42
+ optionalArray(detection.cookiePatterns, "policy.detection.cookiePatterns").forEach(
43
+ validateCookieRule
44
+ );
45
+ optionalArray(detection.initiatorRules, "policy.detection.initiatorRules").forEach(
46
+ validateInitiatorRule
47
+ );
48
+ const standdown = object(policy.standdown, "policy.standdown");
49
+ literal(standdown.scope, "advertiser", "policy.standdown.scope");
50
+ if (standdown.sessionRule !== "session-or-min" && standdown.sessionRule !== "inactivity-window") {
51
+ throw new TypeError("policy.standdown.sessionRule is invalid");
52
+ }
53
+ nonNegativeFiniteNumber(
54
+ standdown.minDurationMs,
55
+ "policy.standdown.minDurationMs"
56
+ );
57
+ if (standdown.inactivityMs !== void 0) {
58
+ nonNegativeFiniteNumber(
59
+ standdown.inactivityMs,
60
+ "policy.standdown.inactivityMs"
61
+ );
62
+ }
63
+ const behaviors = array(standdown.behaviors, "policy.standdown.behaviors");
64
+ for (const behavior of behaviors) {
65
+ if (!BEHAVIORS.has(behavior)) {
66
+ throw new TypeError(`policy.standdown.behaviors contains invalid behavior`);
67
+ }
68
+ }
69
+ const activation = object(policy.activation, "policy.activation");
70
+ if (activation.mode !== "user-click" && activation.mode !== "never") {
71
+ throw new TypeError("policy.activation.mode is invalid");
72
+ }
73
+ if (activation.maxPromptsPerJourney !== void 0) {
74
+ nonNegativeFiniteNumber(
75
+ activation.maxPromptsPerJourney,
76
+ "policy.activation.maxPromptsPerJourney"
77
+ );
78
+ }
79
+ for (const referrerClass of optionalArray(
80
+ activation.allowedReferrerClasses,
81
+ "policy.activation.allowedReferrerClasses"
82
+ )) {
83
+ if (!ACTIVATION_REFERRER_CLASSES.has(referrerClass)) {
84
+ throw new TypeError(
85
+ "policy.activation.allowedReferrerClasses contains invalid class"
86
+ );
87
+ }
88
+ }
89
+ const metadata = object(policy.metadata, "policy.metadata");
90
+ urlString(metadata.sourceUrl, "policy.metadata.sourceUrl");
91
+ dateString(metadata.lastVerified, "policy.metadata.lastVerified");
92
+ if (metadata.notes !== void 0) {
93
+ string(metadata.notes, "policy.metadata.notes");
94
+ }
95
+ }
96
+ function validatePolicies(values) {
97
+ values.forEach(validatePolicy);
98
+ }
99
+ function validateParamRule(ruleValue) {
100
+ const rule = object(ruleValue, "ParamRule");
101
+ const anyOf = array(rule.anyOf, "ParamRule.anyOf");
102
+ if (anyOf.length === 0) {
103
+ throw new TypeError("ParamRule.anyOf must not be empty");
104
+ }
105
+ for (const groupValue of anyOf) {
106
+ const group = object(groupValue, "ParamRule.anyOf[]");
107
+ const allOf = array(group.allOf, "ParamRule.anyOf[].allOf");
108
+ if (allOf.length === 0) {
109
+ throw new TypeError("ParamRule.anyOf[].allOf must not be empty");
110
+ }
111
+ allOf.forEach(validateParamMatcher);
112
+ }
113
+ if (rule.reason !== void 0) {
114
+ string(rule.reason, "ParamRule.reason");
115
+ }
116
+ }
117
+ function validateParamMatcher(matcherValue) {
118
+ const matcher = object(matcherValue, "ParamMatcher");
119
+ string(matcher.name, "ParamMatcher.name");
120
+ if (matcher.name.trim() === "") {
121
+ throw new TypeError("ParamMatcher.name must not be empty");
122
+ }
123
+ if (matcher.value !== void 0) {
124
+ string(matcher.value, "ParamMatcher.value");
125
+ }
126
+ if (matcher.match !== void 0 && !PARAM_MATCHES.has(matcher.match)) {
127
+ throw new TypeError("ParamMatcher.match is invalid");
128
+ }
129
+ if ((matcher.match === "equals" || matcher.match === "contains") && matcher.value === void 0) {
130
+ throw new TypeError("ParamMatcher.value is required for equals/contains");
131
+ }
132
+ }
133
+ function validateDomainRule(ruleValue) {
134
+ const rule = object(ruleValue, "DomainRule");
135
+ string(rule.pattern, "DomainRule.pattern");
136
+ if (!DOMAIN_KINDS.has(rule.kind)) {
137
+ throw new TypeError("DomainRule.kind is invalid");
138
+ }
139
+ if (rule.kind === "regex") {
140
+ try {
141
+ new RegExp(rule.pattern);
142
+ } catch {
143
+ throw new TypeError("DomainRule.pattern must be a valid regex");
144
+ }
145
+ }
146
+ if (rule.comment !== void 0) {
147
+ string(rule.comment, "DomainRule.comment");
148
+ }
149
+ }
150
+ function validateCookieRule(ruleValue) {
151
+ const rule = object(ruleValue, "CookieRule");
152
+ string(rule.name, "CookieRule.name");
153
+ if (!COOKIE_MATCHES.has(rule.match)) {
154
+ throw new TypeError("CookieRule.match is invalid");
155
+ }
156
+ if (rule.reason !== void 0) {
157
+ string(rule.reason, "CookieRule.reason");
158
+ }
159
+ }
160
+ function validateInitiatorRule(ruleValue) {
161
+ const rule = object(ruleValue, "InitiatorRule");
162
+ if (!REFERRER_CLASSES.has(rule.referrerClass)) {
163
+ throw new TypeError("InitiatorRule.referrerClass is invalid");
164
+ }
165
+ if (rule.reason !== void 0) {
166
+ string(rule.reason, "InitiatorRule.reason");
167
+ }
168
+ }
169
+ function object(value, path) {
170
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
171
+ throw new TypeError(`${path} must be an object`);
172
+ }
173
+ return value;
174
+ }
175
+ function string(value, path) {
176
+ if (typeof value !== "string" || value.trim() === "") {
177
+ throw new TypeError(`${path} must be a non-empty string`);
178
+ }
179
+ }
180
+ function literal(value, expected, path) {
181
+ if (value !== expected) {
182
+ throw new TypeError(`${path} must be ${String(expected)}`);
183
+ }
184
+ }
185
+ function array(value, path) {
186
+ if (!Array.isArray(value)) {
187
+ throw new TypeError(`${path} must be an array`);
188
+ }
189
+ return value;
190
+ }
191
+ function optionalArray(value, path) {
192
+ if (value === void 0) {
193
+ return [];
194
+ }
195
+ return array(value, path);
196
+ }
197
+ function nonNegativeFiniteNumber(value, path) {
198
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
199
+ throw new TypeError(`${path} must be a non-negative finite number`);
200
+ }
201
+ }
202
+ function urlString(value, path) {
203
+ string(value, path);
204
+ try {
205
+ new URL(value);
206
+ } catch {
207
+ throw new TypeError(`${path} must be an absolute URL`);
208
+ }
209
+ }
210
+ function dateString(value, path) {
211
+ string(value, path);
212
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
213
+ throw new TypeError(`${path} must be YYYY-MM-DD`);
214
+ }
215
+ }
216
+
217
+ // src/bundle.ts
218
+ var textEncoder = new TextEncoder();
219
+ var MAX_SIGNED_BUNDLE_REGEX_LENGTH = 256;
220
+ async function verifyPolicyBundle(current, update, publicKeyJwk) {
221
+ try {
222
+ validatePolicies(current);
223
+ validatePolicies(update.policies);
224
+ } catch (error) {
225
+ return { ok: false, violation: `malformed-policy: ${messageFromError(error)}` };
226
+ }
227
+ if (update.schemaVersion !== 1) {
228
+ return { ok: false, violation: "unsupported-bundle-version" };
229
+ }
230
+ const signatureOk = await verifySignature(
231
+ update.signature.algorithm,
232
+ publicKeyJwk,
233
+ update.signature.value,
234
+ canonicalPolicyBundlePayload(update)
235
+ );
236
+ if (!signatureOk) {
237
+ return { ok: false, violation: "bad-signature" };
238
+ }
239
+ const regexViolation = regexComplexityViolation(update.policies);
240
+ if (regexViolation !== void 0) {
241
+ return { ok: false, violation: regexViolation };
242
+ }
243
+ const monotonicityViolation = checkMonotonicity(current, update.policies);
244
+ if (monotonicityViolation !== void 0) {
245
+ return { ok: false, violation: monotonicityViolation };
246
+ }
247
+ return { ok: true, policies: update.policies.map(clonePolicy) };
248
+ }
249
+ function canonicalPolicyBundlePayload(bundle) {
250
+ return canonicalJson({
251
+ schemaVersion: bundle.schemaVersion,
252
+ policies: bundle.policies
253
+ });
254
+ }
255
+ function canonicalJson(value) {
256
+ if (value === null || typeof value !== "object") {
257
+ if (typeof value === "number" && !Number.isFinite(value)) {
258
+ throw new TypeError("canonical JSON only supports finite numbers");
259
+ }
260
+ return JSON.stringify(value);
261
+ }
262
+ if (Array.isArray(value)) {
263
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
264
+ }
265
+ const record = value;
266
+ const entries = Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
267
+ return `{${entries.join(",")}}`;
268
+ }
269
+ function checkMonotonicity(current, update) {
270
+ const updateById = new Map(update.map((policy) => [policy.id, policy]));
271
+ for (const currentPolicy of current) {
272
+ const updatedPolicy = updateById.get(currentPolicy.id);
273
+ if (updatedPolicy === void 0) {
274
+ return `policy-removed:${currentPolicy.id}`;
275
+ }
276
+ const detectionResult = detectionViolation(currentPolicy, updatedPolicy);
277
+ if (detectionResult !== void 0) {
278
+ return `${currentPolicy.id}:${detectionResult}`;
279
+ }
280
+ const standdownResult = standdownViolation(currentPolicy, updatedPolicy);
281
+ if (standdownResult !== void 0) {
282
+ return `${currentPolicy.id}:${standdownResult}`;
283
+ }
284
+ if (!sameJson(currentPolicy.activation, updatedPolicy.activation)) {
285
+ return `${currentPolicy.id}:activation-edited`;
286
+ }
287
+ }
288
+ return void 0;
289
+ }
290
+ function detectionViolation(currentPolicy, updatedPolicy) {
291
+ if (!domainRulesSurvive(
292
+ currentPolicy.detection.advertiserHosts,
293
+ updatedPolicy.detection.advertiserHosts,
294
+ { advertiserHosts: true }
295
+ )) {
296
+ return "advertiser-hosts-narrowed";
297
+ }
298
+ if (!paramRulesSurvive(
299
+ currentPolicy.detection.landingParams,
300
+ updatedPolicy.detection.landingParams
301
+ )) {
302
+ return "landing-params-narrowed";
303
+ }
304
+ if (!domainRulesSurvive(
305
+ currentPolicy.detection.redirectDomains,
306
+ updatedPolicy.detection.redirectDomains
307
+ )) {
308
+ return "redirect-domains-narrowed";
309
+ }
310
+ if (!cookieRulesSurvive(
311
+ currentPolicy.detection.cookiePatterns,
312
+ updatedPolicy.detection.cookiePatterns
313
+ )) {
314
+ return "cookie-patterns-narrowed";
315
+ }
316
+ if (!initiatorRulesSurvive(
317
+ currentPolicy.detection.initiatorRules,
318
+ updatedPolicy.detection.initiatorRules
319
+ )) {
320
+ return "initiator-rules-narrowed";
321
+ }
322
+ return void 0;
323
+ }
324
+ function standdownViolation(currentPolicy, updatedPolicy) {
325
+ if (updatedPolicy.standdown.scope !== currentPolicy.standdown.scope) {
326
+ return "standdown-scope-edited";
327
+ }
328
+ if (updatedPolicy.standdown.sessionRule !== currentPolicy.standdown.sessionRule) {
329
+ return "session-rule-edited";
330
+ }
331
+ if (updatedPolicy.standdown.minDurationMs < currentPolicy.standdown.minDurationMs) {
332
+ return "min-duration-shortened";
333
+ }
334
+ if (currentPolicy.standdown.inactivityMs !== void 0 && (updatedPolicy.standdown.inactivityMs === void 0 || updatedPolicy.standdown.inactivityMs < currentPolicy.standdown.inactivityMs)) {
335
+ return "inactivity-duration-shortened";
336
+ }
337
+ for (const behavior of currentPolicy.standdown.behaviors) {
338
+ if (!updatedPolicy.standdown.behaviors.includes(behavior)) {
339
+ return "standdown-behavior-removed";
340
+ }
341
+ }
342
+ return void 0;
343
+ }
344
+ function domainRulesSurvive(current, update, opts = {}) {
345
+ if (opts.advertiserHosts && current === void 0) {
346
+ return update === void 0;
347
+ }
348
+ if (current === void 0 || current.length === 0) {
349
+ return true;
350
+ }
351
+ if (opts.advertiserHosts && update === void 0) {
352
+ return true;
353
+ }
354
+ if (update === void 0 || update.length === 0) {
355
+ return false;
356
+ }
357
+ return current.every(
358
+ (rule) => update.some((candidate) => domainRuleCovers(rule, candidate))
359
+ );
360
+ }
361
+ function regexComplexityViolation(policies) {
362
+ for (const policy of policies) {
363
+ for (const [field, rules] of domainRuleEntries(policy)) {
364
+ for (const rule of rules ?? []) {
365
+ if (rule.kind === "regex" && isComplexRegex(rule.pattern)) {
366
+ return `${policy.id}:complex-regex:${field}`;
367
+ }
368
+ }
369
+ }
370
+ }
371
+ return void 0;
372
+ }
373
+ function domainRuleEntries(policy) {
374
+ return [
375
+ ["detection.advertiserHosts", policy.detection.advertiserHosts],
376
+ ["detection.redirectDomains", policy.detection.redirectDomains]
377
+ ];
378
+ }
379
+ function isComplexRegex(pattern) {
380
+ return pattern.length > MAX_SIGNED_BUNDLE_REGEX_LENGTH || hasRegexBackreference(pattern) || hasRegexLookaround(pattern) || hasNestedUnboundedQuantifier(pattern);
381
+ }
382
+ function hasRegexBackreference(pattern) {
383
+ return /(^|[^\\])\\[1-9]/.test(pattern);
384
+ }
385
+ function hasRegexLookaround(pattern) {
386
+ return /\(\?(?:[=!]|<[=!])/.test(pattern);
387
+ }
388
+ function hasNestedUnboundedQuantifier(pattern) {
389
+ return /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)(?:[+*]|\{\d*,?\})/.test(
390
+ pattern
391
+ );
392
+ }
393
+ function domainRuleCovers(current, update) {
394
+ if (current.kind === "suffix" && update.kind === "suffix") {
395
+ return domainSuffixMatches(current.pattern, update.pattern);
396
+ }
397
+ return sameJson(current, update);
398
+ }
399
+ function paramRulesSurvive(current, update) {
400
+ return exactRulesSurvive(current, update);
401
+ }
402
+ function cookieRulesSurvive(current, update) {
403
+ return exactRulesSurvive(current, update);
404
+ }
405
+ function initiatorRulesSurvive(current, update) {
406
+ return exactRulesSurvive(current, update);
407
+ }
408
+ function exactRulesSurvive(current, update) {
409
+ if (current === void 0 || current.length === 0) {
410
+ return true;
411
+ }
412
+ if (update === void 0 || update.length === 0) {
413
+ return false;
414
+ }
415
+ const updatedRules = new Set(update.map((rule) => canonicalJson(rule)));
416
+ return current.every((rule) => updatedRules.has(canonicalJson(rule)));
417
+ }
418
+ async function verifySignature(algorithm, publicKeyJwk, signatureValue, payload) {
419
+ try {
420
+ const publicKey = await importVerificationKey(algorithm, publicKeyJwk);
421
+ const signature = base64UrlToBytes(signatureValue);
422
+ const data = bytesToArrayBuffer(textEncoder.encode(payload));
423
+ if (algorithm === "ECDSA-P256") {
424
+ return crypto.subtle.verify(
425
+ { name: "ECDSA", hash: "SHA-256" },
426
+ publicKey,
427
+ bytesToArrayBuffer(signature),
428
+ data
429
+ );
430
+ }
431
+ return crypto.subtle.verify(
432
+ "Ed25519",
433
+ publicKey,
434
+ bytesToArrayBuffer(signature),
435
+ data
436
+ );
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+ async function importVerificationKey(algorithm, publicKeyJwk) {
442
+ if (algorithm === "ECDSA-P256") {
443
+ return crypto.subtle.importKey(
444
+ "jwk",
445
+ publicKeyJwk,
446
+ { name: "ECDSA", namedCurve: "P-256" },
447
+ false,
448
+ ["verify"]
449
+ );
450
+ }
451
+ return crypto.subtle.importKey("jwk", publicKeyJwk, "Ed25519", false, [
452
+ "verify"
453
+ ]);
454
+ }
455
+ function base64UrlToBytes(value) {
456
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
457
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
458
+ const binary = atob(padded);
459
+ const bytes = new Uint8Array(binary.length);
460
+ for (let index = 0; index < binary.length; index += 1) {
461
+ bytes[index] = binary.charCodeAt(index);
462
+ }
463
+ return bytes;
464
+ }
465
+ function bytesToArrayBuffer(bytes) {
466
+ return bytes.buffer.slice(
467
+ bytes.byteOffset,
468
+ bytes.byteOffset + bytes.byteLength
469
+ );
470
+ }
471
+ function sameJson(left, right) {
472
+ return canonicalJson(left) === canonicalJson(right);
473
+ }
474
+ function domainSuffixMatches(host, pattern) {
475
+ const normalizedHost = host.toLowerCase().replace(/^\./, "").replace(/\.$/, "");
476
+ const normalizedPattern = pattern.toLowerCase().replace(/^\./, "").replace(/\.$/, "");
477
+ return normalizedHost === normalizedPattern || normalizedHost.endsWith(`.${normalizedPattern}`);
478
+ }
479
+ function clonePolicy(policy) {
480
+ return JSON.parse(JSON.stringify(policy));
481
+ }
482
+ function messageFromError(error) {
483
+ return error instanceof Error ? error.message : String(error);
484
+ }
485
+
486
+ // src/detect.ts
487
+ var ORGANIC_REFERRER_SUFFIXES = [
488
+ "google.com",
489
+ "bing.com",
490
+ "duckduckgo.com",
491
+ "yahoo.com",
492
+ "ecosia.org",
493
+ "baidu.com",
494
+ "yandex.com"
495
+ ];
496
+ function detect(signals, policies) {
497
+ const currentUrl = parseUrl(signals.url);
498
+ if (!currentUrl) {
499
+ return {
500
+ matched: [],
501
+ selfMatch: false,
502
+ failClosedReason: "invalid-url"
503
+ };
504
+ }
505
+ const advertiserHost = currentUrl.hostname.toLowerCase();
506
+ const matched = [];
507
+ let selfMatch = false;
508
+ for (const policy of policies) {
509
+ const scopedSelfMatch = hasScopedSelfExemption(
510
+ currentUrl,
511
+ signals.selfPatterns,
512
+ policy
513
+ );
514
+ const unscopedSelfMatch = hasUnscopedSelfExemption(
515
+ currentUrl,
516
+ signals.selfPatterns
517
+ );
518
+ if (scopedSelfMatch || unscopedSelfMatch) {
519
+ selfMatch = true;
520
+ }
521
+ if (scopedSelfMatch) {
522
+ continue;
523
+ }
524
+ matched.push(
525
+ ...collectPolicyMatches(policy, signals, currentUrl, advertiserHost)
526
+ );
527
+ }
528
+ matched.sort((left, right) => kindPriority(left.kind) - kindPriority(right.kind));
529
+ const strongest = matched[0] ? {
530
+ policyId: matched[0].policyId,
531
+ advertiserHost: matched[0].advertiserHost,
532
+ reason: matched[0].reason
533
+ } : void 0;
534
+ return strongest ? { matched, selfMatch, strongest } : { matched, selfMatch };
535
+ }
536
+ function classifyReferrer(signals, advertiserHost) {
537
+ const candidate = signals.initiator ?? signals.referrer;
538
+ if (!candidate) {
539
+ return "direct";
540
+ }
541
+ const referrerHost = hostFromUrl(candidate);
542
+ if (!referrerHost) {
543
+ return "other";
544
+ }
545
+ if ((signals.publisherSites ?? []).some(
546
+ (site) => domainSuffixMatches2(referrerHost, site)
547
+ )) {
548
+ return "own-site";
549
+ }
550
+ if (domainSuffixMatches2(referrerHost, advertiserHost)) {
551
+ return "advertiser-internal";
552
+ }
553
+ if (ORGANIC_REFERRER_SUFFIXES.some(
554
+ (suffix) => domainSuffixMatches2(referrerHost, suffix)
555
+ )) {
556
+ return "organic";
557
+ }
558
+ return "other";
559
+ }
560
+ function domainRuleMatchesUrl(rule, value) {
561
+ const url = parseUrl(value);
562
+ const host = url?.hostname.toLowerCase() ?? value.toLowerCase();
563
+ if (rule.kind === "suffix") {
564
+ return domainSuffixMatches2(host, rule.pattern);
565
+ }
566
+ try {
567
+ const regex = new RegExp(rule.pattern, "i");
568
+ return regex.test(host) || (url ? regex.test(url.href) : regex.test(value));
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+ function collectPolicyMatches(policy, signals, currentUrl, advertiserHost) {
574
+ const matches = [];
575
+ const advertiserHostMatches = policy.detection.advertiserHosts === void 0 || policy.detection.advertiserHosts.some(
576
+ (rule) => domainRuleMatchesUrl(rule, advertiserHost)
577
+ );
578
+ if (advertiserHostMatches) {
579
+ for (const rule of policy.detection.landingParams ?? []) {
580
+ if (paramRuleMatches(rule, currentUrl.searchParams)) {
581
+ matches.push(
582
+ matchedRule(
583
+ policy,
584
+ "landing-param",
585
+ describeParamRule(rule),
586
+ advertiserHost,
587
+ rule.reason ?? "landing parameter matched"
588
+ )
589
+ );
590
+ }
591
+ }
592
+ }
593
+ for (const rule of policy.detection.redirectDomains ?? []) {
594
+ if ((signals.redirectChain ?? []).some((url) => domainRuleMatchesUrl(rule, url))) {
595
+ matches.push(
596
+ matchedRule(
597
+ policy,
598
+ "redirect-domain",
599
+ `${rule.kind}:${rule.pattern}`,
600
+ advertiserHost,
601
+ rule.comment ?? "redirect domain matched"
602
+ )
603
+ );
604
+ }
605
+ }
606
+ if (advertiserHostMatches) {
607
+ for (const rule of policy.detection.cookiePatterns ?? []) {
608
+ if (cookieRuleMatches(rule, signals.cookieNames ?? [])) {
609
+ matches.push(
610
+ matchedRule(
611
+ policy,
612
+ "cookie",
613
+ `${rule.match}:${rule.name}`,
614
+ advertiserHost,
615
+ rule.reason ?? "first-party cookie name matched"
616
+ )
617
+ );
618
+ }
619
+ }
620
+ }
621
+ if (advertiserHostMatches) {
622
+ for (const rule of policy.detection.initiatorRules ?? []) {
623
+ if (initiatorRuleMatches(rule, signals, advertiserHost)) {
624
+ matches.push(
625
+ matchedRule(
626
+ policy,
627
+ "initiator",
628
+ `referrer-class:${rule.referrerClass}`,
629
+ advertiserHost,
630
+ rule.reason ?? "initiator/referrer class matched"
631
+ )
632
+ );
633
+ }
634
+ }
635
+ }
636
+ return matches;
637
+ }
638
+ function matchedRule(policy, kind, rule, advertiserHost, reason) {
639
+ return {
640
+ policyId: policy.id,
641
+ networkId: policy.network.id,
642
+ networkName: policy.network.name,
643
+ kind,
644
+ rule,
645
+ advertiserHost,
646
+ reason,
647
+ sourceUrl: policy.metadata.sourceUrl
648
+ };
649
+ }
650
+ function kindPriority(kind) {
651
+ if (kind === "redirect-domain") {
652
+ return 0;
653
+ }
654
+ if (kind === "landing-param") {
655
+ return 1;
656
+ }
657
+ if (kind === "cookie") {
658
+ return 2;
659
+ }
660
+ return 3;
661
+ }
662
+ function paramRuleMatches(rule, params) {
663
+ return rule.anyOf.some(
664
+ (group) => group.allOf.every((matcher) => paramMatcherMatches(matcher, params))
665
+ );
666
+ }
667
+ function paramMatcherMatches(matcher, params) {
668
+ const values = params.getAll(matcher.name);
669
+ const mode = matcher.match ?? (matcher.value === void 0 ? "exists" : "equals");
670
+ if (mode === "exists") {
671
+ return values.length > 0;
672
+ }
673
+ const expectedValue = matcher.value;
674
+ if (expectedValue === void 0) {
675
+ return false;
676
+ }
677
+ if (mode === "equals") {
678
+ return values.some((value) => value === expectedValue);
679
+ }
680
+ return values.some((value) => value.includes(expectedValue));
681
+ }
682
+ function cookieRuleMatches(rule, cookieNames) {
683
+ if (rule.match === "exact") {
684
+ return cookieNames.some((name) => name === rule.name);
685
+ }
686
+ return cookieNames.some((name) => name.includes(rule.name));
687
+ }
688
+ function initiatorRuleMatches(rule, signals, advertiserHost) {
689
+ return classifyReferrer(signals, advertiserHost) === rule.referrerClass;
690
+ }
691
+ function hasScopedSelfExemption(currentUrl, selfPatterns, policy) {
692
+ return (selfPatterns ?? []).some((pattern) => {
693
+ const scopedToPolicy = pattern.policyId === policy.id;
694
+ const scopedToNetwork = pattern.networkId === policy.network.id;
695
+ return (scopedToPolicy || scopedToNetwork) && paramMatcherMatches(pattern, currentUrl.searchParams);
696
+ });
697
+ }
698
+ function hasUnscopedSelfExemption(currentUrl, selfPatterns) {
699
+ return (selfPatterns ?? []).some(
700
+ (pattern) => pattern.policyId === void 0 && pattern.networkId === void 0 && paramMatcherMatches(pattern, currentUrl.searchParams)
701
+ );
702
+ }
703
+ function domainSuffixMatches2(host, pattern) {
704
+ const normalizedHost = host.toLowerCase().replace(/\.$/, "");
705
+ const normalizedPattern = pattern.toLowerCase().replace(/^\./, "").replace(/\.$/, "");
706
+ return normalizedHost === normalizedPattern || normalizedHost.endsWith(`.${normalizedPattern}`);
707
+ }
708
+ function describeParamRule(rule) {
709
+ return rule.anyOf.map(
710
+ (group) => group.allOf.map((matcher) => {
711
+ const mode = matcher.match ?? (matcher.value === void 0 ? "exists" : "equals");
712
+ return matcher.value === void 0 ? `${matcher.name}:${mode}` : `${matcher.name}:${mode}:${matcher.value}`;
713
+ }).join("&")
714
+ ).join("|");
715
+ }
716
+ function parseUrl(value) {
717
+ try {
718
+ return new URL(value);
719
+ } catch {
720
+ return void 0;
721
+ }
722
+ }
723
+ function hostFromUrl(value) {
724
+ return parseUrl(value)?.hostname.toLowerCase();
725
+ }
726
+
727
+ // src/session.ts
728
+ var StanddownSession = class {
729
+ #store;
730
+ #auditLog;
731
+ #maxAuditEntries;
732
+ #readOnlyAuditLog = [];
733
+ constructor(store, opts) {
734
+ this.#store = store;
735
+ this.#auditLog = opts?.auditLog ?? true;
736
+ this.#maxAuditEntries = Math.max(0, opts?.maxAuditEntries ?? 1e3);
737
+ }
738
+ async ingest(signals, policies) {
739
+ const advertiserHost = hostFromUrl2(signals.url);
740
+ try {
741
+ validatePolicies(policies);
742
+ } catch (error) {
743
+ return this.#failClosedWithAudit(
744
+ signals.now,
745
+ "ingest",
746
+ advertiserHost,
747
+ `malformed-policy: ${messageFromError2(error)}`
748
+ );
749
+ }
750
+ const detection = detect(signals, policies);
751
+ if (detection.failClosedReason) {
752
+ return this.#failClosedWithAudit(
753
+ signals.now,
754
+ "ingest",
755
+ advertiserHost,
756
+ detection.failClosedReason,
757
+ detection
758
+ );
759
+ }
760
+ return this.#withState(signals.now, (state) => {
761
+ pruneExpiredSessions(state, signals.now);
762
+ if (detection.strongest) {
763
+ const matchedPolicies = policiesForDetection(policies, detection);
764
+ if (matchedPolicies.length === 0) {
765
+ return {
766
+ decision: failClosedDecision("matched-policy-missing"),
767
+ detection
768
+ };
769
+ }
770
+ const record = upsertSessionRecord(
771
+ state,
772
+ detection.strongest.advertiserHost,
773
+ detection.strongest.policyId,
774
+ matchedPolicies,
775
+ signals.now
776
+ );
777
+ return {
778
+ decision: decisionFromRecord(record, signals.now, {
779
+ reason: detection.strongest.reason,
780
+ referrerClass: classifyReferrer(signals, record.advertiserHost)
781
+ }),
782
+ detection
783
+ };
784
+ }
785
+ const activeDecision = advertiserHost ? activeDecisionForHost(state, advertiserHost, signals.now) : void 0;
786
+ return {
787
+ decision: activeDecision ?? {
788
+ standDown: false,
789
+ reason: detection.selfMatch ? "self-exempted-no-active-standdown" : "no-active-standdown",
790
+ behaviors: [],
791
+ referrerClass: advertiserHost ? classifyReferrer(signals, advertiserHost) : "other"
792
+ },
793
+ detection
794
+ };
795
+ }, "ingest");
796
+ }
797
+ async shouldStandDown(advertiserHost, now) {
798
+ return this.#withState(
799
+ now,
800
+ (state) => {
801
+ pruneExpiredSessions(state, now);
802
+ return {
803
+ decision: activeDecisionForHost(state, advertiserHost, now) ?? {
804
+ standDown: false,
805
+ reason: "no-active-standdown",
806
+ behaviors: []
807
+ }
808
+ };
809
+ },
810
+ "shouldStandDown",
811
+ normalizeHost(advertiserHost),
812
+ { persist: false }
813
+ );
814
+ }
815
+ async recordActivity(now) {
816
+ await this.#withState(
817
+ now,
818
+ (state) => {
819
+ pruneExpiredSessions(state, now);
820
+ for (const record of Object.values(state.sessions)) {
821
+ if (record.sessionRule === "inactivity-window") {
822
+ record.lastActivityAt = now;
823
+ const expiresAt = computeExpiresAt(record);
824
+ if (expiresAt !== void 0) {
825
+ record.expiresAt = expiresAt;
826
+ }
827
+ }
828
+ }
829
+ return {
830
+ decision: {
831
+ standDown: false,
832
+ reason: "activity-recorded",
833
+ behaviors: []
834
+ }
835
+ };
836
+ },
837
+ "recordActivity"
838
+ );
839
+ }
840
+ async exportAuditLog() {
841
+ const state = await this.#loadState();
842
+ return trimAuditLog(
843
+ [...state.auditLog, ...this.#readOnlyAuditLog],
844
+ this.#maxAuditEntries
845
+ ).map(cloneAuditEntry);
846
+ }
847
+ async #withState(now, fn, action, advertiserHost, opts = {}) {
848
+ let state;
849
+ try {
850
+ state = await this.#loadState();
851
+ } catch {
852
+ return failClosedDecision("store-error");
853
+ }
854
+ const result = fn(state);
855
+ if (this.#auditLog) {
856
+ const entry = auditEntry({
857
+ time: now,
858
+ action,
859
+ advertiserHost: advertiserHost ?? result.detection?.strongest?.advertiserHost,
860
+ detection: result.detection,
861
+ decision: result.decision
862
+ });
863
+ if (opts.persist === false) {
864
+ appendAuditEntry(this.#readOnlyAuditLog, entry, this.#maxAuditEntries);
865
+ } else {
866
+ appendAuditEntry(state.auditLog, entry, this.#maxAuditEntries);
867
+ }
868
+ }
869
+ if (opts.persist === false) {
870
+ return result.decision;
871
+ }
872
+ try {
873
+ await this.#store.save(state);
874
+ } catch {
875
+ return failClosedDecision("store-error");
876
+ }
877
+ return result.decision;
878
+ }
879
+ async #failClosedWithAudit(now, action, advertiserHost, reason, detection) {
880
+ const decision = failClosedDecision(reason);
881
+ if (!this.#auditLog) {
882
+ return decision;
883
+ }
884
+ try {
885
+ const state = await this.#loadState();
886
+ appendAuditEntry(
887
+ state.auditLog,
888
+ auditEntry({
889
+ time: now,
890
+ action,
891
+ advertiserHost,
892
+ detection,
893
+ decision
894
+ }),
895
+ this.#maxAuditEntries
896
+ );
897
+ await this.#store.save(state);
898
+ } catch {
899
+ return failClosedDecision("store-error");
900
+ }
901
+ return decision;
902
+ }
903
+ async #loadState() {
904
+ return await this.#store.load() ?? { sessions: {}, auditLog: [] };
905
+ }
906
+ };
907
+ function appendAuditEntry(auditLog, entry, maxEntries) {
908
+ if (maxEntries === 0) {
909
+ auditLog.length = 0;
910
+ return;
911
+ }
912
+ auditLog.push(entry);
913
+ if (auditLog.length > maxEntries) {
914
+ auditLog.splice(0, auditLog.length - maxEntries);
915
+ }
916
+ }
917
+ function trimAuditLog(auditLog, maxEntries) {
918
+ if (maxEntries === 0) {
919
+ return [];
920
+ }
921
+ return auditLog.slice(Math.max(0, auditLog.length - maxEntries));
922
+ }
923
+ function upsertSessionRecord(state, advertiserHost, primaryPolicyId, policies, now) {
924
+ const key = normalizeHost(advertiserHost);
925
+ const existing = state.sessions[key];
926
+ const startedAt = existing?.startedAt ?? now;
927
+ const lastActivityAt = now;
928
+ const sessionRule = existing?.sessionRule === "session-or-min" || policies.some((policy) => policy.standdown.sessionRule === "session-or-min") ? "session-or-min" : "inactivity-window";
929
+ const baseRecord = {
930
+ advertiserHost: key,
931
+ policyId: primaryPolicyId,
932
+ startedAt,
933
+ lastActivityAt,
934
+ sessionRule,
935
+ minDurationMs: Math.max(
936
+ existing?.minDurationMs ?? 0,
937
+ ...policies.map((policy) => policy.standdown.minDurationMs)
938
+ ),
939
+ behaviors: unionBehaviors([
940
+ ...existing?.behaviors ?? [],
941
+ ...policies.flatMap((policy) => policy.standdown.behaviors)
942
+ ])
943
+ };
944
+ if (sessionRule === "inactivity-window") {
945
+ const inactivityMs = Math.max(
946
+ existing?.inactivityMs ?? 0,
947
+ ...policies.flatMap(
948
+ (policy) => policy.standdown.inactivityMs === void 0 ? [] : [policy.standdown.inactivityMs]
949
+ )
950
+ );
951
+ if (inactivityMs > 0) {
952
+ baseRecord.inactivityMs = inactivityMs;
953
+ }
954
+ }
955
+ const computedExpiresAt = computeExpiresAt(baseRecord);
956
+ if (computedExpiresAt !== void 0) {
957
+ baseRecord.expiresAt = Math.max(existing?.expiresAt ?? 0, computedExpiresAt);
958
+ }
959
+ state.sessions[key] = baseRecord;
960
+ return baseRecord;
961
+ }
962
+ function policiesForDetection(policies, detection) {
963
+ if (detection.strongest === void 0) {
964
+ return [];
965
+ }
966
+ const matchingPolicyIds = new Set(
967
+ detection.matched.filter(
968
+ (match) => match.advertiserHost === detection.strongest?.advertiserHost
969
+ ).map((match) => match.policyId)
970
+ );
971
+ return policies.filter((policy) => matchingPolicyIds.has(policy.id));
972
+ }
973
+ function unionBehaviors(behaviors) {
974
+ return [...new Set(behaviors)];
975
+ }
976
+ function activeDecisionForHost(state, advertiserHost, now) {
977
+ const record = state.sessions[normalizeHost(advertiserHost)];
978
+ if (!record || !isActive(record, now)) {
979
+ return void 0;
980
+ }
981
+ return decisionFromRecord(record, now);
982
+ }
983
+ function decisionFromRecord(record, now, overrides) {
984
+ const expiresAt = computeExpiresAt(record);
985
+ const decision = {
986
+ standDown: true,
987
+ policyId: record.policyId,
988
+ reason: overrides?.reason ?? `active-standdown:${record.policyId}`,
989
+ behaviors: [...record.behaviors]
990
+ };
991
+ if (expiresAt !== void 0) {
992
+ decision.expiresAt = expiresAt;
993
+ }
994
+ if (overrides?.referrerClass !== void 0) {
995
+ decision.referrerClass = overrides.referrerClass;
996
+ }
997
+ if (!isActive({ ...record, ...expiresAt === void 0 ? {} : { expiresAt } }, now)) {
998
+ return {
999
+ standDown: false,
1000
+ reason: "standdown-expired",
1001
+ behaviors: []
1002
+ };
1003
+ }
1004
+ return decision;
1005
+ }
1006
+ function computeExpiresAt(record) {
1007
+ if (record.sessionRule === "inactivity-window" && record.inactivityMs !== void 0) {
1008
+ return Math.max(
1009
+ record.startedAt + record.minDurationMs,
1010
+ record.lastActivityAt + record.inactivityMs
1011
+ );
1012
+ }
1013
+ return record.expiresAt;
1014
+ }
1015
+ function isActive(record, now) {
1016
+ if (record.sessionRule === "session-or-min") {
1017
+ return true;
1018
+ }
1019
+ const expiresAt = computeExpiresAt(record);
1020
+ return expiresAt === void 0 ? false : now < expiresAt;
1021
+ }
1022
+ function pruneExpiredSessions(state, now) {
1023
+ for (const [host, record] of Object.entries(state.sessions)) {
1024
+ if (!isActive(record, now)) {
1025
+ delete state.sessions[host];
1026
+ }
1027
+ }
1028
+ }
1029
+ function failClosedDecision(reason) {
1030
+ return {
1031
+ standDown: true,
1032
+ reason,
1033
+ behaviors: [
1034
+ "suppress-prompts",
1035
+ "no-cookie-write",
1036
+ "no-redirect",
1037
+ "no-background-tracking"
1038
+ ]
1039
+ };
1040
+ }
1041
+ function auditEntry(value) {
1042
+ const entry = {
1043
+ time: value.time,
1044
+ action: value.action
1045
+ };
1046
+ if (value.advertiserHost !== void 0) {
1047
+ entry.advertiserHost = value.advertiserHost;
1048
+ }
1049
+ if (value.detection !== void 0) {
1050
+ entry.detection = value.detection;
1051
+ }
1052
+ if (value.decision !== void 0) {
1053
+ entry.decision = value.decision;
1054
+ }
1055
+ return entry;
1056
+ }
1057
+ function cloneAuditEntry(entry) {
1058
+ const cloned = {
1059
+ time: entry.time,
1060
+ action: entry.action
1061
+ };
1062
+ if (entry.advertiserHost !== void 0) {
1063
+ cloned.advertiserHost = entry.advertiserHost;
1064
+ }
1065
+ if (entry.detection !== void 0) {
1066
+ const detection = {
1067
+ matched: entry.detection.matched.map((match) => ({ ...match })),
1068
+ selfMatch: entry.detection.selfMatch
1069
+ };
1070
+ if (entry.detection.strongest !== void 0) {
1071
+ detection.strongest = { ...entry.detection.strongest };
1072
+ }
1073
+ if (entry.detection.failClosedReason !== void 0) {
1074
+ detection.failClosedReason = entry.detection.failClosedReason;
1075
+ }
1076
+ cloned.detection = detection;
1077
+ }
1078
+ if (entry.decision !== void 0) {
1079
+ cloned.decision = {
1080
+ ...entry.decision,
1081
+ behaviors: [...entry.decision.behaviors]
1082
+ };
1083
+ }
1084
+ return cloned;
1085
+ }
1086
+ function normalizeHost(host) {
1087
+ return host.toLowerCase().replace(/\.$/, "");
1088
+ }
1089
+ function hostFromUrl2(value) {
1090
+ try {
1091
+ return normalizeHost(new URL(value).hostname);
1092
+ } catch {
1093
+ return void 0;
1094
+ }
1095
+ }
1096
+ function messageFromError2(error) {
1097
+ return error instanceof Error ? error.message : String(error);
1098
+ }
1099
+
1100
+ // src/stores.ts
1101
+ var DEFAULT_STATE_KEY = "standdown:state:v1";
1102
+ var DEFAULT_SESSION_ID_KEY = "standdown:session-id:v1";
1103
+ var ChromeLocalStateStore = class {
1104
+ #localStorage;
1105
+ #sessionStorage;
1106
+ #runtime;
1107
+ #key;
1108
+ #identityKey;
1109
+ #fixedSessionId;
1110
+ #now;
1111
+ #createSessionId;
1112
+ #cachedSessionId;
1113
+ constructor(localStorage, opts = {}) {
1114
+ this.#localStorage = localStorage;
1115
+ this.#sessionStorage = opts.sessionStorage;
1116
+ this.#runtime = opts.runtime;
1117
+ this.#key = opts.key ?? DEFAULT_STATE_KEY;
1118
+ this.#identityKey = opts.identityKey ?? DEFAULT_SESSION_ID_KEY;
1119
+ this.#fixedSessionId = opts.sessionId;
1120
+ this.#now = opts.now ?? Date.now;
1121
+ this.#createSessionId = opts.createSessionId ?? createSessionId;
1122
+ }
1123
+ async load() {
1124
+ const sessionId = await this.#currentSessionId();
1125
+ const items = await chromeGet(this.#localStorage, this.#key, this.#runtime);
1126
+ const envelope = items[this.#key];
1127
+ if (envelope === void 0) {
1128
+ return void 0;
1129
+ }
1130
+ const persisted = parsePersistedState(envelope);
1131
+ const state = cloneState(persisted.state);
1132
+ if (sessionIdentityMismatch(persisted.sessionId, sessionId)) {
1133
+ return dropSessionBoundRecords(state, this.#now());
1134
+ }
1135
+ return state;
1136
+ }
1137
+ async save(state) {
1138
+ const sessionId = await this.#currentSessionId();
1139
+ const envelope = {
1140
+ schemaVersion: 1,
1141
+ state: cloneState(state)
1142
+ };
1143
+ if (sessionId !== void 0) {
1144
+ envelope.sessionId = sessionId;
1145
+ }
1146
+ await chromeSet(
1147
+ this.#localStorage,
1148
+ {
1149
+ [this.#key]: envelope
1150
+ },
1151
+ this.#runtime
1152
+ );
1153
+ }
1154
+ async #currentSessionId() {
1155
+ if (this.#fixedSessionId !== void 0) {
1156
+ return this.#fixedSessionId;
1157
+ }
1158
+ if (this.#cachedSessionId !== void 0) {
1159
+ return this.#cachedSessionId;
1160
+ }
1161
+ if (this.#sessionStorage === void 0) {
1162
+ return void 0;
1163
+ }
1164
+ const items = await chromeGet(
1165
+ this.#sessionStorage,
1166
+ this.#identityKey,
1167
+ this.#runtime
1168
+ );
1169
+ const existing = items[this.#identityKey];
1170
+ if (typeof existing === "string" && existing.length > 0) {
1171
+ this.#cachedSessionId = existing;
1172
+ return existing;
1173
+ }
1174
+ const next = this.#createSessionId();
1175
+ await chromeSet(
1176
+ this.#sessionStorage,
1177
+ { [this.#identityKey]: next },
1178
+ this.#runtime
1179
+ );
1180
+ this.#cachedSessionId = next;
1181
+ return next;
1182
+ }
1183
+ };
1184
+ function dropSessionBoundRecords(state, now) {
1185
+ const next = cloneState(state);
1186
+ for (const [host, record] of Object.entries(next.sessions)) {
1187
+ if (record.sessionRule === "session-or-min" && now >= record.startedAt + record.minDurationMs) {
1188
+ delete next.sessions[host];
1189
+ }
1190
+ }
1191
+ return next;
1192
+ }
1193
+ function createSessionId() {
1194
+ const cryptoApi = globalThis.crypto;
1195
+ if (typeof cryptoApi?.randomUUID === "function") {
1196
+ return cryptoApi.randomUUID();
1197
+ }
1198
+ return `session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1199
+ }
1200
+ function parsePersistedState(value) {
1201
+ if (!isRecord(value)) {
1202
+ throw new Error("invalid persisted state");
1203
+ }
1204
+ const sessionId = value.sessionId;
1205
+ const savedAt = value.savedAt ?? 0;
1206
+ const state = value.state;
1207
+ if (sessionId !== void 0 && typeof sessionId !== "string" || typeof savedAt !== "number") {
1208
+ throw new Error("invalid persisted state envelope");
1209
+ }
1210
+ return {
1211
+ sessionId,
1212
+ savedAt,
1213
+ state: parseState(state)
1214
+ };
1215
+ }
1216
+ function parseState(value) {
1217
+ if (!isRecord(value) || !isRecord(value.sessions) || !Array.isArray(value.auditLog)) {
1218
+ throw new Error("invalid standdown state");
1219
+ }
1220
+ const sessions = {};
1221
+ for (const [host, record] of Object.entries(value.sessions)) {
1222
+ sessions[host] = parseSessionRecord(record);
1223
+ }
1224
+ return {
1225
+ sessions,
1226
+ auditLog: value.auditLog.map(parseAuditEntry)
1227
+ };
1228
+ }
1229
+ function parseSessionRecord(value) {
1230
+ if (!isRecord(value)) {
1231
+ throw new Error("invalid session record");
1232
+ }
1233
+ const {
1234
+ advertiserHost,
1235
+ policyId,
1236
+ startedAt,
1237
+ lastActivityAt,
1238
+ expiresAt,
1239
+ sessionRule,
1240
+ minDurationMs,
1241
+ inactivityMs,
1242
+ behaviors
1243
+ } = value;
1244
+ if (typeof advertiserHost !== "string" || typeof policyId !== "string" || typeof startedAt !== "number" || typeof lastActivityAt !== "number" || expiresAt !== void 0 && typeof expiresAt !== "number" || sessionRule !== "session-or-min" && sessionRule !== "inactivity-window" || typeof minDurationMs !== "number" || inactivityMs !== void 0 && typeof inactivityMs !== "number" || !Array.isArray(behaviors) || !behaviors.every(isBehavior)) {
1245
+ throw new Error("invalid session record");
1246
+ }
1247
+ const record = {
1248
+ advertiserHost,
1249
+ policyId,
1250
+ startedAt,
1251
+ lastActivityAt,
1252
+ sessionRule,
1253
+ minDurationMs,
1254
+ behaviors
1255
+ };
1256
+ if (expiresAt !== void 0) {
1257
+ record.expiresAt = expiresAt;
1258
+ }
1259
+ if (inactivityMs !== void 0) {
1260
+ record.inactivityMs = inactivityMs;
1261
+ }
1262
+ return record;
1263
+ }
1264
+ function parseAuditEntry(value) {
1265
+ if (!isRecord(value)) {
1266
+ throw new Error("invalid audit entry");
1267
+ }
1268
+ const { time, action, advertiserHost, detection, decision } = value;
1269
+ if (typeof time !== "number" || action !== "ingest" && action !== "shouldStandDown" && action !== "recordActivity" && action !== "refresh" || advertiserHost !== void 0 && typeof advertiserHost !== "string") {
1270
+ throw new Error("invalid audit entry");
1271
+ }
1272
+ const entry = { time, action };
1273
+ if (advertiserHost !== void 0) {
1274
+ entry.advertiserHost = advertiserHost;
1275
+ }
1276
+ if (detection !== void 0) {
1277
+ entry.detection = detection;
1278
+ }
1279
+ if (decision !== void 0) {
1280
+ entry.decision = decision;
1281
+ }
1282
+ return entry;
1283
+ }
1284
+ function cloneState(state) {
1285
+ return {
1286
+ sessions: Object.fromEntries(
1287
+ Object.entries(state.sessions).map(([host, record]) => [
1288
+ host,
1289
+ cloneSessionRecord(record)
1290
+ ])
1291
+ ),
1292
+ auditLog: state.auditLog.map(cloneAuditEntry2)
1293
+ };
1294
+ }
1295
+ function cloneSessionRecord(record) {
1296
+ const next = {
1297
+ advertiserHost: record.advertiserHost,
1298
+ policyId: record.policyId,
1299
+ startedAt: record.startedAt,
1300
+ lastActivityAt: record.lastActivityAt,
1301
+ sessionRule: record.sessionRule,
1302
+ minDurationMs: record.minDurationMs,
1303
+ behaviors: [...record.behaviors]
1304
+ };
1305
+ if (record.expiresAt !== void 0) {
1306
+ next.expiresAt = record.expiresAt;
1307
+ }
1308
+ if (record.inactivityMs !== void 0) {
1309
+ next.inactivityMs = record.inactivityMs;
1310
+ }
1311
+ return next;
1312
+ }
1313
+ function cloneAuditEntry2(entry) {
1314
+ const next = {
1315
+ time: entry.time,
1316
+ action: entry.action
1317
+ };
1318
+ if (entry.advertiserHost !== void 0) {
1319
+ next.advertiserHost = entry.advertiserHost;
1320
+ }
1321
+ if (entry.detection !== void 0) {
1322
+ next.detection = {
1323
+ matched: entry.detection.matched.map((match) => ({ ...match })),
1324
+ selfMatch: entry.detection.selfMatch
1325
+ };
1326
+ if (entry.detection.strongest !== void 0) {
1327
+ next.detection.strongest = { ...entry.detection.strongest };
1328
+ }
1329
+ if (entry.detection.failClosedReason !== void 0) {
1330
+ next.detection.failClosedReason = entry.detection.failClosedReason;
1331
+ }
1332
+ }
1333
+ if (entry.decision !== void 0) {
1334
+ next.decision = {
1335
+ ...entry.decision,
1336
+ behaviors: [...entry.decision.behaviors]
1337
+ };
1338
+ }
1339
+ return next;
1340
+ }
1341
+ function chromeGet(storage, key, runtime) {
1342
+ return new Promise((resolve, reject) => {
1343
+ try {
1344
+ const result = storage.get(key, (items) => {
1345
+ const error = runtime?.lastError;
1346
+ if (error !== void 0) {
1347
+ reject(new Error(error.message ?? "chrome storage error"));
1348
+ return;
1349
+ }
1350
+ resolve(items ?? {});
1351
+ });
1352
+ if (isPromiseLike(result)) {
1353
+ result.then(resolve, reject);
1354
+ }
1355
+ } catch (error) {
1356
+ reject(error);
1357
+ }
1358
+ });
1359
+ }
1360
+ function chromeSet(storage, items, runtime) {
1361
+ return new Promise((resolve, reject) => {
1362
+ try {
1363
+ const result = storage.set(items, () => {
1364
+ const error = runtime?.lastError;
1365
+ if (error !== void 0) {
1366
+ reject(new Error(error.message ?? "chrome storage error"));
1367
+ return;
1368
+ }
1369
+ resolve();
1370
+ });
1371
+ if (isPromiseLike(result)) {
1372
+ result.then(resolve, reject);
1373
+ }
1374
+ } catch (error) {
1375
+ reject(error);
1376
+ }
1377
+ });
1378
+ }
1379
+ function isPromiseLike(value) {
1380
+ return isRecord(value) && typeof value.then === "function" && typeof value.catch === "function";
1381
+ }
1382
+ function isRecord(value) {
1383
+ return typeof value === "object" && value !== null;
1384
+ }
1385
+ function sessionIdentityMismatch(persistedSessionId, currentSessionId) {
1386
+ return persistedSessionId !== void 0 && currentSessionId !== void 0 && persistedSessionId !== currentSessionId;
1387
+ }
1388
+ function isBehavior(value) {
1389
+ return value === "suppress-prompts" || value === "no-cookie-write" || value === "no-redirect" || value === "no-background-tracking";
1390
+ }
1391
+
1392
+ // src/webext.ts
1393
+ var QUERY_MESSAGE_TYPE = "standdown:shouldStandDown";
1394
+ var FAIL_CLOSED_BEHAVIORS = [
1395
+ "suppress-prompts",
1396
+ "no-cookie-write",
1397
+ "no-redirect",
1398
+ "no-background-tracking"
1399
+ ];
1400
+ var DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1e3;
1401
+ var MAX_ADAPTER_AUDIT_ENTRIES = 1e3;
1402
+ function createStanddown(opts) {
1403
+ const chromeApi = opts.chrome ?? currentChrome();
1404
+ const now = opts.now ?? Date.now;
1405
+ const fetchImpl = opts.fetch ?? currentFetch();
1406
+ let activePolicies = opts.policies.map((policy) => clonePolicy2(policy));
1407
+ let refreshTimer;
1408
+ const store = opts.store ?? createChromeStore(chromeApi, {
1409
+ sessionId: opts.sessionId,
1410
+ now
1411
+ });
1412
+ const session = new StanddownSession(
1413
+ store,
1414
+ opts.auditLog === void 0 ? void 0 : { auditLog: opts.auditLog }
1415
+ );
1416
+ const redirectChains = /* @__PURE__ */ new Map();
1417
+ const redirectRequestIds = /* @__PURE__ */ new Map();
1418
+ const initiators = /* @__PURE__ */ new Map();
1419
+ const tabHosts = /* @__PURE__ */ new Map();
1420
+ const hasWebRequest = typeof chromeApi?.webRequest?.onBeforeRequest?.addListener === "function";
1421
+ const hasWebNavigation = typeof chromeApi?.webNavigation?.onCommitted?.addListener === "function";
1422
+ if (!hasWebNavigation) {
1423
+ throw new TypeError(
1424
+ "standdown/webext requires chrome.webNavigation.onCommitted"
1425
+ );
1426
+ }
1427
+ const mode = hasWebRequest ? "webRequest" : "webNavigation";
1428
+ const onBeforeRequest = (details) => {
1429
+ if (!isTopLevelTabRequest(details.tabId, details.type, details.frameId)) {
1430
+ return;
1431
+ }
1432
+ const existingRequestId = redirectRequestIds.get(details.tabId);
1433
+ const startsNewRequest = details.requestId !== void 0 && existingRequestId !== void 0 && details.requestId !== existingRequestId;
1434
+ const chain = startsNewRequest ? [] : redirectChains.get(details.tabId) ?? [];
1435
+ if (details.requestId !== void 0) {
1436
+ redirectRequestIds.set(details.tabId, details.requestId);
1437
+ }
1438
+ chain.push(details.url);
1439
+ redirectChains.set(details.tabId, chain.slice(-20));
1440
+ const initiator = details.initiator ?? details.originUrl;
1441
+ if (initiator !== void 0 && !initiators.has(details.tabId)) {
1442
+ initiators.set(details.tabId, initiator);
1443
+ }
1444
+ };
1445
+ const onCommitted = (details) => {
1446
+ if (details.tabId < 0 || (details.frameId ?? 0) !== 0) {
1447
+ return;
1448
+ }
1449
+ void ingestNavigation(details.tabId, details.url);
1450
+ };
1451
+ const onRemoved = (tabId) => {
1452
+ redirectChains.delete(tabId);
1453
+ redirectRequestIds.delete(tabId);
1454
+ initiators.delete(tabId);
1455
+ tabHosts.delete(tabId);
1456
+ };
1457
+ const onMessage = (message, sender, sendResponse) => {
1458
+ if (!isShouldStandDownMessage(message)) {
1459
+ return void 0;
1460
+ }
1461
+ void handleMessage(message, sender).then(sendResponse, () => {
1462
+ sendResponse({
1463
+ ok: false,
1464
+ decision: failClosedDecision2("message-handler-error")
1465
+ });
1466
+ });
1467
+ return true;
1468
+ };
1469
+ if (mode === "webRequest") {
1470
+ chromeApi?.webRequest?.onBeforeRequest?.addListener(
1471
+ onBeforeRequest,
1472
+ { urls: ["<all_urls>"], types: ["main_frame"] }
1473
+ );
1474
+ }
1475
+ if (hasWebNavigation) {
1476
+ chromeApi?.webNavigation?.onCommitted?.addListener(onCommitted);
1477
+ }
1478
+ chromeApi?.runtime?.onMessage?.addListener(onMessage);
1479
+ chromeApi?.tabs?.onRemoved?.addListener(onRemoved);
1480
+ if (opts.refresh !== void 0) {
1481
+ if (opts.refresh.intervalMs !== 0) {
1482
+ refreshTimer = setInterval(
1483
+ () => {
1484
+ void refreshNow();
1485
+ },
1486
+ opts.refresh.intervalMs ?? DEFAULT_REFRESH_INTERVAL_MS
1487
+ );
1488
+ }
1489
+ }
1490
+ async function ingestNavigation(tabId, url) {
1491
+ const host = hostFromUrl3(url);
1492
+ if (host !== void 0) {
1493
+ tabHosts.set(tabId, host);
1494
+ }
1495
+ const redirectChain = mode === "webRequest" ? redirectChains.get(tabId) : void 0;
1496
+ const initiator = initiators.get(tabId);
1497
+ redirectChains.delete(tabId);
1498
+ redirectRequestIds.delete(tabId);
1499
+ initiators.delete(tabId);
1500
+ const signals = navigationSignals({
1501
+ url,
1502
+ now: now(),
1503
+ redirectChain,
1504
+ initiator,
1505
+ selfPatterns: opts.selfPatterns,
1506
+ publisherSites: opts.publisherSites
1507
+ });
1508
+ return session.ingest(signals, activePolicies);
1509
+ }
1510
+ async function shouldStandDown(tabId, at = now()) {
1511
+ const host = tabHosts.get(tabId) ?? await hostForTab(chromeApi, tabId);
1512
+ if (host === void 0) {
1513
+ return failClosedDecision2("missing-tab-url");
1514
+ }
1515
+ return session.shouldStandDown(host, at);
1516
+ }
1517
+ async function shouldStandDownForUrl(url, at = now()) {
1518
+ const host = hostFromUrl3(url);
1519
+ if (host === void 0) {
1520
+ return failClosedDecision2("invalid-tab-url");
1521
+ }
1522
+ return session.shouldStandDown(host, at);
1523
+ }
1524
+ async function handleMessage(message, sender) {
1525
+ if (!isShouldStandDownMessage(message)) {
1526
+ return void 0;
1527
+ }
1528
+ const senderUrl = sender?.tab?.url ?? sender?.tab?.pendingUrl;
1529
+ const decision = message.url !== void 0 ? await shouldStandDownForUrl(message.url) : senderUrl !== void 0 ? await shouldStandDownForUrl(senderUrl) : await shouldStandDown(message.tabId ?? sender?.tab?.id ?? -1);
1530
+ return { ok: true, decision };
1531
+ }
1532
+ async function refreshNow() {
1533
+ if (opts.refresh === void 0) {
1534
+ return { ok: false, violation: "refresh-not-configured" };
1535
+ }
1536
+ if (fetchImpl === void 0) {
1537
+ const result = { ok: false, violation: "refresh-fetch-unavailable" };
1538
+ await auditRefresh(store, now(), result);
1539
+ return result;
1540
+ }
1541
+ try {
1542
+ const response = await fetchImpl(opts.refresh.url);
1543
+ if (!response.ok) {
1544
+ const result2 = {
1545
+ ok: false,
1546
+ violation: `refresh-fetch-failed:${response.status}`
1547
+ };
1548
+ await auditRefresh(store, now(), result2);
1549
+ return result2;
1550
+ }
1551
+ const bundle = await response.json();
1552
+ const verified = await verifyPolicyBundle(
1553
+ activePolicies,
1554
+ bundle,
1555
+ opts.refresh.publicKeyJwk
1556
+ );
1557
+ if (!verified.ok) {
1558
+ const result2 = { ok: false, violation: verified.violation };
1559
+ await auditRefresh(store, now(), result2);
1560
+ return result2;
1561
+ }
1562
+ activePolicies = verified.policies.map((policy) => clonePolicy2(policy));
1563
+ const result = { ok: true, applied: activePolicies.length };
1564
+ await auditRefresh(store, now(), result);
1565
+ return result;
1566
+ } catch (error) {
1567
+ const result = {
1568
+ ok: false,
1569
+ violation: `refresh-error:${messageFromError3(error)}`
1570
+ };
1571
+ await auditRefresh(store, now(), result);
1572
+ return result;
1573
+ }
1574
+ }
1575
+ function getPolicies() {
1576
+ return activePolicies.map((policy) => clonePolicy2(policy));
1577
+ }
1578
+ function dispose() {
1579
+ if (refreshTimer !== void 0) {
1580
+ clearInterval(refreshTimer);
1581
+ }
1582
+ chromeApi?.webRequest?.onBeforeRequest?.removeListener?.(onBeforeRequest);
1583
+ chromeApi?.webNavigation?.onCommitted?.removeListener?.(onCommitted);
1584
+ chromeApi?.runtime?.onMessage?.removeListener?.(onMessage);
1585
+ chromeApi?.tabs?.onRemoved?.removeListener?.(onRemoved);
1586
+ redirectChains.clear();
1587
+ redirectRequestIds.clear();
1588
+ initiators.clear();
1589
+ tabHosts.clear();
1590
+ }
1591
+ return {
1592
+ mode,
1593
+ session,
1594
+ store,
1595
+ ingestNavigation,
1596
+ shouldStandDown,
1597
+ shouldStandDownForUrl,
1598
+ refreshNow,
1599
+ getPolicies,
1600
+ handleMessage,
1601
+ dispose
1602
+ };
1603
+ }
1604
+ function createChromeStore(chromeApi, opts) {
1605
+ if (chromeApi?.storage?.local === void 0) {
1606
+ return new UnavailableStateStore();
1607
+ }
1608
+ const storeOptions = { now: opts.now };
1609
+ if (chromeApi.runtime !== void 0) {
1610
+ storeOptions.runtime = chromeApi.runtime;
1611
+ }
1612
+ if (chromeApi.storage.session !== void 0) {
1613
+ storeOptions.sessionStorage = chromeApi.storage.session;
1614
+ }
1615
+ if (opts.sessionId !== void 0) {
1616
+ storeOptions.sessionId = opts.sessionId;
1617
+ }
1618
+ return new ChromeLocalStateStore(chromeApi.storage.local, storeOptions);
1619
+ }
1620
+ function navigationSignals(value) {
1621
+ const signals = {
1622
+ url: value.url,
1623
+ now: value.now
1624
+ };
1625
+ if (value.redirectChain !== void 0 && value.redirectChain.length > 0) {
1626
+ signals.redirectChain = [...value.redirectChain];
1627
+ }
1628
+ if (value.initiator !== void 0) {
1629
+ signals.initiator = value.initiator;
1630
+ }
1631
+ if (value.selfPatterns !== void 0) {
1632
+ signals.selfPatterns = value.selfPatterns;
1633
+ }
1634
+ if (value.publisherSites !== void 0) {
1635
+ signals.publisherSites = value.publisherSites;
1636
+ }
1637
+ return signals;
1638
+ }
1639
+ async function hostForTab(chromeApi, tabId) {
1640
+ if (tabId < 0 || chromeApi?.tabs?.get === void 0) {
1641
+ return void 0;
1642
+ }
1643
+ return new Promise((resolve) => {
1644
+ try {
1645
+ const result = chromeApi.tabs?.get?.(tabId, (tab) => {
1646
+ resolve(hostFromUrl3(tab?.url ?? tab?.pendingUrl));
1647
+ });
1648
+ if (isPromiseLike2(result)) {
1649
+ result.then(
1650
+ (tab) => resolve(hostFromUrl3(tab?.url ?? tab?.pendingUrl)),
1651
+ () => resolve(void 0)
1652
+ );
1653
+ }
1654
+ } catch {
1655
+ resolve(void 0);
1656
+ }
1657
+ });
1658
+ }
1659
+ function isTopLevelTabRequest(tabId, type, frameId) {
1660
+ return tabId >= 0 && (type === void 0 || type === "main_frame") && (frameId ?? 0) === 0;
1661
+ }
1662
+ function isShouldStandDownMessage(message) {
1663
+ return typeof message === "object" && message !== null && message.type === QUERY_MESSAGE_TYPE;
1664
+ }
1665
+ function hostFromUrl3(value) {
1666
+ if (value === void 0) {
1667
+ return void 0;
1668
+ }
1669
+ try {
1670
+ return new URL(value).hostname.toLowerCase().replace(/\.$/, "");
1671
+ } catch {
1672
+ return void 0;
1673
+ }
1674
+ }
1675
+ function failClosedDecision2(reason) {
1676
+ return {
1677
+ standDown: true,
1678
+ reason,
1679
+ behaviors: [...FAIL_CLOSED_BEHAVIORS]
1680
+ };
1681
+ }
1682
+ function currentChrome() {
1683
+ return globalThis.chrome;
1684
+ }
1685
+ function currentFetch() {
1686
+ const fetchValue = globalThis.fetch;
1687
+ return typeof fetchValue === "function" ? fetchValue : void 0;
1688
+ }
1689
+ async function auditRefresh(store, time, result) {
1690
+ const state = await store.load() ?? emptyState();
1691
+ const entry = {
1692
+ time,
1693
+ action: "refresh",
1694
+ decision: {
1695
+ standDown: false,
1696
+ reason: result.ok ? `refresh-applied:${result.applied}` : `refresh-rejected:${result.violation}`,
1697
+ behaviors: []
1698
+ }
1699
+ };
1700
+ state.auditLog.push(entry);
1701
+ if (state.auditLog.length > MAX_ADAPTER_AUDIT_ENTRIES) {
1702
+ state.auditLog.splice(0, state.auditLog.length - MAX_ADAPTER_AUDIT_ENTRIES);
1703
+ }
1704
+ await store.save(state);
1705
+ }
1706
+ function emptyState() {
1707
+ return {
1708
+ sessions: {},
1709
+ auditLog: []
1710
+ };
1711
+ }
1712
+ function clonePolicy2(policy) {
1713
+ return JSON.parse(JSON.stringify(policy));
1714
+ }
1715
+ function isPromiseLike2(value) {
1716
+ return typeof value === "object" && value !== null && typeof value.then === "function";
1717
+ }
1718
+ function messageFromError3(error) {
1719
+ return error instanceof Error ? error.message : String(error);
1720
+ }
1721
+ var UnavailableStateStore = class {
1722
+ async load() {
1723
+ throw new Error("chrome.storage.local unavailable");
1724
+ }
1725
+ async save() {
1726
+ throw new Error("chrome.storage.local unavailable");
1727
+ }
1728
+ };
1729
+
1730
+ exports.ChromeLocalStateStore = ChromeLocalStateStore;
1731
+ exports.createStanddown = createStanddown;
1732
+ //# sourceMappingURL=webext.cjs.map
1733
+ //# sourceMappingURL=webext.cjs.map