@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.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.
Files changed (214) hide show
  1. package/README.md +693 -36
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +97 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +313 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +50 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +45 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +55 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +137 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +128 -7
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +510 -24
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +113 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +679 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +729 -122
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2083 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +384 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +836 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +590 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +290 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +296 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +141 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +217 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +330 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1285 -188
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +7 -7
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +7 -7
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +7 -7
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +7 -7
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +7 -7
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +93 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +75 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +173 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +60 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +213 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +111 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +200 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +298 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/shared/transaction_guard.d.ts +50 -0
  158. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  159. package/build/src/shared/transaction_guard.js +60 -0
  160. package/build/src/shared/transaction_guard.js.map +1 -0
  161. package/build/src/sql_descendants.d.ts +47 -1
  162. package/build/src/sql_descendants.d.ts.map +1 -1
  163. package/build/src/sql_descendants.js +75 -1
  164. package/build/src/sql_descendants.js.map +1 -1
  165. package/build/src/testing/contract.d.ts +129 -4
  166. package/build/src/testing/contract.d.ts.map +1 -1
  167. package/build/src/testing/contract.js +912 -177
  168. package/build/src/testing/contract.js.map +1 -1
  169. package/build/src/testing/main.d.ts +8 -2
  170. package/build/src/testing/main.d.ts.map +1 -1
  171. package/build/src/testing/main.js +4 -1
  172. package/build/src/testing/main.js.map +1 -1
  173. package/build/src/testing/migration_contract.d.ts +284 -0
  174. package/build/src/testing/migration_contract.d.ts.map +1 -0
  175. package/build/src/testing/migration_contract.js +586 -0
  176. package/build/src/testing/migration_contract.js.map +1 -0
  177. package/build/src/testing/relations_contract.d.ts +73 -0
  178. package/build/src/testing/relations_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_contract.js +1069 -0
  180. package/build/src/testing/relations_contract.js.map +1 -0
  181. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  182. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  183. package/build/src/testing/relations_reconcile_contract.js +220 -0
  184. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  185. package/build/src/traits/authz_scopes.js +1 -1
  186. package/build/src/traits/authz_scopes.js.map +1 -1
  187. package/build/src/traits/has_uuid.d.ts +8 -8
  188. package/build/src/traits/has_uuid.d.ts.map +1 -1
  189. package/build/src/types.d.ts +1053 -83
  190. package/build/src/types.d.ts.map +1 -1
  191. package/build/src/types.js +10 -0
  192. package/build/src/types.js.map +1 -1
  193. package/build/stubs/config/authorization.stub +104 -4
  194. package/build/stubs/migration.stub +136 -0
  195. package/build/stubs/scopes_outbox_migration.stub +57 -0
  196. package/package.json +4 -2
  197. package/build/commands/openfga_import.d.ts +0 -34
  198. package/build/commands/openfga_import.d.ts.map +0 -1
  199. package/build/commands/openfga_import.js +0 -97
  200. package/build/commands/openfga_import.js.map +0 -1
  201. package/build/src/catalog.d.ts.map +0 -1
  202. package/build/src/catalog.js.map +0 -1
  203. package/build/src/catalog_cache.d.ts.map +0 -1
  204. package/build/src/catalog_cache.js.map +0 -1
  205. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  206. package/build/src/drivers/backend_guard.js.map +0 -1
  207. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  208. package/build/src/drivers/sql_expiry.js.map +0 -1
  209. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  210. package/build/src/middleware/app_access_middleware.js.map +0 -1
  211. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  212. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  213. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  214. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -0,0 +1,836 @@
1
+ import { InvalidIdentityError, InvalidSlugError, ModelTooLargeError, AuthorizationConfigError, RelationConfigError, } from '../errors.js';
2
+ import { slugAsRelation, RESERVED_SLUG_PREFIXES, RESERVED_FACTS_TYPES } from '../identity.js';
3
+ import { APP_SCOPE_TYPE } from '../types.js';
4
+ /**
5
+ * Modo `facts` — el MODELO (c2) y la proyección del catálogo, sin `@openfga/sdk`.
6
+ *
7
+ * Aquí no hay cliente ni decisiones: es la pieza pura que traduce
8
+ * `holderTypes` + los permisos del catálogo al authorization model que el
9
+ * store publica, y los vínculos rol→permiso a las tuplas que lo materializan.
10
+ * Vive aparte del driver para poder juzgarse sin servidor y sin SDK.
11
+ *
12
+ * El modelo es el que fijó el panel 2 (cruce 1, variante **(c2)** del
13
+ * analista, 53/53 invariantes medidos contra OpenFGA v1.19), con la relación
14
+ * `rooted` que le añadió el diseño **(c2r)** (`fase-3b-diseno-r1.md`, medido
15
+ * contra el `:8101`). No se rediseña:
16
+ *
17
+ * ```
18
+ * type user / admin / integration # los holderTypes del consumidor
19
+ * type role # id = roleUuid
20
+ * define permits_<P>: [user:*, admin:*, integration:*]
21
+ * type role_binding # id = <scopeKey>|<roleUuid>
22
+ * define role: [role]
23
+ * define assignee: [<holders> with not_expired]
24
+ * define <P>: assignee and permits_<P> from role
25
+ * type scope # id = 'app' | '<tipo>|<uuid>'
26
+ * define parent: [scope]
27
+ * define binding: [role_binding]
28
+ * define <P>: <P> from binding or <P> from parent
29
+ * define denied_<P>: [<holders>] or denied_<P> from parent
30
+ * define rooted: [user:*, admin:*, integration:*] or rooted from parent
31
+ * define can_<P>: (<P> but not denied_<P>) and rooted
32
+ * define ancestor: parent or ancestor from parent # isWithin/descendantsOf nativos, 0 tuplas
33
+ * ```
34
+ *
35
+ * Por qué (c2) y no (c1) —catálogo por scope— (cruce 1): un cambio de
36
+ * catálogo son 3 deletes en UN `Write` atómico en vez de O(scopes) requests
37
+ * no atómicos, `attached` escribe 1 tupla en vez de 1+K, y el `verify` del
38
+ * catálogo es O(roles×permisos×holders) en vez de O(scopes×…). Se paga con
39
+ * ~0,8 ms por `authorize` y con un techo de permisos más bajo (**≈450 con
40
+ * slugs realistas**; ver `FACTS_MODEL_MAX_BYTES` para de qué depende esa
41
+ * cifra: no es una propiedad del modelo).
42
+ *
43
+ * **Por qué `rooted`** (3b-2i, cierre del 🔴 1 del auditor R2): sin ella, un
44
+ * scope cuya cadena hasta `app` se rompe —`scopes.detached` de un nodo
45
+ * INTERMEDIO, o un nodo que el consumidor nunca notificó— seguía concediendo
46
+ * lo que tuviera colgado y dejaba de heredar los denies de arriba. O sea que
47
+ * `detached` de un nodo propio funcionaba como un `removeDeny` masivo del
48
+ * subárbol, con las barreras de `within` intactas (medido: `removeDeny` 422,
49
+ * `grant` 422, `detached` OK y después `authorize` = `true` con el deny
50
+ * todavía escrito). `rooted` es la ALCANZABILIDAD DE LA RAÍZ materializada
51
+ * por el propio modelo: solo `scope:app` la tiene directa (el *marcador de
52
+ * raíz*, `factsRootTuples`) y todo lo demás la hereda por `parent`, igual que
53
+ * `denied_<P>`. Al volver `can_<P>` una intersección con ella, un subárbol
54
+ * desgajado deja de conceder **sin enumerar nada y sin una sola tupla por
55
+ * scope**. Medido: `authorize` sigue siendo UN solo `Check`, el techo de
56
+ * profundidad no se mueve (22) y el de tamaño baja un ~4 % (con el catálogo
57
+ * de referencia —3 holder types `user`/`admin`/`integration` y slugs
58
+ * `p0`…`pN`— de 721 a 691 permisos).
59
+ */
60
+ /** Nombre de tipo admitido por FGA (`^[^:#@\s]{1,254}$`). */
61
+ const FGA_TYPE_FORMAT = /^[^:#@\s]{1,254}$/;
62
+ /**
63
+ * `holderTypes` tiene que ser INYECTIVO. Si dos morph names caen en el mismo
64
+ * tipo FGA, para el store son un solo holder: un grant a `users:U` autoriza a
65
+ * `integrations:U`, `listSubjects` devuelve el morph equivocado y un revoke
66
+ * borra al otro (invariante 4, L0.2). El generador del modelo lo "sabía"
67
+ * (deduplicaba con un Set) y publicaba sin quejarse: ahora lanza aquí, al
68
+ * construir el driver y al generar cualquiera de los dos modelos, antes de
69
+ * tocar nada.
70
+ */
71
+ export function assertHolderTypes(holderTypes) {
72
+ if (!holderTypes || typeof holderTypes !== 'object' || Object.keys(holderTypes).length === 0) {
73
+ throw new AuthorizationConfigError('holderTypes vacío: el driver openfga necesita al menos un holder (morph name → tipo FGA)');
74
+ }
75
+ const morphsByFgaType = new Map();
76
+ for (const [morph, fgaType] of Object.entries(holderTypes)) {
77
+ if (typeof fgaType !== 'string' || !FGA_TYPE_FORMAT.test(fgaType)) {
78
+ throw new AuthorizationConfigError(`holderTypes['${morph}'] = ${JSON.stringify(fgaType)} no es un tipo FGA válido ` +
79
+ `(1-254 caracteres, sin ':', '#', '@' ni espacios)`);
80
+ }
81
+ morphsByFgaType.set(fgaType, [...(morphsByFgaType.get(fgaType) ?? []), morph]);
82
+ }
83
+ const collisions = [...morphsByFgaType.entries()].filter(([, morphs]) => morphs.length > 1);
84
+ if (collisions.length) {
85
+ throw new AuthorizationConfigError(`holderTypes no es inyectivo: ` +
86
+ collisions.map(([fga, morphs]) => `${morphs.join(' y ')} → '${fga}'`).join('; ') +
87
+ `. Dos holders con el mismo tipo FGA serían uno solo para el store.`);
88
+ }
89
+ }
90
+ /* ── Cotas del servidor (cruce 9 del panel) ─────────────────────────────── */
91
+ /**
92
+ * Longitud máxima de un nombre de relación en FGA. `MAX_SLUG_LENGTH` (42) es
93
+ * justo esto menos el prefijo derivado más largo (`permits_`), así que un
94
+ * slug legal siempre cabe: la comprobación de aquí es la red del generador,
95
+ * que también lo llaman herramientas con listas de permisos que no pasaron
96
+ * por `assertValidSlug` (una base sincronizada por otra versión, un driver
97
+ * de terceros).
98
+ */
99
+ export const FGA_MAX_RELATION_NAME = 50;
100
+ /**
101
+ * Longitud máxima del id de un objeto FGA. Con la gramática publicada nada
102
+ * se acerca (`role_binding:<tipo>|<uuid>|<roleUuid>` son 107 como mucho),
103
+ * pero el id se compone de partes que vienen de la BASE: un catálogo
104
+ * corrupto no puede fabricar un objeto que el store no pueda leer de vuelta.
105
+ */
106
+ export const FGA_MAX_OBJECT_ID = 256;
107
+ /**
108
+ * Techo del authorization model (default de
109
+ * `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`). **El techo son BYTES; el
110
+ * número de permisos que caben NO es una propiedad del modelo** (3b-4 · C3):
111
+ * depende de cuántos holder types declaras, de cuánto miden sus NOMBRES en el
112
+ * modelo y de cuánto miden los slugs de tus permisos. Medido con
113
+ * `factsModelBytes` —que cuenta los mismos bytes que el servidor, contrastado
114
+ * contra un `:8101` real— y fijado por un caso (`3b-4 · C3`):
115
+ *
116
+ * | catálogo | permisos que caben |
117
+ * |---|---|
118
+ * | 1 holder type, slugs `p0`…`pN` | **800** |
119
+ * | 3 holder types (`user`/`admin`/`integration`), slugs `p0`…`pN` | **691** |
120
+ * | 1 holder type, slugs `docs:readN` | **576** |
121
+ * | 3 holder types, slugs `recursoN:accion` (REALISTAS) | **447** |
122
+ * | 3 holder types, slugs de 40 caracteres | **272** |
123
+ *
124
+ * O sea: el «≈691» que se publicó es la cifra de un catálogo cuyos permisos se
125
+ * llaman `p0`, `p1`, `p2`…; **con nombres de permiso normales el techo está en
126
+ * ~450**. Y los mismos tres holder types con nombres más cortos (`bot` en vez
127
+ * de `integration`) dan 721: no basta con decir «tres holder types».
128
+ *
129
+ * Se valida en `syncAuthzCatalog` ANTES de escribir el catálogo: un catálogo
130
+ * que no se puede publicar no se escribe a medias en un entorno y entero en
131
+ * otro. El gate por bytes es exacto y salta antes de escribir, así que
132
+ * equivocarse con la cifra derivada no concede nada: solo sorprende.
133
+ */
134
+ export const FACTS_MODEL_MAX_BYTES = 262_144;
135
+ /**
136
+ * **Profundidad máxima de cadena que el modelo (c2) resuelve** (3b-2e · E5),
137
+ * MEDIDA contra OpenFGA v1.19 con el `--resolve-node-limit` por defecto (25).
138
+ * Con el grant en la RAÍZ y el `Check` a profundidad creciente, 25
139
+ * repeticiones por punto:
140
+ *
141
+ * | saltos `parent` | `can_<P>` |
142
+ * |---|---|
143
+ * | 21 | 25/25 resuelve |
144
+ * | **22** | **25/25 resuelve** ← la cota que se declara |
145
+ * | 23 | resuelve casi siempre y **falla entre un 4 % y un 26 %** de las veces según la carga — el borde NO es nítido |
146
+ * | 24 | 0/25: siempre falla |
147
+ *
148
+ * (Remedido en 3b-4 · C4 sobre 50 hojas a la misma profundidad: a 22, 200 de
149
+ * 200 resuelven en cuatro lotes; a 23, entre 6 y 13 errores por lote de 50, y
150
+ * 15 de 100 en checks sueltos; a 24, 100 de 100 fallan.)
151
+ *
152
+ * Las otras dos ramas del modelo llegan más lejos (`denied_<P>` hasta 25,
153
+ * `ancestor` hasta 26): manda la más baja, y es `can_<P>` porque es una resta
154
+ * (`difference`) sobre una unión con DOS TTU (`binding` y `parent`), o sea dos
155
+ * reescrituras más que las otras.
156
+ *
157
+ * **Lo importante del hallazgo no es el número, es que el borde es
158
+ * PROBABILÍSTICO**: a 23 saltos la misma pregunta responde casi siempre y
159
+ * falla de vez en cuando (el presupuesto de nodos se consume de forma no
160
+ * determinista al resolver la unión). Por eso se declara **22**, que es la
161
+ * profundidad que resuelve SIEMPRE, y no el primer valor que falló. Un caso
162
+ * de la suite apoyado en 23 habría sido flaky en el artefacto publicado —la
163
+ * misma lección que 3G · Y1—, y de hecho lo fue una vez antes de medirlo.
164
+ *
165
+ * **Y por eso la constante se clava con REPETICIONES, no con una tirada**
166
+ * (3b-4 · C4): el par de casos original —positivo en la cota, negativo en la
167
+ * cota + 2— sujetaba el intervalo [22, 23] y no el 22 (con la constante a 23
168
+ * la suite seguía verde 3 corridas de 3, tester Fase 3b · M12b). El caso que
169
+ * lo cierra hace **500 resoluciones por lado** (10 lotes de 50 con
170
+ * `authorizeMany`): en la cota tienen que resolver las 500, y un salto más
171
+ * tiene que fallar al menos una vez. Con la tasa de fallo más baja jamás
172
+ * medida (4 %) un verde falso es 0,96^500 ≈ 1e-9.
173
+ *
174
+ * Los «~23» que citaba el panel (riesgo S9) eran de un modelo MÁS SIMPLE y
175
+ * estaban sin medir sobre (c2).
176
+ *
177
+ * Pasado el techo el servidor responde 400 («resolution required too many
178
+ * rewrite rules») y el paquete lo propaga como 503, **nunca como un `false`**
179
+ * (invariante 5): es fail-closed, pero es un DoS al alcance de quien pueda
180
+ * crear sub-scopes anidados, y `database` no tiene ese techo — el mismo árbol
181
+ * es legal en un driver y una caída en el otro. Sube
182
+ * `OPENFGA_RESOLVE_NODE_LIMIT` en el servidor si tu árbol es más profundo.
183
+ */
184
+ export const FACTS_MAX_RESOLVE_DEPTH = 22;
185
+ /** Tope de checks por `batchCheck` en OpenFGA (el driver trocea en lotes de este tamaño). */
186
+ export const FGA_MAX_BATCH_CHECK = 50;
187
+ /** Fracción del techo a partir de la cual se avisa (cruce 9: "aviso al 80 %"). */
188
+ export const FACTS_MODEL_WARN_RATIO = 0.8;
189
+ /** El tipo FGA cuyo id es el uuid del rol del catálogo. */
190
+ export const FACTS_ROLE_TYPE = 'role';
191
+ /** Prefijo de la familia que materializa el catálogo (`role:<uuid>#permits_<P>`). */
192
+ export const FACTS_PERMITS_PREFIX = 'permits_';
193
+ /**
194
+ * **La relación de (c2r)**: «desde este scope se llega a la raíz `app`»
195
+ * (3b-2i). Es una relación PROPIA del modelo, como `parent` o `ancestor`, y
196
+ * la única que se escribe una vez por STORE en vez de por hecho: el marcador
197
+ * de raíz (`factsRootTuples`).
198
+ */
199
+ export const FACTS_ROOTED_RELATION = 'rooted';
200
+ /**
201
+ * La condición de caducidad del modelo (`current_time < valid_until`): la de
202
+ * `role_binding#assignee` desde el modo `facts`, y desde R-15 también la de
203
+ * cada sujeto de una relación ReBAC (`document#viewer`, `group#member`…).
204
+ */
205
+ export const FACTS_EXPIRY_CONDITION = 'not_expired';
206
+ /* ── Relaciones derivadas de un permiso ─────────────────────────────────── */
207
+ /**
208
+ * Relaciones propias del modelo (no derivadas de ningún permiso). Un permiso
209
+ * que produzca uno de estos nombres invalidaría el modelo entero (S14):
210
+ * `assertValidSlug` ya los reserva en el núcleo para AMBOS drivers, y aquí
211
+ * entran en el mismo `Map` que las cuatro familias para que el generador no
212
+ * dependa de que alguien haya validado antes.
213
+ */
214
+ const OWN_RELATIONS = Object.freeze([
215
+ ['role', "relación propia del modelo (role_binding#role)"],
216
+ ['assignee', "relación propia del modelo (role_binding#assignee)"],
217
+ ['parent', "relación propia del modelo (scope#parent)"],
218
+ ['binding', "relación propia del modelo (scope#binding)"],
219
+ ['ancestor', "relación propia del modelo (scope#ancestor)"],
220
+ ['rooted', "relación propia del modelo (scope#rooted)"],
221
+ ]);
222
+ /** Las cuatro relaciones que un permiso genera, sin validar nada. */
223
+ export function factsRelationsOf(permission) {
224
+ const base = slugAsRelation(permission);
225
+ return {
226
+ permission,
227
+ base,
228
+ can: `can_${base}`,
229
+ denied: `denied_${base}`,
230
+ permits: `${FACTS_PERMITS_PREFIX}${base}`,
231
+ };
232
+ }
233
+ /**
234
+ * **S4, bloqueante del juez.** Con CUATRO familias, dos permisos distintos
235
+ * pueden generar el mismo nombre de relación: `can_x` sale del permiso
236
+ * `can_x` y también del permiso `x`. El generador de antes las colapsaba en
237
+ * silencio y el modelo publicado anulaba un deny (el auditor lo reprodujo de
238
+ * punta a punta con `allowed: true`). Se detecta con un `Map` nombre→origen
239
+ * y se LANZA: nunca se escribe un modelo ambiguo.
240
+ *
241
+ * Es un espacio de nombres plano a propósito (aunque las relaciones vivan en
242
+ * tipos distintos): las familias tienen prefijos disjuntos, así que dos
243
+ * permisos legales jamás chocan aquí, y lo que sí choca es exactamente lo que
244
+ * `RESERVED_SLUGS`/`RESERVED_SLUG_PREFIXES` prohíben en el núcleo.
245
+ */
246
+ export function factsRelationMap(permissions) {
247
+ const origin = new Map(OWN_RELATIONS);
248
+ const out = [];
249
+ for (const permission of permissions) {
250
+ const relations = factsRelationsOf(permission);
251
+ for (const name of [relations.base, relations.can, relations.denied, relations.permits]) {
252
+ // Cota de nombre de relación (A4): se nombra el permiso y el prefijo
253
+ // que lo desborda, que es lo que el operador tiene que acortar.
254
+ if (name.length > FGA_MAX_RELATION_NAME) {
255
+ const prefix = name.slice(0, name.length - relations.base.length);
256
+ throw new InvalidSlugError(`El permiso '${permission}' no es publicable en el modelo facts: su relación ` +
257
+ `'${name}' tiene ${name.length} caracteres y FGA admite ${FGA_MAX_RELATION_NAME} ` +
258
+ `(el prefijo '${prefix || '(ninguno)'}' añade ${name.length - relations.base.length}). Usa un slug más corto.`);
259
+ }
260
+ const other = origin.get(name);
261
+ if (other !== undefined) {
262
+ throw new InvalidSlugError(`Colisión de relaciones en el modelo facts: '${name}' la generan ${other} y el permiso '${permission}'. ` +
263
+ `Las cuatro familias del modelo (<P>, can_<P>, denied_<P>, permits_<P>) comparten espacio de nombres: ` +
264
+ `renombra uno de los dos permisos.`);
265
+ }
266
+ origin.set(name, `el permiso '${permission}'`);
267
+ }
268
+ out.push(relations);
269
+ }
270
+ return out;
271
+ }
272
+ /* ── El modelo (c2) ─────────────────────────────────────────────────────── */
273
+ const ttu = (tupleset, relation) => ({
274
+ tupleToUserset: { tupleset: { relation: tupleset }, computedUserset: { relation } },
275
+ });
276
+ const computed = (relation) => ({ computedUserset: { relation } });
277
+ const NO_DIRECT = { directly_related_user_types: [] };
278
+ /**
279
+ * El authorization model del modo `facts` en el JSON del API de FGA. El mismo
280
+ * `holderTypes` y el mismo conjunto de permisos deben usarse al construir el
281
+ * driver: si difieren, los checks no encuentran las tuplas y `permits_<P>` de
282
+ * un permiso que el modelo no declara es un 400 del servidor.
283
+ *
284
+ * `permissions` son los slugs del CATÁLOGO (`authz_permissions`), que sigue
285
+ * siendo propiedad local: esto es una proyección derivada y reconstruible, no
286
+ * una fuente de verdad (regla del catálogo reescrita, cruce 7 del panel).
287
+ */
288
+ export function openFgaFactsModel(holderTypeMap, permissions, relationsConfig) {
289
+ assertHolderTypes(holderTypeMap);
290
+ const relations = factsRelationMap(permissions);
291
+ const holderTypes = Object.values(holderTypeMap);
292
+ const direct = holderTypes.map((type) => ({ type }));
293
+ const wildcards = holderTypes.map((type) => ({ type, wildcard: {} }));
294
+ const directWithExpiry = [
295
+ ...direct,
296
+ ...holderTypes.map((type) => ({ type, condition: 'not_expired' })),
297
+ ];
298
+ // type role — el catálogo por tuplas: `role:<uuid>#permits_<P>@<holder>:*`.
299
+ // Un vínculo rol→permiso es UNA tupla global, no una por scope (c2).
300
+ const roleRelations = {};
301
+ const roleMetadata = {};
302
+ for (const r of relations) {
303
+ roleRelations[r.permits] = { this: {} };
304
+ roleMetadata[r.permits] = { directly_related_user_types: wildcards };
305
+ }
306
+ // type role_binding — la asignación. `<P>` es la INTERSECCIÓN de "estás
307
+ // asignado aquí" con "tu rol vincula ese permiso": el catálogo se edita en
308
+ // runtime (tuplas) sin tocar los bindings.
309
+ const bindingRelations = { role: { this: {} }, assignee: { this: {} } };
310
+ const bindingMetadata = {
311
+ role: { directly_related_user_types: [{ type: 'role' }] },
312
+ assignee: { directly_related_user_types: directWithExpiry },
313
+ };
314
+ for (const r of relations) {
315
+ bindingRelations[r.base] = {
316
+ intersection: { child: [computed('assignee'), ttu('role', r.permits)] },
317
+ };
318
+ bindingMetadata[r.base] = NO_DIRECT;
319
+ }
320
+ // type scope — el árbol. `<P>` hereda hacia ABAJO por `parent` (invariante
321
+ // 1), `denied_<P>` también, y `can_<P>` es la resta (invariante 2: el deny
322
+ // explícito gana). `ancestor` da `isWithin`/`descendantsOf` sin una sola
323
+ // tupla extra.
324
+ const scopeRelations = { parent: { this: {} }, binding: { this: {} } };
325
+ const scopeMetadata = {
326
+ parent: { directly_related_user_types: [{ type: 'scope' }] },
327
+ binding: { directly_related_user_types: [{ type: 'role_binding' }] },
328
+ };
329
+ for (const r of relations) {
330
+ scopeRelations[r.base] = { union: { child: [ttu('binding', r.base), ttu('parent', r.base)] } };
331
+ scopeRelations[r.denied] = { union: { child: [{ this: {} }, ttu('parent', r.denied)] } };
332
+ // (c2r): lo concedido, menos lo denegado, **y solo si el scope llega a la
333
+ // raíz**. La intersección va aquí y no dentro de `<P>` a propósito:
334
+ // `can_<P>` es exactamente lo que responde `authorize` y nada más, así que
335
+ // «lo que decide» y «lo que se hereda» siguen separados.
336
+ scopeRelations[r.can] = {
337
+ intersection: {
338
+ child: [
339
+ { difference: { base: computed(r.base), subtract: computed(r.denied) } },
340
+ computed(FACTS_ROOTED_RELATION),
341
+ ],
342
+ },
343
+ };
344
+ scopeMetadata[r.base] = NO_DIRECT;
345
+ scopeMetadata[r.denied] = { directly_related_user_types: direct };
346
+ scopeMetadata[r.can] = NO_DIRECT;
347
+ }
348
+ scopeRelations.ancestor = { union: { child: [computed('parent'), ttu('parent', 'ancestor')] } };
349
+ scopeMetadata.ancestor = NO_DIRECT;
350
+ // El marcador de raíz es lo ÚNICO directo de `rooted`: `scope:app` lo lleva
351
+ // (una tupla por holder type en todo el store) y el resto del árbol la
352
+ // hereda por `parent`, exactamente igual que `denied_<P>`. Es una rama
353
+ // PARALELA al `difference`, y más barata que él, por eso no baja el techo
354
+ // de profundidad (medido: 22 sólido en las cinco variantes del diseño).
355
+ scopeRelations[FACTS_ROOTED_RELATION] = {
356
+ union: { child: [{ this: {} }, ttu('parent', FACTS_ROOTED_RELATION)] },
357
+ };
358
+ scopeMetadata[FACTS_ROOTED_RELATION] = { directly_related_user_types: wildcards };
359
+ const typeDefinitions = [
360
+ ...holderTypes.map((type) => ({ type, relations: {}, metadata: null })),
361
+ { type: 'role', relations: roleRelations, metadata: { relations: roleMetadata } },
362
+ { type: 'role_binding', relations: bindingRelations, metadata: { relations: bindingMetadata } },
363
+ { type: 'scope', relations: scopeRelations, metadata: { relations: scopeMetadata } },
364
+ ];
365
+ // Fase 4-1 · el modelo FUSIONADO: las relaciones ReBAC (`group` + los tipos
366
+ // de objeto declarados) van al MISMO modelo y el MISMO store que `facts`. El
367
+ // generador valida ANTES de emitir que ningún tipo ni relación de relaciones
368
+ // pisa un tipo o una familia reservados de `facts` (⚪4) ni un permiso del
369
+ // catálogo (F-04): en el store compartido los ids viven en el mismo espacio.
370
+ if (relationsConfig) {
371
+ typeDefinitions.push(...factsRelationTypeDefinitions(relations, relationsConfig, holderTypes));
372
+ }
373
+ return {
374
+ schema_version: '1.1',
375
+ type_definitions: typeDefinitions,
376
+ conditions: {
377
+ not_expired: {
378
+ name: 'not_expired',
379
+ expression: 'current_time < valid_until',
380
+ parameters: {
381
+ current_time: { type_name: 'TYPE_NAME_TIMESTAMP' },
382
+ valid_until: { type_name: 'TYPE_NAME_TIMESTAMP' },
383
+ },
384
+ },
385
+ },
386
+ };
387
+ }
388
+ /* ── Cuánto ocupa el modelo PARA EL SERVIDOR ────────────────────────────── */
389
+ /**
390
+ * OpenFGA no mide el JSON: mide `proto.Size(AuthorizationModel)` y rechaza
391
+ * por encima de `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`. Y el JSON no
392
+ * sirve de aproximación: medido contra v1.19, la razón proto/JSON va de 0,33
393
+ * (slugs cortos) a 0,57 (slugs de 40), así que un techo sobre el JSON o deja
394
+ * pasar lo que el servidor rechaza o rechaza catálogos legales por el doble
395
+ * de margen. Se calcula el tamaño protobuf EXACTO, que para este modelo es
396
+ * aritmética de longitudes, y un caso de la suite lo contrasta con el número
397
+ * que el propio servidor reporta al rechazar (delta 0 en cuatro formas de
398
+ * catálogo distintas).
399
+ *
400
+ * Reglas de proto3 que se aplican aquí: campo length-delimited = tag (1 byte,
401
+ * todos los campos del esquema son 1..15) + varint de la longitud + payload;
402
+ * un `string` vacío y un enum 0 no se serializan; un `map<K,V>` es una
403
+ * entrada por par con `key` en el campo 1 y `value` en el 2; un mensaje vacío
404
+ * (`this`, `wildcard`) ocupa tag + longitud 0.
405
+ */
406
+ const varintSize = (value) => value < 128 ? 1 : value < 16_384 ? 2 : value < 2_097_152 ? 3 : 4;
407
+ /** Un campo length-delimited de `bytes` bytes de payload. */
408
+ const fieldSize = (bytes) => 1 + varintSize(bytes) + bytes;
409
+ /** Un `string` de proto3: el vacío no se serializa. */
410
+ const stringSize = (value) => {
411
+ const bytes = Buffer.byteLength(value, 'utf8');
412
+ return bytes === 0 ? 0 : fieldSize(bytes);
413
+ };
414
+ /** Una entrada de `map<string, M>`: mensaje `{ key = 1, value = 2 }`. */
415
+ const mapEntrySize = (key, value) => fieldSize(stringSize(key) + (value === 0 ? 2 : fieldSize(value)));
416
+ /** `ObjectRelation { object = 1, relation = 2 }`. */
417
+ const objectRelationSize = (relation) => stringSize(relation.object ?? '') + stringSize(relation.relation ?? '');
418
+ /** `Userset`: un `oneof` de seis, cada rama un campo length-delimited. */
419
+ function usersetSize(userset) {
420
+ if (!userset)
421
+ return 0;
422
+ if (userset.this)
423
+ return 2;
424
+ if (userset.computedUserset)
425
+ return fieldSize(objectRelationSize(userset.computedUserset));
426
+ if (userset.tupleToUserset) {
427
+ const { tupleset, computedUserset } = userset.tupleToUserset;
428
+ return fieldSize(fieldSize(objectRelationSize(tupleset)) + fieldSize(objectRelationSize(computedUserset)));
429
+ }
430
+ const children = userset.union ?? userset.intersection;
431
+ if (children) {
432
+ return fieldSize(children.child.reduce((total, child) => total + fieldSize(usersetSize(child)), 0));
433
+ }
434
+ if (userset.difference) {
435
+ return fieldSize(fieldSize(usersetSize(userset.difference.base)) + fieldSize(usersetSize(userset.difference.subtract)));
436
+ }
437
+ return 0;
438
+ }
439
+ /** `RelationReference { type = 1, relation = 2 | wildcard = 3, condition = 4 }`. */
440
+ function relationReferenceSize(reference) {
441
+ let bytes = stringSize(reference.type);
442
+ if (reference.relation)
443
+ bytes += stringSize(reference.relation);
444
+ if (reference.wildcard)
445
+ bytes += 2;
446
+ if (reference.condition)
447
+ bytes += stringSize(reference.condition);
448
+ return bytes;
449
+ }
450
+ /**
451
+ * Los bytes que el servidor contará para este modelo. Incluye el id (un ULID
452
+ * de 26 caracteres) porque el servidor lo asigna ANTES de medir: sin él la
453
+ * cuenta se queda 28 bytes corta.
454
+ */
455
+ export function factsModelBytes(model) {
456
+ let total = fieldSize(26) + stringSize(model.schema_version);
457
+ for (const definition of model.type_definitions) {
458
+ let bytes = stringSize(definition.type);
459
+ for (const [name, userset] of Object.entries(definition.relations ?? {})) {
460
+ bytes += mapEntrySize(name, usersetSize(userset));
461
+ }
462
+ if (definition.metadata) {
463
+ let metadata = 0;
464
+ for (const [name, relation] of Object.entries(definition.metadata.relations ?? {})) {
465
+ const types = (relation.directly_related_user_types ?? []).reduce((sum, reference) => sum + fieldSize(relationReferenceSize(reference)), 0);
466
+ metadata += mapEntrySize(name, types);
467
+ }
468
+ bytes += fieldSize(metadata);
469
+ }
470
+ total += fieldSize(bytes);
471
+ }
472
+ for (const [name, condition] of Object.entries(model.conditions ?? {})) {
473
+ let bytes = stringSize(condition.name) + stringSize(condition.expression);
474
+ for (const parameter of Object.keys(condition.parameters ?? {})) {
475
+ // `ConditionParamTypeRef { type_name = 1 }` (un enum ≠ 0: 2 bytes).
476
+ bytes += mapEntrySize(parameter, 2);
477
+ }
478
+ total += mapEntrySize(name, bytes);
479
+ }
480
+ return total;
481
+ }
482
+ /**
483
+ * ¿Este catálogo se puede PUBLICAR como modelo `facts`? Se responde ANTES de
484
+ * escribir nada (`syncAuthzCatalog`), no en runtime: un catálogo que rebasa
485
+ * el techo del servidor entra en la base y deja el store sin poder
486
+ * regenerarse, que es la avería silenciosa.
487
+ *
488
+ * Pasado el 80 % del techo se AVISA por el canal de log del driver: quien
489
+ * declara permisos a ese ritmo tiene que enterarse antes de chocar, no el día
490
+ * del deploy que no arranca.
491
+ */
492
+ export function assertFactsModelPublishable(holderTypeMap, permissions, warn, relationsConfig) {
493
+ // El gate mide el modelo FUSIONADO (Fase 4-1): si `relationsConfig` llega,
494
+ // los tipos de relaciones cuentan para el techo. Sin eso, un
495
+ // `defineRelationsConfig` empujaría el modelo por encima de los 262.144 B en
496
+ // SILENCIO —medía solo `facts`— y dejaría el store sin poder regenerarse
497
+ // (el hueco que señaló el auditor).
498
+ const model = openFgaFactsModel(holderTypeMap, permissions, relationsConfig);
499
+ const bytes = factsModelBytes(model);
500
+ if (bytes > FACTS_MODEL_MAX_BYTES) {
501
+ throw new ModelTooLargeError(`El catálogo no cabe en un authorization model de OpenFGA: ${permissions.length} permisos ` +
502
+ `producen ${bytes} bytes y el techo son ${FACTS_MODEL_MAX_BYTES}. ` +
503
+ `Reduce permisos (o sube OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES en el servidor, que es del pliego de infraestructura).`);
504
+ }
505
+ if (warn && bytes >= FACTS_MODEL_MAX_BYTES * FACTS_MODEL_WARN_RATIO) {
506
+ warn(`authz(openfga): el modelo facts va por ${bytes} de ${FACTS_MODEL_MAX_BYTES} bytes ` +
507
+ `(${Math.round((bytes / FACTS_MODEL_MAX_BYTES) * 100)} %, ${permissions.length} permisos). ` +
508
+ `Pasado el techo el catálogo deja de ser publicable.`);
509
+ }
510
+ return { bytes, permissions: permissions.length };
511
+ }
512
+ /** Cota de id de objeto (A4): lo que el store no podría leer de vuelta no se escribe. */
513
+ export function assertFgaObjectId(kind, id) {
514
+ if (id.length > FGA_MAX_OBJECT_ID) {
515
+ throw new InvalidIdentityError(`Id de objeto FGA inválido (${kind}): '${id.slice(0, 60)}…' tiene ${id.length} caracteres ` +
516
+ `y FGA admite ${FGA_MAX_OBJECT_ID}.`);
517
+ }
518
+ }
519
+ /**
520
+ * Las tuplas que materializan los vínculos rol→permiso del catálogo. Una por
521
+ * (rol, permiso, holder): el comodín de USUARIO es lo que hace (c2) barato
522
+ * —quitar un permiso de un rol son `holders` deletes en un solo `Write`, no
523
+ * una escritura por scope—.
524
+ *
525
+ * NO es catálogo: es una proyección derivada, reconstruible desde `authz_*`,
526
+ * que ningún camino de lectura del driver consulta para responder qué
527
+ * permisos tiene un rol (cruce 7 del panel; A6).
528
+ */
529
+ export function factsCatalogTuples(roles, holderTypeMap) {
530
+ assertHolderTypes(holderTypeMap);
531
+ const holderTypes = Object.values(holderTypeMap);
532
+ const tuples = [];
533
+ for (const role of roles) {
534
+ const object = `${FACTS_ROLE_TYPE}:${role.uuid}`;
535
+ assertFgaObjectId(FACTS_ROLE_TYPE, object);
536
+ for (const permission of role.permissions) {
537
+ const { permits } = factsRelationsOf(permission);
538
+ for (const holderType of holderTypes) {
539
+ tuples.push({ user: `${holderType}:*`, relation: permits, object });
540
+ }
541
+ }
542
+ }
543
+ return tuples;
544
+ }
545
+ /** Clave textual de una tupla de la proyección, para comparar conjuntos. */
546
+ export function factsTupleId(tuple) {
547
+ return `${tuple.user}#${tuple.relation}@${tuple.object}`;
548
+ }
549
+ /* ── El ÁRBOL como hechos (3b-2b) ───────────────────────────────────────── */
550
+ /** El tipo FGA cuyo id es la `scopeKey` del paquete (`app` o `<tipo>|<uuid>`). */
551
+ export const FACTS_SCOPE_TYPE = 'scope';
552
+ /** La arista del árbol: `scope:<hijo>#parent@scope:<padre>` (una por nodo). */
553
+ export const FACTS_PARENT_RELATION = 'parent';
554
+ /**
555
+ * El objeto FGA de un scope. La `scopeKey` es la MISMA codificación que ya
556
+ * usan los ids de binding (`identity.ts`), así que el árbol y los hechos
557
+ * hablan del mismo nodo con la misma cadena; el scope tiene que venir ya
558
+ * CANÓNICO (invariante 17: `chain[0]`), o se abriría una segunda rama para
559
+ * el alias del uuid.
560
+ */
561
+ export function factsScopeObject(key) {
562
+ const object = `${FACTS_SCOPE_TYPE}:${key}`;
563
+ assertFgaObjectId(FACTS_SCOPE_TYPE, object);
564
+ return object;
565
+ }
566
+ /**
567
+ * La arista `hijo → padre` del árbol. Una sola tupla por nodo (c2): mover un
568
+ * subárbol es reescribir ESA tupla, no recorrer nada — por eso el `moved`
569
+ * del cruce 8 cabe en un `Write` atómico.
570
+ */
571
+ export function factsParentTuple(childKey, parentKey) {
572
+ return {
573
+ user: factsScopeObject(parentKey),
574
+ relation: FACTS_PARENT_RELATION,
575
+ object: factsScopeObject(childKey),
576
+ };
577
+ }
578
+ /**
579
+ * **El marcador de raíz** (3b-2i): `scope:app#rooted@<holder>:*`, una tupla
580
+ * por holder type en TODO el store — cero por scope.
581
+ *
582
+ * Es lo que hace verdadera la premisa de `rooted`: solo la raíz la tiene
583
+ * directa. `attached`/`moved`/`detached` no escriben ni una tupla más por su
584
+ * culpa (la outbox y el relay no llevan entradas nuevas), y a cambio lo
585
+ * escribe quien toca el CATÁLOGO —`projectCatalog`, en cada
586
+ * `syncAuthzCatalog` con proyección, de forma idempotente— porque ése es el
587
+ * momento en el que un holderType nuevo del config aparece.
588
+ *
589
+ * ⚠️ **Modo de fallo que hay que conocer: sin marcador, todo el store
590
+ * DENIEGA** (medido: `can_<P>` es `false` en `app`, en la org y en la unit).
591
+ * Es fail-closed —no es una fuga— y ruidoso a la primera pregunta, pero es
592
+ * una caída total silenciosa desde el punto de vista del log. Por eso el
593
+ * marcador se repone en cada sync y `authz:reconcile` (3b-3) tiene que
594
+ * reportarlo como deriva cuando falte.
595
+ */
596
+ export function factsRootTuples(holderTypeMap) {
597
+ assertHolderTypes(holderTypeMap);
598
+ return Object.values(holderTypeMap).map((type) => ({
599
+ user: `${type}:*`,
600
+ relation: FACTS_ROOTED_RELATION,
601
+ object: factsScopeObject(APP_SCOPE_TYPE),
602
+ }));
603
+ }
604
+ /* ── Los HECHOS del modo `facts` (3b-2c) ────────────────────────────────── */
605
+ /** El tipo FGA de una asignación: `role_binding:<scopeKey>|<roleUuid>`. */
606
+ export const FACTS_BINDING_TYPE = 'role_binding';
607
+ /** `scope:<key>#binding@role_binding:…` — qué asignaciones cuelgan del scope. */
608
+ export const FACTS_BINDING_RELATION = 'binding';
609
+ /** `role_binding:…#role@role:<roleUuid>` — qué rol vincula la asignación. */
610
+ export const FACTS_ROLE_RELATION = 'role';
611
+ /** `role_binding:…#assignee@<holder>` — quién está asignado (con la caducidad). */
612
+ export const FACTS_ASSIGNEE_RELATION = 'assignee';
613
+ /**
614
+ * El objeto de una asignación. Mismo id que en el modo `resolver`
615
+ * (`<scopeKey>|<roleUuid>`, 3A · A1: uuid del catálogo, nunca el slug), y por
616
+ * eso el cambio de modelo no renombra un solo binding: lo que (c2) añade son
617
+ * las DOS aristas de abajo, no una identidad nueva.
618
+ */
619
+ export function factsBindingObject(scopeKeyValue, roleUuid) {
620
+ const object = `${FACTS_BINDING_TYPE}:${scopeKeyValue}|${roleUuid}`;
621
+ assertFgaObjectId(FACTS_BINDING_TYPE, object);
622
+ return object;
623
+ }
624
+ /**
625
+ * Las dos aristas que hacen ALCANZABLE una asignación en (c2): el binding
626
+ * cuelga del scope (`scope#binding`) y apunta a su rol (`role_binding#role`).
627
+ * Sin ellas el `assignee` es un hecho huérfano que `can_<P>` no ve — es la
628
+ * diferencia entre el modo `resolver` (donde la cadena la expande el paquete
629
+ * y basta con el `assignee`) y el modo `facts`.
630
+ *
631
+ * Son ESTRUCTURA, no concesión: no llevan caducidad y no dicen quién está
632
+ * asignado. Por eso `revoke` no las borra (otro holder puede seguir usando el
633
+ * mismo binding) y re-escribirlas es idempotente.
634
+ */
635
+ export function factsBindingTuples(scopeKeyValue, roleUuid) {
636
+ const object = factsBindingObject(scopeKeyValue, roleUuid);
637
+ const role = `${FACTS_ROLE_TYPE}:${roleUuid}`;
638
+ assertFgaObjectId(FACTS_ROLE_TYPE, role);
639
+ return [
640
+ { user: role, relation: FACTS_ROLE_RELATION, object },
641
+ factsScopeBindingTuple(scopeKeyValue, roleUuid),
642
+ ];
643
+ }
644
+ /**
645
+ * La arista `scope:<key>#binding@role_binding:<key>|<rol>` sola, que es la
646
+ * que hace ALCANZABLE la asignación desde el scope.
647
+ *
648
+ * **Qué significa** (3b-2g · R1, decisión del dueño del 2026-08-30 (2)): desde
649
+ * el barrido, «**el rol es visible aquí**» — no «esta asignación existe». El
650
+ * hecho de la asignación es el `assignee`, que el barrido no toca; esta arista
651
+ * la escribe y la borra el paquete cada vez que la REGLA DE VISIBILIDAD cambia
652
+ * de respuesta para ese `(rol, scope)`: el árbol se mueve y el owner deja de
653
+ * estar en la cadena (`scopes.moved`, 3b-2e · E1) o el catálogo cambia el
654
+ * NIVEL declarado del rol (`projectCatalogRole`, 3b-2g · R1). Es lo que en
655
+ * `database` se evalúa en cada pregunta con `declaredRoleAt`, y aquí hay que
656
+ * materializar porque el modelo (c2) no lleva ni el owner ni el nivel.
657
+ */
658
+ export function factsScopeBindingTuple(scopeKeyValue, roleUuid) {
659
+ return {
660
+ user: factsBindingObject(scopeKeyValue, roleUuid),
661
+ relation: FACTS_BINDING_RELATION,
662
+ object: factsScopeObject(scopeKeyValue),
663
+ };
664
+ }
665
+ /**
666
+ * El deny explícito de (c2): `scope:<key>#denied_<P>@<holder>`. Ya no existe
667
+ * el tipo `deny_binding` — el deny es una relación DEL SCOPE, que es lo que
668
+ * permite que `denied_<P>` se herede hacia abajo por `parent` dentro del
669
+ * propio modelo y que `can_<P>` sea la resta (invariante 2) en un solo Check.
670
+ */
671
+ export function factsDenyTuple(scopeKeyValue, permission, user) {
672
+ return {
673
+ user,
674
+ relation: factsRelationsOf(permission).denied,
675
+ object: factsScopeObject(scopeKeyValue),
676
+ };
677
+ }
678
+ /** Prefijo de la familia del deny (`denied_<P>`), para leer denies por relación. */
679
+ export const FACTS_DENIED_PREFIX = 'denied_';
680
+ /* ── Relaciones ReBAC FUSIONADAS en el modelo (Fase 4-1) ─────────────────── */
681
+ /**
682
+ * El tipo `group` de las relaciones ReBAC: el portador de los usersets
683
+ * (`group:eng#member`) que hace que un `viewer` valga para todos los miembros
684
+ * de un grupo. Lo emite SIEMPRE el generador de relaciones (no lo declara el
685
+ * consumidor), y por eso es un tipo RESERVADO igual que los de `facts`.
686
+ */
687
+ export const FACTS_GROUP_TYPE = 'group';
688
+ /** La relación de pertenencia del grupo: `group:<id>#member@<holder>` y `@group:<otro>#member`. */
689
+ export const FACTS_GROUP_MEMBER_RELATION = 'member';
690
+ /**
691
+ * Los tipos reservados del modelo compartido (⚪4). La fuente única está en
692
+ * `src/identity.ts` (la gramática compartida), porque también la consume
693
+ * `defineRelationsConfig` en `src/relations/`, que la frontera de pureza
694
+ * mantiene disjunto de `drivers/`. Se re-exporta aquí para no romper el
695
+ * subpath `/openfga` (`src/openfga.ts` la publica desde este módulo).
696
+ */
697
+ export { RESERVED_FACTS_TYPES } from '../identity.js';
698
+ /**
699
+ * **⚪4 + F-04, a nivel de MODELO.** Antes de emitir un solo `type_definition`
700
+ * de relaciones, el generador comprueba que la config es FUSIONABLE en el
701
+ * modelo compartido:
702
+ *
703
+ * - ningún `objectType` duplica un tipo reservado de `facts`/`group` ni un
704
+ * holder type (⚪4 · tipo), ni se repite;
705
+ * - ningún NOMBRE de relación (deduplicado entre tipos: `document#viewer` y
706
+ * `folder#viewer` son la misma relación lógica y se permiten) empieza por
707
+ * una familia derivada (`can_`/`denied_`/`permits_`) ni coincide con una
708
+ * relación PROPIA del modelo (`parent`/`rooted`/`assignee`… — ⚪4 · familia)
709
+ * ni con un permiso del catálogo (F-04);
710
+ * - los `includes` refieren relaciones del MISMO tipo (un nivel).
711
+ *
712
+ * Lanza 422 `E_AUTHZ_RELATION_CONFIG` del PAQUETE (no el 400 opaco del
713
+ * servidor), nombrando qué choca con qué.
714
+ */
715
+ export function assertRelationsConfigPublishable(permissionRelations, config, holderTypes) {
716
+ // El espacio de nombres PLANO de `facts`: relaciones propias del modelo +
717
+ // las cuatro familias de cada permiso. Es el mismo criterio que
718
+ // `factsRelationMap` (A2/S4): aunque las relaciones vivan en tipos distintos,
719
+ // se tratan como un solo espacio para que el namespace de relaciones quede
720
+ // DEMOSTRABLEMENTE disjunto del de `facts` (cierre por construcción).
721
+ const origin = new Map(OWN_RELATIONS);
722
+ for (const r of permissionRelations) {
723
+ for (const name of [r.base, r.can, r.denied, r.permits]) {
724
+ origin.set(name, `el permiso '${r.permission}'`);
725
+ }
726
+ }
727
+ const reservedTypes = new Set([...RESERVED_FACTS_TYPES, ...holderTypes]);
728
+ const seenTypes = new Set();
729
+ const relationNames = new Set();
730
+ for (const objectType of config.objectTypes) {
731
+ const type = objectType?.type;
732
+ if (typeof type !== 'string' || !FGA_TYPE_FORMAT.test(type)) {
733
+ throw new RelationConfigError(`Tipo de objeto de relaciones inválido: ${JSON.stringify(type)} no es un tipo FGA válido ` +
734
+ `(1-254 caracteres, sin ':', '#', '@' ni espacios).`);
735
+ }
736
+ if (reservedTypes.has(type)) {
737
+ throw new RelationConfigError(`El tipo de objeto de relaciones '${type}' colisiona con un tipo reservado del modelo compartido ` +
738
+ `(${[...RESERVED_FACTS_TYPES].join(', ')}${holderTypes.length ? ', y los holder types ' + holderTypes.join(', ') : ''}). ` +
739
+ `En el store compartido un tipo de relaciones no puede duplicar uno de 'facts' (⚪4): renómbralo.`);
740
+ }
741
+ if (seenTypes.has(type)) {
742
+ throw new RelationConfigError(`El tipo de objeto de relaciones '${type}' está declarado dos veces.`);
743
+ }
744
+ seenTypes.add(type);
745
+ const own = new Set();
746
+ for (const relation of objectType.relations ?? []) {
747
+ const name = relation?.name;
748
+ if (typeof name !== 'string' || !FGA_TYPE_FORMAT.test(name)) {
749
+ throw new RelationConfigError(`Relación inválida en el tipo '${type}': ${JSON.stringify(name)} no es un nombre de relación válido.`);
750
+ }
751
+ own.add(name);
752
+ relationNames.add(name);
753
+ }
754
+ // Los `includes` refieren relaciones del MISMO tipo (un nivel, sin `from`).
755
+ for (const relation of objectType.relations ?? []) {
756
+ for (const included of relation.includes ?? []) {
757
+ if (!own.has(included)) {
758
+ throw new RelationConfigError(`La relación '${type}#${relation.name}' incluye '${included}', que no es una relación de '${type}'. ` +
759
+ `Los includes son de un nivel y del mismo tipo (v1 no soporta 'from').`);
760
+ }
761
+ }
762
+ }
763
+ }
764
+ for (const name of relationNames) {
765
+ if (name.length > FGA_MAX_RELATION_NAME) {
766
+ throw new RelationConfigError(`La relación '${name}' tiene ${name.length} caracteres y FGA admite ${FGA_MAX_RELATION_NAME}.`);
767
+ }
768
+ const family = RESERVED_SLUG_PREFIXES.find((prefix) => name.startsWith(prefix));
769
+ if (family) {
770
+ throw new RelationConfigError(`La relación '${name}' empieza por '${family}', prefijo reservado de las relaciones derivadas del modelo ` +
771
+ `facts (${RESERVED_SLUG_PREFIXES.join(', ')}): elegiría el nombre de un permiso proyectado (⚪4).`);
772
+ }
773
+ const clash = origin.get(name);
774
+ if (clash !== undefined) {
775
+ throw new RelationConfigError(`Colisión de nombres en el modelo compartido: la relación de objeto '${name}' ya la usa ${clash} ` +
776
+ `en 'facts'. El espacio de relaciones de 'relations/' y el de 'facts' tienen que ser disjuntos ` +
777
+ `(⚪4 para una relación propia del modelo, F-04 para un permiso del catálogo): renombra la relación.`);
778
+ }
779
+ }
780
+ }
781
+ /**
782
+ * Los `type_definitions` de relaciones que el generador AÑADE al modelo
783
+ * `facts`: el tipo `group` (usersets) + un tipo por objeto declarado. El
784
+ * literal medido contra el `:8101` está en la §1 del plan de la Fase 4.
785
+ *
786
+ * `group.member` admite holders directos y `group#member` (grupos anidan un
787
+ * nivel); cada relación de objeto admite `[holders, group#member]` directos y,
788
+ * si declara `includes`, se une a las relaciones incluidas del mismo tipo
789
+ * (`viewer or editor`), sin una sola tupla extra.
790
+ *
791
+ * **R-15 (2.4.0-alpha.2) · la caducidad de la tupla de relación**: cada
792
+ * sujeto admitido va ADEMÁS `with not_expired` —los holders Y el userset
793
+ * `group#member`—, la MISMA condición que `role_binding#assignee` (invariante
794
+ * 3: la expiración es una *condition* del modelo, sin scheduler). Así
795
+ * `relate(…, { expiresAt })` escribe la tupla con `valid_until` y el `Check`
796
+ * (con `current_time`) la respeta; una tupla sin condición sigue siendo
797
+ * válida (no caduca). Coste medido en el gate de bytes: la condición añade
798
+ * `(holders + 1) × (tipo + 'not_expired')` bytes por relación declarada.
799
+ */
800
+ export function factsRelationTypeDefinitions(permissionRelations, config, holderTypes) {
801
+ assertRelationsConfigPublishable(permissionRelations, config, holderTypes);
802
+ const direct = holderTypes.map((type) => ({ type }));
803
+ const groupMember = { type: FACTS_GROUP_TYPE, relation: FACTS_GROUP_MEMBER_RELATION };
804
+ // `[user, admin, integration, group#member, user with not_expired, …,
805
+ // group#member with not_expired]`: los holders y el userset del grupo, sin
806
+ // condición (no caduca) y con ella (R-15).
807
+ const holdersOrGroup = [
808
+ ...direct,
809
+ groupMember,
810
+ ...holderTypes.map((type) => ({ type, condition: FACTS_EXPIRY_CONDITION })),
811
+ { ...groupMember, condition: FACTS_EXPIRY_CONDITION },
812
+ ];
813
+ const definitions = [
814
+ {
815
+ type: FACTS_GROUP_TYPE,
816
+ relations: { [FACTS_GROUP_MEMBER_RELATION]: { this: {} } },
817
+ metadata: {
818
+ relations: { [FACTS_GROUP_MEMBER_RELATION]: { directly_related_user_types: holdersOrGroup } },
819
+ },
820
+ },
821
+ ];
822
+ for (const objectType of config.objectTypes) {
823
+ const relations = {};
824
+ const metadata = {};
825
+ for (const relation of objectType.relations) {
826
+ const includes = relation.includes ?? [];
827
+ relations[relation.name] = includes.length
828
+ ? { union: { child: [{ this: {} }, ...includes.map((name) => computed(name))] } }
829
+ : { this: {} };
830
+ metadata[relation.name] = { directly_related_user_types: holdersOrGroup };
831
+ }
832
+ definitions.push({ type: objectType.type, relations, metadata: { relations: metadata } });
833
+ }
834
+ return definitions;
835
+ }
836
+ //# sourceMappingURL=openfga_facts.js.map