@jantstack/adonis-authz 2.5.0-rc.1 → 2.5.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.
@@ -9,7 +9,8 @@ import AuthzReconcile from './authz_reconcile.js';
9
9
  import AuthzRelationsReconcile from './authz_relations_reconcile.js';
10
10
  import AuthzFreeze from './authz_freeze.js';
11
11
  import AuthzUnfreeze from './authz_unfreeze.js';
12
- export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, };
12
+ import AuthzUpgrade1x from './authz_upgrade_1x.js';
13
+ export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
13
14
  /** Contrato de loader de ace (`LoadersContract.getMetaData`). */
14
15
  export declare function getMetaData(): Promise<CommandMetaData[]>;
15
16
  /** Contrato de loader de ace (`LoadersContract.getCommand`): la clase, o `null` si el nombre no es nuestro. */
@@ -10,7 +10,7 @@
10
10
  * desenvolver `default` (mismo contrato que `@adonisjs/core/commands` y
11
11
  * `@adonisjs/lucid/commands`, que leen un `commands.json` generado por el
12
12
  * assembler; aquí no hay assembler, así que el loader es un `ListLoader`
13
- * sobre las diez clases (nueve hasta alpha.4) y no hay índice que generar).
13
+ * sobre las once clases (nueve hasta alpha.4, diez hasta la rotación 3 de 2.5) y no hay índice que generar).
14
14
  *
15
15
  * Hasta alpha.3 este fichero re-exportaba las nueve clases y NADA más, de
16
16
  * modo que en un consumidor configurado con `node ace configure` **`node ace`
@@ -37,8 +37,9 @@ import AuthzReconcile from './authz_reconcile.js';
37
37
  import AuthzRelationsReconcile from './authz_relations_reconcile.js';
38
38
  import AuthzFreeze from './authz_freeze.js';
39
39
  import AuthzUnfreeze from './authz_unfreeze.js';
40
- export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, };
41
- /** Los diez (nueve hasta alpha.4; `authz:scopes:requeue` desde alpha.5), en el orden en que `node ace list` los enseña. */
40
+ import AuthzUpgrade1x from './authz_upgrade_1x.js';
41
+ export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
42
+ /** Los once (nueve hasta alpha.4; `authz:scopes:requeue` desde alpha.5; `authz:upgrade-1x` desde la rotación 3 de 2.5), en el orden en que `node ace list` los enseña. */
42
43
  const loader = new ListLoader([
43
44
  OpenFgaProvision,
44
45
  AuthzCatalogDiff,
@@ -50,6 +51,7 @@ const loader = new ListLoader([
50
51
  AuthzRelationsReconcile,
51
52
  AuthzFreeze,
52
53
  AuthzUnfreeze,
54
+ AuthzUpgrade1x,
53
55
  ]);
54
56
  /** Contrato de loader de ace (`LoadersContract.getMetaData`). */
55
57
  export function getMetaData() {
@@ -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). */
@@ -204,15 +208,18 @@ export interface DriverCapabilities {
204
208
  enumerateFacts: boolean;
205
209
  /**
206
210
  * **El driver es EXPERIMENTAL** (estrategia 2.5.0, firma 4; `AuthorizationDriverCapabilities.experimental`). Opcional
207
- * (regla D15: un harness publicado de 2.4/2.5 sin la clave sigue valiendo y cuenta como `false`). Con `true` el juez fija
208
- * 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
209
214
  * declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
210
215
  */
211
216
  experimental?: boolean;
212
217
  /**
213
218
  * **Toda decisión que combina concesión y deny se lee en UNA foto** (rotación 2 · cierre 3, L12;
214
- * `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15: un harness publicado sin la clave sigue
215
- * valiendo y cuenta como `false`). Solo tiene casos en `level: '2.1'` o superior (necesita `listDenies`). Con `true`
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`
216
223
  * el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
217
224
  * no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
218
225
  * `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
@@ -225,6 +232,15 @@ export interface DriverCapabilities {
225
232
  */
226
233
  snapshotDecisions?: boolean;
227
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;
228
244
  export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
229
245
  export interface DriverContractHarness {
230
246
  name: string;
@@ -1,12 +1,27 @@
1
1
  import '@japa/assert';
2
2
  import { test } from '@japa/runner';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
- import { APP_SCOPE } from '../types.js';
4
+ import { APP_SCOPE, normalizeAuthorizationCapabilities } from '../types.js';
5
5
  import { AuthorizationManager } from '../manager.js';
6
6
  import db from '@adonisjs/lucid/services/db';
7
7
  import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
8
8
  import { scopeKey } from '../identity.js';
9
9
  import { descendantsFrom, memoryScopeTree, resolveChainFrom } from './scope_tree.js';
10
+ const OPTIONAL_DRIVER_CAPABILITIES = Object.freeze({
11
+ experimental: true,
12
+ snapshotDecisions: true,
13
+ });
14
+ /**
15
+ * **Clave D15 OMITIDA ⇒ la declaración del driver** (ensayo rc.1 de COGNITIV, día 1, hallazgo 1; firma del dueño
16
+ * 2026-10-23). Una clave opcional que el harness no declara (`undefined`) no es `false`: el juez la toma de la
17
+ * declaración NORMALIZADA del driver (`normalizeAuthorizationCapabilities`) y juzga ESA cara. Si el harness la declara,
18
+ * manda el harness (y contradecir al driver sigue siendo rojo). Un harness publicado antes de que la clave existiera no
19
+ * puede declararla: tratarla como `false` ponía rojo a un harness verde contra un driver que la declara `true` (el propio
20
+ * `database` con `snapshotDecisions`), que es lo que D15 prohíbe.
21
+ */
22
+ export function isOmittedOptionalCapability(capabilities, capability) {
23
+ return Object.prototype.hasOwnProperty.call(OPTIONAL_DRIVER_CAPABILITIES, capability) && capabilities[capability] === undefined;
24
+ }
10
25
  /** El default de `DriverContractHarness.transactions`: Lucid + `authz_*`. */
11
26
  export function lucidContractTransactions() {
12
27
  const count = async (table, subject) => {
@@ -243,6 +258,11 @@ async function rejectsWith(assert, fn, expected) {
243
258
  }
244
259
  assert.fail('debería haber rechazado');
245
260
  }
261
+ /**
262
+ * Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida):
263
+ * el caso que la envuelve suma los de su cara. Solo acota un cuelgue; no es una medida.
264
+ */
265
+ const OMITTED_CASE_TIMEOUT_MS = 30_000;
246
266
  export function runAuthorizationDriverContract(harness) {
247
267
  return registerAuthorizationDriverContract(harness, {
248
268
  group: (title, define) => test.group(title, define),
@@ -259,7 +279,21 @@ export function registerAuthorizationDriverContract(harness, api) {
259
279
  let tree;
260
280
  /** Registra el caso solo si el harness pidió ese nivel o uno superior. */
261
281
  let registered = 0;
282
+ /**
283
+ * Mientras no es `null`, `test` NO registra: guarda el caso aquí (las caras de una clave D15 omitida, que se juzgan
284
+ * dentro de un único caso cuando el driver ya existe; ver `caseFromDriver`).
285
+ */
286
+ let capturing = null;
262
287
  const test = (title, fn) => {
288
+ if (capturing) {
289
+ const entry = { title, fn };
290
+ capturing.push(entry);
291
+ return {
292
+ timeout(ms) {
293
+ entry.timeout = ms;
294
+ },
295
+ };
296
+ }
263
297
  registered += 1;
264
298
  return api.test(title, fn);
265
299
  };
@@ -273,6 +307,8 @@ export function registerAuthorizationDriverContract(harness, api) {
273
307
  * cara `whenTrue` queda sin cubrir y la suite lanza al cerrar el grupo.
274
308
  */
275
309
  function caseFor(capability, pair) {
310
+ if (isOmittedOptionalCapability(harness.capabilities, capability))
311
+ return caseFromDriver(capability, pair);
276
312
  const side = harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse;
277
313
  if (!side)
278
314
  return;
@@ -284,7 +320,66 @@ export function registerAuthorizationDriverContract(harness, api) {
284
320
  if (registered > before)
285
321
  covered.add(capability);
286
322
  }
287
- group.each.setup(async () => {
323
+ /** Los casos de una cara, sin registrarlos. */
324
+ function captureFace(side) {
325
+ if (!side)
326
+ return [];
327
+ const previous = capturing;
328
+ const into = [];
329
+ capturing = into;
330
+ try {
331
+ side();
332
+ }
333
+ finally {
334
+ capturing = previous;
335
+ }
336
+ return into;
337
+ }
338
+ /**
339
+ * **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (ver `isOmittedOptionalCapability`). Al
340
+ * registrar no hay driver (lo construye `makeDriver` en cada caso, y puede ser asíncrono o tener efectos: crear un
341
+ * store), así que las DOS caras se capturan sin registrarse y se registra UN caso que, ya con el driver del caso,
342
+ * lee su declaración normalizada y corre EN SERIE todos los casos de esa cara, cada uno sobre un estado fresco
343
+ * (`prepareCase`, lo mismo que el `setup` de Japa). Un rojo dice la clave, la declaración del driver y el título del
344
+ * caso interno. Nada se relaja: con `true` se juzga la cara `true` entera, y si esa cara no tiene caso en este nivel
345
+ * es rojo como lo sería declararla (`uncoveredCapabilities`, la misma regla pura). Si ninguna cara tiene caso en este
346
+ * nivel no hay nada observable y no se registra nada (como antes).
347
+ */
348
+ function caseFromDriver(capability, pair) {
349
+ const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
350
+ if (!faces.true.length && !faces.false.length)
351
+ return;
352
+ const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
353
+ const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
354
+ `(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
355
+ test(title, async ({ assert }) => {
356
+ const declared = normalizeAuthorizationCapabilities(driver.capabilities)[capability] === true;
357
+ const cases = declared ? faces.true : faces.false;
358
+ const uncovered = uncoveredCapabilities({ [capability]: declared }, new Set(cases.length ? [capability] : []));
359
+ if (uncovered.length) {
360
+ throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero el ` +
361
+ `contrato no tiene caso para ese valor en el nivel '${level}'. Declara la clave en el harness con lo observable hoy.`);
362
+ }
363
+ for (const [index, entry] of cases.entries()) {
364
+ if (index > 0)
365
+ await prepareCase();
366
+ try {
367
+ await entry.fn({ assert });
368
+ }
369
+ catch (error) {
370
+ const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
371
+ if (error && typeof error === 'object' && !Object.isFrozen(error)) {
372
+ error.message = prefix + String(error.message ?? '');
373
+ throw error;
374
+ }
375
+ throw new Error(prefix + String(error?.message ?? error), { cause: error });
376
+ }
377
+ }
378
+ })?.timeout(Math.max(budget(faces.true), budget(faces.false)));
379
+ covered.add(capability);
380
+ }
381
+ /** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
382
+ async function prepareCase() {
288
383
  await harness.cleanup();
289
384
  await harness.seedCatalog(CONTRACT_CATALOG);
290
385
  tree = harness.makeTree ? await harness.makeTree() : memoryScopeTree();
@@ -341,7 +436,8 @@ export function registerAuthorizationDriverContract(harness, api) {
341
436
  await mirror(() => driver.onScopeMoved?.(child, parent));
342
437
  };
343
438
  }
344
- });
439
+ }
440
+ group.each.setup(prepareCase);
345
441
  group.teardown(async () => {
346
442
  await harness.cleanup();
347
443
  });
@@ -47,9 +47,19 @@ export declare const RELATIONS_CONTRACT_DENY_TYPES: {
47
47
  readonly note: undefined;
48
48
  };
49
49
  export declare function contractDenyRelationsConfig(): RelationsConfig;
50
+ /**
51
+ * **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
52
+ * rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
53
+ * driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
54
+ */
55
+ export declare function isOmittedOptionalRelationsCapability(capabilities: RelationsDriverCapabilities, capability: keyof RelationsDriverCapabilities): boolean;
50
56
  export interface RelationsDriverContractHarness {
51
57
  name: string;
52
- /** Lo que este driver DECLARA. Cada valor se juzga con su cara. */
58
+ /**
59
+ * Lo que este driver DECLARA. Cada valor se juzga con su cara. Una clave OPCIONAL (`?:`, regla D15: `relationDenies`,
60
+ * `atomicRenewal`, `experimental`) que el harness omite se juzga con la declaración normalizada del DRIVER
61
+ * (`isOmittedOptionalRelationsCapability`); declararla fija la cara y el juez exige que el driver diga lo mismo.
62
+ */
53
63
  capabilities: RelationsDriverCapabilities;
54
64
  /** Construye el driver sobre la config del contrato (fresco por caso). */
55
65
  makeDriver(config: RelationsConfig): RelationsDriver | Promise<RelationsDriver>;
@@ -28,7 +28,7 @@ import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CH
28
28
  import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
29
29
  import { systemClock } from '../clock.js';
30
30
  import { resolveGrantExpiry, sameInstant } from '../expiry.js';
31
- import { isRelUserset } from '../types.js';
31
+ import { isRelUserset, normalizeRelationsCapabilities } from '../types.js';
32
32
  /* ── El vocabulario del contrato ─────────────────────────────────────────── */
33
33
  /** Los holder types del contrato de relaciones. */
34
34
  export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
@@ -101,6 +101,21 @@ export function contractDenyRelationsConfig() {
101
101
  database: { membersOf: true },
102
102
  });
103
103
  }
104
+ const OPTIONAL_RELATIONS_CAPABILITIES = Object.freeze({
105
+ relationDenies: true,
106
+ atomicRenewal: true,
107
+ experimental: true,
108
+ });
109
+ /**
110
+ * **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
111
+ * rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
112
+ * driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
113
+ */
114
+ export function isOmittedOptionalRelationsCapability(capabilities, capability) {
115
+ return Object.prototype.hasOwnProperty.call(OPTIONAL_RELATIONS_CAPABILITIES, capability) && capabilities[capability] === undefined;
116
+ }
117
+ /** Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida). */
118
+ const OMITTED_CASE_TIMEOUT_MS = 30_000;
104
119
  /** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
105
120
  export function lucidRelationsContractTransactions() {
106
121
  return {
@@ -668,22 +683,94 @@ export function registerRelationsDriverContract(harness, api) {
668
683
  base,
669
684
  };
670
685
  }
686
+ /** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
687
+ async function prepareCase() {
688
+ // alpha.3: el manager de cada caso captura `onRelationWrite` — el par
689
+ // `transactionalWrites` juzga también el EVENTO (la otra cara observable
690
+ // de la capacidad) y R-07/R-08 juzgan el evento de la purga.
691
+ state.events = [];
692
+ const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
693
+ state.manager = f.manager;
694
+ state.base = f.base;
695
+ state.writes = f.writes;
696
+ state.calls = f.calls;
697
+ }
698
+ /**
699
+ * Lo que se JUZGA de una capacidad dentro de un caso: lo que declara el harness, o —si omite una clave opcional (D15)—
700
+ * la declaración normalizada del driver del caso. Lo leen los casos que dependen de otra capacidad (p. ej. los de
701
+ * `atomicRenewal` siembran el deny si `relationDenies`): con la clave omitida no pueden leer `undefined` como `false`.
702
+ */
703
+ function judged(capability) {
704
+ if (isOmittedOptionalRelationsCapability(harness.capabilities, capability)) {
705
+ return normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
706
+ }
707
+ return harness.capabilities[capability] === true;
708
+ }
671
709
  api.group(`relations contract [${harness.name}]`, (group) => {
672
- group.each.setup(async () => {
673
- // alpha.3: el manager de cada caso captura `onRelationWrite` — el par
674
- // `transactionalWrites` juzga también el EVENTO (la otra cara observable
675
- // de la capacidad) y R-07/R-08 juzgan el evento de la purga.
676
- state.events = [];
677
- const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
678
- state.manager = f.manager;
679
- state.base = f.base;
680
- state.writes = f.writes;
681
- state.calls = f.calls;
682
- });
710
+ group.each.setup(prepareCase);
711
+ /** Mientras no es `null`, `test` guarda el caso aquí en vez de registrarlo (ver `caseFromDriver`). */
712
+ let capturing = null;
683
713
  const test = (title, fn) => {
714
+ if (capturing) {
715
+ const entry = { title, fn };
716
+ capturing.push(entry);
717
+ return {
718
+ timeout(ms) {
719
+ entry.timeout = ms;
720
+ },
721
+ };
722
+ }
684
723
  registered++;
685
724
  return api.test(title, fn);
686
725
  };
726
+ function captureFace(side) {
727
+ const previous = capturing;
728
+ const into = [];
729
+ capturing = into;
730
+ try {
731
+ side();
732
+ }
733
+ finally {
734
+ capturing = previous;
735
+ }
736
+ return into;
737
+ }
738
+ /**
739
+ * **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (gemelo de `caseFromDriver` del runner de
740
+ * roles). Las dos caras se capturan sin registrarse y se registra UN caso que, con el driver del caso, lee su
741
+ * declaración normalizada y corre en serie todos los casos de esa cara, cada uno sobre un estado fresco
742
+ * (`prepareCase`). Nada se relaja: si el driver declara `true` se juzga la cara `true` entera.
743
+ */
744
+ function caseFromDriver(capability, pair) {
745
+ const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
746
+ const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
747
+ const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
748
+ `(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
749
+ test(title, async ({ assert }) => {
750
+ const declared = normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
751
+ const cases = declared ? faces.true : faces.false;
752
+ if (!cases.length) {
753
+ throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero ` +
754
+ `esa cara no registró ningún caso: una cara vacía es un skip disfrazado.`);
755
+ }
756
+ for (const [index, entry] of cases.entries()) {
757
+ if (index > 0)
758
+ await prepareCase();
759
+ try {
760
+ await entry.fn({ assert });
761
+ }
762
+ catch (error) {
763
+ const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
764
+ if (error && typeof error === 'object' && !Object.isFrozen(error)) {
765
+ error.message = prefix + String(error.message ?? '');
766
+ throw error;
767
+ }
768
+ throw new Error(prefix + String(error?.message ?? error), { cause: error });
769
+ }
770
+ }
771
+ })?.timeout(Math.max(budget(faces.true), budget(faces.false)));
772
+ covered.add(capability);
773
+ }
687
774
  function caseFor(capability, pair) {
688
775
  // MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
689
776
  // en el código del runner, o es un skip disfrazado (exigencia del tester).
@@ -691,6 +778,8 @@ export function registerRelationsDriverContract(harness, api) {
691
778
  throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras ` +
692
779
  `(whenTrue Y whenFalse): una cara ausente es un skip disfrazado.`);
693
780
  }
781
+ if (isOmittedOptionalRelationsCapability(harness.capabilities, capability))
782
+ return caseFromDriver(capability, pair);
694
783
  const before = registered;
695
784
  (harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse)();
696
785
  if (registered > before)
@@ -860,7 +949,7 @@ export function registerRelationsDriverContract(harness, api) {
860
949
  assert.notProperty(members, 'cursor', 'membersOf sin page no devuelve cursor');
861
950
  assert.deepEqual(ids(members.subjects), expected, `membersOf devuelve los ${N}`);
862
951
  }
863
- if (harness.capabilities.relationDenies) {
952
+ if (judged('relationDenies')) {
864
953
  const { manager } = await denyManager();
865
954
  const denied = { type: 'document', id: docId() };
866
955
  for (const u of users)
@@ -1984,20 +2073,20 @@ export function registerRelationsDriverContract(harness, api) {
1984
2073
  }
1985
2074
  caseFor('atomicRenewal', {
1986
2075
  whenTrue: () => test('atomicRenewal:true · renovar (otra caducidad) una membresía que TRANSPORTA un deny —directa y ANIDADA— entra en UNA escritura: la caducidad cambia y el deny sigue negando', async ({ assert }) => {
1987
- const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
1988
- const { u, p, x, g, h, k } = await seedRenewal(manager, harness.capabilities.relationDenies === true);
2076
+ const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
2077
+ const { u, p, x, g, h, k } = await seedRenewal(manager, judged('relationDenies'));
1989
2078
  for (const group of [g, h, k]) {
1990
2079
  await manager.relate(u, 'member', group, p, { expiresAt: null });
1991
2080
  const expiry = await membershipExpiry(base, u, group, p);
1992
2081
  if (expiry !== undefined)
1993
2082
  assert.isNull(expiry, `la membresía de ${group.id} quedó SIN caducidad`);
1994
2083
  }
1995
- const carried = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
2084
+ const carried = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
1996
2085
  assert.equal(await manager.check(u, 'viewer', x, p), !carried, 'el deny transportado sigue negando tras renovar');
1997
2086
  }),
1998
2087
  whenFalse: () => test('atomicRenewal:false · renovar una membresía que TRANSPORTA un deny vivo —directa o por un grupo que la contiene— ⇒ 409 E_AUTHZ_RENEWAL_UNSAFE con la receta y CERO escrituras (la caducidad no cambia, el deny sigue negando); una membresía sin deny se renueva', async ({ assert }) => {
1999
- const denies = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
2000
- const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
2088
+ const denies = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
2089
+ const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
2001
2090
  const { u, p, x, g, h, k, hour } = await seedRenewal(manager, denies);
2002
2091
  if (denies) {
2003
2092
  for (const group of [g, h]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jantstack/adonis-authz",
3
- "version": "2.5.0-rc.1",
3
+ "version": "2.5.0",
4
4
  "description": "Driver-based authorization engine for AdonisJS + Lucid: hierarchical scopes with downward inheritance, explicit denies, expiring assignments and polymorphic holders. Ships a self-contained database driver and an OpenFGA driver, both judged by the same executable contract suite.",
5
5
  "type": "module",
6
6
  "license": "MIT",