@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
package/dist/http/db_routes.js
CHANGED
|
@@ -1,25 +1,155 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API route specs for database administration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Allowlist-gated PostgreSQL table browser using `information_schema` —
|
|
5
|
+
* consumers declare `browsable_tables` and the credential floor
|
|
6
|
+
* (`NON_BROWSABLE_TABLES`) is always subtracted. Provides: list tables, view
|
|
7
|
+
* columns/rows (paginated, bytea values placeholdered), delete rows by PK
|
|
8
|
+
* (audited as `db_admin_row_delete`), health check.
|
|
6
9
|
*
|
|
7
10
|
* @module
|
|
8
11
|
*/
|
|
9
12
|
import { z } from 'zod';
|
|
10
13
|
import { get_route_params, get_route_query } from './route_spec.js';
|
|
11
14
|
import { ActingActor } from './auth_shape.js';
|
|
12
|
-
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
15
|
+
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_TABLE_NOT_DELETABLE, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
13
16
|
import { assert_valid_sql_identifier, VALID_SQL_IDENTIFIER } from '../db/sql_identifier.js';
|
|
17
|
+
import { pg_error_code } from '../db/pg_error.js';
|
|
18
|
+
import { get_client_ip } from './client_ip.js';
|
|
19
|
+
import { emit_after_commit } from './pending_effects.js';
|
|
14
20
|
/** Default page size for `GET /tables/:name` rows. */
|
|
15
21
|
export const DB_TABLE_ROWS_DEFAULT_LIMIT = 100;
|
|
16
22
|
/** Maximum page size for `GET /tables/:name` rows. */
|
|
17
23
|
export const DB_TABLE_ROWS_LIMIT_MAX = 1000;
|
|
24
|
+
/**
|
|
25
|
+
* Per-statement timeout applied (`SET LOCAL statement_timeout`) inside the
|
|
26
|
+
* table-list, table-detail, and row-`DELETE` transactions — no single
|
|
27
|
+
* statement outlives it. (Per statement, not per request: the table listing's
|
|
28
|
+
* one-`COUNT(*)`-per-table loop can still take several in sequence.)
|
|
29
|
+
* Milliseconds.
|
|
30
|
+
*/
|
|
31
|
+
export const DB_ADMIN_STATEMENT_TIMEOUT_MS = 5000;
|
|
32
|
+
/**
|
|
33
|
+
* Tables the browser can never expose, whatever `browsable_tables` names —
|
|
34
|
+
* the credential floor, subtracted from the consumer's allowlist and not
|
|
35
|
+
* overridable:
|
|
36
|
+
*
|
|
37
|
+
* - `account` — the one surface that would return the argon2 password-hash
|
|
38
|
+
* corpus in bulk (every other read path excludes the hash by projection).
|
|
39
|
+
* - `auth_session` / `api_token` — credential digests plus per-account
|
|
40
|
+
* session/token metadata.
|
|
41
|
+
* - `bootstrap_lock` — the first-admin gate; its row is load-bearing state,
|
|
42
|
+
* not content.
|
|
43
|
+
*
|
|
44
|
+
* A table on the floor answers exactly like one that doesn't exist (404
|
|
45
|
+
* `table_not_found`) so the browser doesn't confirm its presence.
|
|
46
|
+
*/
|
|
47
|
+
export const NON_BROWSABLE_TABLES = Object.freeze([
|
|
48
|
+
'account',
|
|
49
|
+
'auth_session',
|
|
50
|
+
'api_token',
|
|
51
|
+
'bootstrap_lock'
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* Tables this endpoint refuses to delete rows from, whatever their key shape.
|
|
55
|
+
*
|
|
56
|
+
* Two kinds, one rule — a generic storage endpoint has no business deleting a
|
|
57
|
+
* row whose meaning lives in the domain layer:
|
|
58
|
+
*
|
|
59
|
+
* - `audit_log` — the trail and the tamper path would otherwise be the same
|
|
60
|
+
* surface. It is also how revocation propagates: `realtime/sse_auth_guard.ts`
|
|
61
|
+
* and the WS auth guard close live streams by listening to audit events, so a
|
|
62
|
+
* raw row delete here is invisible to them.
|
|
63
|
+
* `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
|
|
64
|
+
* advertising an open bootstrap window on the next boot that
|
|
65
|
+
* `bootstrap_account.ts`'s atomic flip then always refuses.
|
|
66
|
+
* - `app_settings` — deleting the singleton makes every settings read throw.
|
|
67
|
+
* - `schema_version` — the migration tracker. Already refused by the
|
|
68
|
+
* single-column-primary-key rule (its key is `(namespace, name)`), but that
|
|
69
|
+
* is incidental: naming it here keeps the protection if the key shape ever
|
|
70
|
+
* changes.
|
|
71
|
+
*
|
|
72
|
+
* Consumers extend this with `DbRouteOptions.non_deletable_tables` — the
|
|
73
|
+
* builtin set is a floor, never replaced.
|
|
74
|
+
*/
|
|
75
|
+
export const NON_DELETABLE_TABLES = Object.freeze([
|
|
76
|
+
'audit_log',
|
|
77
|
+
'bootstrap_lock',
|
|
78
|
+
'app_settings',
|
|
79
|
+
'schema_version'
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a table's primary-key column names in key order
|
|
83
|
+
* (`key_column_usage.ordinal_position`). Empty for a table with no primary key;
|
|
84
|
+
* length `> 1` for a composite primary key.
|
|
85
|
+
*
|
|
86
|
+
* Every column is returned (no `LIMIT`) so callers see the full key shape: the
|
|
87
|
+
* row-`DELETE` route acts only on a single-column primary key, and an earlier
|
|
88
|
+
* `LIMIT 1` (no `ORDER BY`) hid a composite PK behind one arbitrary column,
|
|
89
|
+
* causing single-column deletes to over-match.
|
|
90
|
+
*/
|
|
91
|
+
const query_primary_key_columns = async (db, name) => {
|
|
92
|
+
const pk_rows = await db.query(`SELECT kcu.column_name
|
|
93
|
+
FROM information_schema.table_constraints tc
|
|
94
|
+
JOIN information_schema.key_column_usage kcu
|
|
95
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
96
|
+
AND tc.table_schema = kcu.table_schema
|
|
97
|
+
AND tc.table_name = kcu.table_name
|
|
98
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
99
|
+
AND tc.table_schema = 'public'
|
|
100
|
+
AND tc.table_name = $1
|
|
101
|
+
ORDER BY kcu.ordinal_position`, [name]);
|
|
102
|
+
return pk_rows.map((row) => row.column_name);
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Whether `name` is a table in the `public` schema. Both the detail and
|
|
106
|
+
* row-`DELETE` routes gate on this before interpolating the name into SQL, so
|
|
107
|
+
* an unknown table is a 404 rather than a raw PG error.
|
|
108
|
+
*/
|
|
109
|
+
const query_table_exists = async (db, name) => {
|
|
110
|
+
const row = await db.query_one(`SELECT table_name FROM information_schema.tables
|
|
111
|
+
WHERE table_schema = 'public' AND table_name = $1`, [name]);
|
|
112
|
+
return row !== undefined;
|
|
113
|
+
};
|
|
18
114
|
/**
|
|
19
115
|
* Create the db API route specs.
|
|
20
116
|
*/
|
|
21
|
-
export const create_db_route_specs = (options) => {
|
|
22
|
-
const { db_type, db_name, extra_stats, log } = options;
|
|
117
|
+
export const create_db_route_specs = (deps, options) => {
|
|
118
|
+
const { db_type, db_name, browsable_tables, extra_stats, log, non_deletable_tables } = options;
|
|
119
|
+
const non_deletable = new Set([
|
|
120
|
+
...NON_DELETABLE_TABLES,
|
|
121
|
+
...(non_deletable_tables ?? [])
|
|
122
|
+
]);
|
|
123
|
+
// The credential floor is subtracted, not merely documented — a consumer
|
|
124
|
+
// naming `account` in its allowlist still gets no `account` browsing.
|
|
125
|
+
const browsable = new Set(browsable_tables.filter((name) => !NON_BROWSABLE_TABLES.includes(name)));
|
|
126
|
+
// `SET` can't be parameterized; the value is a module constant, never input.
|
|
127
|
+
const set_statement_timeout = async (db) => {
|
|
128
|
+
await db.query(`SET LOCAL statement_timeout = ${DB_ADMIN_STATEMENT_TIMEOUT_MS}`);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* The shared masked gate prologue for the detail and row-`DELETE`
|
|
132
|
+
* handlers: allowlist check (before any catalog read), then the statement
|
|
133
|
+
* timeout, then existence — an unbrowsable table and a missing one get
|
|
134
|
+
* the same 404 from the same place, so the masking invariant has one
|
|
135
|
+
* home. Returns whether the table is browsable AND exists.
|
|
136
|
+
*/
|
|
137
|
+
const gate_browsable_table = async (db, name) => {
|
|
138
|
+
if (!browsable.has(name))
|
|
139
|
+
return false;
|
|
140
|
+
await set_statement_timeout(db);
|
|
141
|
+
return query_table_exists(db, name);
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Strict integer query param — accepts exactly what the Rust twin's
|
|
145
|
+
* `str::parse::<i64>` does (`[+-]?digits`), so the twins refuse the same
|
|
146
|
+
* spellings (`z.coerce` alone admits `""`, `" 5"`, `"1e2"`, `"5.0"`).
|
|
147
|
+
*/
|
|
148
|
+
const query_int = (bounds, fallback) => z.preprocess((v) => v === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: typeof v === 'string' && /^[+-]?\d+$/.test(v)
|
|
151
|
+
? Number(v)
|
|
152
|
+
: Number.NaN, bounds.default(fallback));
|
|
23
153
|
return [
|
|
24
154
|
{
|
|
25
155
|
method: 'GET',
|
|
@@ -40,6 +170,9 @@ export const create_db_route_specs = (options) => {
|
|
|
40
170
|
handler: async (c, route) => {
|
|
41
171
|
try {
|
|
42
172
|
await route.db.query('SELECT 1');
|
|
173
|
+
// Deliberately schema-wide (not allowlist-filtered): the count
|
|
174
|
+
// is a did-migrations-run diagnostic, and a bare number leaks
|
|
175
|
+
// no table contents or names.
|
|
43
176
|
const table_result = await route.db.query(`SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
44
177
|
const table_count = table_result[0] ? parseInt(table_result[0].count, 10) : 0;
|
|
45
178
|
const stats = extra_stats ? await extra_stats(route.db) : {};
|
|
@@ -70,18 +203,24 @@ export const create_db_route_specs = (options) => {
|
|
|
70
203
|
roles: ['keeper'],
|
|
71
204
|
credential_types: ['daemon_token']
|
|
72
205
|
},
|
|
73
|
-
description: 'List
|
|
206
|
+
description: 'List browsable tables with row counts',
|
|
74
207
|
query: z.strictObject({ acting: ActingActor }),
|
|
75
208
|
input: z.null(),
|
|
76
209
|
output: z.looseObject({
|
|
77
210
|
tables: z.array(z.strictObject({ name: z.string(), row_count: z.number() }))
|
|
78
211
|
}),
|
|
212
|
+
transaction: true,
|
|
79
213
|
handler: async (c, route) => {
|
|
214
|
+
await set_statement_timeout(route.db);
|
|
215
|
+
// Existence still comes from `information_schema` — a listed table
|
|
216
|
+
// that hasn't migrated in yet is silently absent, not a 500.
|
|
80
217
|
const table_names = await route.db.query(`SELECT table_name FROM information_schema.tables
|
|
81
218
|
WHERE table_schema = 'public'
|
|
82
219
|
ORDER BY table_name`);
|
|
83
220
|
const tables = [];
|
|
84
221
|
for (const { table_name } of table_names) {
|
|
222
|
+
if (!browsable.has(table_name))
|
|
223
|
+
continue;
|
|
85
224
|
const result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(table_name)}"`);
|
|
86
225
|
tables.push({
|
|
87
226
|
name: table_name,
|
|
@@ -104,13 +243,8 @@ export const create_db_route_specs = (options) => {
|
|
|
104
243
|
params: z.strictObject({ name: z.string().regex(VALID_SQL_IDENTIFIER) }),
|
|
105
244
|
query: z.strictObject({
|
|
106
245
|
acting: ActingActor,
|
|
107
|
-
offset: z.
|
|
108
|
-
limit: z.
|
|
109
|
-
.number()
|
|
110
|
-
.int()
|
|
111
|
-
.min(1)
|
|
112
|
-
.max(DB_TABLE_ROWS_LIMIT_MAX)
|
|
113
|
-
.default(DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
246
|
+
offset: query_int(z.number().int().min(0), 0),
|
|
247
|
+
limit: query_int(z.number().int().min(1).max(DB_TABLE_ROWS_LIMIT_MAX), DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
114
248
|
}),
|
|
115
249
|
input: z.null(),
|
|
116
250
|
errors: {
|
|
@@ -127,34 +261,59 @@ export const create_db_route_specs = (options) => {
|
|
|
127
261
|
total: z.number(),
|
|
128
262
|
offset: z.number(),
|
|
129
263
|
limit: z.number(),
|
|
130
|
-
primary_key: z.string().nullable()
|
|
264
|
+
primary_key: z.string().nullable(),
|
|
265
|
+
deletable: z.boolean()
|
|
131
266
|
}),
|
|
267
|
+
transaction: true,
|
|
132
268
|
handler: async (c, route) => {
|
|
133
269
|
const { name } = get_route_params(c);
|
|
134
270
|
const { offset, limit } = get_route_query(c);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!
|
|
271
|
+
// Allowlist gate, masked: an unlisted (or floor) table answers
|
|
272
|
+
// exactly like one that doesn't exist.
|
|
273
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
138
274
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
139
275
|
}
|
|
140
|
-
|
|
276
|
+
// `udt_name` is internal (drives the byte-value placeholder;
|
|
277
|
+
// `data_type` reports only `ARRAY` for array columns) — it is
|
|
278
|
+
// stripped from the response so the wire shape twins Rust's.
|
|
279
|
+
const column_rows = await route.db.query(`SELECT column_name, data_type, is_nullable, udt_name
|
|
141
280
|
FROM information_schema.columns
|
|
142
281
|
WHERE table_schema = 'public' AND table_name = $1
|
|
143
282
|
ORDER BY ordinal_position`, [name]);
|
|
283
|
+
const columns = column_rows.map(({ column_name, data_type, is_nullable }) => ({ column_name, data_type, is_nullable }));
|
|
144
284
|
const count_result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(name)}"`);
|
|
145
285
|
const total = count_result ? parseInt(count_result.count, 10) : 0;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
286
|
+
// Surface a single-column PK for the delete affordance; a
|
|
287
|
+
// composite (or absent) PK has no single deletable column, so
|
|
288
|
+
// report null — twinning the row-DELETE's single-column rule.
|
|
289
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
290
|
+
const primary_key = pk_columns.length === 1 ? pk_columns[0] : null;
|
|
291
|
+
// `deletable` is exactly what the row-DELETE will accept, so a
|
|
292
|
+
// client can hide the affordance rather than discover the refusal.
|
|
293
|
+
// `primary_key` stays a truthful report of the key shape — a policy
|
|
294
|
+
// exclusion must not masquerade as one.
|
|
295
|
+
const deletable = primary_key !== null && !non_deletable.has(name);
|
|
296
|
+
// byte values never leave the server — a full page of e.g.
|
|
297
|
+
// `fact.bytes` would otherwise materialize GiBs in one response.
|
|
298
|
+
// The column stays in the result with a `<N bytes>` placeholder
|
|
299
|
+
// per cell (NULL stays NULL); `columns` metadata is untouched.
|
|
300
|
+
// Keyed on `udt_name` so `bytea[]` (`data_type: 'ARRAY'`,
|
|
301
|
+
// `udt_name: '_bytea'`) is covered too — `octet_length` doesn't
|
|
302
|
+
// take arrays, so those report the stored `pg_column_size`.
|
|
303
|
+
const select_list = column_rows.length === 0 // zero-column tables are legal PG
|
|
304
|
+
? '*'
|
|
305
|
+
: column_rows
|
|
306
|
+
.map(({ column_name, udt_name }) => {
|
|
307
|
+
const ident = assert_valid_sql_identifier(column_name);
|
|
308
|
+
if (udt_name === 'bytea')
|
|
309
|
+
return `('<' || octet_length("${ident}") || ' bytes>') AS "${ident}"`;
|
|
310
|
+
if (udt_name === '_bytea')
|
|
311
|
+
return `('<' || pg_column_size("${ident}") || ' bytes>') AS "${ident}"`;
|
|
312
|
+
return `"${ident}"`;
|
|
313
|
+
})
|
|
314
|
+
.join(', ');
|
|
315
|
+
const rows = await route.db.query(`SELECT ${select_list} FROM "${assert_valid_sql_identifier(name)}" LIMIT $1 OFFSET $2`, [limit, offset]);
|
|
316
|
+
return c.json({ columns, rows, total, offset, limit, primary_key, deletable });
|
|
158
317
|
}
|
|
159
318
|
},
|
|
160
319
|
{
|
|
@@ -176,7 +335,11 @@ export const create_db_route_specs = (options) => {
|
|
|
176
335
|
output: z.looseObject({ success: z.boolean() }),
|
|
177
336
|
errors: {
|
|
178
337
|
400: z.looseObject({
|
|
179
|
-
error: z.enum([
|
|
338
|
+
error: z.enum([
|
|
339
|
+
ERROR_INVALID_ROUTE_PARAMS,
|
|
340
|
+
ERROR_TABLE_NO_PRIMARY_KEY,
|
|
341
|
+
ERROR_TABLE_NOT_DELETABLE
|
|
342
|
+
])
|
|
180
343
|
}),
|
|
181
344
|
404: z.looseObject({
|
|
182
345
|
error: z.enum([ERROR_TABLE_NOT_FOUND, ERROR_ROW_NOT_FOUND])
|
|
@@ -185,32 +348,86 @@ export const create_db_route_specs = (options) => {
|
|
|
185
348
|
},
|
|
186
349
|
handler: async (c, route) => {
|
|
187
350
|
const { name, id } = get_route_params(c);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!
|
|
351
|
+
// Same allowlist gate and masking as the detail route — an
|
|
352
|
+
// unbrowsable table exposes no rows to delete either.
|
|
353
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
191
354
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
192
355
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
356
|
+
// Policy exclusion, checked before the key shape: the trail and the
|
|
357
|
+
// framework's singleton bookkeeping rows are never row-deletable through
|
|
358
|
+
// a generic storage endpoint (see `NON_DELETABLE_TABLES`). 400 rather
|
|
359
|
+
// than 403 — this says nothing about the caller's authority, and it sits
|
|
360
|
+
// beside the structural refusal below as the same kind of answer: this
|
|
361
|
+
// table exposes no deletable row here. A 403 would also have to be
|
|
362
|
+
// declared as a union with whatever the route's `auth` derives, which a
|
|
363
|
+
// consumer rewriting `auth` (as fuz_forge does) would silently invalidate.
|
|
364
|
+
if (non_deletable.has(name)) {
|
|
365
|
+
return c.json({ error: ERROR_TABLE_NOT_DELETABLE }, 400);
|
|
366
|
+
}
|
|
367
|
+
// Single-column primary keys only. Deleting by a single
|
|
368
|
+
// `WHERE "<col>" = $1` is safe only when the PK is exactly one
|
|
369
|
+
// column: on a composite PK that filter matches every row sharing
|
|
370
|
+
// the column's value and silently over-deletes (deleting one
|
|
371
|
+
// cell_field row would wipe every field of that name on every
|
|
372
|
+
// cell); an absent PK has nothing to target. Refuse both.
|
|
373
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
374
|
+
if (pk_columns.length !== 1) {
|
|
203
375
|
return c.json({ error: ERROR_TABLE_NO_PRIMARY_KEY }, 400);
|
|
204
376
|
}
|
|
377
|
+
const pk_column = pk_columns[0];
|
|
205
378
|
try {
|
|
206
|
-
|
|
379
|
+
// Typed PK compare, twinning the Rust spine: `$1` travels as an
|
|
380
|
+
// untyped text-format parameter, so PG infers the PK column's
|
|
381
|
+
// type and coerces the id with that type's input function —
|
|
382
|
+
// index-scan deletes and canonical per-type semantics (citext
|
|
383
|
+
// case-insensitivity, char(n) padding, any valid uuid spelling).
|
|
384
|
+
// An id that cannot be a value of the PK type errors at bind
|
|
385
|
+
// (22P02/22003), mapped below to the same 404 as a typed miss.
|
|
386
|
+
// `RETURNING "<pk>"::text` reads back only the PK cell — the
|
|
387
|
+
// deleted row's other values (e.g. a bytea column) are never
|
|
388
|
+
// materialized — so the audit trail records the row's canonical
|
|
389
|
+
// PK rendering rather than whatever spelling the URL supplied.
|
|
390
|
+
const pk_ident = assert_valid_sql_identifier(pk_column);
|
|
391
|
+
const result = await route.db.query(`DELETE FROM "${assert_valid_sql_identifier(name)}" WHERE "${pk_ident}" = $1 RETURNING "${pk_ident}"::text AS pk_text`, [id]);
|
|
207
392
|
if (result.length === 0) {
|
|
208
393
|
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
209
394
|
}
|
|
395
|
+
const deleted_id = result[0].pk_text;
|
|
396
|
+
// The trail for the trail-adjacent surface: every successful row
|
|
397
|
+
// delete through this endpoint is audited. Deferred via
|
|
398
|
+
// `emit_after_commit` so the pool-routed row can never claim a
|
|
399
|
+
// delete whose transaction failed at COMMIT — the Rust twin
|
|
400
|
+
// commits before emitting for the same reason. Account-grain
|
|
401
|
+
// attribution — the browser is a raw storage surface, so no actor
|
|
402
|
+
// is claimed.
|
|
403
|
+
const account_id = c.var.request_context?.account.id ?? null;
|
|
404
|
+
const ip = get_client_ip(c);
|
|
405
|
+
emit_after_commit(route, () => {
|
|
406
|
+
deps.audit.emit(route, {
|
|
407
|
+
event_type: 'db_admin_row_delete',
|
|
408
|
+
account_id,
|
|
409
|
+
ip,
|
|
410
|
+
metadata: { table: name, pk_column, id: deleted_id }
|
|
411
|
+
});
|
|
412
|
+
});
|
|
210
413
|
return c.json({ success: true });
|
|
211
414
|
}
|
|
212
415
|
catch (err) {
|
|
213
|
-
|
|
416
|
+
const code = pg_error_code(err);
|
|
417
|
+
// 22P02 invalid_text_representation / 22003 numeric_value_out_of_range
|
|
418
|
+
// / 22021 character_not_in_repertoire: the id cannot be coerced
|
|
419
|
+
// to the PK column's type — or cannot even be a text value (a
|
|
420
|
+
// NUL byte, rejected by the server encoding whatever the PK
|
|
421
|
+
// type) — so no row can match; answer with the same masked 404
|
|
422
|
+
// as a typed miss.
|
|
423
|
+
if (code === '22P02' || code === '22003' || code === '22021') {
|
|
424
|
+
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
425
|
+
}
|
|
426
|
+
// 23503 foreign_key_violation / 23001 restrict_violation — the
|
|
427
|
+
// same referencing-row refusal: PG 18+ raises 23001 for `ON
|
|
428
|
+
// DELETE RESTRICT` constraints where PG ≤17 (PGlite today)
|
|
429
|
+
// raises 23503 for both.
|
|
430
|
+
if (code === '23503' || code === '23001') {
|
|
214
431
|
const pg_err = err;
|
|
215
432
|
log?.warn('Foreign key violation:', pg_err.detail, pg_err.constraint);
|
|
216
433
|
return c.json({ error: ERROR_FOREIGN_KEY_VIOLATION }, 409);
|
|
@@ -120,8 +120,19 @@ export declare const ERROR_INVALID_EVENT_TYPE: "invalid_event_type";
|
|
|
120
120
|
export declare const ERROR_FOREIGN_KEY_VIOLATION: "foreign_key_violation";
|
|
121
121
|
/** Table name not found in `information_schema`. */
|
|
122
122
|
export declare const ERROR_TABLE_NOT_FOUND: "table_not_found";
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
125
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
126
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
127
|
+
*/
|
|
124
128
|
export declare const ERROR_TABLE_NO_PRIMARY_KEY: "table_no_primary_key";
|
|
129
|
+
/**
|
|
130
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
131
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
132
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
133
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const ERROR_TABLE_NOT_DELETABLE: "table_not_deletable";
|
|
125
136
|
/** Row with the given PK value not found. */
|
|
126
137
|
export declare const ERROR_ROW_NOT_FOUND: "row_not_found";
|
|
127
138
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -272,7 +283,7 @@ export type RouteErrorSchemas = Partial<Record<number, z.ZodType>>;
|
|
|
272
283
|
* Rate limit key type — declares what a route or RPC action's rate limiter
|
|
273
284
|
* is keyed on.
|
|
274
285
|
*
|
|
275
|
-
* - `'ip'` — per-IP rate limiting (bootstrap, password change,
|
|
286
|
+
* - `'ip'` — per-IP rate limiting (login, bootstrap, password change, signup)
|
|
276
287
|
* - `'account'` — per-account rate limiting. On REST auth routes the key is
|
|
277
288
|
* the submitted identifier (login). On RPC actions (post-auth) the key is
|
|
278
289
|
* the resolved actor id (`request_context.actor.id`) — separate namespace.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI9D,0CAA0C;AAC1C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE,6CAA6C;AAC7C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAI1E,wCAAwC;AACxC,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,qDAAqD;AACrD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAIpE,uCAAuC;AACvC,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAG,eAAwB,CAAC;AAE5D,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAG,gBAAyB,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAIlE,uDAAuD;AACvD,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAIlF,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAEtE,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE,gDAAgD;AAChD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,6DAA6D;AAC7D,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,0DAA0D;AAC1D,eAAO,MAAM,iCAAiC,EAAG,6BAAsC,CAAC;AAIxF,6DAA6D;AAC7D,eAAO,MAAM,4BAA4B,EAAG,wBAAiC,CAAC;AAE9E,gEAAgE;AAChE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,oEAAoE;AACpE,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,kDAAkD;AAClD,eAAO,MAAM,2BAA2B,EAAG,uBAAgC,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"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"}
|
|
@@ -128,8 +128,19 @@ export const ERROR_INVALID_EVENT_TYPE = 'invalid_event_type';
|
|
|
128
128
|
export const ERROR_FOREIGN_KEY_VIOLATION = 'foreign_key_violation';
|
|
129
129
|
/** Table name not found in `information_schema`. */
|
|
130
130
|
export const ERROR_TABLE_NOT_FOUND = 'table_not_found';
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
133
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
134
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
135
|
+
*/
|
|
132
136
|
export const ERROR_TABLE_NO_PRIMARY_KEY = 'table_no_primary_key';
|
|
137
|
+
/**
|
|
138
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
139
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
140
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
141
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
142
|
+
*/
|
|
143
|
+
export const ERROR_TABLE_NOT_DELETABLE = 'table_not_deletable';
|
|
133
144
|
/** Row with the given PK value not found. */
|
|
134
145
|
export const ERROR_ROW_NOT_FOUND = 'row_not_found';
|
|
135
146
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -261,7 +272,7 @@ export const AccountVanishedError = z.looseObject({
|
|
|
261
272
|
* Rate limit key type — declares what a route or RPC action's rate limiter
|
|
262
273
|
* is keyed on.
|
|
263
274
|
*
|
|
264
|
-
* - `'ip'` — per-IP rate limiting (bootstrap, password change,
|
|
275
|
+
* - `'ip'` — per-IP rate limiting (login, bootstrap, password change, signup)
|
|
265
276
|
* - `'account'` — per-account rate limiting. On REST auth routes the key is
|
|
266
277
|
* the submitted identifier (login). On RPC actions (post-auth) the key is
|
|
267
278
|
* the resolved actor id (`request_context.actor.id`) — separate namespace.
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
43
43
|
/**
|
|
44
|
-
* Minimal structural context required by `emit_after_commit
|
|
45
|
-
* `RouteContext` and `ActionContext` satisfy this
|
|
46
|
-
* `log`
|
|
44
|
+
* Minimal structural context required by `emit_after_commit` — just the
|
|
45
|
+
* deferred queue. Both `RouteContext` and `ActionContext` satisfy this.
|
|
46
|
+
* (No `log` here: the flush middleware owns the per-thunk `try/catch` +
|
|
47
|
+
* `log.error`, with a logger of its own.)
|
|
47
48
|
*/
|
|
48
49
|
export interface EmitAfterCommitContext {
|
|
49
|
-
log: Logger;
|
|
50
50
|
post_commit_effects: Array<() => void | Promise<void>>;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
@@ -71,7 +71,7 @@ export interface EmitAfterCommitContext {
|
|
|
71
71
|
* land even when the handler fails) belong on the eager `pending_effects` queue
|
|
72
72
|
* instead.
|
|
73
73
|
*
|
|
74
|
-
* @param ctx - context carrying
|
|
74
|
+
* @param ctx - context carrying the `post_commit_effects` queue
|
|
75
75
|
* @param fn - side effect to run after commit; may return `void` or `Promise<void>`
|
|
76
76
|
* @mutates `ctx.post_commit_effects` - appends `fn` verbatim
|
|
77
77
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD
|
|
1
|
+
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACtC,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,KAAK,sBAAsB,EAC3B,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAC5B,IAEF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kCAAkC,GAAU,CAAC,EACzD,qBAAqB,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EAClE,UAAU,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,KAC5B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAQpB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EACrC,KAAK,MAAM,EACX,eAAe,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KACtC,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,aAAa,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAClD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAiBd,CAAC"}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* land even when the handler fails) belong on the eager `pending_effects` queue
|
|
62
62
|
* instead.
|
|
63
63
|
*
|
|
64
|
-
* @param ctx - context carrying
|
|
64
|
+
* @param ctx - context carrying the `post_commit_effects` queue
|
|
65
65
|
* @param fn - side effect to run after commit; may return `void` or `Promise<void>`
|
|
66
66
|
* @mutates `ctx.post_commit_effects` - appends `fn` verbatim
|
|
67
67
|
*/
|
package/dist/rate_limiter.d.ts
CHANGED
|
@@ -42,7 +42,18 @@ export interface RateLimiterOptions {
|
|
|
42
42
|
*/
|
|
43
43
|
max_keys?: number | null;
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Default options for per-IP auth rate limiting: 5 attempts per 15 minutes.
|
|
47
|
+
* The default for each of the three per-surface IP limiters (login + password
|
|
48
|
+
* change, signup, bootstrap) and the shared cap the Rust spine's
|
|
49
|
+
* `DEFAULT_LOGIN_IP_RATE_LIMIT` pins.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately **not** widened when the buckets became monotone (a success no
|
|
52
|
+
* longer refunds them — see `RateLimiter.reset`). Widening would have bought
|
|
53
|
+
* NAT'd egress headroom by loosening the one bound that caps credential
|
|
54
|
+
* guessing from a single address; splitting one shared bucket into three
|
|
55
|
+
* per-surface ones buys the same headroom without touching that bound.
|
|
56
|
+
*/
|
|
46
57
|
export declare const default_login_ip_rate_limit: RateLimiterOptions;
|
|
47
58
|
/** Default options for per-account login rate limiting: 10 attempts per 30 minutes. */
|
|
48
59
|
export declare const default_login_account_rate_limit: RateLimiterOptions;
|
|
@@ -86,7 +97,7 @@ export interface RateLimitResult {
|
|
|
86
97
|
* key-enumeration attack at the cost of a slight per-op overhead and the
|
|
87
98
|
* LRU trade-off described on `RateLimiterOptions.max_keys`.
|
|
88
99
|
*
|
|
89
|
-
* Parameters that accept `RateLimiter | null` (e.g. `
|
|
100
|
+
* Parameters that accept `RateLimiter | null` (e.g. `login_ip_rate_limiter`,
|
|
90
101
|
* `login_account_rate_limiter`) silently disable rate limiting when `null`
|
|
91
102
|
* is passed — no checks are performed and all requests are allowed through.
|
|
92
103
|
*/
|
|
@@ -117,7 +128,29 @@ export declare class RateLimiter {
|
|
|
117
128
|
*/
|
|
118
129
|
record(key: string, now?: number): RateLimitResult;
|
|
119
130
|
/**
|
|
120
|
-
* Clear all attempts for `key
|
|
131
|
+
* Clear all attempts for `key`.
|
|
132
|
+
*
|
|
133
|
+
* **Account-grain keys only.** Called after a successful auth to forgive a
|
|
134
|
+
* user's own typos, which is safe because the key *is* the account being
|
|
135
|
+
* attacked — clearing it requires that account's credential and can only
|
|
136
|
+
* widen the budget against an account the caller already holds.
|
|
137
|
+
*
|
|
138
|
+
* Never call this on an **IP-aggregate** key. That bucket is the
|
|
139
|
+
* distributed-spray backstop, and success on one account must not zero the
|
|
140
|
+
* budget shared by every other account reachable from the same IP;
|
|
141
|
+
* otherwise an attacker interleaves their own logins (or, under open signup,
|
|
142
|
+
* throwaway account creations needing no credential at all) with guesses
|
|
143
|
+
* against arbitrary victims and sprays indefinitely from one address.
|
|
144
|
+
* Nothing records on the IP bucket on success on any path, so with no reset
|
|
145
|
+
* it is a pure monotone-within-window failure counter that only time clears.
|
|
146
|
+
*
|
|
147
|
+
* Because they are monotone, the IP buckets are **one instance per auth
|
|
148
|
+
* surface** (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
|
|
149
|
+
* `bootstrap_ip_rate_limiter`) rather than one shared across all of them.
|
|
150
|
+
* A single instance meant a failure on any surface spent the budget that
|
|
151
|
+
* bounds guessing on every other one, and one caller's exhaustion denied
|
|
152
|
+
* four routes at once — a cost that used to be masked by the reset this
|
|
153
|
+
* doc forbids.
|
|
121
154
|
*
|
|
122
155
|
* @mutates internal map - removes the entry for `key`
|
|
123
156
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/rate_limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAKpC;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,SAAU,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/rate_limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAKpC;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,SAAU,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,2BAA2B,EAAE,kBAKzC,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,gCAAgC,EAAE,kBAK9C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,EAAE,kBAK1C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,kBAK/C,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,sCAAsC;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,WAAW,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,WAAW;;IACvB,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;gBAOzB,OAAO,EAAE,kBAAkB;IAcvC,8BAA8B;IAC9B,IAAI,IAAI,IAAI,MAAM,CAEjB;IAcD;;;;;;;;;;OAUG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,eAAe;IA2B7D;;;;;;OAMG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,eAAe;IA0B9D;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIxB;;;;;OAKG;IACH,OAAO,CAAC,GAAG,GAAE,MAAmB,GAAG,IAAI;IAgBvC;;;;OAIG;IACH,OAAO,IAAI,IAAI;CAMf;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,OAAO,CAAC,kBAAkB,CAAC,KAAG,WAE3E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,GAAG,OAAO,EAAE,aAAa,MAAM,KAAG,QAI7E,CAAC"}
|