@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 +17 -0
- package/README.md +78 -4
- package/dist/plugin.d.ts +40 -7
- package/dist/plugin.js +20 -0
- package/dist/preset.d.ts +2 -2
- package/dist/preset.js +2 -2
- package/dist/schema.d.ts +23 -6
- package/dist/schema.js +19 -6
- package/dist/session-lifetime.d.ts +21 -0
- package/dist/session-lifetime.js +50 -0
- package/dist/stores.js +47 -4
- package/package.json +5 -2
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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<
|
|
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»
|
|
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»
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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: [
|
|
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) =>
|
|
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
|
|
355
|
-
return adapter
|
|
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.
|
|
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.
|
|
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
|
}
|