@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.
- package/dist/actions/CLAUDE.md +10 -5
- package/dist/actions/connection_closer.d.ts +19 -2
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +25 -2
- package/dist/actions/perform_action.d.ts +10 -0
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +23 -9
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -2
- package/dist/auth/CLAUDE.md +33 -13
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +12 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +8 -0
- package/dist/auth/account_routes.d.ts +7 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +240 -187
- package/dist/auth/admin_action_specs.d.ts +6 -1
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +6 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +14 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +85 -76
- package/dist/auth/cell_action_specs.d.ts +7 -3
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +9 -5
- package/dist/auth/cell_actions.d.ts +3 -3
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +67 -38
- package/dist/auth/cell_authorize.d.ts +69 -8
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +89 -9
- package/dist/auth/cell_field_action_specs.d.ts +5 -4
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_field_action_specs.js +7 -7
- package/dist/auth/cell_field_actions.d.ts +7 -5
- package/dist/auth/cell_field_actions.d.ts.map +1 -1
- package/dist/auth/cell_field_actions.js +24 -22
- package/dist/auth/cell_grant_action_specs.d.ts +6 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_grant_action_specs.js +6 -0
- package/dist/auth/cell_grant_actions.d.ts +3 -1
- package/dist/auth/cell_grant_actions.d.ts.map +1 -1
- package/dist/auth/cell_grant_actions.js +16 -2
- package/dist/auth/cell_item_action_specs.d.ts +5 -4
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_item_action_specs.js +7 -7
- package/dist/auth/cell_item_actions.d.ts +6 -5
- package/dist/auth/cell_item_actions.d.ts.map +1 -1
- package/dist/auth/cell_item_actions.js +23 -22
- package/dist/auth/cell_relation_list_limit.d.ts +20 -0
- package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
- package/dist/auth/cell_relation_list_limit.js +27 -0
- package/dist/auth/cell_relation_visibility.d.ts +4 -3
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
- package/dist/auth/cell_relation_visibility.js +6 -5
- package/dist/auth/cell_steward.d.ts +45 -0
- package/dist/auth/cell_steward.d.ts.map +1 -0
- package/dist/auth/cell_steward.js +53 -0
- package/dist/auth/request_context.d.ts +4 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +6 -1
- package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +6 -1
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +23 -16
- package/dist/auth/role_grant_offer_queries.d.ts +8 -6
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +16 -8
- package/dist/auth/signup_routes.d.ts +4 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +157 -134
- package/dist/auth/token_scope.d.ts +7 -1
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +8 -2
- package/dist/db/CLAUDE.md +32 -19
- package/dist/db/cell_ddl.d.ts +1 -2
- package/dist/db/cell_ddl.d.ts.map +1 -1
- package/dist/db/cell_ddl.js +1 -2
- package/dist/db/cell_field_queries.d.ts +20 -18
- package/dist/db/cell_field_queries.d.ts.map +1 -1
- package/dist/db/cell_field_queries.js +28 -22
- package/dist/db/cell_item_queries.d.ts +20 -14
- package/dist/db/cell_item_queries.d.ts.map +1 -1
- package/dist/db/cell_item_queries.js +28 -18
- package/dist/db/cell_moderation.d.ts +67 -0
- package/dist/db/cell_moderation.d.ts.map +1 -0
- package/dist/db/cell_moderation.js +66 -0
- package/dist/db/cell_queries.d.ts +44 -14
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +68 -45
- package/dist/db/cell_view_sql.d.ts +102 -0
- package/dist/db/cell_view_sql.d.ts.map +1 -0
- package/dist/db/cell_view_sql.js +112 -0
- package/dist/db/db_pg.d.ts +1 -1
- package/dist/db/db_pg.js +1 -1
- package/dist/http/auth_shape.d.ts +5 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +5 -2
- package/dist/http/ip_canonical.d.ts +30 -3
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +50 -3
- package/dist/primitive_schemas.d.ts +10 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +11 -2
- package/dist/rate_limiter.d.ts +179 -12
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +352 -42
- package/dist/server/serve_fact_route.d.ts +8 -4
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +15 -6
- package/dist/testing/CLAUDE.md +10 -3
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +37 -2
- package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +401 -5
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +145 -2
- package/dist/testing/cross_backend/expected_schema.json +0 -1
- package/dist/testing/cross_backend/login_security.d.ts +16 -0
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +154 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
- package/dist/testing/cross_backend/query_shape.js +24 -2
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +64 -0
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
- package/dist/testing/schema_introspect.js +1 -1
- package/dist/testing/schema_parity.d.ts +4 -3
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/schema_parity.js +4 -3
- package/dist/testing/schema_ready_fixture.d.ts +2 -4
- package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
- package/dist/testing/schema_ready_fixture.js +2 -4
- package/dist/ui/auth_state.svelte.d.ts +10 -0
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +22 -4
- package/package.json +1 -1
- package/dist/db/cell_history_ddl.d.ts +0 -38
- package/dist/db/cell_history_ddl.d.ts.map +0 -1
- package/dist/db/cell_history_ddl.js +0 -61
package/dist/db/cell_queries.js
CHANGED
|
@@ -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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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 (
|
|
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 `
|
|
373
|
-
*
|
|
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
|
|
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
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
|
415
|
-
OR c.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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 ${
|
|
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
|
+
};
|
package/dist/db/db_pg.d.ts
CHANGED
|
@@ -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`, `
|
|
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`, `
|
|
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
|
-
*
|
|
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
|
|
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"}
|
package/dist/http/auth_shape.js
CHANGED
|
@@ -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
|
-
*
|
|
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 `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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 `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
185
|
-
|
|
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. */
|