@proofkit/better-auth 0.3.1-beta.1 → 0.4.0-beta.11

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.
@@ -0,0 +1,233 @@
1
+ ---
2
+ name: better-auth-setup
3
+ description: >
4
+ Set up self-hosted authentication with better-auth using FileMaker as the
5
+ database backend. Covers FileMakerAdapter, FMServerConnection, betterAuth
6
+ config, migration via npx @proofkit/better-auth migrate, OData prerequisites,
7
+ fmodata privilege, Full Access credentials for schema modification, plugin
8
+ migration workflow, troubleshooting "filemaker is not supported" errors.
9
+ type: core
10
+ library: proofkit
11
+ library_version: "0.4.0-beta.7"
12
+ requires:
13
+ - fmodata-client
14
+ sources:
15
+ - "proofgeist/proofkit:packages/better-auth/src/*.ts"
16
+ - "proofgeist/proofkit:apps/docs/content/docs/better-auth/*.mdx"
17
+ ---
18
+
19
+ ## Setup
20
+
21
+ ### Prerequisites
22
+
23
+ - OData enabled on FileMaker Server
24
+ - API credentials with `fmodata` privilege enabled
25
+ - Read/write access to the better-auth tables
26
+ - Full Access credentials available for schema migration (can differ from runtime credentials)
27
+
28
+ ### Install packages
29
+
30
+ ```bash
31
+ pnpm add @proofkit/better-auth @proofkit/fmodata
32
+ ```
33
+
34
+ ### Configure auth.ts
35
+
36
+ ```ts
37
+ import { betterAuth } from "better-auth";
38
+ import { FMServerConnection } from "@proofkit/fmodata";
39
+ import { FileMakerAdapter } from "@proofkit/better-auth";
40
+
41
+ const connection = new FMServerConnection({
42
+ serverUrl: process.env.FM_SERVER_URL!,
43
+ auth: {
44
+ username: process.env.FM_USERNAME!,
45
+ password: process.env.FM_PASSWORD!,
46
+ },
47
+ });
48
+
49
+ const db = connection.database(process.env.FM_DATABASE!);
50
+
51
+ export const auth = betterAuth({
52
+ database: FileMakerAdapter({ database: db }),
53
+ // add plugins, social providers, etc.
54
+ });
55
+ ```
56
+
57
+ `FileMakerAdapter` accepts a `FileMakerAdapterConfig`:
58
+
59
+ - `database` (required) -- an fmodata `Database` instance
60
+ - `debugLogs` (optional) -- enable adapter debug logging
61
+ - `usePlural` (optional) -- set `true` if table names are plural
62
+
63
+ The adapter maps Better Auth operations (create, findOne, findMany, update, delete, count) to OData requests via `db._makeRequest`. It does not support JSON columns, native dates, or native booleans -- all values are stored as strings/numbers.
64
+
65
+ ### Alternative: Data API key (OttoFMS 4.11+)
66
+
67
+ ```ts
68
+ const connection = new FMServerConnection({
69
+ serverUrl: process.env.FM_SERVER_URL!,
70
+ auth: {
71
+ apiKey: process.env.OTTO_API_KEY!,
72
+ },
73
+ });
74
+ ```
75
+
76
+ OData must be enabled for the key.
77
+
78
+ ## Core Patterns
79
+
80
+ ### 1. Initial migration
81
+
82
+ After configuring `auth.ts`, run the migration CLI to create tables and fields in FileMaker:
83
+
84
+ ```bash
85
+ npx @proofkit/better-auth migrate
86
+ ```
87
+
88
+ The CLI:
89
+
90
+ 1. Loads your `auth.ts` config (auto-detected or via `--config <path>`)
91
+ 2. Calls `getSchema()` from `better-auth/db` to determine required tables/fields
92
+ 3. Fetches current OData metadata via `db.getMetadata()`
93
+ 4. Computes a diff: tables to create, fields to add to existing tables
94
+ 5. Prints the migration plan and prompts for confirmation
95
+ 6. Executes via `db.schema.createTable()` and `db.schema.addFields()`
96
+
97
+ Only schema is modified. No layouts or relationships are created.
98
+
99
+ If your runtime credentials lack Full Access, override for migration only:
100
+
101
+ ```bash
102
+ npx @proofkit/better-auth migrate --username "admin" --password "admin_pass"
103
+ ```
104
+
105
+ Skip confirmation with `-y`:
106
+
107
+ ```bash
108
+ npx @proofkit/better-auth migrate -y
109
+ ```
110
+
111
+ ### 2. Adding plugins and re-migrating
112
+
113
+ When you add a Better Auth plugin (e.g. `twoFactor`, `organization`), it declares additional tables/fields. After updating `auth.ts`:
114
+
115
+ ```ts
116
+ import { betterAuth } from "better-auth";
117
+ import { twoFactor } from "better-auth/plugins";
118
+ import { FMServerConnection } from "@proofkit/fmodata";
119
+ import { FileMakerAdapter } from "@proofkit/better-auth";
120
+
121
+ const connection = new FMServerConnection({
122
+ serverUrl: process.env.FM_SERVER_URL!,
123
+ auth: {
124
+ username: process.env.FM_USERNAME!,
125
+ password: process.env.FM_PASSWORD!,
126
+ },
127
+ });
128
+
129
+ const db = connection.database(process.env.FM_DATABASE!);
130
+
131
+ export const auth = betterAuth({
132
+ database: FileMakerAdapter({ database: db }),
133
+ plugins: [twoFactor()],
134
+ });
135
+ ```
136
+
137
+ Re-run migration:
138
+
139
+ ```bash
140
+ npx @proofkit/better-auth migrate
141
+ ```
142
+
143
+ The planner diffs against existing metadata, so only new tables/fields are added. Existing tables are left untouched.
144
+
145
+ ### 3. Troubleshooting privilege errors
146
+
147
+ When migration fails with OData error code `207`, the account lacks schema modification privileges. The CLI outputs:
148
+
149
+ ```
150
+ Failed to create table "tableName": Cannot modify schema.
151
+ The account used does not have schema modification privileges.
152
+ Use --username and --password to provide Full Access credentials.
153
+ ```
154
+
155
+ Fix: provide Full Access credentials via CLI flags. These are only used for migration, not at runtime.
156
+
157
+ ## Common Mistakes
158
+
159
+ ### [CRITICAL] Using better-auth CLI instead of @proofkit/better-auth
160
+
161
+ Wrong:
162
+ ```bash
163
+ npx better-auth migrate
164
+ ```
165
+
166
+ Correct:
167
+ ```bash
168
+ npx @proofkit/better-auth migrate
169
+ ```
170
+
171
+ The standard better-auth CLI does not know about the FileMaker adapter and produces: `ERROR [Better Auth]: filemaker is not supported. If it is a custom adapter, please request the maintainer to implement createSchema`. The `@proofkit/better-auth` CLI loads your auth config, extracts the `Database` instance from the adapter, and handles migration via fmodata's schema API.
172
+
173
+ Source: `apps/docs/content/docs/better-auth/troubleshooting.mdx`
174
+
175
+ ### [HIGH] Missing Full Access credentials for schema migration
176
+
177
+ Wrong:
178
+ ```bash
179
+ # Using runtime credentials that only have fmodata privilege
180
+ npx @proofkit/better-auth migrate
181
+ # Fails with OData error 207: Cannot modify schema
182
+ ```
183
+
184
+ Correct:
185
+ ```bash
186
+ npx @proofkit/better-auth migrate --username "full_access_user" --password "full_access_pass"
187
+ ```
188
+
189
+ Schema modification (`db.schema.createTable`, `db.schema.addFields`) requires [Full Access] privileges. Standard API accounts with `fmodata` privilege can read/write data but cannot alter schema. The CLI accepts `--username` and `--password` flags to override credentials for migration only.
190
+
191
+ Source: `packages/better-auth/src/cli/index.ts`, `packages/better-auth/src/migrate.ts`
192
+
193
+ ### [HIGH] Removing fields added by migration
194
+
195
+ Wrong:
196
+ ```
197
+ Manually deleting "unused" fields from better-auth tables in FileMaker
198
+ ```
199
+
200
+ Correct:
201
+ ```
202
+ Keep all fields created by migration, even if you don't plan to use them
203
+ ```
204
+
205
+ Better Auth expects all schema fields to exist at runtime. The adapter issues OData requests that reference these fields. Removing them causes runtime errors when Better Auth attempts to read or write those columns.
206
+
207
+ Source: `apps/docs/content/docs/better-auth/installation.mdx`
208
+
209
+ ### [HIGH] Forgetting to re-run migration after adding plugins
210
+
211
+ Wrong:
212
+ ```ts
213
+ // Added twoFactor() plugin to auth.ts but did not re-run migration
214
+ export const auth = betterAuth({
215
+ database: FileMakerAdapter({ database: db }),
216
+ plugins: [twoFactor()],
217
+ });
218
+ // Runtime errors: tables/fields for twoFactor don't exist
219
+ ```
220
+
221
+ Correct:
222
+ ```bash
223
+ # After adding any plugin to auth.ts, always re-run:
224
+ npx @proofkit/better-auth migrate
225
+ ```
226
+
227
+ Each plugin declares additional tables and fields via `getSchema()`. The migration planner diffs the full schema (including plugins) against current OData metadata. Without re-running, the new tables/fields don't exist and Better Auth throws at runtime.
228
+
229
+ Source: `apps/docs/content/docs/better-auth/installation.mdx`
230
+
231
+ ## References
232
+
233
+ - **fmodata-client** -- Better Auth uses fmodata `Database` under the hood for all OData requests. `FMServerConnection` and `database()` must be configured before `FileMakerAdapter` can work. The adapter calls `db._makeRequest()` for CRUD and `db.schema.*` for migrations.
package/src/adapter.ts CHANGED
@@ -1,19 +1,7 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: library code */
2
+ import type { Database } from "@proofkit/fmodata";
2
3
  import { logger } from "better-auth";
3
- import { type CleanedWhere, createAdapter, type DBAdapterDebugLogOption } from "better-auth/adapters";
4
- import buildQuery from "odata-query";
5
- import { prettifyError, z } from "zod/v4";
6
- import { createRawFetch, type FmOdataConfig } from "./odata";
7
-
8
- const configSchema = z.object({
9
- debugLogs: z.unknown().optional(),
10
- usePlural: z.boolean().optional(),
11
- odata: z.object({
12
- serverUrl: z.url(),
13
- auth: z.union([z.object({ username: z.string(), password: z.string() }), z.object({ apiKey: z.string() })]),
14
- database: z.string().endsWith(".fmp12"),
15
- }),
16
- });
4
+ import { type CleanedWhere, createAdapterFactory, type DBAdapterDebugLogOption } from "better-auth/adapters";
17
5
 
18
6
  export interface FileMakerAdapterConfig {
19
7
  /**
@@ -24,27 +12,12 @@ export interface FileMakerAdapterConfig {
24
12
  * If the table names in the schema are plural.
25
13
  */
26
14
  usePlural?: boolean;
27
-
28
15
  /**
29
- * Connection details for the FileMaker server.
16
+ * The fmodata Database instance to use for all OData requests.
30
17
  */
31
- odata: FmOdataConfig;
32
- }
33
-
34
- export interface AdapterOptions {
35
- config: FileMakerAdapterConfig;
18
+ database: Database;
36
19
  }
37
20
 
38
- const defaultConfig: Required<FileMakerAdapterConfig> = {
39
- debugLogs: false,
40
- usePlural: false,
41
- odata: {
42
- serverUrl: "",
43
- auth: { username: "", password: "" },
44
- database: "",
45
- },
46
- };
47
-
48
21
  // Regex patterns for field validation and ISO date detection
49
22
  const FIELD_SPECIAL_CHARS_REGEX = /[\s_]/;
50
23
  const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
@@ -155,41 +128,59 @@ export function parseWhere(where?: CleanedWhere[]): string {
155
128
  return clauses.join(" ");
156
129
  }
157
130
 
158
- export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig) => {
159
- const parsed = configSchema.loose().safeParse(_config);
131
+ /**
132
+ * Build an OData query string from parameters.
133
+ */
134
+ function buildQueryString(params: {
135
+ top?: number;
136
+ skip?: number;
137
+ filter?: string;
138
+ orderBy?: string;
139
+ select?: string[];
140
+ }): string {
141
+ const parts: string[] = [];
142
+ if (params.top !== undefined) {
143
+ parts.push(`$top=${params.top}`);
144
+ }
145
+ if (params.skip !== undefined) {
146
+ parts.push(`$skip=${params.skip}`);
147
+ }
148
+ if (params.filter) {
149
+ parts.push(`$filter=${encodeURIComponent(params.filter)}`);
150
+ }
151
+ if (params.orderBy) {
152
+ parts.push(`$orderby=${encodeURIComponent(params.orderBy)}`);
153
+ }
154
+ if (params.select?.length) {
155
+ parts.push(`$select=${params.select.map(encodeURIComponent).join(",")}`);
156
+ }
157
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
158
+ }
160
159
 
161
- if (!parsed.success) {
162
- throw new Error(`Invalid configuration: ${prettifyError(parsed.error)}`);
160
+ export const FileMakerAdapter = (config: FileMakerAdapterConfig) => {
161
+ if (!config.database || typeof config.database !== "object") {
162
+ throw new Error("FileMakerAdapter requires a `database` (fmodata Database instance).");
163
163
  }
164
- const config = parsed.data;
165
164
 
166
- const { fetch } = createRawFetch({
167
- ...config.odata,
168
- logging: config.debugLogs ? "verbose" : "none",
169
- });
165
+ const db = config.database;
170
166
 
171
- const adapterFactory = createAdapter({
167
+ const adapterFactory = createAdapterFactory({
172
168
  config: {
173
169
  adapterId: "filemaker",
174
170
  adapterName: "FileMaker",
175
- usePlural: config.usePlural ?? false, // Whether the table names in the schema are plural.
176
- debugLogs: config.debugLogs ?? false, // Whether to enable debug logs.
177
- supportsJSON: false, // Whether the database supports JSON. (Default: false)
178
- supportsDates: false, // Whether the database supports dates. (Default: true)
179
- supportsBooleans: false, // Whether the database supports booleans. (Default: true)
180
- supportsNumericIds: false, // Whether the database supports auto-incrementing numeric IDs. (Default: true)
171
+ usePlural: config.usePlural ?? false,
172
+ debugLogs: config.debugLogs ?? false,
173
+ supportsJSON: false,
174
+ supportsDates: false,
175
+ supportsBooleans: false,
176
+ supportsNumericIds: false,
181
177
  },
182
178
  adapter: () => {
183
179
  return {
184
180
  create: async ({ data, model }) => {
185
- if (model === "session") {
186
- console.log("session", data);
187
- }
188
-
189
- const result = await fetch(`/${model}`, {
181
+ const result = await db._makeRequest<Record<string, any>>(`/${model}`, {
190
182
  method: "POST",
191
- body: data,
192
- output: z.looseObject({ id: z.string() }),
183
+ body: JSON.stringify(data),
193
184
  });
194
185
 
195
186
  if (result.error) {
@@ -202,15 +193,12 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
202
193
  const filter = parseWhere(where);
203
194
  logger.debug("$filter", filter);
204
195
 
205
- const query = buildQuery({
196
+ const query = buildQueryString({
206
197
  filter: filter.length > 0 ? filter : undefined,
207
198
  });
208
199
 
209
- const result = await fetch(`/${model}/$count${query}`, {
210
- method: "GET",
211
- output: z.object({ value: z.number() }),
212
- });
213
- if (!result.data) {
200
+ const result = await db._makeRequest<{ value: number }>(`/${model}/$count${query}`);
201
+ if (result.error) {
214
202
  throw new Error("Failed to count records");
215
203
  }
216
204
  return (result.data?.value as any) ?? 0;
@@ -219,15 +207,12 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
219
207
  const filter = parseWhere(where);
220
208
  logger.debug("$filter", filter);
221
209
 
222
- const query = buildQuery({
210
+ const query = buildQueryString({
223
211
  top: 1,
224
212
  filter: filter.length > 0 ? filter : undefined,
225
213
  });
226
214
 
227
- const result = await fetch(`/${model}${query}`, {
228
- method: "GET",
229
- output: z.object({ value: z.array(z.any()) }),
230
- });
215
+ const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
231
216
  if (result.error) {
232
217
  throw new Error("Failed to find record");
233
218
  }
@@ -237,7 +222,7 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
237
222
  const filter = parseWhere(where);
238
223
  logger.debug("FIND MANY", { where, filter });
239
224
 
240
- const query = buildQuery({
225
+ const query = buildQueryString({
241
226
  top: limit,
242
227
  skip: offset,
243
228
  orderBy: sortBy ? `${sortBy.field} ${sortBy.direction ?? "asc"}` : undefined,
@@ -245,10 +230,7 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
245
230
  });
246
231
  logger.debug("QUERY", query);
247
232
 
248
- const result = await fetch(`/${model}${query}`, {
249
- method: "GET",
250
- output: z.object({ value: z.array(z.any()) }),
251
- });
233
+ const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
252
234
  logger.debug("RESULT", result);
253
235
 
254
236
  if (result.error) {
@@ -259,54 +241,44 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
259
241
  },
260
242
  delete: async ({ model, where }) => {
261
243
  const filter = parseWhere(where);
262
- console.log("DELETE", { model, where, filter });
263
244
  logger.debug("$filter", filter);
264
245
 
265
246
  // Find a single id matching the filter
266
- const query = buildQuery({
247
+ const query = buildQueryString({
267
248
  top: 1,
268
249
  select: [`"id"`],
269
250
  filter: filter.length > 0 ? filter : undefined,
270
251
  });
271
252
 
272
- const toDelete = await fetch(`/${model}${query}`, {
273
- method: "GET",
274
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
275
- });
253
+ const toDelete = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
276
254
 
277
255
  const id = toDelete.data?.value?.[0]?.id;
278
256
  if (!id) {
279
- // Nothing to delete
280
257
  return;
281
258
  }
282
259
 
283
- const result = await fetch(`/${model}('${id}')`, {
260
+ const result = await db._makeRequest(`/${model}('${id}')`, {
284
261
  method: "DELETE",
285
262
  });
286
263
  if (result.error) {
287
- console.log("DELETE ERROR", result.error);
288
264
  throw new Error("Failed to delete record");
289
265
  }
290
266
  },
291
267
  deleteMany: async ({ model, where }) => {
292
268
  const filter = parseWhere(where);
293
- console.log("DELETE MANY", { model, where, filter });
294
269
 
295
270
  // Find all ids matching the filter
296
- const query = buildQuery({
271
+ const query = buildQueryString({
297
272
  select: [`"id"`],
298
273
  filter: filter.length > 0 ? filter : undefined,
299
274
  });
300
275
 
301
- const rows = await fetch(`/${model}${query}`, {
302
- method: "GET",
303
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
304
- });
276
+ const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
305
277
 
306
278
  const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
307
279
  let deleted = 0;
308
280
  for (const id of ids) {
309
- const res = await fetch(`/${model}('${id}')`, {
281
+ const res = await db._makeRequest(`/${model}('${id}')`, {
310
282
  method: "DELETE",
311
283
  });
312
284
  if (!res.error) {
@@ -319,16 +291,14 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
319
291
  const filter = parseWhere(where);
320
292
  logger.debug("UPDATE", { model, where, update });
321
293
  logger.debug("$filter", filter);
294
+
322
295
  // Find one id to update
323
- const query = buildQuery({
296
+ const query = buildQueryString({
324
297
  select: [`"id"`],
325
298
  filter: filter.length > 0 ? filter : undefined,
326
299
  });
327
300
 
328
- const existing = await fetch(`/${model}${query}`, {
329
- method: "GET",
330
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
331
- });
301
+ const existing = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
332
302
  logger.debug("EXISTING", existing.data);
333
303
 
334
304
  const id = existing.data?.value?.[0]?.id;
@@ -336,9 +306,9 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
336
306
  return null;
337
307
  }
338
308
 
339
- const patchRes = await fetch(`/${model}('${id}')`, {
309
+ const patchRes = await db._makeRequest(`/${model}('${id}')`, {
340
310
  method: "PATCH",
341
- body: update,
311
+ body: JSON.stringify(update),
342
312
  });
343
313
  logger.debug("PATCH RES", patchRes.data);
344
314
  if (patchRes.error) {
@@ -346,32 +316,27 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
346
316
  }
347
317
 
348
318
  // Read back the updated record
349
- const readBack = await fetch(`/${model}('${id}')`, {
350
- method: "GET",
351
- output: z.record(z.string(), z.unknown()),
352
- });
319
+ const readBack = await db._makeRequest<Record<string, unknown>>(`/${model}('${id}')`);
353
320
  logger.debug("READ BACK", readBack.data);
354
321
  return (readBack.data as any) ?? null;
355
322
  },
356
323
  updateMany: async ({ model, where, update }) => {
357
324
  const filter = parseWhere(where);
325
+
358
326
  // Find all ids matching the filter
359
- const query = buildQuery({
327
+ const query = buildQueryString({
360
328
  select: [`"id"`],
361
329
  filter: filter.length > 0 ? filter : undefined,
362
330
  });
363
331
 
364
- const rows = await fetch(`/${model}${query}`, {
365
- method: "GET",
366
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
367
- });
332
+ const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
368
333
 
369
334
  const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
370
335
  let updated = 0;
371
336
  for (const id of ids) {
372
- const res = await fetch(`/${model}('${id}')`, {
337
+ const res = await db._makeRequest(`/${model}('${id}')`, {
373
338
  method: "PATCH",
374
- body: update,
339
+ body: JSON.stringify(update),
375
340
  });
376
341
  if (!res.error) {
377
342
  updated++;
@@ -383,7 +348,15 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
383
348
  },
384
349
  });
385
350
 
386
- // Expose the FileMaker config for CLI access
387
- (adapterFactory as any).filemakerConfig = config as FileMakerAdapterConfig;
388
- return adapterFactory;
351
+ // Expose the Database instance for CLI access.
352
+ // Set on both the factory function (for pre-getAdapter extraction)
353
+ // and the returned adapter (for post-getAdapter extraction).
354
+ const originalFactory = adapterFactory;
355
+ const wrappedFactory = ((options: unknown) => {
356
+ const adapter = (originalFactory as (opts: unknown) => Record<string, unknown>)(options);
357
+ adapter.database = db;
358
+ return adapter;
359
+ }) as typeof adapterFactory;
360
+ (wrappedFactory as unknown as { database: Database }).database = db;
361
+ return wrappedFactory;
389
362
  };