neural-consent 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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +45 -0
- package/src/consent.js +255 -0
- package/src/index.js +37 -0
- package/src/notices.js +158 -0
- package/src/record.js +329 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Le Vain Bey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# neural-consent
|
|
2
|
+
|
|
3
|
+
**Purpose-granular consent records with a tamper-evident local event log** for tools that read a neural or assistive signal on the user's own device. Zero dependencies. Runs in the browser and in Node.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install neural-consent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { ConsentManager, PURPOSES, DISCLAIMER } from 'neural-consent';
|
|
11
|
+
|
|
12
|
+
const consent = new ConsentManager({ storage: localStorage });
|
|
13
|
+
|
|
14
|
+
// The gate. Processing must not happen without this passing.
|
|
15
|
+
if (consent.isGranted(PURPOSES.ACQUIRE_SIGNAL.id)) {
|
|
16
|
+
startReadingSignal();
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What this is not
|
|
23
|
+
|
|
24
|
+
**This is not a compliance solution, and it does not claim to be one.** That sentence is the most important one in this README.
|
|
25
|
+
|
|
26
|
+
Whether a privacy statute applies to a given tool depends on facts about whoever *publishes* it — their revenue, how many people use it, and where those people live — not on what the code does. A consent screen cannot change that. The shipped `DISCLAIMER` says so in the words the tool should show its users, and a test asserts that it never contains the phrases *"CCPA compliant"*, *"GDPR compliant"*, *"HIPAA compliant"*, *"FDA cleared"*, *"certified"*, or *"privacy-compliant by architecture"*.
|
|
27
|
+
|
|
28
|
+
A comparable project markets itself as "privacy-compliant by architecture — not by policy." No library can make that claim. Architecture is evidence; compliance is a legal conclusion that depends on facts outside the code.
|
|
29
|
+
|
|
30
|
+
## The thing that is actually true
|
|
31
|
+
|
|
32
|
+
A tool that never transmits the signal **has no third party to share it with, nothing to sell, and no database to breach.** That is a verifiable statement about the software — you can open your browser's network panel and watch that nothing leaves.
|
|
33
|
+
|
|
34
|
+
This module records that as a machine-readable field rather than a promise:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
record.handling = {
|
|
38
|
+
storage: 'local-only',
|
|
39
|
+
recipients: [],
|
|
40
|
+
recipientsDeclaration: 'none — no transmission',
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The gate actually gates
|
|
45
|
+
|
|
46
|
+
A consent screen that records a decision but does not prevent processing is documentation, not consent. So the API is shaped as a gate, and `require()` **throws** rather than returning a falsy value — a silently-skipped permission check is how consent gets bypassed by accident:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
consent.require(PURPOSES.PROCESS_LOCALLY.id); // throws ConsentRequiredError
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Purposes are separate
|
|
53
|
+
|
|
54
|
+
Granularity is deliberate. Both ISO/IEC TS 27560 and the state laws require **purpose limitation**: consent to one thing must not silently authorise another. There is no bundled "I agree."
|
|
55
|
+
|
|
56
|
+
| Purpose | What it covers |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `acquire_signal` | reading the switch / gaze / EEG signal |
|
|
59
|
+
| `process_locally` | processing it on the device |
|
|
60
|
+
| `persist_locally` | remembering settings between sessions |
|
|
61
|
+
| `export` | the user saving their own data to a file |
|
|
62
|
+
|
|
63
|
+
There is deliberately no *analytics* or *improve our services* purpose — including a purpose you do not use is its own kind of dishonesty.
|
|
64
|
+
|
|
65
|
+
## Withdrawal is immediate and symmetric
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
consent.withdraw(PURPOSES.ACQUIRE_SIGNAL.id); // takes effect now
|
|
69
|
+
consent.withdrawAll(); // the "turn it all off" path
|
|
70
|
+
consent.isGranted(id); // false
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Withdrawal is recorded as an event, not as an amendment. Consent also **lapses**: grants carry a validity window, and a stale record is expired on load rather than silently remaining valid. `reaffirm()` renews deliberately.
|
|
74
|
+
|
|
75
|
+
## The event log is tamper-evident, not tamper-proof
|
|
76
|
+
|
|
77
|
+
Each event carries the hash of the previous one, so altering or removing a past entry breaks the chain and `verify()` reports where:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
const { ok, brokenAt } = record.verify();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Be clear about what this does and does not do.** Anyone with access to their own device can rewrite the whole file — that is inherent to local-first storage and pretending otherwise would be a lie. The chain's job is to make *accidental or partial* modification detectable, and to give a user a way to check their own record was not quietly changed by the tool.
|
|
84
|
+
|
|
85
|
+
The hash is FNV-1a (32-bit), not a cryptographic digest, because this runs in a browser with zero dependencies and WebCrypto's digest is async. 32 bits is the right size for detecting accidental modification. It is not a security hash.
|
|
86
|
+
|
|
87
|
+
## Structured per ISO/IEC TS 27560 — with documented departures
|
|
88
|
+
|
|
89
|
+
The field set follows ISO/IEC TS 27560:2023's consent record model (mandatory fields are public via the W3C Data Privacy Vocabularies and Controls CG guide, published 2026-02-15).
|
|
90
|
+
|
|
91
|
+
Two deliberate departures, both recorded in the output rather than silently omitted:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
handling.omittedFields = ['pii_controller_address', 'jurisdiction', 'authority_party']
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Those 27560 fields exist to let one organisation's consent records be read by another. A local-first tool exchanges nothing with anyone, so carrying a controller address would be theatre. A reviewer can see the decision instead of guessing at an absence.
|
|
98
|
+
|
|
99
|
+
Kept, because they earn their place: schema version, record id, subject id, the **notice version actually shown**, language, purpose, data types, a `neural` sensitivity flag, grant time, validity duration, the named withdrawal path, and the full event log.
|
|
100
|
+
|
|
101
|
+
## Accessibility is a requirement, not a nicety
|
|
102
|
+
|
|
103
|
+
This module serves tools built *for* disabled users. A consent flow that a screen-reader user cannot operate is self-refuting, so the demo is keyboard-operable with labelled controls and plain-language text. The notice model is layered — a concise key-information panel first, full detail available behind it — which is both the Common Rule's explicit model (45 CFR 46.116(a)(5)(i)) and what accessibility practice wants: a user should hear five lines before deciding, not a wall of text.
|
|
104
|
+
|
|
105
|
+
## Demo
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m http.server 8796
|
|
109
|
+
# open http://127.0.0.1:8796/demo/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Tests
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm test
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
28 tests, zero dependencies, `node:test`. The clock is injected everywhere, so validity-window and expiry behaviour is deterministic.
|
|
119
|
+
|
|
120
|
+
## What the research says about applicability
|
|
121
|
+
|
|
122
|
+
Verified against primary sources (September 2026):
|
|
123
|
+
|
|
124
|
+
- **Colorado HB24-1058** — in force 2024-08-07; adds neural data to the CPA's sensitive-data category requiring **opt-in** consent. Its "biological data" trigger only bites when the data is used for **identification**, which FPF notes "significantly narrow[s]" its scope for ordinary neural data.
|
|
125
|
+
- **California SB 1223** — operative 2025-01-01; neural data is sensitive personal information, giving a right to limit use.
|
|
126
|
+
- **Connecticut PA 25-113** (in force 2026-07-01) — note the sensitive-data trigger has **no consumer-count minimum**.
|
|
127
|
+
- **Vermont S.71** (signed 2026-06-16, effective 2028-01-01) — third threshold regime, 3,000 consumers' sensitive data.
|
|
128
|
+
- **Washington's My Health My Data Act** — **no thresholds at all, and a private right of action.** This is the jurisdiction where an overclaimed privacy policy creates the most exposure.
|
|
129
|
+
- **No federal neural-data statute.** The MIND Act (S.2925) would only direct an FTC study and has not advanced. FTC Act Section 5 remains the live federal instrument — which is precisely why *claims* matter, whether or not a privacy statute applies.
|
|
130
|
+
|
|
131
|
+
**No statute addresses on-device-only processing.** The argument that a tool which never receives data does not "collect" or "process" it is an interpretation, not settled law. This module is built so that the interpretation is at least easy to defend — and never asserted as established.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "neural-consent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Purpose-granular consent records with a tamper-evident local event log for neural-data tools. Local-first, zero dependencies. Structured on the ISO/IEC TS 27560 field model — and explicit about what it does not claim.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./record.js": "./src/record.js",
|
|
10
|
+
"./notices.js": "./src/notices.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test tests/consent.test.mjs"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"consent",
|
|
22
|
+
"neural-data",
|
|
23
|
+
"neurotech",
|
|
24
|
+
"bci",
|
|
25
|
+
"privacy",
|
|
26
|
+
"consent-record",
|
|
27
|
+
"iso-27560",
|
|
28
|
+
"local-first",
|
|
29
|
+
"accessibility",
|
|
30
|
+
"a11y"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "Le Vain Bey",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/LE-VAI/neural-consent.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/LE-VAI/neural-consent/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/LE-VAI/neural-consent#readme",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/consent.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent.js — the manager a tool actually talks to.
|
|
3
|
+
*
|
|
4
|
+
* The point of this layer is that the REST of the tool asks permission before
|
|
5
|
+
* doing anything with a signal. A consent screen that records a decision but
|
|
6
|
+
* does not gate behaviour is documentation, not consent. So the API is shaped
|
|
7
|
+
* as a gate:
|
|
8
|
+
*
|
|
9
|
+
* if (!consent.isGranted(PURPOSES.ACQUIRE_SIGNAL.id)) return; // do not read
|
|
10
|
+
*
|
|
11
|
+
* and `require()` throws rather than returning a falsy value, because a
|
|
12
|
+
* silently-skipped permission check is how consent gets bypassed by accident.
|
|
13
|
+
*
|
|
14
|
+
* PERSISTENCE IS OPTIONAL AND LOCAL. When a storage object is supplied the
|
|
15
|
+
* record is written to it; when it is not, everything lives in memory for the
|
|
16
|
+
* session. No network path exists in this module at all — there is no fetch,
|
|
17
|
+
* no beacon, no endpoint configuration. That absence is the feature: it is
|
|
18
|
+
* what makes the "recipients: none" declaration in the record true rather
|
|
19
|
+
* than aspirational.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { ConsentRecord, generateId } from './record.js';
|
|
23
|
+
import { PURPOSES, DISCLAIMER, noticeVersion, purposeById } from './notices.js';
|
|
24
|
+
|
|
25
|
+
export { PURPOSES, DISCLAIMER, noticeVersion, purposeById };
|
|
26
|
+
|
|
27
|
+
export class ConsentManager {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {object} [options.storage] an object with getItem/setItem/removeItem
|
|
31
|
+
* (e.g. window.localStorage). Omit for in-memory only.
|
|
32
|
+
* @param {string} [options.storageKey='neural-consent.record']
|
|
33
|
+
* @param {string} [options.recordId]
|
|
34
|
+
* @param {Function} [options.now] injected clock
|
|
35
|
+
* @param {number} [options.validDays=365] validity window for a grant
|
|
36
|
+
* @param {string} [options.language='en']
|
|
37
|
+
*/
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.storage = options.storage ?? null;
|
|
40
|
+
this.storageKey = options.storageKey ?? 'neural-consent.record';
|
|
41
|
+
this.validDays = options.validDays ?? 365;
|
|
42
|
+
this.language = options.language ?? 'en';
|
|
43
|
+
this.recordId = options.recordId ?? null;
|
|
44
|
+
this._now = options.now ?? (() => Date.now());
|
|
45
|
+
|
|
46
|
+
/** Change listeners — a UI subscribes to re-render on any decision. */
|
|
47
|
+
this._listeners = new Set();
|
|
48
|
+
|
|
49
|
+
this.record = this._load();
|
|
50
|
+
// Consent should lapse. Expiring on load means a record that sat unused
|
|
51
|
+
// past its window is correctly inert rather than silently still valid.
|
|
52
|
+
const lapsed = this.record.expireLapsed();
|
|
53
|
+
if (lapsed.length) this._emit({ type: 'expired', purposes: lapsed });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -- the gate --------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** True only while consent is currently held for the purpose. */
|
|
59
|
+
isGranted(purposeId) {
|
|
60
|
+
return this.record.isGranted(purposeId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Throw unless consent is held. Use this where processing must not happen
|
|
65
|
+
* without it — a falsy return that a caller forgets to check is how a gate
|
|
66
|
+
* silently stops gating.
|
|
67
|
+
*/
|
|
68
|
+
require(purposeId) {
|
|
69
|
+
if (!this.isGranted(purposeId)) {
|
|
70
|
+
throw new ConsentRequiredError(purposeId);
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every purpose currently granted. */
|
|
76
|
+
granted() {
|
|
77
|
+
return this.record.grantedPurposes();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Grant consent for a purpose. The notice version shown MUST be passed in
|
|
82
|
+
* by the UI — the manager will not guess it, because the record has to
|
|
83
|
+
* reflect the text the person actually read.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} purposeId
|
|
86
|
+
* @param {object} [meta]
|
|
87
|
+
* @param {string} meta.noticeVersion
|
|
88
|
+
*/
|
|
89
|
+
grant(purposeId, meta = {}) {
|
|
90
|
+
if (!purposeById(purposeId)) throw new Error(`unknown purpose: ${purposeId}`);
|
|
91
|
+
const event = this.record.decide(purposeId, 'given', {
|
|
92
|
+
noticeVersion: meta.noticeVersion ?? noticeVersion(),
|
|
93
|
+
language: meta.language ?? this.language,
|
|
94
|
+
validDays: meta.validDays ?? this.validDays,
|
|
95
|
+
dataTypes: meta.dataTypes ?? [],
|
|
96
|
+
withdrawalMethod: meta.withdrawalMethod,
|
|
97
|
+
});
|
|
98
|
+
this._persist();
|
|
99
|
+
this._emit({ type: 'grant', purposeId });
|
|
100
|
+
return event;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Decline a purpose. Refusal is recorded, not discarded. */
|
|
104
|
+
refuse(purposeId, meta = {}) {
|
|
105
|
+
if (!purposeById(purposeId)) throw new Error(`unknown purpose: ${purposeId}`);
|
|
106
|
+
const event = this.record.decide(purposeId, 'refused', {
|
|
107
|
+
noticeVersion: meta.noticeVersion ?? noticeVersion(),
|
|
108
|
+
language: meta.language ?? this.language,
|
|
109
|
+
});
|
|
110
|
+
this._persist();
|
|
111
|
+
this._emit({ type: 'refuse', purposeId });
|
|
112
|
+
return event;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Withdraw a grant. Immediate, symmetric, and logged. */
|
|
116
|
+
withdraw(purposeId, meta = {}) {
|
|
117
|
+
const event = this.record.withdraw(purposeId, meta);
|
|
118
|
+
if (event) {
|
|
119
|
+
this._persist();
|
|
120
|
+
this._emit({ type: 'withdraw', purposeId });
|
|
121
|
+
}
|
|
122
|
+
return event;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Re-affirm an existing grant, resetting its validity window.
|
|
127
|
+
*
|
|
128
|
+
* Colorado's rules require periodic consent refresh for sensitive data, and
|
|
129
|
+
* a validity window that can only expire — never renew — would leave a user
|
|
130
|
+
* re-consenting from scratch every cycle. Re-affirmation is the same
|
|
131
|
+
* decision, re-taken deliberately, recorded as its own event.
|
|
132
|
+
*/
|
|
133
|
+
reaffirm(purposeId, meta = {}) {
|
|
134
|
+
if (!purposeById(purposeId)) throw new Error(`unknown purpose: ${purposeId}`);
|
|
135
|
+
const existing = this.record.purposes[purposeId];
|
|
136
|
+
if (!existing) throw new Error(`no consent recorded for purpose: ${purposeId}`);
|
|
137
|
+
const event = this.record.decide(purposeId, 'given', {
|
|
138
|
+
noticeVersion: meta.noticeVersion ?? existing.noticeVersion,
|
|
139
|
+
language: meta.language ?? this.language,
|
|
140
|
+
validDays: meta.validDays ?? this.validDays,
|
|
141
|
+
dataTypes: meta.dataTypes ?? existing.dataTypes,
|
|
142
|
+
withdrawalMethod: meta.withdrawalMethod ?? existing.withdrawalMethod,
|
|
143
|
+
});
|
|
144
|
+
this._persist();
|
|
145
|
+
this._emit({ type: 'reaffirm', purposeId });
|
|
146
|
+
return event;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Withdraw everything at once — the "turn it all off" path. */
|
|
150
|
+
withdrawAll(meta = {}) {
|
|
151
|
+
const ids = this.granted();
|
|
152
|
+
const events = ids.map((id) => this.record.withdraw(id, meta)).filter(Boolean);
|
|
153
|
+
if (events.length) {
|
|
154
|
+
this._persist();
|
|
155
|
+
this._emit({ type: 'withdraw-all', purposes: ids });
|
|
156
|
+
}
|
|
157
|
+
return events;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// -- inspection ------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** State of one purpose, or null if never decided. */
|
|
163
|
+
stateOf(purposeId) {
|
|
164
|
+
return this.record.purposes[purposeId]?.state ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A snapshot the UI renders from. Includes the disclaimer, always. */
|
|
168
|
+
snapshot() {
|
|
169
|
+
const granted = this.record.grantedPurposes();
|
|
170
|
+
return {
|
|
171
|
+
recordId: this.record.recordId,
|
|
172
|
+
createdAt: this.record.createdAt,
|
|
173
|
+
// The gate's answer, so a UI does not have to re-derive it and get it
|
|
174
|
+
// wrong. A consent screen that shows the wrong state is worse than none.
|
|
175
|
+
granted,
|
|
176
|
+
purposes: Object.values(PURPOSES).map((p) => ({
|
|
177
|
+
id: p.id,
|
|
178
|
+
label: p.label,
|
|
179
|
+
keyText: p.keyText,
|
|
180
|
+
detailText: p.detailText,
|
|
181
|
+
version: p.version,
|
|
182
|
+
state: this.stateOf(p.id),
|
|
183
|
+
entry: this.record.purposes[p.id] ?? null,
|
|
184
|
+
})),
|
|
185
|
+
disclaimer: DISCLAIMER,
|
|
186
|
+
handling: this.record.handling,
|
|
187
|
+
chain: this.record.verify(),
|
|
188
|
+
eventCount: this.record.events.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Subscribe to decisions. Returns an unsubscribe function. */
|
|
193
|
+
onChange(fn) {
|
|
194
|
+
this._listeners.add(fn);
|
|
195
|
+
return () => this._listeners.delete(fn);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_emit(evt) {
|
|
199
|
+
for (const fn of this._listeners) {
|
|
200
|
+
try { fn(evt, this.snapshot()); } catch { /* a bad listener must not break the gate */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -- persistence -----------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
_load() {
|
|
207
|
+
if (this.storage) {
|
|
208
|
+
try {
|
|
209
|
+
const raw = this.storage.getItem(this.storageKey);
|
|
210
|
+
if (raw) {
|
|
211
|
+
const data = JSON.parse(raw);
|
|
212
|
+
return ConsentRecord.fromJSON(data, { now: this._now });
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// A corrupt record must not silently become a fresh one with
|
|
216
|
+
// everything granted. Fall through to an empty record, which grants
|
|
217
|
+
// nothing — the fail-closed direction.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return new ConsentRecord({ now: this._now, recordId: this.recordId ?? generateId() });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_persist() {
|
|
224
|
+
if (!this.storage) return;
|
|
225
|
+
try {
|
|
226
|
+
this.storage.setItem(this.storageKey, JSON.stringify(this.record.toJSON()));
|
|
227
|
+
} catch {
|
|
228
|
+
// Storage may be unavailable (private mode, quota). Consent stays valid
|
|
229
|
+
// for the session; it simply will not survive a reload.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Export the record — the portability path the user controls. */
|
|
234
|
+
export() {
|
|
235
|
+
return JSON.stringify(this.record.toJSON(), null, 2);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Erase everything, locally. */
|
|
239
|
+
erase() {
|
|
240
|
+
this.record = new ConsentRecord({ now: this._now });
|
|
241
|
+
if (this.storage) {
|
|
242
|
+
try { this.storage.removeItem(this.storageKey); } catch { /* nothing to remove */ }
|
|
243
|
+
}
|
|
244
|
+
this._emit({ type: 'erase' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Thrown when processing is attempted without consent. */
|
|
249
|
+
export class ConsentRequiredError extends Error {
|
|
250
|
+
constructor(purposeId) {
|
|
251
|
+
super(`consent required for: ${purposeId}`);
|
|
252
|
+
this.name = 'ConsentRequiredError';
|
|
253
|
+
this.purposeId = purposeId;
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neural-consent — local-first consent mechanics for neural-data tools.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS. A purpose-granular consent record with a tamper-evident local
|
|
5
|
+
* event log, structured on the ISO/IEC TS 27560 field model, for tools that
|
|
6
|
+
* read a neural or assistive signal on the user's own device.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS IS NOT. It is not a compliance solution, and it does not claim to
|
|
9
|
+
* be one. Whether a privacy statute applies to a given tool depends on facts
|
|
10
|
+
* about whoever publishes it — revenue, consumer counts, jurisdictions — not
|
|
11
|
+
* on what the code does. A consent screen cannot change that. The DISCLAIMER
|
|
12
|
+
* export states this in the words the tool should show.
|
|
13
|
+
*
|
|
14
|
+
* The honest position this module is built on: a tool that never transmits
|
|
15
|
+
* the signal has no third party to share with, nothing to sell, and no
|
|
16
|
+
* database to breach. That is a verifiable statement about the software. The
|
|
17
|
+
* record states it as a machine-readable field rather than as a promise.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
ConsentManager,
|
|
22
|
+
ConsentRequiredError,
|
|
23
|
+
PURPOSES,
|
|
24
|
+
DISCLAIMER,
|
|
25
|
+
noticeVersion,
|
|
26
|
+
purposeById,
|
|
27
|
+
} from './consent.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
ConsentRecord,
|
|
31
|
+
SCHEMA_VERSION,
|
|
32
|
+
STATES,
|
|
33
|
+
generateId,
|
|
34
|
+
fnv1a,
|
|
35
|
+
} from './record.js';
|
|
36
|
+
|
|
37
|
+
export { keyInformationText } from './notices.js';
|
package/src/notices.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notices.js — consent notice text and the layered presentation model.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS FILE IS FOR. Consent is only meaningful if the person actually
|
|
5
|
+
* understood what they agreed to. The legal basis for presenting it in layers
|
|
6
|
+
* is 45 CFR 46.116(a)(5)(i) (the Common Rule — the most rigorous consent model
|
|
7
|
+
* in US law): consent "must begin with a concise and focused presentation of
|
|
8
|
+
* the key information." That is a regulation explicitly asking for a
|
|
9
|
+
* short-form-first design, which is also what accessibility practice wants:
|
|
10
|
+
* a screen-reader user should hear five lines, not a wall.
|
|
11
|
+
*
|
|
12
|
+
* So every purpose carries TWO texts:
|
|
13
|
+
* - keyText: the concise, focused version, shown first
|
|
14
|
+
* - detailText: the full explanation, available on request
|
|
15
|
+
*
|
|
16
|
+
* Both are versioned. The version that was SHOWN is recorded in the consent
|
|
17
|
+
* record, because "what did the user actually agree to" is an evidentiary
|
|
18
|
+
* question and the answer must be reconstructable.
|
|
19
|
+
*
|
|
20
|
+
* TONE. The text here is written for the person, not for a lawyer. It states
|
|
21
|
+
* what happens, in plain words, at a reading level appropriate to someone
|
|
22
|
+
* using an assistive tool — many of whom are disabled, tired, or in a hurry.
|
|
23
|
+
* It does not use "we may" constructions that hide who does what.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The purposes this module can record consent for.
|
|
28
|
+
*
|
|
29
|
+
* Granularity is deliberate and minimal. ISO/IEC TS 27560 and the state laws
|
|
30
|
+
* both require PURPOSE LIMITATION: consent to one thing must not silently
|
|
31
|
+
* authorise another. A blanket "I agree" fails that, so each purpose is
|
|
32
|
+
* separate and separate consent is required for each.
|
|
33
|
+
*
|
|
34
|
+
* These are the four things a local-first access-input-style tool actually
|
|
35
|
+
* does. There is no "analytics" and no "improve our services" purpose,
|
|
36
|
+
* because this tool does neither — including a purpose you do not use is its
|
|
37
|
+
* own kind of dishonesty.
|
|
38
|
+
*/
|
|
39
|
+
export const PURPOSES = {
|
|
40
|
+
ACQUIRE_SIGNAL: {
|
|
41
|
+
id: 'acquire_signal',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
label: 'Read my input signal',
|
|
44
|
+
keyText:
|
|
45
|
+
'The tool reads the signal you connect — a switch, a gaze tracker, or an ' +
|
|
46
|
+
'EEG sensor — to know what you are pointing at. This happens on your ' +
|
|
47
|
+
'device, while the tool is open.',
|
|
48
|
+
detailText:
|
|
49
|
+
'Your device captures a signal from whatever input you connect. The tool ' +
|
|
50
|
+
'uses it to decide which item on screen you mean, using the dwell and ' +
|
|
51
|
+
'scan timings you set. Nothing about this signal is sent anywhere. ' +
|
|
52
|
+
'Closing the tool stops the reading.',
|
|
53
|
+
},
|
|
54
|
+
PROCESS_LOCALLY: {
|
|
55
|
+
id: 'process_locally',
|
|
56
|
+
version: '1.0.0',
|
|
57
|
+
label: 'Process it on this device',
|
|
58
|
+
keyText:
|
|
59
|
+
'Your signal is processed in this browser. It is not sent to a server, ' +
|
|
60
|
+
'and no copy leaves this device.',
|
|
61
|
+
detailText:
|
|
62
|
+
'All processing — filtering, timing, deciding when a selection happens — ' +
|
|
63
|
+
'runs in this browser tab. There is no backend that receives your data. ' +
|
|
64
|
+
'This is verifiable: you can open your browser\u2019s network panel and ' +
|
|
65
|
+
'watch that nothing is transmitted.',
|
|
66
|
+
},
|
|
67
|
+
PERSIST_LOCALLY: {
|
|
68
|
+
id: 'persist_locally',
|
|
69
|
+
version: '1.0.0',
|
|
70
|
+
label: 'Remember my settings',
|
|
71
|
+
keyText:
|
|
72
|
+
'Your settings — dwell time, scan speed, and which input you use — are ' +
|
|
73
|
+
'saved on this device so you do not have to set them again.',
|
|
74
|
+
detailText:
|
|
75
|
+
'Settings are stored in this browser\u2019s local storage. They stay on ' +
|
|
76
|
+
'this device. Clearing your browser data removes them. No settings ' +
|
|
77
|
+
'history is kept beyond your current choices.',
|
|
78
|
+
},
|
|
79
|
+
EXPORT: {
|
|
80
|
+
id: 'export',
|
|
81
|
+
version: '1.0.0',
|
|
82
|
+
label: 'Let me export my own data',
|
|
83
|
+
keyText:
|
|
84
|
+
'You can save a copy of your own settings and consent record to a file, ' +
|
|
85
|
+
'so you can move it to another device or send it to someone you choose.',
|
|
86
|
+
detailText:
|
|
87
|
+
'Export produces a file on your device containing your settings and this ' +
|
|
88
|
+
'consent record. The tool does not send it anywhere \u2014 you decide what ' +
|
|
89
|
+
'to do with the file. This exists so your data is portable to you, ' +
|
|
90
|
+
'which is a right under several state privacy laws.',
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The disclaimer, stated once and exported so every surface uses identical
|
|
96
|
+
* wording.
|
|
97
|
+
*
|
|
98
|
+
* WHY THIS TEXT IS LOAD-BEARING. The FTC\u2019s deception doctrine reaches ANY
|
|
99
|
+
* claim a product makes, whether or not a privacy statute applies to the
|
|
100
|
+
* publisher. So a tool that says "CCPA compliant" when applicability depends
|
|
101
|
+
* on facts about the developer (revenue, consumer counts) is not being modest
|
|
102
|
+
* \u2014 it is making a false claim, which is its own legal exposure. The honest
|
|
103
|
+
* position is the one below: describe the mechanics precisely, decline to
|
|
104
|
+
* assert compliance, and say why.
|
|
105
|
+
*
|
|
106
|
+
* This wording deliberately avoids: "compliant", "certified", "HIPAA",
|
|
107
|
+
* "FDA cleared", "GDPR", and "privacy compliant by architecture". The last is
|
|
108
|
+
* the overclaim a comparable project shipped; architecture is evidence, not a
|
|
109
|
+
* legal conclusion.
|
|
110
|
+
*/
|
|
111
|
+
export const DISCLAIMER = {
|
|
112
|
+
version: '1.0.0',
|
|
113
|
+
short:
|
|
114
|
+
'This tool handles your data in the ways described above. It does not ' +
|
|
115
|
+
'claim to satisfy any particular privacy law.',
|
|
116
|
+
full:
|
|
117
|
+
'WHAT THIS DOES\n' +
|
|
118
|
+
'This consent layer records what you agreed to, lets you change or withdraw ' +
|
|
119
|
+
'it at any time, and keeps a local record of those changes. It implements ' +
|
|
120
|
+
'the consent mechanics described in the ISO/IEC TS 27560 field model.\n\n' +
|
|
121
|
+
'WHAT THIS DOES NOT DO\n' +
|
|
122
|
+
'It does not make this tool "compliant" with any specific law. Whether a ' +
|
|
123
|
+
'law applies depends on facts about whoever publishes the tool \u2014 their ' +
|
|
124
|
+
'revenue, how many people use it, and where those people live \u2014 not on ' +
|
|
125
|
+
'what the code does. A consent screen cannot change that.\n\n' +
|
|
126
|
+
'Specifically: this is not a claim of CCPA, CPA, CTDPA, or VDPOSA ' +
|
|
127
|
+
'compliance. It is not a HIPAA claim (HIPAA generally does not apply to a ' +
|
|
128
|
+
'direct-to-consumer tool with no health-care provider involved). It is not ' +
|
|
129
|
+
'an FDA claim. ISO/IEC TS 27560 is a technical specification with no ' +
|
|
130
|
+
'certification programme, so "structured per 27560" describes the record ' +
|
|
131
|
+
'format and nothing more.\n\n' +
|
|
132
|
+
'WHAT IS ACTUALLY TRUE\n' +
|
|
133
|
+
'Your signal is processed on this device and is not transmitted. There is ' +
|
|
134
|
+
'no server receiving it, so there is nobody to sell it to, no third party ' +
|
|
135
|
+
'to share it with, and no database to breach. That is a statement about ' +
|
|
136
|
+
'this software, and you can verify it.',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Concatenate the key texts for the initial panel. */
|
|
140
|
+
export function keyInformationText(purposes = Object.values(PURPOSES)) {
|
|
141
|
+
return purposes.map((p) => `\u2022 ${p.keyText}`).join('\n\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Look up a purpose by id, or null. */
|
|
145
|
+
export function purposeById(id) {
|
|
146
|
+
return Object.values(PURPOSES).find((p) => p.id === id) ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A stable identifier for the notice bundle as a whole. Recorded in every
|
|
151
|
+
* consent record so the exact text a person saw is reconstructable later.
|
|
152
|
+
*/
|
|
153
|
+
export function noticeVersion(purposes = Object.values(PURPOSES)) {
|
|
154
|
+
return purposes
|
|
155
|
+
.map((p) => `${p.id}@${p.version}`)
|
|
156
|
+
.sort()
|
|
157
|
+
.join('+');
|
|
158
|
+
}
|
package/src/record.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record.js — the consent record and its event log.
|
|
3
|
+
*
|
|
4
|
+
* DATA MODEL BASIS. The field set follows ISO/IEC TS 27560:2023 ("Privacy
|
|
5
|
+
* technologies — Consent record information structure"), whose mandatory
|
|
6
|
+
* fields are public via the W3C Data Privacy Vocabularies and Controls CG
|
|
7
|
+
* guide (published 2026-02-15). Two deliberate departures, each documented
|
|
8
|
+
* where it happens, because blindly copying an inter-organisational standard
|
|
9
|
+
* into a local-first tool imports fields that exist only to make records
|
|
10
|
+
* exchangeable BETWEEN organisations.
|
|
11
|
+
*
|
|
12
|
+
* WHAT 27560 ASKS FOR AND WHY WE KEEP IT:
|
|
13
|
+
* - schema version, record id, subject id → the record must be
|
|
14
|
+
* self-describing and attributable
|
|
15
|
+
* - privacy notice + language → "what did they actually agree
|
|
16
|
+
* to" is an evidentiary question
|
|
17
|
+
* - purpose, data types, retention → purpose limitation
|
|
18
|
+
* - withdrawal method → must name a REAL working path
|
|
19
|
+
* - events: time, validity, entity, type, state
|
|
20
|
+
*
|
|
21
|
+
* WHAT WE DROP, AND WHY: 27560 mandates a controller address, jurisdiction,
|
|
22
|
+
* and an authority party — fields whose only function is to let one
|
|
23
|
+
* organisation's records be read by another. A local-first tool exchanges
|
|
24
|
+
* nothing with anyone, so carrying a controller address would be theatre.
|
|
25
|
+
* The fields are documented as intentionally absent rather than silently
|
|
26
|
+
* omitted, so a reviewer can see the decision.
|
|
27
|
+
*
|
|
28
|
+
* THE EVENT LOG IS TAMPER-EVIDENT, NOT TAMPER-PROOF. Each event carries the
|
|
29
|
+
* hash of the previous one, so altering or removing a past entry breaks the
|
|
30
|
+
* chain and verify() reports it. A determined user with their own device can
|
|
31
|
+
* always rewrite the whole file — that is inherent to local-first storage and
|
|
32
|
+
* pretending otherwise would be a lie. The chain's job is to make ACCIDENTAL
|
|
33
|
+
* or partial modification detectable, and to give the user a way to check
|
|
34
|
+
* their own record was not quietly changed by the tool.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Bump when the record shape changes; recorded in every record. */
|
|
38
|
+
export const SCHEMA_VERSION = '1.0.0';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Consent lifecycle states (ISO/IEC TS 27560 event states).
|
|
42
|
+
* requested — the user was asked
|
|
43
|
+
* given — the user agreed
|
|
44
|
+
* refused — the user declined
|
|
45
|
+
* withdrawn — the user revoked a previous grant
|
|
46
|
+
* expired — the validity window lapsed
|
|
47
|
+
* terminated — the tool or purpose no longer exists
|
|
48
|
+
*/
|
|
49
|
+
export const STATES = ['requested', 'given', 'refused', 'withdrawn', 'expired', 'terminated'];
|
|
50
|
+
|
|
51
|
+
/** The statuses a purpose can currently be in. */
|
|
52
|
+
export const ACTIVE_STATES = new Set(['given']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* FNV-1a, 32-bit, as a hex string.
|
|
56
|
+
*
|
|
57
|
+
* Why not SHA-256: this runs in a browser with no dependencies, and the
|
|
58
|
+
* WebCrypto digest is async — which would make every append() asynchronous
|
|
59
|
+
* and force the whole API to be promise-based for a check that is only
|
|
60
|
+
* detecting accidental modification. 32 bits is the right size for that job.
|
|
61
|
+
* It is NOT a security hash and the code says so rather than implying it.
|
|
62
|
+
*/
|
|
63
|
+
export function fnv1a(str) {
|
|
64
|
+
let h = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < str.length; i++) {
|
|
66
|
+
h ^= str.charCodeAt(i);
|
|
67
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
68
|
+
}
|
|
69
|
+
return h.toString(16).padStart(8, '0');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stable stringify so a hash does not depend on key order. */
|
|
73
|
+
function canonical(obj) {
|
|
74
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
75
|
+
if (Array.isArray(obj)) return '[' + obj.map(canonical).join(',') + ']';
|
|
76
|
+
const keys = Object.keys(obj).sort();
|
|
77
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(obj[k])).join(',') + '}';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Event hash = fnv1a(previous hash + canonical event payload). */
|
|
81
|
+
function eventHash(prevHash, event) {
|
|
82
|
+
const { hash, ...rest } = event; // never hash the hash field itself
|
|
83
|
+
return fnv1a(prevHash + '|' + canonical(rest));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A local consent record.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} [options]
|
|
90
|
+
* @param {string} [options.subjectId] local pseudonymous id; never transmitted
|
|
91
|
+
* @param {Function} [options.now] clock, injected for determinism
|
|
92
|
+
* @param {string} [options.storageKey] localStorage key, when persistence is on
|
|
93
|
+
*/
|
|
94
|
+
export class ConsentRecord {
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
// The clock must be assigned BEFORE anything reads it — createdAt below
|
|
97
|
+
// depends on it, and reading it first was a real bug caught by the suite.
|
|
98
|
+
this._now = options.now ?? (() => Date.now());
|
|
99
|
+
this.schemaVersion = SCHEMA_VERSION;
|
|
100
|
+
this.recordId = options.recordId ?? generateId();
|
|
101
|
+
this.subjectId = options.subjectId ?? generateId();
|
|
102
|
+
this.createdAt = options.createdAt ?? new Date(this._now()).toISOString();
|
|
103
|
+
this._storageKey = options.storageKey ?? null;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Per-purpose consent entries. Keyed by purpose id.
|
|
107
|
+
* Each entry is the 27560 "Processing" block for one purpose.
|
|
108
|
+
*/
|
|
109
|
+
this.purposes = {};
|
|
110
|
+
|
|
111
|
+
/** Append-only event log. */
|
|
112
|
+
this.events = [];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* RECIPIENTS IS ALWAYS EMPTY, AND THAT IS THE POINT.
|
|
116
|
+
*
|
|
117
|
+
* 27560 makes "recipient third parties" a mandatory field. For this tool
|
|
118
|
+
* the honest value is a declared emptiness: there is no server, so there
|
|
119
|
+
* is nobody to receive the data. Recording that as an explicit field
|
|
120
|
+
* rather than prose makes the tool's central privacy claim machine-
|
|
121
|
+
* readable and checkable.
|
|
122
|
+
*/
|
|
123
|
+
this.handling = {
|
|
124
|
+
storage: 'local-only',
|
|
125
|
+
recipients: [],
|
|
126
|
+
recipientsDeclaration: 'none — no transmission',
|
|
127
|
+
// 27560 fields intentionally not carried (see the file header):
|
|
128
|
+
omittedFields: ['pii_controller_address', 'jurisdiction', 'authority_party'],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Local pseudonymous identifier generator (no PII, no network). */
|
|
133
|
+
_newEventId() {
|
|
134
|
+
return generateId();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Record a consent decision for one purpose.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} purposeId
|
|
141
|
+
* @param {('given'|'refused')} state
|
|
142
|
+
* @param {object} [meta]
|
|
143
|
+
* @param {string} meta.noticeVersion the notice text version shown
|
|
144
|
+
* @param {string} [meta.language] BCP-47 tag of the text shown
|
|
145
|
+
* @param {number} [meta.validDays] validity window; consent should lapse
|
|
146
|
+
* @param {string[]} [meta.dataTypes] what data this purpose covers
|
|
147
|
+
* @param {string} [meta.withdrawalMethod] how to revoke (must be real)
|
|
148
|
+
* @returns {object} the event appended
|
|
149
|
+
*/
|
|
150
|
+
decide(purposeId, state, meta = {}) {
|
|
151
|
+
if (!STATES.includes(state)) {
|
|
152
|
+
throw new Error(`unknown consent state: ${state}`);
|
|
153
|
+
}
|
|
154
|
+
const at = new Date(this._now()).toISOString();
|
|
155
|
+
|
|
156
|
+
const entry = {
|
|
157
|
+
purposeId,
|
|
158
|
+
state,
|
|
159
|
+
noticeId: meta.noticeId ?? 'default',
|
|
160
|
+
noticeVersion: meta.noticeVersion ?? 'unversioned',
|
|
161
|
+
language: meta.language ?? 'en',
|
|
162
|
+
dataTypes: meta.dataTypes ?? [],
|
|
163
|
+
sensitivity: 'neural',
|
|
164
|
+
grantedAt: state === 'given' ? at : null,
|
|
165
|
+
validUntil: this._validUntil(at, meta.validDays),
|
|
166
|
+
// 27560 requires the withdrawal method be named. It must describe a
|
|
167
|
+
// path that actually exists in the UI — naming a method the user
|
|
168
|
+
// cannot reach is worse than omitting the field.
|
|
169
|
+
withdrawalMethod: meta.withdrawalMethod ?? 'Settings \u2192 Consent \u2192 withdraw',
|
|
170
|
+
// Legal basis is what the PUBLISHER asserts. This module does not
|
|
171
|
+
// assert compliance on anyone's behalf, so it records the honest
|
|
172
|
+
// default rather than inventing one.
|
|
173
|
+
legalBasis: meta.legalBasis ?? 'consent',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
this.purposes[purposeId] = entry;
|
|
177
|
+
return this.append(state === 'given' ? 'grant' : 'refuse', {
|
|
178
|
+
purposeId,
|
|
179
|
+
state,
|
|
180
|
+
...entry,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Withdraw a previously granted purpose. Symmetric and immediate. */
|
|
185
|
+
withdraw(purposeId, meta = {}) {
|
|
186
|
+
const existing = this.purposes[purposeId];
|
|
187
|
+
if (!existing) throw new Error(`no consent recorded for purpose: ${purposeId}`);
|
|
188
|
+
if (existing.state === 'withdrawn') return null; // idempotent
|
|
189
|
+
const at = new Date(this._now()).toISOString();
|
|
190
|
+
this.purposes[purposeId] = {
|
|
191
|
+
...existing,
|
|
192
|
+
state: 'withdrawn',
|
|
193
|
+
withdrawnAt: at,
|
|
194
|
+
};
|
|
195
|
+
return this.append('withdraw', {
|
|
196
|
+
purposeId,
|
|
197
|
+
state: 'withdrawn',
|
|
198
|
+
at,
|
|
199
|
+
reason: meta.reason ?? 'user request',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Re-affirm consent (Colorado requires periodic refresh). */
|
|
204
|
+
reaffirm(purposeId, meta = {}) {
|
|
205
|
+
const existing = this.purposes[purposeId];
|
|
206
|
+
if (!existing) throw new Error(`no consent recorded for purpose: ${purposeId}`);
|
|
207
|
+
return this.decide(purposeId, 'given', { ...meta, noticeVersion: existing.noticeVersion });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Mark any purpose whose validity window has lapsed as expired.
|
|
212
|
+
* Returns the ids that expired, so the host can re-ask.
|
|
213
|
+
*/
|
|
214
|
+
expireLapsed() {
|
|
215
|
+
const now = this._now();
|
|
216
|
+
const expired = [];
|
|
217
|
+
for (const [id, entry] of Object.entries(this.purposes)) {
|
|
218
|
+
if (entry.state !== 'given' || !entry.validUntil) continue;
|
|
219
|
+
if (Date.parse(entry.validUntil) <= now) {
|
|
220
|
+
this.purposes[id] = { ...entry, state: 'expired', expiredAt: new Date(now).toISOString() };
|
|
221
|
+
this.append('expire', { purposeId: id, state: 'expired', at: new Date(now).toISOString() });
|
|
222
|
+
expired.push(id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return expired;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Is consent currently held for this purpose? */
|
|
229
|
+
isGranted(purposeId) {
|
|
230
|
+
return this.purposes[purposeId]?.state === 'given';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every purpose currently granted. */
|
|
234
|
+
grantedPurposes() {
|
|
235
|
+
return Object.entries(this.purposes)
|
|
236
|
+
.filter(([, e]) => e.state === 'given')
|
|
237
|
+
.map(([id]) => id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Append an event to the tamper-evident log.
|
|
242
|
+
* The chain makes accidental or partial edits detectable.
|
|
243
|
+
*/
|
|
244
|
+
append(type, payload = {}) {
|
|
245
|
+
const prev = this.events[this.events.length - 1];
|
|
246
|
+
const event = {
|
|
247
|
+
seq: this.events.length,
|
|
248
|
+
type,
|
|
249
|
+
at: new Date(this._now()).toISOString(),
|
|
250
|
+
...payload,
|
|
251
|
+
prevHash: prev ? prev.hash : 'genesis',
|
|
252
|
+
};
|
|
253
|
+
event.hash = eventHash(event.prevHash, event);
|
|
254
|
+
this.events.push(event);
|
|
255
|
+
return event;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Verify the event chain. Returns { ok, brokenAt } — brokenAt is the seq of
|
|
260
|
+
* the first event whose hash does not match its contents, or -1.
|
|
261
|
+
*/
|
|
262
|
+
verify() {
|
|
263
|
+
let prevHash = 'genesis';
|
|
264
|
+
for (const event of this.events) {
|
|
265
|
+
if (event.prevHash !== prevHash) return { ok: false, brokenAt: event.seq };
|
|
266
|
+
const expected = eventHash(prevHash, event);
|
|
267
|
+
if (event.hash !== expected) return { ok: false, brokenAt: event.seq };
|
|
268
|
+
prevHash = event.hash;
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, brokenAt: -1 };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Serialise for export or storage. */
|
|
274
|
+
toJSON() {
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: this.schemaVersion,
|
|
277
|
+
recordId: this.recordId,
|
|
278
|
+
subjectId: this.subjectId,
|
|
279
|
+
createdAt: this.createdAt,
|
|
280
|
+
purposes: this.purposes,
|
|
281
|
+
handling: this.handling,
|
|
282
|
+
events: this.events,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Restore from a serialised record. */
|
|
287
|
+
static fromJSON(data, options = {}) {
|
|
288
|
+
const rec = new ConsentRecord({
|
|
289
|
+
...options,
|
|
290
|
+
recordId: data.recordId,
|
|
291
|
+
subjectId: data.subjectId,
|
|
292
|
+
createdAt: data.createdAt,
|
|
293
|
+
});
|
|
294
|
+
rec.schemaVersion = data.schemaVersion ?? SCHEMA_VERSION;
|
|
295
|
+
rec.purposes = data.purposes ?? {};
|
|
296
|
+
rec.handling = data.handling ?? rec.handling;
|
|
297
|
+
rec.events = data.events ?? [];
|
|
298
|
+
return rec;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A human-readable summary — what the person can actually read back. */
|
|
302
|
+
summary() {
|
|
303
|
+
return {
|
|
304
|
+
recordId: this.recordId,
|
|
305
|
+
createdAt: this.createdAt,
|
|
306
|
+
granted: this.grantedPurposes(),
|
|
307
|
+
events: this.events.length,
|
|
308
|
+
chainIntact: this.verify().ok,
|
|
309
|
+
recipients: this.handling.recipientsDeclaration,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_validUntil(fromIso, validDays) {
|
|
314
|
+
if (!validDays || validDays <= 0) return null;
|
|
315
|
+
return new Date(Date.parse(fromIso) + validDays * 86400000).toISOString();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A local, non-identifying id. Prefers crypto.randomUUID when available, and
|
|
321
|
+
* falls back to a timestamp + random suffix so the module works in any
|
|
322
|
+
* environment without a polyfill. Neither form carries personal data.
|
|
323
|
+
*/
|
|
324
|
+
export function generateId() {
|
|
325
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
326
|
+
return crypto.randomUUID();
|
|
327
|
+
}
|
|
328
|
+
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
329
|
+
}
|