@fuzdev/fuz_app 0.107.0 → 0.109.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/README.md +4 -0
- package/dist/actions/CLAUDE.md +1 -1
- package/dist/auth/CLAUDE.md +1 -1
- package/dist/auth/account_route_schema.d.ts +10 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +10 -1
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +32 -29
- package/dist/auth/api_token_queries.d.ts +4 -0
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +5 -1
- package/dist/auth/audit_emitter.d.ts +9 -5
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.d.ts +7 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +16 -1
- package/dist/auth/bearer_auth.d.ts +9 -6
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +12 -21
- package/dist/auth/bootstrap_routes.d.ts +12 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +26 -10
- package/dist/auth/middleware.d.ts +0 -3
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +11 -10
- package/dist/auth/migrations.d.ts +7 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +45 -4
- package/dist/auth/session_queries.d.ts +13 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +14 -3
- package/dist/auth/signup_routes.d.ts +10 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -11
- package/dist/db/CLAUDE.md +9 -5
- package/dist/db/cell_audit_queries.d.ts +4 -2
- package/dist/db/cell_audit_queries.d.ts.map +1 -1
- package/dist/db/cell_audit_queries.js +4 -2
- package/dist/db/pg_error.d.ts +12 -0
- package/dist/db/pg_error.d.ts.map +1 -1
- package/dist/db/pg_error.js +15 -1
- package/dist/http/CLAUDE.md +78 -18
- package/dist/http/db_routes.d.ts +96 -3
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +263 -46
- package/dist/http/error_schemas.d.ts +13 -2
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +13 -2
- package/dist/http/pending_effects.d.ts +5 -5
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/rate_limiter.d.ts +36 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +36 -3
- package/dist/server/app_server.d.ts +27 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +30 -20
- package/dist/server/app_server_context.d.ts +10 -2
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/testing/CLAUDE.md +42 -8
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +6 -4
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +5 -1
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +53 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -8
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +59 -0
- package/dist/testing/cross_backend/query_shape.d.ts +10 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
- package/dist/testing/cross_backend/query_shape.js +128 -0
- package/dist/testing/cross_backend/session_cap.d.ts +19 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/session_cap.js +93 -0
- package/dist/testing/middleware.d.ts +2 -5
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +5 -5
- package/dist/testing/rate_limiting.d.ts +10 -16
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +11 -95
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -1
- package/dist/testing/testing_rate_limiter.d.ts +7 -1
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
- package/dist/testing/testing_rate_limiter.js +7 -1
- package/dist/ui/CLAUDE.md +9 -4
- package/dist/ui/table_state.svelte.d.ts +18 -5
- package/dist/ui/table_state.svelte.d.ts.map +1 -1
- package/dist/ui/table_state.svelte.js +22 -7
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/dist/ui/ui_format.js +4 -0
- package/package.json +1 -1
|
@@ -27,35 +27,25 @@ import { auth_migration_ns } from '../auth/migrations.js';
|
|
|
27
27
|
import { create_test_app } from './app_server.js';
|
|
28
28
|
import { create_pglite_factory, create_describe_db, auth_integration_truncate_tables } from './db.js';
|
|
29
29
|
import { find_auth_route, assert_rate_limit_retry_after_header } from './integration_helpers.js';
|
|
30
|
-
import { rpc_call_non_browser, require_rpc_endpoint_path, resolve_rpc_endpoints_for_setup } from './rpc_helpers.js';
|
|
31
30
|
import { run_migrations } from '../db/migrate.js';
|
|
32
|
-
import { account_verify_action_spec } from '../auth/account_action_specs.js';
|
|
33
31
|
/**
|
|
34
32
|
* Standard rate limiting integration test suite.
|
|
35
33
|
*
|
|
36
|
-
* Creates
|
|
34
|
+
* Creates 2 test groups:
|
|
37
35
|
* 1. IP rate limiting on login — fires `max_attempts + 1` login requests,
|
|
38
36
|
* verifies the last returns 429 with a valid `RateLimitError` body.
|
|
39
37
|
* 2. Per-account rate limiting on login — fires `max_attempts + 1` login
|
|
40
38
|
* requests with the same username, verifies the last returns 429.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
*
|
|
40
|
+
* There is deliberately no bearer-auth group: the bearer path carries no rate
|
|
41
|
+
* limiter on either spine, because an API token's entropy — not throttling —
|
|
42
|
+
* is what bounds guessing it. See `auth/bearer_auth.ts`.
|
|
43
43
|
*
|
|
44
44
|
* Each test group asserts that required routes exist, failing with a descriptive
|
|
45
45
|
* message if the consumer's route specs are misconfigured.
|
|
46
|
-
*
|
|
47
|
-
* @throws Error at setup time when `options.rpc_endpoints` is empty — the
|
|
48
|
-
* bearer-auth rate-limit test probes via the `account_verify` RPC action,
|
|
49
|
-
* so the suite hard-fails via `require_rpc_endpoint_path`.
|
|
50
46
|
*/
|
|
51
47
|
export const describe_rate_limiting_tests = (options) => {
|
|
52
48
|
const max_attempts = options.max_attempts ?? 2;
|
|
53
|
-
// Hard-fail early so consumers see a clear setup error instead of a
|
|
54
|
-
// confusing test failure when `rpc_endpoints` is missing. Factory-form
|
|
55
|
-
// callers are resolved with a stub ctx purely to extract the endpoint
|
|
56
|
-
// path; real handlers run per-test via the top-level `rpc_endpoints` slot on `CreateTestAppOptions`.
|
|
57
|
-
const rpc_endpoints_for_setup = resolve_rpc_endpoints_for_setup(options.rpc_endpoints, options.session_options);
|
|
58
|
-
const rpc_path = require_rpc_endpoint_path(rpc_endpoints_for_setup);
|
|
59
49
|
const init_schema = async (db) => {
|
|
60
50
|
await run_migrations(db, [auth_migration_ns]);
|
|
61
51
|
};
|
|
@@ -67,7 +57,7 @@ export const describe_rate_limiting_tests = (options) => {
|
|
|
67
57
|
// --- 1. IP rate limiting on login ---
|
|
68
58
|
describe('IP rate limiting on login', () => {
|
|
69
59
|
test(`login is blocked after ${max_attempts} failed attempts`, async () => {
|
|
70
|
-
const
|
|
60
|
+
const login_ip_rate_limiter = create_test_rate_limiter();
|
|
71
61
|
try {
|
|
72
62
|
const test_app = await create_test_app({
|
|
73
63
|
session_options: options.session_options,
|
|
@@ -76,9 +66,8 @@ export const describe_rate_limiting_tests = (options) => {
|
|
|
76
66
|
rpc_endpoints: options.rpc_endpoints,
|
|
77
67
|
app_options: {
|
|
78
68
|
...options.app_options,
|
|
79
|
-
|
|
80
|
-
login_account_rate_limiter: null
|
|
81
|
-
bearer_ip_rate_limiter: null
|
|
69
|
+
login_ip_rate_limiter,
|
|
70
|
+
login_account_rate_limiter: null
|
|
82
71
|
}
|
|
83
72
|
});
|
|
84
73
|
const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
|
|
@@ -113,7 +102,7 @@ export const describe_rate_limiting_tests = (options) => {
|
|
|
113
102
|
assert_rate_limit_retry_after_header(blocked_res, body);
|
|
114
103
|
}
|
|
115
104
|
finally {
|
|
116
|
-
|
|
105
|
+
login_ip_rate_limiter.dispose();
|
|
117
106
|
}
|
|
118
107
|
});
|
|
119
108
|
});
|
|
@@ -129,9 +118,8 @@ export const describe_rate_limiting_tests = (options) => {
|
|
|
129
118
|
rpc_endpoints: options.rpc_endpoints,
|
|
130
119
|
app_options: {
|
|
131
120
|
...options.app_options,
|
|
132
|
-
|
|
133
|
-
login_account_rate_limiter
|
|
134
|
-
bearer_ip_rate_limiter: null
|
|
121
|
+
login_ip_rate_limiter: null,
|
|
122
|
+
login_account_rate_limiter
|
|
135
123
|
}
|
|
136
124
|
});
|
|
137
125
|
const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
|
|
@@ -182,77 +170,5 @@ export const describe_rate_limiting_tests = (options) => {
|
|
|
182
170
|
}
|
|
183
171
|
});
|
|
184
172
|
});
|
|
185
|
-
// --- 3. Bearer auth IP rate limiting ---
|
|
186
|
-
describe('bearer auth IP rate limiting', () => {
|
|
187
|
-
test(`bearer auth is blocked after ${max_attempts} invalid token attempts`, async () => {
|
|
188
|
-
const bearer_ip_rate_limiter = create_test_rate_limiter();
|
|
189
|
-
try {
|
|
190
|
-
const test_app = await create_test_app({
|
|
191
|
-
session_options: options.session_options,
|
|
192
|
-
create_route_specs: options.create_route_specs,
|
|
193
|
-
db: get_db(),
|
|
194
|
-
rpc_endpoints: options.rpc_endpoints,
|
|
195
|
-
app_options: {
|
|
196
|
-
...options.app_options,
|
|
197
|
-
ip_rate_limiter: null,
|
|
198
|
-
login_account_rate_limiter: null,
|
|
199
|
-
bearer_ip_rate_limiter
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
// Probe `account_verify` via RPC with an invalid bearer token.
|
|
203
|
-
// The REST `/api/account/verify` shim is status-only (empty body
|
|
204
|
-
// for nginx `auth_request`), so we use the RPC surface to exercise
|
|
205
|
-
// a typed authenticated method. The bearer_auth rate limiter
|
|
206
|
-
// increments per attempt regardless of the route's own auth outcome.
|
|
207
|
-
// Use `rpc_call_non_browser` so the default `origin` header is
|
|
208
|
-
// suppressed — bearer_auth discards the token when Origin or
|
|
209
|
-
// Referer is present (browser context), which would short-circuit
|
|
210
|
-
// before the rate limiter records the attempt.
|
|
211
|
-
//
|
|
212
|
-
// Note: the rate limiter short-circuits before the RPC dispatcher,
|
|
213
|
-
// so the 429 response is a REST-shaped `RateLimitError`, not a
|
|
214
|
-
// JSON-RPC envelope. We use the underlying `app.request` for the
|
|
215
|
-
// blocked probe so `rpc_call_non_browser` doesn't throw on the
|
|
216
|
-
// non-envelope body.
|
|
217
|
-
const bearer_probe_headers = {
|
|
218
|
-
authorization: 'Bearer secret_fuz_token_invalid'
|
|
219
|
-
};
|
|
220
|
-
// Fire max_attempts invalid bearer requests (sequential — must exhaust the window)
|
|
221
|
-
for (let i = 0; i < max_attempts; i++) {
|
|
222
|
-
const res = await rpc_call_non_browser({
|
|
223
|
-
app: test_app.app,
|
|
224
|
-
path: rpc_path,
|
|
225
|
-
method: account_verify_action_spec.method,
|
|
226
|
-
id: 'rl-probe',
|
|
227
|
-
headers: bearer_probe_headers
|
|
228
|
-
});
|
|
229
|
-
assert.notStrictEqual(res.status, 429, `Request ${i + 1}/${max_attempts} should not be rate limited`);
|
|
230
|
-
}
|
|
231
|
-
// The next request should be rate limited. The 429 body is REST-shape
|
|
232
|
-
// (middleware short-circuits before the RPC dispatcher), so go
|
|
233
|
-
// direct — `rpc_call_non_browser` would throw on the non-envelope body.
|
|
234
|
-
const blocked_res = await test_app.app.request(rpc_path, {
|
|
235
|
-
method: 'POST',
|
|
236
|
-
headers: {
|
|
237
|
-
host: 'localhost',
|
|
238
|
-
'content-type': 'application/json',
|
|
239
|
-
...bearer_probe_headers
|
|
240
|
-
},
|
|
241
|
-
body: JSON.stringify({
|
|
242
|
-
jsonrpc: '2.0',
|
|
243
|
-
method: account_verify_action_spec.method,
|
|
244
|
-
id: 'rl-probe-blocked'
|
|
245
|
-
})
|
|
246
|
-
});
|
|
247
|
-
assert.strictEqual(blocked_res.status, 429);
|
|
248
|
-
const body = await blocked_res.json();
|
|
249
|
-
RateLimitError.parse(body);
|
|
250
|
-
assert_rate_limit_retry_after_header(blocked_res, body);
|
|
251
|
-
}
|
|
252
|
-
finally {
|
|
253
|
-
bearer_ip_rate_limiter.dispose();
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
173
|
});
|
|
258
174
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAa7B,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAG3E,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAmB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAA+B,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAI9F;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,GAAI,CAAC,GAAG,GAAG,EAAE,OAAO,MAAM,KAAG,CAqBtD,CAAC;AAET;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,GAAG,GAAG,EAAE,QAAQ,MAAM,EAAE,YAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,CAOxF,CAAC;AAET,iEAAiE;AACjE,eAAO,MAAM,IAAI,EAAE,GAAkC,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,QAAO,EAI/B,CAAC;AAEJ,gDAAgD;AAChD,eAAO,MAAM,YAAY,QAAO,QAAgC,CAAC;AAEjE,2CAA2C;AAC3C,eAAO,MAAM,OAAO,GAAU,IAAI,GAAG,EAAE,MAAM,GAAG,KAAG,OAAO,CAAC,IAAI,CAAW,CAAC;AAI3E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,QAAO,YAW3C,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,QAAO,WAUxC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,aAAa,EAAE,OAS3B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,oBAAoB,QAAO,OAStC,CAAC;AAEH,2FAA2F;AAC3F,eAAO,MAAM,0BAA0B,GAAI,UAAU;IACpD,iDAAiD;IACjD,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAC/B,KAAG,KAAK,CAAC,cAAc,CAqBvB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,GAC1C,iBAAiB,cAAc,CAAC,MAAM,CAAC,KACrC,
|
|
1
|
+
{"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"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"}
|
package/dist/testing/stubs.js
CHANGED
|
@@ -199,7 +199,9 @@ export const create_stub_app_server_context = (session_options) => {
|
|
|
199
199
|
},
|
|
200
200
|
bootstrap_status: { available: false, token_path: null },
|
|
201
201
|
session_options,
|
|
202
|
-
|
|
202
|
+
login_ip_rate_limiter: null,
|
|
203
|
+
signup_ip_rate_limiter: null,
|
|
204
|
+
bootstrap_ip_rate_limiter: null,
|
|
203
205
|
login_account_rate_limiter: null,
|
|
204
206
|
signup_account_rate_limiter: null,
|
|
205
207
|
action_ip_rate_limiter: null,
|
|
@@ -20,7 +20,7 @@ import './assert_dev_env.js';
|
|
|
20
20
|
* import {TestingRateLimiter} from '@fuzdev/fuz_app/testing/testing_rate_limiter.ts';
|
|
21
21
|
*
|
|
22
22
|
* const limiter = new TestingRateLimiter(default_login_ip_rate_limit);
|
|
23
|
-
* await create_app_server({backend,
|
|
23
|
+
* await create_app_server({backend, login_ip_rate_limiter: limiter, ...});
|
|
24
24
|
*
|
|
25
25
|
* // Inside the `_testing_reset` handler's `reset_state`:
|
|
26
26
|
* limiter.reset_all();
|
|
@@ -45,6 +45,12 @@ export declare class TestingRateLimiter extends RateLimiter {
|
|
|
45
45
|
* safe to call before any check/record activity. Designed to be invoked
|
|
46
46
|
* from a `_testing_reset` handler's `reset_state` callback so the test
|
|
47
47
|
* binary's rate-limit buckets don't leak across test cases.
|
|
48
|
+
*
|
|
49
|
+
* This is the **one sanctioned caller that clears IP-aggregate keys** —
|
|
50
|
+
* `RateLimiter.reset` forbids that for production code, because refunding
|
|
51
|
+
* the IP bucket on a successful auth reopens the distributed-spray path.
|
|
52
|
+
* Here it is test-state teardown between cases, not success-forgiveness, so
|
|
53
|
+
* the rule doesn't apply.
|
|
48
54
|
*/
|
|
49
55
|
reset_all(): void;
|
|
50
56
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing_rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/testing_rate_limiter.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,WAAW;;IAGzC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKjD,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKlD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAKjC
|
|
1
|
+
{"version":3,"file":"testing_rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/testing_rate_limiter.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,WAAW;;IAGzC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKjD,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKlD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;;;;;;;OAWG;IACH,SAAS,IAAI,IAAI;IAOjB;;;;;;OAMG;IACH,IAAI,YAAY,IAAI,WAAW,CAAC,MAAM,CAAC,CAEtC;CACD"}
|
|
@@ -20,7 +20,7 @@ import './assert_dev_env.js';
|
|
|
20
20
|
* import {TestingRateLimiter} from '@fuzdev/fuz_app/testing/testing_rate_limiter.ts';
|
|
21
21
|
*
|
|
22
22
|
* const limiter = new TestingRateLimiter(default_login_ip_rate_limit);
|
|
23
|
-
* await create_app_server({backend,
|
|
23
|
+
* await create_app_server({backend, login_ip_rate_limiter: limiter, ...});
|
|
24
24
|
*
|
|
25
25
|
* // Inside the `_testing_reset` handler's `reset_state`:
|
|
26
26
|
* limiter.reset_all();
|
|
@@ -54,6 +54,12 @@ export class TestingRateLimiter extends RateLimiter {
|
|
|
54
54
|
* safe to call before any check/record activity. Designed to be invoked
|
|
55
55
|
* from a `_testing_reset` handler's `reset_state` callback so the test
|
|
56
56
|
* binary's rate-limit buckets don't leak across test cases.
|
|
57
|
+
*
|
|
58
|
+
* This is the **one sanctioned caller that clears IP-aggregate keys** —
|
|
59
|
+
* `RateLimiter.reset` forbids that for production code, because refunding
|
|
60
|
+
* the IP bucket on a successful auth reopens the distributed-spray path.
|
|
61
|
+
* Here it is test-state teardown between cases, not success-forgiveness, so
|
|
62
|
+
* the rule doesn't apply.
|
|
57
63
|
*/
|
|
58
64
|
reset_all() {
|
|
59
65
|
for (const key of this.#seen_keys) {
|
package/dist/ui/CLAUDE.md
CHANGED
|
@@ -244,9 +244,13 @@ destructive actions.
|
|
|
244
244
|
- `table_state.svelte.ts` — `TableState`. Paginated DB browser state.
|
|
245
245
|
Holds one `AsyncSlot` (`list`) + payload fields (`table_name`,
|
|
246
246
|
`columns`, `rows`, `total`, `offset`, `limit` capped by
|
|
247
|
-
`
|
|
248
|
-
`
|
|
249
|
-
`
|
|
247
|
+
`http/db_routes.ts`'s `DB_TABLE_ROWS_LIMIT_MAX`, `primary_key`,
|
|
248
|
+
`deletable`). Derived
|
|
249
|
+
`showing_start`/`showing_end`/`has_prev`/`has_next`, plus
|
|
250
|
+
`can_delete` (`deletable && primary_key !== null`) — gate the
|
|
251
|
+
delete affordance on that, not on `primary_key`, or a
|
|
252
|
+
policy-excluded table shows a button that only ever refuses.
|
|
253
|
+
Methods: `fetch`, `go_prev`/`go_next`, `delete_row`. `delete_row` uses
|
|
250
254
|
plain try/catch + scalar `deleting` / `delete_error` fields (no
|
|
251
255
|
slot — error must survive past `list.run()` retries).
|
|
252
256
|
- `form_state.svelte.ts` — `FormState`. Enter-advance between
|
|
@@ -421,4 +425,5 @@ format_scope, global_label)` helper — `global_label = null` renders no
|
|
|
421
425
|
- `format_value(value)` — table-cell stringifier (NULL / undefined /
|
|
422
426
|
JSON / primitive).
|
|
423
427
|
- `format_audit_metadata(event_type, metadata)` — event-type-
|
|
424
|
-
specific metadata summary (
|
|
428
|
+
specific metadata summary (bespoke cases for the common event types,
|
|
429
|
+
JSON-stringified fallback for the rest).
|
|
@@ -40,9 +40,7 @@
|
|
|
40
40
|
* @module
|
|
41
41
|
*/
|
|
42
42
|
import { AsyncSlot } from './async_slot.svelte.js';
|
|
43
|
-
import type
|
|
44
|
-
/** Maximum number of rows that can be fetched in a single page. */
|
|
45
|
-
export declare const TABLE_LIMIT_MAX = 1000;
|
|
43
|
+
import { type ColumnInfo } from '../http/db_routes.js';
|
|
46
44
|
export declare class TableState {
|
|
47
45
|
readonly list: AsyncSlot<void, string>;
|
|
48
46
|
table_name: string;
|
|
@@ -52,15 +50,30 @@ export declare class TableState {
|
|
|
52
50
|
offset: number;
|
|
53
51
|
limit: number;
|
|
54
52
|
primary_key: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the server will accept a row `DELETE` on this table. False for a
|
|
55
|
+
* table with no single-column primary key *and* for one the server excludes
|
|
56
|
+
* by policy (the audit trail, singleton bookkeeping rows), which `primary_key`
|
|
57
|
+
* alone can't distinguish — it reports the key shape, not the policy.
|
|
58
|
+
*/
|
|
59
|
+
deletable: boolean;
|
|
55
60
|
deleting: string | null;
|
|
56
61
|
delete_error: string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to offer a delete affordance at all. Gate rendering on this rather
|
|
64
|
+
* than on `primary_key`, or a policy-excluded table shows a button whose only
|
|
65
|
+
* outcome is a `table_not_deletable` refusal.
|
|
66
|
+
*/
|
|
67
|
+
readonly can_delete: boolean;
|
|
57
68
|
readonly showing_start: number;
|
|
58
69
|
readonly showing_end: number;
|
|
59
70
|
readonly has_prev: boolean;
|
|
60
71
|
readonly has_next: boolean;
|
|
61
72
|
/**
|
|
62
73
|
* Fetch a page of rows for `table_name` from `GET /api/db/tables/{table_name}`.
|
|
63
|
-
* `limit` is clamped to `[1,
|
|
74
|
+
* `limit` is clamped to `[1, DB_TABLE_ROWS_LIMIT_MAX]` — the same bounds
|
|
75
|
+
* the route 400s on, single-sourced so the client guard can't drift into
|
|
76
|
+
* sending a request the server refuses.
|
|
64
77
|
*
|
|
65
78
|
* @mutates `this`
|
|
66
79
|
*/
|
|
@@ -72,7 +85,7 @@ export declare class TableState {
|
|
|
72
85
|
* Optimistically drops it from `rows` and decrements `total` on success;
|
|
73
86
|
* surfaces server errors on `delete_error`.
|
|
74
87
|
*
|
|
75
|
-
* @returns `true` when the row was removed; `false`
|
|
88
|
+
* @returns `true` when the row was removed; `false` when the table isn't deletable or the server refused
|
|
76
89
|
* @mutates `this`
|
|
77
90
|
*/
|
|
78
91
|
delete_row(row: Record<string, unknown>): Promise<boolean>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/table_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"table_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/table_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAGN,KAAK,UAAU,EACf,MAAM,sBAAsB,CAAC;AAE9B,qBAAa,UAAU;IACtB,QAAQ,CAAC,IAAI,0BAAyB;IAEtC,UAAU,EAAE,MAAM,CAAkB;IACpC,OAAO,EAAE,KAAK,CAAC,UAAU,CAAC,CAAkB;IAC5C,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAkB;IACtD,KAAK,SAAiB;IACtB,MAAM,SAAiB;IACvB,KAAK,SAA2C;IAChD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC9C;;;;;OAKG;IACH,SAAS,UAAqB;IAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC3C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAoB;IAE/C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,UAAyD;IAG5E,QAAQ,CAAC,aAAa,SAAoD;IAC1E,QAAQ,CAAC,WAAW,SAAkE;IACtF,QAAQ,CAAC,QAAQ,UAA6B;IAC9C,QAAQ,CAAC,QAAQ,UAAmD;IAEpE;;;;;;;OAOG;IACG,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBvE,OAAO,IAAI,IAAI;IAIf,OAAO,IAAI,IAAI;IAIf;;;;;;;OAOG;IACG,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;CAoChE"}
|
|
@@ -43,8 +43,7 @@ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
|
|
|
43
43
|
import { AsyncSlot } from './async_slot.svelte.js';
|
|
44
44
|
import { parse_response_error, ui_fetch } from './ui_fetch.js';
|
|
45
45
|
import { format_value } from './ui_format.js';
|
|
46
|
-
|
|
47
|
-
export const TABLE_LIMIT_MAX = 1000;
|
|
46
|
+
import { DB_TABLE_ROWS_DEFAULT_LIMIT, DB_TABLE_ROWS_LIMIT_MAX } from '../http/db_routes.js';
|
|
48
47
|
export class TableState {
|
|
49
48
|
list = new AsyncSlot();
|
|
50
49
|
table_name = $state.raw('');
|
|
@@ -52,10 +51,23 @@ export class TableState {
|
|
|
52
51
|
rows = $state.raw([]);
|
|
53
52
|
total = $state.raw(0);
|
|
54
53
|
offset = $state.raw(0);
|
|
55
|
-
limit = $state.raw(
|
|
54
|
+
limit = $state.raw(DB_TABLE_ROWS_DEFAULT_LIMIT);
|
|
56
55
|
primary_key = $state.raw(null);
|
|
56
|
+
/**
|
|
57
|
+
* Whether the server will accept a row `DELETE` on this table. False for a
|
|
58
|
+
* table with no single-column primary key *and* for one the server excludes
|
|
59
|
+
* by policy (the audit trail, singleton bookkeeping rows), which `primary_key`
|
|
60
|
+
* alone can't distinguish — it reports the key shape, not the policy.
|
|
61
|
+
*/
|
|
62
|
+
deletable = $state.raw(false);
|
|
57
63
|
deleting = $state.raw(null);
|
|
58
64
|
delete_error = $state.raw(null);
|
|
65
|
+
/**
|
|
66
|
+
* Whether to offer a delete affordance at all. Gate rendering on this rather
|
|
67
|
+
* than on `primary_key`, or a policy-excluded table shows a button whose only
|
|
68
|
+
* outcome is a `table_not_deletable` refusal.
|
|
69
|
+
*/
|
|
70
|
+
can_delete = $derived(this.deletable && this.primary_key !== null);
|
|
59
71
|
// Pagination computed values
|
|
60
72
|
showing_start = $derived(this.total === 0 ? 0 : this.offset + 1);
|
|
61
73
|
showing_end = $derived(Math.min(this.offset + this.rows.length, this.total));
|
|
@@ -63,14 +75,16 @@ export class TableState {
|
|
|
63
75
|
has_next = $derived(this.offset + this.limit < this.total);
|
|
64
76
|
/**
|
|
65
77
|
* Fetch a page of rows for `table_name` from `GET /api/db/tables/{table_name}`.
|
|
66
|
-
* `limit` is clamped to `[1,
|
|
78
|
+
* `limit` is clamped to `[1, DB_TABLE_ROWS_LIMIT_MAX]` — the same bounds
|
|
79
|
+
* the route 400s on, single-sourced so the client guard can't drift into
|
|
80
|
+
* sending a request the server refuses.
|
|
67
81
|
*
|
|
68
82
|
* @mutates `this`
|
|
69
83
|
*/
|
|
70
84
|
async fetch(table_name, offset = 0, limit = 100) {
|
|
71
85
|
this.table_name = table_name;
|
|
72
86
|
this.offset = offset;
|
|
73
|
-
this.limit = Math.max(1, Math.min(
|
|
87
|
+
this.limit = Math.max(1, Math.min(DB_TABLE_ROWS_LIMIT_MAX, limit));
|
|
74
88
|
await this.list.run(async () => {
|
|
75
89
|
const response = await ui_fetch(`/api/db/tables/${table_name}?offset=${this.offset}&limit=${this.limit}`);
|
|
76
90
|
if (!response.ok) {
|
|
@@ -81,6 +95,7 @@ export class TableState {
|
|
|
81
95
|
this.rows = data.rows ?? [];
|
|
82
96
|
this.total = data.total ?? 0;
|
|
83
97
|
this.primary_key = data.primary_key ?? null;
|
|
98
|
+
this.deletable = data.deletable ?? false;
|
|
84
99
|
});
|
|
85
100
|
}
|
|
86
101
|
go_prev() {
|
|
@@ -94,11 +109,11 @@ export class TableState {
|
|
|
94
109
|
* Optimistically drops it from `rows` and decrements `total` on success;
|
|
95
110
|
* surfaces server errors on `delete_error`.
|
|
96
111
|
*
|
|
97
|
-
* @returns `true` when the row was removed; `false`
|
|
112
|
+
* @returns `true` when the row was removed; `false` when the table isn't deletable or the server refused
|
|
98
113
|
* @mutates `this`
|
|
99
114
|
*/
|
|
100
115
|
async delete_row(row) {
|
|
101
|
-
if (!this.primary_key)
|
|
116
|
+
if (!this.can_delete || !this.primary_key)
|
|
102
117
|
return false;
|
|
103
118
|
const pk_value = row[this.primary_key];
|
|
104
119
|
if (pk_value === null || pk_value === undefined)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui_format.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/ui_format.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAChC,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,EACjC,MAAK,MAAmB,KACtB,MA2BF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,KAAG,MAY1C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,YAAY,MAAM,EAAE,kBAAe,KAAG,MAOlF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,MAAmC,CAAC;AAEjF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAS7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,KAAG,MAOzE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GACjC,YAAY,MAAM,EAClB,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KACtC,
|
|
1
|
+
{"version":3,"file":"ui_format.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/ui_format.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAChC,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,EACjC,MAAK,MAAmB,KACtB,MA2BF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,KAAG,MAY1C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,YAAY,MAAM,EAAE,kBAAe,KAAG,MAOlF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,MAAmC,CAAC;AAEjF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAS7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,KAAG,MAOzE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GACjC,YAAY,MAAM,EAClB,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KACtC,MAgEF,CAAC"}
|
package/dist/ui/ui_format.js
CHANGED
|
@@ -178,6 +178,10 @@ export const format_audit_metadata = (event_type, metadata) => {
|
|
|
178
178
|
return metadata.setting
|
|
179
179
|
? `${metadata.setting}: ${JSON.stringify(metadata.old_value)} → ${JSON.stringify(metadata.new_value)}`
|
|
180
180
|
: '';
|
|
181
|
+
case 'db_admin_row_delete':
|
|
182
|
+
return metadata.table
|
|
183
|
+
? `${metadata.table}.${metadata.pk_column}: ${truncate_middle(metadata.id, 12)}`
|
|
184
|
+
: '';
|
|
181
185
|
default:
|
|
182
186
|
return JSON.stringify(metadata);
|
|
183
187
|
}
|