@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.2

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.
@@ -85,7 +85,9 @@ const MYSQL_LOCK_NAME_MAX = 64;
85
85
  * coincidieran en los primeros 64 COMPARTÍAN lease y se excluían para siempre
86
86
  * — y el modo de fallo era `busy: true`, que no lanza: el relay de uno de los
87
87
  * dos no corría nunca, en silencio. Desde alpha.5 el nombre es un hash
88
- * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona.
88
+ * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona entre
89
+ * TABLAS — entre BASES del mismo servidor sí: el «nombre completo» no llevaba la
90
+ * base (rotación 2 · R2-01, abajo).
89
91
  *
90
92
  * **Transición**: `sqlScopeOutbox.acquire()` toma el lock viejo (`legacy`, el
91
93
  * recortado) Y el nuevo durante alpha.5, porque en un rolling deploy un
@@ -114,13 +116,47 @@ export const SCOPE_OUTBOX_LEGACY_LOCK_UNTIL = '2.4.0-alpha.6';
114
116
  * desde ≤ alpha.4 en MySQL exige parada completa del relay, no despliegue rodante.
115
117
  */
116
118
  export const SCOPE_OUTBOX_TAKES_LEGACY_LOCK = false;
117
- export function scopeOutboxLockNames(connection, table) {
119
+ /**
120
+ * **La identidad de la BASE en el nombre del lease** (rotación 2 de M4 · R2-01, decisión del dueño 2026-10-22 (1)).
121
+ * `GET_LOCK` es un cerrojo de SERVIDOR, y hasta 2.5.0-alpha.4 el nombre era `<conexión>:<tabla>`: dos despliegues en
122
+ * BASES distintas del mismo MySQL con la conexión por defecto de Adonis y la tabla del stub calculaban el MISMO nombre y
123
+ * se excluían (`busy`, exit 0) — el síntoma que R6 de alpha.5 daba por cerrado nunca lo estuvo, porque el «nombre
124
+ * completo» no llevaba la base. Desde este cierre `current` lleva la base que la PROPIA conexión que sostiene el lease
125
+ * lee con `DATABASE()` (no la del config: es la base contra la que esa sesión resuelve la tabla sin cualificar, o sea la
126
+ * identidad real de la cola; un alias de host o una URL distinta que apunte a la misma base dan el mismo nombre).
127
+ *
128
+ * En MINÚSCULAS a propósito: los dos errores posibles no cuestan lo mismo. Fundir `App` y `app` (dos bases distintas en
129
+ * un servidor sensible a mayúsculas) solo hace que esos dos despliegues compartan lease —vivacidad, `busy`—; separar la
130
+ * misma base escrita de dos formas por dos procesos del MISMO despliegue serían dos escritores —seguridad—. Se elige el
131
+ * error barato. `DATABASE()` `NULL` (sesión sin base por defecto: la tabla tiene que ir cualificada) entra como cadena
132
+ * vacía: los procesos de ese despliegue siguen calculando el mismo nombre.
133
+ *
134
+ * Los tres nombres: `legacy` (el recortado a 64 de ≤ alpha.4, retirado en alpha.7, se conserva para el caso que lo
135
+ * fija), `unscoped` (el sha1 sin base de 2.4.0-alpha.5..2.5.0-alpha.4) y `current` (el sha1 con base). Sin `database`
136
+ * —quien no ha abierto conexión: los casos de identidad pura— `current` es `unscoped`.
137
+ */
138
+ export function scopeOutboxLockNames(connection, table, database) {
118
139
  const full = `authz_scope_outbox:${connection ?? 'default'}:${table}`;
140
+ const sha = (value) => `authz_scope_outbox:${createHash('sha1').update(value).digest('hex')}`;
141
+ const unscoped = sha(full);
119
142
  return {
120
143
  legacy: full.slice(0, MYSQL_LOCK_NAME_MAX),
121
- current: `authz_scope_outbox:${createHash('sha1').update(full).digest('hex')}`,
144
+ unscoped,
145
+ current: database === undefined ? unscoped : sha(`${full}@db:${String(database ?? '').toLowerCase()}`),
122
146
  };
123
147
  }
148
+ /**
149
+ * **La transición del nombre sin base** (rotación 2 · R2-01, firmada: «como alpha.5»). En 2.5.x `acquire()` toma el
150
+ * nombre VIEJO (`unscoped`, el de 2.4.0-alpha.5..2.5.0-alpha.4) y DESPUÉS el nuevo (`current`, con la base), y suelta
151
+ * los dos: en un despliegue rodante un proceso de alpha.4 solo conoce el viejo, y los dos tienen que seguir
152
+ * excluyéndose. **El precio, declarado**: mientras se tome el viejo, dos despliegues en bases distintas del mismo
153
+ * servidor SIGUEN compartiéndolo — R2-01 queda abierto en 2.5.x y se cierra al retirarlo en 2.6 (README «Limits»,
154
+ * `relay.mysql-unscoped-lease`; backlog 2.6). Atado a la versión como en alpha.6: `tests/rotacion2_cierre.spec.ts` exige
155
+ * que valga exactamente «`package.json` < `SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN`» (`2.6.0-alpha.0`: toda 2.6, sus
156
+ * prereleases incluidas), así que el bump a 2.6 pone la suite en rojo hasta retirarlo.
157
+ */
158
+ export const SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN = '2.6.0-alpha.0';
159
+ export const SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK = true;
124
160
  /** Hash de 32 bits con signo (lo que acepta `pg_try_advisory_xact_lock`). */
125
161
  function hash32(value) {
126
162
  let hash = 0;
@@ -374,7 +410,6 @@ export function sqlScopeOutbox(options = {}, database = db) {
374
410
  * pasada, que es el precio de que dos pasadas no se pisen.
375
411
  */
376
412
  async acquire() {
377
- const names = scopeOutboxLockNames(connection, table);
378
413
  /** El nombre del cerrojo de PROCESO y del advisory de PG: el completo (allí no hay recorte). */
379
414
  const name = `authz_scope_outbox:${connection ?? 'default'}:${table}`;
380
415
  const client = lockClientOf(database, connection);
@@ -397,11 +432,17 @@ export function sqlScopeOutbox(options = {}, database = db) {
397
432
  return { release: async () => void (await trx.rollback()) };
398
433
  }
399
434
  if (dialect.startsWith('mysql')) {
400
- // **Los DOS locks** (alpha.5 · R6, transición): el nuevo —hash
401
- // estable, sin colisión— y el viejo —el recortado a 64 de alpha.4—,
402
- // para que un rolling deploy con las dos versiones siga siendo
403
- // escritor único. Si cualquiera de los dos falla, se sueltan los que
404
- // se tomaron y es `busy`. El viejo se retira en 2.5.
435
+ // **La base en el nombre** (rotación 2 · R2-01): la lee la MISMA sesión que va a sostener el cerrojo.
436
+ const database = firstValue(await query('acquire', () => trx.rawQuery('select database() as db')), 'db');
437
+ const names = scopeOutboxLockNames(connection, table, database === null || database === undefined ? null : String(database));
438
+ // **Transición** (2.5.x): el VIEJO (sin base) primero y el NUEVO después; si cualquiera falla se sueltan los
439
+ // ya tomados y es `busy`. Un proceso de alpha.4 solo toma el viejo: los dos siguen excluyéndose.
440
+ // (El recortado de ≤ alpha.4 sigue retirado: `SCOPE_OUTBOX_TAKES_LEGACY_LOCK` es `false` desde alpha.7.)
441
+ const order = [
442
+ ...(SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK ? [names.unscoped] : []),
443
+ names.current,
444
+ ...(SCOPE_OUTBOX_TAKES_LEGACY_LOCK ? [names.legacy] : []),
445
+ ];
405
446
  const taken = [];
406
447
  const take = async (lockName) => {
407
448
  const result = await query('acquire', () => trx.rawQuery('select get_lock(?, 0) as taken', [lockName]));
@@ -410,18 +451,38 @@ export function sqlScopeOutbox(options = {}, database = db) {
410
451
  taken.push(lockName);
411
452
  return ok;
412
453
  };
454
+ // `GET_LOCK` es de SESIÓN, no de la transacción: revertir no lo suelta, y una conexión que vuelve al pool
455
+ // con el cerrojo puesto deja la cola `busy` para siempre. Se sueltan TODOS los tomados (en orden inverso)
456
+ // aunque uno falle, y la transacción se revierte al final pase lo que pase.
457
+ let released = false;
413
458
  const releaseAll = async () => {
414
- try {
415
- for (const lockName of taken.reverse())
459
+ if (released)
460
+ return;
461
+ released = true;
462
+ let failure;
463
+ for (const lockName of [...taken].reverse()) {
464
+ try {
416
465
  await trx.rawQuery('select release_lock(?)', [lockName]);
466
+ }
467
+ catch (error) {
468
+ failure ??= error;
469
+ }
417
470
  }
418
- finally {
419
- await trx.rollback();
420
- }
471
+ await trx.rollback();
472
+ if (failure !== undefined)
473
+ throw failure;
421
474
  };
422
- if (!(await take(names.current)) || (SCOPE_OUTBOX_TAKES_LEGACY_LOCK && !(await take(names.legacy)))) {
423
- await releaseAll();
424
- return null;
475
+ try {
476
+ for (const lockName of order) {
477
+ if (!(await take(lockName))) {
478
+ await releaseAll();
479
+ return null;
480
+ }
481
+ }
482
+ }
483
+ catch (error) {
484
+ await releaseAll().catch(() => { });
485
+ throw error;
425
486
  }
426
487
  return { release: releaseAll };
427
488
  }
@@ -26,7 +26,7 @@ export interface ExpiryCodec {
26
26
  bind(at: Date): unknown;
27
27
  /** Expresión de SELECT que devuelve la columna de forma que `fromDb` la lea sin depender del cliente. */
28
28
  select(column: string, alias?: string): unknown;
29
- /** Lo que devuelve el SELECT de `select(...)` → `Date | null`. */
29
+ /** Lo que devuelve el SELECT de `select(...)` → `Date | null` (`null` = columna NULL); presente e ilegible LANZA (RR-2). */
30
30
  fromDb(value: unknown): Date | null;
31
31
  }
32
32
  /** ¿Es MySQL? (el mismo conjunto que decide el codec). */
@@ -44,8 +44,12 @@ export declare function dialectOf(connection: any): string;
44
44
  export declare function isSqliteDialect(connection: any): boolean;
45
45
  /** `2030-06-15T12:00:00.000Z` → `2030-06-15 12:00:00.000` (UTC, con milisegundos). */
46
46
  export declare function formatUtcDatetime(at: Date): string;
47
- /** `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`, o `null` si no es una fecha. */
48
- export declare function parseUtcDatetime(value: string): Date | null;
47
+ /**
48
+ * `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`. Lo que no es una fecha REAL lanza
49
+ * `unreadableExpiry` (RR-2): la fecha cero, un mes 13, un 31 de febrero o un `24:00:00` (que `Date` desbordaría en
50
+ * silencio al día o al mes siguiente) no se leen como nada.
51
+ */
52
+ export declare function parseUtcDatetime(value: string): Date;
49
53
  /**
50
54
  * El codec para una conexión de Lucid (default: la primaria). Se decide por
51
55
  * dialecto una vez por driver; MySQL es el único que necesita algo distinto
@@ -26,20 +26,54 @@ export function isSqliteDialect(connection) {
26
26
  export function formatUtcDatetime(at) {
27
27
  return at.toISOString().slice(0, 23).replace('T', ' ');
28
28
  }
29
- /** `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`, o `null` si no es una fecha. */
29
+ /**
30
+ * Una caducidad PRESENTE que no se puede leer (rotación 3 · cierre 2, RR-2 del re-ataque): es un error, nunca «sin
31
+ * caducidad». Hasta aquí `parseUtcDatetime` y el codec identidad devolvían `null` para una fecha que no lo es —la fecha
32
+ * cero `0000-00-00` que MySQL deja con un `sql_mode` no estricto, un `-infinity` de PostgreSQL, un texto en SQLite—, y
33
+ * `null` es PERMANENTE para quien lo consume: el re-grant la conservaba como «sin caducidad», `reconcile --to=openfga`
34
+ * y el export de `relations:reconcile` escribían una tupla permanente para una fila que en SQL ya no concede. Ahora es
35
+ * 500 `E_AUTHZ_INTERNAL` en la lectura: lo que lee la caducidad para reescribirla o migrarla se para antes de escribir
36
+ * (`reconcile --to=database` incluido, que antes la reescribía con la del origen: E1, declarado en «Limits»). La DECISIÓN
37
+ * de SQL (`expires_at > now`) NO pasa por aquí y no es fail-closed para todo valor (re-ataque 2 · C1, corregido): no
38
+ * concede con la fecha cero de MySQL ni con `-infinity`, pero SÍ concede —para siempre— con `+infinity` en PostgreSQL y
39
+ * con un TEXTO en SQLite (`TEXT > INTEGER` es verdadero), aunque sea una fecha pasada. El paquete nunca escribe esos
40
+ * valores; está declarado en «Limits» con su receta (revoke + grant) y el arreglo de fondo va a 2.6. `null`/`undefined` siguen siendo «sin
41
+ * caducidad»: es lo que guarda una columna NULL.
42
+ */
43
+ function unreadableExpiry(value) {
44
+ const shown = value instanceof Date ? 'Invalid Date' : JSON.stringify(typeof value === 'string' ? value.slice(0, 40) : String(value));
45
+ return new AuthorizationInternalError(`expires_at ilegible (${shown}): una caducidad presente que no es un instante es un error, nunca «sin caducidad». ` +
46
+ 'Corrige la fila (o bórrala) antes de releerla, migrarla o reescribirla.');
47
+ }
48
+ /**
49
+ * `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`. Lo que no es una fecha REAL lanza
50
+ * `unreadableExpiry` (RR-2): la fecha cero, un mes 13, un 31 de febrero o un `24:00:00` (que `Date` desbordaría en
51
+ * silencio al día o al mes siguiente) no se leen como nada.
52
+ */
30
53
  export function parseUtcDatetime(value) {
31
- const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,6}))?$/.exec(value.trim());
54
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/.exec(value.trim());
32
55
  if (!match)
33
- return null;
34
- const millis = (match[3] ?? '').padEnd(3, '0').slice(0, 3);
35
- const date = new Date(`${match[1]}T${match[2]}.${millis}Z`);
36
- return Number.isNaN(date.getTime()) ? null : date;
56
+ throw unreadableExpiry(value);
57
+ const [, year, month, day, hour, minute, second, fraction] = match;
58
+ const wall = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
59
+ const date = new Date(`${wall}.${(fraction ?? '').padEnd(3, '0').slice(0, 3)}Z`);
60
+ // Ida y vuelta: `Date` acepta `2030-02-31` (⇒ 3 de marzo) y `24:00:00` (⇒ día siguiente); una fecha real vuelve igual.
61
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 19) !== wall)
62
+ throw unreadableExpiry(value);
63
+ return date;
37
64
  }
38
65
  const identityCodec = {
39
66
  toDb: (expiresAt) => expiresAt,
40
67
  bind: (at) => at,
41
68
  select: (column, alias) => (alias && alias !== column ? `${column} as ${alias}` : column),
42
- fromDb: toExpiryDate,
69
+ // RR-2: `toExpiryDate` da `null` para lo que no es fecha (y deja pasar un `Date` inválido); aquí eso es un error.
70
+ fromDb: (value) => {
71
+ const date = toExpiryDate(value);
72
+ if (date === null ? !(value === null || value === undefined || value === '') : Number.isNaN(date.getTime())) {
73
+ throw unreadableExpiry(value);
74
+ }
75
+ return date;
76
+ },
43
77
  };
44
78
  function mysqlCodec(connection) {
45
79
  const raw = (sql, bindings) => typeof connection?.raw === 'function' ? connection.raw(sql, bindings) : db.raw(sql, bindings);
@@ -37,6 +37,10 @@ import type { ContractScopeTree } from './scope_tree.js';
37
37
  * `truncationSignal: false` (con `exhaustiveLists`) y `hierarchyFacts: false`;
38
38
  * los demás llegan con su fase, y mientras tanto declararlos `true` hace que
39
39
  * la suite lance al registrarse: una promesa sin juez no pasa.
40
+ *
41
+ * Las claves OPCIONALES (`?:`, regla D15) se pueden omitir: entonces la cara
42
+ * la elige la declaración normalizada del DRIVER (`isOmittedOptionalCapability`);
43
+ * declararlas fija la cara y el juez exige que el driver diga lo mismo.
40
44
  */
41
45
  export interface DriverCapabilities {
42
46
  /** El driver materializa el árbol como hechos propios (Fase 3b). */
@@ -132,8 +136,10 @@ export interface DriverCapabilities {
132
136
  * y MySQL, y es la promesa que el README hace.
133
137
  *
134
138
  * Con `false` (SQLite, que serializa bloqueando la BASE ENTERA) el juez
135
- * solo puede exigir lo innegociable: nunca dos ganadores y el perdedor no
136
- * escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
139
+ * solo puede exigir lo innegociable: nunca dos ganadores con el MISMO owner
140
+ * (el del ancestro puede ensombrecer al del descendiente: los dos finales
141
+ * legales de 3G · Y1, también en esta cara desde la rotación 2) y el
142
+ * perdedor no escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
137
143
  * legítimo. Declararlo `true` en un motor así convierte un flake en un
138
144
  * fallo; declararlo `false` en PG/MySQL deja pasar un mutante que
139
145
  * convierte la colisión en 503. Solo tiene casos en `level: '2.2'`.
@@ -202,12 +208,39 @@ export interface DriverCapabilities {
202
208
  enumerateFacts: boolean;
203
209
  /**
204
210
  * **El driver es EXPERIMENTAL** (estrategia 2.5.0, firma 4; `AuthorizationDriverCapabilities.experimental`). Opcional
205
- * (regla D15: un harness publicado de 2.4/2.5 sin la clave sigue valiendo y cuenta como `false`). Con `true` el juez fija
206
- * que el driver lo declara y que `authorization.capabilities()` lo publica; con `false` (o ausente), que el driver NO lo
211
+ * (regla D15): si el harness la OMITE, el juez juzga la cara que el DRIVER declara (su declaración normalizada; ensayo
212
+ * rc.1 de COGNITIV) y un harness publicado sin la clave sigue valiendo; si la declara, manda el harness. Con `true` el
213
+ * juez fija que el driver lo declara y que `authorization.capabilities()` lo publica; con `false`, que el driver NO lo
207
214
  * declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
208
215
  */
209
216
  experimental?: boolean;
217
+ /**
218
+ * **Toda decisión que combina concesión y deny se lee en UNA foto** (rotación 2 · cierre 3, L12;
219
+ * `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15): si el harness la OMITE, el juez juzga la
220
+ * cara que el DRIVER declara —el `database` del paquete declara `true`, así que un harness de alpha.4 que lo registra
221
+ * sin la clave se juzga en la cara `true` (ensayo rc.1 de COGNITIV)—; si la declara, manda el harness y contradecir al
222
+ * driver es rojo. Solo tiene casos en `level: '2.1'` o superior (necesita `listDenies`). Con `true`
223
+ * el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
224
+ * no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
225
+ * `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
226
+ * `rolesInChain`/`listScopes`/`listDenies` (el espía cuenta solo las llamadas del MANAGER: lo que `readSnapshot` llame
227
+ * por dentro no cuenta). Con `false`, que el manager NO llama a `readSnapshot` —aunque el driver traiga un método con
228
+ * ese nombre: decide la capacidad— y compone como antes restando los denies (el hueco declarado de un driver sin la
229
+ * capacidad); sin `listDenies` la cara se queda en la declaración y su título lo dice. Que la foto sea de verdad UNA no lo puede
230
+ * observar un juez genérico sin meterse en el backend: en `database` lo fijan los casos de entrelazado forzado y la
231
+ * guardia `database.decision.snapshot` del paquete.
232
+ */
233
+ snapshotDecisions?: boolean;
210
234
  }
235
+ /**
236
+ * **Clave D15 OMITIDA ⇒ la declaración del driver** (ensayo rc.1 de COGNITIV, día 1, hallazgo 1; firma del dueño
237
+ * 2026-10-23). Una clave opcional que el harness no declara (`undefined`) no es `false`: el juez la toma de la
238
+ * declaración NORMALIZADA del driver (`normalizeAuthorizationCapabilities`) y juzga ESA cara. Si el harness la declara,
239
+ * manda el harness (y contradecir al driver sigue siendo rojo). Un harness publicado antes de que la clave existiera no
240
+ * puede declararla: tratarla como `false` ponía rojo a un harness verde contra un driver que la declara `true` (el propio
241
+ * `database` con `snapshotDecisions`), que es lo que D15 prohíbe.
242
+ */
243
+ export declare function isOmittedOptionalCapability(capabilities: DriverCapabilities, capability: keyof DriverCapabilities): boolean;
211
244
  export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
212
245
  export interface DriverContractHarness {
213
246
  name: string;