@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.
Files changed (95) hide show
  1. package/README.md +4 -0
  2. package/dist/actions/CLAUDE.md +1 -1
  3. package/dist/auth/CLAUDE.md +1 -1
  4. package/dist/auth/account_route_schema.d.ts +10 -1
  5. package/dist/auth/account_route_schema.d.ts.map +1 -1
  6. package/dist/auth/account_route_schema.js +10 -1
  7. package/dist/auth/account_routes.d.ts +18 -4
  8. package/dist/auth/account_routes.d.ts.map +1 -1
  9. package/dist/auth/account_routes.js +32 -29
  10. package/dist/auth/api_token_queries.d.ts +4 -0
  11. package/dist/auth/api_token_queries.d.ts.map +1 -1
  12. package/dist/auth/api_token_queries.js +5 -1
  13. package/dist/auth/audit_emitter.d.ts +9 -5
  14. package/dist/auth/audit_emitter.d.ts.map +1 -1
  15. package/dist/auth/audit_log_schema.d.ts +7 -1
  16. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  17. package/dist/auth/audit_log_schema.js +16 -1
  18. package/dist/auth/bearer_auth.d.ts +9 -6
  19. package/dist/auth/bearer_auth.d.ts.map +1 -1
  20. package/dist/auth/bearer_auth.js +12 -21
  21. package/dist/auth/bootstrap_routes.d.ts +12 -2
  22. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  23. package/dist/auth/bootstrap_routes.js +26 -10
  24. package/dist/auth/middleware.d.ts +0 -3
  25. package/dist/auth/middleware.d.ts.map +1 -1
  26. package/dist/auth/middleware.js +11 -10
  27. package/dist/auth/migrations.d.ts +7 -0
  28. package/dist/auth/migrations.d.ts.map +1 -1
  29. package/dist/auth/migrations.js +45 -4
  30. package/dist/auth/session_queries.d.ts +13 -2
  31. package/dist/auth/session_queries.d.ts.map +1 -1
  32. package/dist/auth/session_queries.js +14 -3
  33. package/dist/auth/signup_routes.d.ts +10 -0
  34. package/dist/auth/signup_routes.d.ts.map +1 -1
  35. package/dist/auth/signup_routes.js +14 -11
  36. package/dist/db/CLAUDE.md +9 -5
  37. package/dist/db/cell_audit_queries.d.ts +4 -2
  38. package/dist/db/cell_audit_queries.d.ts.map +1 -1
  39. package/dist/db/cell_audit_queries.js +4 -2
  40. package/dist/db/pg_error.d.ts +12 -0
  41. package/dist/db/pg_error.d.ts.map +1 -1
  42. package/dist/db/pg_error.js +15 -1
  43. package/dist/http/CLAUDE.md +78 -18
  44. package/dist/http/db_routes.d.ts +96 -3
  45. package/dist/http/db_routes.d.ts.map +1 -1
  46. package/dist/http/db_routes.js +263 -46
  47. package/dist/http/error_schemas.d.ts +13 -2
  48. package/dist/http/error_schemas.d.ts.map +1 -1
  49. package/dist/http/error_schemas.js +13 -2
  50. package/dist/http/pending_effects.d.ts +5 -5
  51. package/dist/http/pending_effects.d.ts.map +1 -1
  52. package/dist/http/pending_effects.js +1 -1
  53. package/dist/rate_limiter.d.ts +36 -3
  54. package/dist/rate_limiter.d.ts.map +1 -1
  55. package/dist/rate_limiter.js +36 -3
  56. package/dist/server/app_server.d.ts +27 -10
  57. package/dist/server/app_server.d.ts.map +1 -1
  58. package/dist/server/app_server.js +30 -20
  59. package/dist/server/app_server_context.d.ts +10 -2
  60. package/dist/server/app_server_context.d.ts.map +1 -1
  61. package/dist/testing/CLAUDE.md +42 -8
  62. package/dist/testing/app_server.d.ts.map +1 -1
  63. package/dist/testing/app_server.js +6 -4
  64. package/dist/testing/audit_completeness.d.ts.map +1 -1
  65. package/dist/testing/audit_completeness.js +5 -1
  66. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  67. package/dist/testing/cross_backend/cell_relations.js +53 -1
  68. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  69. package/dist/testing/cross_backend/default_spine_surface.js +9 -8
  70. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/login_security.js +59 -0
  72. package/dist/testing/cross_backend/query_shape.d.ts +10 -0
  73. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
  74. package/dist/testing/cross_backend/query_shape.js +128 -0
  75. package/dist/testing/cross_backend/session_cap.d.ts +19 -0
  76. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
  77. package/dist/testing/cross_backend/session_cap.js +93 -0
  78. package/dist/testing/middleware.d.ts +2 -5
  79. package/dist/testing/middleware.d.ts.map +1 -1
  80. package/dist/testing/middleware.js +5 -5
  81. package/dist/testing/rate_limiting.d.ts +10 -16
  82. package/dist/testing/rate_limiting.d.ts.map +1 -1
  83. package/dist/testing/rate_limiting.js +11 -95
  84. package/dist/testing/stubs.d.ts.map +1 -1
  85. package/dist/testing/stubs.js +3 -1
  86. package/dist/testing/testing_rate_limiter.d.ts +7 -1
  87. package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
  88. package/dist/testing/testing_rate_limiter.js +7 -1
  89. package/dist/ui/CLAUDE.md +9 -4
  90. package/dist/ui/table_state.svelte.d.ts +18 -5
  91. package/dist/ui/table_state.svelte.d.ts.map +1 -1
  92. package/dist/ui/table_state.svelte.js +22 -7
  93. package/dist/ui/ui_format.d.ts.map +1 -1
  94. package/dist/ui/ui_format.js +4 -0
  95. package/package.json +1 -1
@@ -1,25 +1,155 @@
1
1
  /**
2
2
  * API route specs for database administration.
3
3
  *
4
- * Generic PostgreSQL table browser using `information_schema`.
5
- * Provides: list tables, view columns/rows (paginated), delete rows by PK, health check.
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 public tables with row counts',
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.coerce.number().int().min(0).default(0),
108
- limit: z.coerce
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
- const exists = await route.db.query_one(`SELECT table_name FROM information_schema.tables
136
- WHERE table_schema = 'public' AND table_name = $1`, [name]);
137
- if (!exists) {
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
- const columns = await route.db.query(`SELECT column_name, data_type, is_nullable
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
- const pk_info = await route.db.query_one(`SELECT kcu.column_name
147
- FROM information_schema.table_constraints tc
148
- JOIN information_schema.key_column_usage kcu
149
- ON tc.constraint_name = kcu.constraint_name
150
- AND tc.table_schema = kcu.table_schema
151
- WHERE tc.constraint_type = 'PRIMARY KEY'
152
- AND tc.table_schema = 'public'
153
- AND tc.table_name = $1
154
- LIMIT 1`, [name]);
155
- const primary_key = pk_info?.column_name ?? null;
156
- const rows = await route.db.query(`SELECT * FROM "${assert_valid_sql_identifier(name)}" LIMIT $1 OFFSET $2`, [limit, offset]);
157
- return c.json({ columns, rows, total, offset, limit, primary_key });
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([ERROR_INVALID_ROUTE_PARAMS, ERROR_TABLE_NO_PRIMARY_KEY])
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
- const exists = await route.db.query_one(`SELECT table_name FROM information_schema.tables
189
- WHERE table_schema = 'public' AND table_name = $1`, [name]);
190
- if (!exists) {
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
- const pk_info = await route.db.query_one(`SELECT kcu.column_name
194
- FROM information_schema.table_constraints tc
195
- JOIN information_schema.key_column_usage kcu
196
- ON tc.constraint_name = kcu.constraint_name
197
- AND tc.table_schema = kcu.table_schema
198
- WHERE tc.constraint_type = 'PRIMARY KEY'
199
- AND tc.table_schema = 'public'
200
- AND tc.table_name = $1
201
- LIMIT 1`, [name]);
202
- if (!pk_info) {
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
- const result = await route.db.query(`DELETE FROM "${assert_valid_sql_identifier(name)}" WHERE "${assert_valid_sql_identifier(pk_info.column_name)}" = $1 RETURNING *`, [id]);
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
- if (err instanceof Error && 'code' in err && err.code === '23503') {
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
- /** Table has no primary key constraint (cannot delete by PK). */
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, bearer auth)
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":"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"}
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
- /** Table has no primary key constraint (cannot delete by PK). */
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, bearer auth)
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`. Both
45
- * `RouteContext` and `ActionContext` satisfy this — they each carry
46
- * `log` and `post_commit_effects`.
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 `log` and the `post_commit_effects` queue
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;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,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"}
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 `log` and the `post_commit_effects` queue
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
  */
@@ -42,7 +42,18 @@ export interface RateLimiterOptions {
42
42
  */
43
43
  max_keys?: number | null;
44
44
  }
45
- /** Default options for per-IP login rate limiting: 5 attempts per 15 minutes. */
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. `ip_rate_limiter`,
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` (e.g. after successful login).
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,iFAAiF;AACjF,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;;;;OAIG;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"}
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"}