@fuzdev/fuz_app 0.141.0 → 0.142.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 (147) 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/auth_shape.d.ts +5 -2
  100. package/dist/http/auth_shape.d.ts.map +1 -1
  101. package/dist/http/auth_shape.js +5 -2
  102. package/dist/http/ip_canonical.d.ts +30 -3
  103. package/dist/http/ip_canonical.d.ts.map +1 -1
  104. package/dist/http/ip_canonical.js +50 -3
  105. package/dist/primitive_schemas.d.ts +10 -1
  106. package/dist/primitive_schemas.d.ts.map +1 -1
  107. package/dist/primitive_schemas.js +11 -2
  108. package/dist/rate_limiter.d.ts +179 -12
  109. package/dist/rate_limiter.d.ts.map +1 -1
  110. package/dist/rate_limiter.js +352 -42
  111. package/dist/server/serve_fact_route.d.ts +8 -4
  112. package/dist/server/serve_fact_route.d.ts.map +1 -1
  113. package/dist/server/serve_fact_route.js +15 -6
  114. package/dist/testing/CLAUDE.md +10 -3
  115. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  116. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  117. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  119. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  120. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/cell_relations.js +145 -2
  122. package/dist/testing/cross_backend/expected_schema.json +0 -1
  123. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  124. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/login_security.js +154 -0
  126. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/query_shape.js +24 -2
  128. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  129. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  130. package/dist/testing/cross_backend/spine_surface_constants.d.ts +1 -1
  131. package/dist/testing/cross_backend/spine_surface_constants.js +1 -1
  132. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  134. package/dist/testing/schema_introspect.js +1 -1
  135. package/dist/testing/schema_parity.d.ts +4 -3
  136. package/dist/testing/schema_parity.d.ts.map +1 -1
  137. package/dist/testing/schema_parity.js +4 -3
  138. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  139. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  140. package/dist/testing/schema_ready_fixture.js +2 -4
  141. package/dist/ui/auth_state.svelte.d.ts +10 -0
  142. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  143. package/dist/ui/auth_state.svelte.js +22 -4
  144. package/package.json +1 -1
  145. package/dist/db/cell_history_ddl.d.ts +0 -38
  146. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  147. 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.
@@ -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
@@ -27,9 +27,15 @@
27
27
  * canonicalization because the dotted form is the only form the
28
28
  * strip can recognize symmetrically.
29
29
  *
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.
30
+ * Mirrors the Rust spine's `fuz_http::normalize_ip`, which uses the same
31
+ * parse-then-canonicalize-then-strip ordering for the same
32
+ * rate-limit-key-poisoning surface.
33
+ *
34
+ * `rate_limit_ip_key` is the bucket key an IP-keyed rate limiter uses: an
35
+ * IPv6 address collapses to its `/64`, the smallest block a host is commonly
36
+ * assigned, so rotating through one's own subnet buys no fresh budget. Only
37
+ * the limiter call sites apply it — the client IP stays the full address
38
+ * everywhere else (audit rows, token `last_used_ip`, `REMOTE_ADDR`).
33
39
  *
34
40
  * @module
35
41
  */
@@ -97,4 +103,25 @@ export declare const canonicalize_ip: (ip: string) => string;
97
103
  * @throws {RangeError} when `bits` is negative or exceeds 128 bits
98
104
  */
99
105
  export declare const ipv6_bigint_to_canonical: (bits: bigint) => string;
106
+ /**
107
+ * The bucket key an IP-keyed rate limiter uses for client IP `ip`.
108
+ *
109
+ * An IPv6 address keys its `/64`: the low 64 bits masked off, rendered in
110
+ * RFC 5952 canonical form with a `/64` suffix (`2001:db8:1:2::/64`; the
111
+ * all-zero prefix is `::/64`). A zone suffix (`%eth0`) is dropped first. An
112
+ * IPv4 address — an IPv4-mapped IPv6 one included — keys its canonical form,
113
+ * with no suffix. Anything that doesn't parse (`'unknown'` among them) is
114
+ * returned unchanged. Twin of the Rust spine's `fuz_http::rate_limit_ip_key`.
115
+ *
116
+ * For limiter keys only: audit rows and every other record of the client
117
+ * keep the full address.
118
+ *
119
+ * @example
120
+ * rate_limit_ip_key('2001:db8:1:2::1') // → '2001:db8:1:2::/64'
121
+ * rate_limit_ip_key('2001:db8:1:2:8000::') // → '2001:db8:1:2::/64'
122
+ * rate_limit_ip_key('fe80::1%eth0') // → 'fe80::/64'
123
+ * rate_limit_ip_key('203.0.113.7') // → '203.0.113.7'
124
+ * rate_limit_ip_key('unknown') // → 'unknown'
125
+ */
126
+ export declare const rate_limit_ip_key: (ip: string) => string;
100
127
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;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;AAKF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,MAAM,KAAG,MAc9C,CAAC"}
@@ -27,9 +27,15 @@
27
27
  * canonicalization because the dotted form is the only form the
28
28
  * strip can recognize symmetrically.
29
29
  *
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.
30
+ * Mirrors the Rust spine's `fuz_http::normalize_ip`, which uses the same
31
+ * parse-then-canonicalize-then-strip ordering for the same
32
+ * rate-limit-key-poisoning surface.
33
+ *
34
+ * `rate_limit_ip_key` is the bucket key an IP-keyed rate limiter uses: an
35
+ * IPv6 address collapses to its `/64`, the smallest block a host is commonly
36
+ * assigned, so rotating through one's own subnet buys no fresh budget. Only
37
+ * the limiter call sites apply it — the client IP stays the full address
38
+ * everywhere else (audit rows, token `last_used_ip`, `REMOTE_ADDR`).
33
39
  *
34
40
  * @module
35
41
  */
@@ -193,3 +199,44 @@ export const ipv6_bigint_to_canonical = (bits) => {
193
199
  .join(':');
194
200
  return before + '::' + after;
195
201
  };
202
+ /** The high 64 bits of an IPv6 address — its `/64` network prefix. */
203
+ const IPV6_PREFIX_64_MASK = ((1n << 64n) - 1n) << 64n;
204
+ /**
205
+ * The bucket key an IP-keyed rate limiter uses for client IP `ip`.
206
+ *
207
+ * An IPv6 address keys its `/64`: the low 64 bits masked off, rendered in
208
+ * RFC 5952 canonical form with a `/64` suffix (`2001:db8:1:2::/64`; the
209
+ * all-zero prefix is `::/64`). A zone suffix (`%eth0`) is dropped first. An
210
+ * IPv4 address — an IPv4-mapped IPv6 one included — keys its canonical form,
211
+ * with no suffix. Anything that doesn't parse (`'unknown'` among them) is
212
+ * returned unchanged. Twin of the Rust spine's `fuz_http::rate_limit_ip_key`.
213
+ *
214
+ * For limiter keys only: audit rows and every other record of the client
215
+ * keep the full address.
216
+ *
217
+ * @example
218
+ * rate_limit_ip_key('2001:db8:1:2::1') // → '2001:db8:1:2::/64'
219
+ * rate_limit_ip_key('2001:db8:1:2:8000::') // → '2001:db8:1:2::/64'
220
+ * rate_limit_ip_key('fe80::1%eth0') // → 'fe80::/64'
221
+ * rate_limit_ip_key('203.0.113.7') // → '203.0.113.7'
222
+ * rate_limit_ip_key('unknown') // → 'unknown'
223
+ */
224
+ export const rate_limit_ip_key = (ip) => {
225
+ const zone_at = ip.indexOf('%');
226
+ const canonical = canonicalize_ip(zone_at === -1 ? ip : ip.slice(0, zone_at));
227
+ if (!IP_LITERAL_CHARS.test(canonical))
228
+ return ip;
229
+ const family = distinctRemoteAddr(canonical);
230
+ if (family === 'IPv4')
231
+ return canonical;
232
+ if (family !== 'IPv6')
233
+ return ip;
234
+ let bits;
235
+ try {
236
+ bits = convertIPv6ToBinary(canonical);
237
+ }
238
+ catch {
239
+ return ip;
240
+ }
241
+ return `${ipv6_bigint_to_canonical(bits & IPV6_PREFIX_64_MASK)}/64`;
242
+ };
@@ -117,7 +117,16 @@ export declare const Email: z.ZodString;
117
117
  export type Email = z.infer<typeof Email>;
118
118
  /** Returns a new random (v4) `Uuid`. */
119
119
  export declare const create_uuid: () => Uuid;
120
- /** Branded UUID schema. */
120
+ /**
121
+ * Branded UUID schema. Accepts either case and outputs lowercase — the form
122
+ * Postgres renders a `uuid` in and every id the server mints — so a parsed id
123
+ * compares equal to the same id read back from the database. The Rust spine
124
+ * canonicalizes the same way.
125
+ *
126
+ * `.toLowerCase()` rather than `.transform()`: an overwrite keeps the output
127
+ * type and stays invisible to `z.toJSONSchema`, where a transform would leave
128
+ * no input schema to render.
129
+ */
121
130
  export declare const Uuid: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
122
131
  export type Uuid = z.infer<typeof Uuid>;
123
132
  /** `Uuid` that defaults to a fresh `create_uuid()` when the input is `undefined`. */
@@ -1 +1 @@
1
- {"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAIlF,gEAAgE;AAChE,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,cAAc,GAAI,2BAA2B,oBAAoB,KAAG,CAAC,CAAC,SA8ClF,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,6GAA6G;AAC7G,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gNAAgN;AAChN,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAEc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,wDAEqE,CAAC;AACnG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAqBpC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,KAAK,aAYhB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC;AAE1C,wCAAwC;AACxC,eAAO,MAAM,WAAW,QAAO,IAAmC,CAAC;AAEnE,2BAA2B;AAC3B,eAAO,MAAM,IAAI,8CAAyB,CAAC;AAC3C,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;AAExC,qFAAqF;AACrF,eAAO,MAAM,eAAe,4DAA4B,CAAC;AACzD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,wCAAwC;AACxC,eAAO,MAAM,QAAQ,yDAAqC,CAAC;AAC3D,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD,gEAAgE;AAChE,eAAO,MAAM,gBAAgB,QAAO,QAAgD,CAAC;AAErF,sFAAsF;AACtF,eAAO,MAAM,WAAW,uEAAqC,CAAC;AAC9D,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,uFAAuF;AACvF,eAAO,MAAM,UAAU,aAEuD,CAAC;AAC/E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,EAAE,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAEA,CAAC"}
1
+ {"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAIlF,gEAAgE;AAChE,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,cAAc,GAAI,2BAA2B,oBAAoB,KAAG,CAAC,CAAC,SA8ClF,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,6GAA6G;AAC7G,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gNAAgN;AAChN,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAEc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,wDAEqE,CAAC;AACnG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAqBpC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,KAAK,aAYhB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC;AAE1C,wCAAwC;AACxC,eAAO,MAAM,WAAW,QAAO,IAAmC,CAAC;AAEnE;;;;;;;;;GASG;AACH,eAAO,MAAM,IAAI,8CAAuC,CAAC;AACzD,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;AAExC,qFAAqF;AACrF,eAAO,MAAM,eAAe,4DAA4B,CAAC;AACzD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,wCAAwC;AACxC,eAAO,MAAM,QAAQ,yDAAqC,CAAC;AAC3D,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD,gEAAgE;AAChE,eAAO,MAAM,gBAAgB,QAAO,QAAgD,CAAC;AAErF,sFAAsF;AACtF,eAAO,MAAM,WAAW,uEAAqC,CAAC;AAC9D,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,uFAAuF;AACvF,eAAO,MAAM,UAAU,aAEuD,CAAC;AAC/E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,EAAE,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAEA,CAAC"}
@@ -181,8 +181,17 @@ export const Email = z
181
181
  });
182
182
  /** Returns a new random (v4) `Uuid`. */
183
183
  export const create_uuid = () => crypto.randomUUID();
184
- /** Branded UUID schema. */
185
- export const Uuid = z.uuid().brand('Uuid');
184
+ /**
185
+ * Branded UUID schema. Accepts either case and outputs lowercase — the form
186
+ * Postgres renders a `uuid` in and every id the server mints — so a parsed id
187
+ * compares equal to the same id read back from the database. The Rust spine
188
+ * canonicalizes the same way.
189
+ *
190
+ * `.toLowerCase()` rather than `.transform()`: an overwrite keeps the output
191
+ * type and stays invisible to `z.toJSONSchema`, where a transform would leave
192
+ * no input schema to render.
193
+ */
194
+ export const Uuid = z.uuid().toLowerCase().brand('Uuid');
186
195
  /** `Uuid` that defaults to a fresh `create_uuid()` when the input is `undefined`. */
187
196
  export const UuidWithDefault = Uuid.default(create_uuid);
188
197
  /** Branded ISO 8601 datetime schema. */