better-auth-cookie-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/README.md +328 -0
- package/dist/client.d.mts +114 -0
- package/dist/client.mjs +79 -0
- package/dist/client.mjs.map +1 -0
- package/dist/error-codes-D3VdAX9J.mjs +13 -0
- package/dist/error-codes-D3VdAX9J.mjs.map +1 -0
- package/dist/index-SuR6zFkq.d.mts +270 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +333 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# better-auth-cookie-consent
|
|
2
|
+
|
|
3
|
+
GDPR-compliant cookie consent management plugin for [Better Auth](https://better-auth.com/).
|
|
4
|
+
|
|
5
|
+
- Store and manage user cookie consent preferences
|
|
6
|
+
- Works for both anonymous and authenticated users
|
|
7
|
+
- Automatic merge of anonymous consent on sign-in / sign-up
|
|
8
|
+
- Consent versioning with automatic invalidation
|
|
9
|
+
- Validation schema support (e.g. Zod) to enforce consent shape
|
|
10
|
+
- Generic client plugin — `cookieConsentClient<z.infer<typeof schema>>()` types your consent model end-to-end
|
|
11
|
+
|
|
12
|
+
## How It Works
|
|
13
|
+
|
|
14
|
+
### Anonymous User Flow
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
|
18
|
+
│ User visits site │────▶│ Cookie banner shown │────▶│ User accepts/ │
|
|
19
|
+
│ (no session, │ │ (no server call if │ │ rejects/ │
|
|
20
|
+
│ no anonId) │ │ no anonId cookie) │ │ customizes │
|
|
21
|
+
└──────────────────┘ └─────────────────────┘ └────────┬─────────┘
|
|
22
|
+
│
|
|
23
|
+
▼
|
|
24
|
+
┌──────────────────┐
|
|
25
|
+
│ Client calls │
|
|
26
|
+
│ setConsent with │
|
|
27
|
+
│ typed consent │
|
|
28
|
+
│ object → server │
|
|
29
|
+
│ stores it │
|
|
30
|
+
└──────────────────┘
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Sign-In / Sign-Up Merge Flow
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
|
37
|
+
│ Anonymous user │────▶│ User signs in or │────▶│ Server hook reads│
|
|
38
|
+
│ has given consent │ │ signs up │ │ anonId from │
|
|
39
|
+
│ (anonId cookie │ │ │ │ cookie & merges │
|
|
40
|
+
│ is set) │ │ │ │ consent to user │
|
|
41
|
+
└──────────────────┘ └─────────────────────┘ └────────┬─────────┘
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
┌──────────────────┐
|
|
45
|
+
│ Consent now tied │
|
|
46
|
+
│ to userId — │
|
|
47
|
+
│ persists across │
|
|
48
|
+
│ devices/sessions │
|
|
49
|
+
└──────────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Session-Aware Banner Flow
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
|
56
|
+
│ Banner visible │────▶│ User logs in │────▶│ Client detects │
|
|
57
|
+
│ (no consent) │ │ │ │ session change │
|
|
58
|
+
│ │ │ │ │ (null → session) │
|
|
59
|
+
└──────────────────┘ └─────────────────────┘ └────────┬─────────┘
|
|
60
|
+
│
|
|
61
|
+
▼
|
|
62
|
+
┌──────────────────┐
|
|
63
|
+
│ Fetches consent │
|
|
64
|
+
│ from server → │
|
|
65
|
+
│ hides banner, │
|
|
66
|
+
│ persists anonId │
|
|
67
|
+
│ cookie for logout │
|
|
68
|
+
└──────────────────┘
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# npm
|
|
75
|
+
npm install better-auth-cookie-consent
|
|
76
|
+
|
|
77
|
+
# pnpm
|
|
78
|
+
pnpm add better-auth-cookie-consent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Server Setup
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { betterAuth } from 'better-auth';
|
|
85
|
+
import { cookieConsentPlugin, defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
86
|
+
|
|
87
|
+
export const auth = betterAuth({
|
|
88
|
+
// ... your config
|
|
89
|
+
plugins: [
|
|
90
|
+
cookieConsentPlugin({
|
|
91
|
+
consentVersion: 'v1',
|
|
92
|
+
consent: { validationSchema: defaultConsentSchema },
|
|
93
|
+
onConsentChange: async ({ consent }) => {
|
|
94
|
+
console.log('Consent changed:', consent);
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Plugin Options
|
|
102
|
+
|
|
103
|
+
| Option | Type | Default | Description |
|
|
104
|
+
| ------------------------ | ------------------ | ------- | -------------------------------------------- |
|
|
105
|
+
| consentVersion | `string` | `"v1"` | Current consent policy version |
|
|
106
|
+
| consent.validationSchema | `StandardSchemaV1` | — | Schema to validate consent (e.g. Zod object) |
|
|
107
|
+
| onConsentChange | `function` | — | Callback when consent is created/updated |
|
|
108
|
+
| rateLimit | `object` | — | Rate limit for set/merge endpoints |
|
|
109
|
+
| schema | `object` | — | Schema overrides for the cookieConsent table |
|
|
110
|
+
|
|
111
|
+
### Validation Schema
|
|
112
|
+
|
|
113
|
+
The plugin validates consent JSON against a `StandardSchemaV1` (e.g. Zod schema) on every write. A preset `defaultConsentSchema` is exported for the standard categories:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
117
|
+
|
|
118
|
+
// Equivalent to:
|
|
119
|
+
// z.object({
|
|
120
|
+
// necessary: z.boolean(),
|
|
121
|
+
// analytics: z.boolean(),
|
|
122
|
+
// marketing: z.boolean(),
|
|
123
|
+
// functional: z.boolean(),
|
|
124
|
+
// })
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Client Setup
|
|
128
|
+
|
|
129
|
+
The client plugin accepts a generic type parameter for the consent shape. Use `z.infer<typeof schema>` to get full end-to-end typing:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { createAuthClient } from 'better-auth/client';
|
|
133
|
+
import { defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
134
|
+
import { cookieConsentClient } from 'better-auth-cookie-consent/client';
|
|
135
|
+
import type { z } from 'zod';
|
|
136
|
+
|
|
137
|
+
type ConsentModel = z.infer<typeof defaultConsentSchema>;
|
|
138
|
+
// { necessary: boolean; analytics: boolean; marketing: boolean; functional: boolean }
|
|
139
|
+
|
|
140
|
+
export const authClient = createAuthClient({
|
|
141
|
+
plugins: [cookieConsentClient<ConsentModel>()],
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
This types `setConsent`, `getConsent`, and the nanostore atom so that your consent object is fully checked at compile time.
|
|
146
|
+
|
|
147
|
+
## Usage
|
|
148
|
+
|
|
149
|
+
### Set Consent
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
await authClient.cookieConsent.setConsent({
|
|
153
|
+
anonymousId: 'anon-123',
|
|
154
|
+
consent: {
|
|
155
|
+
necessary: true,
|
|
156
|
+
analytics: true,
|
|
157
|
+
marketing: false,
|
|
158
|
+
functional: true,
|
|
159
|
+
},
|
|
160
|
+
consentVersion: 'v1',
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Accept All / Reject All
|
|
165
|
+
|
|
166
|
+
Build the consent object on the client and call `setConsent`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// Accept all
|
|
170
|
+
await authClient.cookieConsent.setConsent({
|
|
171
|
+
anonymousId: 'anon-123',
|
|
172
|
+
consent: { necessary: true, analytics: true, marketing: true, functional: true },
|
|
173
|
+
consentVersion: 'v1',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Reject all (keep necessary)
|
|
177
|
+
await authClient.cookieConsent.setConsent({
|
|
178
|
+
anonymousId: 'anon-123',
|
|
179
|
+
consent: { necessary: true, analytics: false, marketing: false, functional: false },
|
|
180
|
+
consentVersion: 'v1',
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Get Consent
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const { data } = await authClient.cookieConsent.getConsent('anon-123');
|
|
188
|
+
// data.consent — the consent record (or null)
|
|
189
|
+
// data.versionMatch — whether stored version matches current
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Merge Anonymous Consent After Login
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
await authClient.cookieConsent.mergeConsent('anon-123');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
> **Note:** Manual merging is typically unnecessary — the plugin automatically merges anonymous consent when a user signs in or signs up via server-side hooks.
|
|
199
|
+
|
|
200
|
+
## Cookie Banner — What You Need to Handle
|
|
201
|
+
|
|
202
|
+
The plugin provides the server and client APIs but **does not include a cookie banner UI**. You need to implement the banner in your application. Here's what the banner should handle:
|
|
203
|
+
|
|
204
|
+
### 1. Anonymous ID Management
|
|
205
|
+
|
|
206
|
+
Store a unique anonymous ID in a cookie named `cookie-consent-anon-id`. This cookie must be:
|
|
207
|
+
|
|
208
|
+
- Created when the user first interacts with the banner (e.g. `crypto.randomUUID()`)
|
|
209
|
+
- Sent as a standard browser cookie so the server can read it during sign-in/sign-up hooks
|
|
210
|
+
- Readable via SSR (e.g. `injectRequest()` in Analog.js)
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// Set the anonymous ID cookie
|
|
214
|
+
document.cookie = `cookie-consent-anon-id=${id}; path=/; max-age=31536000; SameSite=Lax`;
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 2. Banner Visibility Logic
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
On page load:
|
|
221
|
+
IF anonId cookie exists OR user is logged in:
|
|
222
|
+
→ Fetch consent from server (GET /cookie-consent/get)
|
|
223
|
+
→ If consent exists and version matches → hide banner
|
|
224
|
+
→ Otherwise → show banner
|
|
225
|
+
ELSE:
|
|
226
|
+
→ Show banner immediately (no server call needed)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### 3. Session Change Detection
|
|
230
|
+
|
|
231
|
+
Subscribe to the auth client's session state. When the session transitions from `null` → logged-in:
|
|
232
|
+
|
|
233
|
+
1. Fetch consent from the server
|
|
234
|
+
2. If consent exists, hide the banner and persist the `anonymousId` cookie
|
|
235
|
+
3. This ensures the banner disappears immediately on login without a page reload
|
|
236
|
+
4. The persisted `anonymousId` ensures consent is still found after logout + page reload
|
|
237
|
+
|
|
238
|
+
### 4. Accept / Reject / Customize
|
|
239
|
+
|
|
240
|
+
All actions use the `setConsent` endpoint — the client builds the consent object:
|
|
241
|
+
|
|
242
|
+
- **Accept All**: Build `{ necessary: true, analytics: true, ... }` and call `setConsent`
|
|
243
|
+
- **Reject All**: Build `{ necessary: true, analytics: false, ... }` and call `setConsent`
|
|
244
|
+
- **Custom**: Use form values and call `setConsent`
|
|
245
|
+
|
|
246
|
+
The consent object is validated against the server's `validationSchema` on every write.
|
|
247
|
+
|
|
248
|
+
### 5. Typing Categories
|
|
249
|
+
|
|
250
|
+
Use `keyof` on the inferred consent model to type your banner categories:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import type { z } from 'zod';
|
|
254
|
+
import { defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
255
|
+
|
|
256
|
+
type ConsentModel = z.infer<typeof defaultConsentSchema>;
|
|
257
|
+
type CategoryId = keyof ConsentModel;
|
|
258
|
+
|
|
259
|
+
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
|
260
|
+
{ id: 'necessary', label: 'Necessary' },
|
|
261
|
+
{ id: 'analytics', label: 'Analytics' },
|
|
262
|
+
// TypeScript enforces that id must be a valid consent category
|
|
263
|
+
];
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### 6. Re-open Banner
|
|
267
|
+
|
|
268
|
+
After consent is recorded, provide a way for users to manage their preferences (e.g. a "Manage Cookies" link in the footer).
|
|
269
|
+
|
|
270
|
+
## Server-Side Helpers
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { getConsentFromCtx, hasConsent } from 'better-auth-cookie-consent';
|
|
274
|
+
|
|
275
|
+
// Inside an endpoint handler:
|
|
276
|
+
const record = await getConsentFromCtx(ctx);
|
|
277
|
+
|
|
278
|
+
if (await hasConsent(ctx, 'analytics')) {
|
|
279
|
+
// tracking is allowed
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## API Endpoints
|
|
284
|
+
|
|
285
|
+
| Method | Path | Description |
|
|
286
|
+
| ------ | ----------------------- | ------------------------------- |
|
|
287
|
+
| POST | `/cookie-consent/set` | Create or update consent |
|
|
288
|
+
| GET | `/cookie-consent/get` | Retrieve consent |
|
|
289
|
+
| POST | `/cookie-consent/merge` | Merge anonymous consent to user |
|
|
290
|
+
|
|
291
|
+
## Auto-Merge on Sign-In / Sign-Up
|
|
292
|
+
|
|
293
|
+
The plugin registers a server-side `after` hook that runs on every `sign-in/*` and `sign-up/*` path. When the `cookie-consent-anon-id` cookie is present in the request, the hook automatically merges the anonymous consent record to the newly authenticated user. This covers all auth methods (email, social, biometrics, passkey, etc.).
|
|
294
|
+
|
|
295
|
+
## Consent Versioning
|
|
296
|
+
|
|
297
|
+
When the `consentVersion` option changes, the `getConsent` endpoint returns `versionMatch: false` so the client knows to prompt for re-consent.
|
|
298
|
+
|
|
299
|
+
## Database Schema
|
|
300
|
+
|
|
301
|
+
The plugin creates a `cookieConsent` table:
|
|
302
|
+
|
|
303
|
+
| Column | Type | Description |
|
|
304
|
+
| -------------- | ------- | -------------------------------- |
|
|
305
|
+
| id | string | Primary key |
|
|
306
|
+
| userId | string? | References user table |
|
|
307
|
+
| anonymousId | string | Anonymous client identifier |
|
|
308
|
+
| consent | string | JSON-encoded consent preferences |
|
|
309
|
+
| consentVersion | string | Consent policy version |
|
|
310
|
+
| timestamp | date | When consent was recorded |
|
|
311
|
+
|
|
312
|
+
### Prisma Schema
|
|
313
|
+
|
|
314
|
+
```prisma
|
|
315
|
+
model CookieConsent {
|
|
316
|
+
id String @id @default(cuid())
|
|
317
|
+
userId String?
|
|
318
|
+
anonymousId String
|
|
319
|
+
consent String
|
|
320
|
+
consentVersion String
|
|
321
|
+
timestamp DateTime @default(now())
|
|
322
|
+
user User? @relation(fields: [userId], references: [id])
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## License
|
|
327
|
+
|
|
328
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { n as cookieConsentPlugin, o as Consent } from "./index-SuR6zFkq.mjs";
|
|
2
|
+
import * as better_auth0 from "better-auth";
|
|
3
|
+
import * as nanostores from "nanostores";
|
|
4
|
+
import * as better_auth_client0 from "better-auth/client";
|
|
5
|
+
|
|
6
|
+
//#region src/client.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* Client-side consent state, kept in sync with the server.
|
|
9
|
+
*/
|
|
10
|
+
interface ConsentState<TConsent extends Consent = Consent> {
|
|
11
|
+
consent: TConsent | null;
|
|
12
|
+
consentVersion: string | null;
|
|
13
|
+
versionMatch: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Client plugin for cookie consent management.
|
|
17
|
+
*
|
|
18
|
+
* @typeParam TConsent - The consent shape, typically `z.infer<typeof yourConsentSchema>`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
23
|
+
* import { cookieConsentClient } from 'better-auth-cookie-consent/client';
|
|
24
|
+
* import type { z } from 'zod';
|
|
25
|
+
*
|
|
26
|
+
* const authClient = createAuthClient({
|
|
27
|
+
* plugins: [cookieConsentClient<z.infer<typeof defaultConsentSchema>>()],
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
declare const cookieConsentClient: <TConsent extends Consent = Consent>() => {
|
|
32
|
+
id: "cookie-consent";
|
|
33
|
+
$InferServerPlugin: ReturnType<typeof cookieConsentPlugin>;
|
|
34
|
+
getAtoms($fetch: better_auth_client0.BetterFetch): {
|
|
35
|
+
$consent: nanostores.PreinitializedWritableAtom<ConsentState<TConsent>> & object;
|
|
36
|
+
};
|
|
37
|
+
getActions($fetch: better_auth_client0.BetterFetch, $store: better_auth0.ClientStore): {
|
|
38
|
+
cookieConsent: {
|
|
39
|
+
/**
|
|
40
|
+
* Set consent preferences on the server.
|
|
41
|
+
* Also used for accept-all / reject-all by passing the
|
|
42
|
+
* appropriate consent object (all `true` or all `false`).
|
|
43
|
+
*/
|
|
44
|
+
setConsent: (data: {
|
|
45
|
+
anonymousId: string;
|
|
46
|
+
consent: TConsent;
|
|
47
|
+
consentVersion: string;
|
|
48
|
+
}) => Promise<{
|
|
49
|
+
data: {
|
|
50
|
+
status: boolean;
|
|
51
|
+
};
|
|
52
|
+
error: null;
|
|
53
|
+
} | {
|
|
54
|
+
data: null;
|
|
55
|
+
error: {
|
|
56
|
+
message?: string | undefined;
|
|
57
|
+
status: number;
|
|
58
|
+
statusText: string;
|
|
59
|
+
};
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Retrieve consent from the server.
|
|
63
|
+
*/
|
|
64
|
+
getConsent: (anonymousId?: string) => Promise<{
|
|
65
|
+
data: null;
|
|
66
|
+
error: {
|
|
67
|
+
message?: string | undefined;
|
|
68
|
+
status: number;
|
|
69
|
+
statusText: string;
|
|
70
|
+
};
|
|
71
|
+
} | {
|
|
72
|
+
data: {
|
|
73
|
+
consent: {
|
|
74
|
+
id: string;
|
|
75
|
+
userId?: string | null;
|
|
76
|
+
anonymousId: string;
|
|
77
|
+
consent: TConsent;
|
|
78
|
+
consentVersion: string;
|
|
79
|
+
timestamp: string;
|
|
80
|
+
} | null;
|
|
81
|
+
versionMatch: boolean;
|
|
82
|
+
};
|
|
83
|
+
error: null;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Merge anonymous consent into the authenticated user's record.
|
|
87
|
+
*/
|
|
88
|
+
mergeConsent: (anonymousId: string) => Promise<{
|
|
89
|
+
data: null;
|
|
90
|
+
error: {
|
|
91
|
+
message?: string | undefined;
|
|
92
|
+
status: number;
|
|
93
|
+
statusText: string;
|
|
94
|
+
};
|
|
95
|
+
} | {
|
|
96
|
+
data: {
|
|
97
|
+
status: boolean;
|
|
98
|
+
merged: boolean;
|
|
99
|
+
};
|
|
100
|
+
error: null;
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
$ERROR_CODES: {
|
|
105
|
+
MISSING_ANONYMOUS_ID: better_auth0.RawError<"MISSING_ANONYMOUS_ID">;
|
|
106
|
+
CONSENT_NOT_FOUND: better_auth0.RawError<"CONSENT_NOT_FOUND">;
|
|
107
|
+
INVALID_CONSENT: better_auth0.RawError<"INVALID_CONSENT">;
|
|
108
|
+
VERSION_MISMATCH: better_auth0.RawError<"VERSION_MISMATCH">;
|
|
109
|
+
AUTHENTICATION_REQUIRED: better_auth0.RawError<"AUTHENTICATION_REQUIRED">;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
//#endregion
|
|
113
|
+
export { ConsentState, cookieConsentClient };
|
|
114
|
+
//# sourceMappingURL=client.d.mts.map
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { t as COOKIE_CONSENT_ERROR_CODES } from "./error-codes-D3VdAX9J.mjs";
|
|
2
|
+
import { atom } from "nanostores";
|
|
3
|
+
//#region src/client.ts
|
|
4
|
+
/**
|
|
5
|
+
* Client plugin for cookie consent management.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam TConsent - The consent shape, typically `z.infer<typeof yourConsentSchema>`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { defaultConsentSchema } from 'better-auth-cookie-consent';
|
|
12
|
+
* import { cookieConsentClient } from 'better-auth-cookie-consent/client';
|
|
13
|
+
* import type { z } from 'zod';
|
|
14
|
+
*
|
|
15
|
+
* const authClient = createAuthClient({
|
|
16
|
+
* plugins: [cookieConsentClient<z.infer<typeof defaultConsentSchema>>()],
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
const cookieConsentClient = () => {
|
|
21
|
+
return {
|
|
22
|
+
id: "cookie-consent",
|
|
23
|
+
$InferServerPlugin: {},
|
|
24
|
+
getAtoms($fetch) {
|
|
25
|
+
return { $consent: atom({
|
|
26
|
+
consent: null,
|
|
27
|
+
consentVersion: null,
|
|
28
|
+
versionMatch: false
|
|
29
|
+
}) };
|
|
30
|
+
},
|
|
31
|
+
getActions($fetch, $store) {
|
|
32
|
+
const consentAtom = $store.atoms.$consent;
|
|
33
|
+
async function syncFromServer(anonymousId) {
|
|
34
|
+
let path = "/cookie-consent/get";
|
|
35
|
+
if (anonymousId) {
|
|
36
|
+
const params = new URLSearchParams({ anonymousId });
|
|
37
|
+
path = `${path}?${params.toString()}`;
|
|
38
|
+
}
|
|
39
|
+
const res = await $fetch(`${path}`, { method: "GET" });
|
|
40
|
+
if (res.data) consentAtom.set({
|
|
41
|
+
consent: res.data.consent?.consent ?? null,
|
|
42
|
+
consentVersion: res.data.consent?.consentVersion ?? null,
|
|
43
|
+
versionMatch: res.data.versionMatch
|
|
44
|
+
});
|
|
45
|
+
return res;
|
|
46
|
+
}
|
|
47
|
+
return { cookieConsent: {
|
|
48
|
+
setConsent: async (data) => {
|
|
49
|
+
const res = await $fetch("/cookie-consent/set", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: data
|
|
52
|
+
});
|
|
53
|
+
if (res.data?.status) consentAtom.set({
|
|
54
|
+
consent: data.consent,
|
|
55
|
+
consentVersion: data.consentVersion,
|
|
56
|
+
versionMatch: true
|
|
57
|
+
});
|
|
58
|
+
return res;
|
|
59
|
+
},
|
|
60
|
+
getConsent: async (anonymousId) => {
|
|
61
|
+
return syncFromServer(anonymousId);
|
|
62
|
+
},
|
|
63
|
+
mergeConsent: async (anonymousId) => {
|
|
64
|
+
const res = await $fetch("/cookie-consent/merge", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
body: { anonymousId }
|
|
67
|
+
});
|
|
68
|
+
if (res.data?.merged) await syncFromServer(anonymousId);
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
} };
|
|
72
|
+
},
|
|
73
|
+
$ERROR_CODES: COOKIE_CONSENT_ERROR_CODES
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { cookieConsentClient };
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=client.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["import type { BetterAuthClientPlugin } from 'better-auth/client';\nimport { atom } from 'nanostores';\n\nimport { COOKIE_CONSENT_ERROR_CODES } from './error-codes';\nimport type { Consent, cookieConsentPlugin } from './index';\n\n/**\n * Client-side consent state, kept in sync with the server.\n */\nexport interface ConsentState<TConsent extends Consent = Consent> {\n consent: TConsent | null;\n consentVersion: string | null;\n versionMatch: boolean;\n}\n\n/**\n * Client plugin for cookie consent management.\n *\n * @typeParam TConsent - The consent shape, typically `z.infer<typeof yourConsentSchema>`.\n *\n * @example\n * ```ts\n * import { defaultConsentSchema } from 'better-auth-cookie-consent';\n * import { cookieConsentClient } from 'better-auth-cookie-consent/client';\n * import type { z } from 'zod';\n *\n * const authClient = createAuthClient({\n * plugins: [cookieConsentClient<z.infer<typeof defaultConsentSchema>>()],\n * });\n * ```\n */\nexport const cookieConsentClient = <TConsent extends Consent = Consent>() => {\n return {\n id: 'cookie-consent',\n $InferServerPlugin: {} as ReturnType<typeof cookieConsentPlugin>,\n\n getAtoms($fetch) {\n const $consent = atom<ConsentState<TConsent>>({\n consent: null,\n consentVersion: null,\n versionMatch: false,\n });\n return { $consent };\n },\n\n getActions($fetch, $store) {\n const consentAtom = $store.atoms.$consent as ReturnType<typeof atom<ConsentState<TConsent>>>;\n\n async function syncFromServer(anonymousId?: string) {\n let path = '/cookie-consent/get';\n if (anonymousId) {\n const params = new URLSearchParams({ anonymousId });\n path = `${path}?${params.toString()}`;\n }\n const res = await $fetch<{\n consent: {\n id: string;\n userId?: string | null;\n anonymousId: string;\n consent: TConsent;\n consentVersion: string;\n timestamp: string;\n } | null;\n versionMatch: boolean;\n }>(`${path}`, { method: 'GET' });\n if (res.data) {\n consentAtom.set({\n consent: res.data.consent?.consent ?? null,\n consentVersion: res.data.consent?.consentVersion ?? null,\n versionMatch: res.data.versionMatch,\n });\n }\n return res;\n }\n\n return {\n cookieConsent: {\n /**\n * Set consent preferences on the server.\n * Also used for accept-all / reject-all by passing the\n * appropriate consent object (all `true` or all `false`).\n */\n setConsent: async (data: {\n anonymousId: string;\n consent: TConsent;\n consentVersion: string;\n }) => {\n const res = await $fetch<{ status: boolean }>('/cookie-consent/set', {\n method: 'POST',\n body: data,\n });\n if (res.data?.status) {\n consentAtom.set({\n consent: data.consent,\n consentVersion: data.consentVersion,\n versionMatch: true,\n });\n }\n return res;\n },\n\n /**\n * Retrieve consent from the server.\n */\n getConsent: async (anonymousId?: string) => {\n return syncFromServer(anonymousId);\n },\n\n /**\n * Merge anonymous consent into the authenticated user's record.\n */\n mergeConsent: async (anonymousId: string) => {\n const res = await $fetch<{ status: boolean; merged: boolean }>(\n '/cookie-consent/merge',\n {\n method: 'POST',\n body: { anonymousId },\n },\n );\n if (res.data?.merged) {\n await syncFromServer(anonymousId);\n }\n return res;\n },\n },\n };\n },\n\n $ERROR_CODES: COOKIE_CONSENT_ERROR_CODES,\n } satisfies BetterAuthClientPlugin;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA+BA,MAAa,4BAAgE;AAC3E,QAAO;EACL,IAAI;EACJ,oBAAoB,EAAE;EAEtB,SAAS,QAAQ;AAMf,UAAO,EAAE,UALQ,KAA6B;IAC5C,SAAS;IACT,gBAAgB;IAChB,cAAc;IACf,CAAC,EACiB;;EAGrB,WAAW,QAAQ,QAAQ;GACzB,MAAM,cAAc,OAAO,MAAM;GAEjC,eAAe,eAAe,aAAsB;IAClD,IAAI,OAAO;AACX,QAAI,aAAa;KACf,MAAM,SAAS,IAAI,gBAAgB,EAAE,aAAa,CAAC;AACnD,YAAO,GAAG,KAAK,GAAG,OAAO,UAAU;;IAErC,MAAM,MAAM,MAAM,OAUf,GAAG,QAAQ,EAAE,QAAQ,OAAO,CAAC;AAChC,QAAI,IAAI,KACN,aAAY,IAAI;KACd,SAAS,IAAI,KAAK,SAAS,WAAW;KACtC,gBAAgB,IAAI,KAAK,SAAS,kBAAkB;KACpD,cAAc,IAAI,KAAK;KACxB,CAAC;AAEJ,WAAO;;AAGT,UAAO,EACL,eAAe;IAMb,YAAY,OAAO,SAIb;KACJ,MAAM,MAAM,MAAM,OAA4B,uBAAuB;MACnE,QAAQ;MACR,MAAM;MACP,CAAC;AACF,SAAI,IAAI,MAAM,OACZ,aAAY,IAAI;MACd,SAAS,KAAK;MACd,gBAAgB,KAAK;MACrB,cAAc;MACf,CAAC;AAEJ,YAAO;;IAMT,YAAY,OAAO,gBAAyB;AAC1C,YAAO,eAAe,YAAY;;IAMpC,cAAc,OAAO,gBAAwB;KAC3C,MAAM,MAAM,MAAM,OAChB,yBACA;MACE,QAAQ;MACR,MAAM,EAAE,aAAa;MACtB,CACF;AACD,SAAI,IAAI,MAAM,OACZ,OAAM,eAAe,YAAY;AAEnC,YAAO;;IAEV,EACF;;EAGH,cAAc;EACf"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineErrorCodes } from "better-auth";
|
|
2
|
+
//#region src/error-codes.ts
|
|
3
|
+
const COOKIE_CONSENT_ERROR_CODES = defineErrorCodes({
|
|
4
|
+
MISSING_ANONYMOUS_ID: "Anonymous ID is required",
|
|
5
|
+
CONSENT_NOT_FOUND: "Cookie consent record not found",
|
|
6
|
+
INVALID_CONSENT: "Consent must be a non-empty object with boolean values",
|
|
7
|
+
VERSION_MISMATCH: "Consent version is outdated and must be renewed",
|
|
8
|
+
AUTHENTICATION_REQUIRED: "Authentication is required for this operation"
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { COOKIE_CONSENT_ERROR_CODES as t };
|
|
12
|
+
|
|
13
|
+
//# sourceMappingURL=error-codes-D3VdAX9J.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-codes-D3VdAX9J.mjs","names":[],"sources":["../src/error-codes.ts"],"sourcesContent":["import { defineErrorCodes } from 'better-auth';\n\nexport const COOKIE_CONSENT_ERROR_CODES = defineErrorCodes({\n MISSING_ANONYMOUS_ID: 'Anonymous ID is required',\n CONSENT_NOT_FOUND: 'Cookie consent record not found',\n INVALID_CONSENT: 'Consent must be a non-empty object with boolean values',\n VERSION_MISMATCH: 'Consent version is outdated and must be renewed',\n AUTHENTICATION_REQUIRED: 'Authentication is required for this operation',\n});\n"],"mappings":";;AAEA,MAAa,6BAA6B,iBAAiB;CACzD,sBAAsB;CACtB,mBAAmB;CACnB,iBAAiB;CACjB,kBAAkB;CAClB,yBAAyB;CAC1B,CAAC"}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import * as better_auth0 from "better-auth";
|
|
3
|
+
import { BetterAuthOptions, DBAdapter, InferOptionSchema, StandardSchemaV1 } from "better-auth";
|
|
4
|
+
|
|
5
|
+
//#region src/schema.d.ts
|
|
6
|
+
declare const cookieConsent: {
|
|
7
|
+
cookieConsent: {
|
|
8
|
+
fields: {
|
|
9
|
+
userId: {
|
|
10
|
+
type: "string";
|
|
11
|
+
required: false;
|
|
12
|
+
references: {
|
|
13
|
+
model: string;
|
|
14
|
+
field: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
anonymousId: {
|
|
18
|
+
type: "string";
|
|
19
|
+
required: true;
|
|
20
|
+
};
|
|
21
|
+
consent: {
|
|
22
|
+
type: "string";
|
|
23
|
+
required: true;
|
|
24
|
+
};
|
|
25
|
+
consentVersion: {
|
|
26
|
+
type: "string";
|
|
27
|
+
required: true;
|
|
28
|
+
};
|
|
29
|
+
timestamp: {
|
|
30
|
+
type: "date";
|
|
31
|
+
defaultValue: () => Date;
|
|
32
|
+
required: true;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/type.d.ts
|
|
39
|
+
/**
|
|
40
|
+
* Consent categories mapped to boolean values.
|
|
41
|
+
* Each key represents a cookie category (e.g., "analytics", "marketing").
|
|
42
|
+
*/
|
|
43
|
+
type ConsentSchemaModel = z.ZodObject<Record<string, z.ZodBoolean>>;
|
|
44
|
+
type Consent = z.infer<ConsentSchemaModel>;
|
|
45
|
+
/**
|
|
46
|
+
* Options for the cookie consent plugin.
|
|
47
|
+
*/
|
|
48
|
+
interface CookieConsentOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Current consent version identifier.
|
|
51
|
+
* When this changes, stored consents with older versions
|
|
52
|
+
* are considered outdated and users must re-consent.
|
|
53
|
+
* @default "v1"
|
|
54
|
+
*/
|
|
55
|
+
consentVersion?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Callback invoked whenever consent changes.
|
|
58
|
+
* @param data the consent record and request
|
|
59
|
+
*/
|
|
60
|
+
onConsentChange?: (data: {
|
|
61
|
+
consent: CookieConsentRecord;
|
|
62
|
+
}, request?: Request) => Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Rate limit configuration for consent endpoints.
|
|
65
|
+
*/
|
|
66
|
+
rateLimit?: {
|
|
67
|
+
/**
|
|
68
|
+
* Time window in seconds for which the rate limit applies.
|
|
69
|
+
* @default 10
|
|
70
|
+
*/
|
|
71
|
+
window: number;
|
|
72
|
+
/**
|
|
73
|
+
* Maximum number of requests allowed within the time window.
|
|
74
|
+
* @default 10 requests
|
|
75
|
+
*/
|
|
76
|
+
max: number;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Schema overrides for the cookie consent table.
|
|
80
|
+
*/
|
|
81
|
+
schema?: InferOptionSchema<typeof cookieConsent> | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Consent validation configuration.
|
|
84
|
+
*/
|
|
85
|
+
consent?: {
|
|
86
|
+
/**
|
|
87
|
+
* A Standard Schema (e.g. Zod) used to validate the consent
|
|
88
|
+
* object on every `setConsent` call. When omitted, the
|
|
89
|
+
* consent object is accepted as-is (any `Consent`).
|
|
90
|
+
*/
|
|
91
|
+
validationSchema?: StandardSchemaV1 & ConsentSchemaModel;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A stored cookie consent record.
|
|
96
|
+
*/
|
|
97
|
+
interface CookieConsentRecord {
|
|
98
|
+
/**
|
|
99
|
+
* Database identifier.
|
|
100
|
+
*/
|
|
101
|
+
id: string;
|
|
102
|
+
/**
|
|
103
|
+
* The authenticated user ID, if available.
|
|
104
|
+
*/
|
|
105
|
+
userId?: string | null;
|
|
106
|
+
/**
|
|
107
|
+
* An anonymous identifier for unauthenticated users.
|
|
108
|
+
*/
|
|
109
|
+
anonymousId: string;
|
|
110
|
+
/**
|
|
111
|
+
* JSON-encoded consent preferences.
|
|
112
|
+
*/
|
|
113
|
+
consent: string;
|
|
114
|
+
/**
|
|
115
|
+
* The version of the consent policy.
|
|
116
|
+
*/
|
|
117
|
+
consentVersion: string;
|
|
118
|
+
/**
|
|
119
|
+
* Timestamp when the consent was recorded.
|
|
120
|
+
*/
|
|
121
|
+
timestamp: Date;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Payload for creating/updating a consent record (without auto-generated fields).
|
|
125
|
+
*/
|
|
126
|
+
type CookieConsentPayload = Omit<CookieConsentRecord, 'id'>;
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/index.d.ts
|
|
129
|
+
/**
|
|
130
|
+
* Cookie name used to store the anonymous consent ID.
|
|
131
|
+
* The client sets this cookie so the server-side hook can
|
|
132
|
+
* read it during sign-in to auto-merge anonymous consent.
|
|
133
|
+
*/
|
|
134
|
+
declare const ANONYMOUS_ID_COOKIE = "cookie-consent-anon-id";
|
|
135
|
+
/**
|
|
136
|
+
* Preset validation schema for cookie consent.
|
|
137
|
+
* Validates the standard consent categories: necessary, analytics, marketing, functional.
|
|
138
|
+
* Use this with `consent.validationSchema` in the plugin options.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* cookieConsentPlugin({
|
|
143
|
+
* consentVersion: 'v1',
|
|
144
|
+
* consent: { validationSchema: defaultConsentSchema },
|
|
145
|
+
* })
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
declare const defaultConsentSchema: z.ZodObject<{
|
|
149
|
+
necessary: z.ZodBoolean;
|
|
150
|
+
analytics: z.ZodBoolean;
|
|
151
|
+
marketing: z.ZodBoolean;
|
|
152
|
+
functional: z.ZodBoolean;
|
|
153
|
+
}, z.core.$strip>;
|
|
154
|
+
declare const cookieConsentPlugin: <O extends CookieConsentOptions>(options?: O) => {
|
|
155
|
+
id: "cookie-consent";
|
|
156
|
+
schema: {
|
|
157
|
+
cookieConsent: {
|
|
158
|
+
fields: {
|
|
159
|
+
userId: {
|
|
160
|
+
type: "string";
|
|
161
|
+
required: false;
|
|
162
|
+
references: {
|
|
163
|
+
model: string;
|
|
164
|
+
field: string;
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
anonymousId: {
|
|
168
|
+
type: "string";
|
|
169
|
+
required: true;
|
|
170
|
+
};
|
|
171
|
+
consent: {
|
|
172
|
+
type: "string";
|
|
173
|
+
required: true;
|
|
174
|
+
};
|
|
175
|
+
consentVersion: {
|
|
176
|
+
type: "string";
|
|
177
|
+
required: true;
|
|
178
|
+
};
|
|
179
|
+
timestamp: {
|
|
180
|
+
type: "date";
|
|
181
|
+
defaultValue: () => Date;
|
|
182
|
+
required: true;
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
endpoints: {
|
|
188
|
+
setConsent: better_auth0.StrictEndpoint<"/cookie-consent/set", {
|
|
189
|
+
method: "POST";
|
|
190
|
+
body: z.ZodObject<{
|
|
191
|
+
anonymousId: z.ZodString;
|
|
192
|
+
consent: z.ZodRecord<z.ZodString, z.ZodBoolean>;
|
|
193
|
+
consentVersion: z.ZodString;
|
|
194
|
+
}, z.core.$strip>;
|
|
195
|
+
}, {
|
|
196
|
+
status: boolean;
|
|
197
|
+
}>;
|
|
198
|
+
getConsent: better_auth0.StrictEndpoint<"/cookie-consent/get", {
|
|
199
|
+
method: "GET";
|
|
200
|
+
query: z.ZodObject<{
|
|
201
|
+
anonymousId: z.ZodOptional<z.ZodString>;
|
|
202
|
+
}, z.core.$strip>;
|
|
203
|
+
}, {
|
|
204
|
+
consent: null;
|
|
205
|
+
versionMatch: boolean;
|
|
206
|
+
} | {
|
|
207
|
+
consent: {
|
|
208
|
+
id: string;
|
|
209
|
+
userId: string | null | undefined;
|
|
210
|
+
anonymousId: string;
|
|
211
|
+
consent: Consent;
|
|
212
|
+
consentVersion: string;
|
|
213
|
+
timestamp: Date;
|
|
214
|
+
};
|
|
215
|
+
versionMatch: boolean;
|
|
216
|
+
}>;
|
|
217
|
+
mergeConsent: better_auth0.StrictEndpoint<"/cookie-consent/merge", {
|
|
218
|
+
method: "POST";
|
|
219
|
+
body: z.ZodObject<{
|
|
220
|
+
anonymousId: z.ZodString;
|
|
221
|
+
}, z.core.$strip>;
|
|
222
|
+
}, {
|
|
223
|
+
status: boolean;
|
|
224
|
+
merged: boolean;
|
|
225
|
+
}>;
|
|
226
|
+
};
|
|
227
|
+
hooks: {
|
|
228
|
+
after: {
|
|
229
|
+
matcher: (context: better_auth0.HookEndpointContext) => boolean;
|
|
230
|
+
handler: (inputContext: better_auth0.MiddlewareInputContext<better_auth0.MiddlewareOptions>) => Promise<void>;
|
|
231
|
+
}[];
|
|
232
|
+
};
|
|
233
|
+
options: NoInfer<O>;
|
|
234
|
+
rateLimit: {
|
|
235
|
+
pathMatcher: (path: string) => boolean;
|
|
236
|
+
window: number;
|
|
237
|
+
max: number;
|
|
238
|
+
}[];
|
|
239
|
+
$ERROR_CODES: {
|
|
240
|
+
MISSING_ANONYMOUS_ID: better_auth0.RawError<"MISSING_ANONYMOUS_ID">;
|
|
241
|
+
CONSENT_NOT_FOUND: better_auth0.RawError<"CONSENT_NOT_FOUND">;
|
|
242
|
+
INVALID_CONSENT: better_auth0.RawError<"INVALID_CONSENT">;
|
|
243
|
+
VERSION_MISMATCH: better_auth0.RawError<"VERSION_MISMATCH">;
|
|
244
|
+
AUTHENTICATION_REQUIRED: better_auth0.RawError<"AUTHENTICATION_REQUIRED">;
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Extract the parsed consent object from the endpoint context.
|
|
249
|
+
* Returns `null` if no consent is available.
|
|
250
|
+
*/
|
|
251
|
+
declare function getConsentFromCtx(ctx: {
|
|
252
|
+
context: {
|
|
253
|
+
adapter: DBAdapter<BetterAuthOptions>;
|
|
254
|
+
session?: {
|
|
255
|
+
user?: {
|
|
256
|
+
id?: string;
|
|
257
|
+
};
|
|
258
|
+
} | null;
|
|
259
|
+
};
|
|
260
|
+
query?: {
|
|
261
|
+
anonymousId?: string;
|
|
262
|
+
};
|
|
263
|
+
}): Promise<CookieConsentRecord | null>;
|
|
264
|
+
/**
|
|
265
|
+
* Check if a specific consent category is granted.
|
|
266
|
+
*/
|
|
267
|
+
declare function hasConsent(ctx: Parameters<typeof getConsentFromCtx>[0], category: string): Promise<boolean>;
|
|
268
|
+
//#endregion
|
|
269
|
+
export { hasConsent as a, CookieConsentOptions as c, getConsentFromCtx as i, CookieConsentPayload as l, cookieConsentPlugin as n, Consent as o, defaultConsentSchema as r, ConsentSchemaModel as s, ANONYMOUS_ID_COOKIE as t, CookieConsentRecord as u };
|
|
270
|
+
//# sourceMappingURL=index-SuR6zFkq.d.mts.map
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as hasConsent, c as CookieConsentOptions, i as getConsentFromCtx, l as CookieConsentPayload, n as cookieConsentPlugin, o as Consent, r as defaultConsentSchema, s as ConsentSchemaModel, t as ANONYMOUS_ID_COOKIE, u as CookieConsentRecord } from "./index-SuR6zFkq.mjs";
|
|
2
|
+
export { ANONYMOUS_ID_COOKIE, Consent, ConsentSchemaModel, CookieConsentOptions, CookieConsentPayload, CookieConsentRecord, cookieConsentPlugin, defaultConsentSchema, getConsentFromCtx, hasConsent };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { t as COOKIE_CONSENT_ERROR_CODES } from "./error-codes-D3VdAX9J.mjs";
|
|
2
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { BASE_ERROR_CODES } from "better-auth";
|
|
5
|
+
import { mergeSchema } from "better-auth/db";
|
|
6
|
+
//#region src/routes.ts
|
|
7
|
+
const setConsentSchema = z.object({
|
|
8
|
+
anonymousId: z.string().meta({ description: "Anonymous identifier for unauthenticated users" }),
|
|
9
|
+
consent: z.record(z.string(), z.boolean()).meta({ description: "Consent preferences as category-boolean pairs" }),
|
|
10
|
+
consentVersion: z.string().meta({ description: "Version of the consent policy" })
|
|
11
|
+
});
|
|
12
|
+
const getConsentSchema = z.object({ anonymousId: z.string().optional().meta({ description: "Anonymous identifier fallback when no session exists" }) });
|
|
13
|
+
const mergeConsentSchema = z.object({ anonymousId: z.string().meta({ description: "Anonymous identifier to merge consent from" }) });
|
|
14
|
+
const setConsent = (options) => createAuthEndpoint("/cookie-consent/set", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: setConsentSchema
|
|
17
|
+
}, async (ctx) => {
|
|
18
|
+
const { anonymousId, consent, consentVersion } = ctx.body;
|
|
19
|
+
if (!anonymousId) throw APIError.from("BAD_REQUEST", COOKIE_CONSENT_ERROR_CODES.MISSING_ANONYMOUS_ID);
|
|
20
|
+
if (!consent || Object.keys(consent).length === 0) throw APIError.from("BAD_REQUEST", COOKIE_CONSENT_ERROR_CODES.INVALID_CONSENT);
|
|
21
|
+
const validatedConsent = validateConsent(options, consent, ctx.context.logger);
|
|
22
|
+
const userId = (await getSessionFromCtx(ctx))?.user?.id ?? null;
|
|
23
|
+
const existing = await findConsentRecord(ctx, userId, anonymousId);
|
|
24
|
+
const consentJson = JSON.stringify(validatedConsent);
|
|
25
|
+
const now = /* @__PURE__ */ new Date();
|
|
26
|
+
if (existing) await ctx.context.adapter.update({
|
|
27
|
+
model: "cookieConsent",
|
|
28
|
+
where: [{
|
|
29
|
+
field: "id",
|
|
30
|
+
value: existing.id
|
|
31
|
+
}],
|
|
32
|
+
update: {
|
|
33
|
+
userId,
|
|
34
|
+
consent: consentJson,
|
|
35
|
+
consentVersion,
|
|
36
|
+
timestamp: now
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
else await ctx.context.adapter.create({
|
|
40
|
+
model: "cookieConsent",
|
|
41
|
+
data: {
|
|
42
|
+
userId,
|
|
43
|
+
anonymousId,
|
|
44
|
+
consent: consentJson,
|
|
45
|
+
consentVersion,
|
|
46
|
+
timestamp: now
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (options.onConsentChange) {
|
|
50
|
+
const record = await findConsentRecord(ctx, userId, anonymousId) ?? {
|
|
51
|
+
id: "",
|
|
52
|
+
userId,
|
|
53
|
+
anonymousId,
|
|
54
|
+
consent: consentJson,
|
|
55
|
+
consentVersion,
|
|
56
|
+
timestamp: now
|
|
57
|
+
};
|
|
58
|
+
await ctx.context.runInBackgroundOrAwait(options.onConsentChange({ consent: record }, ctx.request));
|
|
59
|
+
}
|
|
60
|
+
return ctx.json({ status: true });
|
|
61
|
+
});
|
|
62
|
+
const getConsent = (options) => createAuthEndpoint("/cookie-consent/get", {
|
|
63
|
+
method: "GET",
|
|
64
|
+
query: getConsentSchema
|
|
65
|
+
}, async (ctx) => {
|
|
66
|
+
const userId = (await getSessionFromCtx(ctx))?.user?.id ?? null;
|
|
67
|
+
const anonymousId = ctx.query?.anonymousId;
|
|
68
|
+
if (!userId && !anonymousId) throw APIError.from("BAD_REQUEST", COOKIE_CONSENT_ERROR_CODES.MISSING_ANONYMOUS_ID);
|
|
69
|
+
const record = await findConsentRecord(ctx, userId, anonymousId);
|
|
70
|
+
if (!record) return ctx.json({
|
|
71
|
+
consent: null,
|
|
72
|
+
versionMatch: false
|
|
73
|
+
});
|
|
74
|
+
const currentVersion = options.consentVersion ?? "v1";
|
|
75
|
+
const versionMatch = record.consentVersion === currentVersion;
|
|
76
|
+
return ctx.json({
|
|
77
|
+
consent: {
|
|
78
|
+
id: record.id,
|
|
79
|
+
userId: record.userId,
|
|
80
|
+
anonymousId: record.anonymousId,
|
|
81
|
+
consent: JSON.parse(record.consent),
|
|
82
|
+
consentVersion: record.consentVersion,
|
|
83
|
+
timestamp: record.timestamp
|
|
84
|
+
},
|
|
85
|
+
versionMatch
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
const mergeConsent = (_options) => createAuthEndpoint("/cookie-consent/merge", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: mergeConsentSchema
|
|
91
|
+
}, async (ctx) => {
|
|
92
|
+
const userId = (await getSessionFromCtx(ctx))?.user?.id;
|
|
93
|
+
if (!userId) throw APIError.from("UNAUTHORIZED", COOKIE_CONSENT_ERROR_CODES.AUTHENTICATION_REQUIRED);
|
|
94
|
+
const merged = await mergeAnonymousConsentToUser(ctx.context.adapter, userId, ctx.body.anonymousId);
|
|
95
|
+
return ctx.json({
|
|
96
|
+
status: true,
|
|
97
|
+
merged
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* Merge anonymous consent into a user's record.
|
|
102
|
+
* Shared between the merge endpoint and the sign-in/sign-up hook.
|
|
103
|
+
*
|
|
104
|
+
* @returns `true` if consent was merged, `false` if no anonymous record was found.
|
|
105
|
+
*/
|
|
106
|
+
async function mergeAnonymousConsentToUser(adapter, userId, anonymousId) {
|
|
107
|
+
const anonymousRecord = await adapter.findOne({
|
|
108
|
+
model: "cookieConsent",
|
|
109
|
+
where: [{
|
|
110
|
+
field: "anonymousId",
|
|
111
|
+
value: anonymousId
|
|
112
|
+
}]
|
|
113
|
+
});
|
|
114
|
+
if (!anonymousRecord || anonymousRecord.userId) return false;
|
|
115
|
+
const userRecord = await adapter.findOne({
|
|
116
|
+
model: "cookieConsent",
|
|
117
|
+
where: [{
|
|
118
|
+
field: "userId",
|
|
119
|
+
value: userId
|
|
120
|
+
}]
|
|
121
|
+
});
|
|
122
|
+
if (userRecord) {
|
|
123
|
+
await adapter.update({
|
|
124
|
+
model: "cookieConsent",
|
|
125
|
+
where: [{
|
|
126
|
+
field: "id",
|
|
127
|
+
value: userRecord.id
|
|
128
|
+
}],
|
|
129
|
+
update: {
|
|
130
|
+
consent: anonymousRecord.consent,
|
|
131
|
+
consentVersion: anonymousRecord.consentVersion,
|
|
132
|
+
anonymousId,
|
|
133
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
await adapter.delete({
|
|
137
|
+
model: "cookieConsent",
|
|
138
|
+
where: [{
|
|
139
|
+
field: "id",
|
|
140
|
+
value: anonymousRecord.id
|
|
141
|
+
}]
|
|
142
|
+
});
|
|
143
|
+
} else await adapter.update({
|
|
144
|
+
model: "cookieConsent",
|
|
145
|
+
where: [{
|
|
146
|
+
field: "id",
|
|
147
|
+
value: anonymousRecord.id
|
|
148
|
+
}],
|
|
149
|
+
update: { userId }
|
|
150
|
+
});
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Find a consent record by userId (preferred) or anonymousId fallback.
|
|
155
|
+
*/
|
|
156
|
+
async function findConsentRecord(ctx, userId, anonymousId) {
|
|
157
|
+
if (userId) {
|
|
158
|
+
const byUser = await ctx.context.adapter.findOne({
|
|
159
|
+
model: "cookieConsent",
|
|
160
|
+
where: [{
|
|
161
|
+
field: "userId",
|
|
162
|
+
value: userId
|
|
163
|
+
}]
|
|
164
|
+
});
|
|
165
|
+
if (byUser) return byUser;
|
|
166
|
+
}
|
|
167
|
+
if (anonymousId) return ctx.context.adapter.findOne({
|
|
168
|
+
model: "cookieConsent",
|
|
169
|
+
where: [{
|
|
170
|
+
field: "anonymousId",
|
|
171
|
+
value: anonymousId
|
|
172
|
+
}]
|
|
173
|
+
});
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Validate the consent object against the configured validation schema.
|
|
178
|
+
* Returns the validated consent or the original consent when no schema is set.
|
|
179
|
+
*/
|
|
180
|
+
function validateConsent(options, consent, logger) {
|
|
181
|
+
if (!options.consent?.validationSchema) return consent;
|
|
182
|
+
const validationResult = options.consent.validationSchema["~standard"].validate(consent);
|
|
183
|
+
if (validationResult instanceof Promise) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.ASYNC_VALIDATION_NOT_SUPPORTED);
|
|
184
|
+
if (validationResult.issues) {
|
|
185
|
+
logger.error("Invalid consent", validationResult.issues);
|
|
186
|
+
throw APIError.from("BAD_REQUEST", COOKIE_CONSENT_ERROR_CODES.INVALID_CONSENT);
|
|
187
|
+
}
|
|
188
|
+
return validationResult.value;
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/schema.ts
|
|
192
|
+
const cookieConsent = { cookieConsent: { fields: {
|
|
193
|
+
userId: {
|
|
194
|
+
type: "string",
|
|
195
|
+
required: false,
|
|
196
|
+
references: {
|
|
197
|
+
model: "user",
|
|
198
|
+
field: "id"
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
anonymousId: {
|
|
202
|
+
type: "string",
|
|
203
|
+
required: true
|
|
204
|
+
},
|
|
205
|
+
consent: {
|
|
206
|
+
type: "string",
|
|
207
|
+
required: true
|
|
208
|
+
},
|
|
209
|
+
consentVersion: {
|
|
210
|
+
type: "string",
|
|
211
|
+
required: true
|
|
212
|
+
},
|
|
213
|
+
timestamp: {
|
|
214
|
+
type: "date",
|
|
215
|
+
defaultValue: () => /* @__PURE__ */ new Date(),
|
|
216
|
+
required: true
|
|
217
|
+
}
|
|
218
|
+
} } };
|
|
219
|
+
const getSchema = (options) => {
|
|
220
|
+
return mergeSchema(cookieConsent, options.schema);
|
|
221
|
+
};
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/index.ts
|
|
224
|
+
/**
|
|
225
|
+
* Cookie name used to store the anonymous consent ID.
|
|
226
|
+
* The client sets this cookie so the server-side hook can
|
|
227
|
+
* read it during sign-in to auto-merge anonymous consent.
|
|
228
|
+
*/
|
|
229
|
+
const ANONYMOUS_ID_COOKIE = "cookie-consent-anon-id";
|
|
230
|
+
/**
|
|
231
|
+
* Parse a single cookie value from a raw `Cookie` header string.
|
|
232
|
+
*/
|
|
233
|
+
function parseCookieValue(cookieHeader, name) {
|
|
234
|
+
if (!cookieHeader) return void 0;
|
|
235
|
+
for (const pair of cookieHeader.split("; ")) {
|
|
236
|
+
const [key, ...valueParts] = pair.split("=");
|
|
237
|
+
if (key === name && valueParts.length > 0) {
|
|
238
|
+
const raw = valueParts.join("=");
|
|
239
|
+
try {
|
|
240
|
+
return decodeURIComponent(raw);
|
|
241
|
+
} catch {
|
|
242
|
+
return raw;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Preset validation schema for cookie consent.
|
|
249
|
+
* Validates the standard consent categories: necessary, analytics, marketing, functional.
|
|
250
|
+
* Use this with `consent.validationSchema` in the plugin options.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* cookieConsentPlugin({
|
|
255
|
+
* consentVersion: 'v1',
|
|
256
|
+
* consent: { validationSchema: defaultConsentSchema },
|
|
257
|
+
* })
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
const defaultConsentSchema = z.object({
|
|
261
|
+
necessary: z.boolean(),
|
|
262
|
+
analytics: z.boolean(),
|
|
263
|
+
marketing: z.boolean(),
|
|
264
|
+
functional: z.boolean()
|
|
265
|
+
});
|
|
266
|
+
const cookieConsentPlugin = (options = {}) => {
|
|
267
|
+
return {
|
|
268
|
+
id: "cookie-consent",
|
|
269
|
+
schema: getSchema(options),
|
|
270
|
+
endpoints: {
|
|
271
|
+
setConsent: setConsent(options),
|
|
272
|
+
getConsent: getConsent(options),
|
|
273
|
+
mergeConsent: mergeConsent(options)
|
|
274
|
+
},
|
|
275
|
+
hooks: { after: [{
|
|
276
|
+
matcher: (context) => {
|
|
277
|
+
return !!context.path && (context.path.startsWith("/sign-in/") || context.path.startsWith("/sign-up/"));
|
|
278
|
+
},
|
|
279
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
280
|
+
const userId = ctx.context.newSession?.user?.id;
|
|
281
|
+
if (!userId) return;
|
|
282
|
+
const anonymousId = parseCookieValue(ctx.headers?.get("cookie"), ANONYMOUS_ID_COOKIE);
|
|
283
|
+
if (!anonymousId) return;
|
|
284
|
+
await mergeAnonymousConsentToUser(ctx.context.adapter, userId, anonymousId);
|
|
285
|
+
})
|
|
286
|
+
}] },
|
|
287
|
+
options,
|
|
288
|
+
rateLimit: [{
|
|
289
|
+
pathMatcher: (path) => ["/cookie-consent/set", "/cookie-consent/merge"].includes(path),
|
|
290
|
+
window: options.rateLimit?.window ?? 10,
|
|
291
|
+
max: options.rateLimit?.max ?? 10
|
|
292
|
+
}],
|
|
293
|
+
$ERROR_CODES: COOKIE_CONSENT_ERROR_CODES
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Extract the parsed consent object from the endpoint context.
|
|
298
|
+
* Returns `null` if no consent is available.
|
|
299
|
+
*/
|
|
300
|
+
async function getConsentFromCtx(ctx) {
|
|
301
|
+
const userId = ctx.context.session?.user?.id ?? null;
|
|
302
|
+
const anonymousId = ctx.query?.anonymousId;
|
|
303
|
+
if (userId) {
|
|
304
|
+
const byUser = await ctx.context.adapter.findOne({
|
|
305
|
+
model: "cookieConsent",
|
|
306
|
+
where: [{
|
|
307
|
+
field: "userId",
|
|
308
|
+
value: userId
|
|
309
|
+
}]
|
|
310
|
+
});
|
|
311
|
+
if (byUser) return byUser;
|
|
312
|
+
}
|
|
313
|
+
if (anonymousId) return ctx.context.adapter.findOne({
|
|
314
|
+
model: "cookieConsent",
|
|
315
|
+
where: [{
|
|
316
|
+
field: "anonymousId",
|
|
317
|
+
value: anonymousId
|
|
318
|
+
}]
|
|
319
|
+
});
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Check if a specific consent category is granted.
|
|
324
|
+
*/
|
|
325
|
+
async function hasConsent(ctx, category) {
|
|
326
|
+
const record = await getConsentFromCtx(ctx);
|
|
327
|
+
if (!record) return false;
|
|
328
|
+
return JSON.parse(record.consent)[category] === true;
|
|
329
|
+
}
|
|
330
|
+
//#endregion
|
|
331
|
+
export { ANONYMOUS_ID_COOKIE, cookieConsentPlugin, defaultConsentSchema, getConsentFromCtx, hasConsent };
|
|
332
|
+
|
|
333
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/routes.ts","../src/schema.ts","../src/index.ts"],"sourcesContent":["import {\n BASE_ERROR_CODES,\n type BetterAuthOptions,\n type DBAdapter,\n type InternalLogger,\n} from 'better-auth';\nimport { APIError, createAuthEndpoint, getSessionFromCtx } from 'better-auth/api';\nimport * as z from 'zod';\n\nimport { COOKIE_CONSENT_ERROR_CODES } from './error-codes';\nimport type {\n Consent,\n CookieConsentOptions,\n CookieConsentPayload,\n CookieConsentRecord,\n} from './type';\n\nconst setConsentSchema = z.object({\n anonymousId: z.string().meta({\n description: 'Anonymous identifier for unauthenticated users',\n }),\n consent: z.record(z.string(), z.boolean()).meta({\n description: 'Consent preferences as category-boolean pairs',\n }),\n consentVersion: z.string().meta({\n description: 'Version of the consent policy',\n }),\n});\n\nconst getConsentSchema = z.object({\n anonymousId: z.string().optional().meta({\n description: 'Anonymous identifier fallback when no session exists',\n }),\n});\n\nconst mergeConsentSchema = z.object({\n anonymousId: z.string().meta({\n description: 'Anonymous identifier to merge consent from',\n }),\n});\n\nexport const setConsent = <O extends CookieConsentOptions>(options: O) =>\n createAuthEndpoint(\n '/cookie-consent/set',\n {\n method: 'POST',\n body: setConsentSchema,\n },\n async (ctx) => {\n const { anonymousId, consent, consentVersion } = ctx.body;\n\n if (!anonymousId) {\n throw APIError.from('BAD_REQUEST', COOKIE_CONSENT_ERROR_CODES.MISSING_ANONYMOUS_ID);\n }\n\n if (!consent || Object.keys(consent).length === 0) {\n throw APIError.from('BAD_REQUEST', COOKIE_CONSENT_ERROR_CODES.INVALID_CONSENT);\n }\n\n const validatedConsent = validateConsent(options, consent, ctx.context.logger);\n\n const session = await getSessionFromCtx(ctx);\n const userId = session?.user?.id ?? null;\n\n // Look for existing record by userId or anonymousId\n const existing = await findConsentRecord(ctx, userId, anonymousId);\n\n const consentJson = JSON.stringify(validatedConsent);\n const now = new Date();\n\n if (existing) {\n await ctx.context.adapter.update<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'id', value: existing.id }],\n update: {\n userId,\n consent: consentJson,\n consentVersion,\n timestamp: now,\n },\n });\n } else {\n await ctx.context.adapter.create<CookieConsentPayload, CookieConsentRecord>({\n model: 'cookieConsent',\n data: {\n userId,\n anonymousId,\n consent: consentJson,\n consentVersion,\n timestamp: now,\n },\n });\n }\n\n if (options.onConsentChange) {\n const record =\n (await findConsentRecord(ctx, userId, anonymousId)) ??\n ({\n id: '',\n userId,\n anonymousId,\n consent: consentJson,\n consentVersion,\n timestamp: now,\n } as CookieConsentRecord);\n\n await ctx.context.runInBackgroundOrAwait(\n options.onConsentChange({ consent: record }, ctx.request),\n );\n }\n\n return ctx.json({ status: true });\n },\n );\n\nexport const getConsent = <O extends CookieConsentOptions>(options: O) =>\n createAuthEndpoint(\n '/cookie-consent/get',\n {\n method: 'GET',\n query: getConsentSchema,\n },\n async (ctx) => {\n const session = await getSessionFromCtx(ctx);\n const userId = session?.user?.id ?? null;\n const anonymousId = ctx.query?.anonymousId;\n\n if (!userId && !anonymousId) {\n throw APIError.from('BAD_REQUEST', COOKIE_CONSENT_ERROR_CODES.MISSING_ANONYMOUS_ID);\n }\n\n const record = await findConsentRecord(ctx, userId, anonymousId);\n\n if (!record) {\n return ctx.json({ consent: null, versionMatch: false });\n }\n\n const currentVersion = options.consentVersion ?? 'v1';\n const versionMatch = record.consentVersion === currentVersion;\n\n return ctx.json({\n consent: {\n id: record.id,\n userId: record.userId,\n anonymousId: record.anonymousId,\n consent: JSON.parse(record.consent) as Consent,\n consentVersion: record.consentVersion,\n timestamp: record.timestamp,\n },\n versionMatch,\n });\n },\n );\n\nexport const mergeConsent = <O extends CookieConsentOptions>(_options: O) =>\n createAuthEndpoint(\n '/cookie-consent/merge',\n {\n method: 'POST',\n body: mergeConsentSchema,\n },\n async (ctx) => {\n const session = await getSessionFromCtx(ctx);\n const userId = session?.user?.id;\n\n if (!userId) {\n throw APIError.from('UNAUTHORIZED', COOKIE_CONSENT_ERROR_CODES.AUTHENTICATION_REQUIRED);\n }\n\n const merged = await mergeAnonymousConsentToUser(\n ctx.context.adapter,\n userId,\n ctx.body.anonymousId,\n );\n return ctx.json({ status: true, merged });\n },\n );\n\n/**\n * Merge anonymous consent into a user's record.\n * Shared between the merge endpoint and the sign-in/sign-up hook.\n *\n * @returns `true` if consent was merged, `false` if no anonymous record was found.\n */\nexport async function mergeAnonymousConsentToUser(\n adapter: DBAdapter<BetterAuthOptions>,\n userId: string,\n anonymousId: string,\n): Promise<boolean> {\n const anonymousRecord = await adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'anonymousId', value: anonymousId }],\n });\n\n if (!anonymousRecord || anonymousRecord.userId) return false;\n // ^ Skip if record doesn't exist or is already linked to a user (already merged)\n\n const userRecord = await adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'userId', value: userId }],\n });\n\n if (userRecord) {\n // User already has consent; update with anonymous data\n await adapter.update<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'id', value: userRecord.id }],\n update: {\n consent: anonymousRecord.consent,\n consentVersion: anonymousRecord.consentVersion,\n anonymousId,\n timestamp: new Date(),\n },\n });\n await adapter.delete({\n model: 'cookieConsent',\n where: [{ field: 'id', value: anonymousRecord.id }],\n });\n } else {\n // Attach anonymous consent to user\n await adapter.update<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'id', value: anonymousRecord.id }],\n update: { userId },\n });\n }\n\n return true;\n}\n\n/**\n * Find a consent record by userId (preferred) or anonymousId fallback.\n */\nasync function findConsentRecord(\n ctx: {\n context: {\n adapter: DBAdapter<BetterAuthOptions>;\n };\n },\n userId: string | null | undefined,\n anonymousId: string | undefined,\n): Promise<CookieConsentRecord | null> {\n if (userId) {\n const byUser = await ctx.context.adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'userId', value: userId }],\n });\n if (byUser) return byUser;\n }\n\n if (anonymousId) {\n return ctx.context.adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'anonymousId', value: anonymousId }],\n });\n }\n\n return null;\n}\n\n/**\n * Validate the consent object against the configured validation schema.\n * Returns the validated consent or the original consent when no schema is set.\n */\nfunction validateConsent(\n options: CookieConsentOptions,\n consent: Consent,\n logger: InternalLogger,\n): Consent {\n if (!options.consent?.validationSchema) {\n return consent;\n }\n const validationResult = options.consent.validationSchema['~standard'].validate(consent);\n\n if (validationResult instanceof Promise) {\n throw APIError.from('INTERNAL_SERVER_ERROR', BASE_ERROR_CODES.ASYNC_VALIDATION_NOT_SUPPORTED);\n }\n\n if (validationResult.issues) {\n logger.error('Invalid consent', validationResult.issues);\n throw APIError.from('BAD_REQUEST', COOKIE_CONSENT_ERROR_CODES.INVALID_CONSENT);\n }\n\n return validationResult.value as Consent;\n}\n","import { type BetterAuthPluginDBSchema } from 'better-auth';\nimport { mergeSchema } from 'better-auth/db';\n\nimport type { CookieConsentOptions } from './type';\n\nexport const cookieConsent = {\n cookieConsent: {\n fields: {\n userId: {\n type: 'string',\n required: false,\n references: {\n model: 'user',\n field: 'id',\n },\n },\n anonymousId: {\n type: 'string',\n required: true,\n },\n consent: {\n type: 'string',\n required: true,\n },\n consentVersion: {\n type: 'string',\n required: true,\n },\n timestamp: {\n type: 'date',\n defaultValue: () => new Date(),\n required: true,\n },\n },\n },\n} satisfies BetterAuthPluginDBSchema;\n\nexport const getSchema = <O extends CookieConsentOptions>(options: O) => {\n return mergeSchema(cookieConsent, options.schema);\n};\n","import type { BetterAuthOptions, BetterAuthPlugin, DBAdapter } from 'better-auth';\nimport { createAuthMiddleware } from 'better-auth/api';\nimport * as z from 'zod';\n\nimport { COOKIE_CONSENT_ERROR_CODES } from './error-codes';\nimport { getConsent, mergeAnonymousConsentToUser, mergeConsent, setConsent } from './routes';\nimport { getSchema } from './schema';\nimport type {\n Consent,\n ConsentSchemaModel,\n CookieConsentOptions,\n CookieConsentRecord,\n} from './type';\n\n/**\n * Cookie name used to store the anonymous consent ID.\n * The client sets this cookie so the server-side hook can\n * read it during sign-in to auto-merge anonymous consent.\n */\nexport const ANONYMOUS_ID_COOKIE = 'cookie-consent-anon-id';\n\n/**\n * Parse a single cookie value from a raw `Cookie` header string.\n */\nfunction parseCookieValue(\n cookieHeader: string | null | undefined,\n name: string,\n): string | undefined {\n if (!cookieHeader) return undefined;\n for (const pair of cookieHeader.split('; ')) {\n const [key, ...valueParts] = pair.split('=');\n if (key === name && valueParts.length > 0) {\n const raw = valueParts.join('=');\n try {\n return decodeURIComponent(raw);\n } catch {\n return raw;\n }\n }\n }\n return undefined;\n}\n\n/**\n * Preset validation schema for cookie consent.\n * Validates the standard consent categories: necessary, analytics, marketing, functional.\n * Use this with `consent.validationSchema` in the plugin options.\n *\n * @example\n * ```ts\n * cookieConsentPlugin({\n * consentVersion: 'v1',\n * consent: { validationSchema: defaultConsentSchema },\n * })\n * ```\n */\nexport const defaultConsentSchema = z.object({\n necessary: z.boolean(),\n analytics: z.boolean(),\n marketing: z.boolean(),\n functional: z.boolean(),\n}) satisfies ConsentSchemaModel;\n\nexport const cookieConsentPlugin = <O extends CookieConsentOptions>(options: O = {} as O) => {\n return {\n id: 'cookie-consent',\n schema: getSchema(options),\n endpoints: {\n setConsent: setConsent(options),\n getConsent: getConsent(options),\n mergeConsent: mergeConsent(options),\n },\n hooks: {\n after: [\n {\n // After sign-in or sign-up (any method), merge anonymous consent to user.\n // Uses prefix matching to cover all auth methods: email, social,\n // biometrics, passkey, phone, etc.\n matcher: (context) => {\n return (\n !!context.path &&\n (context.path.startsWith('/sign-in/') || context.path.startsWith('/sign-up/'))\n );\n },\n handler: createAuthMiddleware(async (ctx) => {\n const userId = ctx.context.newSession?.user?.id;\n if (!userId) return;\n\n // Read anonymousId from the cookie set by the client\n const anonymousId = parseCookieValue(ctx.headers?.get('cookie'), ANONYMOUS_ID_COOKIE);\n if (!anonymousId) return;\n\n await mergeAnonymousConsentToUser(ctx.context.adapter, userId, anonymousId);\n }),\n },\n ],\n },\n options: options as NoInfer<O>,\n rateLimit: [\n {\n pathMatcher: (path) => ['/cookie-consent/set', '/cookie-consent/merge'].includes(path),\n window: options.rateLimit?.window ?? 10,\n max: options.rateLimit?.max ?? 10,\n },\n ],\n $ERROR_CODES: COOKIE_CONSENT_ERROR_CODES,\n } satisfies BetterAuthPlugin;\n};\n\n// ─── Helper utilities ──────────────────────────────────────────────────\n\n/**\n * Extract the parsed consent object from the endpoint context.\n * Returns `null` if no consent is available.\n */\nexport async function getConsentFromCtx(ctx: {\n context: {\n adapter: DBAdapter<BetterAuthOptions>;\n session?: { user?: { id?: string } } | null;\n };\n query?: { anonymousId?: string };\n}): Promise<CookieConsentRecord | null> {\n const userId = ctx.context.session?.user?.id ?? null;\n const anonymousId = ctx.query?.anonymousId;\n\n if (userId) {\n const byUser = await ctx.context.adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'userId', value: userId }],\n });\n if (byUser) return byUser;\n }\n\n if (anonymousId) {\n return ctx.context.adapter.findOne<CookieConsentRecord>({\n model: 'cookieConsent',\n where: [{ field: 'anonymousId', value: anonymousId }],\n });\n }\n\n return null;\n}\n\n/**\n * Check if a specific consent category is granted.\n */\nexport async function hasConsent(\n ctx: Parameters<typeof getConsentFromCtx>[0],\n category: string,\n): Promise<boolean> {\n const record = await getConsentFromCtx(ctx);\n if (!record) return false;\n const parsed = JSON.parse(record.consent) as Consent;\n return parsed[category] === true;\n}\n\nexport type * from './type';\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-auth-cookie-consent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Better Auth Cookie Consent plugin for GDPR-compliant consent management",
|
|
5
|
+
"homepage": "https://github.com/marcjulian/better-auth-plugins#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/marcjulian/better-auth-plugins/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Gary Großgarten <info@gary.dev>",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/marcjulian/better-auth-plugins.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.mjs",
|
|
20
|
+
"module": "./dist/index.mjs",
|
|
21
|
+
"types": "./dist/index.d.mts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./dist/index.mjs",
|
|
24
|
+
"./client": "./dist/client.mjs",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"nanostores": "^1.1.1",
|
|
29
|
+
"zod": "^4.3.6"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^25.3.5",
|
|
33
|
+
"bumpp": "^10.4.1",
|
|
34
|
+
"tsdown": "^0.21.1",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^4.0.18"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"better-auth": "^1.5.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"dev": "tsdown --watch",
|
|
44
|
+
"test": "vitest",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|