@fuzdev/fuz_app 0.141.0 → 0.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/actions/CLAUDE.md +10 -5
  2. package/dist/actions/connection_closer.d.ts +19 -2
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +25 -2
  5. package/dist/actions/perform_action.d.ts +10 -0
  6. package/dist/actions/perform_action.d.ts.map +1 -1
  7. package/dist/actions/perform_action.js +23 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -2
  10. package/dist/auth/CLAUDE.md +33 -13
  11. package/dist/auth/account_actions.d.ts.map +1 -1
  12. package/dist/auth/account_actions.js +12 -1
  13. package/dist/auth/account_route_schema.d.ts.map +1 -1
  14. package/dist/auth/account_route_schema.js +8 -0
  15. package/dist/auth/account_routes.d.ts +7 -2
  16. package/dist/auth/account_routes.d.ts.map +1 -1
  17. package/dist/auth/account_routes.js +240 -187
  18. package/dist/auth/admin_action_specs.d.ts +6 -1
  19. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  20. package/dist/auth/admin_action_specs.js +6 -1
  21. package/dist/auth/admin_actions.d.ts.map +1 -1
  22. package/dist/auth/admin_actions.js +14 -2
  23. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  24. package/dist/auth/bootstrap_routes.js +85 -76
  25. package/dist/auth/cell_action_specs.d.ts +7 -3
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +9 -5
  28. package/dist/auth/cell_actions.d.ts +3 -3
  29. package/dist/auth/cell_actions.d.ts.map +1 -1
  30. package/dist/auth/cell_actions.js +67 -38
  31. package/dist/auth/cell_authorize.d.ts +69 -8
  32. package/dist/auth/cell_authorize.d.ts.map +1 -1
  33. package/dist/auth/cell_authorize.js +89 -9
  34. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  35. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  36. package/dist/auth/cell_field_action_specs.js +7 -7
  37. package/dist/auth/cell_field_actions.d.ts +7 -5
  38. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_field_actions.js +24 -22
  40. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  41. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  42. package/dist/auth/cell_grant_action_specs.js +6 -0
  43. package/dist/auth/cell_grant_actions.d.ts +3 -1
  44. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  45. package/dist/auth/cell_grant_actions.js +16 -2
  46. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  47. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  48. package/dist/auth/cell_item_action_specs.js +7 -7
  49. package/dist/auth/cell_item_actions.d.ts +6 -5
  50. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  51. package/dist/auth/cell_item_actions.js +23 -22
  52. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  53. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  54. package/dist/auth/cell_relation_list_limit.js +27 -0
  55. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  56. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  57. package/dist/auth/cell_relation_visibility.js +6 -5
  58. package/dist/auth/cell_steward.d.ts +45 -0
  59. package/dist/auth/cell_steward.d.ts.map +1 -0
  60. package/dist/auth/cell_steward.js +53 -0
  61. package/dist/auth/request_context.d.ts +4 -0
  62. package/dist/auth/request_context.d.ts.map +1 -1
  63. package/dist/auth/request_context.js +6 -1
  64. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  65. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  66. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  67. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_actions.js +23 -16
  69. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +16 -8
  72. package/dist/auth/signup_routes.d.ts +4 -3
  73. package/dist/auth/signup_routes.d.ts.map +1 -1
  74. package/dist/auth/signup_routes.js +157 -134
  75. package/dist/auth/token_scope.d.ts +7 -1
  76. package/dist/auth/token_scope.d.ts.map +1 -1
  77. package/dist/auth/token_scope.js +8 -2
  78. package/dist/db/CLAUDE.md +32 -19
  79. package/dist/db/cell_ddl.d.ts +1 -2
  80. package/dist/db/cell_ddl.d.ts.map +1 -1
  81. package/dist/db/cell_ddl.js +1 -2
  82. package/dist/db/cell_field_queries.d.ts +20 -18
  83. package/dist/db/cell_field_queries.d.ts.map +1 -1
  84. package/dist/db/cell_field_queries.js +28 -22
  85. package/dist/db/cell_item_queries.d.ts +20 -14
  86. package/dist/db/cell_item_queries.d.ts.map +1 -1
  87. package/dist/db/cell_item_queries.js +28 -18
  88. package/dist/db/cell_moderation.d.ts +67 -0
  89. package/dist/db/cell_moderation.d.ts.map +1 -0
  90. package/dist/db/cell_moderation.js +66 -0
  91. package/dist/db/cell_queries.d.ts +44 -14
  92. package/dist/db/cell_queries.d.ts.map +1 -1
  93. package/dist/db/cell_queries.js +68 -45
  94. package/dist/db/cell_view_sql.d.ts +102 -0
  95. package/dist/db/cell_view_sql.d.ts.map +1 -0
  96. package/dist/db/cell_view_sql.js +112 -0
  97. package/dist/db/db_pg.d.ts +1 -1
  98. package/dist/db/db_pg.js +1 -1
  99. package/dist/http/CLAUDE.md +1 -1
  100. package/dist/http/auth_shape.d.ts +5 -2
  101. package/dist/http/auth_shape.d.ts.map +1 -1
  102. package/dist/http/auth_shape.js +5 -2
  103. package/dist/http/ip_canonical.d.ts +56 -5
  104. package/dist/http/ip_canonical.d.ts.map +1 -1
  105. package/dist/http/ip_canonical.js +147 -35
  106. package/dist/primitive_schemas.d.ts +10 -1
  107. package/dist/primitive_schemas.d.ts.map +1 -1
  108. package/dist/primitive_schemas.js +11 -2
  109. package/dist/rate_limiter.d.ts +179 -12
  110. package/dist/rate_limiter.d.ts.map +1 -1
  111. package/dist/rate_limiter.js +352 -42
  112. package/dist/server/serve_fact_route.d.ts +8 -4
  113. package/dist/server/serve_fact_route.d.ts.map +1 -1
  114. package/dist/server/serve_fact_route.js +15 -6
  115. package/dist/testing/CLAUDE.md +16 -7
  116. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  119. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  121. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/cell_relations.js +145 -2
  123. package/dist/testing/cross_backend/expected_schema.json +0 -1
  124. package/dist/testing/cross_backend/full_spine_mount.js +2 -1
  125. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  126. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/login_security.js +154 -0
  128. package/dist/testing/cross_backend/origin_conformance.d.ts +1 -1
  129. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/origin_conformance.js +1 -1
  131. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  132. package/dist/testing/cross_backend/query_shape.js +24 -2
  133. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  134. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  135. package/dist/testing/cross_backend/spine_surface_constants.d.ts +13 -21
  136. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/spine_surface_constants.js +13 -21
  138. package/dist/testing/cross_backend/testing_origin_check.d.ts +128 -0
  139. package/dist/testing/cross_backend/testing_origin_check.d.ts.map +1 -0
  140. package/dist/testing/cross_backend/testing_origin_check.js +87 -0
  141. package/dist/testing/cross_backend/testing_reset_actions.d.ts +7 -119
  142. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  143. package/dist/testing/cross_backend/testing_reset_actions.js +8 -92
  144. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  145. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  146. package/dist/testing/schema_introspect.js +1 -1
  147. package/dist/testing/schema_parity.d.ts +4 -3
  148. package/dist/testing/schema_parity.d.ts.map +1 -1
  149. package/dist/testing/schema_parity.js +4 -3
  150. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  151. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  152. package/dist/testing/schema_ready_fixture.js +2 -4
  153. package/dist/ui/auth_state.svelte.d.ts +10 -0
  154. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  155. package/dist/ui/auth_state.svelte.js +22 -4
  156. package/package.json +1 -1
  157. package/dist/db/cell_history_ddl.d.ts +0 -38
  158. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  159. package/dist/db/cell_history_ddl.js +0 -61
@@ -22,7 +22,9 @@
22
22
  */
23
23
  import { fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.ts';
24
24
  import { assert_row } from './assert_row.js';
25
- import { iso8601_timestamp_expr, qualify_columns } from './sql_columns.js';
25
+ import { columns_sql, iso8601_timestamp_expr, qualify_columns } from './sql_columns.js';
26
+ import { cell_moderation_admits_audience_sql, cell_moderation_is_stewardable_sql } from './cell_moderation.js';
27
+ import { can_view_cell_sql, caller_role_grants_cte, cell_grant_admits_caller_sql } from './cell_view_sql.js';
26
28
  /**
27
29
  * The full `cell` column set, named explicitly so a row read fails loud on
28
30
  * schema drift — a `SELECT *` silently omits a dropped column and the
@@ -69,6 +71,18 @@ const cell_expr = iso8601_timestamp_expr(CELL_COLUMNS, ['created_at', 'updated_a
69
71
  * actually produces one.
70
72
  */
71
73
  export const cell_row_projection = (alias) => `${qualify_columns(CELL_COLUMNS, alias, cell_expr(alias))}, (SELECT COUNT(*)::int FROM cell_grant WHERE cell_id = ${alias}.id) AS grant_count`;
74
+ /**
75
+ * The columns a view check reads (`can_view_cell`) — the `CELL_COLUMNS`
76
+ * subset `query_cell_view_load_many` projects, typed against `CELL_COLUMNS`
77
+ * so the base const's drift guard covers it. Twin of `fuz_cell`'s
78
+ * `CELL_VIEW_COLUMNS`.
79
+ */
80
+ export const CELL_VIEW_COLUMNS = [
81
+ 'id',
82
+ 'visibility',
83
+ 'moderation',
84
+ 'created_by'
85
+ ];
72
86
  /**
73
87
  * Insert a cell row, deriving `refs` from `data`.
74
88
  *
@@ -133,11 +147,10 @@ export const query_cell_get_by_path = async (deps, path) => {
133
147
  return row ?? null;
134
148
  };
135
149
  /**
136
- * Bulk-load active cell rows by id, **no visibility filter applied**. Used
137
- * by the strict relation-read filter (`auth/cell_relation_visibility.ts`'s
138
- * `filter_visible_target_ids`), which runs `can_view_cell` per row in
139
- * memory rather than in SQL. Soft-deleted rows are excluded so relations
140
- * to tombstones never surface.
150
+ * Bulk-load active cell rows by id, **no visibility filter applied** — the
151
+ * full rows, for a caller that copies or returns them (the deep-clone walk).
152
+ * A caller that needs only the view check reads `query_cell_view_load_many`.
153
+ * Soft-deleted rows are excluded so relations to tombstones never surface.
141
154
  *
142
155
  * @param deps - query deps
143
156
  * @param ids - cell ids to load (duplicates are harmless)
@@ -150,6 +163,24 @@ export const query_cell_load_many = async (deps, ids) => {
150
163
  FROM cell
151
164
  WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids]);
152
165
  };
166
+ /**
167
+ * Bulk-load the active cells among `ids` for a view check — the
168
+ * `CELL_VIEW_COLUMNS` alone, never the payload. Backs the strict
169
+ * relation-read filter (`auth/cell_relation_visibility.ts`'s
170
+ * `filter_visible_target_ids`), which runs `can_view_cell` per row in memory
171
+ * rather than in SQL. Soft-deleted rows and ids with no row are absent.
172
+ *
173
+ * @param deps - query deps
174
+ * @param ids - cell ids to load (duplicates are harmless)
175
+ * @returns active rows in arbitrary order (caller indexes by `id`)
176
+ */
177
+ export const query_cell_view_load_many = async (deps, ids) => {
178
+ if (ids.length === 0)
179
+ return [];
180
+ return deps.db.query(`SELECT ${columns_sql(CELL_VIEW_COLUMNS)}
181
+ FROM cell
182
+ WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids]);
183
+ };
153
184
  /**
154
185
  * Update a cell. Fields left `undefined` in the patch keep their existing
155
186
  * value; explicit `null` writes `NULL`. `refs` is re-derived from `data`
@@ -274,15 +305,24 @@ export const query_cell_list_by_creator = async (deps, actor_id, options) => dep
274
305
  * dimension — keep it simple) plus an optional viewer-aware visibility
275
306
  * predicate.
276
307
  *
277
- * The visibility predicate mirrors `can_view_cell` in SQL form:
308
+ * The visibility predicate mirrors `can_view_cell || can_steward_cell` in SQL
309
+ * form:
278
310
  *
279
311
  * ```
280
312
  * (viewer_is_admin
281
- * OR cell.visibility = 'public'
282
313
  * OR (viewer_actor_id IS NOT NULL AND created_by = viewer_actor_id)
283
- * OR (viewer_actor_id IS NOT NULL AND <grant admits caller>))
314
+ * OR (<moderation admits an audience> AND (
315
+ * cell.visibility = 'public'
316
+ * OR (viewer_actor_id IS NOT NULL AND <grant admits caller>)))
317
+ * OR (viewer_actor_id IS NOT NULL AND <pending or rejected contribution>
318
+ * AND <viewer owns its live governing root>))
284
319
  * ```
285
320
  *
321
+ * The moderation rules are `cell_moderation_admits_audience_sql` and
322
+ * `cell_moderation_is_stewardable_sql` (`db/cell_moderation.ts`). The last arm
323
+ * is the steward arm (`auth/cell_steward.ts`) — a root owner reads the
324
+ * moderation queue under their root; a global admin is already admitted.
325
+ *
286
326
  * The grants branch closes parity with `can_view_cell`: a SQL `EXISTS`
287
327
  * over `cell_grant`, parameterized by the caller's `actor_id` and the
288
328
  * parallel `(role[], scope_id[])` projection of `auth.role_grants`. The
@@ -304,7 +344,9 @@ export const query_cell_list_by_creator = async (deps, actor_id, options) => dep
304
344
  * owner-excluding, but the explicit `created_by IS DISTINCT FROM caller`
305
345
  * guards against any future deviation. The shared_with branch does NOT
306
346
  * bypass for admin: an admin asking "what's shared with me" wants their
307
- * own grant footprint, not every cell.
347
+ * own grant footprint, not every cell. A grant admits there only while the
348
+ * cell's moderation admits an audience, as in `can_view_cell`, and the steward
349
+ * arm doesn't apply — a grant footprint is not a moderation queue.
308
350
  *
309
351
  * Soft-deleted rows are excluded by default; opt-in via `include_deleted`.
310
352
  *
@@ -369,36 +411,17 @@ export const query_cell_list = async (deps, params) => {
369
411
  ]);
370
412
  };
371
413
  /**
372
- * The `cell_grant` admits-caller predicate, factored once and reused by
373
- * both SQL shapes (general visibility branch + shared-with-me semi-join).
374
- *
375
- * Resolves true when the grant row at `g_alias` admits the caller via
376
- * either an actor-shaped principal (`g.actor_id = $11`) or a
377
- * role-shaped principal whose `(role, scope_id)` matches a row in the
378
- * `caller_role_grants` CTE. NULL `g.scope_id` matches any scope, mirroring
379
- * `grant_admits` in `auth/cell_authorize.ts`.
414
+ * The caller's `(role, scope_id)` role_grant pairs, materialized once per
415
+ * query so the role-grant predicate doesn't re-scan `unnest()` per outer row.
416
+ * Shared by both SQL shapes.
380
417
  */
381
- const grant_admits_caller_predicate = (g_alias) => `(
382
- ($11::uuid IS NOT NULL AND ${g_alias}.actor_id = $11)
383
- OR (
384
- ${g_alias}.role IS NOT NULL
385
- AND EXISTS (
386
- SELECT 1 FROM caller_role_grants p
387
- WHERE p.role = ${g_alias}.role
388
- AND (${g_alias}.scope_id IS NULL OR p.scope_id IS NOT DISTINCT FROM ${g_alias}.scope_id)
389
- )
390
- )
391
- )`;
418
+ const CALLER_ROLE_GRANTS_CTE = `WITH ${caller_role_grants_cte('$12', '$13')}`;
392
419
  /**
393
- * Materialize the caller's `(role, scope_id)` role_grant pairs once per
394
- * query so the role-grant predicate doesn't re-scan `unnest()` per
395
- * outer row. PG 12+ inlines non-recursive single-use CTEs, so this is
396
- * planner-equivalent to a subquery — the form is for clarity.
420
+ * General `cell_list` SQL: cell-driven scan, full visibility predicate — the
421
+ * `can_view_cell` arms (`can_view_cell_sql`), then the steward arm
422
+ * (`can_steward_cell`: the caller owns the live governing root of a pending or
423
+ * rejected contribution; a global admin is already in through `$6`).
397
424
  */
398
- const CALLER_ROLE_GRANTS_CTE = `WITH caller_role_grants AS (
399
- SELECT role, scope_id FROM unnest($12::text[], $13::uuid[]) AS p(role, scope_id)
400
- )`;
401
- /** General `cell_list` SQL: cell-driven scan, full visibility predicate. */
402
425
  const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
403
426
  SELECT ${cell_row_projection('c')} FROM cell c
404
427
  WHERE ($1::bool OR c.deleted_at IS NULL)
@@ -411,13 +434,12 @@ const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRAN
411
434
  AND ($15::uuid IS NULL OR c.root_id = $15)
412
435
  AND ($16::text IS NULL OR c.moderation = $16::text)
413
436
  AND (
414
- $6::bool
415
- OR c.visibility = 'public'
416
- OR ($7::uuid IS NOT NULL AND c.created_by = $7)
417
- OR ($7::uuid IS NOT NULL AND EXISTS (
418
- SELECT 1 FROM cell_grant g
419
- WHERE g.cell_id = c.id AND ${grant_admits_caller_predicate('g')}
420
- ))
437
+ ${can_view_cell_sql('c', { is_admin: '$6', actor_id: '$7', grant_actor_id: '$11' })}
438
+ OR ($7::uuid IS NOT NULL AND c.root_id IS NOT NULL AND ${cell_moderation_is_stewardable_sql('c')}
439
+ AND EXISTS (
440
+ SELECT 1 FROM cell r
441
+ WHERE r.id = c.root_id AND r.deleted_at IS NULL AND r.created_by = $7
442
+ ))
421
443
  )
422
444
  ORDER BY c.${order_column} ${order_direction} NULLS LAST
423
445
  LIMIT $8 OFFSET $9`;
@@ -450,9 +472,10 @@ const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_
450
472
  AND ($16::text IS NULL OR c.moderation = $16::text)
451
473
  AND $7::uuid IS NOT NULL
452
474
  AND c.created_by IS DISTINCT FROM $7
475
+ AND ${cell_moderation_admits_audience_sql('c')}
453
476
  AND c.id IN (
454
477
  SELECT g.cell_id FROM cell_grant g
455
- WHERE ${grant_admits_caller_predicate('g')}
478
+ WHERE ${cell_grant_admits_caller_sql('g', '$11')}
456
479
  )
457
480
  -- $6 (viewer_is_admin) intentionally not consulted: an admin
458
481
  -- asking "what's shared with me" wants their grant footprint, not
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `can_view_cell` in SQL — the view predicate a query applies to a cell row
3
+ * before `LIMIT`, so a page holds only rows the caller may view.
4
+ *
5
+ * `can_view_cell_sql` is the one SQL form of the predicate: `query_cell_list`
6
+ * embeds it (adding the steward arm beside it), and the relation lists apply
7
+ * it to the cell at the other end of each edge. It binds the caller through a
8
+ * `CellViewer` (`auth/cell_authorize.ts`'s `cell_viewer_from_auth` builds one
9
+ * from a request context), and the role-shaped grant arm reads the
10
+ * `caller_role_grants` CTE `caller_role_grants_cte` defines. Twin of the Rust
11
+ * `fuz_cell::cell_view_sql`.
12
+ *
13
+ * @module
14
+ */
15
+ import type { Uuid } from '../primitive_schemas.js';
16
+ /**
17
+ * The caller a view predicate binds — what `can_view_cell` reads off the
18
+ * request context.
19
+ *
20
+ * `role_grant_roles` and `role_grant_scope_ids` are parallel, one entry per
21
+ * active role grant (`null` = a global grant): `unnest` pads the shorter with
22
+ * NULLs, and a NULL scope reads as a global grant, so `cell_viewer_params`
23
+ * refuses a mismatch.
24
+ */
25
+ export interface CellViewer {
26
+ /** The acting actor; `null` for an anonymous or account-grain caller. */
27
+ readonly actor_id: Uuid | null;
28
+ /** Whether the caller holds the global admin role — the arm that admits every cell. */
29
+ readonly is_admin: boolean;
30
+ /** The roles of the caller's active role grants, parallel to `role_grant_scope_ids`. */
31
+ readonly role_grant_roles: ReadonlyArray<string>;
32
+ /** The scopes of the caller's active role grants, parallel to `role_grant_roles`. */
33
+ readonly role_grant_scope_ids: ReadonlyArray<Uuid | null>;
34
+ }
35
+ /** An anonymous caller — admitted by the public arm alone. */
36
+ export declare const CELL_VIEWER_ANONYMOUS: CellViewer;
37
+ /**
38
+ * A server-side read that filters nothing — the admin arm admits every cell.
39
+ * For a caller that applies its own view check afterwards (the `cell_get`
40
+ * bundle) or acts for the server itself.
41
+ */
42
+ export declare const CELL_VIEWER_UNRESTRICTED: CellViewer;
43
+ /** Where a query binds the `CellViewer` facts `can_view_cell_sql` reads — each a `$N` placeholder. */
44
+ export interface CellViewerSqlParams {
45
+ /** The global-admin flag (`bool`). */
46
+ readonly is_admin: string;
47
+ /** The acting actor (`uuid`, NULL when anonymous) — the owner arm and the grant arm's guard. */
48
+ readonly actor_id: string;
49
+ /**
50
+ * The actor an actor-shaped grant matches, when a query binds it apart
51
+ * from `actor_id` (`query_cell_list` does). Defaults to `actor_id`.
52
+ */
53
+ readonly grant_actor_id?: string;
54
+ }
55
+ /**
56
+ * The viewer's bind values, in the order `[is_admin, actor_id, roles,
57
+ * scope_ids]`.
58
+ *
59
+ * @param viewer - the caller
60
+ * @returns the four bind values
61
+ * @throws {Error} when the role-grant arrays aren't parallel
62
+ */
63
+ export declare const cell_viewer_params: (viewer: CellViewer) => [boolean, Uuid | null, ReadonlyArray<string>, ReadonlyArray<Uuid | null>];
64
+ /**
65
+ * The `caller_role_grants` CTE body (no `WITH`): the caller's
66
+ * `(role, scope_id)` pairs, unnested once per query from the parallel arrays
67
+ * at placeholders `roles` and `scope_ids`. PG 12+ inlines a non-recursive
68
+ * single-use CTE, so this is planner-equivalent to a subquery — the form is
69
+ * for clarity.
70
+ *
71
+ * @param roles - the placeholder of the roles array (`text[]`)
72
+ * @param scope_ids - the placeholder of the scope-id array (`uuid[]`)
73
+ * @returns the CTE body
74
+ */
75
+ export declare const caller_role_grants_cte: (roles: string, scope_ids: string) => string;
76
+ /**
77
+ * The `cell_grant` admits-caller predicate over grant alias `g_alias`: an
78
+ * actor-shaped grant matches the actor at placeholder `actor_id`; a
79
+ * role-shaped grant matches a `(role, scope_id)` pair in the
80
+ * `caller_role_grants` CTE (a NULL grant `scope_id` matches any scope).
81
+ * Mirrors `grant_admits` in `auth/cell_authorize.ts`.
82
+ *
83
+ * @param g_alias - the `cell_grant` alias in the enclosing query
84
+ * @param actor_id - the placeholder of the caller's actor (`uuid`)
85
+ * @returns the SQL predicate
86
+ */
87
+ export declare const cell_grant_admits_caller_sql: (g_alias: string, actor_id: string) => string;
88
+ /**
89
+ * `can_view_cell` over the cell alias `c_alias`: admin, the owner, or — while
90
+ * the cell's `moderation` admits an audience — `public` or a `cell_grant`
91
+ * admitting the caller.
92
+ *
93
+ * Reads the caller at `params`; the grant arm needs the
94
+ * `caller_role_grants_cte` in scope. Liveness (`deleted_at IS NULL`) is the
95
+ * query's own clause, as `can_view_cell` reads a loaded live row.
96
+ *
97
+ * @param c_alias - the `cell` alias in the enclosing query
98
+ * @param params - where the query binds the caller
99
+ * @returns the SQL predicate (parenthesized)
100
+ */
101
+ export declare const can_view_cell_sql: (c_alias: string, params: CellViewerSqlParams) => string;
102
+ //# sourceMappingURL=cell_view_sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cell_view_sql.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/cell_view_sql.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAGpD;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IAC/B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,wFAAwF;IACxF,QAAQ,CAAC,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACjD,qFAAqF;IACrF,QAAQ,CAAC,oBAAoB,EAAE,aAAa,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED,8DAA8D;AAC9D,eAAO,MAAM,qBAAqB,EAAE,UAKnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,UAKtC,CAAC;AAEF,sGAAsG;AACtG,MAAM,WAAW,mBAAmB;IACnC,sCAAsC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,GAC9B,QAAQ,UAAU,KAChB,CAAC,OAAO,EAAE,IAAI,GAAG,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,IAAI,GAAG,IAAI,CAAC,CAQ1E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,MAAM,EAAE,WAAW,MAAM,KAAG,MAGtE,CAAC;AAEL;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,MAU3E,CAAC;AAEP;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,EAAE,QAAQ,mBAAmB,KAAG,MAchF,CAAC"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `can_view_cell` in SQL — the view predicate a query applies to a cell row
3
+ * before `LIMIT`, so a page holds only rows the caller may view.
4
+ *
5
+ * `can_view_cell_sql` is the one SQL form of the predicate: `query_cell_list`
6
+ * embeds it (adding the steward arm beside it), and the relation lists apply
7
+ * it to the cell at the other end of each edge. It binds the caller through a
8
+ * `CellViewer` (`auth/cell_authorize.ts`'s `cell_viewer_from_auth` builds one
9
+ * from a request context), and the role-shaped grant arm reads the
10
+ * `caller_role_grants` CTE `caller_role_grants_cte` defines. Twin of the Rust
11
+ * `fuz_cell::cell_view_sql`.
12
+ *
13
+ * @module
14
+ */
15
+ import { cell_moderation_admits_audience_sql } from './cell_moderation.js';
16
+ /** An anonymous caller — admitted by the public arm alone. */
17
+ export const CELL_VIEWER_ANONYMOUS = {
18
+ actor_id: null,
19
+ is_admin: false,
20
+ role_grant_roles: [],
21
+ role_grant_scope_ids: []
22
+ };
23
+ /**
24
+ * A server-side read that filters nothing — the admin arm admits every cell.
25
+ * For a caller that applies its own view check afterwards (the `cell_get`
26
+ * bundle) or acts for the server itself.
27
+ */
28
+ export const CELL_VIEWER_UNRESTRICTED = {
29
+ actor_id: null,
30
+ is_admin: true,
31
+ role_grant_roles: [],
32
+ role_grant_scope_ids: []
33
+ };
34
+ /**
35
+ * The viewer's bind values, in the order `[is_admin, actor_id, roles,
36
+ * scope_ids]`.
37
+ *
38
+ * @param viewer - the caller
39
+ * @returns the four bind values
40
+ * @throws {Error} when the role-grant arrays aren't parallel
41
+ */
42
+ export const cell_viewer_params = (viewer) => {
43
+ if (viewer.role_grant_roles.length !== viewer.role_grant_scope_ids.length) {
44
+ throw new Error(`CellViewer: role_grant_roles (len=${viewer.role_grant_roles.length}) and ` +
45
+ `role_grant_scope_ids (len=${viewer.role_grant_scope_ids.length}) must be parallel arrays`);
46
+ }
47
+ return [viewer.is_admin, viewer.actor_id, viewer.role_grant_roles, viewer.role_grant_scope_ids];
48
+ };
49
+ /**
50
+ * The `caller_role_grants` CTE body (no `WITH`): the caller's
51
+ * `(role, scope_id)` pairs, unnested once per query from the parallel arrays
52
+ * at placeholders `roles` and `scope_ids`. PG 12+ inlines a non-recursive
53
+ * single-use CTE, so this is planner-equivalent to a subquery — the form is
54
+ * for clarity.
55
+ *
56
+ * @param roles - the placeholder of the roles array (`text[]`)
57
+ * @param scope_ids - the placeholder of the scope-id array (`uuid[]`)
58
+ * @returns the CTE body
59
+ */
60
+ export const caller_role_grants_cte = (roles, scope_ids) => `caller_role_grants AS (
61
+ SELECT role, scope_id FROM unnest(${roles}::text[], ${scope_ids}::uuid[]) AS p(role, scope_id)
62
+ )`;
63
+ /**
64
+ * The `cell_grant` admits-caller predicate over grant alias `g_alias`: an
65
+ * actor-shaped grant matches the actor at placeholder `actor_id`; a
66
+ * role-shaped grant matches a `(role, scope_id)` pair in the
67
+ * `caller_role_grants` CTE (a NULL grant `scope_id` matches any scope).
68
+ * Mirrors `grant_admits` in `auth/cell_authorize.ts`.
69
+ *
70
+ * @param g_alias - the `cell_grant` alias in the enclosing query
71
+ * @param actor_id - the placeholder of the caller's actor (`uuid`)
72
+ * @returns the SQL predicate
73
+ */
74
+ export const cell_grant_admits_caller_sql = (g_alias, actor_id) => `(
75
+ (${actor_id}::uuid IS NOT NULL AND ${g_alias}.actor_id = ${actor_id})
76
+ OR (
77
+ ${g_alias}.role IS NOT NULL
78
+ AND EXISTS (
79
+ SELECT 1 FROM caller_role_grants p
80
+ WHERE p.role = ${g_alias}.role
81
+ AND (${g_alias}.scope_id IS NULL OR p.scope_id IS NOT DISTINCT FROM ${g_alias}.scope_id)
82
+ )
83
+ )
84
+ )`;
85
+ /**
86
+ * `can_view_cell` over the cell alias `c_alias`: admin, the owner, or — while
87
+ * the cell's `moderation` admits an audience — `public` or a `cell_grant`
88
+ * admitting the caller.
89
+ *
90
+ * Reads the caller at `params`; the grant arm needs the
91
+ * `caller_role_grants_cte` in scope. Liveness (`deleted_at IS NULL`) is the
92
+ * query's own clause, as `can_view_cell` reads a loaded live row.
93
+ *
94
+ * @param c_alias - the `cell` alias in the enclosing query
95
+ * @param params - where the query binds the caller
96
+ * @returns the SQL predicate (parenthesized)
97
+ */
98
+ export const can_view_cell_sql = (c_alias, params) => {
99
+ const { is_admin, actor_id } = params;
100
+ const grant_actor_id = params.grant_actor_id ?? actor_id;
101
+ return `(
102
+ ${is_admin}::bool
103
+ OR (${actor_id}::uuid IS NOT NULL AND ${c_alias}.created_by = ${actor_id})
104
+ OR (${cell_moderation_admits_audience_sql(c_alias)} AND (
105
+ ${c_alias}.visibility = 'public'
106
+ OR (${actor_id}::uuid IS NOT NULL AND EXISTS (
107
+ SELECT 1 FROM cell_grant g
108
+ WHERE g.cell_id = ${c_alias}.id AND ${cell_grant_admits_caller_sql('g', grant_actor_id)}
109
+ ))
110
+ ))
111
+ )`;
112
+ };
@@ -18,7 +18,7 @@ import { type DbDriverResult } from './db.js';
18
18
  * call this, don't need `pg` installed) and coerces int8 (`BIGINT`, OID 20)
19
19
  * to a JS number. pg defaults to returning
20
20
  * int8 as a string to avoid 2^53 precision loss; our int8 columns today
21
- * (`audit_log.seq`, `cell_history.id`, `fact.size`) stay well under that
21
+ * (`audit_log.seq`, `fact.size`) stay well under that
22
22
  * bound, and reading as a number keeps the wire shape uniform with PGlite —
23
23
  * which returns int8 as a number — so `AuditLogEvent.seq` and friends
24
24
  * validate identically across both drivers.
package/dist/db/db_pg.js CHANGED
@@ -17,7 +17,7 @@ import { Db, no_nested_transaction } from './db.js';
17
17
  * call this, don't need `pg` installed) and coerces int8 (`BIGINT`, OID 20)
18
18
  * to a JS number. pg defaults to returning
19
19
  * int8 as a string to avoid 2^53 precision loss; our int8 columns today
20
- * (`audit_log.seq`, `cell_history.id`, `fact.size`) stay well under that
20
+ * (`audit_log.seq`, `fact.size`) stay well under that
21
21
  * bound, and reading as a number keeps the wire shape uniform with PGlite —
22
22
  * which returns int8 as a number — so `AuditLogEvent.seq` and friends
23
23
  * validate identically across both drivers.
@@ -24,7 +24,7 @@ effects, see ../../../docs/architecture.md.
24
24
  - `http/surface_query.ts` — pure filters/groupings over `AppSurface`.
25
25
  - `http/proxy.ts` — trusted-proxy middleware, CIDR parsing, rightmost-first XFF resolution. Pulls `hono/utils/ipaddr` for CIDR matching.
26
26
  - `http/client_ip.ts` — `get_client_ip(c)` — reads the resolved IP off the context (set by `proxy.ts`'s middleware). Split out of `proxy.ts` so it's hono-free: dispatch + route modules that only _read_ the IP don't pull `hono/utils/ipaddr`, keeping cross-process test surfaces free of the optional `hono` peer.
27
- - `http/ip_canonical.ts` — RFC 5952 IPv6 canonicalization + IPv4-mapped collapse; `IP_LITERAL_CHARS` regex.
27
+ - `http/ip_canonical.ts` — RFC 5952 IPv6 canonicalization + IPv4-mapped collapse, `rate_limit_ip_key` (an IPv6 client's `/64`), and its own `parse_ipv4_bits` / `parse_ipv6_bits` (the grammar of Rust's `IpAddr::from_str`); `IP_LITERAL_CHARS` regex. Imports nothing — the dispatcher keys its IP limiter through it, so it must stay hono-free (`src/test/hono_free_modules.test.ts` walks every `src/lib` module's graph).
28
28
  - `http/origin.ts` — origin allowlist: the strict pattern grammar shared with the Rust spine, typed refusals, and the Origin-only middleware.
29
29
  - `http/jsonrpc.ts` — JSON-RPC 2.0 envelope schemas (MCP superset), `JsonrpcErrorCode`, `_meta`.
30
30
  - `http/jsonrpc_errors.ts` — `ThrownJsonrpcError`, `jsonrpc_errors` throwers, HTTP-status mappings.
@@ -68,9 +68,12 @@ export type ActingActor = z.infer<typeof ActingActor>;
68
68
  * instead would put an input-shape complaint ahead of the authority gates,
69
69
  * which is the ordering this pipeline exists to avoid.
70
70
  *
71
- * Both callers pass through here so that rule has one home: the action
71
+ * The dispatchers pass through here so that rule has one home: the action
72
72
  * dispatcher's `read_acting` and the REST authorization handler's
73
- * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`.
73
+ * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`. The status
74
+ * route and the WS upgrade read the raw `?acting=` through it too; neither has
75
+ * an input schema behind it, so there a malformed selector is final (the
76
+ * status route answers account-only).
74
77
  *
75
78
  * @param value - the raw `acting` field, from params / query / body
76
79
  * @returns the actor id, or `undefined` when absent or malformed
@@ -1 +1 @@
1
- {"version":3,"file":"auth_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/auth_shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,WAAW,6DAGtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SAGtD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa;;;;EAA2C,CAAC;AACtE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;kBAyEnB,CAAC;AACJ,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,SAAS,CAAC,CAAC;AAUlD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACA,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,SAAS,KAAG,OAAgC,CAAC;AAE/E;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,SAAS,KAAG,OAAkC,CAAC;AAEnF,wEAAwE;AACxE,eAAO,MAAM,YAAY,GAAI,MAAM,SAAS,KAAG,OAA+B,CAAC;AAE/E,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,GAAI,MAAM,SAAS,KAAG,OAC3B,CAAC;AAEjC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACQ,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,SAAS,KAAG,OACwB,CAAC;AAYvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,QAAQ,CAAC,CAAC,OAAO,KAAG,OAIhE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,MAAM,SAAS,EACf,OAAO,WAAW,EAClB,SAAS,MAAM,KACb,IAmBF,CAAC"}
1
+ {"version":3,"file":"auth_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/auth_shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,WAAW,6DAGtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SAGtD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa;;;;EAA2C,CAAC;AACtE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;kBAyEnB,CAAC;AACJ,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,SAAS,CAAC,CAAC;AAUlD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACA,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,SAAS,KAAG,OAAgC,CAAC;AAE/E;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,SAAS,KAAG,OAAkC,CAAC;AAEnF,wEAAwE;AACxE,eAAO,MAAM,YAAY,GAAI,MAAM,SAAS,KAAG,OAA+B,CAAC;AAE/E,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,GAAI,MAAM,SAAS,KAAG,OAC3B,CAAC;AAEjC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACQ,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,SAAS,KAAG,OACwB,CAAC;AAYvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,QAAQ,CAAC,CAAC,OAAO,KAAG,OAIhE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,MAAM,SAAS,EACf,OAAO,WAAW,EAClB,SAAS,MAAM,KACb,IAmBF,CAAC"}
@@ -71,9 +71,12 @@ export const ActingActor = Uuid.optional().meta({
71
71
  * instead would put an input-shape complaint ahead of the authority gates,
72
72
  * which is the ordering this pipeline exists to avoid.
73
73
  *
74
- * Both callers pass through here so that rule has one home: the action
74
+ * The dispatchers pass through here so that rule has one home: the action
75
75
  * dispatcher's `read_acting` and the REST authorization handler's
76
- * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`.
76
+ * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`. The status
77
+ * route and the WS upgrade read the raw `?acting=` through it too; neither has
78
+ * an input schema behind it, so there a malformed selector is final (the
79
+ * status route answers account-only).
77
80
  *
78
81
  * @param value - the raw `acting` field, from params / query / body
79
82
  * @returns the actor id, or `undefined` when absent or malformed
@@ -18,7 +18,8 @@
18
18
  * strings (`'unknown'`, `'attacker:controlled'`, `'::1\n'`) pass
19
19
  * through unchanged so downstream strict validators can still
20
20
  * reject them.
21
- * 2. Parses via Hono's `convertIPv*ToBinary` family.
21
+ * 2. Parses with `parse_ipv4_bits` / `parse_ipv6_bits` — the grammar of
22
+ * Rust's `IpAddr::from_str`, which the spine parses with.
22
23
  * 3. Re-emits the canonical RFC 5952 string (lowercase hex,
23
24
  * longest-zero-run compressed, IPv4-mapped emitted in the dotted
24
25
  * form mandated by RFC 5952 §5).
@@ -27,12 +28,41 @@
27
28
  * canonicalization because the dotted form is the only form the
28
29
  * strip can recognize symmetrically.
29
30
  *
30
- * Mirrors `zzz_server::proxy::normalize_ip` (landed 2026-05-16) which
31
- * uses the same parse-then-canonicalize-then-strip ordering for the
32
- * same rate-limit-key-poisoning surface.
31
+ * Mirrors the Rust spine's `fuz_http::normalize_ip`, which uses the same
32
+ * parse-then-canonicalize-then-strip ordering for the same
33
+ * rate-limit-key-poisoning surface.
34
+ *
35
+ * `rate_limit_ip_key` is the bucket key an IP-keyed rate limiter uses: an
36
+ * IPv6 address collapses to its `/64`, the smallest block a host is commonly
37
+ * assigned, so rotating through one's own subnet buys no fresh budget. Only
38
+ * the limiter call sites apply it — the client IP stays the full address
39
+ * everywhere else (audit rows, token `last_used_ip`, `REMOTE_ADDR`).
40
+ *
41
+ * The module imports nothing: the action dispatcher keys its IP limiter
42
+ * through it, so it must not pull the optional `hono` peer.
33
43
  *
34
44
  * @module
35
45
  */
46
+ /**
47
+ * Parse a dotted-quad IPv4 address to its 32-bit value.
48
+ *
49
+ * Four decimal octets, each 0–255 with no leading zeros (`01.2.3.4` is
50
+ * refused, as Rust's `Ipv4Addr::from_str` refuses it).
51
+ *
52
+ * @returns the address as an unsigned 32-bit number, or `null` when `ip` isn't one
53
+ */
54
+ export declare const parse_ipv4_bits: (ip: string) => number | null;
55
+ /**
56
+ * Parse an IPv6 address to its 128-bit value.
57
+ *
58
+ * The grammar of Rust's `Ipv6Addr::from_str`: eight groups of one to four
59
+ * hex digits (either case), or fewer around a single `::` standing for one or
60
+ * more zero groups, the last two groups optionally written as a dotted-quad
61
+ * IPv4 address. No zone (`%eth0`) — callers strip it first.
62
+ *
63
+ * @returns the address as a `bigint` in `[0, 2^128)`, or `null` when `ip` isn't one
64
+ */
65
+ export declare const parse_ipv6_bits: (ip: string) => bigint | null;
36
66
  /**
37
67
  * Allowed character set for a bare IP literal.
38
68
  *
@@ -89,7 +119,7 @@ export declare const canonicalize_ip: (ip: string) => string;
89
119
  *
90
120
  * Pure helper exported for the test suite to exercise the
91
121
  * canonicalization invariants directly without a full
92
- * `convertIPv6ToBinary` round-trip.
122
+ * `parse_ipv6_bits` round-trip.
93
123
  *
94
124
  * @param bits - the 128-bit IPv6 value as `bigint`. Must satisfy `0n <= bits < 2n ** 128n`;
95
125
  * throws `RangeError` otherwise. Silent truncation would mask caller bugs since the
@@ -97,4 +127,25 @@ export declare const canonicalize_ip: (ip: string) => string;
97
127
  * @throws {RangeError} when `bits` is negative or exceeds 128 bits
98
128
  */
99
129
  export declare const ipv6_bigint_to_canonical: (bits: bigint) => string;
130
+ /**
131
+ * The bucket key an IP-keyed rate limiter uses for client IP `ip`.
132
+ *
133
+ * An IPv6 address keys its `/64`: the low 64 bits masked off, rendered in
134
+ * RFC 5952 canonical form with a `/64` suffix (`2001:db8:1:2::/64`; the
135
+ * all-zero prefix is `::/64`). A zone suffix (`%eth0`) is dropped first. An
136
+ * IPv4 address — an IPv4-mapped IPv6 one included — keys its canonical form,
137
+ * with no suffix. Anything that doesn't parse (`'unknown'` among them) is
138
+ * returned unchanged. Twin of the Rust spine's `fuz_http::rate_limit_ip_key`.
139
+ *
140
+ * For limiter keys only: audit rows and every other record of the client
141
+ * keep the full address.
142
+ *
143
+ * @example
144
+ * rate_limit_ip_key('2001:db8:1:2::1') // → '2001:db8:1:2::/64'
145
+ * rate_limit_ip_key('2001:db8:1:2:8000::') // → '2001:db8:1:2::/64'
146
+ * rate_limit_ip_key('fe80::1%eth0') // → 'fe80::/64'
147
+ * rate_limit_ip_key('203.0.113.7') // → '203.0.113.7'
148
+ * rate_limit_ip_key('unknown') // → 'unknown'
149
+ */
150
+ export declare const rate_limit_ip_key: (ip: string) => string;
100
151
  //# sourceMappingURL=ip_canonical.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ip_canonical.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/ip_canonical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,QAAqB,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAmC5C,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,MAAM,KAAG,MAgEvD,CAAC"}
1
+ {"version":3,"file":"ip_canonical.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/ip_canonical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AASH;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAAM,GAAG,IAKrD,CAAC;AA0BF;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAAM,GAAG,IAkBrD,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,QAAqB,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAsB5C,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,MAAM,KAAG,MAgEvD,CAAC;AAKF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,MAAM,KAAG,MAQ9C,CAAC"}