@fuzdev/fuz_app 0.141.0 → 0.142.0

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 (147) hide show
  1. package/dist/actions/CLAUDE.md +10 -5
  2. package/dist/actions/connection_closer.d.ts +19 -2
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +25 -2
  5. package/dist/actions/perform_action.d.ts +10 -0
  6. package/dist/actions/perform_action.d.ts.map +1 -1
  7. package/dist/actions/perform_action.js +23 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -2
  10. package/dist/auth/CLAUDE.md +33 -13
  11. package/dist/auth/account_actions.d.ts.map +1 -1
  12. package/dist/auth/account_actions.js +12 -1
  13. package/dist/auth/account_route_schema.d.ts.map +1 -1
  14. package/dist/auth/account_route_schema.js +8 -0
  15. package/dist/auth/account_routes.d.ts +7 -2
  16. package/dist/auth/account_routes.d.ts.map +1 -1
  17. package/dist/auth/account_routes.js +240 -187
  18. package/dist/auth/admin_action_specs.d.ts +6 -1
  19. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  20. package/dist/auth/admin_action_specs.js +6 -1
  21. package/dist/auth/admin_actions.d.ts.map +1 -1
  22. package/dist/auth/admin_actions.js +14 -2
  23. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  24. package/dist/auth/bootstrap_routes.js +85 -76
  25. package/dist/auth/cell_action_specs.d.ts +7 -3
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +9 -5
  28. package/dist/auth/cell_actions.d.ts +3 -3
  29. package/dist/auth/cell_actions.d.ts.map +1 -1
  30. package/dist/auth/cell_actions.js +67 -38
  31. package/dist/auth/cell_authorize.d.ts +69 -8
  32. package/dist/auth/cell_authorize.d.ts.map +1 -1
  33. package/dist/auth/cell_authorize.js +89 -9
  34. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  35. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  36. package/dist/auth/cell_field_action_specs.js +7 -7
  37. package/dist/auth/cell_field_actions.d.ts +7 -5
  38. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_field_actions.js +24 -22
  40. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  41. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  42. package/dist/auth/cell_grant_action_specs.js +6 -0
  43. package/dist/auth/cell_grant_actions.d.ts +3 -1
  44. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  45. package/dist/auth/cell_grant_actions.js +16 -2
  46. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  47. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  48. package/dist/auth/cell_item_action_specs.js +7 -7
  49. package/dist/auth/cell_item_actions.d.ts +6 -5
  50. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  51. package/dist/auth/cell_item_actions.js +23 -22
  52. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  53. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  54. package/dist/auth/cell_relation_list_limit.js +27 -0
  55. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  56. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  57. package/dist/auth/cell_relation_visibility.js +6 -5
  58. package/dist/auth/cell_steward.d.ts +45 -0
  59. package/dist/auth/cell_steward.d.ts.map +1 -0
  60. package/dist/auth/cell_steward.js +53 -0
  61. package/dist/auth/request_context.d.ts +4 -0
  62. package/dist/auth/request_context.d.ts.map +1 -1
  63. package/dist/auth/request_context.js +6 -1
  64. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  65. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  66. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  67. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_actions.js +23 -16
  69. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +16 -8
  72. package/dist/auth/signup_routes.d.ts +4 -3
  73. package/dist/auth/signup_routes.d.ts.map +1 -1
  74. package/dist/auth/signup_routes.js +157 -134
  75. package/dist/auth/token_scope.d.ts +7 -1
  76. package/dist/auth/token_scope.d.ts.map +1 -1
  77. package/dist/auth/token_scope.js +8 -2
  78. package/dist/db/CLAUDE.md +32 -19
  79. package/dist/db/cell_ddl.d.ts +1 -2
  80. package/dist/db/cell_ddl.d.ts.map +1 -1
  81. package/dist/db/cell_ddl.js +1 -2
  82. package/dist/db/cell_field_queries.d.ts +20 -18
  83. package/dist/db/cell_field_queries.d.ts.map +1 -1
  84. package/dist/db/cell_field_queries.js +28 -22
  85. package/dist/db/cell_item_queries.d.ts +20 -14
  86. package/dist/db/cell_item_queries.d.ts.map +1 -1
  87. package/dist/db/cell_item_queries.js +28 -18
  88. package/dist/db/cell_moderation.d.ts +67 -0
  89. package/dist/db/cell_moderation.d.ts.map +1 -0
  90. package/dist/db/cell_moderation.js +66 -0
  91. package/dist/db/cell_queries.d.ts +44 -14
  92. package/dist/db/cell_queries.d.ts.map +1 -1
  93. package/dist/db/cell_queries.js +68 -45
  94. package/dist/db/cell_view_sql.d.ts +102 -0
  95. package/dist/db/cell_view_sql.d.ts.map +1 -0
  96. package/dist/db/cell_view_sql.js +112 -0
  97. package/dist/db/db_pg.d.ts +1 -1
  98. package/dist/db/db_pg.js +1 -1
  99. package/dist/http/auth_shape.d.ts +5 -2
  100. package/dist/http/auth_shape.d.ts.map +1 -1
  101. package/dist/http/auth_shape.js +5 -2
  102. package/dist/http/ip_canonical.d.ts +30 -3
  103. package/dist/http/ip_canonical.d.ts.map +1 -1
  104. package/dist/http/ip_canonical.js +50 -3
  105. package/dist/primitive_schemas.d.ts +10 -1
  106. package/dist/primitive_schemas.d.ts.map +1 -1
  107. package/dist/primitive_schemas.js +11 -2
  108. package/dist/rate_limiter.d.ts +179 -12
  109. package/dist/rate_limiter.d.ts.map +1 -1
  110. package/dist/rate_limiter.js +352 -42
  111. package/dist/server/serve_fact_route.d.ts +8 -4
  112. package/dist/server/serve_fact_route.d.ts.map +1 -1
  113. package/dist/server/serve_fact_route.js +15 -6
  114. package/dist/testing/CLAUDE.md +10 -3
  115. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  116. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  117. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  119. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  120. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/cell_relations.js +145 -2
  122. package/dist/testing/cross_backend/expected_schema.json +0 -1
  123. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  124. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/login_security.js +154 -0
  126. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/query_shape.js +24 -2
  128. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  129. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  130. package/dist/testing/cross_backend/spine_surface_constants.d.ts +1 -1
  131. package/dist/testing/cross_backend/spine_surface_constants.js +1 -1
  132. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  134. package/dist/testing/schema_introspect.js +1 -1
  135. package/dist/testing/schema_parity.d.ts +4 -3
  136. package/dist/testing/schema_parity.d.ts.map +1 -1
  137. package/dist/testing/schema_parity.js +4 -3
  138. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  139. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  140. package/dist/testing/schema_ready_fixture.js +2 -4
  141. package/dist/ui/auth_state.svelte.d.ts +10 -0
  142. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  143. package/dist/ui/auth_state.svelte.js +22 -4
  144. package/package.json +1 -1
  145. package/dist/db/cell_history_ddl.d.ts +0 -38
  146. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  147. package/dist/db/cell_history_ddl.js +0 -61
@@ -4,10 +4,28 @@
4
4
  * Tracks failed attempts per key (typically IP address) using a sliding
5
5
  * time window. No external dependencies — state resets on server restart.
6
6
  *
7
+ * **Reserve, then settle.** A handler that checks the limiter, awaits work,
8
+ * and records afterwards lets every request of a concurrent burst pass the
9
+ * check before any of them records — a burst of any size gets through.
10
+ * `RateLimiter.try_reserve` closes that: it admits a request and counts it in
11
+ * flight in one synchronous step, and the attempt counts against the cap
12
+ * until its `RateLimitReservation` is settled — `record` keeps it as an
13
+ * attempt, `release` gives it back. Every handler whose outcome decides
14
+ * whether the attempt counts reserves, and releases in a `finally` so an
15
+ * early return or a throw never leaks an in-flight slot; `check` + `record`
16
+ * stays for callers that record in the same synchronous step they check.
17
+ *
18
+ * **Attempts count while in flight, whatever their outcome** — so a burst of
19
+ * *correct* concurrent attempts from one key is capped too. The password
20
+ * surfaces therefore reserve with `RateLimiter.reserve`, which waits up to
21
+ * `RESERVATION_WAIT_MAX_MS` for a slot when the cap is reached only by
22
+ * attempts in flight, and refuses at once when it is reached by recorded ones.
23
+ *
7
24
  * @module
8
25
  */
9
26
  import { LruMap } from '@fuzdev/fuz_util/lru_map.ts';
10
27
  import { ERROR_RATE_LIMIT_EXCEEDED } from './http/error_schemas.js';
28
+ import { PERIODIC_INTERVAL_MS_MAX } from './periodic.js';
11
29
  /**
12
30
  * Default tracked-key cap: bounds worst-case memory under key-enumeration
13
31
  * attacks (an attacker rotating source IPs cannot grow the backing map
@@ -90,10 +108,36 @@ export const announce_rate_limiters_disabled = () => {
90
108
  rate_limiters_disabled_announced = true;
91
109
  console.error(RATE_LIMITERS_DISABLED_BANNER);
92
110
  };
111
+ /**
112
+ * `retry_after` reported while a key is at its cap only because of attempts
113
+ * still in flight. Those settle within a request's lifetime, so the earliest
114
+ * moment worth retrying is the next second, not the oldest recorded attempt's
115
+ * expiry. Mirrors the Rust spine's `IN_FLIGHT_RETRY_AFTER_SECONDS`.
116
+ */
117
+ export const IN_FLIGHT_RETRY_AFTER_SECONDS = 1;
118
+ /**
119
+ * How long a password-surface reservation (`RateLimiter.reserve`) waits for a
120
+ * slot when the key is at its cap only because of attempts still in flight,
121
+ * before answering `429`. Mirrors the Rust spine's `RESERVATION_WAIT_MAX`.
122
+ *
123
+ * Counting attempts while they are in flight caps *successful* concurrent
124
+ * attempts from one key too — a class behind one NAT logging in together.
125
+ * Those attempts settle within a request's lifetime, so a short wait turns
126
+ * most of those refusals into a late success, while a key at its cap on
127
+ * *recorded* attempts is still refused at once.
128
+ */
129
+ export const RESERVATION_WAIT_MAX_MS = 5_000;
130
+ /** The reservation a disabled limiter hands out: settling it does nothing. */
131
+ const INERT_RESERVATION = Object.freeze({
132
+ record: () => { },
133
+ release: () => { }
134
+ });
135
+ const entry_is_idle = (entry) => entry.timestamps.length === 0 && entry.in_flight === 0;
93
136
  /**
94
137
  * In-memory sliding window rate limiter.
95
138
  *
96
- * Stores an array of timestamps per key. On `check`/`record`, timestamps
139
+ * Stores an array of timestamps per key, plus a count of attempts reserved
140
+ * (`try_reserve`) and not yet settled. On `check`/`record`, timestamps
97
141
  * outside the window are pruned. `retry_after` reports seconds until the
98
142
  * oldest active timestamp expires.
99
143
  *
@@ -110,10 +154,37 @@ export const announce_rate_limiters_disabled = () => {
110
154
  */
111
155
  export class RateLimiter {
112
156
  options;
113
- /** Key → array of attempt timestamps. */
157
+ /** Key → recorded attempt timestamps + attempts in flight. */
114
158
  #attempts;
115
159
  #cleanup_timer = null;
160
+ /**
161
+ * `reserve` calls parked on a key at its in-flight cap, per key, with the
162
+ * wakers that key's settles, records, and resets run so each re-decides.
163
+ * Held apart from the attempts map, so evicting a key's entry strands none
164
+ * of its waiters; a key's slot is removed when its last waiter leaves. Per
165
+ * key, so a settle wakes only the waiters it can admit or refuse — never
166
+ * every waiter on the limiter.
167
+ */
168
+ #waiters = new Map();
169
+ /**
170
+ * @param options - the limiter's bounds
171
+ * @throws {RangeError} when `max_attempts`, `window_ms`, or `cleanup_interval_ms` is not a safe integer ≥ 0, `cleanup_interval_ms` exceeds `PERIODIC_INTERVAL_MS_MAX`, or `max_keys` is neither `null` nor a positive safe integer
172
+ */
116
173
  constructor(options) {
174
+ for (const field of ['max_attempts', 'window_ms', 'cleanup_interval_ms']) {
175
+ const value = options[field];
176
+ if (!Number.isSafeInteger(value) || value < 0) {
177
+ throw new RangeError(`RateLimiterOptions.${field} must be a safe integer ≥ 0: ${value}`);
178
+ }
179
+ }
180
+ if (options.cleanup_interval_ms > PERIODIC_INTERVAL_MS_MAX) {
181
+ throw new RangeError(`RateLimiterOptions.cleanup_interval_ms must be at most ${PERIODIC_INTERVAL_MS_MAX}: ${options.cleanup_interval_ms}`);
182
+ }
183
+ if (options.max_keys !== undefined &&
184
+ options.max_keys !== null &&
185
+ !(Number.isSafeInteger(options.max_keys) && options.max_keys > 0)) {
186
+ throw new RangeError(`RateLimiterOptions.max_keys must be null or a positive safe integer: ${options.max_keys}`);
187
+ }
117
188
  this.options = options;
118
189
  const max_keys = options.max_keys === undefined ? DEFAULT_RATE_LIMITER_MAX_KEYS : options.max_keys;
119
190
  this.#attempts = max_keys === null ? new Map() : new LruMap(max_keys);
@@ -129,6 +200,17 @@ export class RateLimiter {
129
200
  get size() {
130
201
  return this.#attempts.size;
131
202
  }
203
+ /**
204
+ * Keys holding a parked-waiter slot right now (see `reserve`): each slot is
205
+ * removed with its key's last waiter. For tests pinning that a waiter
206
+ * leaves nothing behind; the twin of the Rust spine's test-only
207
+ * `waiter_keys`.
208
+ *
209
+ * @internal
210
+ */
211
+ get waiter_keys() {
212
+ return this.#waiters.size;
213
+ }
132
214
  /**
133
215
  * Custom inspect output that exposes only `options` and `size`, never the
134
216
  * tracked keys. The key set is sensitive (usernames / IP addresses) and
@@ -141,38 +223,59 @@ export class RateLimiter {
141
223
  return { options: this.options, size: this.size };
142
224
  }
143
225
  /**
144
- * Check whether `key` is allowed without recording an attempt.
226
+ * Check whether `key` is allowed without recording an attempt, counting
227
+ * recorded attempts and reserved ones still in flight.
145
228
  *
146
229
  * Prunes timestamps that fell outside the window as a side effect (and
147
- * removes the key entirely when none remain), so the backing map stays
148
- * bounded even under read-only traffic.
230
+ * removes the key entirely when none remain and none are in flight), so
231
+ * the backing map stays bounded even under read-only traffic.
232
+ *
233
+ * A check followed by an awaited operation and then a `record` lets a
234
+ * concurrent burst past the cap; reserve with `try_reserve` instead.
149
235
  *
150
236
  * @param key - rate limit key (e.g. IP address)
151
237
  * @param now - current timestamp in ms (defaults to `Date.now()`)
152
238
  * @mutates internal map - prunes expired timestamps for `key`
153
239
  */
154
240
  check(key, now = Date.now()) {
241
+ const load = this.#load(key, now);
242
+ switch (load.kind) {
243
+ case 'free':
244
+ return { allowed: true, remaining: this.options.max_attempts - load.used, retry_after: 0 };
245
+ case 'recorded':
246
+ return { allowed: false, remaining: 0, retry_after: load.retry_after };
247
+ case 'in_flight':
248
+ return { allowed: false, remaining: 0, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
249
+ }
250
+ }
251
+ /** Where `key` stands against the cap, pruning expired timestamps first. */
252
+ #load(key, now) {
155
253
  const { max_attempts, window_ms } = this.options;
156
- const cutoff = now - window_ms;
157
- const timestamps = this.#attempts.get(key);
158
- if (!timestamps) {
159
- return { allowed: true, remaining: max_attempts, retry_after: 0 };
254
+ // A cap of zero refuses before reading the key: no entry is created or
255
+ // promoted, and no reservation parks. The wait it names is the whole
256
+ // window, at least a second.
257
+ if (max_attempts === 0) {
258
+ return { kind: 'recorded', retry_after: Math.max(1, Math.ceil(window_ms / 1000)) };
160
259
  }
161
- const active = timestamps.filter((t) => t > cutoff);
162
- if (active.length !== timestamps.length) {
163
- if (active.length === 0) {
164
- this.#attempts.delete(key);
165
- }
166
- else {
167
- this.#attempts.set(key, active);
168
- }
260
+ const entry = this.#attempts.get(key);
261
+ if (!entry)
262
+ return { kind: 'free', used: 0 };
263
+ const cutoff = now - window_ms;
264
+ const active = entry.timestamps.filter((t) => t > cutoff);
265
+ entry.timestamps = active;
266
+ // A key with attempts in flight stays even with no live timestamp:
267
+ // deleting it would drop the count it carries.
268
+ if (entry_is_idle(entry)) {
269
+ this.#attempts.delete(key);
270
+ return { kind: 'free', used: 0 };
169
271
  }
170
- if (active.length < max_attempts) {
171
- return { allowed: true, remaining: max_attempts - active.length, retry_after: 0 };
272
+ // At the cap on recorded attempts alone, wait for the oldest; at the
273
+ // cap only with the in-flight attempts counted, a settle frees a slot.
274
+ if (active.length >= max_attempts) {
275
+ return { kind: 'recorded', retry_after: Math.ceil((active[0] + window_ms - now) / 1000) };
172
276
  }
173
- const oldest = active[0];
174
- const retry_after = Math.ceil((oldest + window_ms - now) / 1000);
175
- return { allowed: false, remaining: 0, retry_after };
277
+ const used = active.length + entry.in_flight;
278
+ return used < max_attempts ? { kind: 'free', used } : { kind: 'in_flight' };
176
279
  }
177
280
  /**
178
281
  * Record a failed attempt for `key` and return the updated result.
@@ -184,19 +287,21 @@ export class RateLimiter {
184
287
  record(key, now = Date.now()) {
185
288
  const { max_attempts, window_ms } = this.options;
186
289
  const cutoff = now - window_ms;
187
- let timestamps = this.#attempts.get(key);
188
- if (timestamps) {
290
+ let entry = this.#attempts.get(key);
291
+ if (entry) {
189
292
  // Prune expired entries in place.
190
- const active = timestamps.filter((t) => t > cutoff);
191
- active.push(now);
192
- this.#attempts.set(key, active);
193
- timestamps = active;
293
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
294
+ entry.timestamps.push(now);
194
295
  }
195
296
  else {
196
- timestamps = [now];
197
- this.#attempts.set(key, timestamps);
297
+ entry = { timestamps: [now], in_flight: 0 };
298
+ this.#attempts.set(key, entry);
198
299
  }
199
- const count = timestamps.length;
300
+ // A parked reservation re-decides: the key may now be at its cap on
301
+ // recorded attempts, which refuses it at once.
302
+ this.#wake(key);
303
+ const timestamps = entry.timestamps;
304
+ const count = timestamps.length + entry.in_flight;
200
305
  if (count <= max_attempts) {
201
306
  return { allowed: true, remaining: max_attempts - count, retry_after: 0 };
202
307
  }
@@ -204,6 +309,171 @@ export class RateLimiter {
204
309
  const retry_after = Math.ceil((oldest + window_ms - now) / 1000);
205
310
  return { allowed: false, remaining: 0, retry_after };
206
311
  }
312
+ /**
313
+ * Admit an attempt for `key` and count it in flight, in one synchronous
314
+ * step.
315
+ *
316
+ * The attempt counts against the cap — for this request and every
317
+ * concurrent one — until the returned reservation is settled: recorded as
318
+ * an attempt, or released. So no burst of concurrent requests gets more
319
+ * than `max_attempts` past the limiter between them. Release in a
320
+ * `finally`: nothing settles a reservation for you, so one left unsettled
321
+ * holds its slot for the life of the process.
322
+ *
323
+ * @param key - rate limit key (e.g. IP address)
324
+ * @param now - current timestamp in ms (defaults to `Date.now()`)
325
+ * @returns the reservation, or `{ok: false, retry_after}` when recorded plus in-flight attempts already reach the cap (nothing is reserved then)
326
+ * @mutates internal map - increments `key`'s in-flight count, creating its entry when absent
327
+ */
328
+ try_reserve(key, now = Date.now()) {
329
+ const checked = this.check(key, now);
330
+ if (!checked.allowed)
331
+ return { ok: false, retry_after: checked.retry_after };
332
+ return { ok: true, reservation: this.#admit(key) };
333
+ }
334
+ /**
335
+ * `try_reserve`, waiting up to `max_wait_ms` for a slot when the key is at
336
+ * its cap only because of attempts still in flight.
337
+ *
338
+ * Each settle (and record and reset) on the key wakes its waiters to
339
+ * re-decide — that key's only, so a settle costs nothing for waiters parked
340
+ * elsewhere: a freed slot is reserved; recorded attempts reaching the cap
341
+ * refuse at once with their real `retry_after`; reaching `max_wait_ms` (or
342
+ * `signal` aborting) refuses with `IN_FLIGHT_RETRY_AFTER_SECONDS`. At most
343
+ * `max_attempts` reservations wait on one key — one more is refused at
344
+ * once — so a flood can't park requests without bound. A waiter that
345
+ * leaves, by any path, leaves the key's waiter count as it found it. A
346
+ * waiter holds no place in a queue: each wake, every waiter re-decides, so
347
+ * one can lose every race for a freed slot until `max_wait_ms` refuses it.
348
+ *
349
+ * @param key - rate limit key (e.g. IP address)
350
+ * @param options - the wait bound and an abort signal
351
+ * @returns the reservation, or `{ok: false, retry_after}` as described (nothing is reserved then)
352
+ * @mutates internal map - increments `key`'s in-flight count on admission; counts the call in `key`'s waiters while it waits
353
+ */
354
+ async reserve(key, options = {}) {
355
+ const { max_wait_ms = RESERVATION_WAIT_MAX_MS, signal } = options;
356
+ const deadline = Date.now() + max_wait_ms;
357
+ let parked = false;
358
+ try {
359
+ for (;;) {
360
+ const load = this.#load(key, Date.now());
361
+ if (load.kind === 'free') {
362
+ this.#leave(key, parked);
363
+ parked = false;
364
+ return { ok: true, reservation: this.#admit(key) };
365
+ }
366
+ if (load.kind === 'recorded')
367
+ return { ok: false, retry_after: load.retry_after };
368
+ if (!parked) {
369
+ let waiters = this.#waiters.get(key);
370
+ if (waiters && waiters.count >= this.options.max_attempts) {
371
+ return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
372
+ }
373
+ if (!waiters) {
374
+ waiters = { count: 0, wakers: new Set() };
375
+ this.#waiters.set(key, waiters);
376
+ }
377
+ waiters.count++;
378
+ parked = true;
379
+ }
380
+ const remaining_ms = deadline - Date.now();
381
+ if (remaining_ms <= 0 || !(await this.#wait_for_wake(key, remaining_ms, signal))) {
382
+ return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
383
+ }
384
+ }
385
+ }
386
+ finally {
387
+ this.#leave(key, parked);
388
+ }
389
+ }
390
+ /** Leave `key`'s waiters, if parked there; the last one out removes the key's slot. */
391
+ #leave(key, parked) {
392
+ if (!parked)
393
+ return;
394
+ const waiters = this.#waiters.get(key);
395
+ if (!waiters)
396
+ return;
397
+ waiters.count--;
398
+ if (waiters.count <= 0)
399
+ this.#waiters.delete(key);
400
+ }
401
+ /** Resolves `true` on `key`'s next wake, `false` on timeout or abort; cleans up either way. */
402
+ #wait_for_wake(key, timeout_ms, signal) {
403
+ return new Promise((resolve) => {
404
+ const wakers = this.#waiters.get(key)?.wakers;
405
+ if (signal?.aborted || !wakers) {
406
+ resolve(false);
407
+ return;
408
+ }
409
+ const done = (woke) => {
410
+ wakers.delete(waker);
411
+ clearTimeout(timer);
412
+ signal?.removeEventListener('abort', on_abort);
413
+ resolve(woke);
414
+ };
415
+ const waker = () => done(true);
416
+ const on_abort = () => done(false);
417
+ const timer = setTimeout(() => done(false), timeout_ms);
418
+ wakers.add(waker);
419
+ signal?.addEventListener('abort', on_abort, { once: true });
420
+ });
421
+ }
422
+ /** Run the wakers of the reservations parked on `key`. */
423
+ #wake(key) {
424
+ const wakers = this.#waiters.get(key)?.wakers;
425
+ if (!wakers)
426
+ return;
427
+ for (const waker of [...wakers])
428
+ waker();
429
+ }
430
+ /** Count one more attempt in flight on `key`, already found free. */
431
+ #admit(key) {
432
+ let entry = this.#attempts.get(key);
433
+ if (!entry) {
434
+ // A fresh key past `max_keys` evicts the least-recently-used entry,
435
+ // in-flight attempts and all; their reservations settle as no-ops
436
+ // (release) or into a fresh entry (record).
437
+ entry = { timestamps: [], in_flight: 0 };
438
+ this.#attempts.set(key, entry);
439
+ }
440
+ entry.in_flight++;
441
+ const reserved = entry;
442
+ let settled = false;
443
+ const settle = (record, settle_now) => {
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ this.#settle(key, reserved, record, settle_now);
448
+ };
449
+ return {
450
+ record: (record_now = Date.now()) => settle(true, record_now),
451
+ release: () => settle(false, Date.now())
452
+ };
453
+ }
454
+ #settle(key, reserved, record, now) {
455
+ const current = this.#attempts.get(key);
456
+ if (current !== reserved) {
457
+ // The reserved entry is gone — evicted, or reset with nothing in
458
+ // flight but this — so there is no in-flight count to give back. A
459
+ // record still counts, against whatever the key holds now.
460
+ if (record)
461
+ this.record(key, now);
462
+ else
463
+ this.#wake(key);
464
+ return;
465
+ }
466
+ reserved.in_flight = Math.max(0, reserved.in_flight - 1);
467
+ if (record) {
468
+ const cutoff = now - this.options.window_ms;
469
+ reserved.timestamps = reserved.timestamps.filter((t) => t > cutoff);
470
+ reserved.timestamps.push(now);
471
+ }
472
+ else if (entry_is_idle(reserved)) {
473
+ this.#attempts.delete(key);
474
+ }
475
+ this.#wake(key);
476
+ }
207
477
  /**
208
478
  * Clear all attempts for `key`.
209
479
  *
@@ -218,8 +488,9 @@ export class RateLimiter {
218
488
  * otherwise an attacker interleaves their own logins (or, under open signup,
219
489
  * throwaway account creations needing no credential at all) with guesses
220
490
  * against arbitrary victims and sprays indefinitely from one address.
221
- * Nothing records on the IP bucket on success on any path, so with no reset
222
- * it is a pure monotone-within-window failure counter that only time clears.
491
+ * The only success that records on an IP bucket is an open signup (charged
492
+ * like a failure); no success clears one, so with no reset each is a
493
+ * monotone-within-window counter that only time clears.
223
494
  *
224
495
  * Because they are monotone, the IP buckets are **one instance per auth
225
496
  * surface** (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
@@ -229,13 +500,24 @@ export class RateLimiter {
229
500
  * four routes at once — a cost that used to be masked by the reset this
230
501
  * doc forbids.
231
502
  *
232
- * @mutates internal map - removes the entry for `key`
503
+ * Attempts still in flight on `key` (reserved by concurrent requests) are
504
+ * kept: a reset forgives what was recorded, not what is still running.
505
+ *
506
+ * @mutates internal map - removes the entry for `key`, or clears its timestamps while attempts are in flight
233
507
  */
234
508
  reset(key) {
235
- this.#attempts.delete(key);
509
+ const entry = this.#attempts.get(key);
510
+ if (entry && entry.in_flight > 0) {
511
+ entry.timestamps = [];
512
+ }
513
+ else {
514
+ this.#attempts.delete(key);
515
+ }
516
+ this.#wake(key);
236
517
  }
237
518
  /**
238
- * Remove entries whose timestamps are all outside the window.
519
+ * Remove entries whose timestamps are all outside the window and that
520
+ * have no attempt in flight.
239
521
  *
240
522
  * @param now - current timestamp in ms (defaults to `Date.now()`)
241
523
  * @mutates internal map - prunes expired timestamps and deletes empty keys
@@ -246,14 +528,11 @@ export class RateLimiter {
246
528
  // to the MRU end during iteration and causes re-visit in the same pass.
247
529
  // The `Map` path is unaffected but the snapshot is cheap on both.
248
530
  const entries = [...this.#attempts];
249
- for (const [key, timestamps] of entries) {
250
- const active = timestamps.filter((t) => t > cutoff);
251
- if (active.length === 0) {
531
+ for (const [key, entry] of entries) {
532
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
533
+ if (entry_is_idle(entry)) {
252
534
  this.#attempts.delete(key);
253
535
  }
254
- else {
255
- this.#attempts.set(key, active);
256
- }
257
536
  }
258
537
  }
259
538
  /**
@@ -268,6 +547,37 @@ export class RateLimiter {
268
547
  }
269
548
  }
270
549
  }
550
+ /**
551
+ * `RateLimiter.try_reserve` against a limiter that may be absent.
552
+ *
553
+ * An inert reservation when `limiter` is `null` (disabled) or `key` is `null`
554
+ * (no resolvable key — e.g. no client IP), so a handler reserves and settles
555
+ * the same way whether or not the surface is limited. Twin of the Rust
556
+ * spine's `try_reserve_optional`.
557
+ *
558
+ * @param limiter - the limiter, or `null` when disabled
559
+ * @param key - rate limit key, or `null` to skip
560
+ * @param now - current timestamp in ms (defaults to `Date.now()`)
561
+ */
562
+ export const try_reserve_optional = (limiter, key, now = Date.now()) => {
563
+ if (!limiter || key === null)
564
+ return { ok: true, reservation: INERT_RESERVATION };
565
+ return limiter.try_reserve(key, now);
566
+ };
567
+ /**
568
+ * `RateLimiter.reserve` against a limiter that may be absent — an inert
569
+ * reservation when `limiter` or `key` is `null`. Twin of the Rust spine's
570
+ * `reserve_optional`.
571
+ *
572
+ * @param limiter - the limiter, or `null` when disabled
573
+ * @param key - rate limit key, or `null` to skip
574
+ * @param options - the wait bound and an abort signal
575
+ */
576
+ export const reserve_optional = async (limiter, key, options) => {
577
+ if (!limiter || key === null)
578
+ return { ok: true, reservation: INERT_RESERVATION };
579
+ return limiter.reserve(key, options);
580
+ };
271
581
  /**
272
582
  * Create a `RateLimiter` with sensible defaults for per-IP login protection.
273
583
  *
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * - `GET /api/cells/:cell_id/facts/:hash` — the **per-reference read**.
7
7
  * The request names the referencing cell. Authz is scoped to that one
8
- * reference: `can_view_cell(caller, cell) AND cell.refs includes hash`.
9
- * This is the path non-admin callers use, and the only path for
8
+ * reference: `(can_view_cell(caller, cell) OR the steward arm) AND
9
+ * cell.refs includes hash`, the steward arm being the governing root's
10
+ * manager reading a pending or rejected contribution
11
+ * (`auth/cell_steward.ts`). This is the path non-admin callers use, and the only path for
10
12
  * confidential content.
11
13
  * - `GET /api/facts/:hash` — the **bare-hash read**, restricted to admins.
12
14
  *
@@ -52,7 +54,8 @@
52
54
  * (populated by the `/api/*` session middleware) by resolving the caller's
53
55
  * single actor and loading their role_grants. Unauthed callers pass through
54
56
  * with `req_ctx: null` and are admitted only by a `cell.visibility ===
55
- * 'public'` cell. Multi-actor accounts fall through with `req_ctx: null`
57
+ * 'public'` cell whose `moderation` admits an audience (unmoderated or
58
+ * `'approved'`). Multi-actor accounts fall through with `req_ctx: null`
56
59
  * — there's no `acting?` slot on a pure-public route, so multi-actor
57
60
  * callers are treated as anonymous.
58
61
  *
@@ -121,7 +124,8 @@ export interface CreateServeFactRouteSpecOptions {
121
124
  * — the per-reference read.
122
125
  *
123
126
  * Resolves the named cell (404 if missing / soft-deleted), requires
124
- * `can_view_cell(caller, cell)` AND `cell.refs` to include the hash
127
+ * `can_view_cell(caller, cell)` — or the steward arm,
128
+ * `cell_steward_admits` — AND `cell.refs` to include the hash
125
129
  * (else 404, masked), then serves the bytes. Authz is scoped to this one
126
130
  * `(cell, hash)` edge — never unioned across the fact's other referrers.
127
131
  *
@@ -1 +1 @@
1
- {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAkHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAiDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
1
+ {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAkHD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAuDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * - `GET /api/cells/:cell_id/facts/:hash` — the **per-reference read**.
7
7
  * The request names the referencing cell. Authz is scoped to that one
8
- * reference: `can_view_cell(caller, cell) AND cell.refs includes hash`.
9
- * This is the path non-admin callers use, and the only path for
8
+ * reference: `(can_view_cell(caller, cell) OR the steward arm) AND
9
+ * cell.refs includes hash`, the steward arm being the governing root's
10
+ * manager reading a pending or rejected contribution
11
+ * (`auth/cell_steward.ts`). This is the path non-admin callers use, and the only path for
10
12
  * confidential content.
11
13
  * - `GET /api/facts/:hash` — the **bare-hash read**, restricted to admins.
12
14
  *
@@ -52,7 +54,8 @@
52
54
  * (populated by the `/api/*` session middleware) by resolving the caller's
53
55
  * single actor and loading their role_grants. Unauthed callers pass through
54
56
  * with `req_ctx: null` and are admitted only by a `cell.visibility ===
55
- * 'public'` cell. Multi-actor accounts fall through with `req_ctx: null`
57
+ * 'public'` cell whose `moderation` admits an audience (unmoderated or
58
+ * `'approved'`). Multi-actor accounts fall through with `req_ctx: null`
56
59
  * — there's no `acting?` slot on a pure-public route, so multi-actor
57
60
  * callers are treated as anonymous.
58
61
  *
@@ -106,6 +109,7 @@ import { query_get_fact, query_get_fact_meta } from '../db/fact_queries.js';
106
109
  import { query_cell_get } from '../db/cell_queries.js';
107
110
  import { query_cell_grant_list_for_cell } from '../db/cell_grant_queries.js';
108
111
  import { can_view_cell } from '../auth/cell_authorize.js';
112
+ import { cell_steward_admits } from '../auth/cell_steward.js';
109
113
  import { parse_file_fact_url } from '../db/file_fact_url.js';
110
114
  /** `Cache-Control` for fact responses — 5 min revocation window. */
111
115
  const CACHE_CONTROL = 'private, max-age=300';
@@ -254,7 +258,8 @@ const build_public_request_context = async (c, route) => {
254
258
  * — the per-reference read.
255
259
  *
256
260
  * Resolves the named cell (404 if missing / soft-deleted), requires
257
- * `can_view_cell(caller, cell)` AND `cell.refs` to include the hash
261
+ * `can_view_cell(caller, cell)` — or the steward arm,
262
+ * `cell_steward_admits` — AND `cell.refs` to include the hash
258
263
  * (else 404, masked), then serves the bytes. Authz is scoped to this one
259
264
  * `(cell, hash)` edge — never unioned across the fact's other referrers.
260
265
  *
@@ -269,7 +274,7 @@ export const create_serve_cell_fact_route_spec = (options) => {
269
274
  path: '/api/cells/:cell_id/facts/:hash',
270
275
  auth: { account: 'none', actor: 'none' },
271
276
  reads_credential: true,
272
- description: 'Serve content-addressed fact bytes through a named referencing cell. 404 unless the cell admits the caller via can_view_cell AND references the hash (per-reference, never union-of-referrers).',
277
+ description: "Serve content-addressed fact bytes through a named referencing cell. 404 unless the cell admits the caller via can_view_cell (or the steward arm: the governing root's manager reading a pending or rejected contribution) AND references the hash (per-reference, never union-of-referrers).",
273
278
  params: cell_fact_params_schema,
274
279
  input: z.null(),
275
280
  // The body is a binary stream; no JSON output schema applies.
@@ -298,7 +303,11 @@ export const create_serve_cell_fact_route_spec = (options) => {
298
303
  const grants = req_ctx
299
304
  ? await query_cell_grant_list_for_cell({ db: route.db }, cell.id)
300
305
  : null;
301
- if (!can_view_cell(req_ctx, cell, grants)) {
306
+ // The governing root's manager also reads a pending or rejected
307
+ // contribution's facts (the steward arm, `auth/cell_steward.ts`),
308
+ // checked only once the view check denies.
309
+ if (!can_view_cell(req_ctx, cell, grants) &&
310
+ !(await cell_steward_admits({ db: route.db }, req_ctx, cell))) {
302
311
  // 404 (not 403) so an unviewable cell→fact edge doesn't leak.
303
312
  return c.body(null, 404);
304
313
  }
@@ -1421,7 +1421,14 @@ moderation_required}`, with `moderation` set per the verdict (`pending`+private
1421
1421
  **visible** one **403**s; plain parentless creates stay open. The moderate
1422
1422
  suite then proves the `pending → approved | rejected` transition: an admin
1423
1423
  (root manager) approves → public, the **author** self-approve → 403
1424
- (anti-self-approval), a non-viewer → 404, reject → stays private. The
1424
+ (anti-self-approval), a non-viewer → 404, reject → stays private. It also
1425
+ pins the moderation read gate (a pending or rejected post reaches no
1426
+ non-steward reader past its author and admins on `cell_get`, `cell_list`,
1427
+ `cell_item_list`, and fact serving, even flipped public or granted while
1428
+ approved), `cell_grant_create`'s
1429
+ 403 `cell_grant_unapproved`, and the steward arm (a non-admin root owner — a
1430
+ non-`space` root the policy leaves open — reads the queue, `cell_get`, and
1431
+ the fact, and moderates, but still 404s on clone and create-under). The
1425
1432
  authorizer _behavior_ adds no method/wire shape, so schema + manifest parity
1426
1433
  are **blind** to a divergence — these are the only gates that catch one (the
1427
1434
  `cell_moderate` verb itself is manifest-covered). In-process hook coverage is
@@ -1846,9 +1853,9 @@ so one fixture is the cross-impl contract): the TS spine via
1846
1853
  `import.meta.url` constant off `spine_surface_constants.ts`; the Rust
1847
1854
  `testing_spine_stub` via the absolute path `rust_spine_stub_backend_config`
1848
1855
  passes through `FUZ_RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH`. The fixture covers the full
1849
- spine bootstrap (auth + cell + cell_history + fact) and is regenerated +
1856
+ spine bootstrap (auth + cell + fact) and is regenerated +
1850
1857
  drift-guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`
1851
- (`UPDATE_SCHEMA_READY=1`, then `gro format`).
1858
+ (`UPDATE_SCHEMA_READY=1`).
1852
1859
 
1853
1860
  ### Testing-backdoor credential gate — `cross_backend/testing_backdoor.ts`
1854
1861
 
@@ -1 +1 @@
1
- {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,uCAAuC,CAAC;AAEvF,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAoUF,CAAC"}
1
+ {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,uCAAuC,CAAC;AAEvF,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAqZF,CAAC"}