@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,232 @@
1
+ import { APIError, createAuthEndpoint, getIP, sessionMiddleware } from 'better-auth/api';
2
+ import { z } from 'zod';
3
+ import { AXEDA_PERMISSIONS, AxedaError, } from '@teamblind/axeda-core';
4
+ import { toAssignmentDto, toAuditEventDto, toRoleDto } from './dto.js';
5
+ import { toApiError } from './http-errors.js';
6
+ import { currentUserRow, principalFromUser } from './principal.js';
7
+ export const access = (requires, scope) => ({
8
+ axeda: { requires, scope },
9
+ });
10
+ const MAX_PAGE = 100;
11
+ const DEFAULT_PAGE = 50;
12
+ export const id = z.string().min(1).max(255);
13
+ export const cursor = z.string().min(1).max(512);
14
+ export const limit = z.coerce.number().int().min(1).max(MAX_PAGE);
15
+ export const scopeBody = z.union([
16
+ z.strictObject({ type: z.literal('global') }),
17
+ z.strictObject({ type: z.enum(['company', 'business']), id }),
18
+ ]);
19
+ export const scopeQuery = {
20
+ scopeType: z.enum(['global', 'company', 'business']).optional(),
21
+ scopeId: id.optional(),
22
+ };
23
+ export function scopeFromQuery(query) {
24
+ if (query.scopeType === undefined)
25
+ return undefined;
26
+ if (query.scopeType === 'global')
27
+ return { type: 'global' };
28
+ // Un alcance sin identificador queda fuera de la lista blanca y el núcleo lo rechaza.
29
+ return { type: query.scopeType, id: query.scopeId ?? '' };
30
+ }
31
+ export const pageOf = (query) => ({
32
+ limit: query.limit ?? DEFAULT_PAGE,
33
+ ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
34
+ });
35
+ /**
36
+ * Quien actúa, a partir de la sesión: el `Principal` (ver `principalFromUser`) más los datos de la
37
+ * petición para la auditoría.
38
+ */
39
+ export async function actorFromSession(ctx) {
40
+ const user = await currentUserRow(ctx.context.session.user, ctx.context.options, () => ctx.context.internalAdapter);
41
+ if (user === null) {
42
+ throw new APIError('UNAUTHORIZED', { code: 'AXEDA_UNAUTHENTICATED', message: 'Sign in.' });
43
+ }
44
+ const request = ctx.request;
45
+ const ip = request === undefined ? undefined : (getIP(request, {}) ?? undefined);
46
+ return {
47
+ ...principalFromUser(user),
48
+ ip,
49
+ userAgent: ctx.headers?.get('user-agent') ?? request?.headers.get('user-agent') ?? undefined,
50
+ };
51
+ }
52
+ /** Ejecuta un caso de uso con el actor de la sesión y traduce sus errores a respuestas HTTP. */
53
+ export async function run(ctx, core, work) {
54
+ try {
55
+ return await work(core(), await actorFromSession(ctx));
56
+ }
57
+ catch (error) {
58
+ throw toApiError(error);
59
+ }
60
+ }
61
+ /**
62
+ * Los endpoints del plugin. Todos exigen sesión (`sessionMiddleware`) y declaran su permiso en
63
+ * `metadata.axeda`; el permiso lo impone el caso de uso, no el endpoint. Los endpoints de Better
64
+ * Auth solo admiten GET y POST.
65
+ */
66
+ export function createEndpoints(core) {
67
+ return {
68
+ axedaMe: createAuthEndpoint('/axeda/me', { method: 'GET', use: [sessionMiddleware], metadata: access('(authenticated)', 'own') }, (ctx) => run(ctx, core, async (axeda, actor) => {
69
+ const [permissions, assignments] = await Promise.all([
70
+ axeda.effectivePermissions(actor),
71
+ axeda.assignments.listForUser(actor, actor.userId),
72
+ ]);
73
+ return ctx.json({
74
+ userId: actor.userId,
75
+ twoFactorEnabled: actor.twoFactorEnabled === true,
76
+ globalPermissions: [...permissions.keys],
77
+ assignments: assignments.map(toAssignmentDto),
78
+ });
79
+ })),
80
+ axedaPermissionList: createAuthEndpoint('/axeda/permission/list', {
81
+ method: 'GET',
82
+ use: [sessionMiddleware],
83
+ metadata: access('axeda:role:manage | axeda:assignment:manage', 'any'),
84
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
85
+ const allowed = (await axeda.canAnywhere(actor, AXEDA_PERMISSIONS.roleManage)) ||
86
+ (await axeda.canAnywhere(actor, AXEDA_PERMISSIONS.assignmentManage));
87
+ if (!allowed) {
88
+ throw new AxedaError('AXEDA_FORBIDDEN', 'You are not allowed to read the permission catalog.');
89
+ }
90
+ return ctx.json({ permissions: [...axeda.catalog] });
91
+ })),
92
+ axedaRoleList: createAuthEndpoint('/axeda/role/list', {
93
+ method: 'GET',
94
+ use: [sessionMiddleware],
95
+ query: z.strictObject({ cursor: cursor.optional(), limit: limit.optional() }),
96
+ metadata: access('axeda:role:manage | axeda:assignment:manage', 'any'),
97
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
98
+ const page = await axeda.roles.list(actor, pageOf(ctx.query));
99
+ return ctx.json({ items: page.items.map(toRoleDto), nextCursor: page.nextCursor });
100
+ })),
101
+ axedaRoleGet: createAuthEndpoint('/axeda/role/get', {
102
+ method: 'GET',
103
+ use: [sessionMiddleware],
104
+ query: z.strictObject({ id }),
105
+ metadata: access('axeda:role:manage | axeda:assignment:manage', 'any'),
106
+ }, (ctx) => run(ctx, core, async (axeda, actor) => ctx.json(toRoleDto(await axeda.roles.get(actor, ctx.query.id))))),
107
+ axedaRoleCreate: createAuthEndpoint('/axeda/role/create', {
108
+ method: 'POST',
109
+ use: [sessionMiddleware],
110
+ body: z.strictObject({
111
+ key: z.string().min(1).max(64),
112
+ name: z.string().min(1).max(200),
113
+ description: z.string().max(1000).nullish(),
114
+ permissions: z.array(z.string().max(200)).max(1000),
115
+ requireMfa: z.boolean().optional(),
116
+ }),
117
+ metadata: access('axeda:role:manage', 'global'),
118
+ }, (ctx) => run(ctx, core, async (axeda, actor) => ctx.json(toRoleDto(await axeda.roles.create(actor, ctx.body))))),
119
+ axedaRoleUpdate: createAuthEndpoint('/axeda/role/update', {
120
+ method: 'POST',
121
+ use: [sessionMiddleware],
122
+ body: z.strictObject({
123
+ id,
124
+ version: z.number().int().min(1),
125
+ name: z.string().min(1).max(200).optional(),
126
+ description: z.string().max(1000).nullish(),
127
+ permissions: z.array(z.string().max(200)).max(1000).optional(),
128
+ requireMfa: z.boolean().optional(),
129
+ }),
130
+ metadata: access('axeda:role:manage', 'global'),
131
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
132
+ const { id: roleId, version, ...changes } = ctx.body;
133
+ return ctx.json(toRoleDto(await axeda.roles.update(actor, roleId, version, changes)));
134
+ })),
135
+ axedaRoleSetStatus: createAuthEndpoint('/axeda/role/set-status', {
136
+ method: 'POST',
137
+ use: [sessionMiddleware],
138
+ body: z.strictObject({
139
+ id,
140
+ version: z.number().int().min(1),
141
+ status: z.enum(['active', 'inactive']),
142
+ }),
143
+ metadata: access('axeda:role:manage', 'global'),
144
+ }, (ctx) => run(ctx, core, async (axeda, actor) => ctx.json(toRoleDto(await axeda.roles.setStatus(actor, ctx.body.id, ctx.body.version, ctx.body.status))))),
145
+ axedaRoleDelete: createAuthEndpoint('/axeda/role/delete', {
146
+ method: 'POST',
147
+ use: [sessionMiddleware],
148
+ body: z.strictObject({ id }),
149
+ metadata: access('axeda:role:manage', 'global'),
150
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
151
+ await axeda.roles.delete(actor, ctx.body.id);
152
+ return ctx.json({ deleted: true });
153
+ })),
154
+ axedaAssignmentList: createAuthEndpoint('/axeda/assignment/list', {
155
+ method: 'GET',
156
+ use: [sessionMiddleware],
157
+ query: z.strictObject({
158
+ userId: id.optional(),
159
+ ...scopeQuery,
160
+ cursor: cursor.optional(),
161
+ limit: limit.optional(),
162
+ }),
163
+ metadata: access('axeda:assignment:manage', 'per-target'),
164
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
165
+ const { userId } = ctx.query;
166
+ if (userId !== undefined) {
167
+ const items = await axeda.assignments.listForUser(actor, userId);
168
+ return ctx.json({ items: items.map(toAssignmentDto), nextCursor: null });
169
+ }
170
+ const scope = scopeFromQuery(ctx.query);
171
+ if (scope === undefined) {
172
+ throw new AxedaError('AXEDA_INVALID_INPUT', 'Pass userId, or scopeType (and scopeId).');
173
+ }
174
+ const page = await axeda.assignments.listForScope(actor, scope, pageOf(ctx.query));
175
+ return ctx.json({ items: page.items.map(toAssignmentDto), nextCursor: page.nextCursor });
176
+ })),
177
+ axedaAssignmentCreate: createAuthEndpoint('/axeda/assignment/create', {
178
+ method: 'POST',
179
+ use: [sessionMiddleware],
180
+ body: z.strictObject({
181
+ userId: id,
182
+ roleId: id,
183
+ scope: scopeBody,
184
+ expiresAt: z.iso.datetime().nullish(),
185
+ }),
186
+ metadata: access('axeda:assignment:manage', 'scope-parameter'),
187
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
188
+ const { expiresAt, ...rest } = ctx.body;
189
+ const created = await axeda.assignments.assign(actor, {
190
+ ...rest,
191
+ expiresAt: expiresAt === null || expiresAt === undefined ? null : new Date(expiresAt),
192
+ });
193
+ return ctx.json(toAssignmentDto(created));
194
+ })),
195
+ axedaAssignmentRevoke: createAuthEndpoint('/axeda/assignment/revoke', {
196
+ method: 'POST',
197
+ use: [sessionMiddleware],
198
+ body: z.strictObject({ id }),
199
+ metadata: access('axeda:assignment:manage', 'per-target'),
200
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
201
+ await axeda.assignments.revoke(actor, ctx.body.id);
202
+ return ctx.json({ revoked: true });
203
+ })),
204
+ axedaAuditList: createAuthEndpoint('/axeda/audit/list', {
205
+ method: 'GET',
206
+ use: [sessionMiddleware],
207
+ query: z.strictObject({
208
+ actorId: id.optional(),
209
+ action: z.string().max(64).optional(),
210
+ targetType: z.string().max(64).optional(),
211
+ targetId: id.optional(),
212
+ ...scopeQuery,
213
+ cursor: cursor.optional(),
214
+ limit: limit.optional(),
215
+ }),
216
+ metadata: access('axeda:audit:read', 'scope-parameter'),
217
+ }, (ctx) => run(ctx, core, async (axeda, actor) => {
218
+ const { action, targetType, scopeType, scopeId, cursor: after, limit: size, ...rest } = ctx.query;
219
+ const scope = scopeFromQuery({ scopeType, scopeId });
220
+ const filter = {
221
+ ...rest,
222
+ ...(action === undefined ? {} : { action: action }),
223
+ ...(targetType === undefined
224
+ ? {}
225
+ : { targetType: targetType }),
226
+ ...(scope === undefined ? {} : { scope }),
227
+ };
228
+ const page = await axeda.listAudit(actor, filter, pageOf({ cursor: after, limit: size }));
229
+ return ctx.json({ items: page.items.map(toAuditEventDto), nextCursor: page.nextCursor });
230
+ })),
231
+ };
232
+ }
@@ -0,0 +1,2 @@
1
+ export declare const isUniqueViolation: (error: unknown) => boolean;
2
+ export declare const isForeignKeyViolation: (error: unknown) => boolean;
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ /** Códigos de error que los controladores de base de datos usan para una violación de restricción. */
2
+ const UNIQUE_CODES = new Set([
3
+ '23505', // PostgreSQL
4
+ 'ER_DUP_ENTRY', // MySQL / MariaDB
5
+ 'SQLITE_CONSTRAINT_UNIQUE',
6
+ 'SQLITE_CONSTRAINT_PRIMARYKEY',
7
+ '11000', // MongoDB
8
+ ]);
9
+ const FOREIGN_KEY_CODES = new Set([
10
+ '23503', // PostgreSQL
11
+ 'ER_NO_REFERENCED_ROW_2', // MySQL / MariaDB
12
+ 'ER_NO_REFERENCED_ROW',
13
+ 'SQLITE_CONSTRAINT_FOREIGNKEY',
14
+ ]);
15
+ /** Recorre `error` y sus causas (Better Auth y Kysely envuelven los errores del controlador). */
16
+ function codesOf(error) {
17
+ const codes = [];
18
+ const seen = new Set();
19
+ let current = error;
20
+ while (typeof current === 'object' && current !== null && !seen.has(current)) {
21
+ seen.add(current);
22
+ const { code, errno } = current;
23
+ if (typeof code === 'string' || typeof code === 'number')
24
+ codes.push(String(code));
25
+ if (typeof errno === 'number')
26
+ codes.push(String(errno));
27
+ current = current.cause;
28
+ }
29
+ return codes;
30
+ }
31
+ export const isUniqueViolation = (error) => codesOf(error).some((code) => UNIQUE_CODES.has(code));
32
+ export const isForeignKeyViolation = (error) => codesOf(error).some((code) => FOREIGN_KEY_CODES.has(code));
@@ -0,0 +1,12 @@
1
+ import { APIError } from 'better-auth/api';
2
+ import { type AxedaErrorCode } from '@teamblind/axeda-core';
3
+ /** Los códigos que la API traduce a un estado concreto (para las pruebas). */
4
+ export declare const MAPPED_CODES: AxedaErrorCode[];
5
+ /**
6
+ * Convierte un error de Axeda en una respuesta de Better Auth con el código estable `AXEDA_*`.
7
+ *
8
+ * Solo los errores que se espera ver (validación, permisos, conflictos) llevan su mensaje al
9
+ * cliente. Cualquier otro (base de datos caída, datos corruptos, un fallo inesperado) devuelve
10
+ * un 500 genérico: el detalle puede revelar el esquema o la infraestructura (S17).
11
+ */
12
+ export declare function toApiError(error: unknown): APIError;
@@ -0,0 +1,58 @@
1
+ import { APIError } from 'better-auth/api';
2
+ import { isAxedaError } from '@teamblind/axeda-core';
3
+ /**
4
+ * Estado HTTP de CADA código estable. El mapa es exhaustivo a propósito: al añadir un código en el
5
+ * núcleo, el compilador obliga a decidir aquí qué responde la API (antes, un código olvidado
6
+ * acababa en un 500 sin que nadie lo notara). `'internal'` significa «error del servidor, sin
7
+ * detalles»: son fallos de configuración o de datos que el cliente no puede arreglar.
8
+ */
9
+ const STATUS = {
10
+ AXEDA_INVALID_INPUT: 'BAD_REQUEST',
11
+ AXEDA_INVALID_PERMISSION: 'BAD_REQUEST',
12
+ AXEDA_UNKNOWN_PERMISSION: 'BAD_REQUEST',
13
+ AXEDA_INVITATION_INVALID: 'BAD_REQUEST',
14
+ AXEDA_FORBIDDEN: 'FORBIDDEN',
15
+ AXEDA_MFA_REQUIRED: 'FORBIDDEN',
16
+ AXEDA_ROLE_NOT_FOUND: 'NOT_FOUND',
17
+ AXEDA_ASSIGNMENT_NOT_FOUND: 'NOT_FOUND',
18
+ AXEDA_USER_NOT_FOUND: 'NOT_FOUND',
19
+ AXEDA_EMAIL_TAKEN: 'CONFLICT',
20
+ AXEDA_COMPANY_NOT_FOUND: 'NOT_FOUND',
21
+ AXEDA_BUSINESS_NOT_FOUND: 'NOT_FOUND',
22
+ AXEDA_ROLE_KEY_TAKEN: 'CONFLICT',
23
+ AXEDA_ROLE_MANAGED: 'CONFLICT',
24
+ AXEDA_VERSION_CONFLICT: 'CONFLICT',
25
+ AXEDA_ASSIGNMENT_EXISTS: 'CONFLICT',
26
+ AXEDA_LIMIT_EXCEEDED: 'CONFLICT',
27
+ AXEDA_ALREADY_BOOTSTRAPPED: 'CONFLICT',
28
+ AXEDA_SLUG_TAKEN: 'CONFLICT',
29
+ AXEDA_INVITATION_EXISTS: 'CONFLICT',
30
+ AXEDA_FEATURE_DISABLED: 'NOT_IMPLEMENTED',
31
+ AXEDA_DELIVERY_FAILED: 'BAD_GATEWAY',
32
+ AXEDA_INVALID_CONFIG: 'internal',
33
+ AXEDA_TRANSACTIONS_UNSUPPORTED: 'internal',
34
+ AXEDA_DATA_CORRUPT: 'internal',
35
+ };
36
+ /** Los códigos que la API traduce a un estado concreto (para las pruebas). */
37
+ export const MAPPED_CODES = Object.keys(STATUS);
38
+ /**
39
+ * Convierte un error de Axeda en una respuesta de Better Auth con el código estable `AXEDA_*`.
40
+ *
41
+ * Solo los errores que se espera ver (validación, permisos, conflictos) llevan su mensaje al
42
+ * cliente. Cualquier otro (base de datos caída, datos corruptos, un fallo inesperado) devuelve
43
+ * un 500 genérico: el detalle puede revelar el esquema o la infraestructura (S17).
44
+ */
45
+ export function toApiError(error) {
46
+ if (error instanceof APIError)
47
+ return error;
48
+ if (isAxedaError(error)) {
49
+ const status = STATUS[error.code];
50
+ if (status !== 'internal') {
51
+ return new APIError(status, { code: error.code, message: error.message });
52
+ }
53
+ }
54
+ return new APIError('INTERNAL_SERVER_ERROR', {
55
+ code: 'AXEDA_INTERNAL_ERROR',
56
+ message: 'Internal error.',
57
+ });
58
+ }
@@ -0,0 +1,11 @@
1
+ export { axeda, type AxedaPlugin, type AxedaPluginOptions, type InvitationDeliveryOptions, } from './plugin.js';
2
+ export type { AssignmentDto, AuditEventDto, BusinessDto, CompanyDto, InvitationDto, ManagedUserDto, RoleDto, } from './dto.js';
3
+ export type { EndpointAccess } from './endpoints.js';
4
+ export { MODELS, axedaSchema } from './schema.js';
5
+ export { MAPPED_CODES } from './http-errors.js';
6
+ export { createBetterAuthPersistence, type BetterAuthPersistenceOptions } from './stores.js';
7
+ export { createBetterAuthUserDirectory } from './users.js';
8
+ export { createBetterAuthUserAdmin } from './user-admin.js';
9
+ export { actorFromSession as principalFromSession } from './endpoints.js';
10
+ export { principalFromUser, sessionPrincipal, type SessionReader } from './principal.js';
11
+ export { secureAuthOptions, type SecureAuthInput, type SecureAuthOptions } from './preset.js';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @teamblind/axeda-better-auth — plugin de Better Auth y persistencia de Axeda sobre su adaptador.
2
+ export { axeda, } from './plugin.js';
3
+ export { MODELS, axedaSchema } from './schema.js';
4
+ export { MAPPED_CODES } from './http-errors.js';
5
+ export { createBetterAuthPersistence } from './stores.js';
6
+ export { createBetterAuthUserDirectory } from './users.js';
7
+ export { createBetterAuthUserAdmin } from './user-admin.js';
8
+ export { actorFromSession as principalFromSession } from './endpoints.js';
9
+ export { principalFromUser, sessionPrincipal } from './principal.js';
10
+ export { secureAuthOptions } from './preset.js';