@teamblind/axeda-better-auth 0.1.0-alpha.0 → 0.1.0-alpha.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @teamblind/axeda-better-auth
2
2
 
3
+ ## 0.1.0-alpha.1
4
+
5
+ ### Minor Changes
6
+
7
+ - a8a33dd: Add an absolute lifetime to sessions. Better Auth renews a session every time it is used, so a session in daily use never ended. The plugin now cuts a session's expiry, when it is created and on every renewal, so it never goes past its creation plus `maxSessionAge` (30 days by default, in seconds; `false` turns it off). It applies in Better Auth's own endpoints and in your routes, with sessions in the database or in `secondaryStorage`, and signing in again starts a new count. Sessions that are already older than 30 days are cut the next time Better Auth renews them. While the limit holds a session back, Better Auth writes the session row on every request during the last `session.expiresIn` minus `session.updateAge` of its life, instead of once a day.
8
+ - 0d37dca: Index the `action` column of the audit log. Listing the audit log filtered by action read the whole table, so it got slower as the log grew (30 to 74 ms with 200,000 events in PostgreSQL 16 and 18, measured with `EXPLAIN`); with the index it takes a fraction of a millisecond. Better Auth creates the index when you run its migration. On a very large audit table, building it blocks writes to that table while it runs, so run the migration at a quiet moment. On MySQL, a table created before this version has `action` as `text`, which Better Auth will not index: run `ALTER TABLE axedaAudit MODIFY action varchar(255) NOT NULL;` before the migration (a new database is not affected).
9
+ - 547cc20: Support MongoDB (tested on 7.0 in the CI and on 8.0 by hand, on a replica set; a single node is enough). Give Better Auth `mongodbAdapter(db, { client })`. MongoDB has no migrations in Better Auth, so Axeda writes once, before its first transaction, in each of its collections so that the adapter creates them and their indexes outside of a transaction, where a first use made the transaction hang. On a standalone `mongod` Axeda now stops at start with `AXEDA_TRANSACTIONS_UNSUPPORTED` and says what to do.
10
+
11
+ The unique constraints of Axeda's tables (role key, company slug, the policy state key and the pending invitation key) are now declared as unique table indexes instead of `unique` fields, because Better Auth's MongoDB adapter only creates table indexes, and without them two simultaneous writes with the same key both succeeded. On SQL they are the same constraint: running Better Auth's migration on an existing database adds the unique indexes next to the old column constraints (four small extra indexes), keeps every row and still refuses duplicates, which a test checks against the schema of 0.1.0-alpha.0.
12
+
13
+ - Support MySQL (8.4 and 9, tested against real servers with a connection pool). Give Better Auth the pool from `mysql2/promise` (not the one from `mysql2`) created with `timezone: 'Z'`; without it the driver converts dates to the local time of your process. On a server that stores table names in lowercase (`lower_case_table_names` 1 or 2: Windows, macOS and some managed services) Better Auth's schema check reports Axeda's tables as missing, so turn it off with `advanced: { database: { validateSchema: false } }`. The reference lists both, and what to do when upgrading an existing MySQL database.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [ce83aff]
18
+ - @teamblind/axeda-core@0.1.0-alpha.1
19
+
3
20
  ## 0.1.0-alpha.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -1,14 +1,88 @@
1
1
  # @teamblind/axeda-better-auth
2
2
 
3
- The Better Auth plugin for Axeda: schema, `/axeda/*` endpoints, the typed client, `secureAuthOptions()` and the stores that persist everything through Better Auth's database adapter (SQLite and PostgreSQL verified).
3
+ The Better Auth plugin for Axeda: schema, `/axeda/*` endpoints, the typed client, `secureAuthOptions()` and the stores that persist everything through Better Auth's database adapter. It runs on SQLite, PostgreSQL, MySQL and MongoDB.
4
4
 
5
- > **Status:** alpha (`0.1.0-alpha.0`); the API can still change before 1.0. Install with `npm install @teamblind/axeda-better-auth@alpha`. Documentation: [English](https://github.com/teamblindmx/axeda/tree/main/docs/en) · [Español](https://github.com/teamblindmx/axeda/tree/main/docs/es).
5
+ > **Status:** alpha (`0.1.0-alpha.1`); the API can still change before 1.0. Install with `npm install @teamblind/axeda-better-auth@alpha better-auth @better-auth/core`. Documentation: [English](https://github.com/teamblindmx/axeda/tree/main/docs/en) · [Español](https://github.com/teamblindmx/axeda/tree/main/docs/es).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @teamblind/axeda-better-auth@alpha better-auth @better-auth/core
11
+ # to protect Express routes as well:
12
+ npm install @teamblind/axeda-express@alpha express
13
+ ```
14
+
15
+ Node.js 22 or later. ES modules only. Peer dependencies: `better-auth` and `@better-auth/core` (`^1.7.10`; tested on 1.7.10 and 1.7.17).
16
+
17
+ ## Use
6
18
 
7
19
  ```ts
8
- import { axeda, secureAuthOptions, sessionPrincipal } from '@teamblind/axeda-better-auth';
20
+ import { betterAuth } from 'better-auth';
21
+ import { getMigrations } from 'better-auth/db/migration';
22
+ import { axeda, secureAuthOptions } from '@teamblind/axeda-better-auth';
23
+
24
+ const axedaPlugin = axeda({
25
+ permissions: ['invoice:read', 'invoice:create'],
26
+ roles: { viewer: { name: 'Viewer', permissions: ['invoice:read'] } },
27
+ });
28
+
29
+ const options = {
30
+ ...secureAuthOptions({
31
+ baseURL: 'https://api.example.com',
32
+ secret: process.env.BETTER_AUTH_SECRET!, // at least 32 random characters
33
+ trustedOrigins: ['https://app.example.com'],
34
+ }),
35
+ database, // see "Databases" below
36
+ plugins: [axedaPlugin],
37
+ emailVerification: { sendOnSignUp: true, sendVerificationEmail }, // yours
38
+ };
39
+
40
+ await (await getMigrations(options)).runMigrations(); // SQL databases; MongoDB needs none
41
+ const auth = betterAuth(options);
42
+ await auth.$context;
43
+ await axedaPlugin.start(); // checks the database has real transactions, syncs your roles
44
+ ```
45
+
46
+ From the browser:
47
+
48
+ ```ts
49
+ import { createAuthClient } from 'better-auth/client';
9
50
  import { axedaClient } from '@teamblind/axeda-better-auth/client';
51
+
52
+ const client = createAuthClient({ baseURL: 'https://api.example.com', plugins: [axedaClient()] });
10
53
  ```
11
54
 
12
- Peer dependencies: `better-auth` and `@better-auth/core` (`^1.7.10`; tested on 1.7.10 and 1.7.14).
55
+ ## What you configure
56
+
57
+ Axeda reads no environment variables: everything is passed in code.
58
+
59
+ | You configure… | With | Required |
60
+ | ----------------------------------- | ---------------------------- | -------------------------------------------------- |
61
+ | Better Auth: URLs, secret, sessions | `secureAuthOptions({ … })` | `baseURL`, `secret`, `trustedOrigins` |
62
+ | Better Auth: the rest | your own `betterAuth({ … })` | `database`, `plugins`, a verification email sender |
63
+ | What Axeda manages | `axeda({ … })` | `permissions` |
64
+
65
+ Every option, its accepted values, its default and what happens when it is wrong are in the
66
+ [Configuration guide](https://github.com/teamblindmx/axeda/blob/main/docs/en/configuration.md).
67
+
68
+ ## Databases
69
+
70
+ | Database | What you give Better Auth | Checked in the CI |
71
+ | ---------- | ---------------------------------------------------------- | ----------------------- |
72
+ | SQLite | `new Database(file)` from `better-sqlite3` | In memory |
73
+ | PostgreSQL | `new pg.Pool({ connectionString })` | 16 and 18, real servers |
74
+ | MySQL | `createPool({ uri, timezone: 'Z' })` from `mysql2/promise` | 8.4 and 9, real servers |
75
+ | MongoDB | `mongodbAdapter(db, { client })`, on a replica set | 7.0, a real replica set |
76
+
77
+ Axeda refuses to start on a database without real transactions. MySQL and MongoDB have settings of their
78
+ own: see [Databases](https://github.com/teamblindmx/axeda/blob/main/docs/en/reference.md#databases).
79
+
80
+ ## Guides
81
+
82
+ - [Quick start](https://github.com/teamblindmx/axeda/blob/main/docs/en/quickstart.md): run the example, then add Axeda to your app.
83
+ - [Configuration](https://github.com/teamblindmx/axeda/blob/main/docs/en/configuration.md): every option, the startup order and a production checklist.
84
+ - [Reference](https://github.com/teamblindmx/axeda/blob/main/docs/en/reference.md): endpoints, error codes and the TypeScript API.
85
+ - [Security](https://github.com/teamblindmx/axeda/blob/main/docs/en/security.md): what `secureAuthOptions()` fixes and what stays yours.
86
+ - [Upgrading](https://github.com/teamblindmx/axeda/blob/main/docs/en/upgrading.md): from `0.1.0-alpha.0`.
13
87
 
14
88
  Part of [Axeda](https://github.com/teamblindmx/axeda), hierarchical authorization (global → company → business) on top of [Better Auth](https://www.better-auth.com). Licensed under [Apache-2.0](LICENSE).
package/dist/plugin.d.ts CHANGED
@@ -21,6 +21,13 @@ export interface AxedaPluginOptions extends AxedaOptions {
21
21
  * sesión). Desactivado por defecto: es el único endpoint que no exige iniciar sesión.
22
22
  */
23
23
  readonly publicIdentity?: boolean;
24
+ /**
25
+ * La vida máxima de una sesión, en segundos, contada desde que se abrió. Better Auth renueva la
26
+ * sesión mientras se usa (`session.expiresIn` es el tiempo sin actividad), así que sin esto una
27
+ * sesión usada a diario no caducaría nunca. Pasado este tiempo hay que volver a iniciar sesión.
28
+ * Por defecto 30 días; `false` lo desactiva.
29
+ */
30
+ readonly maxSessionAge?: number | false;
24
31
  }
25
32
  /**
26
33
  * Plugin de Axeda para Better Auth: esquema, endpoints `/axeda/*`, el hook de borrado de usuario
@@ -66,7 +73,6 @@ export declare function axeda(options: AxedaPluginOptions): {
66
73
  key: {
67
74
  type: "string";
68
75
  required: true;
69
- unique: true;
70
76
  input: false;
71
77
  };
72
78
  name: {
@@ -119,6 +125,10 @@ export declare function axeda(options: AxedaPluginOptions): {
119
125
  input: false;
120
126
  };
121
127
  };
128
+ indexes: {
129
+ fields: [string];
130
+ unique: true;
131
+ }[];
122
132
  };
123
133
  axedaAssignment: {
124
134
  fields: {
@@ -181,7 +191,6 @@ export declare function axeda(options: AxedaPluginOptions): {
181
191
  key: {
182
192
  type: "string";
183
193
  required: true;
184
- unique: true;
185
194
  input: false;
186
195
  };
187
196
  version: {
@@ -191,6 +200,10 @@ export declare function axeda(options: AxedaPluginOptions): {
191
200
  input: false;
192
201
  };
193
202
  };
203
+ indexes: {
204
+ fields: [string];
205
+ unique: true;
206
+ }[];
194
207
  };
195
208
  axedaAudit: {
196
209
  fields: {
@@ -261,7 +274,6 @@ export declare function axeda(options: AxedaPluginOptions): {
261
274
  slug: {
262
275
  type: "string";
263
276
  required: true;
264
- unique: true;
265
277
  input: false;
266
278
  };
267
279
  name: {
@@ -317,9 +329,13 @@ export declare function axeda(options: AxedaPluginOptions): {
317
329
  input: false;
318
330
  };
319
331
  };
320
- indexes: {
332
+ indexes: ({
321
333
  fields: [string];
322
- }[];
334
+ unique: true;
335
+ } | {
336
+ fields: [string];
337
+ unique?: never;
338
+ })[];
323
339
  };
324
340
  axedaBusiness: {
325
341
  fields: {
@@ -444,7 +460,6 @@ export declare function axeda(options: AxedaPluginOptions): {
444
460
  pendingKey: {
445
461
  type: "string";
446
462
  required: true;
447
- unique: true;
448
463
  input: false;
449
464
  };
450
465
  consumed: {
@@ -460,9 +475,14 @@ export declare function axeda(options: AxedaPluginOptions): {
460
475
  };
461
476
  };
462
477
  indexes: ({
478
+ fields: [string];
479
+ unique: true;
480
+ } | {
463
481
  fields: [string, string];
482
+ unique?: never;
464
483
  } | {
465
484
  fields: [string];
485
+ unique?: never;
466
486
  })[];
467
487
  };
468
488
  };
@@ -1930,7 +1950,20 @@ export declare function axeda(options: AxedaPluginOptions): {
1930
1950
  create: {
1931
1951
  before: (session: {
1932
1952
  userId: string;
1933
- }) => Promise<void>;
1953
+ }) => Promise<{
1954
+ data: {
1955
+ expiresAt: Date;
1956
+ };
1957
+ } | undefined>;
1958
+ };
1959
+ update: {
1960
+ before: (session: {
1961
+ expiresAt?: unknown;
1962
+ }, context: unknown) => Promise<{
1963
+ data: {
1964
+ expiresAt: Date;
1965
+ };
1966
+ } | undefined>;
1934
1967
  };
1935
1968
  };
1936
1969
  user: {
package/dist/plugin.js CHANGED
@@ -4,6 +4,7 @@ import { createEndpoints } from './endpoints.js';
4
4
  import { createUserEndpoints } from './endpoints-users.js';
5
5
  import { createInvitationEndpoints, createPublicEndpoints, createTenantEndpoints, } from './endpoints-tenants.js';
6
6
  import { axedaSchema } from './schema.js';
7
+ import { cappedExpiry, resolveMaxSessionAge, sessionBeingRefreshed } from './session-lifetime.js';
7
8
  import { createBetterAuthPersistence } from './stores.js';
8
9
  import { createBetterAuthUserAdmin } from './user-admin.js';
9
10
  import { createBetterAuthUserDirectory } from './users.js';
@@ -69,6 +70,7 @@ const RATE_LIMITS = [
69
70
  * ```
70
71
  */
71
72
  export function axeda(options) {
73
+ const maxSessionAge = resolveMaxSessionAge(options.maxSessionAge);
72
74
  let core;
73
75
  let persistence;
74
76
  const instance = () => {
@@ -146,6 +148,24 @@ export function axeda(options) {
146
148
  message: 'This account is suspended.',
147
149
  });
148
150
  }
151
+ // La vida máxima también recorta la sesión que nace (p. ej. si es menor que
152
+ // `session.expiresIn`).
153
+ const expiresAt = maxSessionAge === undefined
154
+ ? undefined
155
+ : cappedExpiry(session.expiresAt, session.createdAt ?? new Date(), maxSessionAge);
156
+ return expiresAt === undefined ? undefined : { data: { expiresAt } };
157
+ },
158
+ },
159
+ update: {
160
+ // Better Auth renueva la sesión moviendo su caducidad hacia delante cada vez que
161
+ // se usa. Aquí se recorta para que nunca pase de su creación más la vida máxima, y
162
+ // su propia caducidad hace el resto, en sus endpoints y en tus rutas.
163
+ before: (session, context) => {
164
+ const current = sessionBeingRefreshed(context);
165
+ const expiresAt = maxSessionAge === undefined || current === undefined
166
+ ? undefined
167
+ : cappedExpiry(session.expiresAt, current.createdAt, maxSessionAge);
168
+ return Promise.resolve(expiresAt === undefined ? undefined : { data: { expiresAt } });
149
169
  },
150
170
  },
151
171
  },
package/dist/preset.d.ts CHANGED
@@ -57,7 +57,7 @@ export interface SecureAuthOptions {
57
57
  * comodín o sin `https` en producción.
58
58
  *
59
59
  * No añade plugins (2FA, passkeys…): las funciones que exigen 2FA (`requireMfa`) necesitan el
60
- * plugin `twoFactor` de Better Auth, y conviene desactivar «confiar en este dispositivo» en las
61
- * cuentas administrativas (ver la guía de seguridad).
60
+ * plugin `twoFactor` de Better Auth, y conviene desactivar «confiar en este dispositivo» con
61
+ * `trustDeviceMaxAge: 0` en las cuentas administrativas (ver la guía de seguridad).
62
62
  */
63
63
  export declare function secureAuthOptions(input: SecureAuthInput): SecureAuthOptions;
package/dist/preset.js CHANGED
@@ -74,8 +74,8 @@ function trustedOriginList(input, production) {
74
74
  * comodín o sin `https` en producción.
75
75
  *
76
76
  * No añade plugins (2FA, passkeys…): las funciones que exigen 2FA (`requireMfa`) necesitan el
77
- * plugin `twoFactor` de Better Auth, y conviene desactivar «confiar en este dispositivo» en las
78
- * cuentas administrativas (ver la guía de seguridad).
77
+ * plugin `twoFactor` de Better Auth, y conviene desactivar «confiar en este dispositivo» con
78
+ * `trustDeviceMaxAge: 0` en las cuentas administrativas (ver la guía de seguridad).
79
79
  */
80
80
  export function secureAuthOptions(input) {
81
81
  const production = (input.environment ?? 'production') === 'production';
package/dist/schema.d.ts CHANGED
@@ -20,6 +20,10 @@ export declare const GLOBAL_SCOPE_ID = "";
20
20
  * Esquema que el plugin añade a Better Auth. Las migraciones las genera Better Auth a partir
21
21
  * de esto (`getMigrations` o su CLI). Reglas: columnas nuevas siempre opcionales o con valor
22
22
  * por defecto; un cambio destructivo exige una migración manual.
23
+ *
24
+ * Las restricciones únicas van como `indexes` con `unique: true` y no como `unique` de un campo: el
25
+ * adaptador de MongoDB de Better Auth solo crea los índices de tabla, y sin ellos dos escrituras
26
+ * simultáneas con la misma clave caben las dos. En SQL son equivalentes.
23
27
  */
24
28
  export declare const axedaSchema: {
25
29
  user: {
@@ -54,7 +58,6 @@ export declare const axedaSchema: {
54
58
  key: {
55
59
  type: "string";
56
60
  required: true;
57
- unique: true;
58
61
  input: false;
59
62
  };
60
63
  name: {
@@ -107,6 +110,10 @@ export declare const axedaSchema: {
107
110
  input: false;
108
111
  };
109
112
  };
113
+ indexes: {
114
+ fields: [string];
115
+ unique: true;
116
+ }[];
110
117
  };
111
118
  axedaAssignment: {
112
119
  fields: {
@@ -169,7 +176,6 @@ export declare const axedaSchema: {
169
176
  key: {
170
177
  type: "string";
171
178
  required: true;
172
- unique: true;
173
179
  input: false;
174
180
  };
175
181
  version: {
@@ -179,6 +185,10 @@ export declare const axedaSchema: {
179
185
  input: false;
180
186
  };
181
187
  };
188
+ indexes: {
189
+ fields: [string];
190
+ unique: true;
191
+ }[];
182
192
  };
183
193
  axedaAudit: {
184
194
  fields: {
@@ -249,7 +259,6 @@ export declare const axedaSchema: {
249
259
  slug: {
250
260
  type: "string";
251
261
  required: true;
252
- unique: true;
253
262
  input: false;
254
263
  };
255
264
  name: {
@@ -305,9 +314,13 @@ export declare const axedaSchema: {
305
314
  input: false;
306
315
  };
307
316
  };
308
- indexes: {
317
+ indexes: ({
309
318
  fields: [string];
310
- }[];
319
+ unique: true;
320
+ } | {
321
+ fields: [string];
322
+ unique?: never;
323
+ })[];
311
324
  };
312
325
  axedaBusiness: {
313
326
  fields: {
@@ -437,7 +450,6 @@ export declare const axedaSchema: {
437
450
  pendingKey: {
438
451
  type: "string";
439
452
  required: true;
440
- unique: true;
441
453
  input: false;
442
454
  };
443
455
  /** 0 mientras está pendiente. Se sube con `incrementOne`: solo un cierre lo consigue. */
@@ -454,9 +466,14 @@ export declare const axedaSchema: {
454
466
  };
455
467
  };
456
468
  indexes: ({
469
+ fields: [string];
470
+ unique: true;
471
+ } | {
457
472
  fields: [string, string];
473
+ unique?: never;
458
474
  } | {
459
475
  fields: [string];
476
+ unique?: never;
460
477
  })[];
461
478
  };
462
479
  };
package/dist/schema.js CHANGED
@@ -20,6 +20,10 @@ export const GLOBAL_SCOPE_ID = '';
20
20
  * Esquema que el plugin añade a Better Auth. Las migraciones las genera Better Auth a partir
21
21
  * de esto (`getMigrations` o su CLI). Reglas: columnas nuevas siempre opcionales o con valor
22
22
  * por defecto; un cambio destructivo exige una migración manual.
23
+ *
24
+ * Las restricciones únicas van como `indexes` con `unique: true` y no como `unique` de un campo: el
25
+ * adaptador de MongoDB de Better Auth solo crea los índices de tabla, y sin ellos dos escrituras
26
+ * simultáneas con la misma clave caben las dos. En SQL son equivalentes.
23
27
  */
24
28
  export const axedaSchema = {
25
29
  user: {
@@ -34,7 +38,7 @@ export const axedaSchema = {
34
38
  },
35
39
  [MODELS.role]: {
36
40
  fields: {
37
- key: { type: 'string', required: true, unique: true, input: false },
41
+ key: { type: 'string', required: true, input: false },
38
42
  name: { type: 'string', required: true, input: false },
39
43
  description: { type: 'string', required: false, input: false },
40
44
  permissions: { type: 'string[]', required: true, input: false },
@@ -45,6 +49,7 @@ export const axedaSchema = {
45
49
  createdAt: { type: 'date', required: true, input: false },
46
50
  updatedAt: { type: 'date', required: true, input: false },
47
51
  },
52
+ indexes: [{ fields: ['key'], unique: true }],
48
53
  },
49
54
  [MODELS.assignment]: {
50
55
  fields: {
@@ -73,9 +78,10 @@ export const axedaSchema = {
73
78
  },
74
79
  [MODELS.state]: {
75
80
  fields: {
76
- key: { type: 'string', required: true, unique: true, input: false },
81
+ key: { type: 'string', required: true, input: false },
77
82
  version: { type: 'number', required: true, defaultValue: 0, input: false },
78
83
  },
84
+ indexes: [{ fields: ['key'], unique: true }],
79
85
  },
80
86
  [MODELS.audit]: {
81
87
  fields: {
@@ -94,13 +100,16 @@ export const axedaSchema = {
94
100
  },
95
101
  indexes: [
96
102
  { fields: ['actorId'] },
103
+ // Sin él, filtrar por acción recorre toda la tabla y crece con ella (50-70 ms con 200 000 eventos
104
+ // en PostgreSQL 16 y 18, 30 ms en PGlite); con él son décimas de milisegundo.
105
+ { fields: ['action'] },
97
106
  { fields: ['targetType', 'targetId'] },
98
107
  { fields: ['scopeType', 'scopeId'] },
99
108
  ],
100
109
  },
101
110
  [MODELS.company]: {
102
111
  fields: {
103
- slug: { type: 'string', required: true, unique: true, input: false },
112
+ slug: { type: 'string', required: true, input: false },
104
113
  name: { type: 'string', required: true, input: false },
105
114
  legalName: { type: 'string', required: false, input: false },
106
115
  logoUrl: { type: 'string', required: false, input: false },
@@ -112,7 +121,7 @@ export const axedaSchema = {
112
121
  createdAt: { type: 'date', required: true, input: false },
113
122
  updatedAt: { type: 'date', required: true, input: false },
114
123
  },
115
- indexes: [{ fields: ['deletedAt'] }],
124
+ indexes: [{ fields: ['slug'], unique: true }, { fields: ['deletedAt'] }],
116
125
  },
117
126
  [MODELS.business]: {
118
127
  fields: {
@@ -152,11 +161,15 @@ export const axedaSchema = {
152
161
  * cerrarla pasa a `closed:<id>`. Así dos invitaciones iguales a la vez no caben, sin
153
162
  * depender de índices parciales que no todas las bases tienen.
154
163
  */
155
- pendingKey: { type: 'string', required: true, unique: true, input: false },
164
+ pendingKey: { type: 'string', required: true, input: false },
156
165
  /** 0 mientras está pendiente. Se sube con `incrementOne`: solo un cierre lo consigue. */
157
166
  consumed: { type: 'number', required: true, defaultValue: 0, input: false },
158
167
  createdAt: { type: 'date', required: true, input: false },
159
168
  },
160
- indexes: [{ fields: ['scopeType', 'scopeId'] }, { fields: ['email'] }],
169
+ indexes: [
170
+ { fields: ['pendingKey'], unique: true },
171
+ { fields: ['scopeType', 'scopeId'] },
172
+ { fields: ['email'] },
173
+ ],
161
174
  },
162
175
  };
@@ -0,0 +1,21 @@
1
+ /** Treinta días: tras ese tiempo hay que volver a iniciar sesión, por mucho que se use. */
2
+ export declare const DEFAULT_MAX_SESSION_AGE: number;
3
+ /**
4
+ * La vida máxima de una sesión, en segundos, o `undefined` si se desactivó con `false`.
5
+ * Un valor que no sea un entero positivo es un error de configuración, no se corrige en silencio.
6
+ */
7
+ export declare function resolveMaxSessionAge(value: number | false | undefined): number | undefined;
8
+ /**
9
+ * Recorta cuándo caduca una sesión para que nunca pase de su creación más `maxAge`. Devuelve
10
+ * `undefined` si no hay nada que recortar.
11
+ */
12
+ export declare function cappedExpiry(expiresAt: unknown, createdAt: unknown, maxAge: number): Date | undefined;
13
+ /**
14
+ * La sesión que Better Auth está renovando. Una renovación solo trae la nueva fecha de caducidad;
15
+ * la fila (con su `createdAt`) está en el contexto de la petición, donde Better Auth la leyó
16
+ * justo antes. Sin petición (un trabajo que llama a `updateSession` por su cuenta) no hay de dónde
17
+ * sacarla y la renovación se deja pasar.
18
+ */
19
+ export declare function sessionBeingRefreshed(context: unknown): {
20
+ readonly createdAt: unknown;
21
+ } | undefined;
@@ -0,0 +1,50 @@
1
+ import { AxedaError } from '@teamblind/axeda-core';
2
+ /** Treinta días: tras ese tiempo hay que volver a iniciar sesión, por mucho que se use. */
3
+ export const DEFAULT_MAX_SESSION_AGE = 30 * 24 * 60 * 60;
4
+ /**
5
+ * La vida máxima de una sesión, en segundos, o `undefined` si se desactivó con `false`.
6
+ * Un valor que no sea un entero positivo es un error de configuración, no se corrige en silencio.
7
+ */
8
+ export function resolveMaxSessionAge(value) {
9
+ if (value === false)
10
+ return undefined;
11
+ if (value === undefined)
12
+ return DEFAULT_MAX_SESSION_AGE;
13
+ if (!Number.isSafeInteger(value) || value <= 0) {
14
+ throw new AxedaError('AXEDA_INVALID_CONFIG', `maxSessionAge must be a positive whole number of seconds, or false to turn it off (got ${String(value)}).`);
15
+ }
16
+ return value;
17
+ }
18
+ const asDate = (value) => {
19
+ if (value instanceof Date || typeof value === 'string' || typeof value === 'number') {
20
+ const date = new Date(value);
21
+ return Number.isNaN(date.getTime()) ? undefined : date;
22
+ }
23
+ return undefined;
24
+ };
25
+ /**
26
+ * Recorta cuándo caduca una sesión para que nunca pase de su creación más `maxAge`. Devuelve
27
+ * `undefined` si no hay nada que recortar.
28
+ */
29
+ export function cappedExpiry(expiresAt, createdAt, maxAge) {
30
+ const expiry = asDate(expiresAt);
31
+ const created = asDate(createdAt);
32
+ if (expiry === undefined || created === undefined)
33
+ return undefined;
34
+ const limit = created.getTime() + maxAge * 1000;
35
+ return expiry.getTime() > limit ? new Date(limit) : undefined;
36
+ }
37
+ /**
38
+ * La sesión que Better Auth está renovando. Una renovación solo trae la nueva fecha de caducidad;
39
+ * la fila (con su `createdAt`) está en el contexto de la petición, donde Better Auth la leyó
40
+ * justo antes. Sin petición (un trabajo que llama a `updateSession` por su cuenta) no hay de dónde
41
+ * sacarla y la renovación se deja pasar.
42
+ */
43
+ export function sessionBeingRefreshed(context) {
44
+ if (typeof context !== 'object' || context === null)
45
+ return undefined;
46
+ const inner = context
47
+ .context;
48
+ const row = inner?.session?.session;
49
+ return row === undefined || row === null ? undefined : { createdAt: row.createdAt };
50
+ }
package/dist/stores.js CHANGED
@@ -1,9 +1,19 @@
1
1
  import { AxedaError, createAuditIdFactory, } from '@teamblind/axeda-core';
2
2
  import { isForeignKeyViolation, isUniqueViolation } from './errors.js';
3
3
  import { scopeColumns, toAssignment, toAuditEvent, toRole, } from './rows.js';
4
- import { MODELS, POLICY_STATE_KEY } from './schema.js';
4
+ import { axedaSchema, MODELS, POLICY_STATE_KEY } from './schema.js';
5
5
  import { byId, decodeCursor, encodeCursor } from './shared.js';
6
6
  import { createBusinessStore, createCompanyStore, createInvitationStore } from './tenant-stores.js';
7
+ /**
8
+ * MongoDB sin conjunto de réplicas (un `mongod` suelto) rechaza las transacciones con un error del
9
+ * servidor que no dice qué hacer. Se traduce al error de Axeda para ese caso.
10
+ */
11
+ function explainTransactionFailure(error) {
12
+ if (error instanceof Error && /replica set member or mongos/i.test(error.message)) {
13
+ throw new AxedaError('AXEDA_TRANSACTIONS_UNSUPPORTED', 'MongoDB only runs transactions on a replica set (a single-node one is enough, also for development) or on a sharded cluster, and Axeda needs them to keep permission versions consistent. Start MongoDB with --replSet and run rs.initiate(), or use a database that supports transactions.', { cause: error });
14
+ }
15
+ throw error;
16
+ }
7
17
  /**
8
18
  * Better Auth limita `findMany` a 100 filas si no se indica otra cosa. Para datos de seguridad
9
19
  * un tope silencioso es peligroso, así que siempre se pide un límite explícito.
@@ -338,6 +348,34 @@ export function createBetterAuthPersistence(databaseAdapter, options = {}) {
338
348
  });
339
349
  return ensured;
340
350
  };
351
+ // MongoDB no tiene migraciones: su adaptador crea cada colección y sus índices la primera vez que
352
+ // escribe en ella. Si esa primera vez cae DENTRO de una transacción, que ya leyó la colección, el
353
+ // adaptador pide crear el índice fuera de la sesión, MongoDB lo deja esperando a que acabe la
354
+ // transacción y la transacción espera al índice: se cuelga hasta que caduca (60 s). Se evita
355
+ // escribiendo una vez, antes de la primera transacción, en cada colección de Axeda (sin tocar nada:
356
+ // el filtro no coincide con ningún documento).
357
+ let prepared = null;
358
+ const prepare = () => {
359
+ prepared ??= (async () => {
360
+ await ensurePolicyRow();
361
+ if (adapter.id !== 'mongodb-adapter')
362
+ return;
363
+ await Promise.all(Object.values(MODELS).map((model) => {
364
+ const field = Object.entries(axedaSchema[model].fields).find(([, attributes]) => attributes.type === 'string')?.[0];
365
+ if (field === undefined)
366
+ return Promise.resolve();
367
+ return adapter.updateMany({
368
+ model,
369
+ where: [{ field: 'id', value: 'axeda-collection-setup' }],
370
+ update: { [field]: '' },
371
+ });
372
+ }));
373
+ })().catch((error) => {
374
+ prepared = null;
375
+ throw error;
376
+ });
377
+ return prepared;
378
+ };
341
379
  const storesFor = (target, atomic, ensure) => ({
342
380
  roles: createRoleStore(target, atomic, now),
343
381
  assignments: createAssignmentStore(target, now),
@@ -347,12 +385,17 @@ export function createBetterAuthPersistence(databaseAdapter, options = {}) {
347
385
  businesses: createBusinessStore(target, atomic, now),
348
386
  invitations: createInvitationStore(target, now),
349
387
  });
350
- const root = storesFor(adapter, (work) => adapter.transaction((trx) => work(trx)), ensurePolicyRow);
388
+ const root = storesFor(adapter, async (work) => {
389
+ await prepare();
390
+ return adapter.transaction((trx) => work(trx)).catch(explainTransactionFailure);
391
+ }, ensurePolicyRow);
351
392
  return {
352
393
  ...root,
353
394
  async transaction(callback) {
354
- await ensurePolicyRow();
355
- return adapter.transaction((trx) => callback(storesFor(trx, (work) => work(trx), () => Promise.resolve())));
395
+ await prepare();
396
+ return adapter
397
+ .transaction((trx) => callback(storesFor(trx, (work) => work(trx), () => Promise.resolve())))
398
+ .catch(explainTransactionFailure);
356
399
  },
357
400
  };
358
401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamblind/axeda-better-auth",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "Better Auth plugin for Axeda: schema, endpoints, client plugin and store implementations.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "TeamBlind",
@@ -49,7 +49,7 @@
49
49
  "node": ">=22"
50
50
  },
51
51
  "dependencies": {
52
- "@teamblind/axeda-core": "^0.1.0-alpha.0",
52
+ "@teamblind/axeda-core": "^0.1.0-alpha.1",
53
53
  "zod": "^4.6.7"
54
54
  },
55
55
  "peerDependencies": {
@@ -57,6 +57,7 @@
57
57
  "better-auth": "^1.7.10"
58
58
  },
59
59
  "devDependencies": {
60
+ "@better-auth/api-key": "^1.7.10",
60
61
  "@better-auth/core": "^1.7.10",
61
62
  "@electric-sql/pglite": "^0.5.10",
62
63
  "@types/better-sqlite3": "^9.6.0",
@@ -64,6 +65,8 @@
64
65
  "better-auth": "^1.7.10",
65
66
  "better-sqlite3": "^13.0.3",
66
67
  "kysely": "^0.29.6",
68
+ "mongodb": "^7.8.0",
69
+ "mysql2": "^3.24.6",
67
70
  "pg": "^8.23.1"
68
71
  }
69
72
  }