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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ import { AxedaError, scopeKey, withoutSecret, } from '@teamblind/axeda-core';
2
+ import { isForeignKeyViolation, isUniqueViolation } from './errors.js';
3
+ import { scopeColumns, toScope } from './rows.js';
4
+ import { MODELS } from './schema.js';
5
+ import { byId, decodeCursor, encodeCursor, MAX_ROWS } from './shared.js';
6
+ const tenantBase = (row) => ({
7
+ id: row.id,
8
+ slug: row.slug,
9
+ name: row.name,
10
+ logoUrl: row.logoUrl ?? null,
11
+ faviconUrl: row.faviconUrl ?? null,
12
+ // Un estado desconocido se trata como inactivo: ante la duda, no concede nada.
13
+ status: (row.status === 'active' ? 'active' : 'inactive'),
14
+ metadata: row.metadata,
15
+ version: row.version,
16
+ deletedAt: row.deletedAt ?? null,
17
+ createdAt: row.createdAt,
18
+ updatedAt: row.updatedAt,
19
+ });
20
+ const toCompany = (row) => ({
21
+ ...tenantBase(row),
22
+ legalName: row.legalName ?? null,
23
+ });
24
+ const toBusiness = (row) => ({
25
+ ...tenantBase(row),
26
+ companyId: row.companyId,
27
+ });
28
+ const toInvitation = (row) => ({
29
+ id: row.id,
30
+ email: row.email,
31
+ scope: toScope({ id: row.id, scopeType: row.scopeType, scopeId: row.scopeId }),
32
+ roleIds: row.roleIds,
33
+ invitedBy: row.invitedBy,
34
+ secretHash: row.secretHash,
35
+ expiresAt: row.expiresAt,
36
+ createdAt: row.createdAt,
37
+ acceptedAt: row.acceptedAt ?? null,
38
+ revokedAt: row.revokedAt ?? null,
39
+ });
40
+ /** Condiciones del filtro de borrados y de identificadores. */
41
+ function filterWhere(filter) {
42
+ const where = [];
43
+ const deleted = filter.deleted ?? 'exclude';
44
+ if (deleted === 'exclude')
45
+ where.push({ field: 'deletedAt', value: null });
46
+ if (deleted === 'only')
47
+ where.push({ field: 'deletedAt', operator: 'ne', value: null });
48
+ if (filter.ids !== undefined)
49
+ where.push({ field: 'id', operator: 'in', value: [...filter.ids] });
50
+ return where;
51
+ }
52
+ /**
53
+ * Cambio con la versión como guarda (`incrementOne`): de dos escritores con la misma versión solo
54
+ * uno la cumple. Si no cambia nada se distingue «no existe» de «versión distinta».
55
+ */
56
+ async function guarded(adapter, model, id, expectedVersion, set, now, missing) {
57
+ const updated = await adapter.incrementOne({
58
+ model,
59
+ where: [
60
+ { field: 'id', value: id },
61
+ { field: 'version', value: expectedVersion },
62
+ ],
63
+ increment: { version: 1 },
64
+ set: { ...set, updatedAt: now() },
65
+ });
66
+ if (updated)
67
+ return updated;
68
+ const current = await adapter.findOne({ model, where: byId(id) });
69
+ if (!current)
70
+ throw missing();
71
+ throw new AxedaError('AXEDA_VERSION_CONFLICT', `"${id}" is at version ${current.version}, not ${expectedVersion}.`);
72
+ }
73
+ const defined = (values) => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
74
+ async function pageOf(adapter, model, where, sortField, sortKey, page, map) {
75
+ const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
76
+ const rows = await adapter.findMany({
77
+ model,
78
+ where: [
79
+ ...where,
80
+ ...(after === null ? [] : [{ field: sortField, operator: 'gt', value: after }]),
81
+ ],
82
+ sortBy: { field: sortField, direction: 'asc' },
83
+ limit: page.limit + 1,
84
+ });
85
+ const items = rows.slice(0, page.limit);
86
+ const last = items.at(-1);
87
+ return {
88
+ items: items.map(map),
89
+ nextCursor: rows.length > page.limit && last ? encodeCursor(sortKey(last)) : null,
90
+ };
91
+ }
92
+ const emptyPage = () => ({ items: [], nextCursor: null });
93
+ /** Borra de verdad las asignaciones e invitaciones de estos alcances. */
94
+ async function dropScopes(adapter, scopeType, ids) {
95
+ if (ids.length === 0)
96
+ return;
97
+ const where = [
98
+ { field: 'scopeType', value: scopeType },
99
+ { field: 'scopeId', operator: 'in', value: [...ids] },
100
+ ];
101
+ await adapter.deleteMany({ model: MODELS.assignment, where });
102
+ await adapter.deleteMany({ model: MODELS.invitation, where });
103
+ }
104
+ // ---------- compañías ----------
105
+ export function createCompanyStore(adapter, atomic, now) {
106
+ const model = MODELS.company;
107
+ const missing = (id) => () => new AxedaError('AXEDA_COMPANY_NOT_FOUND', `Company "${id}" does not exist.`);
108
+ return {
109
+ async get(id) {
110
+ const row = await adapter.findOne({ model, where: byId(id) });
111
+ return row ? toCompany(row) : null;
112
+ },
113
+ async getMany(ids) {
114
+ if (ids.length === 0)
115
+ return [];
116
+ const rows = await adapter.findMany({
117
+ model,
118
+ where: [{ field: 'id', operator: 'in', value: [...ids] }],
119
+ limit: ids.length,
120
+ });
121
+ return rows.map(toCompany);
122
+ },
123
+ async getBySlug(slug) {
124
+ const row = await adapter.findOne({
125
+ model,
126
+ where: [{ field: 'slug', value: slug }],
127
+ });
128
+ return row ? toCompany(row) : null;
129
+ },
130
+ async list(filter, page) {
131
+ if (filter.ids?.length === 0)
132
+ return emptyPage();
133
+ return pageOf(adapter, model, filterWhere(filter), 'slug', (row) => row.slug, page, toCompany);
134
+ },
135
+ async create(input) {
136
+ const taken = () => new AxedaError('AXEDA_SLUG_TAKEN', `A company with slug "${input.slug}" already exists.`);
137
+ if (await adapter.findOne({ model, where: [{ field: 'slug', value: input.slug }] }))
138
+ throw taken();
139
+ const at = now();
140
+ try {
141
+ const row = await adapter.create({
142
+ model,
143
+ data: {
144
+ slug: input.slug,
145
+ name: input.name,
146
+ legalName: input.legalName ?? null,
147
+ logoUrl: input.logoUrl ?? null,
148
+ faviconUrl: input.faviconUrl ?? null,
149
+ status: 'active',
150
+ metadata: { ...(input.metadata ?? {}) },
151
+ version: 1,
152
+ deletedAt: null,
153
+ createdAt: at,
154
+ updatedAt: at,
155
+ },
156
+ });
157
+ return toCompany(row);
158
+ }
159
+ catch (error) {
160
+ if (isUniqueViolation(error))
161
+ throw taken();
162
+ throw error;
163
+ }
164
+ },
165
+ async update(id, expectedVersion, changes) {
166
+ const set = defined({
167
+ name: changes.name,
168
+ legalName: changes.legalName,
169
+ logoUrl: changes.logoUrl,
170
+ faviconUrl: changes.faviconUrl,
171
+ metadata: changes.metadata === undefined ? undefined : { ...changes.metadata },
172
+ });
173
+ return toCompany(await guarded(adapter, model, id, expectedVersion, set, now, missing(id)));
174
+ },
175
+ async setStatus(id, expectedVersion, status) {
176
+ return toCompany(await guarded(adapter, model, id, expectedVersion, { status }, now, missing(id)));
177
+ },
178
+ async setDeleted(id, expectedVersion, at) {
179
+ return toCompany(await guarded(adapter, model, id, expectedVersion, { deletedAt: at }, now, missing(id)));
180
+ },
181
+ async listDeletedBefore(before, limit) {
182
+ const rows = await adapter.findMany({
183
+ model,
184
+ where: [{ field: 'deletedAt', operator: 'lt', value: before }],
185
+ sortBy: { field: 'slug', direction: 'asc' },
186
+ limit,
187
+ });
188
+ return rows.map(toCompany);
189
+ },
190
+ purge: (ids) => ids.length === 0
191
+ ? Promise.resolve(0)
192
+ : atomic(async (trx) => {
193
+ const businesses = await trx.findMany({
194
+ model: MODELS.business,
195
+ where: [{ field: 'companyId', operator: 'in', value: [...ids] }],
196
+ limit: MAX_ROWS,
197
+ });
198
+ await dropScopes(trx, 'business', businesses.map((business) => business.id));
199
+ await dropScopes(trx, 'company', ids);
200
+ await trx.deleteMany({
201
+ model: MODELS.business,
202
+ where: [{ field: 'companyId', operator: 'in', value: [...ids] }],
203
+ });
204
+ return trx.deleteMany({
205
+ model,
206
+ where: [{ field: 'id', operator: 'in', value: [...ids] }],
207
+ });
208
+ }),
209
+ };
210
+ }
211
+ // ---------- negocios ----------
212
+ export function createBusinessStore(adapter, atomic, now) {
213
+ const model = MODELS.business;
214
+ const missing = (id) => () => new AxedaError('AXEDA_BUSINESS_NOT_FOUND', `Business "${id}" does not exist.`);
215
+ return {
216
+ async get(id) {
217
+ const row = await adapter.findOne({ model, where: byId(id) });
218
+ return row ? toBusiness(row) : null;
219
+ },
220
+ async getMany(ids) {
221
+ if (ids.length === 0)
222
+ return [];
223
+ const rows = await adapter.findMany({
224
+ model,
225
+ where: [{ field: 'id', operator: 'in', value: [...ids] }],
226
+ limit: ids.length,
227
+ });
228
+ return rows.map(toBusiness);
229
+ },
230
+ async getBySlug(companyId, slug) {
231
+ const row = await adapter.findOne({
232
+ model,
233
+ where: [
234
+ { field: 'companyId', value: companyId },
235
+ { field: 'slug', value: slug },
236
+ ],
237
+ });
238
+ return row ? toBusiness(row) : null;
239
+ },
240
+ async listForCompany(companyId, filter, page) {
241
+ if (filter.ids?.length === 0)
242
+ return emptyPage();
243
+ return pageOf(adapter, model, [{ field: 'companyId', value: companyId }, ...filterWhere(filter)], 'slug', (row) => row.slug, page, toBusiness);
244
+ },
245
+ async create(input) {
246
+ const taken = () => new AxedaError('AXEDA_SLUG_TAKEN', `A business with slug "${input.slug}" already exists in that company.`);
247
+ const noCompany = () => new AxedaError('AXEDA_COMPANY_NOT_FOUND', `Company "${input.companyId}" does not exist.`);
248
+ if (!(await adapter.findOne({ model: MODELS.company, where: byId(input.companyId) })))
249
+ throw noCompany();
250
+ const existing = await adapter.findOne({
251
+ model,
252
+ where: [
253
+ { field: 'companyId', value: input.companyId },
254
+ { field: 'slug', value: input.slug },
255
+ ],
256
+ });
257
+ if (existing)
258
+ throw taken();
259
+ const at = now();
260
+ try {
261
+ const row = await adapter.create({
262
+ model,
263
+ data: {
264
+ companyId: input.companyId,
265
+ slug: input.slug,
266
+ name: input.name,
267
+ logoUrl: input.logoUrl ?? null,
268
+ faviconUrl: input.faviconUrl ?? null,
269
+ status: 'active',
270
+ metadata: { ...(input.metadata ?? {}) },
271
+ version: 1,
272
+ deletedAt: null,
273
+ createdAt: at,
274
+ updatedAt: at,
275
+ },
276
+ });
277
+ return toBusiness(row);
278
+ }
279
+ catch (error) {
280
+ if (isUniqueViolation(error))
281
+ throw taken();
282
+ if (isForeignKeyViolation(error))
283
+ throw noCompany();
284
+ throw error;
285
+ }
286
+ },
287
+ async update(id, expectedVersion, changes) {
288
+ const set = defined({
289
+ name: changes.name,
290
+ logoUrl: changes.logoUrl,
291
+ faviconUrl: changes.faviconUrl,
292
+ metadata: changes.metadata === undefined ? undefined : { ...changes.metadata },
293
+ });
294
+ return toBusiness(await guarded(adapter, model, id, expectedVersion, set, now, missing(id)));
295
+ },
296
+ async setStatus(id, expectedVersion, status) {
297
+ return toBusiness(await guarded(adapter, model, id, expectedVersion, { status }, now, missing(id)));
298
+ },
299
+ async setDeleted(id, expectedVersion, at) {
300
+ return toBusiness(await guarded(adapter, model, id, expectedVersion, { deletedAt: at }, now, missing(id)));
301
+ },
302
+ async listDeletedBefore(before, limit) {
303
+ const rows = await adapter.findMany({
304
+ model,
305
+ where: [{ field: 'deletedAt', operator: 'lt', value: before }],
306
+ sortBy: { field: 'id', direction: 'asc' },
307
+ limit,
308
+ });
309
+ return rows.map(toBusiness);
310
+ },
311
+ purge: (ids) => ids.length === 0
312
+ ? Promise.resolve(0)
313
+ : atomic(async (trx) => {
314
+ await dropScopes(trx, 'business', ids);
315
+ return trx.deleteMany({
316
+ model,
317
+ where: [{ field: 'id', operator: 'in', value: [...ids] }],
318
+ });
319
+ }),
320
+ };
321
+ }
322
+ // ---------- invitaciones ----------
323
+ /** Texto estable de «correo + alcance»; se reduce a un hash corto para la clave única. */
324
+ async function pendingKey(email, scope) {
325
+ const data = new TextEncoder().encode(`${email}|${scopeKey(scope)}`);
326
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', data);
327
+ return `pending:${Buffer.from(digest).toString('hex')}`;
328
+ }
329
+ export function createInvitationStore(adapter, now) {
330
+ const model = MODELS.invitation;
331
+ return {
332
+ async get(id) {
333
+ const row = await adapter.findOne({ model, where: byId(id) });
334
+ return row ? toInvitation(row) : null;
335
+ },
336
+ async findPending(email, scope) {
337
+ const row = await adapter.findOne({
338
+ model,
339
+ where: [{ field: 'pendingKey', value: await pendingKey(email, scope) }],
340
+ });
341
+ return row ? toInvitation(row) : null;
342
+ },
343
+ async create(input) {
344
+ const exists = () => new AxedaError('AXEDA_INVITATION_EXISTS', 'There is already a pending invitation for that email and scope.');
345
+ const key = await pendingKey(input.email, input.scope);
346
+ if (await adapter.findOne({ model, where: [{ field: 'pendingKey', value: key }] }))
347
+ throw exists();
348
+ try {
349
+ const row = await adapter.create({
350
+ model,
351
+ data: {
352
+ email: input.email,
353
+ ...scopeColumns(input.scope),
354
+ roleIds: [...input.roleIds],
355
+ invitedBy: input.invitedBy,
356
+ secretHash: input.secretHash,
357
+ expiresAt: input.expiresAt,
358
+ acceptedAt: null,
359
+ revokedAt: null,
360
+ pendingKey: key,
361
+ consumed: 0,
362
+ createdAt: now(),
363
+ },
364
+ });
365
+ return toInvitation(row);
366
+ }
367
+ catch (error) {
368
+ if (isUniqueViolation(error))
369
+ throw exists();
370
+ throw error;
371
+ }
372
+ },
373
+ async close(id, outcome, at) {
374
+ // Solo una llamada consigue pasar `consumed` de 0 a 1; el resto recibe `null`.
375
+ const closed = await adapter.incrementOne({
376
+ model,
377
+ where: [
378
+ { field: 'id', value: id },
379
+ { field: 'consumed', value: 0 },
380
+ ],
381
+ increment: { consumed: 1 },
382
+ set: {
383
+ [outcome === 'accepted' ? 'acceptedAt' : 'revokedAt']: at,
384
+ pendingKey: `closed:${id}`,
385
+ },
386
+ });
387
+ return closed !== null;
388
+ },
389
+ async list(scope, options, page) {
390
+ const columns = scopeColumns(scope);
391
+ const where = [
392
+ { field: 'scopeType', value: columns.scopeType },
393
+ { field: 'scopeId', value: columns.scopeId },
394
+ ...(options.pendingOnly
395
+ ? [
396
+ { field: 'consumed', value: 0 },
397
+ { field: 'expiresAt', operator: 'gt', value: options.now },
398
+ ]
399
+ : []),
400
+ ];
401
+ const result = await pageOf(adapter, model, where, 'id', (row) => row.id, page, toInvitation);
402
+ return {
403
+ items: result.items.map(withoutSecret),
404
+ nextCursor: result.nextCursor,
405
+ };
406
+ },
407
+ };
408
+ }
@@ -0,0 +1,28 @@
1
+ import type { AuthContext } from '@better-auth/core';
2
+ import { type ManagedUser, type UserAdmin } from '@teamblind/axeda-core';
3
+ interface UserRow {
4
+ id: string;
5
+ email: string;
6
+ name: string;
7
+ emailVerified?: boolean | null;
8
+ twoFactorEnabled?: boolean | null;
9
+ banned?: boolean | null;
10
+ banReason?: string | null;
11
+ banExpires?: Date | string | null;
12
+ createdAt: Date | string;
13
+ }
14
+ /**
15
+ * El usuario como lo ve la administración. Nunca se copia el resto de la fila: así una columna
16
+ * nueva (o un secreto que una aplicación añada al usuario) no sale por la API (S17).
17
+ */
18
+ export declare function toManagedUser(row: UserRow, now: Date): ManagedUser;
19
+ export interface BetterAuthUserAdminOptions {
20
+ readonly now?: () => Date;
21
+ }
22
+ /**
23
+ * Administración de usuarios sobre Better Auth. Crea y modifica usuarios con su `internalAdapter`
24
+ * (así se ejecutan los ganchos de la aplicación) y **no maneja contraseñas**: la cuenta nace sin
25
+ * credencial y la persona la establece por el flujo normal.
26
+ */
27
+ export declare function createBetterAuthUserAdmin(context: Pick<AuthContext, 'adapter' | 'internalAdapter'>, options?: BetterAuthUserAdminOptions): UserAdmin;
28
+ export {};
@@ -0,0 +1,105 @@
1
+ import { AxedaError, isBanned, } from '@teamblind/axeda-core';
2
+ import { isUniqueViolation } from './errors.js';
3
+ import { byId, decodeCursor, encodeCursor } from './shared.js';
4
+ const asDate = (value) => value === null || value === undefined ? null : new Date(value);
5
+ /**
6
+ * El usuario como lo ve la administración. Nunca se copia el resto de la fila: así una columna
7
+ * nueva (o un secreto que una aplicación añada al usuario) no sale por la API (S17).
8
+ */
9
+ export function toManagedUser(row, now) {
10
+ const banExpires = asDate(row.banExpires);
11
+ const flag = row.banned === true;
12
+ return {
13
+ id: row.id,
14
+ email: row.email,
15
+ name: row.name,
16
+ emailVerified: row.emailVerified === true,
17
+ twoFactorEnabled: row.twoFactorEnabled === true,
18
+ // El bloqueo se calcula con la hora actual: uno con fecha de fin se levanta solo.
19
+ banned: isBanned({ banned: flag, banExpires }, now),
20
+ banReason: flag ? (row.banReason ?? null) : null,
21
+ banExpires: flag ? banExpires : null,
22
+ createdAt: asDate(row.createdAt) ?? new Date(0),
23
+ };
24
+ }
25
+ /**
26
+ * Administración de usuarios sobre Better Auth. Crea y modifica usuarios con su `internalAdapter`
27
+ * (así se ejecutan los ganchos de la aplicación) y **no maneja contraseñas**: la cuenta nace sin
28
+ * credencial y la persona la establece por el flujo normal.
29
+ */
30
+ export function createBetterAuthUserAdmin(context, options = {}) {
31
+ const now = options.now ?? (() => new Date());
32
+ const { adapter, internalAdapter } = context;
33
+ const need = async (userId) => {
34
+ const row = await adapter.findOne({ model: 'user', where: byId(userId) });
35
+ if (row === null) {
36
+ throw new AxedaError('AXEDA_USER_NOT_FOUND', `User "${userId}" does not exist.`);
37
+ }
38
+ return row;
39
+ };
40
+ return {
41
+ async get(userId) {
42
+ const row = await adapter.findOne({ model: 'user', where: byId(userId) });
43
+ return row === null ? null : toManagedUser(row, now());
44
+ },
45
+ async list(filter, page) {
46
+ const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
47
+ const rows = await adapter.findMany({
48
+ model: 'user',
49
+ where: [
50
+ ...(filter.emailPrefix === undefined
51
+ ? []
52
+ : [
53
+ {
54
+ field: 'email',
55
+ operator: 'starts_with',
56
+ value: filter.emailPrefix.toLowerCase(),
57
+ },
58
+ ]),
59
+ ...(after === null ? [] : [{ field: 'id', operator: 'gt', value: after }]),
60
+ ],
61
+ sortBy: { field: 'id', direction: 'asc' },
62
+ limit: page.limit + 1,
63
+ });
64
+ const at = now();
65
+ const kept = rows.slice(0, page.limit);
66
+ const last = kept.at(-1);
67
+ // El filtro de bloqueo se aplica con la hora actual (un bloqueo vencido ya no cuenta), por
68
+ // eso no puede ir en la consulta; una página puede salir con menos elementos que el límite.
69
+ const items = kept
70
+ .map((row) => toManagedUser(row, at))
71
+ .filter((user) => filter.banned === undefined || user.banned === filter.banned);
72
+ return { items, nextCursor: rows.length > page.limit && last ? encodeCursor(last.id) : null };
73
+ },
74
+ async create(input) {
75
+ const taken = () => new AxedaError('AXEDA_EMAIL_TAKEN', 'That email already has an account.');
76
+ if (await internalAdapter.findUserByEmail(input.email))
77
+ throw taken();
78
+ try {
79
+ // `method: 'admin'`: Better Auth lo marca como alta hecha por un administrador.
80
+ const created = await internalAdapter.createUser({ email: input.email, name: input.name, emailVerified: false }, { method: 'admin' });
81
+ return toManagedUser(await need(created.id), now());
82
+ }
83
+ catch (error) {
84
+ if (isUniqueViolation(error))
85
+ throw taken();
86
+ throw error;
87
+ }
88
+ },
89
+ async setBan(userId, ban) {
90
+ await need(userId);
91
+ await internalAdapter.updateUser(userId, {
92
+ banned: ban !== null,
93
+ banReason: ban?.reason ?? null,
94
+ banExpires: ban?.expiresAt ?? null,
95
+ });
96
+ return toManagedUser(await need(userId), now());
97
+ },
98
+ async revokeSessions(userId) {
99
+ await need(userId);
100
+ const open = (await internalAdapter.listSessions(userId)).length;
101
+ await internalAdapter.deleteUserSessions(userId);
102
+ return open;
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,10 @@
1
+ import type { BetterAuthOptions } from '@better-auth/core';
2
+ import type { DBAdapter } from '@better-auth/core/db/adapter';
3
+ import { type UserDirectory } from '@teamblind/axeda-core';
4
+ /**
5
+ * Directorio de usuarios sobre la tabla `user` de Better Auth. Axeda no crea ni modifica
6
+ * usuarios: solo los consulta.
7
+ */
8
+ export declare function createBetterAuthUserDirectory<Options extends BetterAuthOptions>(databaseAdapter: DBAdapter<Options>, options?: {
9
+ readonly now?: () => Date;
10
+ }): UserDirectory;
package/dist/users.js ADDED
@@ -0,0 +1,27 @@
1
+ import { isBanned } from '@teamblind/axeda-core';
2
+ const toDirectoryUser = (row, now) => ({
3
+ id: row.id,
4
+ email: row.email,
5
+ emailVerified: row.emailVerified === true,
6
+ // Ante la duda, sin 2FA: las funciones que lo exigen no se asignan ni conceden.
7
+ twoFactorEnabled: row.twoFactorEnabled === true,
8
+ // Un bloqueo con fecha de fin deja de valer al llegar esa fecha.
9
+ banned: isBanned({ banned: row.banned === true, banExpires: row.banExpires ? new Date(row.banExpires) : null }, now),
10
+ });
11
+ /**
12
+ * Directorio de usuarios sobre la tabla `user` de Better Auth. Axeda no crea ni modifica
13
+ * usuarios: solo los consulta.
14
+ */
15
+ export function createBetterAuthUserDirectory(databaseAdapter, options = {}) {
16
+ const now = options.now ?? (() => new Date());
17
+ const adapter = databaseAdapter;
18
+ const find = async (field, value) => {
19
+ const row = await adapter.findOne({ model: 'user', where: [{ field, value }] });
20
+ return row === null ? null : toDirectoryUser(row, now());
21
+ };
22
+ return {
23
+ find: (userId) => find('id', userId),
24
+ // Better Auth guarda los correos en minúsculas.
25
+ findByEmail: (email) => find('email', email.trim().toLowerCase()),
26
+ };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@teamblind/axeda-better-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Better Auth plugin for Axeda: schema, endpoints, client plugin and store implementations.",
5
+ "license": "Apache-2.0",
6
+ "author": "TeamBlind",
7
+ "homepage": "https://github.com/teamblindmx/axeda#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/teamblindmx/axeda/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/teamblindmx/axeda.git",
14
+ "directory": "packages/better-auth"
15
+ },
16
+ "keywords": [
17
+ "authorization",
18
+ "rbac",
19
+ "multi-tenant",
20
+ "better-auth",
21
+ "access-control"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./client": {
33
+ "types": "./dist/client.d.ts",
34
+ "default": "./dist/client.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "dependencies": {
52
+ "@teamblind/axeda-core": "^0.1.0-alpha.0",
53
+ "zod": "^4.6.7"
54
+ },
55
+ "peerDependencies": {
56
+ "@better-auth/core": "^1.7.10",
57
+ "better-auth": "^1.7.10"
58
+ },
59
+ "devDependencies": {
60
+ "@better-auth/core": "^1.7.10",
61
+ "@electric-sql/pglite": "^0.5.10",
62
+ "@types/better-sqlite3": "^9.6.0",
63
+ "@types/pg": "^8.23.1",
64
+ "better-auth": "^1.7.10",
65
+ "better-sqlite3": "^13.0.3",
66
+ "kysely": "^0.29.6",
67
+ "pg": "^8.23.1"
68
+ }
69
+ }