@aglyn/tenant-feature-instance 1.0.0-beta.143

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 (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +3 -0
  3. package/jest.config.d.ts +36 -0
  4. package/package.json +45 -0
  5. package/src/index.d.ts +17 -0
  6. package/src/index.js +18 -0
  7. package/src/index.js.map +1 -0
  8. package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
  9. package/src/lib/components/duplicate-resource-dialog.js +217 -0
  10. package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
  11. package/src/lib/constants/firebase-config.d.ts +68 -0
  12. package/src/lib/constants/firebase-config.js +155 -0
  13. package/src/lib/constants/firebase-config.js.map +1 -0
  14. package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
  15. package/src/lib/constants/firebase-emulator-hosts.js +81 -0
  16. package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
  17. package/src/lib/constants/mock-data.d.ts +24 -0
  18. package/src/lib/constants/mock-data.js +49 -0
  19. package/src/lib/constants/mock-data.js.map +1 -0
  20. package/src/lib/constants/site-paths.d.ts +47 -0
  21. package/src/lib/constants/site-paths.js +66 -0
  22. package/src/lib/constants/site-paths.js.map +1 -0
  23. package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
  24. package/src/lib/hooks/firebase/auth-persistence.js +117 -0
  25. package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
  26. package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
  27. package/src/lib/hooks/firebase/firebase-services.js +522 -0
  28. package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
  29. package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
  30. package/src/lib/hooks/firebase/firestore-cache.js +152 -0
  31. package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
  32. package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
  33. package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
  34. package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
  35. package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
  36. package/src/lib/hooks/firestore-denial-reporter.js +293 -0
  37. package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
  38. package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
  39. package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
  40. package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
  41. package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
  42. package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
  43. package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
  44. package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
  45. package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
  46. package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
  47. package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
  48. package/src/lib/hooks/helpers/listen-options.js +92 -0
  49. package/src/lib/hooks/helpers/listen-options.js.map +1 -0
  50. package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
  51. package/src/lib/hooks/helpers/use-doc.js +213 -0
  52. package/src/lib/hooks/helpers/use-doc.js.map +1 -0
  53. package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
  54. package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
  55. package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
  56. package/src/lib/hooks/host-collection-queries.d.ts +125 -0
  57. package/src/lib/hooks/host-collection-queries.js +126 -0
  58. package/src/lib/hooks/host-collection-queries.js.map +1 -0
  59. package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
  60. package/src/lib/hooks/list-filter-constraints.js +288 -0
  61. package/src/lib/hooks/list-filter-constraints.js.map +1 -0
  62. package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
  63. package/src/lib/hooks/sorted-collection-window.js +150 -0
  64. package/src/lib/hooks/sorted-collection-window.js.map +1 -0
  65. package/src/lib/hooks/use-component-version.d.ts +43 -0
  66. package/src/lib/hooks/use-component-version.js +73 -0
  67. package/src/lib/hooks/use-component-version.js.map +1 -0
  68. package/src/lib/hooks/use-component.d.ts +39 -0
  69. package/src/lib/hooks/use-component.js +55 -0
  70. package/src/lib/hooks/use-component.js.map +1 -0
  71. package/src/lib/hooks/use-console-host-route.d.ts +24 -0
  72. package/src/lib/hooks/use-console-host-route.js +77 -0
  73. package/src/lib/hooks/use-console-host-route.js.map +1 -0
  74. package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
  75. package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
  76. package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
  77. package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
  78. package/src/lib/hooks/use-firestore-collection.js +307 -0
  79. package/src/lib/hooks/use-firestore-collection.js.map +1 -0
  80. package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
  81. package/src/lib/hooks/use-firestore-doc.js +192 -0
  82. package/src/lib/hooks/use-firestore-doc.js.map +1 -0
  83. package/src/lib/hooks/use-form-version.d.ts +44 -0
  84. package/src/lib/hooks/use-form-version.js +73 -0
  85. package/src/lib/hooks/use-form-version.js.map +1 -0
  86. package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
  87. package/src/lib/hooks/use-host-activity-logger.js +96 -0
  88. package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
  89. package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
  90. package/src/lib/hooks/use-host-campaigns.js +93 -0
  91. package/src/lib/hooks/use-host-campaigns.js.map +1 -0
  92. package/src/lib/hooks/use-host-org-id.d.ts +96 -0
  93. package/src/lib/hooks/use-host-org-id.js +154 -0
  94. package/src/lib/hooks/use-host-org-id.js.map +1 -0
  95. package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
  96. package/src/lib/hooks/use-host-resource-api.js +60 -0
  97. package/src/lib/hooks/use-host-resource-api.js.map +1 -0
  98. package/src/lib/hooks/use-host-template.d.ts +44 -0
  99. package/src/lib/hooks/use-host-template.js +82 -0
  100. package/src/lib/hooks/use-host-template.js.map +1 -0
  101. package/src/lib/hooks/use-host-version-api.d.ts +59 -0
  102. package/src/lib/hooks/use-host-version-api.js +69 -0
  103. package/src/lib/hooks/use-host-version-api.js.map +1 -0
  104. package/src/lib/hooks/use-host.d.ts +101 -0
  105. package/src/lib/hooks/use-host.js +45 -0
  106. package/src/lib/hooks/use-host.js.map +1 -0
  107. package/src/lib/hooks/use-layout-version.d.ts +33 -0
  108. package/src/lib/hooks/use-layout-version.js +64 -0
  109. package/src/lib/hooks/use-layout-version.js.map +1 -0
  110. package/src/lib/hooks/use-layout.d.ts +41 -0
  111. package/src/lib/hooks/use-layout.js +49 -0
  112. package/src/lib/hooks/use-layout.js.map +1 -0
  113. package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
  114. package/src/lib/hooks/use-live-artifact-count.js +102 -0
  115. package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
  116. package/src/lib/hooks/use-org-member-options.d.ts +57 -0
  117. package/src/lib/hooks/use-org-member-options.js +112 -0
  118. package/src/lib/hooks/use-org-member-options.js.map +1 -0
  119. package/src/lib/hooks/use-org-plan.d.ts +46 -0
  120. package/src/lib/hooks/use-org-plan.js +86 -0
  121. package/src/lib/hooks/use-org-plan.js.map +1 -0
  122. package/src/lib/hooks/use-paged-collection.d.ts +95 -0
  123. package/src/lib/hooks/use-paged-collection.js +130 -0
  124. package/src/lib/hooks/use-paged-collection.js.map +1 -0
  125. package/src/lib/hooks/use-plugin-config.d.ts +38 -0
  126. package/src/lib/hooks/use-plugin-config.js +120 -0
  127. package/src/lib/hooks/use-plugin-config.js.map +1 -0
  128. package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
  129. package/src/lib/hooks/use-scope-tokens.js +100 -0
  130. package/src/lib/hooks/use-scope-tokens.js.map +1 -0
  131. package/src/lib/hooks/use-screen-version.d.ts +33 -0
  132. package/src/lib/hooks/use-screen-version.js +65 -0
  133. package/src/lib/hooks/use-screen-version.js.map +1 -0
  134. package/src/lib/hooks/use-screen.d.ts +68 -0
  135. package/src/lib/hooks/use-screen.js +50 -0
  136. package/src/lib/hooks/use-screen.js.map +1 -0
  137. package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
  138. package/src/lib/hooks/use-sending-identity-api.js +95 -0
  139. package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
  140. package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
  141. package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
  142. package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
  143. package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
  144. package/src/lib/hooks/use-switcher-collection.js +275 -0
  145. package/src/lib/hooks/use-switcher-collection.js.map +1 -0
  146. package/src/lib/hooks/use-user-name.d.ts +51 -0
  147. package/src/lib/hooks/use-user-name.js +90 -0
  148. package/src/lib/hooks/use-user-name.js.map +1 -0
  149. package/src/lib/hooks/use-user-photo.d.ts +50 -0
  150. package/src/lib/hooks/use-user-photo.js +87 -0
  151. package/src/lib/hooks/use-user-photo.js.map +1 -0
  152. package/src/lib/tenant.d.ts +61 -0
  153. package/src/lib/tenant.js +62 -0
  154. package/src/lib/tenant.js.map +1 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Let a LISTENER tell the app its reads are being refused (AGL-1066).
18
+ *
19
+ * `session-health` was built around one-shot reads, because that is where
20
+ * AGL-1062 was first noticed. The console is listener-first, so the detector
21
+ * was watching the read style the console barely uses: only four one-shot
22
+ * call sites pass a collection label, one of them fires once per mount and
23
+ * so can never recur inside the 60s window, and none of the pages carrying a
24
+ * stale-write guard issues a labelled read at all. The verdict was
25
+ * unreachable on the surfaces that needed it.
26
+ *
27
+ * Listeners are on every page, and they already know which collection they
28
+ * are reading, so labelling is free rather than something a caller must
29
+ * remember. This is the seam that lets them report without the library
30
+ * importing the app: the console registers `reportDeniedRead` at startup and
31
+ * the hooks call it through here. An unregistered reporter is a no-op, which
32
+ * is what the tenant runtime wants — it has no client Firestore at all.
33
+ *
34
+ * ## Why this cannot fire offline
35
+ *
36
+ * Only `permission-denied` is ever reported. A client that has simply lost
37
+ * the network gets no error callback at all — the listen sits there and the
38
+ * cache keeps answering — and any one-shot it does make fails `unavailable`.
39
+ * That is what keeps the whole mechanism from mistaking a tunnel for a dead
40
+ * session, and it is why callers must NOT relax the code check.
41
+ *
42
+ * The channel runs both ways: see {@link reportFirestoreSessionHeal} at the
43
+ * bottom of this file for the return leg, which is how a refused listener
44
+ * learns the session came back. It lives here rather than in a module of its
45
+ * own because a heal is defined entirely in terms of the denial it reverses —
46
+ * split them and someone broadcasts one without the other's rules.
47
+ */ let reporters = null;
48
+ /**
49
+ * Register the app's reporters. Called once, by the app that owns
50
+ * `session-health`. Pass `null` to unregister (tests).
51
+ */ export function setFirestoreSessionReporters(next) {
52
+ reporters = next;
53
+ }
54
+ /** Report a refused listen. No-op when nothing is registered. */ export function reportFirestoreDenial(collection) {
55
+ reporters == null ? void 0 : reporters.onDenied(collection);
56
+ }
57
+ /** Report a listen the SERVER answered. No-op when nothing is registered. */ export function reportFirestoreServerRead() {
58
+ noteFirestoreServerRead();
59
+ reporters == null ? void 0 : reporters.onServerRead();
60
+ }
61
+ /**
62
+ * When ANY listener in this tab last got a server-answered snapshot.
63
+ *
64
+ * Library-internal evidence, kept regardless of whether app reporters are
65
+ * registered — `helpers/use-doc` deliberately does not feed `session-health`
66
+ * (its callers are the besigner document family, where a false verdict hurts
67
+ * most) but its server answers are still proof the session can read, and
68
+ * {@link refusedRetryDelayMs} needs that proof from every hook.
69
+ */ let lastServerReadAt = 0;
70
+ /**
71
+ * Record a server-answered snapshot WITHOUT reporting to `session-health`.
72
+ * `reportFirestoreServerRead` calls this; `helpers/use-doc` calls only this.
73
+ */ export function noteFirestoreServerRead() {
74
+ lastServerReadAt = Date.now();
75
+ }
76
+ /** Test seam — the evidence is module scope by design. */ export function resetFirestoreServerReadEvidence() {
77
+ lastServerReadAt = 0;
78
+ }
79
+ /**
80
+ * Cadence for a refusal streak that has outlived the retry budget (AGL-1066).
81
+ *
82
+ * A `permission-denied` has two very different causes and the client cannot
83
+ * tell them apart from the error alone:
84
+ *
85
+ * - a SESSION fault — stale token, App Check hiccup, an AGL-1143 SSO
86
+ * session that refuses everything. Every listener in the tab is refused,
87
+ * and the heal is an AGL-664 in-place re-auth or a token that attaches
88
+ * late. A young fault usually heals within seconds, so it starts at 2s.
89
+ *
90
+ * - a RULES denial — the ref itself is one this user may never read (a
91
+ * sentinel id, a scoped collaborator's off-limits collection). Only a
92
+ * rules change (a membership granted mid-session) can end it, so it
93
+ * starts at 60s (AGL-1440).
94
+ *
95
+ * The discriminator is the one this module already trusts for the
96
+ * session-health verdict: A GENUINELY DEAD SESSION HAS NO SERVER ANSWER TO
97
+ * OFFER. If any listener has been answered by the server since this
98
+ * listener's streak began, the session can read and this refusal is about
99
+ * the ref.
100
+ *
101
+ * Either way the delay then grows with the streak's age — a tenth of it, up
102
+ * to five minutes (AGL-2945). A refusal that has lasted ten minutes is
103
+ * unlikely to end in the next two seconds, and refused listens are not free:
104
+ * they bill reads (AGL-2944), so a fixed cadence made every console left open
105
+ * on a dead session a standing cost of 1,800 refusals an hour per listener.
106
+ * Per listener a session fault now costs ~70 in its first hour and 12 an hour
107
+ * after; a rules denial ~35, then 12. The listen is never abandoned.
108
+ *
109
+ * What the fast fixed cadence used to protect is kept as WAKES rather than as
110
+ * a timer — see {@link scheduleRefusedReopen}. The AGL-664 heal broadcast, the
111
+ * tab becoming visible and the window regaining focus all reopen at once, so
112
+ * the slow road only governs a page nobody is touching. The AGL-1358 write
113
+ * guards wait on a save someone clicks, which puts someone at the page.
114
+ */ export const SESSION_REFUSED_RETRY_DELAY_MS = 2000;
115
+ export const RULES_REFUSED_RETRY_DELAY_MS = 60000;
116
+ /** The longest a refused listen ever waits for its next reopen. */ export const REFUSED_RETRY_CEILING_MS = 5 * 60000;
117
+ /** The delay is the streak's age divided by this, between floor and ceiling. */ const REFUSED_RETRY_AGE_DIVISOR = 10;
118
+ /**
119
+ * The delay before a spent refusal streak reopens its listen.
120
+ *
121
+ * `streakStartedAt` is when the streak's FIRST refusal landed. Evaluated per
122
+ * retry, not once: evidence that arrives mid-streak (another listener's
123
+ * first server answer) raises the floor from the next reopen on, and the age
124
+ * term keeps growing for as long as the streak lasts.
125
+ */ export function refusedRetryDelayMs(streakStartedAt) {
126
+ const floor = lastServerReadAt > streakStartedAt ? RULES_REFUSED_RETRY_DELAY_MS : SESSION_REFUSED_RETRY_DELAY_MS;
127
+ // A backwards clock reads as a young streak, never as a negative age.
128
+ const age = Math.max(0, Date.now() - streakStartedAt);
129
+ return Math.min(REFUSED_RETRY_CEILING_MS, Math.max(floor, age / REFUSED_RETRY_AGE_DIVISOR));
130
+ }
131
+ /** No `document` (SSR, a worker) counts as visible: nothing to wait for. */ function tabIsHidden() {
132
+ return typeof document !== 'undefined' && document.visibilityState === 'hidden';
133
+ }
134
+ /**
135
+ * Schedule the reopen of a spent refusal streak, and return its cancel.
136
+ *
137
+ * In a visible tab the reopen waits {@link refusedRetryDelayMs}. A HIDDEN tab
138
+ * sets no timer at all: nobody is there to heal for, and an unattended tab on
139
+ * a dead session was the Aug 20 – Sep 4 storm — 400K–1.7M rules denials a day
140
+ * in ~8-hour blocks, ~$2 of reads the `read_ops_count` metric never showed
141
+ * (AGL-2944). A tab that goes hidden while a reopen is pending drops its
142
+ * timer and waits the same way.
143
+ *
144
+ * A person arriving reopens at once, whatever the cadence: the tab becoming
145
+ * visible or the window regaining focus. (The heal broadcast is the third
146
+ * instant road; the hooks subscribe to it themselves.) Arrivals are held to
147
+ * one reopen no sooner than {@link SESSION_REFUSED_RETRY_DELAY_MS} after the
148
+ * refusal that scheduled this, so focus churn can never run the loop faster
149
+ * than the fixed cadence it replaced.
150
+ */ export function scheduleRefusedReopen(reopen, streakStartedAt) {
151
+ const scheduledAt = Date.now();
152
+ let timer = null;
153
+ let dueAt = Number.POSITIVE_INFINITY;
154
+ let settled = false;
155
+ const disarm = ()=>{
156
+ if (timer !== null) clearTimeout(timer);
157
+ timer = null;
158
+ dueAt = Number.POSITIVE_INFINITY;
159
+ };
160
+ const fire = ()=>{
161
+ if (settled) return;
162
+ cancel();
163
+ reopen();
164
+ };
165
+ /** Reopen no later than `atMs`; an earlier due time already set wins. */ const arm = (atMs)=>{
166
+ if (settled || atMs >= dueAt) return;
167
+ if (atMs <= Date.now()) {
168
+ fire();
169
+ return;
170
+ }
171
+ if (timer !== null) clearTimeout(timer);
172
+ dueAt = atMs;
173
+ timer = setTimeout(fire, atMs - Date.now());
174
+ };
175
+ const onArrival = ()=>{
176
+ if (tabIsHidden()) return;
177
+ arm(scheduledAt + SESSION_REFUSED_RETRY_DELAY_MS);
178
+ };
179
+ // `visibilitychange` fires on the way INTO hidden as well as out of it.
180
+ const onVisibilityChange = ()=>{
181
+ if (tabIsHidden()) disarm();
182
+ else onArrival();
183
+ };
184
+ const cancel = ()=>{
185
+ settled = true;
186
+ disarm();
187
+ if (typeof document !== 'undefined') {
188
+ document.removeEventListener('visibilitychange', onVisibilityChange);
189
+ }
190
+ if (typeof window !== 'undefined') {
191
+ window.removeEventListener('focus', onArrival);
192
+ }
193
+ };
194
+ if (typeof document !== 'undefined') {
195
+ document.addEventListener('visibilitychange', onVisibilityChange);
196
+ }
197
+ if (typeof window !== 'undefined') {
198
+ window.addEventListener('focus', onArrival);
199
+ }
200
+ if (!tabIsHidden()) arm(scheduledAt + refusedRetryDelayMs(streakStartedAt));
201
+ return cancel;
202
+ }
203
+ /**
204
+ * The same seam in the other direction: the app telling refused listeners
205
+ * that the session came back (AGL-1066).
206
+ *
207
+ * ## Why this has to exist
208
+ *
209
+ * A refused listen recovers today only because the retry loop never stops —
210
+ * every reopen is another chance for the token to have attached. That is also
211
+ * the single reason the AGL-664 in-place re-auth works at all: nothing else
212
+ * re-subscribes. The hooks' effects depend on the query's identity
213
+ * (`[ref.firestore, ref.path]` and each caller's `deps`), and a `stale`-reason
214
+ * re-auth signs the same uid back in, so no dependency changes and no effect
215
+ * re-runs. `AuthenticatedLayout` deliberately holds the tree MOUNTED while a
216
+ * re-auth prompt is pending (AGL-664), which is what makes "resume exactly
217
+ * where you were" true — and it is exactly why a remount cannot be relied on
218
+ * to reopen anything.
219
+ *
220
+ * So any change that lets a refusal streak go terminal — the AGL-1066 item-3
221
+ * flip — needs this channel first, or a user who re-authenticates
222
+ * successfully sits in front of an errored console until they reload.
223
+ *
224
+ * ## What may broadcast, and what must not
225
+ *
226
+ * A heal is a RECOVERY FROM DENIAL, not any token event. Firebase refreshes
227
+ * the ID token roughly hourly; broadcasting on that would reopen every
228
+ * listener in the console on a timer for no reason. The console broadcasts
229
+ * from one place — a session fault that has been resolved — see
230
+ * `apps/console/utils/session-heal.ts`.
231
+ *
232
+ * Subscribers gate themselves as well: a listener the server is NOT refusing
233
+ * ignores the broadcast entirely, so even a spurious heal costs nothing.
234
+ * Nothing here clears `serverDenied` or `deniedStreak` — only a snapshot the
235
+ * server answered is evidence, and that invariant does not bend for a
236
+ * hopeful signal from the auth layer.
237
+ */ const healListeners = new Set();
238
+ /**
239
+ * Listen for "the session may have recovered". Returns the unsubscribe.
240
+ *
241
+ * The callback runs synchronously inside {@link reportFirestoreSessionHeal},
242
+ * so it must be cheap and must tolerate being called when there is nothing
243
+ * to do.
244
+ */ export function subscribeFirestoreSessionHeal(listener) {
245
+ healListeners.add(listener);
246
+ return ()=>void healListeners.delete(listener);
247
+ }
248
+ /**
249
+ * Tell every listener the session may have recovered.
250
+ *
251
+ * Iterates a COPY: a subscriber reopening its listen can unsubscribe (an
252
+ * unmount racing the broadcast) and mutating the set mid-iteration would skip
253
+ * the next one.
254
+ */ export function reportFirestoreSessionHeal() {
255
+ for (const listener of [
256
+ ...healListeners
257
+ ])listener();
258
+ }
259
+ /**
260
+ * Consecutive refusals before a listener says anything.
261
+ *
262
+ * Matched to `firestore-one-shot-retry`'s budget so both read styles apply
263
+ * the same bar: surviving this many means it is not the AGL-216/217
264
+ * post-sign-in race, which resolves in well under two seconds.
265
+ *
266
+ * The streak this counts is NOT the hook's retry `attempt`. That one is
267
+ * reset by any snapshot, including one served from cache, so under
268
+ * `persistentLocalCache` it never reaches any threshold at all — see
269
+ * `use-firestore-collection-cached-retry.spec.ts`. This one is reset only by
270
+ * a snapshot the SERVER answered, which is the only evidence that actually
271
+ * bears on whether the session can read.
272
+ */ export const DENIAL_STREAK_TO_REPORT = 5;
273
+ /**
274
+ * The collection a listen was against, when it can be known from public API.
275
+ *
276
+ * `DocumentReference` exposes `parent`, and a `CollectionReference` exposes
277
+ * `path`. A filtered `Query` exposes neither, and reaching into `_query` to
278
+ * get one would be reading SDK internals — so those report `undefined` and
279
+ * land in the shared `unknown` bucket. That can only ever fail to reach the
280
+ * two-collection threshold, never trip it falsely, which is the right way
281
+ * for this to degrade.
282
+ */ export function denialLabelForQuery(target) {
283
+ var _candidate_parent;
284
+ const candidate = target;
285
+ if (!candidate) return undefined;
286
+ // A document listen: name the collection that holds it, not the document,
287
+ // so two docs in one collection stay ONE piece of evidence.
288
+ if ((_candidate_parent = candidate.parent) == null ? void 0 : _candidate_parent.path) return candidate.parent.path;
289
+ if (candidate.type === 'collection' && candidate.path) return candidate.path;
290
+ return undefined;
291
+ }
292
+
293
+ //# sourceMappingURL=firestore-denial-reporter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firestore-denial-reporter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Let a LISTENER tell the app its reads are being refused (AGL-1066).\n *\n * `session-health` was built around one-shot reads, because that is where\n * AGL-1062 was first noticed. The console is listener-first, so the detector\n * was watching the read style the console barely uses: only four one-shot\n * call sites pass a collection label, one of them fires once per mount and\n * so can never recur inside the 60s window, and none of the pages carrying a\n * stale-write guard issues a labelled read at all. The verdict was\n * unreachable on the surfaces that needed it.\n *\n * Listeners are on every page, and they already know which collection they\n * are reading, so labelling is free rather than something a caller must\n * remember. This is the seam that lets them report without the library\n * importing the app: the console registers `reportDeniedRead` at startup and\n * the hooks call it through here. An unregistered reporter is a no-op, which\n * is what the tenant runtime wants — it has no client Firestore at all.\n *\n * ## Why this cannot fire offline\n *\n * Only `permission-denied` is ever reported. A client that has simply lost\n * the network gets no error callback at all — the listen sits there and the\n * cache keeps answering — and any one-shot it does make fails `unavailable`.\n * That is what keeps the whole mechanism from mistaking a tunnel for a dead\n * session, and it is why callers must NOT relax the code check.\n *\n * The channel runs both ways: see {@link reportFirestoreSessionHeal} at the\n * bottom of this file for the return leg, which is how a refused listener\n * learns the session came back. It lives here rather than in a module of its\n * own because a heal is defined entirely in terms of the denial it reverses —\n * split them and someone broadcasts one without the other's rules.\n */\n\nexport interface FirestoreSessionReporters {\n /** A listen was refused past the budget; names the collection. */\n onDenied: (collection?: string) => void\n /**\n * A listen was answered BY THE SERVER.\n *\n * Reported as well as the denials, and it is what keeps this safe to turn\n * on. A scoped collaborator (AGL-1041) has collections they may not read\n * by design, and with listeners reporting they could otherwise accumulate\n * two denied collections and be told their session is dead. But their\n * other listens keep succeeding against the server, and one server answer\n * clears the evidence outright — a genuinely dead session has no such\n * answer to offer, so it is the discriminator the denial count alone\n * cannot be.\n *\n * A CACHED snapshot must never be passed here; it proves nothing.\n */\n onServerRead: () => void\n}\n\nlet reporters: FirestoreSessionReporters | null = null\n\n/**\n * Register the app's reporters. Called once, by the app that owns\n * `session-health`. Pass `null` to unregister (tests).\n */\nexport function setFirestoreSessionReporters(\n next: FirestoreSessionReporters | null,\n): void {\n reporters = next\n}\n\n/** Report a refused listen. No-op when nothing is registered. */\nexport function reportFirestoreDenial(collection?: string): void {\n reporters?.onDenied(collection)\n}\n\n/** Report a listen the SERVER answered. No-op when nothing is registered. */\nexport function reportFirestoreServerRead(): void {\n noteFirestoreServerRead()\n reporters?.onServerRead()\n}\n\n/**\n * When ANY listener in this tab last got a server-answered snapshot.\n *\n * Library-internal evidence, kept regardless of whether app reporters are\n * registered — `helpers/use-doc` deliberately does not feed `session-health`\n * (its callers are the besigner document family, where a false verdict hurts\n * most) but its server answers are still proof the session can read, and\n * {@link refusedRetryDelayMs} needs that proof from every hook.\n */\nlet lastServerReadAt = 0\n\n/**\n * Record a server-answered snapshot WITHOUT reporting to `session-health`.\n * `reportFirestoreServerRead` calls this; `helpers/use-doc` calls only this.\n */\nexport function noteFirestoreServerRead(): void {\n lastServerReadAt = Date.now()\n}\n\n/** Test seam — the evidence is module scope by design. */\nexport function resetFirestoreServerReadEvidence(): void {\n lastServerReadAt = 0\n}\n\n/**\n * Cadence for a refusal streak that has outlived the retry budget (AGL-1066).\n *\n * A `permission-denied` has two very different causes and the client cannot\n * tell them apart from the error alone:\n *\n * - a SESSION fault — stale token, App Check hiccup, an AGL-1143 SSO\n * session that refuses everything. Every listener in the tab is refused,\n * and the heal is an AGL-664 in-place re-auth or a token that attaches\n * late. A young fault usually heals within seconds, so it starts at 2s.\n *\n * - a RULES denial — the ref itself is one this user may never read (a\n * sentinel id, a scoped collaborator's off-limits collection). Only a\n * rules change (a membership granted mid-session) can end it, so it\n * starts at 60s (AGL-1440).\n *\n * The discriminator is the one this module already trusts for the\n * session-health verdict: A GENUINELY DEAD SESSION HAS NO SERVER ANSWER TO\n * OFFER. If any listener has been answered by the server since this\n * listener's streak began, the session can read and this refusal is about\n * the ref.\n *\n * Either way the delay then grows with the streak's age — a tenth of it, up\n * to five minutes (AGL-2945). A refusal that has lasted ten minutes is\n * unlikely to end in the next two seconds, and refused listens are not free:\n * they bill reads (AGL-2944), so a fixed cadence made every console left open\n * on a dead session a standing cost of 1,800 refusals an hour per listener.\n * Per listener a session fault now costs ~70 in its first hour and 12 an hour\n * after; a rules denial ~35, then 12. The listen is never abandoned.\n *\n * What the fast fixed cadence used to protect is kept as WAKES rather than as\n * a timer — see {@link scheduleRefusedReopen}. The AGL-664 heal broadcast, the\n * tab becoming visible and the window regaining focus all reopen at once, so\n * the slow road only governs a page nobody is touching. The AGL-1358 write\n * guards wait on a save someone clicks, which puts someone at the page.\n */\nexport const SESSION_REFUSED_RETRY_DELAY_MS = 2_000\nexport const RULES_REFUSED_RETRY_DELAY_MS = 60_000\n/** The longest a refused listen ever waits for its next reopen. */\nexport const REFUSED_RETRY_CEILING_MS = 5 * 60_000\n/** The delay is the streak's age divided by this, between floor and ceiling. */\nconst REFUSED_RETRY_AGE_DIVISOR = 10\n\n/**\n * The delay before a spent refusal streak reopens its listen.\n *\n * `streakStartedAt` is when the streak's FIRST refusal landed. Evaluated per\n * retry, not once: evidence that arrives mid-streak (another listener's\n * first server answer) raises the floor from the next reopen on, and the age\n * term keeps growing for as long as the streak lasts.\n */\nexport function refusedRetryDelayMs(streakStartedAt: number): number {\n const floor =\n lastServerReadAt > streakStartedAt\n ? RULES_REFUSED_RETRY_DELAY_MS\n : SESSION_REFUSED_RETRY_DELAY_MS\n // A backwards clock reads as a young streak, never as a negative age.\n const age = Math.max(0, Date.now() - streakStartedAt)\n return Math.min(\n REFUSED_RETRY_CEILING_MS,\n Math.max(floor, age / REFUSED_RETRY_AGE_DIVISOR),\n )\n}\n\n/** No `document` (SSR, a worker) counts as visible: nothing to wait for. */\nfunction tabIsHidden(): boolean {\n return (\n typeof document !== 'undefined' && document.visibilityState === 'hidden'\n )\n}\n\n/**\n * Schedule the reopen of a spent refusal streak, and return its cancel.\n *\n * In a visible tab the reopen waits {@link refusedRetryDelayMs}. A HIDDEN tab\n * sets no timer at all: nobody is there to heal for, and an unattended tab on\n * a dead session was the Aug 20 – Sep 4 storm — 400K–1.7M rules denials a day\n * in ~8-hour blocks, ~$2 of reads the `read_ops_count` metric never showed\n * (AGL-2944). A tab that goes hidden while a reopen is pending drops its\n * timer and waits the same way.\n *\n * A person arriving reopens at once, whatever the cadence: the tab becoming\n * visible or the window regaining focus. (The heal broadcast is the third\n * instant road; the hooks subscribe to it themselves.) Arrivals are held to\n * one reopen no sooner than {@link SESSION_REFUSED_RETRY_DELAY_MS} after the\n * refusal that scheduled this, so focus churn can never run the loop faster\n * than the fixed cadence it replaced.\n */\nexport function scheduleRefusedReopen(\n reopen: () => void,\n streakStartedAt: number,\n): () => void {\n const scheduledAt = Date.now()\n let timer: ReturnType<typeof setTimeout> | null = null\n let dueAt = Number.POSITIVE_INFINITY\n let settled = false\n\n const disarm = () => {\n if (timer !== null) clearTimeout(timer)\n timer = null\n dueAt = Number.POSITIVE_INFINITY\n }\n\n const fire = () => {\n if (settled) return\n cancel()\n reopen()\n }\n\n /** Reopen no later than `atMs`; an earlier due time already set wins. */\n const arm = (atMs: number) => {\n if (settled || atMs >= dueAt) return\n if (atMs <= Date.now()) {\n fire()\n return\n }\n if (timer !== null) clearTimeout(timer)\n dueAt = atMs\n timer = setTimeout(fire, atMs - Date.now())\n }\n\n const onArrival = () => {\n if (tabIsHidden()) return\n arm(scheduledAt + SESSION_REFUSED_RETRY_DELAY_MS)\n }\n\n // `visibilitychange` fires on the way INTO hidden as well as out of it.\n const onVisibilityChange = () => {\n if (tabIsHidden()) disarm()\n else onArrival()\n }\n\n const cancel = () => {\n settled = true\n disarm()\n if (typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisibilityChange)\n }\n if (typeof window !== 'undefined') {\n window.removeEventListener('focus', onArrival)\n }\n }\n\n if (typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', onVisibilityChange)\n }\n if (typeof window !== 'undefined') {\n window.addEventListener('focus', onArrival)\n }\n if (!tabIsHidden()) arm(scheduledAt + refusedRetryDelayMs(streakStartedAt))\n\n return cancel\n}\n\n/**\n * The same seam in the other direction: the app telling refused listeners\n * that the session came back (AGL-1066).\n *\n * ## Why this has to exist\n *\n * A refused listen recovers today only because the retry loop never stops —\n * every reopen is another chance for the token to have attached. That is also\n * the single reason the AGL-664 in-place re-auth works at all: nothing else\n * re-subscribes. The hooks' effects depend on the query's identity\n * (`[ref.firestore, ref.path]` and each caller's `deps`), and a `stale`-reason\n * re-auth signs the same uid back in, so no dependency changes and no effect\n * re-runs. `AuthenticatedLayout` deliberately holds the tree MOUNTED while a\n * re-auth prompt is pending (AGL-664), which is what makes \"resume exactly\n * where you were\" true — and it is exactly why a remount cannot be relied on\n * to reopen anything.\n *\n * So any change that lets a refusal streak go terminal — the AGL-1066 item-3\n * flip — needs this channel first, or a user who re-authenticates\n * successfully sits in front of an errored console until they reload.\n *\n * ## What may broadcast, and what must not\n *\n * A heal is a RECOVERY FROM DENIAL, not any token event. Firebase refreshes\n * the ID token roughly hourly; broadcasting on that would reopen every\n * listener in the console on a timer for no reason. The console broadcasts\n * from one place — a session fault that has been resolved — see\n * `apps/console/utils/session-heal.ts`.\n *\n * Subscribers gate themselves as well: a listener the server is NOT refusing\n * ignores the broadcast entirely, so even a spurious heal costs nothing.\n * Nothing here clears `serverDenied` or `deniedStreak` — only a snapshot the\n * server answered is evidence, and that invariant does not bend for a\n * hopeful signal from the auth layer.\n */\nconst healListeners = new Set<() => void>()\n\n/**\n * Listen for \"the session may have recovered\". Returns the unsubscribe.\n *\n * The callback runs synchronously inside {@link reportFirestoreSessionHeal},\n * so it must be cheap and must tolerate being called when there is nothing\n * to do.\n */\nexport function subscribeFirestoreSessionHeal(\n listener: () => void,\n): () => void {\n healListeners.add(listener)\n return () => void healListeners.delete(listener)\n}\n\n/**\n * Tell every listener the session may have recovered.\n *\n * Iterates a COPY: a subscriber reopening its listen can unsubscribe (an\n * unmount racing the broadcast) and mutating the set mid-iteration would skip\n * the next one.\n */\nexport function reportFirestoreSessionHeal(): void {\n for (const listener of [...healListeners]) listener()\n}\n\n/**\n * Consecutive refusals before a listener says anything.\n *\n * Matched to `firestore-one-shot-retry`'s budget so both read styles apply\n * the same bar: surviving this many means it is not the AGL-216/217\n * post-sign-in race, which resolves in well under two seconds.\n *\n * The streak this counts is NOT the hook's retry `attempt`. That one is\n * reset by any snapshot, including one served from cache, so under\n * `persistentLocalCache` it never reaches any threshold at all — see\n * `use-firestore-collection-cached-retry.spec.ts`. This one is reset only by\n * a snapshot the SERVER answered, which is the only evidence that actually\n * bears on whether the session can read.\n */\nexport const DENIAL_STREAK_TO_REPORT = 5\n\n/**\n * The collection a listen was against, when it can be known from public API.\n *\n * `DocumentReference` exposes `parent`, and a `CollectionReference` exposes\n * `path`. A filtered `Query` exposes neither, and reaching into `_query` to\n * get one would be reading SDK internals — so those report `undefined` and\n * land in the shared `unknown` bucket. That can only ever fail to reach the\n * two-collection threshold, never trip it falsely, which is the right way\n * for this to degrade.\n */\nexport function denialLabelForQuery(target: unknown): string | undefined {\n const candidate = target as\n | { type?: string; path?: string; parent?: { path?: string } }\n | null\n | undefined\n if (!candidate) return undefined\n // A document listen: name the collection that holds it, not the document,\n // so two docs in one collection stay ONE piece of evidence.\n if (candidate.parent?.path) return candidate.parent.path\n if (candidate.type === 'collection' && candidate.path) return candidate.path\n return undefined\n}\n"],"names":["reporters","setFirestoreSessionReporters","next","reportFirestoreDenial","collection","onDenied","reportFirestoreServerRead","noteFirestoreServerRead","onServerRead","lastServerReadAt","Date","now","resetFirestoreServerReadEvidence","SESSION_REFUSED_RETRY_DELAY_MS","RULES_REFUSED_RETRY_DELAY_MS","REFUSED_RETRY_CEILING_MS","REFUSED_RETRY_AGE_DIVISOR","refusedRetryDelayMs","streakStartedAt","floor","age","Math","max","min","tabIsHidden","document","visibilityState","scheduleRefusedReopen","reopen","scheduledAt","timer","dueAt","Number","POSITIVE_INFINITY","settled","disarm","clearTimeout","fire","cancel","arm","atMs","setTimeout","onArrival","onVisibilityChange","removeEventListener","window","addEventListener","healListeners","Set","subscribeFirestoreSessionHeal","listener","add","delete","reportFirestoreSessionHeal","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","target","candidate","undefined","parent","path","type"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAsBD,IAAIA,YAA8C;AAElD;;;CAGC,GACD,OAAO,SAASC,6BACdC,IAAsC;IAEtCF,YAAYE;AACd;AAEA,+DAA+D,GAC/D,OAAO,SAASC,sBAAsBC,UAAmB;IACvDJ,6BAAAA,UAAWK,QAAQ,CAACD;AACtB;AAEA,2EAA2E,GAC3E,OAAO,SAASE;IACdC;IACAP,6BAAAA,UAAWQ,YAAY;AACzB;AAEA;;;;;;;;CAQC,GACD,IAAIC,mBAAmB;AAEvB;;;CAGC,GACD,OAAO,SAASF;IACdE,mBAAmBC,KAAKC,GAAG;AAC7B;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,mBAAmB;AACrB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,MAAMI,iCAAiC,KAAK;AACnD,OAAO,MAAMC,+BAA+B,MAAM;AAClD,iEAAiE,GACjE,OAAO,MAAMC,2BAA2B,IAAI,MAAM;AAClD,8EAA8E,GAC9E,MAAMC,4BAA4B;AAElC;;;;;;;CAOC,GACD,OAAO,SAASC,oBAAoBC,eAAuB;IACzD,MAAMC,QACJV,mBAAmBS,kBACfJ,+BACAD;IACN,sEAAsE;IACtE,MAAMO,MAAMC,KAAKC,GAAG,CAAC,GAAGZ,KAAKC,GAAG,KAAKO;IACrC,OAAOG,KAAKE,GAAG,CACbR,0BACAM,KAAKC,GAAG,CAACH,OAAOC,MAAMJ;AAE1B;AAEA,0EAA0E,GAC1E,SAASQ;IACP,OACE,OAAOC,aAAa,eAAeA,SAASC,eAAe,KAAK;AAEpE;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,sBACdC,MAAkB,EAClBV,eAAuB;IAEvB,MAAMW,cAAcnB,KAAKC,GAAG;IAC5B,IAAImB,QAA8C;IAClD,IAAIC,QAAQC,OAAOC,iBAAiB;IACpC,IAAIC,UAAU;IAEd,MAAMC,SAAS;QACb,IAAIL,UAAU,MAAMM,aAAaN;QACjCA,QAAQ;QACRC,QAAQC,OAAOC,iBAAiB;IAClC;IAEA,MAAMI,OAAO;QACX,IAAIH,SAAS;QACbI;QACAV;IACF;IAEA,uEAAuE,GACvE,MAAMW,MAAM,CAACC;QACX,IAAIN,WAAWM,QAAQT,OAAO;QAC9B,IAAIS,QAAQ9B,KAAKC,GAAG,IAAI;YACtB0B;YACA;QACF;QACA,IAAIP,UAAU,MAAMM,aAAaN;QACjCC,QAAQS;QACRV,QAAQW,WAAWJ,MAAMG,OAAO9B,KAAKC,GAAG;IAC1C;IAEA,MAAM+B,YAAY;QAChB,IAAIlB,eAAe;QACnBe,IAAIV,cAAchB;IACpB;IAEA,wEAAwE;IACxE,MAAM8B,qBAAqB;QACzB,IAAInB,eAAeW;aACdO;IACP;IAEA,MAAMJ,SAAS;QACbJ,UAAU;QACVC;QACA,IAAI,OAAOV,aAAa,aAAa;YACnCA,SAASmB,mBAAmB,CAAC,oBAAoBD;QACnD;QACA,IAAI,OAAOE,WAAW,aAAa;YACjCA,OAAOD,mBAAmB,CAAC,SAASF;QACtC;IACF;IAEA,IAAI,OAAOjB,aAAa,aAAa;QACnCA,SAASqB,gBAAgB,CAAC,oBAAoBH;IAChD;IACA,IAAI,OAAOE,WAAW,aAAa;QACjCA,OAAOC,gBAAgB,CAAC,SAASJ;IACnC;IACA,IAAI,CAAClB,eAAee,IAAIV,cAAcZ,oBAAoBC;IAE1D,OAAOoB;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,MAAMS,gBAAgB,IAAIC;AAE1B;;;;;;CAMC,GACD,OAAO,SAASC,8BACdC,QAAoB;IAEpBH,cAAcI,GAAG,CAACD;IAClB,OAAO,IAAM,KAAKH,cAAcK,MAAM,CAACF;AACzC;AAEA;;;;;;CAMC,GACD,OAAO,SAASG;IACd,KAAK,MAAMH,YAAY;WAAIH;KAAc,CAAEG;AAC7C;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMI,0BAA0B,EAAC;AAExC;;;;;;;;;CASC,GACD,OAAO,SAASC,oBAAoBC,MAAe;QAQ7CC;IAPJ,MAAMA,YAAYD;IAIlB,IAAI,CAACC,WAAW,OAAOC;IACvB,0EAA0E;IAC1E,4DAA4D;IAC5D,KAAID,oBAAAA,UAAUE,MAAM,qBAAhBF,kBAAkBG,IAAI,EAAE,OAAOH,UAAUE,MAAM,CAACC,IAAI;IACxD,IAAIH,UAAUI,IAAI,KAAK,gBAAgBJ,UAAUG,IAAI,EAAE,OAAOH,UAAUG,IAAI;IAC5E,OAAOF;AACT"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type DocumentReference } from 'firebase/firestore';
18
+ /**
19
+ * Compression at rest for a besigner document that has no converter of its
20
+ * own — the two EMAIL editors (AGL-1151).
21
+ *
22
+ * ## Why a converter rather than decoding at the read site
23
+ *
24
+ * Both email besigners read their version with a bare `useFirestoreDoc` and
25
+ * write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to
26
+ * be in ONE shape across four comparisons for either to work:
27
+ *
28
+ * - `useBesignerDocument` diffs the arriving snapshot against its baseline to
29
+ * decide whether somebody else has written, and against the tree it just
30
+ * sent to recognise the echo of its own save;
31
+ * - `saveNodesGuarded` re-reads inside its transaction and compares the
32
+ * stored `nodes` against the baseline the editor presents.
33
+ *
34
+ * Decoding at the read site alone would leave the stored form on one side of
35
+ * those comparisons and a decoded map on the other, so every save would look
36
+ * like a conflict and be refused. A converter puts the decode BEFORE all four,
37
+ * which is why the screen, layout, component and form editors — which have
38
+ * carried converters since AGL-1151 — never had the problem.
39
+ *
40
+ * ## What it does not do
41
+ *
42
+ * It does not stamp `updatedAt`. The version converters do, because their
43
+ * callers do not; both email editors pass their own stamp, and the seeding
44
+ * writes pass the one they also put on the template document, so a stamp
45
+ * applied here would silently replace a value the caller is coordinating.
46
+ */
47
+ export declare const besignerNodesConverter: {
48
+ toFirestore(data: Record<string, any>): {
49
+ [x: string]: any;
50
+ };
51
+ fromFirestore(snapshot: any, options: any): any;
52
+ };
53
+ /**
54
+ * The same document, read and written through {@link besignerNodesConverter}.
55
+ *
56
+ * A named wrapper rather than `.withConverter(...)` at each call site: an
57
+ * editor has to apply it to BOTH the ref it reads and the ref it saves
58
+ * through, and the failure when only one of them gets it is a save refused as
59
+ * a phantom conflict rather than anything that looks like a missing converter.
60
+ */
61
+ export declare function withBesignerNodes<T>(ref: DocumentReference<any>): DocumentReference<T>;
62
+ export default besignerNodesConverter;
@@ -0,0 +1,91 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { decodeStoredNodes, encodeStoredNodes } from "@aglyn/aglyn";
19
+ import { Bytes } from "firebase/firestore";
20
+ /**
21
+ * Compression at rest for a besigner document that has no converter of its
22
+ * own — the two EMAIL editors (AGL-1151).
23
+ *
24
+ * ## Why a converter rather than decoding at the read site
25
+ *
26
+ * Both email besigners read their version with a bare `useFirestoreDoc` and
27
+ * write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to
28
+ * be in ONE shape across four comparisons for either to work:
29
+ *
30
+ * - `useBesignerDocument` diffs the arriving snapshot against its baseline to
31
+ * decide whether somebody else has written, and against the tree it just
32
+ * sent to recognise the echo of its own save;
33
+ * - `saveNodesGuarded` re-reads inside its transaction and compares the
34
+ * stored `nodes` against the baseline the editor presents.
35
+ *
36
+ * Decoding at the read site alone would leave the stored form on one side of
37
+ * those comparisons and a decoded map on the other, so every save would look
38
+ * like a conflict and be refused. A converter puts the decode BEFORE all four,
39
+ * which is why the screen, layout, component and form editors — which have
40
+ * carried converters since AGL-1151 — never had the problem.
41
+ *
42
+ * ## What it does not do
43
+ *
44
+ * It does not stamp `updatedAt`. The version converters do, because their
45
+ * callers do not; both email editors pass their own stamp, and the seeding
46
+ * writes pass the one they also put on the template document, so a stamp
47
+ * applied here would silently replace a value the caller is coordinating.
48
+ */ export const besignerNodesConverter = {
49
+ toFirestore (data) {
50
+ const { $id } = data, rest = _object_without_properties_loose(data, [
51
+ "$id"
52
+ ]);
53
+ // Only emit `nodes` when the write actually carries them (AGL-1250).
54
+ // Encoding `rest.nodes` unconditionally would make a partial
55
+ // `setDoc(…, {merge: true})` — one that set some other field — ship an
56
+ // empty map, and merge faithfully merges that emptiness over the real
57
+ // tree, destroying the document.
58
+ if ((rest == null ? void 0 : rest.nodes) === undefined) return rest;
59
+ const nodes = encodeStoredNodes(rest.nodes);
60
+ // `encodeStoredNodes` passes an already-encoded value through, so a write
61
+ // that round-trips what it read cannot double-encode. `Bytes` is what the
62
+ // client SDK accepts for a bytes field.
63
+ return _extends({}, rest, nodes ? {
64
+ nodes: Bytes.fromUint8Array(nodes)
65
+ } : {});
66
+ },
67
+ fromFirestore (snapshot, options) {
68
+ if (!snapshot.exists()) return undefined;
69
+ const data = snapshot.data(options);
70
+ if ((data == null ? void 0 : data.nodes) === undefined) return data;
71
+ // BOTH forms, permanently. Every email version written before this
72
+ // converter is a plain map and nothing migrates them; `decodeStoredNodes`
73
+ // returns a plain map unchanged, so one call serves both.
74
+ return _extends({}, data, {
75
+ nodes: decodeStoredNodes(data.nodes)
76
+ });
77
+ }
78
+ };
79
+ /**
80
+ * The same document, read and written through {@link besignerNodesConverter}.
81
+ *
82
+ * A named wrapper rather than `.withConverter(...)` at each call site: an
83
+ * editor has to apply it to BOTH the ref it reads and the ref it saves
84
+ * through, and the failure when only one of them gets it is a save refused as
85
+ * a phantom conflict rather than anything that looks like a missing converter.
86
+ */ export function withBesignerNodes(ref) {
87
+ return ref.withConverter(besignerNodesConverter);
88
+ }
89
+ export default besignerNodesConverter;
90
+
91
+ //# sourceMappingURL=besigner-nodes-converter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/besigner-nodes-converter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { decodeStoredNodes, encodeStoredNodes } from '@aglyn/aglyn'\nimport { Bytes, type DocumentReference } from 'firebase/firestore'\n\n/**\n * Compression at rest for a besigner document that has no converter of its\n * own — the two EMAIL editors (AGL-1151).\n *\n * ## Why a converter rather than decoding at the read site\n *\n * Both email besigners read their version with a bare `useFirestoreDoc` and\n * write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to\n * be in ONE shape across four comparisons for either to work:\n *\n * - `useBesignerDocument` diffs the arriving snapshot against its baseline to\n * decide whether somebody else has written, and against the tree it just\n * sent to recognise the echo of its own save;\n * - `saveNodesGuarded` re-reads inside its transaction and compares the\n * stored `nodes` against the baseline the editor presents.\n *\n * Decoding at the read site alone would leave the stored form on one side of\n * those comparisons and a decoded map on the other, so every save would look\n * like a conflict and be refused. A converter puts the decode BEFORE all four,\n * which is why the screen, layout, component and form editors — which have\n * carried converters since AGL-1151 — never had the problem.\n *\n * ## What it does not do\n *\n * It does not stamp `updatedAt`. The version converters do, because their\n * callers do not; both email editors pass their own stamp, and the seeding\n * writes pass the one they also put on the template document, so a stamp\n * applied here would silently replace a value the caller is coordinating.\n */\nexport const besignerNodesConverter = {\n toFirestore(data: Record<string, any>) {\n const { $id, ...rest } = data\n // Only emit `nodes` when the write actually carries them (AGL-1250).\n // Encoding `rest.nodes` unconditionally would make a partial\n // `setDoc(…, {merge: true})` — one that set some other field — ship an\n // empty map, and merge faithfully merges that emptiness over the real\n // tree, destroying the document.\n if (rest?.nodes === undefined) return rest\n const nodes = encodeStoredNodes(rest.nodes)\n // `encodeStoredNodes` passes an already-encoded value through, so a write\n // that round-trips what it read cannot double-encode. `Bytes` is what the\n // client SDK accepts for a bytes field.\n return { ...rest, ...(nodes ? { nodes: Bytes.fromUint8Array(nodes) } : {}) }\n },\n fromFirestore(snapshot: any, options: any) {\n if (!snapshot.exists()) return undefined\n const data = snapshot.data(options)\n if (data?.nodes === undefined) return data\n // BOTH forms, permanently. Every email version written before this\n // converter is a plain map and nothing migrates them; `decodeStoredNodes`\n // returns a plain map unchanged, so one call serves both.\n return { ...data, nodes: decodeStoredNodes(data.nodes) }\n },\n}\n\n/**\n * The same document, read and written through {@link besignerNodesConverter}.\n *\n * A named wrapper rather than `.withConverter(...)` at each call site: an\n * editor has to apply it to BOTH the ref it reads and the ref it saves\n * through, and the failure when only one of them gets it is a save refused as\n * a phantom conflict rather than anything that looks like a missing converter.\n */\nexport function withBesignerNodes<T>(\n ref: DocumentReference<any>,\n): DocumentReference<T> {\n return ref.withConverter(\n besignerNodesConverter as never,\n ) as DocumentReference<T>\n}\n\nexport default besignerNodesConverter\n"],"names":["decodeStoredNodes","encodeStoredNodes","Bytes","besignerNodesConverter","toFirestore","data","$id","rest","nodes","undefined","fromUint8Array","fromFirestore","snapshot","options","exists","withBesignerNodes","ref","withConverter"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,eAAc;AACnE,SAASC,KAAK,QAAgC,qBAAoB;AAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,yBAAyB;IACpCC,aAAYC,IAAyB;QACnC,MAAM,EAAEC,GAAG,EAAW,GAAGD,MAATE,wCAASF;;;QACzB,qEAAqE;QACrE,6DAA6D;QAC7D,uEAAuE;QACvE,sEAAsE;QACtE,iCAAiC;QACjC,IAAIE,CAAAA,wBAAAA,KAAMC,KAAK,MAAKC,WAAW,OAAOF;QACtC,MAAMC,QAAQP,kBAAkBM,KAAKC,KAAK;QAC1C,0EAA0E;QAC1E,0EAA0E;QAC1E,wCAAwC;QACxC,OAAO,aAAKD,MAAUC,QAAQ;YAAEA,OAAON,MAAMQ,cAAc,CAACF;QAAO,IAAI,CAAC;IAC1E;IACAG,eAAcC,QAAa,EAAEC,OAAY;QACvC,IAAI,CAACD,SAASE,MAAM,IAAI,OAAOL;QAC/B,MAAMJ,OAAOO,SAASP,IAAI,CAACQ;QAC3B,IAAIR,CAAAA,wBAAAA,KAAMG,KAAK,MAAKC,WAAW,OAAOJ;QACtC,mEAAmE;QACnE,0EAA0E;QAC1E,0DAA0D;QAC1D,OAAO,aAAKA;YAAMG,OAAOR,kBAAkBK,KAAKG,KAAK;;IACvD;AACF,EAAC;AAED;;;;;;;CAOC,GACD,OAAO,SAASO,kBACdC,GAA2B;IAE3B,OAAOA,IAAIC,aAAa,CACtBd;AAEJ;AAEA,eAAeA,uBAAsB"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type DocumentReference } from 'firebase/firestore';
18
+ /**
19
+ * What the editor believes the stored document looked like when it last
20
+ * agreed with it. Both fields optional: an absent baseline means "never
21
+ * established", which must not refuse the save (see `hasConcurrentWrite`).
22
+ */
23
+ export interface NodesSaveBaseline {
24
+ /** `versionStamp(...)` of the document's `updatedAt` at load/last save. */
25
+ baseStamp?: string | null;
26
+ /** The stored nodes as of that agreement, in STORED shape. */
27
+ baseNodes?: unknown;
28
+ }
29
+ /**
30
+ * Persists a besigner node map only if the stored document still matches the
31
+ * baseline the editor loaded (AGL-1301).
32
+ *
33
+ * The AGL-674 guard is listener-based, so a save clicked in the window
34
+ * between another writer's commit and the local snapshot's delivery still
35
+ * overwrote it — the guard had simply not heard yet. Running the same check
36
+ * inside a Firestore transaction closes that window: the `get` is served by
37
+ * the backend (never the local cache), the check runs against what is
38
+ * actually stored, and the write only commits if nothing moved. Contention
39
+ * during the transaction re-runs it against fresh data, so the late writer
40
+ * aborts rather than winning by timing.
41
+ *
42
+ * TWO preconditions, deliberately:
43
+ *
44
+ * - The `updatedAt` stamp, same equality-only comparison as the listener
45
+ * guard.
46
+ * - The stored `nodes` content itself. `updatedAt` is an app-level FIELD
47
+ * that a writer can forget — admin backfills have updated `nodes` without
48
+ * touching it, and such a write was invisible to the stamp and got
49
+ * clobbered. The web client SDK exposes no server-side `updateTime` on a
50
+ * snapshot (`snapshot.metadata` carries only `hasPendingWrites` /
51
+ * `fromCache`; document update times are Admin-SDK-only), so comparing
52
+ * the content is the strongest check actually available to this client —
53
+ * and it is exact: this save only ever writes `nodes` (+ stamp), so
54
+ * "did `nodes` change under me" is precisely the conflict that loses work.
55
+ *
56
+ * Aborts by throwing `ConcurrentEditError`, which `useBesignerDocument`
57
+ * surfaces through the existing refusal UX. A converter on `ref` applies as
58
+ * usual, so compressed-at-rest documents stay compressed and keep stamping
59
+ * `updatedAt` on write.
60
+ *
61
+ * Trade-off, accepted: a transaction requires the backend, so this save no
62
+ * longer queues offline. A conflict guard that consults a local cache would
63
+ * be theatre — the AGL-674 refusal already implies "the server has spoken".
64
+ *
65
+ * ## Why this did NOT change when the client relaxed (AGL-2486)
66
+ *
67
+ * The client no longer refuses merely because the stored version moved: with
68
+ * the co-edit mirror running it usually already holds what the other writer
69
+ * stored, so its own write is a superset (`incorporatesStoredNodes`). What
70
+ * it does with that evidence is advance its BASELINE onto the write it has
71
+ * incorporated — and this precondition is what makes that safe. It is
72
+ * unchanged and strictly stricter: the baseline the client presents must
73
+ * still match what is actually stored when the transaction reads it, so
74
+ * anything that lands between the client's decision and this commit moves
75
+ * `nodes` away from it and aborts. The docs' promise that "even a save
76
+ * racing the conflict by milliseconds is refused" therefore holds exactly as
77
+ * before; the client simply stopped refusing saves this would have accepted.
78
+ */
79
+ export declare function saveNodesGuarded<T>(ref: DocumentReference<T>, data: Partial<T>, baseline?: NodesSaveBaseline): Promise<void>;
80
+ export default saveNodesGuarded;