@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/dist/plugin.js ADDED
@@ -0,0 +1,188 @@
1
+ import { AxedaError, createAxeda, isBanned, } from '@teamblind/axeda-core';
2
+ import { APIError, createAuthMiddleware } from 'better-auth/api';
3
+ import { createEndpoints } from './endpoints.js';
4
+ import { createUserEndpoints } from './endpoints-users.js';
5
+ import { createInvitationEndpoints, createPublicEndpoints, createTenantEndpoints, } from './endpoints-tenants.js';
6
+ import { axedaSchema } from './schema.js';
7
+ import { createBetterAuthPersistence } from './stores.js';
8
+ import { createBetterAuthUserAdmin } from './user-admin.js';
9
+ import { createBetterAuthUserDirectory } from './users.js';
10
+ /** Las opciones del núcleo: todo menos lo que es propio del plugin (reloj, envío, identidad pública). */
11
+ function coreOptions(options) {
12
+ return {
13
+ permissions: options.permissions,
14
+ ...(options.roles === undefined ? {} : { roles: options.roles }),
15
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
16
+ ...(options.retention === undefined ? {} : { retention: options.retention }),
17
+ ...(options.invitations === undefined ? {} : { invitations: options.invitations }),
18
+ ...(options.menus === undefined ? {} : { menus: options.menus }),
19
+ };
20
+ }
21
+ /**
22
+ * Opciones de Better Auth con las que Axeda se niega a arrancar. Cada una anula algo que protege
23
+ * a los endpoints `/axeda/*` (todos con cookie de sesión) o a la propia autorización.
24
+ */
25
+ const UNSAFE_AUTH_OPTIONS = [
26
+ {
27
+ // La caché de cookie de sesión haría que `authzVersion` llegue desactualizado y que una
28
+ // sesión revocada siga siendo válida.
29
+ path: ['session', 'cookieCache', 'enabled'],
30
+ why: 'a revoked session or a changed permission would stay valid until the cookie expires.',
31
+ },
32
+ {
33
+ path: ['advanced', 'disableCSRFCheck'],
34
+ why: 'every /axeda/* endpoint is authenticated by a cookie and would be open to cross-site requests.',
35
+ },
36
+ {
37
+ path: ['advanced', 'disableOriginCheck'],
38
+ why: 'callback and redirect URLs would no longer be validated against trustedOrigins.',
39
+ },
40
+ {
41
+ path: ['advanced', 'ipAddress', 'disableIpTracking'],
42
+ why: 'rate limits and the audit log depend on the client address.',
43
+ },
44
+ ];
45
+ function assertSafeAuthOptions(options) {
46
+ for (const { path, why } of UNSAFE_AUTH_OPTIONS) {
47
+ const value = path.reduce((node, key) => typeof node === 'object' && node !== null
48
+ ? node[key]
49
+ : undefined, options);
50
+ if (value === true) {
51
+ throw new AxedaError('AXEDA_INVALID_CONFIG', `Axeda cannot run with ${path.join('.')}: ${why} Remove the option.`);
52
+ }
53
+ }
54
+ }
55
+ /** Límites de peticiones por IP y minuto de los endpoints de administración. */
56
+ const RATE_LIMITS = [
57
+ // El único endpoint sin sesión, más estricto (T10: enumeración).
58
+ { pathMatcher: (path) => path.startsWith('/axeda/public/'), max: 30, window: 60 },
59
+ { pathMatcher: (path) => path.startsWith('/axeda/'), max: 120, window: 60 },
60
+ ];
61
+ /**
62
+ * Plugin de Axeda para Better Auth: esquema, endpoints `/axeda/*`, el hook de borrado de usuario
63
+ * y la conexión de la persistencia de Axeda al adaptador de base de datos de Better Auth.
64
+ *
65
+ * ```ts
66
+ * const axedaPlugin = axeda({ permissions: ['invoice:read'], roles: { viewer: ['invoice:read'] } });
67
+ * const auth = betterAuth({ plugins: [axedaPlugin] });
68
+ * await axedaPlugin.start(); // tras migrar la base de datos
69
+ * ```
70
+ */
71
+ export function axeda(options) {
72
+ let core;
73
+ let persistence;
74
+ const instance = () => {
75
+ if (core === undefined) {
76
+ throw new AxedaError('AXEDA_INVALID_CONFIG', 'Axeda is not initialised yet: create the Better Auth instance with this plugin first.');
77
+ }
78
+ return core;
79
+ };
80
+ const plugin = {
81
+ id: 'axeda',
82
+ schema: axedaSchema,
83
+ endpoints: {
84
+ ...createEndpoints(instance),
85
+ ...createTenantEndpoints(instance),
86
+ ...createInvitationEndpoints(instance),
87
+ ...createUserEndpoints(instance),
88
+ // Siempre registrado (los tipos del cliente no dependen de la configuración); si está
89
+ // desactivado responde 404, igual que una compañía que no existe.
90
+ ...createPublicEndpoints(instance, () => options.publicIdentity === true),
91
+ },
92
+ rateLimit: RATE_LIMITS,
93
+ hooks: {
94
+ after: [
95
+ {
96
+ // Todo lo que contesta Axeda (permisos, usuarios, auditoría, invitaciones) es de una
97
+ // persona concreta: ni el navegador ni una caché intermedia deben guardarlo (ASVS V14.3.2).
98
+ // También las respuestas de error.
99
+ matcher: (context) => context.path?.startsWith('/axeda/') === true,
100
+ handler: createAuthMiddleware(async (ctx) => {
101
+ ctx.setHeader('Cache-Control', 'no-store');
102
+ }),
103
+ },
104
+ ],
105
+ },
106
+ init(ctx) {
107
+ assertSafeAuthOptions(ctx.options);
108
+ const stores = createBetterAuthPersistence(ctx.adapter, {
109
+ ...(options.clock === undefined ? {} : { now: () => options.clock?.now() ?? new Date() }),
110
+ });
111
+ persistence = stores;
112
+ const { clock, invitationDelivery } = options;
113
+ const clockOption = clock === undefined ? {} : { now: () => clock.now() };
114
+ core = createAxeda(coreOptions(options), {
115
+ persistence: stores,
116
+ users: createBetterAuthUserDirectory(ctx.adapter, clockOption),
117
+ userAdmin: createBetterAuthUserAdmin(ctx, clockOption),
118
+ ...(clock === undefined ? {} : { clock }),
119
+ ...(invitationDelivery === undefined
120
+ ? {}
121
+ : {
122
+ invitations: {
123
+ send: (message) => invitationDelivery.send({
124
+ ...message,
125
+ link: invitationDelivery.acceptUrl(message.token),
126
+ }),
127
+ },
128
+ }),
129
+ });
130
+ return {
131
+ options: {
132
+ databaseHooks: {
133
+ session: {
134
+ create: {
135
+ // Un usuario bloqueado no puede abrir sesión (las que tenía se cerraron al bloquearlo).
136
+ // Se consulta con `internalAdapter`, que usa la transacción en curso: Better Auth abre
137
+ // sesión DENTRO de la transacción del registro, y con el adaptador raíz la consulta
138
+ // esperaría para siempre a una conexión que la propia transacción tiene tomada.
139
+ before: async (session) => {
140
+ const row = (await ctx.internalAdapter.findUserById(session.userId));
141
+ const now = clock?.now() ?? new Date();
142
+ const banExpires = row?.banExpires ? new Date(row.banExpires) : null;
143
+ if (row !== null && isBanned({ banned: row.banned === true, banExpires }, now)) {
144
+ throw new APIError('FORBIDDEN', {
145
+ code: 'AXEDA_USER_BANNED',
146
+ message: 'This account is suspended.',
147
+ });
148
+ }
149
+ },
150
+ },
151
+ },
152
+ user: {
153
+ delete: {
154
+ // Las claves foráneas ya lo hacen en SQL; MongoDB no las tiene. Se deja constancia.
155
+ after: async (user) => {
156
+ await stores.assignments.deleteForUser(user.id);
157
+ await stores.audit.append({
158
+ actorId: null,
159
+ action: 'user.delete',
160
+ targetType: 'user',
161
+ targetId: user.id,
162
+ scope: null,
163
+ outcome: 'success',
164
+ ip: null,
165
+ userAgent: null,
166
+ metadata: {},
167
+ });
168
+ },
169
+ },
170
+ },
171
+ },
172
+ },
173
+ };
174
+ },
175
+ };
176
+ return Object.assign(plugin, {
177
+ /** El núcleo de Axeda (`can`, `scopesFor`, `bootstrap`…). Disponible tras crear Better Auth. */
178
+ instance,
179
+ /** Comprueba la base de datos y sincroniza las funciones declaradas. Llamar tras migrar. */
180
+ start: () => instance().start(),
181
+ /** La persistencia, para herramientas de la propia aplicación. */
182
+ persistence: () => {
183
+ if (persistence === undefined)
184
+ throw new AxedaError('AXEDA_INVALID_CONFIG', 'Not initialised.');
185
+ return persistence;
186
+ },
187
+ });
188
+ }
@@ -0,0 +1,63 @@
1
+ import type { BetterAuthOptions } from '@better-auth/core';
2
+ export interface SecureAuthInput {
3
+ /** URL pública del servidor de autenticación. En producción, `https`. */
4
+ readonly baseURL: string;
5
+ /** Secreto de Better Auth: al menos 32 caracteres, aleatorio (p. ej. `openssl rand -base64 48`). */
6
+ readonly secret: string;
7
+ /**
8
+ * Orígenes (`https://app.example.com`) desde los que se permiten peticiones con cookie y a los
9
+ * que se puede redirigir. Sin comodines ni rutas. El `baseURL` ya cuenta como de confianza.
10
+ */
11
+ readonly trustedOrigins: readonly string[];
12
+ /** `production` (por defecto) exige `https` y cookies `Secure`. `development` admite `localhost`. */
13
+ readonly environment?: 'production' | 'development';
14
+ /**
15
+ * IPs o rangos CIDR de los proxies que tienes delante. Sin ellos solo se confía en una
16
+ * cabecera `X-Forwarded-For` de un único valor: una cadena la puede falsificar el cliente y
17
+ * evadir los límites de peticiones.
18
+ */
19
+ readonly trustedProxies?: readonly string[];
20
+ /**
21
+ * Dónde llevar la cuenta de los límites. `memory` (por defecto) es por proceso: con varias
22
+ * instancias, el límite efectivo se multiplica. Con varias instancias usa `database`.
23
+ */
24
+ readonly rateLimitStorage?: 'memory' | 'database' | 'secondary-storage';
25
+ /**
26
+ * Exige correo verificado para iniciar sesión (por defecto sí). Las invitaciones de Axeda solo
27
+ * las acepta quien tiene el correo verificado; desactivarlo solo tiene sentido en desarrollo.
28
+ * Con esto activo tu aplicación debe configurar `emailVerification.sendVerificationEmail`.
29
+ */
30
+ readonly requireEmailVerification?: boolean;
31
+ }
32
+ /** La parte de la configuración de Better Auth que fija el preset. */
33
+ export interface SecureAuthOptions {
34
+ readonly baseURL: string;
35
+ readonly secret: string;
36
+ readonly trustedOrigins: string[];
37
+ readonly emailAndPassword: NonNullable<BetterAuthOptions['emailAndPassword']>;
38
+ readonly session: NonNullable<BetterAuthOptions['session']>;
39
+ readonly advanced: NonNullable<BetterAuthOptions['advanced']>;
40
+ readonly rateLimit: NonNullable<BetterAuthOptions['rateLimit']>;
41
+ }
42
+ /**
43
+ * La configuración de Better Auth que Axeda recomienda, lista para extender:
44
+ *
45
+ * ```ts
46
+ * betterAuth({
47
+ * ...secureAuthOptions({ baseURL, secret, trustedOrigins: ['https://app.example.com'] }),
48
+ * database,
49
+ * plugins: [axedaPlugin],
50
+ * });
51
+ * ```
52
+ *
53
+ * Fija orígenes de confianza explícitos, cookies `HttpOnly`/`SameSite=Lax`/`Secure`, sesiones de
54
+ * 7 días, contraseñas de 12 a 128 caracteres, correo verificado, sesiones cerradas al restablecer
55
+ * la contraseña y límites de peticiones más estrictos en el inicio de sesión y el registro.
56
+ * **Se niega a devolver** una configuración débil: un secreto corto o repetitivo, orígenes con
57
+ * comodín o sin `https` en producción.
58
+ *
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» en las
61
+ * cuentas administrativas (ver la guía de seguridad).
62
+ */
63
+ export declare function secureAuthOptions(input: SecureAuthInput): SecureAuthOptions;
package/dist/preset.js ADDED
@@ -0,0 +1,130 @@
1
+ import { AxedaError } from '@teamblind/axeda-core';
2
+ const MIN_SECRET_LENGTH = 32;
3
+ const MIN_DISTINCT_CHARACTERS = 8;
4
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
5
+ const DAY = 60 * 60 * 24;
6
+ function invalid(message) {
7
+ throw new AxedaError('AXEDA_INVALID_CONFIG', message);
8
+ }
9
+ function parseUrl(raw, label) {
10
+ try {
11
+ return new URL(raw);
12
+ }
13
+ catch {
14
+ return invalid(`${label} "${raw}" is not a valid URL.`);
15
+ }
16
+ }
17
+ /** Un origen es solo esquema, servidor y puerto: sin ruta, consulta, fragmento ni credenciales. */
18
+ function assertPlainOrigin(url, raw, label) {
19
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
20
+ invalid(`${label} "${raw}" must use http or https.`);
21
+ }
22
+ const hasPath = url.pathname !== '/' || url.search !== '' || url.hash !== '';
23
+ if (hasPath || url.username !== '' || url.password !== '') {
24
+ invalid(`${label} "${raw}" must be just an origin (scheme, host and optional port).`);
25
+ }
26
+ }
27
+ function assertTransport(url, raw, label, production) {
28
+ if (url.protocol === 'https:')
29
+ return;
30
+ if (production || !LOCAL_HOSTS.has(url.hostname)) {
31
+ invalid(`${label} "${raw}" must use https${production ? '' : ' (http is only for localhost)'}.`);
32
+ }
33
+ }
34
+ function parseOrigin(raw, production, label) {
35
+ if (raw.includes('*'))
36
+ invalid(`${label} "${raw}" has a wildcard; list each origin explicitly.`);
37
+ const url = parseUrl(raw, label);
38
+ assertPlainOrigin(url, raw, label);
39
+ assertTransport(url, raw, label, production);
40
+ return url.origin;
41
+ }
42
+ function assertSecret(secret) {
43
+ if (secret.length < MIN_SECRET_LENGTH) {
44
+ invalid(`The secret must have at least ${MIN_SECRET_LENGTH} characters.`);
45
+ }
46
+ if (new Set(secret).size < MIN_DISTINCT_CHARACTERS) {
47
+ invalid('The secret is too repetitive; generate a random one (openssl rand -base64 48).');
48
+ }
49
+ }
50
+ function trustedOriginList(input, production) {
51
+ // El `baseURL` puede llevar ruta (p. ej. `/api/auth`); solo se valida su origen.
52
+ parseOrigin(parseUrl(input.baseURL, 'baseURL').origin, production, 'baseURL');
53
+ if (input.trustedOrigins.length === 0) {
54
+ invalid('List at least one trusted origin (the front end that will call the API).');
55
+ }
56
+ const origins = input.trustedOrigins.map((origin) => parseOrigin(origin, production, 'trustedOrigins entry'));
57
+ return [...new Set(origins)];
58
+ }
59
+ /**
60
+ * La configuración de Better Auth que Axeda recomienda, lista para extender:
61
+ *
62
+ * ```ts
63
+ * betterAuth({
64
+ * ...secureAuthOptions({ baseURL, secret, trustedOrigins: ['https://app.example.com'] }),
65
+ * database,
66
+ * plugins: [axedaPlugin],
67
+ * });
68
+ * ```
69
+ *
70
+ * Fija orígenes de confianza explícitos, cookies `HttpOnly`/`SameSite=Lax`/`Secure`, sesiones de
71
+ * 7 días, contraseñas de 12 a 128 caracteres, correo verificado, sesiones cerradas al restablecer
72
+ * la contraseña y límites de peticiones más estrictos en el inicio de sesión y el registro.
73
+ * **Se niega a devolver** una configuración débil: un secreto corto o repetitivo, orígenes con
74
+ * comodín o sin `https` en producción.
75
+ *
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» en las
78
+ * cuentas administrativas (ver la guía de seguridad).
79
+ */
80
+ export function secureAuthOptions(input) {
81
+ const production = (input.environment ?? 'production') === 'production';
82
+ const requireEmailVerification = input.requireEmailVerification ?? true;
83
+ assertSecret(input.secret);
84
+ const trustedOrigins = trustedOriginList(input, production);
85
+ if (!requireEmailVerification && production) {
86
+ invalid('requireEmailVerification: false is only for development: invitations rely on verified emails.');
87
+ }
88
+ const proxies = input.trustedProxies ?? [];
89
+ return {
90
+ baseURL: input.baseURL,
91
+ secret: input.secret,
92
+ trustedOrigins,
93
+ emailAndPassword: {
94
+ enabled: true,
95
+ minPasswordLength: 12,
96
+ maxPasswordLength: 128,
97
+ requireEmailVerification,
98
+ revokeSessionsOnPasswordReset: true,
99
+ autoSignIn: !requireEmailVerification,
100
+ },
101
+ session: {
102
+ expiresIn: 7 * DAY,
103
+ updateAge: DAY,
104
+ freshAge: 15 * 60,
105
+ // Axeda no arranca con la caché de cookie activa (revocaciones tardías).
106
+ cookieCache: { enabled: false },
107
+ },
108
+ advanced: {
109
+ useSecureCookies: production,
110
+ defaultCookieAttributes: { httpOnly: true, sameSite: 'lax', secure: production },
111
+ disableCSRFCheck: false,
112
+ disableOriginCheck: false,
113
+ ...(proxies.length === 0 ? {} : { ipAddress: { trustedProxies: [...proxies] } }),
114
+ },
115
+ rateLimit: {
116
+ enabled: true,
117
+ window: 60,
118
+ max: 100,
119
+ storage: input.rateLimitStorage ?? 'memory',
120
+ customRules: {
121
+ '/sign-in/email': { window: 60, max: 5 },
122
+ '/sign-up/email': { window: 300, max: 5 },
123
+ '/request-password-reset': { window: 300, max: 3 },
124
+ '/reset-password': { window: 300, max: 5 },
125
+ '/send-verification-email': { window: 300, max: 3 },
126
+ '/two-factor/verify-totp': { window: 60, max: 5 },
127
+ },
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,61 @@
1
+ import { type Principal } from '@teamblind/axeda-core';
2
+ type UserRow = {
3
+ readonly id: string;
4
+ } & Record<string, unknown>;
5
+ /**
6
+ * Quien es el usuario para Axeda, a partir de la fila de usuario que Better Auth acaba de leer
7
+ * de la base de datos (nunca de lo que envíe el cliente). Se usa en todos los caminos que
8
+ * llegan con una sesión: los endpoints del plugin y las rutas de tu aplicación.
9
+ *
10
+ * - `authzVersion` ahorra consultas (la caché de Axeda se valida con ella).
11
+ * - `twoFactorEnabled` permite evaluar las funciones que exigen 2FA.
12
+ * - `banned` hace que un usuario bloqueado no pueda nada, incluso si llega por una vía que
13
+ * no pasó por el control de inicio de sesión (p. ej. una llave de API).
14
+ */
15
+ export declare function principalFromUser(user: UserRow): Principal;
16
+ /** Lo que se necesita de Better Auth para releer la fila de un usuario (su `internalAdapter`). */
17
+ export interface UserLookup {
18
+ findUserById(userId: string): Promise<{
19
+ readonly id: string;
20
+ } | null>;
21
+ }
22
+ /**
23
+ * La fila de usuario con la que decidir. Sin `secondaryStorage`, Better Auth la acaba de leer de la
24
+ * base de datos junto con la sesión. Con `secondaryStorage` (p. ej. Redis), la sesión trae una copia
25
+ * que Better Auth solo refresca cuando él mismo cambia al usuario: no se entera de que Axeda subió
26
+ * `authzVersion`, y la caché seguiría concediendo lo revocado. Entonces se relee de la base de datos.
27
+ * `null` si el usuario ya no existe.
28
+ */
29
+ export declare function currentUserRow(user: UserRow,
30
+ /** Las opciones de Better Auth. */
31
+ options: object | undefined, lookup: () => Promise<UserLookup | undefined> | UserLookup | undefined): Promise<UserRow | null>;
32
+ /** Lo mínimo que se necesita de `auth` (así no se ata a la forma exacta de Better Auth). */
33
+ export interface SessionReader {
34
+ readonly api: {
35
+ getSession(input: {
36
+ headers: Headers;
37
+ }): Promise<{
38
+ user: {
39
+ id: string;
40
+ } & Record<string, unknown>;
41
+ } | null>;
42
+ };
43
+ /** Las opciones de Better Auth: si hay `secondaryStorage`, el usuario se relee de la base de datos. */
44
+ readonly options?: object | undefined;
45
+ readonly $context?: Promise<{
46
+ readonly internalAdapter: UserLookup;
47
+ }> | undefined;
48
+ }
49
+ /**
50
+ * Cómo saber quién hace una petición de tu aplicación: pregunta a Better Auth por la sesión
51
+ * (cookie o, si usas ese plugin, llave de API) y la convierte en un `Principal`. Sin sesión da
52
+ * `null`. Se le pasa a `createAxedaExpress({ principal })`.
53
+ *
54
+ * ```ts
55
+ * createAxedaExpress({ axeda: axedaPlugin.instance(), principal: sessionPrincipal(auth) });
56
+ * ```
57
+ */
58
+ export declare function sessionPrincipal(auth: SessionReader): (request: {
59
+ readonly headers: Headers;
60
+ }) => Promise<Principal | null>;
61
+ export {};
@@ -0,0 +1,61 @@
1
+ import { AxedaError, isBanned } from '@teamblind/axeda-core';
2
+ const asDate = (value) => value instanceof Date || typeof value === 'string' || typeof value === 'number'
3
+ ? new Date(value)
4
+ : null;
5
+ /**
6
+ * Quien es el usuario para Axeda, a partir de la fila de usuario que Better Auth acaba de leer
7
+ * de la base de datos (nunca de lo que envíe el cliente). Se usa en todos los caminos que
8
+ * llegan con una sesión: los endpoints del plugin y las rutas de tu aplicación.
9
+ *
10
+ * - `authzVersion` ahorra consultas (la caché de Axeda se valida con ella).
11
+ * - `twoFactorEnabled` permite evaluar las funciones que exigen 2FA.
12
+ * - `banned` hace que un usuario bloqueado no pueda nada, incluso si llega por una vía que
13
+ * no pasó por el control de inicio de sesión (p. ej. una llave de API).
14
+ */
15
+ export function principalFromUser(user) {
16
+ const principal = {
17
+ userId: user.id,
18
+ authzVersion: typeof user['authzVersion'] === 'number' ? user['authzVersion'] : undefined,
19
+ twoFactorEnabled: user['twoFactorEnabled'] === true,
20
+ banned: isBanned({ banned: user['banned'] === true, banExpires: asDate(user['banExpires']) }, new Date()),
21
+ };
22
+ return principal;
23
+ }
24
+ /**
25
+ * La fila de usuario con la que decidir. Sin `secondaryStorage`, Better Auth la acaba de leer de la
26
+ * base de datos junto con la sesión. Con `secondaryStorage` (p. ej. Redis), la sesión trae una copia
27
+ * que Better Auth solo refresca cuando él mismo cambia al usuario: no se entera de que Axeda subió
28
+ * `authzVersion`, y la caché seguiría concediendo lo revocado. Entonces se relee de la base de datos.
29
+ * `null` si el usuario ya no existe.
30
+ */
31
+ export async function currentUserRow(user,
32
+ /** Las opciones de Better Auth. */
33
+ options, lookup) {
34
+ const { secondaryStorage } = (options ?? {});
35
+ if (secondaryStorage === undefined || secondaryStorage === null)
36
+ return user;
37
+ const users = await lookup();
38
+ if (users === undefined) {
39
+ throw new AxedaError('AXEDA_INVALID_CONFIG', 'Sessions come from secondaryStorage, so Axeda must re-read the user from the database, ' +
40
+ 'but no internalAdapter was given to read it with.');
41
+ }
42
+ return (await users.findUserById(user.id));
43
+ }
44
+ /**
45
+ * Cómo saber quién hace una petición de tu aplicación: pregunta a Better Auth por la sesión
46
+ * (cookie o, si usas ese plugin, llave de API) y la convierte en un `Principal`. Sin sesión da
47
+ * `null`. Se le pasa a `createAxedaExpress({ principal })`.
48
+ *
49
+ * ```ts
50
+ * createAxedaExpress({ axeda: axedaPlugin.instance(), principal: sessionPrincipal(auth) });
51
+ * ```
52
+ */
53
+ export function sessionPrincipal(auth) {
54
+ return async ({ headers }) => {
55
+ const session = await auth.api.getSession({ headers });
56
+ if (session === null)
57
+ return null;
58
+ const user = await currentUserRow(session.user, auth.options, async () => (await auth.$context)?.internalAdapter);
59
+ return user === null ? null : principalFromUser(user);
60
+ };
61
+ }
package/dist/rows.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type AssignmentScope, type AuditEvent, type Role, type RoleAssignment } from '@teamblind/axeda-core';
2
+ /** Filas tal como las devuelve el adaptador de Better Auth. */
3
+ export interface RoleRow {
4
+ id: string;
5
+ key: string;
6
+ name: string;
7
+ description?: string | null;
8
+ permissions: readonly string[];
9
+ requireMfa: boolean;
10
+ status: string;
11
+ managed: boolean;
12
+ version: number;
13
+ createdAt: Date;
14
+ updatedAt: Date;
15
+ }
16
+ export interface AssignmentRow {
17
+ id: string;
18
+ userId: string;
19
+ roleId: string;
20
+ scopeType: string;
21
+ scopeId: string;
22
+ grantedBy?: string | null;
23
+ createdAt: Date;
24
+ expiresAt?: Date | null;
25
+ }
26
+ export declare function toRole(row: RoleRow): Role;
27
+ export declare function toScope(row: Pick<AssignmentRow, 'id' | 'scopeType' | 'scopeId'>): AssignmentScope;
28
+ export declare function toAssignment(row: AssignmentRow): RoleAssignment;
29
+ export declare const scopeColumns: (scope: AssignmentScope) => {
30
+ scopeType: string;
31
+ scopeId: string;
32
+ };
33
+ export interface AuditRow {
34
+ id: string;
35
+ actorId?: string | null;
36
+ action: string;
37
+ targetType: string;
38
+ targetId?: string | null;
39
+ scopeType?: string | null;
40
+ scopeId?: string | null;
41
+ outcome: string;
42
+ ip?: string | null;
43
+ userAgent?: string | null;
44
+ metadata: Record<string, unknown>;
45
+ at: Date;
46
+ }
47
+ export declare function toAuditEvent(row: AuditRow): AuditEvent;
package/dist/rows.js ADDED
@@ -0,0 +1,66 @@
1
+ import { AxedaError, GLOBAL_SCOPE, parsePermissionKey, } from '@teamblind/axeda-core';
2
+ import { GLOBAL_SCOPE_ID } from './schema.js';
3
+ const corrupt = (message) => new AxedaError('AXEDA_DATA_CORRUPT', message);
4
+ export function toRole(row) {
5
+ return {
6
+ id: row.id,
7
+ key: row.key,
8
+ name: row.name,
9
+ description: row.description ?? null,
10
+ // Un permiso corrupto lanza: es preferible fallar que decidir con datos dudosos.
11
+ permissions: row.permissions.map(parsePermissionKey),
12
+ requireMfa: row.requireMfa,
13
+ // Un estado desconocido se trata como inactivo: ante la duda, se deniega.
14
+ status: row.status === 'active' ? 'active' : 'inactive',
15
+ managed: row.managed,
16
+ version: row.version,
17
+ createdAt: row.createdAt,
18
+ updatedAt: row.updatedAt,
19
+ };
20
+ }
21
+ export function toScope(row) {
22
+ switch (row.scopeType) {
23
+ case 'global':
24
+ return GLOBAL_SCOPE;
25
+ case 'company':
26
+ case 'business':
27
+ if (row.scopeId === GLOBAL_SCOPE_ID) {
28
+ throw corrupt(`Assignment "${row.id}" has a ${row.scopeType} scope without an id.`);
29
+ }
30
+ return { type: row.scopeType, id: row.scopeId };
31
+ default:
32
+ throw corrupt(`Assignment "${row.id}" has an unknown scope type "${row.scopeType}".`);
33
+ }
34
+ }
35
+ export function toAssignment(row) {
36
+ return {
37
+ id: row.id,
38
+ userId: row.userId,
39
+ roleId: row.roleId,
40
+ scope: toScope(row),
41
+ grantedBy: row.grantedBy ?? null,
42
+ createdAt: row.createdAt,
43
+ expiresAt: row.expiresAt ?? null,
44
+ };
45
+ }
46
+ export const scopeColumns = (scope) => scope.type === 'global'
47
+ ? { scopeType: 'global', scopeId: GLOBAL_SCOPE_ID }
48
+ : { scopeType: scope.type, scopeId: scope.id };
49
+ export function toAuditEvent(row) {
50
+ return {
51
+ id: row.id,
52
+ at: row.at,
53
+ actorId: row.actorId ?? null,
54
+ // Los valores los escribe Axeda; una fila ajena o corrupta se rechaza en vez de adivinarse.
55
+ action: row.action,
56
+ targetType: row.targetType,
57
+ targetId: row.targetId ?? null,
58
+ scope: row.scopeType === null || row.scopeType === undefined
59
+ ? null
60
+ : toScope({ id: row.id, scopeType: row.scopeType, scopeId: row.scopeId ?? '' }),
61
+ outcome: row.outcome,
62
+ ip: row.ip ?? null,
63
+ userAgent: row.userAgent ?? null,
64
+ metadata: row.metadata,
65
+ };
66
+ }