@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10

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.
Files changed (158) hide show
  1. package/README.md +496 -29
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
  6. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
  8. package/build/commands/authz_freeze.d.ts.map +1 -1
  9. package/build/commands/authz_freeze.js +1 -2
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +15 -6
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +143 -20
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts +72 -1
  16. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  17. package/build/commands/authz_relations_reconcile.js +234 -21
  18. package/build/commands/authz_relations_reconcile.js.map +1 -1
  19. package/build/commands/authz_scopes_relay.d.ts +50 -0
  20. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  21. package/build/commands/authz_scopes_relay.js +213 -10
  22. package/build/commands/authz_scopes_relay.js.map +1 -1
  23. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  24. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  25. package/build/commands/authz_scopes_requeue.js +230 -0
  26. package/build/commands/authz_scopes_requeue.js.map +1 -0
  27. package/build/commands/authz_unfreeze.d.ts +12 -4
  28. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  29. package/build/commands/authz_unfreeze.js +74 -9
  30. package/build/commands/authz_unfreeze.js.map +1 -1
  31. package/build/commands/main.d.ts +16 -13
  32. package/build/commands/main.d.ts.map +1 -1
  33. package/build/commands/main.js +59 -11
  34. package/build/commands/main.js.map +1 -1
  35. package/build/commands/openfga_provision.d.ts +49 -3
  36. package/build/commands/openfga_provision.d.ts.map +1 -1
  37. package/build/commands/openfga_provision.js +72 -7
  38. package/build/commands/openfga_provision.js.map +1 -1
  39. package/build/index.d.ts +15 -5
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +16 -3
  42. package/build/index.js.map +1 -1
  43. package/build/providers/authz_provider.d.ts +7 -2
  44. package/build/providers/authz_provider.d.ts.map +1 -1
  45. package/build/providers/authz_provider.js +20 -3
  46. package/build/providers/authz_provider.js.map +1 -1
  47. package/build/src/define_config.d.ts +106 -1
  48. package/build/src/define_config.d.ts.map +1 -1
  49. package/build/src/define_config.js +11 -0
  50. package/build/src/define_config.js.map +1 -1
  51. package/build/src/drivers/database_driver.d.ts +53 -14
  52. package/build/src/drivers/database_driver.d.ts.map +1 -1
  53. package/build/src/drivers/database_driver.js +104 -32
  54. package/build/src/drivers/database_driver.js.map +1 -1
  55. package/build/src/drivers/database_relations_driver.d.ts +74 -9
  56. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  57. package/build/src/drivers/database_relations_driver.js +391 -93
  58. package/build/src/drivers/database_relations_driver.js.map +1 -1
  59. package/build/src/drivers/openfga_driver.d.ts +71 -10
  60. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  61. package/build/src/drivers/openfga_driver.js +195 -16
  62. package/build/src/drivers/openfga_driver.js.map +1 -1
  63. package/build/src/drivers/openfga_facts.d.ts +15 -0
  64. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  65. package/build/src/drivers/openfga_facts.js +25 -2
  66. package/build/src/drivers/openfga_facts.js.map +1 -1
  67. package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
  68. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  69. package/build/src/drivers/openfga_relations_driver.js +308 -50
  70. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  71. package/build/src/errors.d.ts +175 -4
  72. package/build/src/errors.d.ts.map +1 -1
  73. package/build/src/errors.js +219 -3
  74. package/build/src/errors.js.map +1 -1
  75. package/build/src/freeze.d.ts +100 -20
  76. package/build/src/freeze.d.ts.map +1 -1
  77. package/build/src/freeze.js +159 -25
  78. package/build/src/freeze.js.map +1 -1
  79. package/build/src/manager.d.ts +239 -7
  80. package/build/src/manager.d.ts.map +1 -1
  81. package/build/src/manager.js +1192 -148
  82. package/build/src/manager.js.map +1 -1
  83. package/build/src/models/authz_assignment.d.ts +2 -2
  84. package/build/src/models/authz_assignment.d.ts.map +1 -1
  85. package/build/src/models/authz_deny.d.ts +2 -2
  86. package/build/src/models/authz_deny.d.ts.map +1 -1
  87. package/build/src/models/authz_permission.d.ts +2 -2
  88. package/build/src/models/authz_permission.d.ts.map +1 -1
  89. package/build/src/models/authz_role.d.ts +2 -2
  90. package/build/src/models/authz_role.d.ts.map +1 -1
  91. package/build/src/models/authz_role_permission.d.ts +2 -2
  92. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  93. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  94. package/build/src/relation_partition_trigger.js +11 -3
  95. package/build/src/relation_partition_trigger.js.map +1 -1
  96. package/build/src/relations/define_relations_config.d.ts +51 -0
  97. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  98. package/build/src/relations/define_relations_config.js +68 -1
  99. package/build/src/relations/define_relations_config.js.map +1 -1
  100. package/build/src/relations/manager.d.ts +106 -6
  101. package/build/src/relations/manager.d.ts.map +1 -1
  102. package/build/src/relations/manager.js +368 -30
  103. package/build/src/relations/manager.js.map +1 -1
  104. package/build/src/relations/reconcile.d.ts +81 -6
  105. package/build/src/relations/reconcile.d.ts.map +1 -1
  106. package/build/src/relations/reconcile.js +233 -77
  107. package/build/src/relations/reconcile.js.map +1 -1
  108. package/build/src/scope_outbox.d.ts +54 -0
  109. package/build/src/scope_outbox.d.ts.map +1 -1
  110. package/build/src/scope_outbox.js +274 -33
  111. package/build/src/scope_outbox.js.map +1 -1
  112. package/build/src/shared/backend_guard.d.ts +28 -0
  113. package/build/src/shared/backend_guard.d.ts.map +1 -1
  114. package/build/src/shared/backend_guard.js +52 -0
  115. package/build/src/shared/backend_guard.js.map +1 -1
  116. package/build/src/shared/sql_expiry.d.ts +2 -0
  117. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  118. package/build/src/shared/sql_expiry.js +4 -0
  119. package/build/src/shared/sql_expiry.js.map +1 -1
  120. package/build/src/shared/transaction_guard.d.ts +53 -0
  121. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  122. package/build/src/shared/transaction_guard.js +74 -0
  123. package/build/src/shared/transaction_guard.js.map +1 -0
  124. package/build/src/sql_descendants.d.ts +8 -0
  125. package/build/src/sql_descendants.d.ts.map +1 -1
  126. package/build/src/sql_descendants.js.map +1 -1
  127. package/build/src/testing/contract.d.ts +55 -4
  128. package/build/src/testing/contract.d.ts.map +1 -1
  129. package/build/src/testing/contract.js +244 -9
  130. package/build/src/testing/contract.js.map +1 -1
  131. package/build/src/testing/main.d.ts +6 -4
  132. package/build/src/testing/main.d.ts.map +1 -1
  133. package/build/src/testing/main.js +3 -2
  134. package/build/src/testing/main.js.map +1 -1
  135. package/build/src/testing/migration_contract.d.ts +9 -0
  136. package/build/src/testing/migration_contract.d.ts.map +1 -1
  137. package/build/src/testing/migration_contract.js +5 -2
  138. package/build/src/testing/migration_contract.js.map +1 -1
  139. package/build/src/testing/relations_contract.d.ts +23 -1
  140. package/build/src/testing/relations_contract.d.ts.map +1 -1
  141. package/build/src/testing/relations_contract.js +603 -19
  142. package/build/src/testing/relations_contract.js.map +1 -1
  143. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  144. package/build/src/testing/relations_reconcile_contract.js +49 -1
  145. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  146. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  147. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  148. package/build/src/testing/scope_outbox_contract.js +677 -0
  149. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  150. package/build/src/traits/has_uuid.d.ts +2 -2
  151. package/build/src/traits/has_uuid.d.ts.map +1 -1
  152. package/build/src/types.d.ts +708 -38
  153. package/build/src/types.d.ts.map +1 -1
  154. package/build/src/types.js +55 -0
  155. package/build/src/types.js.map +1 -1
  156. package/build/stubs/config/authorization.stub +45 -1
  157. package/build/stubs/migration.stub +20 -10
  158. package/package.json +1 -1
@@ -7,6 +7,26 @@ 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
+ /** El default de `DriverContractHarness.transactions`: Lucid + `authz_*`. */
11
+ export function lucidContractTransactions() {
12
+ const count = async (table, subject) => {
13
+ const rows = await db
14
+ .from(table)
15
+ .where('holder_type', subject.type)
16
+ .where('holder_uuid', subject.uuid)
17
+ .count('* as n');
18
+ return Number(rows[0]?.n ?? 0);
19
+ };
20
+ return {
21
+ async begin() {
22
+ const trx = await db.transaction();
23
+ return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
24
+ },
25
+ async census(subject) {
26
+ return { assignments: await count('authz_assignments', subject), denies: await count('authz_denies', subject) };
27
+ },
28
+ };
29
+ }
10
30
  /**
11
31
  * Catálogo de prueba: roles a nivel app y a nivel organization. Los dos
12
32
  * últimos existen solo para el invariante 8: `rank` es metadata y el motor no
@@ -1689,8 +1709,9 @@ export function registerAuthorizationDriverContract(harness, api) {
1689
1709
  }
1690
1710
  });
1691
1711
  // ── Concurrencia (2.5 · J4) ──────────────────────────────────────────
1692
- // Sin `{trx}` en el puerto (`transactions: false` sigue; es el diferido
1693
- // 2.6): lo que se fija es que dos escrituras solapadas nunca dejan un
1712
+ // Sin `{trx}` en estos casos (la escritura en la transacción del
1713
+ // llamante tiene su par, `transactionalWrites`, abajo): lo que se fija
1714
+ // es que dos escrituras solapadas nunca dejan un
1694
1715
  // estado que ninguna de las dos habría dejado sola. Con el harness en
1695
1716
  // memoria (pool 1) se solapan a nivel de consulta; con `sqlite-file`, PG
1696
1717
  // y MySQL (pool ≥ 2) a nivel de conexión.
@@ -2223,7 +2244,10 @@ export function registerAuthorizationDriverContract(harness, api) {
2223
2244
  await driver.grant(alice, 'propio', unitA1);
2224
2245
  await driver.grant(alice, 'nieto', unitA1x);
2225
2246
  assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
2226
- assert.isUndefined(await authz.scopes.detached(unitA1), 'no devuelve nada que declarar a medias');
2247
+ // Desde 2.4.0-alpha.5 `scopes.*` devuelven el RECIBO del encolado
2248
+ // (`{ queued }`); sin outbox, `{ queued: [] }` = aplicado en línea. Lo
2249
+ // que Z1 fija —ningún contador de lo purgado a medias— sigue en pie.
2250
+ assert.deepEqual(await authz.scopes.detached(unitA1), { queued: [] }, 'no devuelve nada que declarar a medias: el recibo vacío no es un contador de lo purgado');
2227
2251
  assert.deepEqual(await driver.listRoles(alice, unitA1), [], 'los HECHOS del scope, purgados');
2228
2252
  assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
2229
2253
  const view = await new CatalogCache().view();
@@ -3255,9 +3279,201 @@ export function registerAuthorizationDriverContract(harness, api) {
3255
3279
  // `hierarchyFacts: false` → lo observan N1b/N2/N4/N5 (arriba): el driver
3256
3280
  // responde según el árbol que le resuelve el consumidor, y `tree.move`
3257
3281
  // cambia la respuesta sin escritura. `true` llega en Fase 3b.
3258
- // `injectableClock` tiene su par arriba (2.5 · J1). `transactions` (2.6)
3259
- // y `singleCheckAuthorize` (Fase 3b): pares en su fase; hoy solo pueden
3260
- // declararse `false`.
3282
+ // `injectableClock` tiene su par arriba (2.5 · J1). `transactionalWrites`
3283
+ // (L-2) tiene su par al final: la cara `false` hoy, la `true` con L-3.
3284
+ // ── L-2 · `transactionalWrites` (panel `{trx}`, (C)) ──────────────────
3285
+ // Las DOS caras son obligatorias en este par (`BOTH_FACES_REQUIRED`): una
3286
+ // `whenFalse` ausente se saltaba en silencio y «rechazo activo» sería
3287
+ // una etiqueta. Lo que la cara `false` fija es la PUERTA 1 (por llamada,
3288
+ // siempre activa) y la PUERTA 2 (por config, opt-in), las dos con el
3289
+ // manager construido sobre el driver del harness, en TODOS los niveles:
3290
+ // son composición del manager sobre `driver.capabilities`, así que un
3291
+ // driver de terceros de nivel `core` también las observa.
3292
+ caseFor('transactionalWrites', {
3293
+ // L-3: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
3294
+ // transacción» POR CENSO: el rollback deja CERO filas nuevas (no solo
3295
+ // `authorize` false — una respuesta puede salir bien con la fila viva),
3296
+ // el commit las deja escritas, y mientras la transacción está abierta
3297
+ // la autoridad (que lee por la conexión del motor) no ve nada. Y la
3298
+ // otra mitad de la regla: una transacción que NO es la del driver no
3299
+ // recibe ni una sentencia. Cuatro escrituras, las dos caras cada una.
3300
+ whenTrue: () => {
3301
+ test('transactionalWrites: true · grant/revoke/deny/removeDeny con { transaction } + rollback ⇒ authorize sin cambio Y CERO filas (censo); + commit ⇒ aplicado; una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
3302
+ assert.equal(driver.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
3303
+ const tx = harness.transactions ?? lucidContractTransactions();
3304
+ const alice = subject();
3305
+ const org = await orgUnder(tree, APP_SCOPE);
3306
+ const authz = managerOver({});
3307
+ const can = () => driver.authorize(alice, 'docs:read', org);
3308
+ const census = () => tx.census(alice);
3309
+ // Una transacción que se queda abierta tras una aserción fallida
3310
+ // retiene una conexión del pool y arrastra a los casos siguientes:
3311
+ // el rollback va en `finally`, y el commit solo si nada falló.
3312
+ const rolledBack = async (fn) => {
3313
+ const t = await tx.begin();
3314
+ try {
3315
+ await fn(t.client);
3316
+ }
3317
+ finally {
3318
+ await t.rollback();
3319
+ }
3320
+ };
3321
+ const committed = async (fn) => {
3322
+ const t = await tx.begin();
3323
+ try {
3324
+ await fn(t.client);
3325
+ }
3326
+ catch (error) {
3327
+ await t.rollback();
3328
+ throw error;
3329
+ }
3330
+ await t.commit();
3331
+ };
3332
+ // grant: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
3333
+ let seenInside;
3334
+ await rolledBack(async (transaction) => {
3335
+ await authz.grant(alice, 'org-editor', org, { transaction });
3336
+ seenInside = await can();
3337
+ });
3338
+ assert.deepEqual(await census(), { assignments: 0, denies: 0 }, 'ROJO: la asignación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
3339
+ assert.isFalse(await can(), 'grant + rollback ⇒ no concede');
3340
+ assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el grant sin confirmar no concede');
3341
+ await committed(async (transaction) => {
3342
+ await authz.grant(alice, 'org-editor', org, { transaction });
3343
+ });
3344
+ assert.isTrue(await can(), 'grant + commit ⇒ concede');
3345
+ assert.deepEqual(await census(), { assignments: 1, denies: 0 });
3346
+ // revoke: rollback ⇒ la asignación sigue; commit ⇒ se va.
3347
+ await rolledBack(async (transaction) => {
3348
+ await authz.revoke(alice, 'org-editor', org, { transaction });
3349
+ seenInside = await can();
3350
+ });
3351
+ assert.deepEqual(await census(), { assignments: 1, denies: 0 }, 'ROJO: el revoke sobrevivió al rollback');
3352
+ assert.isTrue(await can(), 'revoke + rollback ⇒ sigue concediendo');
3353
+ assert.isTrue(seenInside, 'el revoke sin confirmar no se ve desde la conexión del motor');
3354
+ await committed(async (transaction) => {
3355
+ await authz.revoke(alice, 'org-editor', org, { transaction });
3356
+ });
3357
+ assert.isFalse(await can(), 'revoke + commit ⇒ no concede');
3358
+ assert.deepEqual(await census(), { assignments: 0, denies: 0 });
3359
+ // deny (sobre un grant confirmado): rollback ⇒ sigue concediendo; commit ⇒ deniega.
3360
+ await authz.grant(alice, 'org-editor', org);
3361
+ await rolledBack(async (transaction) => {
3362
+ await authz.deny(alice, 'docs:read', org, { transaction });
3363
+ seenInside = await can();
3364
+ });
3365
+ assert.deepEqual(await census(), { assignments: 1, denies: 0 }, 'ROJO: el deny sobrevivió al rollback');
3366
+ assert.isTrue(await can(), 'deny + rollback ⇒ sigue concediendo');
3367
+ assert.isTrue(seenInside, 'el deny sin confirmar no se ve desde la conexión del motor');
3368
+ await committed(async (transaction) => {
3369
+ await authz.deny(alice, 'docs:read', org, { transaction });
3370
+ });
3371
+ assert.isFalse(await can(), 'deny + commit ⇒ deniega');
3372
+ assert.deepEqual(await census(), { assignments: 1, denies: 1 });
3373
+ // removeDeny: rollback ⇒ el deny sigue; commit ⇒ restaura.
3374
+ await rolledBack(async (transaction) => {
3375
+ await authz.removeDeny(alice, 'docs:read', org, { transaction });
3376
+ seenInside = await can();
3377
+ });
3378
+ assert.deepEqual(await census(), { assignments: 1, denies: 1 }, 'ROJO: el removeDeny sobrevivió al rollback');
3379
+ assert.isFalse(await can(), 'removeDeny + rollback ⇒ sigue denegando');
3380
+ assert.isFalse(seenInside, 'el removeDeny sin confirmar no se ve desde la conexión del motor');
3381
+ await committed(async (transaction) => {
3382
+ await authz.removeDeny(alice, 'docs:read', org, { transaction });
3383
+ });
3384
+ assert.isTrue(await can(), 'removeDeny + commit ⇒ restaura');
3385
+ assert.deepEqual(await census(), { assignments: 1, denies: 0 });
3386
+ // Una transacción que NO es la del driver (`assertCallerTransaction`):
3387
+ // 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
3388
+ const statements = [];
3389
+ const spy = (shape) => new Proxy(shape, {
3390
+ get(target, prop) {
3391
+ if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery') {
3392
+ return (...args) => {
3393
+ statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
3394
+ return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
3395
+ };
3396
+ }
3397
+ return Reflect.get(target, prop);
3398
+ },
3399
+ });
3400
+ const foreign = spy({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
3401
+ const notATransaction = spy({ isTransaction: false, connectionName: 'primary' });
3402
+ const wholeDb = spy({ connection() { }, primaryConnectionName: 'primary' });
3403
+ const bob = subject();
3404
+ for (const [label, transaction] of [
3405
+ ['otra conexión', foreign],
3406
+ ['un cliente sin transacción', notATransaction],
3407
+ ['el db entero', wholeDb],
3408
+ ]) {
3409
+ for (const [operation, run] of [
3410
+ ['grant', () => authz.grant(bob, 'org-editor', org, { transaction })],
3411
+ ['revoke', () => authz.revoke(bob, 'org-editor', org, { transaction })],
3412
+ ['deny', () => authz.deny(bob, 'docs:read', org, { transaction })],
3413
+ ['removeDeny', () => authz.removeDeny(bob, 'docs:read', org, { transaction })],
3414
+ ]) {
3415
+ const error = await rejectsWith(assert, run, { status: 500, code: 'E_AUTHZ_CONFIG' });
3416
+ assert.include(error.message, operation, `${label}/${operation}: nombra la operación`);
3417
+ }
3418
+ }
3419
+ assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
3420
+ assert.deepEqual(await tx.census(bob), { assignments: 0, denies: 0 });
3421
+ assert.isFalse(await driver.authorize(bob, 'docs:read', org));
3422
+ })?.timeout(30_000);
3423
+ },
3424
+ whenFalse: () => {
3425
+ test('transactionalWrites: false · { transaction } en grant/revoke/deny/removeDeny es 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía); y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al RESOLVER el driver', async ({ assert, }) => {
3426
+ // El harness declara `false`: el driver tiene que decir lo mismo
3427
+ // (el manager lee `driver.capabilities`, no el harness).
3428
+ assert.notEqual(driver.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
3429
+ const touched = [];
3430
+ const spied = new Proxy(driver, {
3431
+ get(target, prop, receiver) {
3432
+ const value = Reflect.get(target, prop, receiver);
3433
+ if (typeof value === 'function' && typeof prop === 'string') {
3434
+ return (...args) => {
3435
+ touched.push(prop);
3436
+ return value.apply(receiver, args);
3437
+ };
3438
+ }
3439
+ return value;
3440
+ },
3441
+ });
3442
+ // La forma de un `TransactionClientContract` de Lucid: la puerta
3443
+ // decide por la CAPACIDAD, antes de mirar qué transacción es.
3444
+ const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
3445
+ const alice = subject();
3446
+ const org = await orgUnder(tree, APP_SCOPE);
3447
+ const authz = managerOver({}, spied);
3448
+ const writes = [
3449
+ ['grant', () => authz.grant(alice, 'org-editor', org, { transaction: trx })],
3450
+ ['revoke', () => authz.revoke(alice, 'org-editor', org, { transaction: trx })],
3451
+ ['deny', () => authz.deny(alice, 'docs:read', org, { transaction: trx })],
3452
+ ['removeDeny', () => authz.removeDeny(alice, 'docs:read', org, { transaction: trx })],
3453
+ ];
3454
+ for (const [operation, run] of writes) {
3455
+ const error = await rejectsWith(assert, run, { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
3456
+ assert.include(error.message, `'${harness.name}'`, `${operation}: nombra el driver`);
3457
+ assert.include(error.message, operation, `${operation}: nombra la operación`);
3458
+ assert.include(error.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
3459
+ }
3460
+ assert.deepEqual(touched, [], 'cero llamadas al driver: el rechazo va ANTES del backend');
3461
+ // Sin `{ transaction }` la MISMA escritura entra: la puerta es del
3462
+ // parámetro, no del driver.
3463
+ await authz.grant(alice, 'org-editor', org);
3464
+ assert.isTrue(await driver.authorize(alice, 'docs:read', org));
3465
+ assert.include(touched, 'grant');
3466
+ // Puerta 2, opt-in: quien quiera fallar al ARRANCAR lo pide, y
3467
+ // entonces el driver ni se resuelve (tampoco para leer).
3468
+ const strict = managerOver({ requireTransactionalWrites: true }, spied);
3469
+ const atResolve = await rejectsWith(assert, () => strict.driver(), { status: 500, code: 'E_AUTHZ_CONFIG' });
3470
+ assert.include(atResolve.message, `'${harness.name}'`);
3471
+ assert.include(atResolve.message, 'transactionalWrites');
3472
+ await rejectsWith(assert, () => strict.authorize(alice, 'docs:read', org), { status: 500, code: 'E_AUTHZ_CONFIG' });
3473
+ await rejectsWith(assert, () => strict.grant(alice, 'org-editor', org), { status: 500, code: 'E_AUTHZ_CONFIG' });
3474
+ });
3475
+ },
3476
+ });
3261
3477
  // ── 3b-2e · E6 · `singleCheckAuthorize` ──────────────────────────────
3262
3478
  caseFor('singleCheckAuthorize', {
3263
3479
  whenTrue: () => {
@@ -3626,11 +3842,30 @@ export function registerAuthorizationDriverContract(harness, api) {
3626
3842
  });
3627
3843
  });
3628
3844
  // Al cerrar el grupo: toda capacidad declarada `true` tiene que haber
3629
- // registrado su par. Es la regla "jamás un skip" hecha ejecutable.
3630
- const uncovered = Object.keys(harness.capabilities).filter((capability) => harness.capabilities[capability] && !covered.has(capability));
3845
+ // registrado su par — y las de `BOTH_FACES_REQUIRED`, también con `false`.
3846
+ // Es la regla "jamás un skip" hecha ejecutable.
3847
+ const uncovered = uncoveredCapabilities(harness.capabilities, covered);
3631
3848
  if (uncovered.length) {
3632
- throw new Error(`[contrato ${harness.name}] declara ${uncovered.map((c) => `'${c}: true'`).join(', ')} ` +
3849
+ throw new Error(`[contrato ${harness.name}] declara ${uncovered.map((c) => `'${c}: ${String(harness.capabilities[c])}'`).join(', ')} ` +
3633
3850
  `pero el contrato no tiene caso para ese valor todavía. Declara lo observable hoy.`);
3634
3851
  }
3635
3852
  }
3853
+ /**
3854
+ * La regla del guard, PURA (para que `contract_harness.spec.ts` la juzgue sin
3855
+ * borrar caras): una capacidad `true` sin caso registrado queda sin cubrir;
3856
+ * y una de `BOTH_FACES_REQUIRED` queda sin cubrir con CUALQUIER valor.
3857
+ */
3858
+ export function uncoveredCapabilities(capabilities, covered) {
3859
+ return Object.keys(capabilities).filter((capability) => (capabilities[capability] || BOTH_FACES_REQUIRED.has(capability)) && !covered.has(capability));
3860
+ }
3861
+ /**
3862
+ * Capacidades cuyas DOS caras son obligatorias (L-2): declarar `false` sin
3863
+ * un caso `whenFalse` registrado lanza igual que declarar `true` sin
3864
+ * `whenTrue`. El guard general solo obliga a la cara `true` porque los pares
3865
+ * de nivel (`listDenies`, `purgeRole`…) no registran nada en un harness
3866
+ * `core` y `false` ahí es legítimo; `transactionalWrites` es composición del
3867
+ * manager y se observa en todos los niveles, así que su `false` sin caso es
3868
+ * un skip disfrazado — «rechazo activo» sería una etiqueta.
3869
+ */
3870
+ const BOTH_FACES_REQUIRED = new Set(['transactionalWrites']);
3636
3871
  //# sourceMappingURL=contract.js.map