payload-better-auth 1.1.6 → 2.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 +109 -13
- package/dist/better-auth/crypto-shared.d.ts +0 -34
- package/dist/better-auth/crypto-shared.js +0 -14
- package/dist/better-auth/crypto-shared.js.map +1 -1
- package/dist/better-auth/plugin.d.ts +40 -2
- package/dist/better-auth/plugin.js +148 -21
- package/dist/better-auth/plugin.js.map +1 -1
- package/dist/better-auth/reconcile-queue.d.ts +0 -4
- package/dist/better-auth/reconcile-queue.js +0 -16
- package/dist/better-auth/reconcile-queue.js.map +1 -1
- package/dist/better-auth/sources.d.ts +6 -11
- package/dist/better-auth/sources.js +9 -30
- package/dist/better-auth/sources.js.map +1 -1
- package/dist/collections/Users/index.d.ts +18 -4
- package/dist/collections/Users/index.js +93 -43
- package/dist/collections/Users/index.js.map +1 -1
- package/dist/eventBus/RedisEventBus.d.ts +92 -0
- package/dist/eventBus/RedisEventBus.js +117 -0
- package/dist/eventBus/RedisEventBus.js.map +1 -0
- package/dist/eventBus/SqlitePollingEventBus.d.ts +81 -0
- package/dist/eventBus/SqlitePollingEventBus.js +139 -0
- package/dist/eventBus/SqlitePollingEventBus.js.map +1 -0
- package/dist/eventBus/index.d.ts +3 -0
- package/dist/eventBus/index.js +4 -0
- package/dist/eventBus/index.js.map +1 -0
- package/dist/eventBus/types.d.ts +30 -0
- package/dist/eventBus/types.js +18 -0
- package/dist/eventBus/types.js.map +1 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/payload/plugin.d.ts +39 -1
- package/dist/payload/plugin.js +20 -18
- package/dist/payload/plugin.js.map +1 -1
- package/dist/shared/deduplicatedLogger.d.ts +57 -0
- package/dist/shared/deduplicatedLogger.js +66 -0
- package/dist/shared/deduplicatedLogger.js.map +1 -0
- package/dist/storage/RedisStorage.d.ts +42 -0
- package/dist/storage/RedisStorage.js +36 -0
- package/dist/storage/RedisStorage.js.map +1 -0
- package/dist/storage/SqliteStorage.d.ts +46 -0
- package/dist/storage/SqliteStorage.js +88 -0
- package/dist/storage/SqliteStorage.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/keys.js +9 -0
- package/dist/storage/keys.js.map +1 -0
- package/dist/storage/types.d.ts +26 -0
- package/dist/storage/types.js +10 -0
- package/dist/storage/types.js.map +1 -0
- package/package.json +28 -3
- package/src/better-auth/crypto-shared.ts +0 -40
- package/src/better-auth/plugin.ts +211 -31
- package/src/better-auth/reconcile-queue.ts +0 -24
- package/src/better-auth/sources.ts +14 -31
- package/src/collections/Users/index.ts +146 -42
- package/src/eventBus/RedisEventBus.ts +201 -0
- package/src/eventBus/SqlitePollingEventBus.ts +228 -0
- package/src/eventBus/index.ts +10 -0
- package/src/eventBus/types.ts +33 -0
- package/src/index.ts +12 -2
- package/src/payload/plugin.ts +63 -27
- package/src/shared/deduplicatedLogger.ts +125 -0
- package/src/storage/RedisStorage.ts +67 -0
- package/src/storage/SqliteStorage.ts +133 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/keys.ts +16 -0
- package/src/storage/types.ts +29 -0
- package/dist/better-auth/databaseHooks.d.ts +0 -5
- package/src/better-auth/databaseHooks.ts +0 -30
package/README.md
CHANGED
|
@@ -5,9 +5,11 @@ A Payload CMS plugin that integrates [Better Auth](https://better-auth.com) for
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Better Auth as Single Source of Truth** — All user operations managed through Better Auth
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
8
|
+
- **SecondaryStorage Pattern** — Pluggable storage with SQLite (dev) or Redis (production)
|
|
9
|
+
- **Instant Session Validation** — Payload reads sessions directly from shared storage (no HTTP calls)
|
|
10
|
+
- **Automatic Session Invalidation** — Logout in Better Auth immediately invalidates Payload sessions
|
|
11
|
+
- **Horizontal Scaling** — Redis adapter supports multiple instances
|
|
12
|
+
- **Timestamp-based Coordination** — Automatic reconciliation without race conditions
|
|
11
13
|
- **Custom Login UI** — Replaces Payload's default login with Better Auth authentication
|
|
12
14
|
|
|
13
15
|
## Installation
|
|
@@ -16,11 +18,29 @@ A Payload CMS plugin that integrates [Better Auth](https://better-auth.com) for
|
|
|
16
18
|
pnpm add payload-better-auth better-auth
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
**Requirements:** Node.js
|
|
21
|
+
**Requirements:** Node.js 22+ (for native SQLite), Better Auth 1.4.10+, Payload CMS 3.37.0+
|
|
20
22
|
|
|
21
23
|
## Quick Start
|
|
22
24
|
|
|
23
|
-
### 1.
|
|
25
|
+
### 1. Create Shared Storage & EventBus
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// lib/syncAdapter.ts
|
|
29
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
30
|
+
import { createSqliteStorage } from 'payload-better-auth/storage'
|
|
31
|
+
|
|
32
|
+
const db = new DatabaseSync('.sync-state.db')
|
|
33
|
+
export const storage = createSqliteStorage({ db })
|
|
34
|
+
|
|
35
|
+
// lib/eventBus.ts
|
|
36
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
37
|
+
import { createSqlitePollingEventBus } from 'payload-better-auth/eventBus'
|
|
38
|
+
|
|
39
|
+
const db = new DatabaseSync('.event-bus.db')
|
|
40
|
+
export const eventBus = createSqlitePollingEventBus({ db })
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Configure Better Auth
|
|
24
44
|
|
|
25
45
|
```typescript
|
|
26
46
|
// lib/auth.ts
|
|
@@ -29,6 +49,8 @@ import { admin, apiKey } from 'better-auth/plugins'
|
|
|
29
49
|
import Database from 'better-sqlite3'
|
|
30
50
|
import { payloadBetterAuthPlugin } from 'payload-better-auth'
|
|
31
51
|
import buildConfig from './payload.config.js'
|
|
52
|
+
import { eventBus } from './eventBus'
|
|
53
|
+
import { storage } from './syncAdapter'
|
|
32
54
|
|
|
33
55
|
export const auth = betterAuth({
|
|
34
56
|
database: new Database(process.env.BETTER_AUTH_DB_PATH || './better-auth.db'),
|
|
@@ -40,26 +62,38 @@ export const auth = betterAuth({
|
|
|
40
62
|
payloadBetterAuthPlugin({
|
|
41
63
|
payloadConfig: buildConfig,
|
|
42
64
|
token: process.env.RECONCILE_TOKEN,
|
|
65
|
+
storage, // Shared with Payload plugin
|
|
66
|
+
eventBus, // Shared with Payload plugin
|
|
43
67
|
}),
|
|
44
68
|
],
|
|
45
69
|
})
|
|
46
70
|
```
|
|
47
71
|
|
|
48
|
-
###
|
|
72
|
+
### 3. Configure Payload
|
|
49
73
|
|
|
50
74
|
```typescript
|
|
51
75
|
// payload.config.ts
|
|
52
76
|
import { buildConfig } from 'payload'
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
77
|
+
import { betterAuthPayloadPlugin } from 'payload-better-auth'
|
|
78
|
+
import { eventBus } from './lib/eventBus'
|
|
79
|
+
import { storage } from './lib/syncAdapter'
|
|
55
80
|
|
|
56
81
|
export default buildConfig({
|
|
57
|
-
plugins: [
|
|
82
|
+
plugins: [
|
|
83
|
+
betterAuthPayloadPlugin({
|
|
84
|
+
betterAuthClientOptions: {
|
|
85
|
+
externalBaseURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
|
|
86
|
+
internalBaseURL: process.env.INTERNAL_SERVER_URL || 'http://localhost:3000',
|
|
87
|
+
},
|
|
88
|
+
storage, // Shared with Better Auth plugin
|
|
89
|
+
eventBus, // Shared with Better Auth plugin
|
|
90
|
+
}),
|
|
91
|
+
],
|
|
58
92
|
// ... rest of your config
|
|
59
93
|
})
|
|
60
94
|
```
|
|
61
95
|
|
|
62
|
-
###
|
|
96
|
+
### 4. Set Environment Variables
|
|
63
97
|
|
|
64
98
|
```bash
|
|
65
99
|
BETTER_AUTH_SECRET=your-secret-min-32-chars
|
|
@@ -70,6 +104,47 @@ PAYLOAD_SECRET=your-payload-secret
|
|
|
70
104
|
DATABASE_URI=file:./payload.db
|
|
71
105
|
```
|
|
72
106
|
|
|
107
|
+
## Production Setup with Redis
|
|
108
|
+
|
|
109
|
+
For multi-server or geo-distributed deployments, use the Redis storage and EventBus adapters:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// lib/syncAdapter.ts
|
|
113
|
+
import { createRedisStorage } from 'payload-better-auth/storage'
|
|
114
|
+
import Redis from 'ioredis'
|
|
115
|
+
|
|
116
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
117
|
+
export const storage = createRedisStorage({ redis })
|
|
118
|
+
|
|
119
|
+
// lib/eventBus.ts
|
|
120
|
+
import { createRedisEventBus } from 'payload-better-auth/eventBus'
|
|
121
|
+
import Redis from 'ioredis'
|
|
122
|
+
|
|
123
|
+
// Redis Pub/Sub requires separate connections for publishing and subscribing
|
|
124
|
+
const publisher = new Redis(process.env.REDIS_URL)
|
|
125
|
+
const subscriber = new Redis(process.env.REDIS_URL)
|
|
126
|
+
export const eventBus = createRedisEventBus({ publisher, subscriber })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Then pass the **same instances** to both plugins:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// In Better Auth config:
|
|
133
|
+
payloadBetterAuthPlugin({
|
|
134
|
+
storage,
|
|
135
|
+
eventBus,
|
|
136
|
+
payloadConfig: buildConfig,
|
|
137
|
+
token: process.env.RECONCILE_TOKEN,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// In Payload config:
|
|
141
|
+
betterAuthPayloadPlugin({
|
|
142
|
+
storage,
|
|
143
|
+
eventBus,
|
|
144
|
+
betterAuthClientOptions: { ... },
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
73
148
|
## Documentation
|
|
74
149
|
|
|
75
150
|
For detailed configuration options, API endpoints, architecture details, and production considerations, see the **[MANUAL.md](./MANUAL.md)**.
|
|
@@ -91,6 +166,21 @@ pnpm dev
|
|
|
91
166
|
|
|
92
167
|
The dev server starts at [http://localhost:3000](http://localhost:3000) with a mail server at port 1080.
|
|
93
168
|
|
|
169
|
+
### Testing with Redis
|
|
170
|
+
|
|
171
|
+
To test the Redis integration locally:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Start Redis container
|
|
175
|
+
pnpm docker:redis
|
|
176
|
+
|
|
177
|
+
# Run dev server with Redis (instead of SQLite)
|
|
178
|
+
pnpm dev:redis
|
|
179
|
+
|
|
180
|
+
# Stop Redis when done
|
|
181
|
+
pnpm docker:redis:stop
|
|
182
|
+
```
|
|
183
|
+
|
|
94
184
|
### Git Hooks
|
|
95
185
|
|
|
96
186
|
This project uses [Husky](https://typicode.github.io/husky/) for Git hooks:
|
|
@@ -134,6 +224,9 @@ pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0
|
|
|
134
224
|
| Script | Description |
|
|
135
225
|
|--------|-------------|
|
|
136
226
|
| `pnpm dev` | Start dev server with mail server |
|
|
227
|
+
| `pnpm dev:redis` | Start dev server with Redis (instead of SQLite) |
|
|
228
|
+
| `pnpm docker:redis` | Start Redis container via Docker Compose |
|
|
229
|
+
| `pnpm docker:redis:stop` | Stop Redis container |
|
|
137
230
|
| `pnpm build` | Build the plugin |
|
|
138
231
|
| `pnpm reset` | Reset databases and run all migrations |
|
|
139
232
|
| `pnpm test` | Run all tests |
|
|
@@ -145,10 +238,13 @@ pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0
|
|
|
145
238
|
|
|
146
239
|
```
|
|
147
240
|
├── src/ # Plugin source code
|
|
148
|
-
│ ├──
|
|
149
|
-
│ ├──
|
|
150
|
-
│ ├──
|
|
241
|
+
│ ├── storage/ # SecondaryStorage implementations (SQLite, Redis)
|
|
242
|
+
│ ├── eventBus/ # EventBus implementations (SQLite polling, Redis Pub/Sub)
|
|
243
|
+
│ ├── better-auth/ # Better Auth integration & reconcile queue
|
|
244
|
+
│ ├── collections/ # Payload collections (Users)
|
|
245
|
+
│ ├── components/ # React components (Login UI)
|
|
151
246
|
│ ├── payload/ # Payload plugin
|
|
247
|
+
│ ├── shared/ # Shared utilities (deduplicated logger)
|
|
152
248
|
│ └── exports/ # Client/RSC exports
|
|
153
249
|
├── dev/ # Development environment
|
|
154
250
|
│ ├── app/ # Next.js app
|
|
@@ -9,28 +9,6 @@ export interface CryptoSignature {
|
|
|
9
9
|
/** Unix timestamp as string */
|
|
10
10
|
ts: string;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Input parameters for signature verification
|
|
14
|
-
*/
|
|
15
|
-
export interface VerifySignatureInput {
|
|
16
|
-
/** The data that was signed */
|
|
17
|
-
body: unknown;
|
|
18
|
-
/** Maximum allowed time skew in seconds (default: 300) */
|
|
19
|
-
maxSkewSec?: number;
|
|
20
|
-
/** Secret key for verification */
|
|
21
|
-
secret: string;
|
|
22
|
-
/** The signature to verify */
|
|
23
|
-
signature: CryptoSignature;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Input parameters for signature creation
|
|
27
|
-
*/
|
|
28
|
-
export interface SignCanonicalInput {
|
|
29
|
-
/** The data to sign */
|
|
30
|
-
body: unknown;
|
|
31
|
-
/** Secret key for signing */
|
|
32
|
-
secret: string;
|
|
33
|
-
}
|
|
34
12
|
/**
|
|
35
13
|
* Creates a cryptographic signature for the given data
|
|
36
14
|
* @param body - The data to sign
|
|
@@ -47,15 +25,3 @@ export declare function signCanonical(body: unknown, secret: string): CryptoSign
|
|
|
47
25
|
* @returns true if signature is valid, false otherwise
|
|
48
26
|
*/
|
|
49
27
|
export declare function verifyCanonical(body: unknown, sig: CryptoSignature, secret: string, maxSkewSec?: number): boolean;
|
|
50
|
-
/**
|
|
51
|
-
* Convenience function for verifying signatures with input object
|
|
52
|
-
* @param input - Verification parameters
|
|
53
|
-
* @returns true if signature is valid, false otherwise
|
|
54
|
-
*/
|
|
55
|
-
export declare function verifySignature(input: VerifySignatureInput): boolean;
|
|
56
|
-
/**
|
|
57
|
-
* Convenience function for creating signatures with input object
|
|
58
|
-
* @param input - Signing parameters
|
|
59
|
-
* @returns Signature object
|
|
60
|
-
*/
|
|
61
|
-
export declare function createSignature(input: SignCanonicalInput): CryptoSignature;
|
|
@@ -72,19 +72,5 @@ import crypto from 'crypto';
|
|
|
72
72
|
return false;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Convenience function for verifying signatures with input object
|
|
77
|
-
* @param input - Verification parameters
|
|
78
|
-
* @returns true if signature is valid, false otherwise
|
|
79
|
-
*/ export function verifySignature(input) {
|
|
80
|
-
return verifyCanonical(input.body, input.signature, input.secret, input.maxSkewSec);
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Convenience function for creating signatures with input object
|
|
84
|
-
* @param input - Signing parameters
|
|
85
|
-
* @returns Signature object
|
|
86
|
-
*/ export function createSignature(input) {
|
|
87
|
-
return signCanonical(input.body, input.secret);
|
|
88
|
-
}
|
|
89
75
|
|
|
90
76
|
//# sourceMappingURL=crypto-shared.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/better-auth/crypto-shared.ts"],"sourcesContent":["// crypto-shared.ts\nimport crypto from 'crypto'\n\n/**\n * Type for serializable values that can be canonically stringified\n */\ntype SerializableValue =\n | boolean\n | null\n | number\n | SerializableArray\n | SerializableObject\n | string\n | undefined\n\ninterface SerializableObject {\n [key: string]: SerializableValue\n}\n\ninterface SerializableArray extends Array<SerializableValue> {}\n\n/**\n * Signature object containing timestamp, nonce, and MAC\n */\nexport interface CryptoSignature {\n /** HMAC-SHA256 signature */\n mac: string\n /** Unique nonce for this signature */\n nonce: string\n /** Unix timestamp as string */\n ts: string\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/better-auth/crypto-shared.ts"],"sourcesContent":["// crypto-shared.ts\nimport crypto from 'crypto'\n\n/**\n * Type for serializable values that can be canonically stringified\n */\ntype SerializableValue =\n | boolean\n | null\n | number\n | SerializableArray\n | SerializableObject\n | string\n | undefined\n\ninterface SerializableObject {\n [key: string]: SerializableValue\n}\n\ninterface SerializableArray extends Array<SerializableValue> {}\n\n/**\n * Signature object containing timestamp, nonce, and MAC\n */\nexport interface CryptoSignature {\n /** HMAC-SHA256 signature */\n mac: string\n /** Unique nonce for this signature */\n nonce: string\n /** Unix timestamp as string */\n ts: string\n}\n\n\n/**\n * Converts an object to a canonical string representation\n * Handles circular references and ensures consistent ordering\n */\nfunction canonicalStringify(obj: unknown): string {\n const seen = new WeakSet<object>()\n\n const walk = (v: unknown): string => {\n if (v && typeof v === 'object') {\n if (seen.has(v)) {\n throw new Error('Circular reference detected in object')\n }\n seen.add(v)\n\n if (Array.isArray(v)) {\n const result = `[${v.map(walk).join(',')}]`\n seen.delete(v)\n return result\n }\n\n const keys = Object.keys(v).sort()\n const result = `{${keys.map((k) => `\"${k}\":${walk((v as Record<string, unknown>)[k])}`).join(',')}}`\n seen.delete(v)\n return result\n }\n return JSON.stringify(v)\n }\n\n return walk(obj)\n}\n\n/**\n * Creates a cryptographic signature for the given data\n * @param body - The data to sign\n * @param secret - Secret key for signing\n * @returns Signature object with timestamp, nonce, and MAC\n */\nexport function signCanonical(body: unknown, secret: string): CryptoSignature {\n if (!secret || typeof secret !== 'string') {\n throw new Error('Secret must be a non-empty string')\n }\n\n const ts = Math.floor(Date.now() / 1000).toString()\n const nonce = crypto.randomUUID()\n const payload = canonicalStringify(body)\n const mac = crypto.createHmac('sha256', secret).update(`${ts}.${nonce}.${payload}`).digest('hex')\n\n return { mac, nonce, ts }\n}\n\n/**\n * Verifies a cryptographic signature\n * @param body - The original data that was signed\n * @param sig - The signature to verify\n * @param secret - Secret key for verification\n * @param maxSkewSec - Maximum allowed time skew in seconds (default: 300)\n * @returns true if signature is valid, false otherwise\n */\nexport function verifyCanonical(\n body: unknown,\n sig: CryptoSignature,\n secret: string,\n maxSkewSec: number = 300,\n) {\n if (!secret || typeof secret !== 'string') {\n return false\n }\n\n if (!sig || typeof sig !== 'object' || !sig.ts || !sig.nonce || !sig.mac) {\n return false\n }\n\n // Validate timestamp\n const now = Math.floor(Date.now() / 1000)\n const t = Number(sig.ts)\n if (!Number.isFinite(t) || Math.abs(now - t) > maxSkewSec) {\n return false\n }\n\n try {\n const payload = canonicalStringify(body)\n const expected = crypto\n .createHmac('sha256', secret)\n .update(`${sig.ts}.${sig.nonce}.${payload}`)\n .digest('hex')\n\n return crypto.timingSafeEqual(\n new Uint8Array(Buffer.from(sig.mac, 'hex')),\n new Uint8Array(Buffer.from(expected, 'hex')),\n )\n } catch {\n return false\n }\n}\n\n"],"names":["crypto","canonicalStringify","obj","seen","WeakSet","walk","v","has","Error","add","Array","isArray","result","map","join","delete","keys","Object","sort","k","JSON","stringify","signCanonical","body","secret","ts","Math","floor","Date","now","toString","nonce","randomUUID","payload","mac","createHmac","update","digest","verifyCanonical","sig","maxSkewSec","t","Number","isFinite","abs","expected","timingSafeEqual","Uint8Array","Buffer","from"],"mappings":"AAAA,mBAAmB;AACnB,OAAOA,YAAY,SAAQ;AAiC3B;;;CAGC,GACD,SAASC,mBAAmBC,GAAY;IACtC,MAAMC,OAAO,IAAIC;IAEjB,MAAMC,OAAO,CAACC;QACZ,IAAIA,KAAK,OAAOA,MAAM,UAAU;YAC9B,IAAIH,KAAKI,GAAG,CAACD,IAAI;gBACf,MAAM,IAAIE,MAAM;YAClB;YACAL,KAAKM,GAAG,CAACH;YAET,IAAII,MAAMC,OAAO,CAACL,IAAI;gBACpB,MAAMM,SAAS,CAAC,CAAC,EAAEN,EAAEO,GAAG,CAACR,MAAMS,IAAI,CAAC,KAAK,CAAC,CAAC;gBAC3CX,KAAKY,MAAM,CAACT;gBACZ,OAAOM;YACT;YAEA,MAAMI,OAAOC,OAAOD,IAAI,CAACV,GAAGY,IAAI;YAChC,MAAMN,SAAS,CAAC,CAAC,EAAEI,KAAKH,GAAG,CAAC,CAACM,IAAM,CAAC,CAAC,EAAEA,EAAE,EAAE,EAAEd,KAAK,AAACC,CAA6B,CAACa,EAAE,GAAG,EAAEL,IAAI,CAAC,KAAK,CAAC,CAAC;YACpGX,KAAKY,MAAM,CAACT;YACZ,OAAOM;QACT;QACA,OAAOQ,KAAKC,SAAS,CAACf;IACxB;IAEA,OAAOD,KAAKH;AACd;AAEA;;;;;CAKC,GACD,OAAO,SAASoB,cAAcC,IAAa,EAAEC,MAAc;IACzD,IAAI,CAACA,UAAU,OAAOA,WAAW,UAAU;QACzC,MAAM,IAAIhB,MAAM;IAClB;IAEA,MAAMiB,KAAKC,KAAKC,KAAK,CAACC,KAAKC,GAAG,KAAK,MAAMC,QAAQ;IACjD,MAAMC,QAAQ/B,OAAOgC,UAAU;IAC/B,MAAMC,UAAUhC,mBAAmBsB;IACnC,MAAMW,MAAMlC,OAAOmC,UAAU,CAAC,UAAUX,QAAQY,MAAM,CAAC,GAAGX,GAAG,CAAC,EAAEM,MAAM,CAAC,EAAEE,SAAS,EAAEI,MAAM,CAAC;IAE3F,OAAO;QAAEH;QAAKH;QAAON;IAAG;AAC1B;AAEA;;;;;;;CAOC,GACD,OAAO,SAASa,gBACdf,IAAa,EACbgB,GAAoB,EACpBf,MAAc,EACdgB,aAAqB,GAAG;IAExB,IAAI,CAAChB,UAAU,OAAOA,WAAW,UAAU;QACzC,OAAO;IACT;IAEA,IAAI,CAACe,OAAO,OAAOA,QAAQ,YAAY,CAACA,IAAId,EAAE,IAAI,CAACc,IAAIR,KAAK,IAAI,CAACQ,IAAIL,GAAG,EAAE;QACxE,OAAO;IACT;IAEA,qBAAqB;IACrB,MAAML,MAAMH,KAAKC,KAAK,CAACC,KAAKC,GAAG,KAAK;IACpC,MAAMY,IAAIC,OAAOH,IAAId,EAAE;IACvB,IAAI,CAACiB,OAAOC,QAAQ,CAACF,MAAMf,KAAKkB,GAAG,CAACf,MAAMY,KAAKD,YAAY;QACzD,OAAO;IACT;IAEA,IAAI;QACF,MAAMP,UAAUhC,mBAAmBsB;QACnC,MAAMsB,WAAW7C,OACdmC,UAAU,CAAC,UAAUX,QACrBY,MAAM,CAAC,GAAGG,IAAId,EAAE,CAAC,CAAC,EAAEc,IAAIR,KAAK,CAAC,CAAC,EAAEE,SAAS,EAC1CI,MAAM,CAAC;QAEV,OAAOrC,OAAO8C,eAAe,CAC3B,IAAIC,WAAWC,OAAOC,IAAI,CAACV,IAAIL,GAAG,EAAE,SACpC,IAAIa,WAAWC,OAAOC,IAAI,CAACJ,UAAU;IAEzC,EAAE,OAAM;QACN,OAAO;IACT;AACF"}
|
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
import type { AuthContext, BetterAuthPlugin } from 'better-auth';
|
|
2
2
|
import type { SanitizedConfig } from 'payload';
|
|
3
|
+
import type { EventBus } from '../eventBus/types';
|
|
4
|
+
import type { SecondaryStorage } from '../storage/types';
|
|
3
5
|
import { type InitOptions } from './reconcile-queue';
|
|
4
6
|
type CreateAdminsUser = Parameters<AuthContext['internalAdapter']['createUser']>['0'];
|
|
5
|
-
export
|
|
7
|
+
export interface PayloadBetterAuthPluginOptions extends InitOptions {
|
|
6
8
|
createAdmins?: {
|
|
7
9
|
overwrite?: boolean;
|
|
8
10
|
user: CreateAdminsUser;
|
|
9
11
|
}[];
|
|
10
12
|
enableLogging?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* EventBus for timestamp-based coordination between plugins.
|
|
15
|
+
* Both plugins MUST share the same eventBus instance.
|
|
16
|
+
*
|
|
17
|
+
* Available implementations:
|
|
18
|
+
* - `createSqlitePollingEventBus()` - Uses SQLite for cross-process coordination
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Create shared eventBus (e.g., in a separate file)
|
|
22
|
+
* import { createSqlitePollingEventBus } from 'payload-better-auth'
|
|
23
|
+
* import { DatabaseSync } from 'node:sqlite'
|
|
24
|
+
* const db = new DatabaseSync('.event-bus.db')
|
|
25
|
+
* export const eventBus = createSqlitePollingEventBus({ db })
|
|
26
|
+
*/
|
|
27
|
+
eventBus: EventBus;
|
|
11
28
|
payloadConfig: Promise<SanitizedConfig>;
|
|
29
|
+
/**
|
|
30
|
+
* Secondary storage for state coordination between Better Auth and Payload.
|
|
31
|
+
* Both plugins MUST share the same storage instance.
|
|
32
|
+
*
|
|
33
|
+
* This storage is automatically passed to Better Auth as `secondaryStorage`,
|
|
34
|
+
* enabling session caching - Payload validates sessions directly from storage
|
|
35
|
+
* without HTTP calls to Better Auth.
|
|
36
|
+
*
|
|
37
|
+
* Available storage adapters:
|
|
38
|
+
* - `createSqliteStorage()` - Uses Node.js 22+ native SQLite (no external dependencies, recommended for dev)
|
|
39
|
+
* - `createRedisStorage(redis)` - Redis-backed, for distributed/multi-server production
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // Create shared storage (e.g., in a separate file)
|
|
43
|
+
* import { createSqliteStorage } from 'payload-better-auth'
|
|
44
|
+
* import { DatabaseSync } from 'node:sqlite'
|
|
45
|
+
* const db = new DatabaseSync('.sync-state.db')
|
|
46
|
+
* export const storage = createSqliteStorage({ db })
|
|
47
|
+
*/
|
|
48
|
+
storage: SecondaryStorage;
|
|
12
49
|
token: string;
|
|
13
|
-
}
|
|
50
|
+
}
|
|
51
|
+
export declare const payloadBetterAuthPlugin: (opts: PayloadBetterAuthPluginOptions) => BetterAuthPlugin;
|
|
14
52
|
export {};
|
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
// src/plugins/reconcile-queue-plugin.ts
|
|
2
2
|
import { APIError } from 'better-auth/api';
|
|
3
3
|
import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
|
|
4
|
-
import {
|
|
4
|
+
import { createDeduplicatedLogger } from '../shared/deduplicatedLogger';
|
|
5
|
+
import { SESSION_COOKIE_NAME_KEY, TIMESTAMP_PREFIX } from '../storage/keys';
|
|
5
6
|
import { Queue } from './reconcile-queue';
|
|
6
7
|
import { createDeleteUserFromPayload, createListPayloadUsersPage, createSyncUserToPayload } from './sources';
|
|
7
8
|
const defaultLog = (msg, extra)=>{
|
|
8
9
|
console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '');
|
|
9
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Create database hooks that enqueue user changes to the reconciliation queue.
|
|
13
|
+
* All sync operations go through the queue for consistent handling with retries.
|
|
14
|
+
*/ function createQueueBasedHooks(queue) {
|
|
15
|
+
return {
|
|
16
|
+
user: {
|
|
17
|
+
create: {
|
|
18
|
+
after: (user)=>{
|
|
19
|
+
queue.enqueueEnsure(user, true, 'user-operation');
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
delete: {
|
|
24
|
+
after: (user)=>{
|
|
25
|
+
queue.enqueueDelete(user.id, true, 'user-operation');
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
update: {
|
|
30
|
+
after: (user)=>{
|
|
31
|
+
queue.enqueueEnsure(user, true, 'user-operation');
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
10
38
|
export const payloadBetterAuthPlugin = (opts)=>{
|
|
39
|
+
const { eventBus, storage } = opts;
|
|
40
|
+
// Create deduplicated logger
|
|
41
|
+
const logger = createDeduplicatedLogger({
|
|
42
|
+
enabled: opts.enableLogging ?? false,
|
|
43
|
+
prefix: '[better-auth]',
|
|
44
|
+
storage
|
|
45
|
+
});
|
|
46
|
+
// Keep the simple log for queue operations (they handle their own deduplication)
|
|
47
|
+
const queueLog = opts.enableLogging ? defaultLog : undefined;
|
|
48
|
+
// Track subscription for cleanup
|
|
49
|
+
let unsubscribeFromPayload = null;
|
|
11
50
|
return {
|
|
12
51
|
id: 'reconcile-queue-plugin',
|
|
13
52
|
endpoints: {
|
|
@@ -119,18 +158,30 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
119
158
|
}
|
|
120
159
|
]
|
|
121
160
|
},
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
161
|
+
async init ({ internalAdapter, options, password }) {
|
|
162
|
+
// Compute and store the session cookie name for Payload to read
|
|
163
|
+
// This accounts for cookiePrefix, custom cookie names, and __Secure- prefix
|
|
164
|
+
const cookiePrefix = options.advanced?.cookiePrefix ?? 'better-auth';
|
|
165
|
+
const customCookieName = options.advanced?.cookies?.session_token?.name;
|
|
166
|
+
// Better Auth uses secure cookies when:
|
|
167
|
+
// 1. Explicitly set via useSecureCookies option
|
|
168
|
+
// 2. NODE_ENV is 'production'
|
|
169
|
+
// 3. baseURL starts with 'https://'
|
|
170
|
+
const isHttps = options.baseURL?.startsWith('https://') ?? false;
|
|
171
|
+
const useSecureCookies = options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps);
|
|
172
|
+
let sessionCookieName;
|
|
173
|
+
if (customCookieName) {
|
|
174
|
+
// Custom cookie name takes precedence
|
|
175
|
+
sessionCookieName = useSecureCookies ? `__Secure-${customCookieName}` : customCookieName;
|
|
176
|
+
} else {
|
|
177
|
+
// Default format: {prefix}.session_token
|
|
178
|
+
const baseName = `${cookiePrefix}.session_token`;
|
|
179
|
+
sessionCookieName = useSecureCookies ? `__Secure-${baseName}` : baseName;
|
|
130
180
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
// Store session cookie name in KV for Payload plugin to read
|
|
182
|
+
await storage.set(SESSION_COOKIE_NAME_KEY, sessionCookieName);
|
|
183
|
+
await logger.log('cookie-config', `Session cookie name: ${sessionCookieName}`);
|
|
184
|
+
// Create admin users if configured
|
|
134
185
|
if (opts.createAdmins) {
|
|
135
186
|
try {
|
|
136
187
|
await Promise.all(opts.createAdmins.map(async ({ overwrite, user })=>{
|
|
@@ -143,7 +194,6 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
143
194
|
...user,
|
|
144
195
|
role: 'admin'
|
|
145
196
|
});
|
|
146
|
-
// assuming this creates an account?
|
|
147
197
|
await internalAdapter.linkAccount({
|
|
148
198
|
accountId: createdUser.id,
|
|
149
199
|
password: await password.hash(user.password),
|
|
@@ -165,18 +215,84 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
165
215
|
}
|
|
166
216
|
}));
|
|
167
217
|
} catch (error) {
|
|
168
|
-
|
|
169
|
-
defaultLog('Failed to create Admin user', error);
|
|
170
|
-
}
|
|
218
|
+
logger.always('Failed to create Admin user', error);
|
|
171
219
|
}
|
|
172
220
|
}
|
|
221
|
+
// Create the reconciliation queue
|
|
173
222
|
const queue = new Queue({
|
|
174
223
|
deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig),
|
|
175
224
|
internalAdapter,
|
|
176
225
|
listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig),
|
|
177
|
-
log:
|
|
226
|
+
log: queueLog,
|
|
178
227
|
syncUserToPayload: createSyncUserToPayload(opts.payloadConfig)
|
|
179
|
-
},
|
|
228
|
+
}, {
|
|
229
|
+
...opts,
|
|
230
|
+
// Don't run reconcile on boot - we use timestamp-based coordination instead
|
|
231
|
+
runOnBoot: false
|
|
232
|
+
});
|
|
233
|
+
// Log init (deduplicated)
|
|
234
|
+
await logger.log('init', 'Initialized');
|
|
235
|
+
// Timestamp-based reconciliation coordination
|
|
236
|
+
async function attemptReconciliation() {
|
|
237
|
+
logger.always('Syncing users to Payload...');
|
|
238
|
+
await storage.set(TIMESTAMP_PREFIX + 'better-auth', String(Date.now()));
|
|
239
|
+
try {
|
|
240
|
+
await queue.seedFullReconcile();
|
|
241
|
+
logger.always('Sync completed successfully');
|
|
242
|
+
// Success - unsubscribe if we were watching
|
|
243
|
+
if (unsubscribeFromPayload) {
|
|
244
|
+
unsubscribeFromPayload();
|
|
245
|
+
unsubscribeFromPayload = null;
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
logger.always('Sync failed, will retry when Payload restarts', error);
|
|
249
|
+
// Subscribe to Payload timestamp changes if not already
|
|
250
|
+
if (!unsubscribeFromPayload) {
|
|
251
|
+
unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
|
|
252
|
+
attemptReconciliation().catch((err)=>{
|
|
253
|
+
logger.always('Sync attempt failed', err);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Check if Payload is online and started more recently than our last reconcile
|
|
260
|
+
const payloadTsStr = await storage.get(TIMESTAMP_PREFIX + 'payload');
|
|
261
|
+
const baTsStr = await storage.get(TIMESTAMP_PREFIX + 'better-auth');
|
|
262
|
+
const payloadTs = payloadTsStr ? parseInt(payloadTsStr, 10) : null;
|
|
263
|
+
const baTs = baTsStr ? parseInt(baTsStr, 10) : null;
|
|
264
|
+
// Determine reconciliation state
|
|
265
|
+
if (payloadTs === null) {
|
|
266
|
+
// Payload hasn't started yet
|
|
267
|
+
await logger.log('status', 'Waiting for Payload to start...');
|
|
268
|
+
unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
|
|
269
|
+
attemptReconciliation().catch((err)=>{
|
|
270
|
+
logger.always('Sync attempt failed', err);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
} else if (baTs === null) {
|
|
274
|
+
// First run - always sync
|
|
275
|
+
attemptReconciliation().catch((err)=>{
|
|
276
|
+
logger.always('Initial sync failed', err);
|
|
277
|
+
});
|
|
278
|
+
} else if (payloadTs > baTs) {
|
|
279
|
+
// Payload restarted since last reconcile - sync needed
|
|
280
|
+
attemptReconciliation().catch((err)=>{
|
|
281
|
+
logger.always('Sync failed', err);
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
// Already reconciled and up-to-date
|
|
285
|
+
await logger.log('status', 'Already synchronized', {
|
|
286
|
+
lastSync: new Date(baTs).toISOString()
|
|
287
|
+
});
|
|
288
|
+
unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
|
|
289
|
+
attemptReconciliation().catch((err)=>{
|
|
290
|
+
logger.always('Sync attempt failed', err);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// Create queue-based database hooks - all user sync goes through the queue
|
|
295
|
+
const queueBasedHooks = createQueueBasedHooks(queue);
|
|
180
296
|
return {
|
|
181
297
|
context: {
|
|
182
298
|
payloadSyncPlugin: {
|
|
@@ -184,9 +300,10 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
184
300
|
}
|
|
185
301
|
},
|
|
186
302
|
options: {
|
|
187
|
-
databaseHooks:
|
|
188
|
-
|
|
189
|
-
|
|
303
|
+
databaseHooks: queueBasedHooks,
|
|
304
|
+
// Pass storage to Better Auth as secondaryStorage - this makes BA write sessions
|
|
305
|
+
// to the shared storage, allowing Payload to validate sessions directly from cache
|
|
306
|
+
secondaryStorage: storage,
|
|
190
307
|
user: {
|
|
191
308
|
deleteUser: {
|
|
192
309
|
enabled: true
|
|
@@ -194,6 +311,16 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
194
311
|
}
|
|
195
312
|
}
|
|
196
313
|
};
|
|
314
|
+
},
|
|
315
|
+
schema: {
|
|
316
|
+
user: {
|
|
317
|
+
fields: {
|
|
318
|
+
locale: {
|
|
319
|
+
type: 'string',
|
|
320
|
+
required: false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
197
324
|
}
|
|
198
325
|
};
|
|
199
326
|
};
|