@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.
@@ -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
  });
@@ -3017,7 +3113,7 @@ export function registerAuthorizationDriverContract(harness, api) {
3017
3113
  // dejaba vivo un mutante que convertía la colisión en un 503.
3018
3114
  const raceOfDefines = (serialized) => since('2.2', serialized
3019
3115
  ? 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: EXACTAMENTE uno gana y el perdedor es 422 E_AUTHZ_CATALOG_CONFLICT — la unicidad no es una carrera'
3020
- : 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
3116
+ : 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores con el MISMO owner (el del ancestro puede ensombrecer al del descendiente) y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
3021
3117
  // 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
3022
3118
  // porque el unique de la base es (slug, scope_type, owner_scope_key)
3023
3119
  // y cada uno comprobaba la colisión contra SU foto del memo). Ahora
@@ -3060,11 +3156,21 @@ export function registerAuthorizationDriverContract(harness, api) {
3060
3156
  assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
3061
3157
  }
3062
3158
  else {
3063
- // Sin serialización garantizada: nunca dos. Cuántos confirman
3064
- // depende del motor — SQLite bloquea la base entera y el segundo
3065
- // escritor puede recibir `SQLITE_BUSY`, un fallo de backend
3066
- // legítimo (503) porque su transacción NO se aplicó.
3067
- assert.isAtMost(ok.length, 1, `nunca dos ganadores (${estado})`);
3159
+ // Sin serialización garantizada cuántos confirman depende del
3160
+ // motor — SQLite bloquea la base entera y el segundo escritor
3161
+ // puede recibir `SQLITE_BUSY`, un fallo de backend legítimo (503)
3162
+ // porque su transacción NO se aplicó—, así que cero también vale.
3163
+ // Y los mismos DOS finales legales que la cara `true` (rotación 2 ·
3164
+ // cierre 2 · T-07): desde R2-05 el re-chequeo en la transacción
3165
+ // decide por AUTORIDAD, así que si el del descendiente confirma
3166
+ // mientras el del ancestro valida, el del ancestro ENTRA y lo
3167
+ // ensombrece (final 2 de 3G · Y1). Hasta alpha.4 aquí se afirmaba
3168
+ // «como mucho uno», que un backend sin el bloqueo de SQLite podía
3169
+ // poner rojo sin defecto (aserción flaky en el artefacto
3170
+ // publicado). Lo innegociable se afirma abajo igual que en la
3171
+ // cara `true`: nunca dos con el MISMO owner y, si son dos, el
3172
+ // extra es el del ANCESTRO.
3173
+ assert.isAtMost(ok.length, 2, `nunca dos ganadores con el MISMO owner (${estado})`);
3068
3174
  }
3069
3175
  for (const rejected of results.filter((r) => r.status === 'rejected')) {
3070
3176
  const error = rejected.reason;
@@ -3161,22 +3267,18 @@ export function registerAuthorizationDriverContract(harness, api) {
3161
3267
  await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
3162
3268
  await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
3163
3269
  assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
3164
- // Y la rama del re-chequeo que NO se puede juzgar ahí dentro (3F ·
3165
- // U3, tester 3E · §4.2): un homónimo cuyo owner no es global ni
3166
- // ancestro-o-igual del owner pedido —una unit HERMANA— no se
3167
- // clasifica sin resolver su cadena, y resolverla con el cerrojo del
3168
- // catálogo sostenido sería un abrazo mortal con un pool de 1. Se
3169
- // rechaza igual, con el 422 que pide REINTENTAR, y no escribe.
3170
- // Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
3171
- // en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
3172
- // SERIE (el memo se recalienta primero, así que la fila nueva le es
3173
- // invisible y solo la relectura de la BASE la ve).
3270
+ // Y la rama del re-chequeo de un owner que NO es global ni ancestro-o-igual del owner pedido —una unit
3271
+ // HERMANA— (3F · U3, tester 3E · §4.2; volteada en la rotación 2 de M4 · R2-05). Hasta 2.5.0-alpha.4 se
3272
+ // rechazaba con un 422 «reintenta» que NOMBRABA el owner del hermano (otro tenant) y la colisión era falsa:
3273
+ // dos hermanos pueden compartir slug. Ahora se juzga con la MISMA regla de autoridad que la comprobación
3274
+ // previa y entra. EN SERIE: el memo se recalienta primero, así que la fila nueva le es invisible y solo la
3275
+ // relectura de la BASE la ve.
3174
3276
  const unitA2 = await unitUnder(tree, orgA);
3175
3277
  assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
3176
3278
  await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
3177
- const cambio = await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' }), conflicto);
3178
- assert.include(String(cambio?.message), 'El catálogo cambió mientras se validaba');
3179
- assert.lengthOf(await rowsNamed('lateral', 'unit'), 1, 'el 422 genérico tampoco escribe a ciegas');
3279
+ const lateral = await here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' });
3280
+ assert.equal(lateral.owner, scopeKey(unitA1), 'el homónimo de un HERMANO no es colisión: entra');
3281
+ assert.lengthOf(await rowsNamed('lateral', 'unit'), 2, 'los dos hermanos tienen su lateral@unit');
3180
3282
  });
3181
3283
  caseFor('serializedCatalogWrites', {
3182
3284
  whenTrue: () => raceOfDefines(true),
@@ -3815,6 +3917,162 @@ export function registerAuthorizationDriverContract(harness, api) {
3815
3917
  });
3816
3918
  },
3817
3919
  });
3920
+ // ── Rotación 2 · cierre 3 · `snapshotDecisions` (regla D15, linaje L12) ─────────
3921
+ // Una decisión que combina lo que el holder TIENE con lo que tiene DENEGADO, leída en dos métodos del puerto sin
3922
+ // foto común, puede responder lo que ningún estado real responde (un offboarding entre las dos lecturas). La
3923
+ // capacidad dice que el driver lee las dos mitades de una vez; lo que el juez observa es el MÉTODO (`readSnapshot`):
3924
+ // su respuesta es la de los métodos homónimos, y el manager lo usa en vez de componer.
3925
+ const snapshotSeed = async () => {
3926
+ const alice = subject();
3927
+ const orgA = await orgUnder(tree, APP_SCOPE);
3928
+ const unitA1 = await unitUnder(tree, orgA);
3929
+ await driver.grant(alice, 'org-editor', orgA);
3930
+ await driver.grant(alice, 'unit-editor', unitA1);
3931
+ await driver.grant(alice, 'viewer', APP_SCOPE);
3932
+ await driver.deny(alice, 'docs:read', unitA1);
3933
+ await driver.deny(alice, 'billing:read', orgA);
3934
+ return { alice, orgA, unitA1 };
3935
+ };
3936
+ const canonical = (items) => items.map((item) => JSON.stringify(item)).sort();
3937
+ /**
3938
+ * El espía del par (rotación 2 · cierre 3b, tester 🟡 6): cuenta SOLO las llamadas que hace el MANAGER al driver. Cada
3939
+ * método se reenvía con `this` = el driver de verdad (no el proxy), así que lo que un `readSnapshot` llame por dentro
3940
+ * (`this.rolesInChain()` dentro de una transacción REPEATABLE READ, p. ej.) no se cuenta: el juez fija el contrato
3941
+ * (UNA llamada del manager), no la implementación.
3942
+ */
3943
+ const watchManagerCalls = (target) => {
3944
+ const touched = [];
3945
+ // El proxy va sobre un objeto VACÍO (no sobre el driver): un driver congelado o con métodos no configurables no
3946
+ // choca con las invariantes de `Proxy`, y todo se lee del driver de verdad. Una VISTA que devuelve el driver
3947
+ // (`withChainResolver`, `withClock`: el manager las pide por request) se vigila igual, con la misma cuenta.
3948
+ const watch = (real) => new Proxy({}, {
3949
+ get: (_shell, prop) => {
3950
+ const value = Reflect.get(real, prop, real);
3951
+ if (typeof value === 'function' && typeof prop === 'string') {
3952
+ return (...args) => {
3953
+ touched.push(prop);
3954
+ const out = value.apply(real, args);
3955
+ const isView = out !== null && typeof out === 'object' && typeof out.then !== 'function' && typeof out.authorize === 'function';
3956
+ return isView ? watch(out) : out;
3957
+ };
3958
+ }
3959
+ return value;
3960
+ },
3961
+ has: (_shell, prop) => prop in real,
3962
+ });
3963
+ const watched = watch(target);
3964
+ const reads = () => touched.filter((name) => ['readSnapshot', 'rolesInChain', 'listScopes', 'listDenies', 'listRoles'].includes(name));
3965
+ return { watched, touched, reads };
3966
+ };
3967
+ /**
3968
+ * La parte de DELEGACIÓN de las dos caras (rotación 2 · cierre 3c, A/J1 del auditor y del tester): la policy de
3969
+ * `defineScopedRole` solo se juzga si el harness declara `purgeRole: true`. Sin `purgeRole` el manager no delega
3970
+ * (`defineScopedRole` es 500 `E_AUTHZ_UNSUPPORTED` ANTES de la policy, 3E · P4), y `purgeRole` es un par de `'2.2'`:
3971
+ * un tercero `'2.1'` con `listDenies` y sin `purgeRole` —legítimo, D15— salía ROJO con un caso nuevo. Decide la
3972
+ * DECLARACIÓN del harness, no la presencia del método, por el patrón del juez (como `listDenies` aquí mismo): una
3973
+ * declaración `true` solo es posible en `'2.2'` (en `'2.1'` el juez lanza al registrar un `true` sin caso) y ahí la
3974
+ * juzga su propio par —método presente y purga total, o ausente y 500—, mientras que un método presente en un
3975
+ * harness `'2.1'` no lo juzga nadie. El título de cada cara dice cuál de las dos se registró.
3976
+ */
3977
+ const snapshotDelegation = harness.capabilities.purgeRole === true;
3978
+ caseFor('snapshotDecisions', {
3979
+ whenTrue: () => {
3980
+ since('2.1', 'snapshotDecisions:true · readSnapshot responde cada lectura pedida EXACTAMENTE como el método homónimo (rolesInChain, listScopes, listDenies), lo no pedido no viene, un permiso fuera del catálogo da listScopes [] y una pregunta mal formada es 422', async ({ assert, }) => {
3981
+ assert.typeOf(driver.readSnapshot, 'function', 'snapshotDecisions: true exige readSnapshot en el puerto');
3982
+ assert.strictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: true y el driver no');
3983
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, true);
3984
+ const { alice, unitA1 } = await snapshotSeed();
3985
+ const chain = (await tree.chainOf(unitA1));
3986
+ const all = await driver.readSnapshot(alice, { rolesInChain: chain, listScopes: 'docs:write', listDenies: true });
3987
+ assert.sameMembers(Object.keys(all), ['rolesInChain', 'listScopes', 'listDenies']);
3988
+ assert.deepEqual(canonical(all.rolesInChain), canonical(await driver.rolesInChain(alice, chain)));
3989
+ assert.deepEqual(canonical(all.listScopes), canonical(await driver.listScopes(alice, 'docs:write')));
3990
+ assert.deepEqual(canonical(all.listDenies), canonical(await driver.listDenies(alice)));
3991
+ assert.lengthOf(all.listDenies, 2);
3992
+ assert.isAbove(all.rolesInChain.length, 0);
3993
+ // Con el deny de docs:read en la unit, listScopes de docs:read no lista la unit (ni lo que bloquea).
3994
+ const reads = await driver.readSnapshot(alice, { listScopes: 'docs:read' });
3995
+ assert.deepEqual(Object.keys(reads), ['listScopes'], 'lo no pedido no viene');
3996
+ assert.deepEqual(canonical(reads.listScopes), canonical(await driver.listScopes(alice, 'docs:read')));
3997
+ assert.deepEqual(await driver.readSnapshot(alice, { listScopes: 'no:existe' }), { listScopes: [] });
3998
+ for (const bad of [{}, { foo: true }, { listDenies: false }, { rolesInChain: 'x' }, null]) {
3999
+ await rejectsWith(assert, () => driver.readSnapshot(alice, bad), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
4000
+ }
4001
+ });
4002
+ since('2.1', snapshotDelegation
4003
+ ? 'snapshotDecisions:true · el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado'
4004
+ : 'snapshotDecisions:true · el manager compone effectivePermissions y authorizedScopes con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado (sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
4005
+ const { alice, orgA, unitA1 } = await snapshotSeed();
4006
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
4007
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
4008
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write']);
4009
+ assert.deepEqual(reads(), ['readSnapshot']);
4010
+ touched.length = 0;
4011
+ // docs:write: org-editor en la org y unit-editor en la unit (viewer en app solo da docs:read) ⇒ la unit.
4012
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
4013
+ assert.deepEqual(reads(), ['readSnapshot']);
4014
+ touched.length = 0;
4015
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
4016
+ assert.deepEqual(reads(), ['readSnapshot']);
4017
+ if (!snapshotDelegation)
4018
+ return;
4019
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
4020
+ // La policy de delegación: el actor tiene docs:read DENEGADO en la unit (owner) ⇒ 422 con UNA foto.
4021
+ const admin = subject();
4022
+ await driver.grant(admin, 'org-admin', orgA);
4023
+ await driver.deny(admin, 'docs:read', orgA);
4024
+ touched.length = 0;
4025
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
4026
+ status: 422,
4027
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
4028
+ });
4029
+ assert.deepEqual(reads(), ['readSnapshot']);
4030
+ });
4031
+ },
4032
+ whenFalse: () => {
4033
+ // Rotación 2 · cierre 3b (H3 del auditor): el manager decide por la CAPACIDAD, no por la presencia del método, así
4034
+ // que esta cara NO exige que el puerto no traiga `readSnapshot` (un tercero puede tener un método con ese nombre
4035
+ // para otra cosa): exige que el manager NO lo llame. Sin `listDenies` no hay composición que juzgar y la cara
4036
+ // se queda en la declaración —lo dice su título (tester 🟡 6)—.
4037
+ if (!harness.capabilities.listDenies) {
4038
+ since('2.1', 'snapshotDecisions:false · (sin listDenies: solo la declaración) authorization.capabilities().snapshotDecisions es false y el driver no declara true; sin listDenies el manager no compone ninguna decisión con deny que juzgar', async ({ assert, }) => {
4039
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
4040
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
4041
+ });
4042
+ return;
4043
+ }
4044
+ since('2.1', snapshotDelegation
4045
+ ? 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad)'
4046
+ : 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions y authorizedScopes con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad; sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
4047
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
4048
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
4049
+ const { alice, orgA, unitA1 } = await snapshotSeed();
4050
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
4051
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
4052
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write'], 'el deny de docs:read en la unit se resta');
4053
+ assert.include(reads(), 'listDenies');
4054
+ assert.notInclude(reads(), 'readSnapshot');
4055
+ touched.length = 0;
4056
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
4057
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
4058
+ assert.include(reads(), 'listDenies');
4059
+ assert.notInclude(reads(), 'readSnapshot');
4060
+ if (!snapshotDelegation)
4061
+ return;
4062
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
4063
+ const admin = subject();
4064
+ await driver.grant(admin, 'org-admin', orgA);
4065
+ await driver.deny(admin, 'docs:read', orgA);
4066
+ touched.length = 0;
4067
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
4068
+ status: 422,
4069
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
4070
+ });
4071
+ assert.include(reads(), 'listDenies');
4072
+ assert.notInclude(reads(), 'readSnapshot');
4073
+ });
4074
+ },
4075
+ });
3818
4076
  // ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
3819
4077
  caseFor('roleInheritanceNative', {
3820
4078
  whenFalse: () => {
@@ -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]) {