@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.1

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 (277) hide show
  1. package/README.md +437 -48
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
  5. package/build/commands/authz_catalog_prune_orphans.js +28 -6
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +0 -1
  13. package/build/commands/authz_relations_reconcile.js +56 -8
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +54 -6
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +16 -3
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +10 -1
  23. package/build/commands/openfga_provision.js +43 -3
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +9 -7
  27. package/build/index.js +10 -3
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +27 -2
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +60 -3
  43. package/build/src/drivers/database_driver.js +160 -25
  44. package/build/src/drivers/database_relations_driver.d.ts +11 -5
  45. package/build/src/drivers/database_relations_driver.js +115 -33
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +882 -199
  48. package/build/src/drivers/openfga_facts.d.ts +91 -1
  49. package/build/src/drivers/openfga_facts.js +277 -3
  50. package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
  51. package/build/src/drivers/openfga_relations_driver.js +422 -228
  52. package/build/src/errors.d.ts +160 -1
  53. package/build/src/errors.js +161 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +9 -2
  65. package/build/src/identity.js +33 -1
  66. package/build/src/manager.d.ts +24 -1
  67. package/build/src/manager.js +381 -85
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +2 -3
  81. package/build/src/openfga.js +4 -2
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +0 -1
  87. package/build/src/relations/check_many.js +0 -1
  88. package/build/src/relations/define_relations_config.d.ts +25 -3
  89. package/build/src/relations/define_relations_config.js +8 -3
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +14 -7
  95. package/build/src/relations/manager.js +60 -15
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +58 -4
  99. package/build/src/relations/reconcile.js +266 -12
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +5 -4
  103. package/build/src/relations/tuple_order.js +5 -4
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +36 -3
  109. package/build/src/scope_outbox.js +78 -18
  110. package/build/src/shared/backend_guard.d.ts +52 -1
  111. package/build/src/shared/backend_guard.js +164 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +26 -3
  119. package/build/src/testing/contract.js +258 -20
  120. package/build/src/testing/main.d.ts +0 -1
  121. package/build/src/testing/main.js +0 -1
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +7 -1
  125. package/build/src/testing/relations_contract.js +174 -7
  126. package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
  127. package/build/src/testing/relations_reconcile_contract.js +0 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +138 -14
  139. package/build/src/types.js +6 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations/tuple_order.d.ts.map +0 -1
  239. package/build/src/relations/tuple_order.js.map +0 -1
  240. package/build/src/relations_config_store.d.ts.map +0 -1
  241. package/build/src/relations_config_store.js.map +0 -1
  242. package/build/src/relations_reconcile.d.ts.map +0 -1
  243. package/build/src/relations_reconcile.js.map +0 -1
  244. package/build/src/scope_outbox.d.ts.map +0 -1
  245. package/build/src/scope_outbox.js.map +0 -1
  246. package/build/src/shared/backend_guard.d.ts.map +0 -1
  247. package/build/src/shared/backend_guard.js.map +0 -1
  248. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  249. package/build/src/shared/sql_expiry.js.map +0 -1
  250. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  251. package/build/src/shared/transaction_guard.js.map +0 -1
  252. package/build/src/sql_descendants.d.ts.map +0 -1
  253. package/build/src/sql_descendants.js.map +0 -1
  254. package/build/src/testing/contract.d.ts.map +0 -1
  255. package/build/src/testing/contract.js.map +0 -1
  256. package/build/src/testing/main.d.ts.map +0 -1
  257. package/build/src/testing/main.js.map +0 -1
  258. package/build/src/testing/migration_contract.d.ts.map +0 -1
  259. package/build/src/testing/migration_contract.js.map +0 -1
  260. package/build/src/testing/relations_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_contract.js.map +0 -1
  262. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  263. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  264. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  265. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  266. package/build/src/testing/scope_tree.d.ts.map +0 -1
  267. package/build/src/testing/scope_tree.js.map +0 -1
  268. package/build/src/testing/test_clock.d.ts.map +0 -1
  269. package/build/src/testing/test_clock.js.map +0 -1
  270. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  271. package/build/src/traits/authz_scopes.js.map +0 -1
  272. package/build/src/traits/has_uuid.d.ts.map +0 -1
  273. package/build/src/traits/has_uuid.js.map +0 -1
  274. package/build/src/types.d.ts.map +0 -1
  275. package/build/src/types.js.map +0 -1
  276. package/build/stubs/main.d.ts.map +0 -1
  277. package/build/stubs/main.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, NoScopeResolverError, ScopeResolverError, UnknownScopeError, } from '../errors.js';
1
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, markDeploymentFailure, NoScopeResolverError, ResolutionLimitError, ScopeResolverError, UnknownScopeError, WriteConflictError, } from '../errors.js';
2
2
  import { APP_SCOPE, APP_SCOPE_TYPE } from '../types.js';
3
3
  import { assertScope, scopeSpellings } from '../identity.js';
4
4
  /**
@@ -120,10 +120,173 @@ export function isTimeoutLike(error) {
120
120
  return true;
121
121
  if (current.code === 'ECONNABORTED' || current.code === 'ETIMEDOUT')
122
122
  return true;
123
+ if (isBareFgaError(current))
124
+ return true;
123
125
  current = current.cause;
124
126
  }
125
127
  return false;
126
128
  }
129
+ /**
130
+ * **El `FgaError` SIN respuesta** (lote «antes de RC» · R3; F0 · M-d, nota 1 al auditor): el SDK 0.9.6 envuelve el
131
+ * timeout de axios —y también la conexión rechazada— en un `FgaError` genérico que NO conserva `code`, `cause` ni
132
+ * `statusCode` (fixtures `real-client-timeout`, `fake-client-timeout-slow-server`, `net-connection-refused`): solo
133
+ * `name` y `message`. Sin leer el mensaje (prohibido, decisión 2026-10-17 (3)) los dos NO se separan, así que se toma la
134
+ * clase CONSERVADORA: timeout —indeterminado—. Para el relay los dos son transitorios; para la auditoría
135
+ * (invariante 13) decir «quizá se escribió» de una conexión rechazada sobra, y callarlo de un timeout faltaría. Hasta R3
136
+ * solo lo salvaba que `withDeadline` arranca antes que el reloj del SDK. El nombre EXACTO `FgaError` (las subclases del
137
+ * SDK tienen el suyo) y SIN `statusCode`: con respuesta, lo decide `classifyFgaFailure`.
138
+ */
139
+ function isBareFgaError(candidate) {
140
+ return candidate?.name === 'FgaError' && (candidate.statusCode === undefined || candidate.statusCode === null);
141
+ }
142
+ /** El código de OpenFGA que dice «el grafo excede lo que resuelvo» (decisión 4: 503 con código propio). */
143
+ export const FGA_RESOLUTION_TOO_COMPLEX = 'authorization_model_resolution_too_complex';
144
+ /**
145
+ * **La LISTA BLANCA de códigos de despliegue** (F0 · M-d): solo códigos MEDIDOS. Un 400 con un código fuera de ella (y todo 422)
146
+ * es `unknown-4xx` (la clase `FgaApiValidationError` no basta: el 400 de código desconocido es la misma clase). Dentro de
147
+ * la clase no se separan sin el mensaje —y no hace falta, la decisión 3 los junta—: relación/tipo ausente del modelo
148
+ * fijado ↔ contexto ausente en la tupla ↔ `current_time` ausente (los tres `validation_error`), y store ↔ modelo ausente
149
+ * con pin (los dos `authorization_model_not_found`).
150
+ */
151
+ export const FGA_DEPLOYMENT_CODES = new Set([
152
+ 'validation_error',
153
+ 'authorization_model_not_found',
154
+ 'latest_authorization_model_not_found',
155
+ 'exceeded_entity_limit',
156
+ // `store_id_not_found` NO está (cierre de R3, ⚪-6 del tester): se midió solo como 404, que ya decide el status.
157
+ ]);
158
+ /** El código con el que OpenFGA dice que venció SU deadline (504 por el grpc-gateway; F0 · M-d `fake-504-deadline-exceeded`). */
159
+ const FGA_DEADLINE_EXCEEDED = 'deadline_exceeded';
160
+ /** El 400 de la carrera (`isDuplicateWrite`/`isTreeWriteRace` leen su sub-tipo; aquí basta el código). */
161
+ const FGA_RACE_CODE = 'write_failed_due_to_invalid_input';
162
+ /**
163
+ * Los errores del SDK que se lanzan SIN red (el cliente valida antes de llamar: un `authorizationModelId` que no es un
164
+ * ULID, un parámetro obligatorio ausente). Deterministas: despliegue. Es lo ÚNICO que se decide por `name`, porque sin
165
+ * respuesta no hay `statusCode` que mirar (la regla general es «por `statusCode` + `apiErrorCode`, nunca por la clase JS»:
166
+ * 408/409/418/501 son los cuatro `FgaApiError`).
167
+ */
168
+ const FGA_CLIENT_VALIDATION_NAMES = new Set(['FgaValidationError', 'FgaRequiredParamError']);
169
+ /**
170
+ * **`classifyFgaFailure(error)`**: la clase de un fallo de OpenFGA, por `statusCode` + `apiErrorCode` (lista blanca) o,
171
+ * en el error POR ÍTEM de un `batchCheck` 200, por la CLAVE del objeto `error` (`input_error`/`internal_error`). **Jamás
172
+ * lee `message` ni `apiErrorMessage`** (lo vigila la propiedad `fga.errors.classify` de la guardia; las ÚNICAS exenciones,
173
+ * por nombre y firmadas —decisión del dueño 2026-10-18 (5-bis)—, son `isDuplicateWrite`/`isTreeWriteRace`, que leen el
174
+ * SUB-tipo de la carrera «already exists» / «does not exist» y viven ENCIMA de esta clase). PUNTO DE PASO ÚNICO de los
175
+ * TRES sitios: `#guard` del driver de relaciones, `guardBackendErrors` y `alignBatchResults` (los dos drivers).
176
+ */
177
+ export function classifyFgaFailure(error, context = {}) {
178
+ const item = error;
179
+ if (item && typeof item === 'object' && item.statusCode === undefined && ('input_error' in item || 'internal_error' in item)) {
180
+ // El error POR ÍTEM de `batchCheck` (respuesta 200): no es una excepción del SDK, es un objeto de la respuesta.
181
+ if (typeof item.internal_error === 'string')
182
+ return { kind: 'transient', apiErrorCode: item.internal_error };
183
+ if (typeof item.input_error === 'string') {
184
+ const code = item.input_error;
185
+ if (code === FGA_RESOLUTION_TOO_COMPLEX)
186
+ return { kind: 'resolution-limit', apiErrorCode: code };
187
+ if (FGA_DEPLOYMENT_CODES.has(code))
188
+ return { kind: 'deployment', apiErrorCode: code };
189
+ return { kind: 'unknown-4xx', apiErrorCode: code };
190
+ }
191
+ return { kind: 'transient' };
192
+ }
193
+ if (isTimeoutLike(error))
194
+ return { kind: 'timeout' };
195
+ const responded = respondedFgaError(error);
196
+ if (responded === null) {
197
+ let current = error;
198
+ for (let depth = 0; current && depth < 6; depth++) {
199
+ if (typeof current.name === 'string' && FGA_CLIENT_VALIDATION_NAMES.has(current.name))
200
+ return { kind: 'deployment' };
201
+ current = current.cause;
202
+ }
203
+ return { kind: 'transient' };
204
+ }
205
+ const { status, apiErrorCode } = responded;
206
+ const failure = (kind) => ({ kind, status, ...(apiErrorCode !== undefined ? { apiErrorCode } : {}) });
207
+ // Cierre de R3 (🟡 R3-A2 del auditor; invariante 13: «una escritura que vence el deadline es indeterminada»): en una
208
+ // ESCRITURA, el deadline del SERVIDOR (`deadline_exceeded`) y el 504 de un proxy/gateway son de la familia TIMEOUT —el
209
+ // `Write` puede haberse confirmado aguas arriba después de que se cortara la respuesta—, así que salen 503
210
+ // `E_AUTHZ_BACKEND_TIMEOUT` y el manager notifica `indeterminate: true`. El resto de 5xx en una escritura conserva
211
+ // «no ocurrió» (el servidor contestó que falló); en una LECTURA el 504 sigue siendo transitorio (no hay nada que auditar).
212
+ if (context.write === true && (status === 504 || apiErrorCode === FGA_DEADLINE_EXCEEDED))
213
+ return failure('timeout');
214
+ if (status >= 500 || status === 408 || status === 429)
215
+ return failure('transient');
216
+ if (status === 409)
217
+ return failure('race');
218
+ if (status === 401 || status === 403 || status === 404)
219
+ return failure('deployment');
220
+ // Solo el 400 lleva códigos medidos (cierre de R3, ⚪-6 del tester: un 422 se midió solo con código desconocido, así
221
+ // que cualquier 422 es `unknown-4xx` —mismo error público que el despliegue: 500 marcado—).
222
+ if (status === 400) {
223
+ if (apiErrorCode === FGA_RACE_CODE)
224
+ return failure('race');
225
+ if (apiErrorCode === FGA_RESOLUTION_TOO_COMPLEX)
226
+ return failure('resolution-limit');
227
+ if (apiErrorCode !== undefined && FGA_DEPLOYMENT_CODES.has(apiErrorCode))
228
+ return failure('deployment');
229
+ return failure('unknown-4xx');
230
+ }
231
+ if (status >= 400)
232
+ return failure('unknown-4xx');
233
+ return failure('transient');
234
+ }
235
+ /** El primer error de la cadena de `cause` que trae una respuesta HTTP (`statusCode` numérico). */
236
+ function respondedFgaError(error) {
237
+ let current = error;
238
+ for (let depth = 0; current && depth < 6; depth++) {
239
+ if (typeof current.statusCode === 'number') {
240
+ return {
241
+ status: current.statusCode,
242
+ ...(typeof current.apiErrorCode === 'string' ? { apiErrorCode: current.apiErrorCode } : {}),
243
+ };
244
+ }
245
+ current = current.cause;
246
+ }
247
+ return null;
248
+ }
249
+ /**
250
+ * **El error PÚBLICO de un fallo de OpenFGA** (R3): `classifyFgaFailure` ⇒ la clase de la tabla de arriba. La causa
251
+ * original viaja SIEMPRE en `cause` (la carrera la leen `isDuplicateWrite`/`isTreeWriteRace` por la cadena). `timeoutMs`
252
+ * solo da la letra del timeout. `failure` deja pasar una clasificación ya hecha (el llamante que necesita mirarla antes).
253
+ */
254
+ export function fgaFailureError(driver, operation, error, options = {}) {
255
+ const failure = options.failure ?? classifyFgaFailure(error, { write: options.write });
256
+ const what = [failure.status !== undefined ? `HTTP ${failure.status}` : null, failure.apiErrorCode ?? null]
257
+ .filter((x) => x !== null)
258
+ .join(' ');
259
+ switch (failure.kind) {
260
+ case 'timeout': {
261
+ const timeout = new AuthorizationBackendTimeoutError(driver, operation, options.timeoutMs ?? 0, error);
262
+ if (failure.status !== undefined) {
263
+ // Con respuesta (504 / `deadline_exceeded` en una escritura): no es NUESTRO deadline, es el del servidor o el
264
+ // de un proxy intermedio. La escritura es INDETERMINADA (invariante 13), no «no ocurrió».
265
+ timeout.message =
266
+ `El backend de autorización '${driver}' venció un deadline aguas arriba en ${operation} (${what}): la ` +
267
+ 'escritura es INDETERMINADA —el servidor puede haberla confirmado después de cortarse la respuesta—.';
268
+ }
269
+ return timeout;
270
+ }
271
+ case 'transient':
272
+ return new AuthorizationBackendError(driver, operation, error);
273
+ case 'resolution-limit':
274
+ return new ResolutionLimitError(driver, operation, error);
275
+ case 'race':
276
+ return new WriteConflictError(`El backend de autorización '${driver}' rechazó ${operation} por una CARRERA con otro escritor (${what}): el ` +
277
+ 'estado del store no es el que esta escritura esperaba. Relee y reintenta la escritura.', { cause: error });
278
+ case 'deployment':
279
+ return markDeploymentFailure(new AuthorizationConfigError(`El backend de autorización '${driver}' rechazó ${operation} (${what || 'validación del cliente, sin red'}): el ` +
280
+ 'DESPLIEGUE no es el que espera la config —store o modelo inexistente (OPENFGA_STORE_ID/OPENFGA_MODEL_ID), ' +
281
+ 'credenciales, o un tipo, relación o contexto que el modelo fijado no admite—. No es transitorio: repetida, la ' +
282
+ 'operación vuelve a fallar igual hasta que se corrija el despliegue (openfga:provision publica el modelo de la ' +
283
+ 'config). Para el relay no consume intento.', { cause: error }));
284
+ case 'unknown-4xx':
285
+ return markDeploymentFailure(new AuthorizationConfigError(`El backend de autorización '${driver}' rechazó ${operation} con un 4xx que este paquete no conoce (${what}). ` +
286
+ 'No es transitorio (un 4xx dice «no reintentes»): mira la causa (cause) y la versión del servidor OpenFGA. ' +
287
+ 'Para el relay no consume intento.', { cause: error }));
288
+ }
289
+ }
127
290
  /**
128
291
  * Ejecuta una consulta SQL con deadline y traduce su fallo. El builder de
129
292
  * Lucid es perezoso (se ejecuta al hacer `await`), así que el `timeout` se
@@ -340,4 +503,3 @@ export async function assertKnownScope(resolver, scope, operation) {
340
503
  }
341
504
  return chain;
342
505
  }
343
- //# sourceMappingURL=backend_guard.js.map
@@ -52,4 +52,3 @@ export declare function parseUtcDatetime(value: string): Date | null;
52
52
  * de la identidad.
53
53
  */
54
54
  export declare function sqlExpiryCodec(connection?: any): ExpiryCodec;
55
- //# sourceMappingURL=sql_expiry.d.ts.map
@@ -67,4 +67,3 @@ function mysqlCodec(connection) {
67
67
  export function sqlExpiryCodec(connection = db.connection()) {
68
68
  return MYSQL_DIALECTS.has(dialectOf(connection)) ? mysqlCodec(connection) : identityCodec;
69
69
  }
70
- //# sourceMappingURL=sql_expiry.js.map
@@ -50,4 +50,3 @@ export interface CallerTransactionOwner {
50
50
  * (no llegó ninguna; el llamante decide qué hacer).
51
51
  */
52
52
  export declare function assertCallerTransaction(operation: string, transaction: unknown, owner: CallerTransactionOwner): CallerTransaction | undefined;
53
- //# sourceMappingURL=transaction_guard.d.ts.map
@@ -71,4 +71,3 @@ export function assertCallerTransaction(operation, transaction, owner) {
71
71
  }
72
72
  return trx;
73
73
  }
74
- //# sourceMappingURL=transaction_guard.js.map
@@ -102,4 +102,3 @@ export interface SqlScopeEdgesOptions {
102
102
  }
103
103
  export declare function sqlScopeEdges(options: SqlScopeEdgesOptions, database?: DatabaseLike): ScopeEdgesEnumerator;
104
104
  export {};
105
- //# sourceMappingURL=sql_descendants.d.ts.map
@@ -200,4 +200,3 @@ export function sqlScopeEdges(options, database = db) {
200
200
  : { edges, cursor: String(last.child_uuid) };
201
201
  };
202
202
  }
203
- //# sourceMappingURL=sql_descendants.js.map
@@ -132,8 +132,10 @@ export interface DriverCapabilities {
132
132
  * y MySQL, y es la promesa que el README hace.
133
133
  *
134
134
  * Con `false` (SQLite, que serializa bloqueando la BASE ENTERA) el juez
135
- * solo puede exigir lo innegociable: nunca dos ganadores y el perdedor no
136
- * escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
135
+ * solo puede exigir lo innegociable: nunca dos ganadores con el MISMO owner
136
+ * (el del ancestro puede ensombrecer al del descendiente: los dos finales
137
+ * legales de 3G · Y1, también en esta cara desde la rotación 2) y el
138
+ * perdedor no escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
137
139
  * legítimo. Declararlo `true` en un motor así convierte un flake en un
138
140
  * fallo; declararlo `false` en PG/MySQL deja pasar un mutante que
139
141
  * convierte la colisión en 503. Solo tiene casos en `level: '2.2'`.
@@ -200,6 +202,28 @@ export interface DriverCapabilities {
200
202
  * el destino los lee de ahí. Solo tiene casos en `level: '2.2'`.
201
203
  */
202
204
  enumerateFacts: boolean;
205
+ /**
206
+ * **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
209
+ * declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
210
+ */
211
+ experimental?: boolean;
212
+ /**
213
+ * **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`
216
+ * el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
217
+ * no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
218
+ * `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
219
+ * `rolesInChain`/`listScopes`/`listDenies` (el espía cuenta solo las llamadas del MANAGER: lo que `readSnapshot` llame
220
+ * por dentro no cuenta). Con `false`, que el manager NO llama a `readSnapshot` —aunque el driver traiga un método con
221
+ * ese nombre: decide la capacidad— y compone como antes restando los denies (el hueco declarado de un driver sin la
222
+ * capacidad); sin `listDenies` la cara se queda en la declaración y su título lo dice. Que la foto sea de verdad UNA no lo puede
223
+ * observar un juez genérico sin meterse en el backend: en `database` lo fijan los casos de entrelazado forzado y la
224
+ * guardia `database.decision.snapshot` del paquete.
225
+ */
226
+ snapshotDecisions?: boolean;
203
227
  }
204
228
  export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
205
229
  export interface DriverContractHarness {
@@ -280,4 +304,3 @@ export declare function registerAuthorizationDriverContract(harness: DriverContr
280
304
  * y una de `BOTH_FACES_REQUIRED` queda sin cubrir con CUALQUIER valor.
281
305
  */
282
306
  export declare function uncoveredCapabilities(capabilities: DriverCapabilities, covered: ReadonlySet<keyof DriverCapabilities>): (keyof DriverCapabilities)[];
283
- //# sourceMappingURL=contract.d.ts.map
@@ -1922,6 +1922,33 @@ export function registerAuthorizationDriverContract(harness, api) {
1922
1922
  await driver.revoke(alice, 'lead', unitA1);
1923
1923
  assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
1924
1924
  });
1925
+ since('2.2', 'un deny en el OWNER sigue ganando sobre su rol local mientras el scope sale del owner y vuelve: authorize false antes, fuera y de vuelta (invariantes 2 y 18; lote «antes de RC» · F4, F-1); sin el deny: true, false, true', async ({ assert, }) => {
1926
+ // F-1 del tester (`loteRC-6b-estudio-tester.md` §6): PARIDAD entre drivers del `moved` con un rol LOCAL y un deny
1927
+ // en su owner. En `database` no hay estado intermedio (la regla se evalúa en cada pregunta); en `openfga` el
1928
+ // barrido F4 borra el binding ANTES de escribir la arista, así que tampoco lo hay (el prefijo lo juzga la
1929
+ // propiedad `openfga.roles.moved.order` de la guardia). Aquí se observa el estado de reposo en los tres puntos.
1930
+ const denied = subject();
1931
+ const plain = subject();
1932
+ const orgA = await orgUnder(tree, APP_SCOPE);
1933
+ const orgB = await orgUnder(tree, APP_SCOPE);
1934
+ const unit = await unitUnder(tree, orgA);
1935
+ const leaf = await unitUnder(tree, unit);
1936
+ await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
1937
+ await driver.grant(denied, 'lead', unit);
1938
+ await driver.grant(plain, 'lead', unit);
1939
+ await driver.deny(denied, 'docs:write', orgA);
1940
+ const both = async () => [
1941
+ await driver.authorize(denied, 'docs:write', unit),
1942
+ await driver.authorize(denied, 'docs:write', leaf),
1943
+ await driver.authorize(plain, 'docs:write', unit),
1944
+ await driver.authorize(plain, 'docs:write', leaf),
1945
+ ];
1946
+ assert.deepEqual(await both(), [false, false, true, true], 'dentro del owner: el deny gana, sin él concede (también abajo)');
1947
+ await tree.move(unit, orgB);
1948
+ assert.deepEqual(await both(), [false, false, false, false], 'fuera del owner: el rol local no existe (y el deny ya no hace falta)');
1949
+ await tree.move(unit, orgA);
1950
+ assert.deepEqual(await both(), [false, false, true, true], 'de vuelta: restaura lo concedido y el deny sigue ganando');
1951
+ });
1925
1952
  since('2.2', 'la visibilidad se decide POR NIVEL, no por el conjunto de la cadena: si el árbol se recuelga y el owner queda POR DEBAJO del scope de la asignación, esa asignación deja de contar aunque el owner siga en la cadena de la pregunta', async ({ assert, }) => {
1926
1953
  // 3B · B2 (desviación 2 del lote, sin caso hasta aquí). La regla es
1927
1954
  // «una asignación en el scope S cuenta si su rol EXISTE EN S»: el owner
@@ -2072,6 +2099,35 @@ export function registerAuthorizationDriverContract(harness, api) {
2072
2099
  assert.isFalse(await driver.authorize(victima, 'docs:write', unitU1), 'el rol de A ya no existe aquí');
2073
2100
  assert.isTrue(await driver.hasRole(victima, 'lead', unitU1));
2074
2101
  });
2102
+ since('2.2', 'K4 · revoke por SLUG decide con el catálogo EN FRESCO aunque el memo sirva con ventana { everyMs }: un rol local recién definido en OTRO proceso con el slug de uno global también se revoca (nunca un «revocado» mudo)', async ({ assert, }) => {
2103
+ // Asentar · cierre 2 (D-1 del re-ataque del cierre 1; gemelo del 🟡 5 de `openfga`). `{ everyMs }` es una ventana
2104
+ // de DECISIÓN (invariante 14); una ESCRITURA decide con la versión en fresco (K4). Con el memo de la ventana,
2105
+ // `revoke(alice, 'unit-editor', unit)` solo veía el global y el DELETE no tocaba la asignación del local homónimo:
2106
+ // seguía concediendo, en silencio y para siempre (al caducar la ventana, también en este proceso). La ventana solo se
2107
+ // construye si `driver.catalog` es un `CatalogCache` (los dos drivers del paquete): un driver con memo PROPIO corre
2108
+ // el caso SIN ventana y el K4 de `revoke` NO queda juzgado para él (cierre 3 · 🟡 B del tester: es su deber escrito,
2109
+ // no juzgado; un hook de harness para su memo sería contrato nuevo).
2110
+ const alice = subject();
2111
+ const orgA = await orgUnder(tree, APP_SCOPE);
2112
+ const unit = await unitUnder(tree, orgA);
2113
+ const memo = new CatalogCache({ revalidate: { everyMs: 3_600_000 } });
2114
+ let windowed = driver;
2115
+ if (driver.catalog instanceof CatalogCache) {
2116
+ windowed = Object.create(driver);
2117
+ Object.defineProperty(windowed, 'catalog', { value: memo, enumerable: true });
2118
+ await memo.view();
2119
+ }
2120
+ // «Otro proceso» define el local con el slug del global `unit-editor` (fila + versión, sin señal en memoria).
2121
+ const local = await localRole(orgA, { slug: 'unit-editor', scopeType: 'unit', permissions: ['docs:write'] });
2122
+ await driver.grant(alice, { uuid: local }, unit);
2123
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
2124
+ if (windowed !== driver) {
2125
+ assert.isNull((await memo.view()).roleByUuid(local), 'la ventana del memo sigue sirviendo la foto SIN el local (si no, el caso no juzga nada)');
2126
+ }
2127
+ await windowed.revoke(alice, 'unit-editor', unit);
2128
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'la asignación del local homónimo también se revoca');
2129
+ assert.isFalse(await driver.hasRole(alice, { uuid: local }, unit));
2130
+ });
2075
2131
  since('2.2', 'paridad entre drivers (3D · N1): una asignación cuyo rol está declarado para OTRO nivel no cuenta en ninguno de los dos — ni concede, ni es membresía, ni se enumera — y vuelve a contar en cuanto el nivel del rol coincide otra vez', async ({ assert, }) => {
2076
2132
  // Desviación 3 del lote 3B («`database.authorize` exige además
2077
2133
  // `r.scope_type = a.scope_type`, como ya hacía `openfga`») era una
@@ -2961,7 +3017,7 @@ export function registerAuthorizationDriverContract(harness, api) {
2961
3017
  // dejaba vivo un mutante que convertía la colisión en un 503.
2962
3018
  const raceOfDefines = (serialized) => since('2.2', serialized
2963
3019
  ? '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'
2964
- : '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 }) => {
3020
+ : '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 }) => {
2965
3021
  // 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
2966
3022
  // porque el unique de la base es (slug, scope_type, owner_scope_key)
2967
3023
  // y cada uno comprobaba la colisión contra SU foto del memo). Ahora
@@ -3004,11 +3060,21 @@ export function registerAuthorizationDriverContract(harness, api) {
3004
3060
  assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
3005
3061
  }
3006
3062
  else {
3007
- // Sin serialización garantizada: nunca dos. Cuántos confirman
3008
- // depende del motor — SQLite bloquea la base entera y el segundo
3009
- // escritor puede recibir `SQLITE_BUSY`, un fallo de backend
3010
- // legítimo (503) porque su transacción NO se aplicó.
3011
- assert.isAtMost(ok.length, 1, `nunca dos ganadores (${estado})`);
3063
+ // Sin serialización garantizada cuántos confirman depende del
3064
+ // motor — SQLite bloquea la base entera y el segundo escritor
3065
+ // puede recibir `SQLITE_BUSY`, un fallo de backend legítimo (503)
3066
+ // porque su transacción NO se aplicó—, así que cero también vale.
3067
+ // Y los mismos DOS finales legales que la cara `true` (rotación 2 ·
3068
+ // cierre 2 · T-07): desde R2-05 el re-chequeo en la transacción
3069
+ // decide por AUTORIDAD, así que si el del descendiente confirma
3070
+ // mientras el del ancestro valida, el del ancestro ENTRA y lo
3071
+ // ensombrece (final 2 de 3G · Y1). Hasta alpha.4 aquí se afirmaba
3072
+ // «como mucho uno», que un backend sin el bloqueo de SQLite podía
3073
+ // poner rojo sin defecto (aserción flaky en el artefacto
3074
+ // publicado). Lo innegociable se afirma abajo igual que en la
3075
+ // cara `true`: nunca dos con el MISMO owner y, si son dos, el
3076
+ // extra es el del ANCESTRO.
3077
+ assert.isAtMost(ok.length, 2, `nunca dos ganadores con el MISMO owner (${estado})`);
3012
3078
  }
3013
3079
  for (const rejected of results.filter((r) => r.status === 'rejected')) {
3014
3080
  const error = rejected.reason;
@@ -3105,22 +3171,18 @@ export function registerAuthorizationDriverContract(harness, api) {
3105
3171
  await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
3106
3172
  await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
3107
3173
  assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
3108
- // Y la rama del re-chequeo que NO se puede juzgar ahí dentro (3F ·
3109
- // U3, tester 3E · §4.2): un homónimo cuyo owner no es global ni
3110
- // ancestro-o-igual del owner pedido —una unit HERMANA— no se
3111
- // clasifica sin resolver su cadena, y resolverla con el cerrojo del
3112
- // catálogo sostenido sería un abrazo mortal con un pool de 1. Se
3113
- // rechaza igual, con el 422 que pide REINTENTAR, y no escribe.
3114
- // Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
3115
- // en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
3116
- // SERIE (el memo se recalienta primero, así que la fila nueva le es
3117
- // invisible y solo la relectura de la BASE la ve).
3174
+ // Y la rama del re-chequeo de un owner que NO es global ni ancestro-o-igual del owner pedido —una unit
3175
+ // HERMANA— (3F · U3, tester 3E · §4.2; volteada en la rotación 2 de M4 · R2-05). Hasta 2.5.0-alpha.4 se
3176
+ // rechazaba con un 422 «reintenta» que NOMBRABA el owner del hermano (otro tenant) y la colisión era falsa:
3177
+ // dos hermanos pueden compartir slug. Ahora se juzga con la MISMA regla de autoridad que la comprobación
3178
+ // previa y entra. EN SERIE: el memo se recalienta primero, así que la fila nueva le es invisible y solo la
3179
+ // relectura de la BASE la ve.
3118
3180
  const unitA2 = await unitUnder(tree, orgA);
3119
3181
  assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
3120
3182
  await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
3121
- const cambio = await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' }), conflicto);
3122
- assert.include(String(cambio?.message), 'El catálogo cambió mientras se validaba');
3123
- assert.lengthOf(await rowsNamed('lateral', 'unit'), 1, 'el 422 genérico tampoco escribe a ciegas');
3183
+ const lateral = await here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' });
3184
+ assert.equal(lateral.owner, scopeKey(unitA1), 'el homónimo de un HERMANO no es colisión: entra');
3185
+ assert.lengthOf(await rowsNamed('lateral', 'unit'), 2, 'los dos hermanos tienen su lateral@unit');
3124
3186
  });
3125
3187
  caseFor('serializedCatalogWrites', {
3126
3188
  whenTrue: () => raceOfDefines(true),
@@ -3738,6 +3800,183 @@ export function registerAuthorizationDriverContract(harness, api) {
3738
3800
  tree.chainOf = original;
3739
3801
  }
3740
3802
  }
3803
+ // ── Estrategia 2.5.0 · firma 4 · `experimental` (regla D15) ─────────
3804
+ // La capacidad DICE algo que el consumidor tiene que saber —«este driver está fuera de la garantía de la versión»— y
3805
+ // no apaga ningún caso: el juez corre ENTERO contra un driver experimental. Lo que se observa es la declaración: la
3806
+ // del driver y la copia normalizada que publica `authorization.capabilities()`, en las DOS caras.
3807
+ caseFor('experimental', {
3808
+ whenTrue: () => {
3809
+ test('experimental:true · el driver se declara EXPERIMENTAL (capabilities.experimental === true) y `authorization.capabilities()` lo publica tal cual; el juez corre igual contra él', async ({ assert, }) => {
3810
+ assert.strictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: true y el driver no: declara lo observable');
3811
+ const published = await managerOver().capabilities();
3812
+ assert.strictEqual(published.experimental, true);
3813
+ assert.isTrue(Object.isFrozen(published));
3814
+ });
3815
+ },
3816
+ whenFalse: () => {
3817
+ test('experimental:false · el driver NO se declara experimental (ausente o false) y `authorization.capabilities().experimental` es false, nunca undefined (D15)', async ({ assert, }) => {
3818
+ assert.notStrictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: false y el driver declara true: declara lo observable');
3819
+ const published = await managerOver().capabilities();
3820
+ assert.strictEqual(published.experimental, false);
3821
+ });
3822
+ },
3823
+ });
3824
+ // ── Rotación 2 · cierre 3 · `snapshotDecisions` (regla D15, linaje L12) ─────────
3825
+ // Una decisión que combina lo que el holder TIENE con lo que tiene DENEGADO, leída en dos métodos del puerto sin
3826
+ // foto común, puede responder lo que ningún estado real responde (un offboarding entre las dos lecturas). La
3827
+ // capacidad dice que el driver lee las dos mitades de una vez; lo que el juez observa es el MÉTODO (`readSnapshot`):
3828
+ // su respuesta es la de los métodos homónimos, y el manager lo usa en vez de componer.
3829
+ const snapshotSeed = async () => {
3830
+ const alice = subject();
3831
+ const orgA = await orgUnder(tree, APP_SCOPE);
3832
+ const unitA1 = await unitUnder(tree, orgA);
3833
+ await driver.grant(alice, 'org-editor', orgA);
3834
+ await driver.grant(alice, 'unit-editor', unitA1);
3835
+ await driver.grant(alice, 'viewer', APP_SCOPE);
3836
+ await driver.deny(alice, 'docs:read', unitA1);
3837
+ await driver.deny(alice, 'billing:read', orgA);
3838
+ return { alice, orgA, unitA1 };
3839
+ };
3840
+ const canonical = (items) => items.map((item) => JSON.stringify(item)).sort();
3841
+ /**
3842
+ * El espía del par (rotación 2 · cierre 3b, tester 🟡 6): cuenta SOLO las llamadas que hace el MANAGER al driver. Cada
3843
+ * método se reenvía con `this` = el driver de verdad (no el proxy), así que lo que un `readSnapshot` llame por dentro
3844
+ * (`this.rolesInChain()` dentro de una transacción REPEATABLE READ, p. ej.) no se cuenta: el juez fija el contrato
3845
+ * (UNA llamada del manager), no la implementación.
3846
+ */
3847
+ const watchManagerCalls = (target) => {
3848
+ const touched = [];
3849
+ // El proxy va sobre un objeto VACÍO (no sobre el driver): un driver congelado o con métodos no configurables no
3850
+ // choca con las invariantes de `Proxy`, y todo se lee del driver de verdad. Una VISTA que devuelve el driver
3851
+ // (`withChainResolver`, `withClock`: el manager las pide por request) se vigila igual, con la misma cuenta.
3852
+ const watch = (real) => new Proxy({}, {
3853
+ get: (_shell, prop) => {
3854
+ const value = Reflect.get(real, prop, real);
3855
+ if (typeof value === 'function' && typeof prop === 'string') {
3856
+ return (...args) => {
3857
+ touched.push(prop);
3858
+ const out = value.apply(real, args);
3859
+ const isView = out !== null && typeof out === 'object' && typeof out.then !== 'function' && typeof out.authorize === 'function';
3860
+ return isView ? watch(out) : out;
3861
+ };
3862
+ }
3863
+ return value;
3864
+ },
3865
+ has: (_shell, prop) => prop in real,
3866
+ });
3867
+ const watched = watch(target);
3868
+ const reads = () => touched.filter((name) => ['readSnapshot', 'rolesInChain', 'listScopes', 'listDenies', 'listRoles'].includes(name));
3869
+ return { watched, touched, reads };
3870
+ };
3871
+ /**
3872
+ * La parte de DELEGACIÓN de las dos caras (rotación 2 · cierre 3c, A/J1 del auditor y del tester): la policy de
3873
+ * `defineScopedRole` solo se juzga si el harness declara `purgeRole: true`. Sin `purgeRole` el manager no delega
3874
+ * (`defineScopedRole` es 500 `E_AUTHZ_UNSUPPORTED` ANTES de la policy, 3E · P4), y `purgeRole` es un par de `'2.2'`:
3875
+ * un tercero `'2.1'` con `listDenies` y sin `purgeRole` —legítimo, D15— salía ROJO con un caso nuevo. Decide la
3876
+ * DECLARACIÓN del harness, no la presencia del método, por el patrón del juez (como `listDenies` aquí mismo): una
3877
+ * declaración `true` solo es posible en `'2.2'` (en `'2.1'` el juez lanza al registrar un `true` sin caso) y ahí la
3878
+ * juzga su propio par —método presente y purga total, o ausente y 500—, mientras que un método presente en un
3879
+ * harness `'2.1'` no lo juzga nadie. El título de cada cara dice cuál de las dos se registró.
3880
+ */
3881
+ const snapshotDelegation = harness.capabilities.purgeRole === true;
3882
+ caseFor('snapshotDecisions', {
3883
+ whenTrue: () => {
3884
+ 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, }) => {
3885
+ assert.typeOf(driver.readSnapshot, 'function', 'snapshotDecisions: true exige readSnapshot en el puerto');
3886
+ assert.strictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: true y el driver no');
3887
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, true);
3888
+ const { alice, unitA1 } = await snapshotSeed();
3889
+ const chain = (await tree.chainOf(unitA1));
3890
+ const all = await driver.readSnapshot(alice, { rolesInChain: chain, listScopes: 'docs:write', listDenies: true });
3891
+ assert.sameMembers(Object.keys(all), ['rolesInChain', 'listScopes', 'listDenies']);
3892
+ assert.deepEqual(canonical(all.rolesInChain), canonical(await driver.rolesInChain(alice, chain)));
3893
+ assert.deepEqual(canonical(all.listScopes), canonical(await driver.listScopes(alice, 'docs:write')));
3894
+ assert.deepEqual(canonical(all.listDenies), canonical(await driver.listDenies(alice)));
3895
+ assert.lengthOf(all.listDenies, 2);
3896
+ assert.isAbove(all.rolesInChain.length, 0);
3897
+ // Con el deny de docs:read en la unit, listScopes de docs:read no lista la unit (ni lo que bloquea).
3898
+ const reads = await driver.readSnapshot(alice, { listScopes: 'docs:read' });
3899
+ assert.deepEqual(Object.keys(reads), ['listScopes'], 'lo no pedido no viene');
3900
+ assert.deepEqual(canonical(reads.listScopes), canonical(await driver.listScopes(alice, 'docs:read')));
3901
+ assert.deepEqual(await driver.readSnapshot(alice, { listScopes: 'no:existe' }), { listScopes: [] });
3902
+ for (const bad of [{}, { foo: true }, { listDenies: false }, { rolesInChain: 'x' }, null]) {
3903
+ await rejectsWith(assert, () => driver.readSnapshot(alice, bad), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
3904
+ }
3905
+ });
3906
+ since('2.1', snapshotDelegation
3907
+ ? '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'
3908
+ : '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, }) => {
3909
+ const { alice, orgA, unitA1 } = await snapshotSeed();
3910
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
3911
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
3912
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write']);
3913
+ assert.deepEqual(reads(), ['readSnapshot']);
3914
+ touched.length = 0;
3915
+ // docs:write: org-editor en la org y unit-editor en la unit (viewer en app solo da docs:read) ⇒ la unit.
3916
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
3917
+ assert.deepEqual(reads(), ['readSnapshot']);
3918
+ touched.length = 0;
3919
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
3920
+ assert.deepEqual(reads(), ['readSnapshot']);
3921
+ if (!snapshotDelegation)
3922
+ return;
3923
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
3924
+ // La policy de delegación: el actor tiene docs:read DENEGADO en la unit (owner) ⇒ 422 con UNA foto.
3925
+ const admin = subject();
3926
+ await driver.grant(admin, 'org-admin', orgA);
3927
+ await driver.deny(admin, 'docs:read', orgA);
3928
+ touched.length = 0;
3929
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
3930
+ status: 422,
3931
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
3932
+ });
3933
+ assert.deepEqual(reads(), ['readSnapshot']);
3934
+ });
3935
+ },
3936
+ whenFalse: () => {
3937
+ // Rotación 2 · cierre 3b (H3 del auditor): el manager decide por la CAPACIDAD, no por la presencia del método, así
3938
+ // que esta cara NO exige que el puerto no traiga `readSnapshot` (un tercero puede tener un método con ese nombre
3939
+ // para otra cosa): exige que el manager NO lo llame. Sin `listDenies` no hay composición que juzgar y la cara
3940
+ // se queda en la declaración —lo dice su título (tester 🟡 6)—.
3941
+ if (!harness.capabilities.listDenies) {
3942
+ 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, }) => {
3943
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
3944
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
3945
+ });
3946
+ return;
3947
+ }
3948
+ since('2.1', snapshotDelegation
3949
+ ? '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)'
3950
+ : '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, }) => {
3951
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
3952
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
3953
+ const { alice, orgA, unitA1 } = await snapshotSeed();
3954
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
3955
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
3956
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write'], 'el deny de docs:read en la unit se resta');
3957
+ assert.include(reads(), 'listDenies');
3958
+ assert.notInclude(reads(), 'readSnapshot');
3959
+ touched.length = 0;
3960
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
3961
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
3962
+ assert.include(reads(), 'listDenies');
3963
+ assert.notInclude(reads(), 'readSnapshot');
3964
+ if (!snapshotDelegation)
3965
+ return;
3966
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
3967
+ const admin = subject();
3968
+ await driver.grant(admin, 'org-admin', orgA);
3969
+ await driver.deny(admin, 'docs:read', orgA);
3970
+ touched.length = 0;
3971
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
3972
+ status: 422,
3973
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
3974
+ });
3975
+ assert.include(reads(), 'listDenies');
3976
+ assert.notInclude(reads(), 'readSnapshot');
3977
+ });
3978
+ },
3979
+ });
3741
3980
  // ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
3742
3981
  caseFor('roleInheritanceNative', {
3743
3982
  whenFalse: () => {
@@ -3868,4 +4107,3 @@ export function uncoveredCapabilities(capabilities, covered) {
3868
4107
  * un skip disfrazado — «rechazo activo» sería una etiqueta.
3869
4108
  */
3870
4109
  const BOTH_FACES_REQUIRED = new Set(['transactionalWrites']);
3871
- //# sourceMappingURL=contract.js.map
@@ -20,4 +20,3 @@ export { runRelationsReconcileContract, registerRelationsReconcileContract, } fr
20
20
  export type { RelationsReconcileHarness } from './relations_reconcile_contract.js';
21
21
  export { runScopeOutboxContract, registerScopeOutboxContract, makeScopeOutbox, SCOPE_OUTBOX_CAPABILITY_KEYS, } from './scope_outbox_contract.js';
22
22
  export type { ScopeOutboxCapabilities, ScopeOutboxContractHarness, ScopeOutboxContractTransactions, MakeScopeOutboxOptions, } from './scope_outbox_contract.js';
23
- //# sourceMappingURL=main.d.ts.map
@@ -13,4 +13,3 @@ export { runRelationsDriverContract, registerRelationsDriverContract, makeRelati
13
13
  export { createTestClock } from './test_clock.js';
14
14
  export { runRelationsReconcileContract, registerRelationsReconcileContract, } from './relations_reconcile_contract.js';
15
15
  export { runScopeOutboxContract, registerScopeOutboxContract, makeScopeOutbox, SCOPE_OUTBOX_CAPABILITY_KEYS, } from './scope_outbox_contract.js';
16
- //# sourceMappingURL=main.js.map
@@ -290,4 +290,3 @@ export declare function runMigrationDirection(harness: MigrationContractHarness,
290
290
  */
291
291
  export declare function runMigrationContract(harness: MigrationContractHarness): void;
292
292
  export declare function registerMigrationContract(harness: MigrationContractHarness, api: ContractTestApi): void;
293
- //# sourceMappingURL=migration_contract.d.ts.map