@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 +21 -0
- package/README.md +3 -1
- package/dist/consent.d.ts +111 -0
- package/dist/consent.js +194 -0
- package/dist/consent.test.d.ts +1 -0
- package/dist/consent.test.js +247 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/payment.d.ts +64 -2
- package/dist/payment.js +80 -5
- package/dist/payment.test.js +212 -9
- package/dist/reputation.d.ts +59 -20
- package/dist/reputation.js +75 -26
- package/dist/reputation.test.js +73 -25
- package/dist/transports.js +38 -16
- package/package.json +8 -3
- package/skills/utils-integration/SKILL.md +161 -0
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
|
-
|
|
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;
|
package/dist/consent.js
ADDED
|
@@ -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
package/dist/index.js
CHANGED