@provex/utils 1.6.2 → 1.7.0-rc.20260522201545.020a3eb

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Provex
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 CHANGED
@@ -284,4 +284,6 @@ When adding new utilities:
284
284
 
285
285
  ## License
286
286
 
287
- ISC License
287
+ MIT — see `LICENSE` in this package.
288
+
289
+ > Versions `1.6.3` and later are licensed under MIT. Versions published before `1.6.3` (up to and including `1.6.2`) carry the ISC license they were originally published under.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @fileoverview Per-section policy consent gate with content hashing.
3
+ *
4
+ * Each policy section (e.g. risks, disclaimer, terms, privacy) is
5
+ * tracked independently as `{ hash, acceptedAt }`. The hash is a cheap
6
+ * FNV-1a digest of the section content — when an author edits a
7
+ * section, the stored hash mismatches and only that section re-prompts;
8
+ * unchanged sections stay accepted. This avoids the "edit a typo,
9
+ * every user is re-prompted on every section" papercut of single-boolean
10
+ * gates and produces an audit trail for free.
11
+ *
12
+ * Pure utilities only — no React, no DOM, no project-specific section
13
+ * keys. The UI binds these primitives to its own section definitions
14
+ * and storage-key string.
15
+ *
16
+ * See `~/.claude/skills/per-section-policy-consent-hashing/SKILL.md`
17
+ * for the broader design rationale, layout candidates, and trade-offs.
18
+ */
19
+ /** A single per-section consent receipt. */
20
+ export interface ConsentEntry {
21
+ /** Content hash at the time of acceptance — see {@link hashString}. */
22
+ hash: string;
23
+ /** ISO-8601 timestamp the user accepted at. */
24
+ acceptedAt: string;
25
+ }
26
+ /** Map of section key (caller-defined) to its consent receipt. */
27
+ export type ConsentState = Record<string, ConsentEntry | undefined>;
28
+ /** Three-valued status of a single section. */
29
+ export type SectionStatusValue = 'unread' | 'accepted' | 'stale';
30
+ /**
31
+ * Minimal structural shape used by this module. Satisfied by
32
+ * `globalThis.localStorage` in browsers and by any in-memory shim in
33
+ * tests — the module never depends on a real DOM Storage instance.
34
+ */
35
+ export interface ConsentStorage {
36
+ getItem(key: string): string | null;
37
+ setItem(key: string, value: string): void;
38
+ removeItem(key: string): void;
39
+ }
40
+ /**
41
+ * FNV-1a 32-bit hash, hex-truncated to 8 characters. Sync, no
42
+ * dependencies, non-cryptographic — the threat model is "did our
43
+ * authors edit this string", not adversarial.
44
+ *
45
+ * If a compliance requirement forces SHA-256 via SubtleCrypto, bump
46
+ * the storage key version (e.g. `provex_consent_v3`) and migrate.
47
+ */
48
+ export declare function hashString(s: string): string;
49
+ /**
50
+ * Compact relative-time formatter for the audit row next to the accept
51
+ * button. Past offsets only — future timestamps clamp to "just now".
52
+ * Falls back to a locale date string once the offset exceeds a week
53
+ * because "12d ago" is less useful than the actual date by then.
54
+ */
55
+ export declare function formatRelative(iso: string, now?: Date): string;
56
+ /**
57
+ * Reads the persisted consent state for a given storage key.
58
+ * Always returns a usable object — malformed JSON, missing keys, or a
59
+ * value of the wrong shape all degrade to `{}` rather than throwing,
60
+ * because surfacing a parse error at modal open is worse than
61
+ * re-prompting the user.
62
+ */
63
+ export declare function readConsent(storageKey: string, storage?: ConsentStorage | null): ConsentState;
64
+ /**
65
+ * Writes the consent state. Silently no-ops when storage is unavailable
66
+ * or write fails — private mode and quota errors should not break the
67
+ * accept flow, just fail to persist across sessions.
68
+ */
69
+ export declare function writeConsent(storageKey: string, state: ConsentState, storage?: ConsentStorage | null): void;
70
+ /**
71
+ * Computes the three-valued status of a single section from its stored
72
+ * entry and the current content hash. Pure — caller owns I/O.
73
+ */
74
+ export declare function sectionStatus(stored: ConsentEntry | undefined, currentHash: string): SectionStatusValue;
75
+ export interface AcceptSectionOptions {
76
+ storage?: ConsentStorage | null;
77
+ now?: Date;
78
+ }
79
+ /**
80
+ * Persists acceptance of a single section, preserving any sibling
81
+ * sections that already have entries. Returns the new state so React
82
+ * callers can update local state without re-reading storage.
83
+ */
84
+ export declare function acceptSection(storageKey: string, sectionKey: string, currentHash: string, opts?: AcceptSectionOptions): ConsentState;
85
+ /**
86
+ * The gate: returns true only when every section listed in
87
+ * `currentHashes` has a stored entry whose hash matches. Empty
88
+ * `currentHashes` returns false so the gate stays closed by default.
89
+ */
90
+ export declare function allAccepted(storageKey: string, currentHashes: Record<string, string>, storage?: ConsentStorage | null): boolean;
91
+ export interface MigrateLegacyBooleanOptions {
92
+ legacyKey: string;
93
+ storageKey: string;
94
+ currentHashes: Record<string, string>;
95
+ storage?: ConsentStorage | null;
96
+ now?: Date;
97
+ }
98
+ /**
99
+ * One-time migration from a single legacy boolean ("user accepted the
100
+ * old monolithic risk modal") to the per-section state shape. Treats
101
+ * the legacy flag as "all sections accepted at migration time" so
102
+ * existing users don't see the modal again on first open.
103
+ *
104
+ * Returns true iff migration ran. No-op (returns false) when:
105
+ * - The legacy key is unset or not exactly `'1'`.
106
+ * - Storage isn't available.
107
+ * - Per-section state already exists for the new key — the new state
108
+ * wins; the legacy boolean is left intact in case a downstream
109
+ * migration wants to inspect it.
110
+ */
111
+ export declare function migrateLegacyBoolean(opts: MigrateLegacyBooleanOptions): boolean;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @fileoverview Per-section policy consent gate with content hashing.
3
+ *
4
+ * Each policy section (e.g. risks, disclaimer, terms, privacy) is
5
+ * tracked independently as `{ hash, acceptedAt }`. The hash is a cheap
6
+ * FNV-1a digest of the section content — when an author edits a
7
+ * section, the stored hash mismatches and only that section re-prompts;
8
+ * unchanged sections stay accepted. This avoids the "edit a typo,
9
+ * every user is re-prompted on every section" papercut of single-boolean
10
+ * gates and produces an audit trail for free.
11
+ *
12
+ * Pure utilities only — no React, no DOM, no project-specific section
13
+ * keys. The UI binds these primitives to its own section definitions
14
+ * and storage-key string.
15
+ *
16
+ * See `~/.claude/skills/per-section-policy-consent-hashing/SKILL.md`
17
+ * for the broader design rationale, layout candidates, and trade-offs.
18
+ */
19
+ /**
20
+ * Returns the ambient browser storage, or `null` in non-browser /
21
+ * private-mode / SSR contexts. Never throws — failure to access storage
22
+ * is normal, not exceptional.
23
+ */
24
+ function ambientStorage() {
25
+ try {
26
+ if (typeof globalThis === 'undefined')
27
+ return null;
28
+ const candidate = globalThis.localStorage;
29
+ if (candidate !== null &&
30
+ typeof candidate === 'object' &&
31
+ 'getItem' in candidate &&
32
+ 'setItem' in candidate &&
33
+ 'removeItem' in candidate) {
34
+ return candidate;
35
+ }
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * FNV-1a 32-bit hash, hex-truncated to 8 characters. Sync, no
44
+ * dependencies, non-cryptographic — the threat model is "did our
45
+ * authors edit this string", not adversarial.
46
+ *
47
+ * If a compliance requirement forces SHA-256 via SubtleCrypto, bump
48
+ * the storage key version (e.g. `provex_consent_v3`) and migrate.
49
+ */
50
+ export function hashString(s) {
51
+ let h = 2166136261;
52
+ for (let i = 0; i < s.length; i++) {
53
+ h ^= s.charCodeAt(i);
54
+ h = Math.imul(h, 16777619);
55
+ }
56
+ return ('00000000' + (h >>> 0).toString(16)).slice(-8);
57
+ }
58
+ /**
59
+ * Compact relative-time formatter for the audit row next to the accept
60
+ * button. Past offsets only — future timestamps clamp to "just now".
61
+ * Falls back to a locale date string once the offset exceeds a week
62
+ * because "12d ago" is less useful than the actual date by then.
63
+ */
64
+ export function formatRelative(iso, now = new Date()) {
65
+ if (!iso)
66
+ return '';
67
+ const then = new Date(iso).getTime();
68
+ if (Number.isNaN(then))
69
+ return '';
70
+ const sec = Math.max(0, Math.floor((now.getTime() - then) / 1000));
71
+ if (sec < 5)
72
+ return 'just now';
73
+ if (sec < 60)
74
+ return sec + 's ago';
75
+ const min = Math.floor(sec / 60);
76
+ if (min < 60)
77
+ return min + 'm ago';
78
+ const hr = Math.floor(min / 60);
79
+ if (hr < 24)
80
+ return hr + 'h ago';
81
+ const day = Math.floor(hr / 24);
82
+ if (day < 7)
83
+ return day + 'd ago';
84
+ return new Date(iso).toLocaleDateString();
85
+ }
86
+ /**
87
+ * Reads the persisted consent state for a given storage key.
88
+ * Always returns a usable object — malformed JSON, missing keys, or a
89
+ * value of the wrong shape all degrade to `{}` rather than throwing,
90
+ * because surfacing a parse error at modal open is worse than
91
+ * re-prompting the user.
92
+ */
93
+ export function readConsent(storageKey, storage = ambientStorage()) {
94
+ if (!storage)
95
+ return {};
96
+ try {
97
+ const raw = storage.getItem(storageKey);
98
+ if (!raw)
99
+ return {};
100
+ const parsed = JSON.parse(raw);
101
+ if (typeof parsed !== 'object' ||
102
+ parsed === null ||
103
+ Array.isArray(parsed)) {
104
+ return {};
105
+ }
106
+ return parsed;
107
+ }
108
+ catch {
109
+ return {};
110
+ }
111
+ }
112
+ /**
113
+ * Writes the consent state. Silently no-ops when storage is unavailable
114
+ * or write fails — private mode and quota errors should not break the
115
+ * accept flow, just fail to persist across sessions.
116
+ */
117
+ export function writeConsent(storageKey, state, storage = ambientStorage()) {
118
+ if (!storage)
119
+ return;
120
+ try {
121
+ storage.setItem(storageKey, JSON.stringify(state));
122
+ }
123
+ catch {
124
+ // Quota / private-mode — accept stands for the session but won't survive a reload.
125
+ }
126
+ }
127
+ /**
128
+ * Computes the three-valued status of a single section from its stored
129
+ * entry and the current content hash. Pure — caller owns I/O.
130
+ */
131
+ export function sectionStatus(stored, currentHash) {
132
+ if (!stored)
133
+ return 'unread';
134
+ return stored.hash === currentHash ? 'accepted' : 'stale';
135
+ }
136
+ /**
137
+ * Persists acceptance of a single section, preserving any sibling
138
+ * sections that already have entries. Returns the new state so React
139
+ * callers can update local state without re-reading storage.
140
+ */
141
+ export function acceptSection(storageKey, sectionKey, currentHash, opts = {}) {
142
+ const storage = opts.storage === undefined ? ambientStorage() : opts.storage;
143
+ const now = opts.now ?? new Date();
144
+ const previous = readConsent(storageKey, storage);
145
+ const next = {
146
+ ...previous,
147
+ [sectionKey]: { hash: currentHash, acceptedAt: now.toISOString() },
148
+ };
149
+ writeConsent(storageKey, next, storage);
150
+ return next;
151
+ }
152
+ /**
153
+ * The gate: returns true only when every section listed in
154
+ * `currentHashes` has a stored entry whose hash matches. Empty
155
+ * `currentHashes` returns false so the gate stays closed by default.
156
+ */
157
+ export function allAccepted(storageKey, currentHashes, storage = ambientStorage()) {
158
+ const keys = Object.keys(currentHashes);
159
+ if (keys.length === 0)
160
+ return false;
161
+ const state = readConsent(storageKey, storage);
162
+ return keys.every((k) => sectionStatus(state[k], currentHashes[k]) === 'accepted');
163
+ }
164
+ /**
165
+ * One-time migration from a single legacy boolean ("user accepted the
166
+ * old monolithic risk modal") to the per-section state shape. Treats
167
+ * the legacy flag as "all sections accepted at migration time" so
168
+ * existing users don't see the modal again on first open.
169
+ *
170
+ * Returns true iff migration ran. No-op (returns false) when:
171
+ * - The legacy key is unset or not exactly `'1'`.
172
+ * - Storage isn't available.
173
+ * - Per-section state already exists for the new key — the new state
174
+ * wins; the legacy boolean is left intact in case a downstream
175
+ * migration wants to inspect it.
176
+ */
177
+ export function migrateLegacyBoolean(opts) {
178
+ const storage = opts.storage === undefined ? ambientStorage() : opts.storage;
179
+ if (!storage)
180
+ return false;
181
+ if (storage.getItem(opts.legacyKey) !== '1')
182
+ return false;
183
+ const existing = readConsent(opts.storageKey, storage);
184
+ if (Object.keys(existing).length > 0)
185
+ return false;
186
+ const acceptedAt = (opts.now ?? new Date()).toISOString();
187
+ const state = {};
188
+ for (const k of Object.keys(opts.currentHashes)) {
189
+ state[k] = { hash: opts.currentHashes[k], acceptedAt };
190
+ }
191
+ writeConsent(opts.storageKey, state, storage);
192
+ storage.removeItem(opts.legacyKey);
193
+ return true;
194
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,247 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { hashString, formatRelative, readConsent, writeConsent, sectionStatus, acceptSection, allAccepted, migrateLegacyBoolean, } from './consent.js';
4
+ /**
5
+ * Minimal in-memory `ConsentStorage` shim used by every test so each
6
+ * case starts from a clean slate. The production caller passes
7
+ * `globalThis.localStorage`; the tests never touch globals.
8
+ */
9
+ function memStorage() {
10
+ const data = new Map();
11
+ return {
12
+ getItem: (k) => (data.has(k) ? data.get(k) : null),
13
+ setItem: (k, v) => void data.set(k, v),
14
+ removeItem: (k) => void data.delete(k),
15
+ };
16
+ }
17
+ describe('hashString (FNV-1a)', () => {
18
+ it('produces same hash for same input', () => {
19
+ assert.equal(hashString('hello'), hashString('hello'));
20
+ });
21
+ it('produces different hashes for different inputs', () => {
22
+ assert.notEqual(hashString('hello'), hashString('world'));
23
+ });
24
+ it('returns 8-character lowercase hex', () => {
25
+ assert.match(hashString('anything'), /^[0-9a-f]{8}$/);
26
+ });
27
+ it('handles the empty string', () => {
28
+ // Important: empty content must still hash to something stable so
29
+ // sectionStatus doesn't blow up if a section is temporarily empty.
30
+ assert.match(hashString(''), /^[0-9a-f]{8}$/);
31
+ });
32
+ it('reflects single-character edits', () => {
33
+ const before = hashString('the cutting-edge software statement.');
34
+ const after = hashString('the cutting-edge software statement!');
35
+ assert.notEqual(before, after);
36
+ });
37
+ });
38
+ describe('formatRelative', () => {
39
+ // Anchor "now" so the relative-time bands are deterministic.
40
+ const T0 = new Date('2026-05-21T18:00:00.000Z').getTime();
41
+ const now = new Date(T0);
42
+ const at = (offsetSec) => new Date(T0 - offsetSec * 1000).toISOString();
43
+ it('returns "just now" for offsets under 5s', () => {
44
+ assert.equal(formatRelative(at(2), now), 'just now');
45
+ });
46
+ it('returns "Xs ago" for offsets under 60s', () => {
47
+ assert.equal(formatRelative(at(30), now), '30s ago');
48
+ });
49
+ it('returns "Xm ago" for offsets under 60m', () => {
50
+ assert.equal(formatRelative(at(120), now), '2m ago');
51
+ });
52
+ it('returns "Xh ago" for offsets under 24h', () => {
53
+ assert.equal(formatRelative(at(3 * 3600), now), '3h ago');
54
+ });
55
+ it('returns "Xd ago" for offsets under 7 days', () => {
56
+ assert.equal(formatRelative(at(2 * 86400), now), '2d ago');
57
+ });
58
+ it('returns a locale date for offsets at or above 7 days', () => {
59
+ const out = formatRelative(at(8 * 86400), now);
60
+ // The exact format is locale-dependent — assert only on the audit
61
+ // signal: it no longer reads as a relative duration.
62
+ assert.ok(!out.includes('ago'), `expected a date, got "${out}"`);
63
+ assert.ok(out.length > 0);
64
+ });
65
+ it('returns empty string for empty input', () => {
66
+ assert.equal(formatRelative('', now), '');
67
+ });
68
+ });
69
+ describe('readConsent', () => {
70
+ it('returns {} when the storage key is unset', () => {
71
+ assert.deepEqual(readConsent('k', memStorage()), {});
72
+ });
73
+ it('parses a previously written state', () => {
74
+ const s = memStorage();
75
+ s.setItem('k', JSON.stringify({ risks: { hash: 'abc', acceptedAt: 'T' } }));
76
+ assert.deepEqual(readConsent('k', s), {
77
+ risks: { hash: 'abc', acceptedAt: 'T' },
78
+ });
79
+ });
80
+ it('returns {} on malformed JSON instead of throwing', () => {
81
+ // Private-mode quirks and manual localStorage edits both produce
82
+ // unparseable values — we must not surface those as user-visible
83
+ // errors at modal open.
84
+ const s = memStorage();
85
+ s.setItem('k', '{nope');
86
+ assert.deepEqual(readConsent('k', s), {});
87
+ });
88
+ it('returns {} when the parsed value is not an object', () => {
89
+ const s = memStorage();
90
+ s.setItem('k', '"a string"');
91
+ assert.deepEqual(readConsent('k', s), {});
92
+ });
93
+ it('returns {} when storage is null', () => {
94
+ // Server-side render path: no localStorage available.
95
+ assert.deepEqual(readConsent('k', null), {});
96
+ });
97
+ });
98
+ describe('sectionStatus', () => {
99
+ it('returns "unread" when no stored entry exists', () => {
100
+ assert.equal(sectionStatus(undefined, 'abc'), 'unread');
101
+ });
102
+ it('returns "accepted" when stored hash matches current', () => {
103
+ assert.equal(sectionStatus({ hash: 'abc', acceptedAt: 'T' }, 'abc'), 'accepted');
104
+ });
105
+ it('returns "stale" when stored hash differs from current', () => {
106
+ assert.equal(sectionStatus({ hash: 'old', acceptedAt: 'T' }, 'new'), 'stale');
107
+ });
108
+ });
109
+ describe('acceptSection', () => {
110
+ it('writes a new entry with the supplied hash and timestamp', () => {
111
+ const s = memStorage();
112
+ const now = new Date('2026-05-21T18:00:00.000Z');
113
+ const after = acceptSection('k', 'risks', 'h1', { storage: s, now });
114
+ assert.deepEqual(after.risks, {
115
+ hash: 'h1',
116
+ acceptedAt: '2026-05-21T18:00:00.000Z',
117
+ });
118
+ assert.deepEqual(readConsent('k', s), after);
119
+ });
120
+ it('preserves other sections when accepting one', () => {
121
+ const s = memStorage();
122
+ writeConsent('k', { terms: { hash: 't', acceptedAt: 'A' } }, s);
123
+ const after = acceptSection('k', 'risks', 'r', {
124
+ storage: s,
125
+ now: new Date(0),
126
+ });
127
+ assert.equal(after.terms?.hash, 't');
128
+ assert.equal(after.risks?.hash, 'r');
129
+ });
130
+ it('overwrites the existing entry for the same section', () => {
131
+ const s = memStorage();
132
+ writeConsent('k', { risks: { hash: 'old', acceptedAt: 'A' } }, s);
133
+ const after = acceptSection('k', 'risks', 'new', {
134
+ storage: s,
135
+ now: new Date(0),
136
+ });
137
+ assert.equal(after.risks?.hash, 'new');
138
+ });
139
+ });
140
+ describe('allAccepted', () => {
141
+ it('is true when every required section has a matching hash', () => {
142
+ const s = memStorage();
143
+ writeConsent('k', {
144
+ risks: { hash: 'r', acceptedAt: 'A' },
145
+ disclaimer: { hash: 'd', acceptedAt: 'A' },
146
+ }, s);
147
+ assert.equal(allAccepted('k', { risks: 'r', disclaimer: 'd' }, s), true);
148
+ });
149
+ it('is false when a required section is missing', () => {
150
+ const s = memStorage();
151
+ writeConsent('k', { risks: { hash: 'r', acceptedAt: 'A' } }, s);
152
+ assert.equal(allAccepted('k', { risks: 'r', disclaimer: 'd' }, s), false);
153
+ });
154
+ it('is false when a required section is stale', () => {
155
+ const s = memStorage();
156
+ writeConsent('k', {
157
+ risks: { hash: 'old', acceptedAt: 'A' },
158
+ disclaimer: { hash: 'd', acceptedAt: 'A' },
159
+ }, s);
160
+ assert.equal(allAccepted('k', { risks: 'r', disclaimer: 'd' }, s), false);
161
+ });
162
+ it('is false when the required-hashes map is empty', () => {
163
+ // Defensive: no sections defined means "nothing to accept", which
164
+ // we treat as not-yet-accepted so the gate stays closed.
165
+ assert.equal(allAccepted('k', {}, memStorage()), false);
166
+ });
167
+ });
168
+ describe('migrateLegacyBoolean', () => {
169
+ it('is a no-op when the legacy key is unset', () => {
170
+ const s = memStorage();
171
+ const migrated = migrateLegacyBoolean({
172
+ legacyKey: 'old',
173
+ storageKey: 'new',
174
+ currentHashes: { risks: 'r' },
175
+ storage: s,
176
+ });
177
+ assert.equal(migrated, false);
178
+ assert.deepEqual(readConsent('new', s), {});
179
+ });
180
+ it('is a no-op when the legacy value is not "1"', () => {
181
+ const s = memStorage();
182
+ s.setItem('old', '0');
183
+ const migrated = migrateLegacyBoolean({
184
+ legacyKey: 'old',
185
+ storageKey: 'new',
186
+ currentHashes: { risks: 'r' },
187
+ storage: s,
188
+ });
189
+ assert.equal(migrated, false);
190
+ assert.deepEqual(readConsent('new', s), {});
191
+ // Legacy value left intact when we didn't migrate.
192
+ assert.equal(s.getItem('old'), '0');
193
+ });
194
+ it('migrates "1" by writing every section with the current hash', () => {
195
+ const s = memStorage();
196
+ s.setItem('old', '1');
197
+ const now = new Date('2026-05-21T18:00:00.000Z');
198
+ const migrated = migrateLegacyBoolean({
199
+ legacyKey: 'old',
200
+ storageKey: 'new',
201
+ currentHashes: { risks: 'r1', disclaimer: 'd1' },
202
+ storage: s,
203
+ now,
204
+ });
205
+ assert.equal(migrated, true);
206
+ const state = readConsent('new', s);
207
+ assert.equal(state.risks?.hash, 'r1');
208
+ assert.equal(state.disclaimer?.hash, 'd1');
209
+ assert.equal(state.risks?.acceptedAt, '2026-05-21T18:00:00.000Z');
210
+ assert.equal(state.disclaimer?.acceptedAt, '2026-05-21T18:00:00.000Z');
211
+ });
212
+ it('clears the legacy key after migrating', () => {
213
+ const s = memStorage();
214
+ s.setItem('old', '1');
215
+ migrateLegacyBoolean({
216
+ legacyKey: 'old',
217
+ storageKey: 'new',
218
+ currentHashes: { risks: 'r' },
219
+ storage: s,
220
+ });
221
+ assert.equal(s.getItem('old'), null);
222
+ });
223
+ it('does not overwrite existing per-section state', () => {
224
+ // The new key wins — if a user has already accepted via the new
225
+ // flow on another device or tab, the legacy boolean is moot.
226
+ const s = memStorage();
227
+ s.setItem('old', '1');
228
+ writeConsent('new', { risks: { hash: 'existing', acceptedAt: 'A' } }, s);
229
+ const migrated = migrateLegacyBoolean({
230
+ legacyKey: 'old',
231
+ storageKey: 'new',
232
+ currentHashes: { risks: 'r1' },
233
+ storage: s,
234
+ });
235
+ assert.equal(migrated, false);
236
+ assert.equal(readConsent('new', s).risks?.hash, 'existing');
237
+ });
238
+ it('returns false when storage is null', () => {
239
+ const migrated = migrateLegacyBoolean({
240
+ legacyKey: 'old',
241
+ storageKey: 'new',
242
+ currentHashes: { risks: 'r' },
243
+ storage: null,
244
+ });
245
+ assert.equal(migrated, false);
246
+ });
247
+ });
package/dist/index.d.ts CHANGED
@@ -44,3 +44,4 @@ export * from './peer-types.js';
44
44
  export * from './fees.js';
45
45
  export * from './reputation.js';
46
46
  export * from './transports.js';
47
+ export * from './consent.js';
package/dist/index.js CHANGED
@@ -44,3 +44,4 @@ export * from './peer-types.js';
44
44
  export * from './fees.js';
45
45
  export * from './reputation.js';
46
46
  export * from './transports.js';
47
+ export * from './consent.js';