Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/necessary-consent-always-granted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@junctionjs/core": patch
---

fix(core): pin `necessary: true` in consent state so it is always granted and cannot be overridden
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-debug.log*
yarn-error.log*
.vercel
.env*.local

# Worktrees
.worktrees/
10 changes: 5 additions & 5 deletions packages/core/src/collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ describe("Collector", () => {
vi.advanceTimersByTime(3000);

const event = (dest.transform as any).mock.calls[0][0] as JctEvent;
expect(event.context.consent).toEqual({ analytics: true, marketing: false });
expect(event.context.consent).toEqual({ necessary: true, analytics: true, marketing: false });
});

it("does not include was_queued for non-queued events", () => {
Expand Down Expand Up @@ -740,7 +740,7 @@ describe("Collector", () => {

const event = (dest.transform as any).mock.calls[0][0] as JctEvent;
// The consent snapshot should reflect the resolved state, not the original pending state
expect(event.context.consent).toEqual({ analytics: true });
expect(event.context.consent).toEqual({ necessary: true, analytics: true });
});

it("consent snapshot reflects state at track() time, not dispatch time", () => {
Expand All @@ -759,7 +759,7 @@ describe("Collector", () => {

const event = (dest.transform as any).mock.calls[0][0] as JctEvent;
// Event was built with the state at track() time
expect(event.context.consent).toEqual({ analytics: true, marketing: false });
expect(event.context.consent).toEqual({ necessary: true, analytics: true, marketing: false });
});
});

Expand All @@ -784,7 +784,7 @@ describe("Collector", () => {
collector.consent({ analytics: true });

expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith({ analytics: true });
expect(update).toHaveBeenCalledWith({ necessary: true, analytics: true });
});

it("calls signal update() before destination onConsent()", () => {
Expand Down Expand Up @@ -901,7 +901,7 @@ describe("Collector", () => {
const collector = createCollector(options);

expect(() => collector.consent({ analytics: false })).not.toThrow();
expect(dest.onConsent).toHaveBeenCalledWith({ analytics: false });
expect(dest.onConsent).toHaveBeenCalledWith({ necessary: true, analytics: false });
});
});
});
10 changes: 5 additions & 5 deletions packages/core/src/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("ConsentManager", () => {

it("starts with empty state when no defaults provided", () => {
manager = createConsentManager(makeConfig());
expect(manager.getState()).toEqual({});
expect(manager.getState()).toEqual({ necessary: true });
});
});

Expand All @@ -69,7 +69,7 @@ describe("ConsentManager", () => {
manager = createConsentManager(makeConfig({ defaultState: { analytics: false } }));

manager.setState({ analytics: true });
expect(manager.getState()).toEqual({ analytics: true });
expect(manager.getState()).toEqual({ necessary: true, analytics: true });
});

it("notifies listeners on change", () => {
Expand All @@ -80,7 +80,7 @@ describe("ConsentManager", () => {
manager.setState({ analytics: true });

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ analytics: true }, {});
expect(listener).toHaveBeenCalledWith({ necessary: true, analytics: true }, { necessary: true });
});

it("passes previous state to listeners", () => {
Expand Down Expand Up @@ -388,7 +388,7 @@ describe("ConsentManager", () => {
vi.advanceTimersByTime(3500);

expect(listener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toEqual({ analytics: false, marketing: false });
expect(manager.getState()).toEqual({ necessary: true, analytics: false, marketing: false });
});

it("cancels fallback timer when setState() is called before timeout", () => {
Expand All @@ -410,7 +410,7 @@ describe("ConsentManager", () => {
// Advance past fallback timeout β€” should NOT fire again
vi.advanceTimersByTime(6000);
expect(listener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toEqual({ analytics: true, marketing: true });
expect(manager.getState()).toEqual({ necessary: true, analytics: true, marketing: true });
});

it("does not apply fallback when not configured", () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export interface ConsentManager {
// ─── Implementation ──────────────────────────────────────────────

export function createConsentManager(config: ConsentConfig): ConsentManager {
let state: ConsentState = { ...config.defaultState };
let state: ConsentState = { necessary: true, ...config.defaultState };
let queue: QueuedEvent[] = [];
const listeners = new Set<ConsentListener>();

Expand Down Expand Up @@ -117,14 +117,15 @@ export function createConsentManager(config: ConsentConfig): ConsentManager {

// Merge β€” don't replace. This allows incremental consent updates
// (e.g., user grants analytics first, marketing later).
state = { ...state, ...update };
// "necessary" is always granted β€” external callers cannot override it.
state = { ...state, ...update, necessary: true };

notify(previous);
},

reset() {
const previous = { ...state };
state = { ...config.defaultState };
state = { necessary: true, ...config.defaultState };
queue = [];
notify(previous);
},
Expand Down
Loading