@teamblind/axeda-better-auth 0.0.0-stage → 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.
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import type { DBAdapter, DBTransactionAdapter } from '@better-auth/core/db/adapter';
2
+ export type Adapter = DBAdapter | DBTransactionAdapter;
3
+ /** Ejecuta `work` de forma atómica con el adaptador que corresponda. */
4
+ export type Atomic = <T>(work: (adapter: Adapter) => Promise<T>) => Promise<T>;
5
+ export declare const encodeCursor: (value: string) => string;
6
+ export declare const decodeCursor: (cursor: string) => string;
7
+ export declare const byId: (id: string) => {
8
+ field: string;
9
+ value: string;
10
+ }[];
11
+ /**
12
+ * Better Auth limita `findMany` a 100 filas si no se indica otra cosa. Para datos de seguridad
13
+ * un tope silencioso es peligroso, así que siempre se pide un límite explícito.
14
+ */
15
+ export declare const MAX_ROWS = 10000;
package/dist/shared.js ADDED
@@ -0,0 +1,8 @@
1
+ export const encodeCursor = (value) => Buffer.from(value, 'utf8').toString('base64url');
2
+ export const decodeCursor = (cursor) => Buffer.from(cursor, 'base64url').toString('utf8');
3
+ export const byId = (id) => [{ field: 'id', value: id }];
4
+ /**
5
+ * Better Auth limita `findMany` a 100 filas si no se indica otra cosa. Para datos de seguridad
6
+ * un tope silencioso es peligroso, así que siempre se pide un límite explícito.
7
+ */
8
+ export const MAX_ROWS = 10_000;
@@ -0,0 +1,13 @@
1
+ import type { BetterAuthOptions } from '@better-auth/core';
2
+ import type { DBAdapter } from '@better-auth/core/db/adapter';
3
+ import { type AxedaPersistence } from '@teamblind/axeda-core';
4
+ export interface BetterAuthPersistenceOptions {
5
+ /** Reloj para `createdAt` y `updatedAt`. Por defecto, la hora del sistema. */
6
+ readonly now?: () => Date;
7
+ }
8
+ /**
9
+ * Implementa los puertos de persistencia de Axeda sobre el adaptador de base de datos de
10
+ * Better Auth (`ctx.adapter`). Sirve para cualquier base que Better Auth soporte; las
11
+ * transacciones deben ser reales, algo que Axeda comprueba al arrancar.
12
+ */
13
+ export declare function createBetterAuthPersistence<Options extends BetterAuthOptions>(databaseAdapter: DBAdapter<Options>, options?: BetterAuthPersistenceOptions): AxedaPersistence;
package/dist/stores.js ADDED
@@ -0,0 +1,401 @@
1
+ import { AxedaError, createAuditIdFactory, } from '@teamblind/axeda-core';
2
+ import { isForeignKeyViolation, isUniqueViolation } from './errors.js';
3
+ import { scopeColumns, toAssignment, toAuditEvent, toRole, } from './rows.js';
4
+ import { axedaSchema, MODELS, POLICY_STATE_KEY } from './schema.js';
5
+ import { byId, decodeCursor, encodeCursor } from './shared.js';
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
+ }
17
+ /**
18
+ * Better Auth limita `findMany` a 100 filas si no se indica otra cosa. Para datos de seguridad
19
+ * un tope silencioso es peligroso, así que siempre se pide un límite explícito.
20
+ */
21
+ const MAX_ASSIGNMENTS_READ = 1_000;
22
+ function createRoleStore(adapter, atomic, now) {
23
+ const model = MODELS.role;
24
+ const find = async (id) => adapter.findOne({ model, where: byId(id) });
25
+ return {
26
+ async get(id) {
27
+ const row = await find(id);
28
+ return row ? toRole(row) : null;
29
+ },
30
+ async getMany(ids) {
31
+ if (ids.length === 0)
32
+ return [];
33
+ const rows = await adapter.findMany({
34
+ model,
35
+ where: [{ field: 'id', operator: 'in', value: [...ids] }],
36
+ limit: ids.length,
37
+ });
38
+ return rows.map(toRole);
39
+ },
40
+ async getByKey(key) {
41
+ const row = await adapter.findOne({ model, where: [{ field: 'key', value: key }] });
42
+ return row ? toRole(row) : null;
43
+ },
44
+ async list(page) {
45
+ const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
46
+ const rows = await adapter.findMany({
47
+ model,
48
+ ...(after === null ? {} : { where: [{ field: 'key', operator: 'gt', value: after }] }),
49
+ sortBy: { field: 'key', direction: 'asc' },
50
+ limit: page.limit + 1,
51
+ });
52
+ const items = rows.slice(0, page.limit).map(toRole);
53
+ const last = items.at(-1);
54
+ return {
55
+ items,
56
+ nextCursor: rows.length > page.limit && last ? encodeCursor(last.key) : null,
57
+ };
58
+ },
59
+ async create(input) {
60
+ const taken = () => new AxedaError('AXEDA_ROLE_KEY_TAKEN', `A role with key "${input.key}" already exists.`);
61
+ if (await adapter.findOne({ model, where: [{ field: 'key', value: input.key }] })) {
62
+ throw taken();
63
+ }
64
+ const at = now();
65
+ try {
66
+ const row = await adapter.create({
67
+ model,
68
+ data: {
69
+ key: input.key,
70
+ name: input.name,
71
+ description: input.description ?? null,
72
+ permissions: [...input.permissions],
73
+ requireMfa: input.requireMfa ?? false,
74
+ status: input.status ?? 'active',
75
+ managed: input.managed ?? false,
76
+ version: 1,
77
+ createdAt: at,
78
+ updatedAt: at,
79
+ },
80
+ });
81
+ return toRole(row);
82
+ }
83
+ catch (error) {
84
+ // Otra petición creó la misma clave entre la comprobación y la inserción.
85
+ if (isUniqueViolation(error))
86
+ throw taken();
87
+ throw error;
88
+ }
89
+ },
90
+ async update(id, expectedVersion, changes) {
91
+ // `incrementOne` usa la versión como guarda dentro de la propia sentencia: de dos
92
+ // escritores con la misma versión, solo uno la cumple. `update` no ofrece esa garantía.
93
+ const updated = await adapter.incrementOne({
94
+ model,
95
+ where: [
96
+ { field: 'id', value: id },
97
+ { field: 'version', value: expectedVersion },
98
+ ],
99
+ increment: { version: 1 },
100
+ set: {
101
+ ...(changes.name === undefined ? {} : { name: changes.name }),
102
+ ...(changes.description === undefined ? {} : { description: changes.description }),
103
+ ...(changes.permissions === undefined ? {} : { permissions: [...changes.permissions] }),
104
+ ...(changes.requireMfa === undefined ? {} : { requireMfa: changes.requireMfa }),
105
+ ...(changes.status === undefined ? {} : { status: changes.status }),
106
+ updatedAt: now(),
107
+ },
108
+ });
109
+ if (updated)
110
+ return toRole(updated);
111
+ const current = await find(id);
112
+ if (!current)
113
+ throw new AxedaError('AXEDA_ROLE_NOT_FOUND', `Role "${id}" does not exist.`);
114
+ throw new AxedaError('AXEDA_VERSION_CONFLICT', `Role "${id}" is at version ${current.version}, not ${expectedVersion}.`);
115
+ },
116
+ delete: (id) =>
117
+ // Las claves foráneas ya lo harían en SQL, pero MongoDB no las tiene: se hace explícito.
118
+ atomic(async (trx) => {
119
+ await trx.deleteMany({ model: MODELS.assignment, where: [{ field: 'roleId', value: id }] });
120
+ const removed = await trx.consumeOne({ model, where: byId(id) });
121
+ return removed !== null;
122
+ }),
123
+ };
124
+ }
125
+ function createAssignmentStore(adapter, now) {
126
+ const model = MODELS.assignment;
127
+ return {
128
+ async get(id) {
129
+ const row = await adapter.findOne({ model, where: byId(id) });
130
+ return row ? toAssignment(row) : null;
131
+ },
132
+ async listForScope(scope, page) {
133
+ const columns = scopeColumns(scope);
134
+ const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
135
+ const rows = await adapter.findMany({
136
+ model,
137
+ where: [
138
+ { field: 'scopeType', value: columns.scopeType },
139
+ { field: 'scopeId', value: columns.scopeId },
140
+ ...(after === null ? [] : [{ field: 'id', operator: 'gt', value: after }]),
141
+ ],
142
+ sortBy: { field: 'id', direction: 'asc' },
143
+ limit: page.limit + 1,
144
+ });
145
+ const items = rows.slice(0, page.limit).map(toAssignment);
146
+ const last = items.at(-1);
147
+ return {
148
+ items,
149
+ nextCursor: rows.length > page.limit && last ? encodeCursor(last.id) : null,
150
+ };
151
+ },
152
+ async listForUser(userId) {
153
+ const rows = await adapter.findMany({
154
+ model,
155
+ where: [{ field: 'userId', value: userId }],
156
+ sortBy: { field: 'createdAt', direction: 'asc' },
157
+ limit: MAX_ASSIGNMENTS_READ,
158
+ });
159
+ return rows.map(toAssignment);
160
+ },
161
+ countForUser: (userId) => adapter.count({ model, where: [{ field: 'userId', value: userId }] }),
162
+ async create(input) {
163
+ const missingRole = () => new AxedaError('AXEDA_ROLE_NOT_FOUND', `Role "${input.roleId}" does not exist.`);
164
+ const exists = () => new AxedaError('AXEDA_ASSIGNMENT_EXISTS', 'The user already has that role in that scope.');
165
+ const columns = scopeColumns(input.scope);
166
+ if (!(await adapter.findOne({ model: MODELS.role, where: byId(input.roleId) }))) {
167
+ throw missingRole();
168
+ }
169
+ const duplicate = await adapter.findOne({
170
+ model,
171
+ where: [
172
+ { field: 'userId', value: input.userId },
173
+ { field: 'roleId', value: input.roleId },
174
+ { field: 'scopeType', value: columns.scopeType },
175
+ { field: 'scopeId', value: columns.scopeId },
176
+ ],
177
+ });
178
+ if (duplicate)
179
+ throw exists();
180
+ try {
181
+ const row = await adapter.create({
182
+ model,
183
+ data: {
184
+ userId: input.userId,
185
+ roleId: input.roleId,
186
+ ...columns,
187
+ grantedBy: input.grantedBy ?? null,
188
+ createdAt: now(),
189
+ expiresAt: input.expiresAt ?? null,
190
+ },
191
+ });
192
+ return toAssignment(row);
193
+ }
194
+ catch (error) {
195
+ // Carreras entre la comprobación y la inserción.
196
+ if (isUniqueViolation(error))
197
+ throw exists();
198
+ if (isForeignKeyViolation(error))
199
+ throw missingRole();
200
+ throw error;
201
+ }
202
+ },
203
+ async revoke(id) {
204
+ return (await adapter.consumeOne({ model, where: byId(id) })) !== null;
205
+ },
206
+ deleteForUser: (userId) => adapter.deleteMany({ model, where: [{ field: 'userId', value: userId }] }),
207
+ };
208
+ }
209
+ /** Condiciones de `AuditStore.list`: cada filtro presente, más el cursor (`id` menor que el anterior). */
210
+ function auditWhere(filter, after) {
211
+ const columns = filter.scope === undefined ? null : scopeColumns(filter.scope);
212
+ const conditions = [];
213
+ if (filter.actorId !== undefined)
214
+ conditions.push({ field: 'actorId', value: filter.actorId });
215
+ if (filter.action !== undefined)
216
+ conditions.push({ field: 'action', value: filter.action });
217
+ if (filter.targetType !== undefined) {
218
+ conditions.push({ field: 'targetType', value: filter.targetType });
219
+ }
220
+ if (filter.targetId !== undefined)
221
+ conditions.push({ field: 'targetId', value: filter.targetId });
222
+ if (columns !== null) {
223
+ conditions.push({ field: 'scopeType', value: columns.scopeType });
224
+ conditions.push({ field: 'scopeId', value: columns.scopeId });
225
+ }
226
+ if (after !== null)
227
+ conditions.push({ field: 'id', operator: 'lt', value: after });
228
+ return conditions;
229
+ }
230
+ function createAuditStore(adapter, now) {
231
+ const model = MODELS.audit;
232
+ const nextId = createAuditIdFactory();
233
+ return {
234
+ async append(input) {
235
+ const at = now();
236
+ const scope = input.scope === null ? null : scopeColumns(input.scope);
237
+ const row = await adapter.create({
238
+ model,
239
+ forceAllowId: true,
240
+ data: {
241
+ // El identificador ordena cronológicamente; `list` se apoya en eso.
242
+ id: nextId(at),
243
+ actorId: input.actorId,
244
+ action: input.action,
245
+ targetType: input.targetType,
246
+ targetId: input.targetId,
247
+ scopeType: scope?.scopeType ?? null,
248
+ scopeId: scope?.scopeId ?? null,
249
+ outcome: input.outcome,
250
+ ip: input.ip,
251
+ userAgent: input.userAgent,
252
+ metadata: { ...input.metadata },
253
+ at,
254
+ },
255
+ });
256
+ return toAuditEvent(row);
257
+ },
258
+ async list(filter, page) {
259
+ const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
260
+ const where = auditWhere(filter, after);
261
+ const rows = await adapter.findMany({
262
+ model,
263
+ ...(where.length === 0 ? {} : { where }),
264
+ sortBy: { field: 'id', direction: 'desc' },
265
+ limit: page.limit + 1,
266
+ });
267
+ const items = rows.slice(0, page.limit).map(toAuditEvent);
268
+ const last = items.at(-1);
269
+ return {
270
+ items,
271
+ nextCursor: rows.length > page.limit && last ? encodeCursor(last.id) : null,
272
+ };
273
+ },
274
+ };
275
+ }
276
+ function createAuthzStore(adapter, ensurePolicyRow) {
277
+ const policyWhere = [{ field: 'key', value: POLICY_STATE_KEY }];
278
+ return {
279
+ async getUserVersion(userId) {
280
+ const row = await adapter.findOne({
281
+ model: 'user',
282
+ where: byId(userId),
283
+ select: ['authzVersion'],
284
+ });
285
+ return row?.authzVersion ?? 0;
286
+ },
287
+ async bumpUserVersion(userId) {
288
+ // Atómico: leer, sumar y escribir pierde incrementos concurrentes.
289
+ const row = await adapter.incrementOne({
290
+ model: 'user',
291
+ where: byId(userId),
292
+ increment: { authzVersion: 1 },
293
+ });
294
+ if (!row)
295
+ throw new AxedaError('AXEDA_USER_NOT_FOUND', `User "${userId}" does not exist.`);
296
+ if (typeof row.authzVersion !== 'number') {
297
+ throw new AxedaError('AXEDA_DATA_CORRUPT', `User "${userId}" has no authzVersion. Run the Better Auth migrations for the Axeda plugin.`);
298
+ }
299
+ return row.authzVersion;
300
+ },
301
+ async getPolicyVersion() {
302
+ const row = await adapter.findOne({ model: MODELS.state, where: policyWhere });
303
+ return row?.version ?? 0;
304
+ },
305
+ async bumpPolicyVersion() {
306
+ await ensurePolicyRow();
307
+ const row = await adapter.incrementOne({
308
+ model: MODELS.state,
309
+ where: policyWhere,
310
+ increment: { version: 1 },
311
+ });
312
+ if (!row) {
313
+ throw new AxedaError('AXEDA_DATA_CORRUPT', 'The Axeda policy state row is missing.');
314
+ }
315
+ return row.version;
316
+ },
317
+ };
318
+ }
319
+ /**
320
+ * Implementa los puertos de persistencia de Axeda sobre el adaptador de base de datos de
321
+ * Better Auth (`ctx.adapter`). Sirve para cualquier base que Better Auth soporte; las
322
+ * transacciones deben ser reales, algo que Axeda comprueba al arrancar.
323
+ */
324
+ export function createBetterAuthPersistence(databaseAdapter, options = {}) {
325
+ // El adaptador se parametriza por las opciones de Better Auth de cada aplicación; aquí solo se
326
+ // usan operaciones que no dependen de ellas.
327
+ const adapter = databaseAdapter;
328
+ const now = options.now ?? (() => new Date());
329
+ // La fila de la versión de política se crea la primera vez, FUERA de cualquier transacción:
330
+ // una inserción duplicada dentro de una transacción de PostgreSQL la dejaría abortada.
331
+ let ensured = null;
332
+ const ensurePolicyRow = () => {
333
+ ensured ??= (async () => {
334
+ const where = [{ field: 'key', value: POLICY_STATE_KEY }];
335
+ if (await adapter.findOne({ model: MODELS.state, where }))
336
+ return;
337
+ try {
338
+ await adapter.create({ model: MODELS.state, data: { key: POLICY_STATE_KEY, version: 0 } });
339
+ }
340
+ catch (error) {
341
+ // Otra instancia la creó a la vez: es lo que se buscaba.
342
+ if (!isUniqueViolation(error))
343
+ throw error;
344
+ }
345
+ })().catch((error) => {
346
+ ensured = null;
347
+ throw error;
348
+ });
349
+ return ensured;
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
+ };
379
+ const storesFor = (target, atomic, ensure) => ({
380
+ roles: createRoleStore(target, atomic, now),
381
+ assignments: createAssignmentStore(target, now),
382
+ authz: createAuthzStore(target, ensure),
383
+ audit: createAuditStore(target, now),
384
+ companies: createCompanyStore(target, atomic, now),
385
+ businesses: createBusinessStore(target, atomic, now),
386
+ invitations: createInvitationStore(target, now),
387
+ });
388
+ const root = storesFor(adapter, async (work) => {
389
+ await prepare();
390
+ return adapter.transaction((trx) => work(trx)).catch(explainTransactionFailure);
391
+ }, ensurePolicyRow);
392
+ return {
393
+ ...root,
394
+ async transaction(callback) {
395
+ await prepare();
396
+ return adapter
397
+ .transaction((trx) => callback(storesFor(trx, (work) => work(trx), () => Promise.resolve())))
398
+ .catch(explainTransactionFailure);
399
+ },
400
+ };
401
+ }
@@ -0,0 +1,5 @@
1
+ import { type BusinessStore, type CompanyStore, type InvitationStore } from '@teamblind/axeda-core';
2
+ import { type Adapter, type Atomic } from './shared.js';
3
+ export declare function createCompanyStore(adapter: Adapter, atomic: Atomic, now: () => Date): CompanyStore;
4
+ export declare function createBusinessStore(adapter: Adapter, atomic: Atomic, now: () => Date): BusinessStore;
5
+ export declare function createInvitationStore(adapter: Adapter, now: () => Date): InvitationStore;