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.
Files changed (72) hide show
  1. package/README.md +109 -13
  2. package/dist/better-auth/crypto-shared.d.ts +0 -34
  3. package/dist/better-auth/crypto-shared.js +0 -14
  4. package/dist/better-auth/crypto-shared.js.map +1 -1
  5. package/dist/better-auth/plugin.d.ts +40 -2
  6. package/dist/better-auth/plugin.js +148 -21
  7. package/dist/better-auth/plugin.js.map +1 -1
  8. package/dist/better-auth/reconcile-queue.d.ts +0 -4
  9. package/dist/better-auth/reconcile-queue.js +0 -16
  10. package/dist/better-auth/reconcile-queue.js.map +1 -1
  11. package/dist/better-auth/sources.d.ts +6 -11
  12. package/dist/better-auth/sources.js +9 -30
  13. package/dist/better-auth/sources.js.map +1 -1
  14. package/dist/collections/Users/index.d.ts +18 -4
  15. package/dist/collections/Users/index.js +93 -43
  16. package/dist/collections/Users/index.js.map +1 -1
  17. package/dist/eventBus/RedisEventBus.d.ts +92 -0
  18. package/dist/eventBus/RedisEventBus.js +117 -0
  19. package/dist/eventBus/RedisEventBus.js.map +1 -0
  20. package/dist/eventBus/SqlitePollingEventBus.d.ts +81 -0
  21. package/dist/eventBus/SqlitePollingEventBus.js +139 -0
  22. package/dist/eventBus/SqlitePollingEventBus.js.map +1 -0
  23. package/dist/eventBus/index.d.ts +3 -0
  24. package/dist/eventBus/index.js +4 -0
  25. package/dist/eventBus/index.js.map +1 -0
  26. package/dist/eventBus/types.d.ts +30 -0
  27. package/dist/eventBus/types.js +18 -0
  28. package/dist/eventBus/types.js.map +1 -0
  29. package/dist/index.d.ts +1 -2
  30. package/dist/index.js +9 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/payload/plugin.d.ts +39 -1
  33. package/dist/payload/plugin.js +20 -18
  34. package/dist/payload/plugin.js.map +1 -1
  35. package/dist/shared/deduplicatedLogger.d.ts +57 -0
  36. package/dist/shared/deduplicatedLogger.js +66 -0
  37. package/dist/shared/deduplicatedLogger.js.map +1 -0
  38. package/dist/storage/RedisStorage.d.ts +42 -0
  39. package/dist/storage/RedisStorage.js +36 -0
  40. package/dist/storage/RedisStorage.js.map +1 -0
  41. package/dist/storage/SqliteStorage.d.ts +46 -0
  42. package/dist/storage/SqliteStorage.js +88 -0
  43. package/dist/storage/SqliteStorage.js.map +1 -0
  44. package/dist/storage/index.d.ts +3 -0
  45. package/dist/storage/index.js +4 -0
  46. package/dist/storage/index.js.map +1 -0
  47. package/dist/storage/keys.d.ts +12 -0
  48. package/dist/storage/keys.js +9 -0
  49. package/dist/storage/keys.js.map +1 -0
  50. package/dist/storage/types.d.ts +26 -0
  51. package/dist/storage/types.js +10 -0
  52. package/dist/storage/types.js.map +1 -0
  53. package/package.json +28 -3
  54. package/src/better-auth/crypto-shared.ts +0 -40
  55. package/src/better-auth/plugin.ts +211 -31
  56. package/src/better-auth/reconcile-queue.ts +0 -24
  57. package/src/better-auth/sources.ts +14 -31
  58. package/src/collections/Users/index.ts +146 -42
  59. package/src/eventBus/RedisEventBus.ts +201 -0
  60. package/src/eventBus/SqlitePollingEventBus.ts +228 -0
  61. package/src/eventBus/index.ts +10 -0
  62. package/src/eventBus/types.ts +33 -0
  63. package/src/index.ts +12 -2
  64. package/src/payload/plugin.ts +63 -27
  65. package/src/shared/deduplicatedLogger.ts +125 -0
  66. package/src/storage/RedisStorage.ts +67 -0
  67. package/src/storage/SqliteStorage.ts +133 -0
  68. package/src/storage/index.ts +8 -0
  69. package/src/storage/keys.ts +16 -0
  70. package/src/storage/types.ts +29 -0
  71. package/dist/better-auth/databaseHooks.d.ts +0 -5
  72. 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
- - **Real-time Sync** — Automatic synchronization via database hooks
9
- - **Background Reconciliation** — Periodic full sync ensures data consistency
10
- - **Cryptographic Security** — Signed operations prevent unauthorized modifications
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 18.20.2+, Better Auth 1.4.10+, Payload CMS 3.37.0+
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. Configure Better Auth
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
- ### 2. Configure Payload
72
+ ### 3. Configure Payload
49
73
 
50
74
  ```typescript
51
75
  // payload.config.ts
52
76
  import { buildConfig } from 'payload'
53
- import { betterAuthPlugin } from 'payload-better-auth'
54
- import { auth } from './lib/auth.js'
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: [betterAuthPlugin({ betterAuth: auth })],
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
- ### 3. Set Environment Variables
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
- │ ├── better-auth/ # Better Auth integration
149
- │ ├── collections/ # Payload collections
150
- │ ├── components/ # React components
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/**\n * Input parameters for signature verification\n */\nexport interface VerifySignatureInput {\n /** The data that was signed */\n body: unknown\n /** Maximum allowed time skew in seconds (default: 300) */\n maxSkewSec?: number\n /** Secret key for verification */\n secret: string\n /** The signature to verify */\n signature: CryptoSignature\n}\n\n/**\n * Input parameters for signature creation\n */\nexport interface SignCanonicalInput {\n /** The data to sign */\n body: unknown\n /** Secret key for signing */\n secret: string\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/**\n * Convenience function for verifying signatures with input object\n * @param input - Verification parameters\n * @returns true if signature is valid, false otherwise\n */\nexport function verifySignature(input: VerifySignatureInput) {\n return verifyCanonical(input.body, input.signature, input.secret, input.maxSkewSec)\n}\n\n/**\n * Convenience function for creating signatures with input object\n * @param input - Signing parameters\n * @returns Signature object\n */\nexport function createSignature(input: SignCanonicalInput) {\n return signCanonical(input.body, input.secret)\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","verifySignature","input","signature","createSignature"],"mappings":"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"}
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 declare const payloadBetterAuthPlugin: (opts: {
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
- } & InitOptions) => BetterAuthPlugin;
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 { createDatabaseHooks } from './databaseHooks';
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
- schema: {
123
- user: {
124
- fields: {
125
- locale: {
126
- type: 'string',
127
- required: false
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
- // TODO: the queue must be destroyed on better auth instance destruction, as it utilizes timers.
133
- async init ({ internalAdapter, password }) {
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
- if (opts.enableLogging) {
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: opts.enableLogging ? defaultLog : undefined,
226
+ log: queueLog,
178
227
  syncUserToPayload: createSyncUserToPayload(opts.payloadConfig)
179
- }, opts);
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: createDatabaseHooks({
188
- config: opts.payloadConfig
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
  };