@fuzdev/fuz_app 0.141.0 → 0.143.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.
- package/dist/actions/CLAUDE.md +10 -5
- package/dist/actions/connection_closer.d.ts +19 -2
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +25 -2
- package/dist/actions/perform_action.d.ts +10 -0
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +23 -9
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -2
- package/dist/auth/CLAUDE.md +33 -13
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +12 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +8 -0
- package/dist/auth/account_routes.d.ts +7 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +240 -187
- package/dist/auth/admin_action_specs.d.ts +6 -1
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +6 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +14 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +85 -76
- package/dist/auth/cell_action_specs.d.ts +7 -3
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +9 -5
- package/dist/auth/cell_actions.d.ts +3 -3
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +67 -38
- package/dist/auth/cell_authorize.d.ts +69 -8
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +89 -9
- package/dist/auth/cell_field_action_specs.d.ts +5 -4
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_field_action_specs.js +7 -7
- package/dist/auth/cell_field_actions.d.ts +7 -5
- package/dist/auth/cell_field_actions.d.ts.map +1 -1
- package/dist/auth/cell_field_actions.js +24 -22
- package/dist/auth/cell_grant_action_specs.d.ts +6 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_grant_action_specs.js +6 -0
- package/dist/auth/cell_grant_actions.d.ts +3 -1
- package/dist/auth/cell_grant_actions.d.ts.map +1 -1
- package/dist/auth/cell_grant_actions.js +16 -2
- package/dist/auth/cell_item_action_specs.d.ts +5 -4
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_item_action_specs.js +7 -7
- package/dist/auth/cell_item_actions.d.ts +6 -5
- package/dist/auth/cell_item_actions.d.ts.map +1 -1
- package/dist/auth/cell_item_actions.js +23 -22
- package/dist/auth/cell_relation_list_limit.d.ts +20 -0
- package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
- package/dist/auth/cell_relation_list_limit.js +27 -0
- package/dist/auth/cell_relation_visibility.d.ts +4 -3
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
- package/dist/auth/cell_relation_visibility.js +6 -5
- package/dist/auth/cell_steward.d.ts +45 -0
- package/dist/auth/cell_steward.d.ts.map +1 -0
- package/dist/auth/cell_steward.js +53 -0
- package/dist/auth/request_context.d.ts +4 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +6 -1
- package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +6 -1
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +23 -16
- package/dist/auth/role_grant_offer_queries.d.ts +8 -6
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +16 -8
- package/dist/auth/signup_routes.d.ts +4 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +157 -134
- package/dist/auth/token_scope.d.ts +7 -1
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +8 -2
- package/dist/db/CLAUDE.md +32 -19
- package/dist/db/cell_ddl.d.ts +1 -2
- package/dist/db/cell_ddl.d.ts.map +1 -1
- package/dist/db/cell_ddl.js +1 -2
- package/dist/db/cell_field_queries.d.ts +20 -18
- package/dist/db/cell_field_queries.d.ts.map +1 -1
- package/dist/db/cell_field_queries.js +28 -22
- package/dist/db/cell_item_queries.d.ts +20 -14
- package/dist/db/cell_item_queries.d.ts.map +1 -1
- package/dist/db/cell_item_queries.js +28 -18
- package/dist/db/cell_moderation.d.ts +67 -0
- package/dist/db/cell_moderation.d.ts.map +1 -0
- package/dist/db/cell_moderation.js +66 -0
- package/dist/db/cell_queries.d.ts +44 -14
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +68 -45
- package/dist/db/cell_view_sql.d.ts +102 -0
- package/dist/db/cell_view_sql.d.ts.map +1 -0
- package/dist/db/cell_view_sql.js +112 -0
- package/dist/db/db_pg.d.ts +1 -1
- package/dist/db/db_pg.js +1 -1
- package/dist/http/CLAUDE.md +1 -1
- package/dist/http/auth_shape.d.ts +5 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +5 -2
- package/dist/http/ip_canonical.d.ts +56 -5
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +147 -35
- package/dist/primitive_schemas.d.ts +10 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +11 -2
- package/dist/rate_limiter.d.ts +179 -12
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +352 -42
- package/dist/server/serve_fact_route.d.ts +8 -4
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +15 -6
- package/dist/testing/CLAUDE.md +16 -7
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +37 -2
- package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +401 -5
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +145 -2
- package/dist/testing/cross_backend/expected_schema.json +0 -1
- package/dist/testing/cross_backend/full_spine_mount.js +2 -1
- package/dist/testing/cross_backend/login_security.d.ts +16 -0
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +154 -0
- package/dist/testing/cross_backend/origin_conformance.d.ts +1 -1
- package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -1
- package/dist/testing/cross_backend/origin_conformance.js +1 -1
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
- package/dist/testing/cross_backend/query_shape.js +24 -2
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +64 -0
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +13 -21
- package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +13 -21
- package/dist/testing/cross_backend/testing_origin_check.d.ts +128 -0
- package/dist/testing/cross_backend/testing_origin_check.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_origin_check.js +87 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +7 -119
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +8 -92
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
- package/dist/testing/schema_introspect.js +1 -1
- package/dist/testing/schema_parity.d.ts +4 -3
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/schema_parity.js +4 -3
- package/dist/testing/schema_ready_fixture.d.ts +2 -4
- package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
- package/dist/testing/schema_ready_fixture.js +2 -4
- package/dist/ui/auth_state.svelte.d.ts +10 -0
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +22 -4
- package/package.json +1 -1
- package/dist/db/cell_history_ddl.d.ts +0 -38
- package/dist/db/cell_history_ddl.d.ts.map +0 -1
- package/dist/db/cell_history_ddl.js +0 -61
package/dist/rate_limiter.js
CHANGED
|
@@ -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
|
|
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 →
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
174
|
-
|
|
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
|
|
188
|
-
if (
|
|
290
|
+
let entry = this.#attempts.get(key);
|
|
291
|
+
if (entry) {
|
|
189
292
|
// Prune expired entries in place.
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
this.#attempts.set(key,
|
|
297
|
+
entry = { timestamps: [now], in_flight: 0 };
|
|
298
|
+
this.#attempts.set(key, entry);
|
|
198
299
|
}
|
|
199
|
-
|
|
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
|
-
*
|
|
222
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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,
|
|
250
|
-
|
|
251
|
-
if (
|
|
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)
|
|
9
|
-
*
|
|
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
|
|
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)`
|
|
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
|
|
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)
|
|
9
|
-
*
|
|
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
|
|
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)`
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -1600,7 +1607,8 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
|
1600
1607
|
payload-expiry tests never reached. Daemon-token-gated like its siblings; the
|
|
1601
1608
|
Rust mirror is `fuz_testing::create_testing_mint_session_action_spec`.
|
|
1602
1609
|
|
|
1603
|
-
|
|
1610
|
+
In the hono-free sibling `testing/cross_backend/testing_origin_check.ts`,
|
|
1611
|
+
**not** bundled: `_testing_origin_check`
|
|
1604
1612
|
(`create_testing_origin_check_action()`, `run_testing_origin_check(input)`,
|
|
1605
1613
|
`TestingOriginCheckInput` / `TestingOriginCheckOutput`) — answers the origin
|
|
1606
1614
|
allowlist's parse and request check for one `{patterns, origin}` pair:
|
|
@@ -1610,7 +1618,8 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
|
1610
1618
|
keeps an exact action-manifest gate; the spine binary appends it in
|
|
1611
1619
|
`build_full_spine_rpc_actions`.
|
|
1612
1620
|
|
|
1613
|
-
|
|
1621
|
+
Back in `testing_reset_actions.ts`, also **not** bundled:
|
|
1622
|
+
`_testing_shorten_credential_expiry`
|
|
1614
1623
|
(`create_testing_shorten_credential_expiry_action()`) — pulls an account's
|
|
1615
1624
|
`auth_session` and `api_token` expiries in to `seconds` (1–60) from now,
|
|
1616
1625
|
shorten-only (`LEAST`, an eternal token gaining one), answering
|
|
@@ -1621,8 +1630,8 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
|
1621
1630
|
`_testing_origin_check`. The Rust mirror is
|
|
1622
1631
|
`fuz_testing::create_testing_shorten_credential_expiry_action_spec`.
|
|
1623
1632
|
|
|
1624
|
-
Also in
|
|
1625
|
-
`_testing_action_manifest` (+ `create_testing_action_manifest_action(mounted)`):
|
|
1633
|
+
Also in `testing_reset_actions.ts` — but **not** bundled by
|
|
1634
|
+
`create_testing_actions` — `_testing_action_manifest` (+ `create_testing_action_manifest_action(mounted)`):
|
|
1626
1635
|
dumps the live RPC registry as a normalized `ActionManifest` for the
|
|
1627
1636
|
cross-impl manifest-parity gate (see §Cross-impl RPC-manifest parity). It must
|
|
1628
1637
|
enumerate _every_ mounted method, which `create_testing_actions` (one
|
|
@@ -1846,9 +1855,9 @@ so one fixture is the cross-impl contract): the TS spine via
|
|
|
1846
1855
|
`import.meta.url` constant off `spine_surface_constants.ts`; the Rust
|
|
1847
1856
|
`testing_spine_stub` via the absolute path `rust_spine_stub_backend_config`
|
|
1848
1857
|
passes through `FUZ_RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH`. The fixture covers the full
|
|
1849
|
-
spine bootstrap (auth + cell +
|
|
1858
|
+
spine bootstrap (auth + cell + fact) and is regenerated +
|
|
1850
1859
|
drift-guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`
|
|
1851
|
-
(`UPDATE_SCHEMA_READY=1
|
|
1860
|
+
(`UPDATE_SCHEMA_READY=1`).
|
|
1852
1861
|
|
|
1853
1862
|
### Testing-backdoor credential gate — `cross_backend/testing_backdoor.ts`
|
|
1854
1863
|
|