@peax/consent-core 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,445 @@
1
+ import * as z from "zod/mini";
2
+ //#region src/policy/arePoliciesEqual.ts
3
+ /** Structural equality of validated policies; array order matters, object key order does not. */
4
+ function arePoliciesEqual(first, second) {
5
+ if (first.purposes.length !== second.purposes.length || first.uses.length !== second.uses.length) return false;
6
+ if (!first.purposes.every((purpose, index) => {
7
+ const other = second.purposes[index];
8
+ return other !== void 0 && purpose.id === other.id && purpose.title === other.title && purpose.description === other.description;
9
+ })) return false;
10
+ return first.uses.every((use, index) => {
11
+ const other = second.uses[index];
12
+ return other !== void 0 && use.id === other.id && use.serviceId === other.serviceId && use.categoryId === other.categoryId && use.purposeId === other.purposeId && use.consentVersion === other.consentVersion;
13
+ });
14
+ }
15
+ //#endregion
16
+ //#region src/utils/validation.ts
17
+ var nonemptyStringSchema = /* @__PURE__ */ (() => z.string({ error: "Expected a nonempty string." }).check(z.refine((value) => value.trim().length > 0, { error: "Expected a nonempty string." })))();
18
+ var consentVersionSchema = /* @__PURE__ */ (() => z.int({ error: "Expected a positive safe integer consent version." }).check(z.positive({ error: "Expected a positive safe integer consent version." })))();
19
+ /** Internal adapter: keep Zod errors out of the public result contract. */
20
+ function validateWithSchema(schema, input) {
21
+ const result = schema.safeParse(input, { error: (issue) => issue.code === "unrecognized_keys" ? `Unexpected fields: ${issue.keys.join(", ")}.` : issue.code === "invalid_type" ? `Expected ${issue.expected}.` : "Invalid consent data." });
22
+ if (result.success) return {
23
+ success: true,
24
+ data: result.data
25
+ };
26
+ return {
27
+ success: false,
28
+ issues: result.error.issues.map((issue) => ({
29
+ code: issue.code,
30
+ path: issue.path.map((segment) => typeof segment === "number" ? segment : String(segment)),
31
+ message: issue.message
32
+ }))
33
+ };
34
+ }
35
+ //#endregion
36
+ //#region src/policy/schemas.ts
37
+ var consentPurposeSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
38
+ id: nonemptyStringSchema,
39
+ title: nonemptyStringSchema,
40
+ description: nonemptyStringSchema
41
+ })))();
42
+ var consentUseSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
43
+ id: nonemptyStringSchema,
44
+ serviceId: nonemptyStringSchema,
45
+ categoryId: nonemptyStringSchema,
46
+ purposeId: nonemptyStringSchema,
47
+ /** Version of this use's consent scope, independent of other uses. */
48
+ consentVersion: consentVersionSchema
49
+ })))();
50
+ var consentPolicySchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
51
+ purposes: z.readonly(z.array(consentPurposeSchema).check((context) => {
52
+ const ids = /* @__PURE__ */ new Set();
53
+ context.value.forEach((purpose, index) => {
54
+ if (ids.has(purpose.id)) context.issues.push({
55
+ code: "custom",
56
+ path: [index, "id"],
57
+ message: "Consent purpose IDs must be unique.",
58
+ input: purpose.id
59
+ });
60
+ ids.add(purpose.id);
61
+ });
62
+ })),
63
+ uses: z.readonly(z.array(consentUseSchema).check((context) => {
64
+ const ids = /* @__PURE__ */ new Set();
65
+ context.value.forEach((use, index) => {
66
+ if (ids.has(use.id)) context.issues.push({
67
+ code: "custom",
68
+ path: [index, "id"],
69
+ message: "Consent use IDs must be unique.",
70
+ input: use.id
71
+ });
72
+ ids.add(use.id);
73
+ });
74
+ }))
75
+ }).check((context) => {
76
+ if (context.issues.length > 0) return;
77
+ const purposeIds = new Set(context.value.purposes.map((purpose) => purpose.id));
78
+ context.value.uses.forEach((use, index) => {
79
+ if (!purposeIds.has(use.purposeId)) context.issues.push({
80
+ code: "custom",
81
+ path: [
82
+ "uses",
83
+ index,
84
+ "purposeId"
85
+ ],
86
+ message: "Consent use must reference a declared purpose.",
87
+ input: use.purposeId
88
+ });
89
+ });
90
+ })))();
91
+ //#endregion
92
+ //#region src/policy/validateConsentPolicy.ts
93
+ /** Validates policy structure, not its approval or the legal adequacy of its purposes. */
94
+ function validateConsentPolicy(input) {
95
+ return validateWithSchema(consentPolicySchema, input);
96
+ }
97
+ //#endregion
98
+ //#region src/consent/areConsentStatesEqual.ts
99
+ /** Compare recorded choices, not permission or policy compatibility. Choice order is preserved. */
100
+ function areConsentStatesEqual(first, second) {
101
+ return first.choices.length === second.choices.length && first.choices.every((choice, index) => {
102
+ const other = second.choices[index];
103
+ return other?.useId === choice.useId && other.consentVersion === choice.consentVersion && other.decision === choice.decision;
104
+ });
105
+ }
106
+ //#endregion
107
+ //#region src/consent/createConsentSubscription.ts
108
+ /** Synchronous invalidation notifications, not a log of every intermediate state. */
109
+ function createConsentSubscription() {
110
+ const listeners = /* @__PURE__ */ new Set();
111
+ let notifying = false;
112
+ let pending = false;
113
+ return {
114
+ subscribe(listener) {
115
+ if (typeof listener !== "function") throw new TypeError("Expected a subscription listener.");
116
+ const subscription = () => listener();
117
+ listeners.add(subscription);
118
+ return () => {
119
+ listeners.delete(subscription);
120
+ };
121
+ },
122
+ notify() {
123
+ pending = true;
124
+ if (notifying) return;
125
+ notifying = true;
126
+ const errors = [];
127
+ try {
128
+ do {
129
+ pending = false;
130
+ for (const listener of [...listeners]) {
131
+ if (!listeners.has(listener)) continue;
132
+ try {
133
+ listener();
134
+ } catch (error) {
135
+ errors.push(error);
136
+ }
137
+ }
138
+ } while (pending);
139
+ } finally {
140
+ notifying = false;
141
+ }
142
+ if (errors.length > 0) throw new AggregateError(errors, "Consent subscribers failed after the change was committed.");
143
+ }
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region src/consent/evaluateConsent.ts
148
+ /**
149
+ * Evaluates one use without mutating inputs or performing enforcement.
150
+ * Inputs must be validated, with unique policy use IDs and at most one choice
151
+ * per use. Denials remain blocked regardless of their consent version.
152
+ * A version mismatch blocks only this use, not other unchanged uses.
153
+ */
154
+ function evaluateConsent({ currentAppPolicy, state, useId }) {
155
+ const use = currentAppPolicy.uses.find((use) => use.id === useId);
156
+ if (!use) return {
157
+ allowed: false,
158
+ reason: "unknown-use"
159
+ };
160
+ const choice = state.choices.find((choice) => choice.useId === useId);
161
+ if (!choice) return {
162
+ allowed: false,
163
+ reason: "undecided"
164
+ };
165
+ if (choice.decision === "denied") return {
166
+ allowed: false,
167
+ reason: "denied"
168
+ };
169
+ if (choice.consentVersion !== use.consentVersion) return {
170
+ allowed: false,
171
+ reason: "consent-version-mismatch"
172
+ };
173
+ return { allowed: true };
174
+ }
175
+ //#endregion
176
+ //#region src/consent/getMismatchedConsent.ts
177
+ /**
178
+ * Lists undecided uses and mismatched grants in policy order, without changing
179
+ * choices or prompting the user. Inputs must follow evaluateConsent's validated
180
+ * input contract. An empty result is not blanket permission: denials are omitted.
181
+ */
182
+ function getMismatchedConsent({ currentAppPolicy, state }) {
183
+ return currentAppPolicy.uses.flatMap((use) => {
184
+ const evaluation = evaluateConsent({
185
+ currentAppPolicy,
186
+ state,
187
+ useId: use.id
188
+ });
189
+ if (evaluation.allowed) return [];
190
+ if (evaluation.reason !== "undecided" && evaluation.reason !== "consent-version-mismatch") return [];
191
+ return [{
192
+ useId: use.id,
193
+ categoryId: use.categoryId,
194
+ consentVersion: use.consentVersion,
195
+ reason: evaluation.reason
196
+ }];
197
+ });
198
+ }
199
+ //#endregion
200
+ //#region src/consent/schemas.ts
201
+ var consentDecisionSchema = /* @__PURE__ */ z.enum(["granted", "denied"], { error: "Expected granted or denied." });
202
+ var consentChoiceSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
203
+ useId: nonemptyStringSchema,
204
+ consentVersion: consentVersionSchema,
205
+ decision: consentDecisionSchema
206
+ })))();
207
+ var consentStateSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({ choices: z.readonly(z.array(consentChoiceSchema).check((context) => {
208
+ const ids = /* @__PURE__ */ new Set();
209
+ context.value.forEach((choice, index) => {
210
+ if (ids.has(choice.useId)) context.issues.push({
211
+ code: "custom",
212
+ path: [index, "useId"],
213
+ message: "Only one consent choice per use is allowed.",
214
+ input: choice.useId
215
+ });
216
+ ids.add(choice.useId);
217
+ });
218
+ })) })))();
219
+ var createConsentOptionsSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
220
+ currentAppPolicy: consentPolicySchema,
221
+ initialState: consentStateSchema
222
+ })))();
223
+ var consentDecisionCommandSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
224
+ userConsentedPolicy: consentPolicySchema,
225
+ useId: nonemptyStringSchema,
226
+ decision: consentDecisionSchema
227
+ })))();
228
+ var categoryConsentCommandSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
229
+ userConsentedPolicy: consentPolicySchema,
230
+ categoryId: nonemptyStringSchema,
231
+ decision: consentDecisionSchema
232
+ })))();
233
+ var consentDecisionsCommandSchema = /* @__PURE__ */ (() => z.readonly(z.strictObject({
234
+ userConsentedPolicy: consentPolicySchema,
235
+ decisions: z.readonly(z.array(z.union([z.readonly(z.strictObject({
236
+ useId: nonemptyStringSchema,
237
+ decision: consentDecisionSchema
238
+ })), z.readonly(z.strictObject({
239
+ categoryId: nonemptyStringSchema,
240
+ decision: consentDecisionSchema
241
+ }))])))
242
+ })))();
243
+ //#endregion
244
+ //#region src/consent/setConsent.ts
245
+ /**
246
+ * Records an explicit decision for every use in a category of the supplied policy.
247
+ * Pass only the policy scope presented to the user; this creates no blanket grant
248
+ * for future uses. Inputs follow the same validation contract as setConsentDecision.
249
+ * Throws when no uses match; returns the same state when all choices are unchanged.
250
+ */
251
+ function setCategoryConsent({ userConsentedPolicy, state, categoryId, decision }) {
252
+ const uses = userConsentedPolicy.uses.filter((use) => use.categoryId === categoryId);
253
+ if (uses.length === 0) throw new Error(`Unknown consent category: ${categoryId}`);
254
+ return uses.reduce((nextState, use) => setConsentDecision({
255
+ userConsentedPolicy,
256
+ decision,
257
+ state: nextState,
258
+ useId: use.id
259
+ }), state);
260
+ }
261
+ /**
262
+ * Records an explicit decision for one use at the supplied policy's version.
263
+ * Pass the policy presented to the user, not a newer policy they have not seen.
264
+ * Inputs must be validated, with unique use IDs and at most one choice per use.
265
+ * Throws for an unknown use; returns the same state for an identical choice.
266
+ */
267
+ function setConsentDecision({ userConsentedPolicy, state, useId, decision }) {
268
+ const use = userConsentedPolicy.uses.find((use) => use.id === useId);
269
+ if (!use) throw new Error(`Unknown consent use: ${useId}`);
270
+ const previous = state.choices.find((choice) => choice.useId === useId);
271
+ if (previous?.decision === decision && previous.consentVersion === use.consentVersion) return state;
272
+ const choice = {
273
+ useId,
274
+ consentVersion: use.consentVersion,
275
+ decision
276
+ };
277
+ return { choices: previous ? state.choices.map((existing) => existing.useId === useId ? choice : existing) : [...state.choices, choice] };
278
+ }
279
+ //#endregion
280
+ //#region src/consent/setConsentDecisions.ts
281
+ /** Validated command in, one pure merged state out; no partially applied targets. */
282
+ function setConsentDecisions({ userConsentedPolicy, decisions }, state) {
283
+ const targets = /* @__PURE__ */ new Set();
284
+ const selected = /* @__PURE__ */ new Map();
285
+ const issues = [];
286
+ decisions.forEach((item, index) => {
287
+ const field = "useId" in item ? "useId" : "categoryId";
288
+ const id = "useId" in item ? item.useId : item.categoryId;
289
+ const path = [
290
+ "decisions",
291
+ index,
292
+ field
293
+ ];
294
+ const target = `${field}:${id}`;
295
+ if (targets.has(target)) {
296
+ issues.push({
297
+ code: "duplicate_target",
298
+ path,
299
+ message: "Duplicate consent target."
300
+ });
301
+ return;
302
+ }
303
+ targets.add(target);
304
+ const uses = userConsentedPolicy.uses.filter((use) => field === "useId" ? use.id === id : use.categoryId === id);
305
+ if (!uses.length) issues.push({
306
+ code: "unknown_target",
307
+ path,
308
+ message: "Unknown consent target."
309
+ });
310
+ uses.forEach((use) => {
311
+ if (selected.has(use.id)) issues.push({
312
+ code: "overlapping_target",
313
+ path,
314
+ message: "Consent targets must not overlap."
315
+ });
316
+ else selected.set(use.id, {
317
+ useId: use.id,
318
+ consentVersion: use.consentVersion,
319
+ decision: item.decision
320
+ });
321
+ });
322
+ });
323
+ if (issues.length) return {
324
+ success: false,
325
+ issues
326
+ };
327
+ let changed = false;
328
+ const choices = state.choices.map((previous) => {
329
+ const next = selected.get(previous.useId);
330
+ selected.delete(previous.useId);
331
+ if (!next || next.decision === previous.decision && next.consentVersion === previous.consentVersion) return previous;
332
+ changed = true;
333
+ return next;
334
+ });
335
+ userConsentedPolicy.uses.forEach((use) => {
336
+ const choice = selected.get(use.id);
337
+ if (choice) {
338
+ choices.push(choice);
339
+ changed = true;
340
+ }
341
+ });
342
+ return {
343
+ success: true,
344
+ data: changed ? { choices } : state
345
+ };
346
+ }
347
+ //#endregion
348
+ //#region src/consent/createConsent.ts
349
+ function createSnapshot(currentAppPolicy, state) {
350
+ state.choices.forEach((choice) => {
351
+ Object.freeze(choice);
352
+ });
353
+ Object.freeze(state.choices);
354
+ Object.freeze(state);
355
+ return Object.freeze({
356
+ currentAppPolicy,
357
+ state
358
+ });
359
+ }
360
+ function snapshotRequirements(snapshot) {
361
+ return Object.freeze(getMismatchedConsent(snapshot).map((requirement) => Object.freeze(requirement)));
362
+ }
363
+ /** Creates an isolated runtime from decoded data; invalid input creates no instance. */
364
+ function createConsent(input) {
365
+ const result = validateWithSchema(createConsentOptionsSchema, input);
366
+ if (!result.success) return result;
367
+ let snapshot = createSnapshot(result.data.currentAppPolicy, result.data.initialState);
368
+ let requirements = snapshotRequirements(snapshot);
369
+ const subscriptions = createConsentSubscription();
370
+ function commit(currentAppPolicy, state) {
371
+ if (snapshot.currentAppPolicy === currentAppPolicy && snapshot.state === state) return {
372
+ success: true,
373
+ data: snapshot
374
+ };
375
+ const next = createSnapshot(currentAppPolicy, state);
376
+ const nextRequirements = snapshotRequirements(next);
377
+ snapshot = next;
378
+ requirements = nextRequirements;
379
+ subscriptions.notify();
380
+ return {
381
+ success: true,
382
+ data: next
383
+ };
384
+ }
385
+ return {
386
+ success: true,
387
+ data: Object.freeze({
388
+ getSnapshot: () => snapshot,
389
+ evaluate: (useId) => evaluateConsent({
390
+ ...snapshot,
391
+ useId
392
+ }),
393
+ getMismatchedConsent: () => requirements,
394
+ restoreState(input) {
395
+ const parsed = validateWithSchema(consentStateSchema, input);
396
+ if (!parsed.success) return parsed;
397
+ if (areConsentStatesEqual(parsed.data, snapshot.state)) return {
398
+ success: true,
399
+ data: snapshot
400
+ };
401
+ return commit(snapshot.currentAppPolicy, parsed.data);
402
+ },
403
+ setConsentDecision(command) {
404
+ const parsed = validateWithSchema(consentDecisionCommandSchema, command);
405
+ if (!parsed.success) return parsed;
406
+ const { userConsentedPolicy, useId, decision } = parsed.data;
407
+ return commit(snapshot.currentAppPolicy, setConsentDecision({
408
+ userConsentedPolicy,
409
+ state: snapshot.state,
410
+ useId,
411
+ decision
412
+ }));
413
+ },
414
+ setCategoryConsent(command) {
415
+ const parsed = validateWithSchema(categoryConsentCommandSchema, command);
416
+ if (!parsed.success) return parsed;
417
+ const { userConsentedPolicy, categoryId, decision } = parsed.data;
418
+ return commit(snapshot.currentAppPolicy, setCategoryConsent({
419
+ userConsentedPolicy,
420
+ state: snapshot.state,
421
+ categoryId,
422
+ decision
423
+ }));
424
+ },
425
+ updatePolicy(input) {
426
+ const parsed = validateConsentPolicy(input);
427
+ if (!parsed.success) return parsed;
428
+ if (arePoliciesEqual(parsed.data, snapshot.currentAppPolicy)) return {
429
+ success: true,
430
+ data: snapshot
431
+ };
432
+ return commit(parsed.data, snapshot.state);
433
+ },
434
+ setConsentDecisions(command) {
435
+ const parsed = validateWithSchema(consentDecisionsCommandSchema, command);
436
+ if (!parsed.success) return parsed;
437
+ const result = setConsentDecisions(parsed.data, snapshot.state);
438
+ return result.success ? commit(snapshot.currentAppPolicy, result.data) : result;
439
+ },
440
+ subscribe: subscriptions.subscribe
441
+ })
442
+ };
443
+ }
444
+ //#endregion
445
+ export { createConsent };
@@ -0,0 +1,23 @@
1
+ /** One record, scoped by the adapter's key or endpoint. Only null means absent. */
2
+ declare type ConsentStorageAdapter = Readonly<{
3
+ read: () => string | null | Promise<string | null>;
4
+ write: (record: string) => void | Promise<void>;
5
+ /** Optional lazy, key-scoped external invalidations; notifications contain no stored values. */
6
+ subscribe?: (listener: () => void) => () => void;
7
+ }>;
8
+
9
+ export declare function createLocalForageAdapter({ key, store }: LocalForageAdapterOptions): ConsentStorageAdapter;
10
+
11
+ export declare type LocalForageAdapterOptions = Readonly<{
12
+ key: string;
13
+ /** Supply an existing configured instance, or use the lazy peax-consent/consent store. */
14
+ store?: LocalForageStore;
15
+ }>;
16
+
17
+ /** Structural interface keeps optional peer declarations out of the other entry points. */
18
+ export declare type LocalForageStore = Readonly<{
19
+ getItem: (key: string) => Promise<unknown>;
20
+ setItem: (key: string, record: string) => Promise<unknown>;
21
+ }>;
22
+
23
+ export { }
@@ -0,0 +1,26 @@
1
+ import { t as assertStorageIdentifier } from "../assertStorageIdentifier-BQuj-9bp.js";
2
+ //#region src/storage/localforage/createLocalForageAdapter.ts
3
+ function createLocalForageAdapter({ key, store }) {
4
+ assertStorageIdentifier(key);
5
+ let pendingStore;
6
+ const getStore = () => {
7
+ if (store) return Promise.resolve(store);
8
+ pendingStore ??= import("localforage").then(({ default: localforage }) => localforage.createInstance({
9
+ name: "peax-consent",
10
+ storeName: "consent"
11
+ }));
12
+ return pendingStore;
13
+ };
14
+ return Object.freeze({
15
+ read: async () => {
16
+ const record = await (await getStore()).getItem(key);
17
+ if (record !== null && typeof record !== "string") throw new TypeError("Expected a stored string or null.");
18
+ return record;
19
+ },
20
+ write: async (record) => {
21
+ await (await getStore()).setItem(key, record);
22
+ }
23
+ });
24
+ }
25
+ //#endregion
26
+ export { createLocalForageAdapter };