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