@peax/consent-core 0.6.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.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # @peax/consent-core
2
+
3
+ [Peax Consent](../../README.md) · [Documentation](../../docs/README.md) · [React banner](../consent-banner-react/README.md)
4
+
5
+ Framework-independent consent validation, decisions, and an in-memory runtime with immutable snapshots and subscriptions.
6
+
7
+ > [!NOTE]
8
+ > The root API evaluates and records decisions; opt-in enforcement subpaths control explicitly guarded operations. The React banner does not depend on it; connect the banner through your application's callbacks.
9
+
10
+ ## Create consent
11
+
12
+ `createConsent(input)` is the only runtime export from the root entry. Explicit persistence is available separately through `/storage` and `/storage/localforage`; see [Consent storage](../../docs/consent-storage.md). Opt-in request enforcement is available through `/enforcement/fetch` and `/enforcement/axios`; see [Consent-controlled fetch](../../docs/consent-fetch.md) and [Consent-controlled Axios](../../docs/consent-axios.md). The consent factory validates decoded `{ currentAppPolicy, initialState }` data and returns `ValidationResult<ConsentRuntime>`: either `{ success: true, data }` or `{ success: false, issues }`. Both fields are required; malformed saved data is never replaced with an empty default.
13
+
14
+ Opt-in external script/iframe loading is available through `/enforcement/script` and `/enforcement/iframe`; see [Consent-controlled scripts and iframes](../../docs/consent-dom.md). These loaders own only their explicitly created nodes. Removing a script cannot undo its execution or shut down its SDK.
15
+
16
+ Additional subpaths provide [integration lifecycles and a narrowly scoped PostHog helper](../../docs/consent-integrations.md) (`/enforcement/integration`, `/enforcement/posthog`) plus [opt-in marked HTML](../../docs/consent-markup.md) (`/enforcement/markup`). These do not add root runtime exports. The separate [React consent bindings](../../docs/consent-react.md) depend on core; the published banner remains independent.
17
+
18
+ ```ts
19
+ import { createConsent } from '@peax/consent-core'
20
+ import type { ConsentPolicy } from '@peax/consent-core'
21
+
22
+ const currentAppPolicy: ConsentPolicy = {
23
+ purposes: [{
24
+ id: 'product-usage-analysis',
25
+ title: 'Product usage analysis',
26
+ description: 'Measure application usage.',
27
+ }],
28
+ uses: [{
29
+ id: 'product-analytics',
30
+ serviceId: 'posthog',
31
+ categoryId: 'analytics',
32
+ purposeId: 'product-usage-analysis',
33
+ consentVersion: 1,
34
+ }],
35
+ }
36
+
37
+ const created = createConsent({ currentAppPolicy, initialState: { choices: [] } })
38
+ if (!created.success) throw new Error(JSON.stringify(created.issues))
39
+ const consent = created.data
40
+
41
+ consent.evaluate('product-analytics')
42
+ // { allowed: false, reason: 'undecided' }
43
+
44
+ const unsubscribe = consent.subscribe(() => {
45
+ console.log(consent.getSnapshot(), consent.getMismatchedConsent())
46
+ })
47
+
48
+ // Capture the policy when showing the prompt, not when submitting it.
49
+ const userConsentedPolicy = consent.getSnapshot().currentAppPolicy
50
+
51
+ // Later, in response to an explicit applied user decision:
52
+ const result = consent.setCategoryConsent({
53
+ userConsentedPolicy,
54
+ categoryId: 'analytics',
55
+ decision: 'granted',
56
+ })
57
+ if (!result.success) console.error(result.issues)
58
+
59
+ consent.evaluate('product-analytics') // { allowed: true }
60
+ unsubscribe()
61
+ ```
62
+
63
+ | Method | Contract |
64
+ | --- | --- |
65
+ | `getSnapshot()` | Returns frozen `{ currentAppPolicy, state }`, retaining its identity until a committed change |
66
+ | `evaluate(useId)` | Evaluates against the active policy and latest state |
67
+ | `getMismatchedConsent()` | Returns cached, deeply frozen outstanding requirements in policy order |
68
+ | `setConsentDecision({ userConsentedPolicy, useId, decision })` | Validates and applies a per-use decision to the latest state at the presented version |
69
+ | `setCategoryConsent({ userConsentedPolicy, categoryId, decision })` | Validates and atomically applies all category choices from the presented policy |
70
+ | `setConsentDecisions({ userConsentedPolicy, decisions })` | Validates disjoint category/use targets and publishes the complete batch once; [submission/persistence contract](../../docs/consent-decisions.md) |
71
+ | `updatePolicy(input)` | Validates and replaces the active policy without changing saved choices |
72
+ | `restoreState(input)` | Validates and replaces all choices from trusted persistence, preserving policy and recorded versions |
73
+ | `subscribe(listener)` | Registers a synchronous invalidation callback and returns an idempotent unsubscribe function |
74
+
75
+ The root also exports these types: `ConsentDecision`, `ConsentPurpose`, `ConsentUse`, `ConsentPolicy`, `ConsentChoice`, `ConsentState`, `ConsentEvaluation`, `ConsentRequirement`, `ValidationIssue`, `ValidationResult<T>`, `CreateConsentOptions`, `ConsentRuntime`, `ConsentSnapshot`, `ConsentDecisionCommand`, `ConsentDecisionsCommand`, and `CategoryConsentCommand`.
76
+
77
+ Pure helpers, standalone validators, schemas, and the subscription helper are internal. Deep imports are unsupported. Implementation is organized by domain:
78
+
79
+ The root also exports `ConsentDecisionsCommand`. Optional window-local fetch/XHR interception lives in [`/enforcement/request-rules`](../../docs/consent-request-rules.md); explicit per-use guards remain the primary path.
80
+
81
+ | Domain | Ownership |
82
+ | --- | --- |
83
+ | `src/policy/` | `ConsentPolicy`, `ConsentPurpose`, declared `ConsentUse` values, their schemas, structural policy validation, and equality |
84
+ | `src/consent/` | User decisions/state, evaluation, requirements, decision commands, factory/snapshots/subscriptions, and consent-state validation |
85
+ | `src/storage/` | Internal codec, explicit persistence, and storage adapters |
86
+ | `src/enforcement/fetch/` | Explicit fetch guard, cancellation, errors, types and tests |
87
+ | `src/enforcement/axios/` | Explicit Axios guard, cancellation, errors, types and tests |
88
+ | `src/enforcement/script/` | External script loader, load readiness and owned-node cleanup |
89
+ | `src/enforcement/iframe/` | External iframe mounting and owned browsing-context removal |
90
+ | `src/enforcement/integration/` | Per-use SDK activation, teardown, status, and reload requirements |
91
+ | `src/enforcement/providers/posthog/` | Strict lazy PostHog and explicit existing-SDK adapters |
92
+ | `src/enforcement/markup/` | Inert marked-script/iframe scanning and dynamic observation |
93
+ | `src/enforcement/dom/` | Private shared DOM ownership/validation helpers and lifecycle tests |
94
+ | `src/enforcement/request/` | Private shared grant-period cancellation, permission checks, and disposal lifecycle for the request guards |
95
+ | `src/enforcement/_utils/` | Private shared constructor validation, signal retention, and the guard error base |
96
+ | `src/utils/validation.ts` | Generic schema primitives, `validateWithSchema`, `ValidationIssue`, and `ValidationResult` |
97
+
98
+ Each domain keeps its types, schemas, and tests locally. Policy does not import consent or storage. Shared validation in `utils/validation.ts` imports only Zod Mini. Consent depends on policy to evaluate user choices. The factory behavior is unchanged; the public validation types are renamed from `ConsentValidationIssue`/`ConsentValidationResult` to `ValidationIssue`/`ValidationResult` without aliases; `ConsentUse` describes an app declaration, while `ConsentChoice` describes a user's decision about it. Internal `arePoliciesEqual()` compares validated declarations structurally, including purpose/use order and every field; it does not determine scope compatibility or whether renewed consent is needed.
99
+
100
+ ## Permissions and outstanding decisions
101
+
102
+ ### Purpose definitions
103
+
104
+ Every policy declares `purposes: readonly ConsentPurpose[]` alongside `uses`. A purpose is `{ id, title, description }`; each use references one definition through `purposeId`. For example, PostHog and Umami audience-measurement uses can reference the same purpose while retaining distinct use IDs, versions, and choices. Sharing a purpose never shares a grant. Services performing independently gated activities need separate uses.
105
+
106
+ Purpose IDs are app-defined nonempty strings, not a closed built-in union. Custom and unused definitions are allowed; duplicate purpose IDs and undeclared references are rejected. Core does not infer purpose meaning, supply a provider catalog, or perform scope analysis. Human-readable descriptions remain necessary; an identifier is not a full disclosure or consent record.
107
+
108
+ Purpose-only edits update the snapshot and notify subscribers, but do not automatically change permission. When a purpose change alters consent scope, the integrator/platform must review every referencing use and supply the appropriate new use or `consentVersion`. Cosmetic wording changes can retain versions. Core cannot infer that distinction from text.
109
+
110
+ ### Permission checks
111
+
112
+ `getMismatchedConsent()` replaces `getRequirements()` without an alias. Despite the name, it includes undecided uses as well as version-mismatched grants.
113
+
114
+ | Condition | Evaluation |
115
+ | --- | --- |
116
+ | Use is absent from the active policy, even if a saved grant exists | `allowed: false`, `reason: 'unknown-use'` |
117
+ | Use has no choice | `allowed: false`, `reason: 'undecided'` |
118
+ | Choice is denied, regardless of version | `allowed: false`, `reason: 'denied'` |
119
+ | Grant version differs from the current use's version | `allowed: false`, `reason: 'consent-version-mismatch'` |
120
+ | Grant version matches | `allowed: true` |
121
+
122
+ Consent is recorded per use, not as blanket permission for a service or category. Separate uses of one service can have independent decisions and categories. `consentVersion` identifies one use's consent scope, independently of package versions or other uses. Both older and newer mismatched grants are blocked. Core does not calculate versions, infer semantic scope compatibility, or determine which changes legally require renewed consent.
123
+
124
+ A policy update alone does not invalidate unchanged uses. Wording-only changes can preserve a version when they preserve scope. A mismatch does not erase or retroactively revoke the saved decision; it means that decision does not authorize the current version. Core cannot partially authorize an expanded use: independently gated activities need separate uses.
125
+
126
+ `getMismatchedConsent()` returns readonly `ConsentRequirement` values with `useId`, `categoryId`, the current `consentVersion`, and reason `undecided` or `consent-version-mismatch`. Matching grants and all denials are omitted. Removed-use choices are ignored without deletion. Empty policies produce an empty list.
127
+
128
+ An empty result is not blanket permission: all-denied states also have no outstanding decisions. Use `evaluate()` for permission checks. Requirements are derived data, not persisted state, UI commands, or mandatory user actions. The application decides whether and when to prompt.
129
+
130
+ ## Decisions and policy changes
131
+
132
+ `currentAppPolicy` is the active app policy supplied at creation or through `updatePolicy()`. `userConsentedPolicy` is the policy scope shown for the submitted decision, including a denial; its name does not mean every use was granted. Capture it when opening the prompt. It is command input, not a second snapshot field or a stored historical policy. See the [consent terminology dictionary](../../docs/consent-terminology.md).
133
+
134
+ All mutation methods return `ValidationResult<ConsentSnapshot>`. Decisions must be exactly `granted` or `denied`; no choice means undecided.
135
+
136
+ Each decision must explicitly carry the policy shown to the user. The runtime validates that policy's structure, not proof that it was displayed or approved. It applies decisions to the latest state using the presented versions while leaving the active policy in place. If the policy changes while a prompt is open, accepting the older prompt cannot authorize unseen new uses or changed versions.
137
+
138
+ A category decision creates individual choices only for that category's uses in the presented policy. A future member does not inherit a grant or denial. Use `setConsentDecision()` when the prompt covers just one new or changed use. Never replay a previous acceptance after a policy change or treat UI drafts as applied decisions.
139
+
140
+ Decisions preserve unrelated and removed-use choices and existing choice order; new choices append in presented-policy order. Removed uses remain blocked by active-policy evaluation. `updatePolicy()` preserves every saved choice, including denials and mismatched versions.
141
+
142
+ `restoreState(unknown)` validates and atomically restores a trusted saved choices record into an existing runtime. It does not replace `currentAppPolicy`, change consent versions, perform I/O, or constitute a new user decision. Malformed records change nothing; equal choices preserve snapshot identity and do not notify. Existing guards respond to effective restorations through their existing subscriptions. The optional `/storage/sync` entry uses this method for [cross-tab synchronization](../../docs/consent-sync.md), with explicit save-then-notify and protection for unsaved local decisions.
143
+
144
+ This trusted-persistence method intentionally permits full state replacement, replacing the earlier no-arbitrary-replacement invariant. It retains unknown/removed-use choices for compatibility, but evaluation still blocks unknown uses and mismatched grants. Validation does not prove that the user made those choices; applications must provide trusted saved records and use the presented-policy decision commands for new user input.
145
+
146
+ Malformed commands or policies return issues without changing state, references, or notifications. Structurally valid commands targeting a use or category absent from their presented policy throw `Error` without changing the runtime.
147
+
148
+ ## Validation and snapshots
149
+
150
+ Creation validates both policy and initial state. Decision methods validate the entire command and presented policy; `updatePolicy()` validates the replacement policy. There are no public standalone validation methods.
151
+
152
+ - Objects and arrays must have the declared structure; unexpected fields are rejected.
153
+ - Policy/state identifiers, purpose titles, and descriptions must contain a non-whitespace character. Values are preserved exactly, without trimming, conversion, or coercion.
154
+ - Versions must be positive safe integers.
155
+ - Policy purpose IDs, policy use IDs, and state choice use IDs must each be unique, even for identical duplicates. Every use's `purposeId` must exactly match a declared purpose ID.
156
+ - Empty arrays are valid; missing arrays are not defaulted.
157
+ - Initial-state validation is independent of the policy and preserves removed-use and mismatched choices.
158
+
159
+ Issues contain a `code`, string/number `path` segments, and a `message`, not raw Zod errors or copies of rejected data. Nested creation issues retain `currentAppPolicy` or `initialState` path prefixes. Inputs must be decoded JSON-like data, not JSON strings or arbitrary executable objects; caller-defined throwing getters/proxies are not caught. Validation does not authenticate saved choices or establish consent evidence.
160
+
161
+ Snapshots and requirements are detached and frozen at every object/array level. The runtime's method object is frozen too. Caller inputs are neither mutated nor frozen. Methods are stable and work without `this` binding. Instances are independent; do not share a mutable instance across unrelated server requests or users.
162
+
163
+ Identical decision/version updates and structurally equivalent policies are no-ops: snapshot/requirements identity is retained and subscribers are not notified. Policy property insertion order is ignored; purpose/use array order and every definition/declaration field remain significant.
164
+
165
+ ## Subscriptions
166
+
167
+ Effective changes atomically publish the new snapshot and requirements before synchronous notification. A category update publishes once.
168
+
169
+ Subscribers are not called at registration or on reads. Duplicate registrations are independent. Notifications follow registration order; removed listeners are skipped, and listeners added during delivery join the next pass.
170
+
171
+ Reentrant mutations commit immediately and request another notification pass instead of recursively invoking listeners. Notifications mean "read the latest snapshot," not a history of every intermediate transition. A mutation's success result contains its own committed snapshot, which may precede a later reentrant change. Subscribers must not unconditionally write on every notification.
172
+
173
+ Synchronous listener failures do not roll back a commit or skip other active listeners. After notification passes finish, the originating mutation throws an `AggregateError` containing those failures. Inspect the current snapshot rather than assuming the mutation failed to apply. Subscribers handle their own asynchronous errors; callback return values are ignored.
174
+
175
+ ## Migration from standalone functions
176
+
177
+ Policies now require `purposes`, even when empty, and uses require `purposeId` instead of `purpose`. Move existing purpose text into a declared definition's `description`, supply a `title` and stable `id`, and reference that ID from the use. There are no legacy aliases or automatic conversions. Preserve existing use IDs and versions when this is only a structural migration of unchanged scope. The V1 stored choices format is unchanged and contains no purpose definitions.
178
+
179
+ Creation and snapshots now use `currentAppPolicy` instead of `policy`; decision commands use `userConsentedPolicy` instead of `presentedPolicy`. The old fields are rejected, with no compatibility aliases. `ConsentPolicy` and `updatePolicy()` retain their names; decision and evaluation behavior is unchanged.
180
+
181
+ Replace standalone evaluation and requirements calls with `consent.evaluate(useId)` and `consent.getMismatchedConsent()`. Replace standalone setters with the corresponding instance methods, passing `userConsentedPolicy` explicitly instead of `policy` and `state`. Pass decoded policy/state into `createConsent()`; creation and mutations perform validation.
182
+
183
+ The former evaluation reason `outdated` is `consent-version-mismatch`, with no legacy alias. Update exhaustive switches and reason comparisons.
184
+
185
+ ## Internal persisted format
186
+
187
+ The storage domain contains internal `encodeConsentState(unknown)` and `decodeConsentState(unknown)` helpers. These are not root exports or supported deep imports. `createConsentStorage(adapter)` exposes explicit async `load()`/`save(state)` through `@peax/consent-core/storage`, reusing this codec without changing the synchronous consent factory. The same subpath exports `createLocalStorageAdapter` and `createFetchStorageAdapter`; `/storage/localforage` exports `createLocalForageAdapter` with a lazy optional peer. Apps still decide when to load/save. See [Consent storage](../../docs/consent-storage.md) for examples, results, HTTP protocol, and failure/ordering guarantees.
188
+
189
+ Version 1 is a strict JSON record containing only `{ formatVersion: 1, state: { choices: [...] } }`. `formatVersion` identifies the wire structure, independently of each choice's `consentVersion`. Neither policy, derived permissions, nor requirements are persisted. This is preference state, not authenticated consent evidence or a policy history.
190
+
191
+ Encoding validates decoded JSON-like state before serializing. Decoding requires a string, parses JSON, checks the positive safe integer format version, then validates the original record using its strict version-specific schema. Both use the existing `ValidationResult` contract. Non-string input returns `invalid_type`; broken JSON returns `invalid_json`; an unsupported positive safe integer version returns `unsupported_format_version` at `['formatVersion']`. Missing/malformed versions and malformed V1 records return structural issues. Nested state issues retain their `state` prefix; rejected values and raw parsing errors are not returned.
192
+
193
+ Empty choices are valid. Duplicate choices, unexpected fields, and invalid decisions or versions are rejected without coercion, repair, or an empty-state fallback. Unknown format versions are not interpreted as V1. Absence of a stored record is a separate caller concern; the codec does not delete or overwrite failed records.
194
+
195
+ Successful decoding returns detached, deeply frozen state. Choice order, exact IDs, grants, denials, removed-use choices, and mismatched consent versions are preserved. Only evaluation against `currentAppPolicy` determines permission after restoration. The codec performs no I/O, runtime mutations, or subscription notifications. Expiry, migrations and automatic saving remain deferred. Cross-tab synchronization is separately opt-in under `/storage/sync`; V1's wire shape is unchanged and must remain stable if the in-memory model changes later.
196
+
197
+ ## Boundaries and package cost
198
+
199
+ Storage I/O and cross-tab synchronization are opt-in through separate storage entries; no Peax service is required. Explicit request blocking/cancellation, script/iframe loading, integration lifecycles/PostHog shutdown hooks, and marked-HTML scanning use their own subpaths. Unmarked resources remain outside DOM guard scope; optional window-local fetch/XHR interception is provided separately by `/enforcement/request-rules`. There is no automatic saving, automatic banner opening or React code in Core; the separate React bindings remain optional. The internal codec only converts state and JSON. Subscriptions are not an audit log. Tracker classification, legal conclusions and policy approval remain outside Core.
200
+
201
+ Zod Mini is an external runtime dependency of the factory. The reviewed 4 KB root ceiling excludes Zod; packed-consumer verification separately reports the complete factory bundle including dependencies. The former pure-function-only and standalone-validation bundle profiles no longer describe supported public APIs. No global Zod configuration or locale is installed.
202
+
203
+ Core now provides persistence adapters and framework-independent fetch/Axios guards. The additive pre-release `/enforcement/fetch` entry exports `createConsentFetch`, `ConsentFetchError`, and the readonly `ConsentFetch`, `ConsentFetchOptions`, and `ConsentFetchErrorReason` types. `/enforcement/axios` similarly exports `createConsentAxios`, `ConsentAxiosError`, `ConsentAxios`, `ConsentAxiosOptions`, and `ConsentAxiosErrorReason`; Axios `^1.20.0` is an optional external peer. The additive atomic submission API has a reviewed 4 KB root ceiling; optional request rules have a reviewed 6 KB closure ceiling. Size checks include shared output chunks per entry, with unchanged separate budgets for storage, localForage, dependency-free fetch (2.25 KB), and Axios enforcement (2.75 KB excluding Axios). Clean consumers verify SSR-safe imports with and without optional peers, declarations, browser persistence, and request enforcement. See the canonical [package contracts](../../docs/engineering/package-contracts.md) before extending the API.
204
+
205
+ The additive `/enforcement/script` and `/enforcement/iframe` entries expose `createConsentScript`/`createConsentIframe`, their error classes, and readonly guard, factory options, load/mount options, handle and error-reason types. They add no dependencies and have 3 KB/2.75 KB Brotli budgets respectively, including their shared DOM and internal chunks. Browser tests cover script execution/storage/SRI, cross-origin iframe activity, ownership cleanup and explicit script-shutdown limitations. Full usage and pre-release API notes are in [the DOM guard guide](../../docs/consent-dom.md).
206
+
207
+ Pre-release correctness fixes: integration/markup listener failures no longer interrupt lifecycle cleanup; failed markup startup rolls back all managed resources while retaining script reload/no-replay state. Single-use/category commands reject blank identifiers through validation issues instead of unknown-target exceptions. Public exports and existing size budgets are unchanged. See [integration notification handling](../../docs/consent-integrations.md) and [markup rollback](../../docs/consent-markup.md).
@@ -0,0 +1,16 @@
1
+ //#region src/enforcement/_utils/ConsentEnforcementError.ts
2
+ /**
3
+ * Shared shape for enforcement guard errors. Only the concrete classes are public: each subpath
4
+ * exports exactly one of them, so consumers keep their own `instanceof` and literal `name` type.
5
+ */
6
+ var ConsentEnforcementError = class extends Error {
7
+ useId;
8
+ reason;
9
+ constructor(message, useId, reason) {
10
+ super(message);
11
+ this.useId = useId;
12
+ this.reason = reason;
13
+ }
14
+ };
15
+ //#endregion
16
+ export { ConsentEnforcementError as t };
@@ -0,0 +1,7 @@
1
+ //#region src/storage/assertStorageIdentifier.ts
2
+ /** Adapter configuration is typed programmer input; persisted data uses the codec. */
3
+ function assertStorageIdentifier(value, label = "storage key or URL") {
4
+ if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`Expected a nonempty ${label}.`);
5
+ }
6
+ //#endregion
7
+ export { assertStorageIdentifier as t };
@@ -0,0 +1,93 @@
1
+ import { t as ConsentEnforcementError } from "./ConsentEnforcementError-Bn_ayWJI.js";
2
+ import { a as setReferrerPolicy, i as requireText, n as insertGuarded, o as setStringAttribute, r as requireParent, s as sourceUrl, t as createDomGuard } from "./createDomGuard-CuIEC0HP.js";
3
+ //#region src/enforcement/iframe/ConsentIframeError.ts
4
+ var ConsentIframeError = class extends ConsentEnforcementError {
5
+ name = "ConsentIframeError";
6
+ constructor(useId, reason) {
7
+ super(`Consent iframe blocked (${reason}).`, useId, reason);
8
+ }
9
+ };
10
+ //#endregion
11
+ //#region src/enforcement/iframe/iframePresentation.ts
12
+ function readIframePresentation({ title, width, height }) {
13
+ requireText(title, "iframe title");
14
+ for (const [name, value] of [["width", width], ["height", height]]) if (value !== void 0 && typeof value !== "string") throw new TypeError(`Expected a string ${name}.`);
15
+ return {
16
+ title,
17
+ width,
18
+ height
19
+ };
20
+ }
21
+ function applyIframePresentation(element, options) {
22
+ element.title = options.title;
23
+ for (const name of ["width", "height"]) {
24
+ const value = options[name];
25
+ if (value === void 0) element.removeAttribute(name);
26
+ else setStringAttribute(element, name, value);
27
+ }
28
+ }
29
+ //#endregion
30
+ //#region src/enforcement/iframe/createIframeElement.ts
31
+ function createIframeElement(options) {
32
+ const presentation = readIframePresentation(options);
33
+ const { src, parent, sandbox, allow, referrerPolicy, loading = "eager" } = options;
34
+ requireParent(parent);
35
+ if (loading !== "eager" && loading !== "lazy") throw new TypeError("Expected eager or lazy loading.");
36
+ const url = sourceUrl(src, parent);
37
+ const element = parent.ownerDocument.createElement("iframe");
38
+ applyIframePresentation(element, presentation);
39
+ setStringAttribute(element, "sandbox", sandbox);
40
+ setStringAttribute(element, "allow", allow);
41
+ setStringAttribute(element, "loading", loading);
42
+ setReferrerPolicy(element, referrerPolicy);
43
+ return {
44
+ element,
45
+ parent,
46
+ url
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region src/enforcement/iframe/createConsentIframe.ts
51
+ /** Owns only frames explicitly mounted through this guard; never scans or patches the page. */
52
+ function createConsentIframe({ consent, useId }) {
53
+ const owner = createDomGuard(consent, useId, (reason) => new ConsentIframeError(useId, reason));
54
+ return Object.freeze({
55
+ mount(options) {
56
+ const check = owner.begin();
57
+ const { element, parent, url } = createIframeElement(options);
58
+ check();
59
+ let removed = false;
60
+ const remove = () => {
61
+ if (removed) return;
62
+ removed = true;
63
+ forget();
64
+ element.remove();
65
+ };
66
+ const forget = owner.track(remove);
67
+ try {
68
+ insertGuarded(element, parent, url, check);
69
+ } catch (error) {
70
+ remove();
71
+ throw error;
72
+ }
73
+ const checkMounted = () => {
74
+ check();
75
+ if (removed) throw new ConsentIframeError(useId, "removed");
76
+ };
77
+ return Object.freeze({
78
+ element,
79
+ remove,
80
+ updatePresentation(options) {
81
+ checkMounted();
82
+ const presentation = readIframePresentation(options);
83
+ checkMounted();
84
+ applyIframePresentation(element, presentation);
85
+ checkMounted();
86
+ }
87
+ });
88
+ },
89
+ dispose: owner.dispose
90
+ });
91
+ }
92
+ //#endregion
93
+ export { ConsentIframeError as n, createConsentIframe as t };
@@ -0,0 +1,58 @@
1
+ //#region src/enforcement/_utils/signalRetention.ts
2
+ /**
3
+ * Keeps a composed AbortSignal reachable from the native objects it guards.
4
+ *
5
+ * WebKit can lose a composed signal after headers when JS no longer retains it, so the signal is
6
+ * weakly associated with its response/body instead of being held in a permanent per-guard request
7
+ * list. Weak keys never keep completed responses or streams alive, and the association is only
8
+ * ever written: it exists to retain, not to be read back.
9
+ *
10
+ * The observable guarantee (cancellation stays connected for response bodies, clones and readers
11
+ * after headers resolve) is verified by the browser fixtures, which cannot be reproduced in jsdom.
12
+ */
13
+ var signals = /* @__PURE__ */ new WeakMap();
14
+ /** Retains `signal` for as long as any supplied owner stays reachable. Absent owners are skipped. */
15
+ function retainSignal(signal, ...owners) {
16
+ for (const owner of owners) if (owner) signals.set(owner, signal);
17
+ }
18
+ //#endregion
19
+ //#region src/enforcement/request/createConsentRequestScope.ts
20
+ /** Private grant-period cancellation shared by request guards, not transport dispatch. */
21
+ function createConsentRequestScope(consent, useId, createError) {
22
+ let disposed = false;
23
+ let controller;
24
+ function cancel(error) {
25
+ const previous = controller;
26
+ controller = void 0;
27
+ previous?.abort(error);
28
+ }
29
+ function evaluate() {
30
+ const result = consent.evaluate(useId);
31
+ if (!result.allowed) cancel(createError(result.reason));
32
+ return result;
33
+ }
34
+ function assertAllowed() {
35
+ if (disposed) throw createError("disposed");
36
+ const result = evaluate();
37
+ if (!result.allowed) throw createError(result.reason);
38
+ }
39
+ const unsubscribe = consent.subscribe(() => {
40
+ if (!disposed) evaluate();
41
+ });
42
+ return {
43
+ assertAllowed,
44
+ begin() {
45
+ assertAllowed();
46
+ controller ??= new AbortController();
47
+ return controller.signal;
48
+ },
49
+ dispose() {
50
+ if (disposed) return;
51
+ disposed = true;
52
+ unsubscribe();
53
+ cancel(createError("disposed"));
54
+ }
55
+ };
56
+ }
57
+ //#endregion
58
+ export { retainSignal as n, createConsentRequestScope as t };