@fuzdev/fuz_app 0.62.0 → 0.64.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 (136) hide show
  1. package/dist/actions/CLAUDE.md +139 -24
  2. package/dist/actions/action_rpc.d.ts +10 -0
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +1 -1
  5. package/dist/actions/action_spec.d.ts +1 -1
  6. package/dist/actions/action_spec.js +1 -1
  7. package/dist/actions/connection_closer.d.ts +68 -0
  8. package/dist/actions/connection_closer.d.ts.map +1 -0
  9. package/dist/actions/connection_closer.js +41 -0
  10. package/dist/actions/perform_action.d.ts.map +1 -1
  11. package/dist/actions/perform_action.js +1 -0
  12. package/dist/actions/register_action_ws.d.ts.map +1 -1
  13. package/dist/actions/register_action_ws.js +23 -2
  14. package/dist/actions/register_ws_endpoint.d.ts +11 -9
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +5 -5
  17. package/dist/actions/transports_ws_auth_guard.d.ts +24 -8
  18. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  19. package/dist/actions/transports_ws_auth_guard.js +23 -7
  20. package/dist/actions/ws_endpoint_spec.d.ts +119 -0
  21. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -0
  22. package/dist/actions/ws_endpoint_spec.js +13 -0
  23. package/dist/auth/CLAUDE.md +124 -39
  24. package/dist/auth/account_action_specs.d.ts +7 -1
  25. package/dist/auth/account_action_specs.d.ts.map +1 -1
  26. package/dist/auth/account_action_specs.js +11 -4
  27. package/dist/auth/account_actions.d.ts +13 -0
  28. package/dist/auth/account_actions.d.ts.map +1 -1
  29. package/dist/auth/account_actions.js +40 -5
  30. package/dist/auth/account_routes.d.ts +12 -2
  31. package/dist/auth/account_routes.d.ts.map +1 -1
  32. package/dist/auth/account_routes.js +63 -12
  33. package/dist/auth/account_schema.d.ts +5 -5
  34. package/dist/auth/account_schema.js +2 -2
  35. package/dist/auth/actor_lookup_actions.d.ts +1 -1
  36. package/dist/auth/actor_lookup_actions.js +1 -1
  37. package/dist/auth/actor_lookup_queries.d.ts +1 -1
  38. package/dist/auth/actor_lookup_queries.js +1 -1
  39. package/dist/auth/actor_search_action_specs.d.ts +1 -1
  40. package/dist/auth/actor_search_action_specs.js +1 -1
  41. package/dist/auth/actor_search_actions.d.ts +1 -1
  42. package/dist/auth/actor_search_actions.js +1 -1
  43. package/dist/auth/actor_search_queries.d.ts +1 -1
  44. package/dist/auth/actor_search_queries.js +1 -1
  45. package/dist/auth/admin_action_specs.d.ts +8 -8
  46. package/dist/auth/admin_actions.d.ts +11 -0
  47. package/dist/auth/admin_actions.d.ts.map +1 -1
  48. package/dist/auth/admin_actions.js +25 -0
  49. package/dist/auth/all_action_spec_registries.d.ts +2 -2
  50. package/dist/auth/all_action_spec_registries.js +2 -2
  51. package/dist/auth/audit_emitter.d.ts +56 -12
  52. package/dist/auth/audit_emitter.d.ts.map +1 -1
  53. package/dist/auth/audit_emitter.js +38 -12
  54. package/dist/auth/audit_log_routes.d.ts +1 -1
  55. package/dist/auth/audit_log_routes.js +1 -1
  56. package/dist/auth/audit_log_schema.d.ts +30 -3
  57. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  58. package/dist/auth/audit_log_schema.js +21 -3
  59. package/dist/auth/bootstrap_routes.d.ts +1 -1
  60. package/dist/auth/invite_schema.d.ts +2 -2
  61. package/dist/auth/request_context.d.ts +1 -1
  62. package/dist/auth/signup_routes.d.ts +1 -1
  63. package/dist/auth/standard_rpc_actions.d.ts +1 -0
  64. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  65. package/dist/auth/standard_rpc_actions.js +1 -0
  66. package/dist/env/update_env_variable.js +1 -1
  67. package/dist/http/CLAUDE.md +42 -26
  68. package/dist/http/ip_canonical.d.ts +99 -0
  69. package/dist/http/ip_canonical.d.ts.map +1 -0
  70. package/dist/http/ip_canonical.js +191 -0
  71. package/dist/http/origin.d.ts +13 -5
  72. package/dist/http/origin.d.ts.map +1 -1
  73. package/dist/http/origin.js +13 -31
  74. package/dist/http/pending_effects.d.ts +1 -1
  75. package/dist/http/pending_effects.js +1 -1
  76. package/dist/http/proxy.d.ts +13 -5
  77. package/dist/http/proxy.d.ts.map +1 -1
  78. package/dist/http/proxy.js +15 -23
  79. package/dist/http/surface.d.ts +50 -0
  80. package/dist/http/surface.d.ts.map +1 -1
  81. package/dist/http/surface.js +27 -1
  82. package/dist/primitive_schemas.d.ts +20 -4
  83. package/dist/primitive_schemas.d.ts.map +1 -1
  84. package/dist/primitive_schemas.js +25 -4
  85. package/dist/realtime/sse_auth_guard.d.ts +16 -4
  86. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  87. package/dist/realtime/sse_auth_guard.js +15 -3
  88. package/dist/server/app_backend.d.ts +66 -19
  89. package/dist/server/app_backend.d.ts.map +1 -1
  90. package/dist/server/app_backend.js +57 -34
  91. package/dist/server/app_server.d.ts +60 -0
  92. package/dist/server/app_server.d.ts.map +1 -1
  93. package/dist/server/app_server.js +95 -2
  94. package/dist/server/startup.d.ts.map +1 -1
  95. package/dist/server/startup.js +12 -0
  96. package/dist/testing/CLAUDE.md +91 -71
  97. package/dist/testing/admin_integration.d.ts.map +1 -1
  98. package/dist/testing/admin_integration.js +4 -5
  99. package/dist/testing/adversarial_headers.d.ts +6 -0
  100. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  101. package/dist/testing/adversarial_headers.js +13 -5
  102. package/dist/testing/app_server.d.ts +33 -32
  103. package/dist/testing/app_server.d.ts.map +1 -1
  104. package/dist/testing/app_server.js +4 -13
  105. package/dist/testing/attack_surface.d.ts +8 -7
  106. package/dist/testing/attack_surface.d.ts.map +1 -1
  107. package/dist/testing/attack_surface.js +12 -8
  108. package/dist/testing/audit_completeness.d.ts.map +1 -1
  109. package/dist/testing/audit_completeness.js +20 -6
  110. package/dist/testing/audit_drift_guard.d.ts +116 -0
  111. package/dist/testing/audit_drift_guard.d.ts.map +1 -0
  112. package/dist/testing/audit_drift_guard.js +134 -0
  113. package/dist/testing/connection_closer_helpers.d.ts +44 -0
  114. package/dist/testing/connection_closer_helpers.d.ts.map +1 -0
  115. package/dist/testing/connection_closer_helpers.js +48 -0
  116. package/dist/testing/integration.d.ts.map +1 -1
  117. package/dist/testing/integration.js +7 -9
  118. package/dist/testing/rate_limiting.js +4 -4
  119. package/dist/testing/rpc_helpers.d.ts +2 -1
  120. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  121. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  122. package/dist/testing/rpc_round_trip.js +6 -8
  123. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  124. package/dist/testing/sse_round_trip.js +12 -6
  125. package/dist/testing/stubs.d.ts +11 -0
  126. package/dist/testing/stubs.d.ts.map +1 -1
  127. package/dist/testing/stubs.js +4 -0
  128. package/dist/testing/surface_invariants.d.ts +66 -1
  129. package/dist/testing/surface_invariants.d.ts.map +1 -1
  130. package/dist/testing/surface_invariants.js +103 -1
  131. package/dist/ui/CLAUDE.md +13 -18
  132. package/dist/ui/SurfaceExplorer.svelte +161 -2
  133. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  134. package/dist/ui/keyed_async_slot.svelte.d.ts +1 -1
  135. package/dist/ui/keyed_async_slot.svelte.js +1 -1
  136. package/package.json +1 -1
@@ -5,12 +5,12 @@ surface introspection, JSON-RPC envelope + error taxonomy, proxy/origin
5
5
  middleware primitives, post-commit effect helper, generic admin route specs.
6
6
 
7
7
  **Nothing in this directory is auth-specific.** Auth middleware, routes, and
8
- guards live in `../auth/` and consume these primitives. Routes and actions in
8
+ guards live in `auth/` and consume these primitives. Routes and actions in
9
9
  other domains should do the same — extend, don't special-case.
10
10
 
11
11
  For the design rationale behind declarative routes, DEV-only output
12
12
  validation, the three-layer error-schema merge, and fire-and-forget effects,
13
- see `../../docs/architecture.md`.
13
+ see ../../docs/architecture.md.
14
14
 
15
15
  ## Module Map
16
16
 
@@ -24,7 +24,8 @@ see `../../docs/architecture.md`.
24
24
  | `surface.ts` | `AppSurface`, `AppSurfaceSpec`, `generate_app_surface`, diagnostics |
25
25
  | `surface_query.ts` | Pure filters/groupings over `AppSurface` |
26
26
  | `proxy.ts` | Trusted-proxy middleware, CIDR parsing, rightmost-first XFF resolution |
27
- | `origin.ts` | Origin/Referer allowlist middleware with wildcard patterns |
27
+ | `ip_canonical.ts` | RFC 5952 IPv6 canonicalization + IPv4-mapped collapse; `IP_LITERAL_CHARS` regex |
28
+ | `origin.ts` | Origin allowlist middleware with wildcard patterns (Origin-only) |
28
29
  | `jsonrpc.ts` | JSON-RPC 2.0 envelope schemas (MCP superset), `JsonrpcErrorCode`, `_meta` |
29
30
  | `jsonrpc_errors.ts` | `ThrownJsonrpcError`, `jsonrpc_errors` throwers, HTTP-status mappings |
30
31
  | `jsonrpc_helpers.ts` | Message builders, type guards, input/result normalizers |
@@ -42,7 +43,7 @@ by `generate_app_surface`. Same-shaped data, different consumers.
42
43
 
43
44
  - `method` — `'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'`
44
45
  - `path` — Hono path (supports `:param` segments)
45
- - `auth: RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `auth_shape.ts`. Each axis is `'none' | 'optional' | 'required'`. Same shape governs `ActionSpec.auth` (see `../actions/CLAUDE.md`).
46
+ - `auth: RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `auth_shape.ts`. Each axis is `'none' | 'optional' | 'required'`. Same shape governs `ActionSpec.auth` (see `actions/CLAUDE.md`).
46
47
  - `handler: RouteHandler` — `(c: Context, route: RouteContext) => Response | Promise<Response>`
47
48
  - `description` — free-text, surfaced in `AppSurface`
48
49
  - `params?: z.ZodObject` — strict-object schema for URL path params
@@ -84,7 +85,7 @@ interface RouteContext {
84
85
  `db.transaction`.
85
86
 
86
87
  Pool-level fire-and-forget writes (audit logs, etc.) run through the bound
87
- `AppDeps.audit` capability — see `../auth/CLAUDE.md` §Deps. Handlers that
88
+ `AppDeps.audit` capability — see `auth/CLAUDE.md` §Deps. Handlers that
88
89
  need rollback-resilient writes call `deps.audit.emit(route, input)`, which
89
90
  captures the pool inside the bound emitter so the row lands even when
90
91
  the handler's transaction rolls back.
@@ -98,7 +99,7 @@ the handler's transaction rolls back.
98
99
 
99
100
  Override explicitly when a mutation route must manage its own transactions
100
101
  (e.g. signup, which does a multi-step flow that can't live inside a single
101
- wrapper). See `../auth/signup_routes.ts`.
102
+ wrapper). See `auth/signup_routes.ts`.
102
103
 
103
104
  ### Validation pipeline (per-route middleware order)
104
105
 
@@ -114,7 +115,7 @@ wrapper). See `../auth/signup_routes.ts`.
114
115
  or `auth.actor === 'required'`. Fires before any body parsing so
115
116
  unauthenticated callers never see route-shape information from
116
117
  input parse failures. The `AuthGuardResolver` (e.g.
117
- `fuz_auth_guard_resolver` from `../auth/auth_guard_resolver.ts`) returns
118
+ `fuz_auth_guard_resolver` from `auth/auth_guard_resolver.ts`) returns
118
119
  this set as `pre_validation: Array<MiddlewareHandler>`.
119
120
  4. **Input validation** — JSON body parsed + validated; mismatch returns
120
121
  400 `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY`
@@ -191,7 +192,7 @@ are the contract with external callers.
191
192
 
192
193
  The production behavior short-circuits to the unwrapped handler — no
193
194
  parse work on the hot path. Uniform across all three action-handler
194
- surfaces (REST, RPC, WS); see `../../docs/architecture.md` §DEV-only
195
+ surfaces (REST, RPC, WS); see ../../docs/architecture.md §DEV-only
195
196
  Output Validation.
196
197
 
197
198
  ### Helpers
@@ -205,8 +206,8 @@ Output Validation.
205
206
 
206
207
  `error_schemas.ts` is the **declarative** error surface:
207
208
 
208
- - `ERROR_*` snake*case string constants — single source of truth; use
209
- `.literal(ERROR*\*)` in Zod schemas and inline checks in handlers
209
+ - `ERROR_*` `snake_case` string constants — single source of truth; use
210
+ `.literal(ERROR_*)` in Zod schemas and inline checks in handlers
210
211
  - `ApiError`, `ValidationError`, `PermissionError`,
211
212
  `CredentialTypeRequiredError`, `RateLimitError`, `PayloadTooLargeError`,
212
213
  `ForeignKeyError` — standard shapes
@@ -280,7 +281,7 @@ invariant 2's throw) was the empirical confirmation.
280
281
  - **Auth**: `ERROR_AUTHENTICATION_REQUIRED`, `ERROR_INSUFFICIENT_PERMISSIONS`,
281
282
  `ERROR_CREDENTIAL_TYPE_REQUIRED`, `ERROR_RATE_LIMIT_EXCEEDED`,
282
283
  `ERROR_INVALID_CREDENTIALS`, `ERROR_PAYLOAD_TOO_LARGE`
283
- - **Origin + bearer**: `ERROR_FORBIDDEN_ORIGIN`, `ERROR_FORBIDDEN_REFERER`,
284
+ - **Origin + bearer**: `ERROR_FORBIDDEN_ORIGIN`, `ERROR_FORBIDDEN_REFERER` (retained for consumer compat; no longer emitted),
284
285
  `ERROR_BEARER_REJECTED_BROWSER`, `ERROR_INVALID_TOKEN`, `ERROR_ACCOUNT_NOT_FOUND`
285
286
  - **Keeper/daemon**: `ERROR_INVALID_DAEMON_TOKEN`,
286
287
  `ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`, `ERROR_KEEPER_ACCOUNT_NOT_FOUND`
@@ -413,12 +414,17 @@ connection is from a configured trusted proxy. Without this middleware,
413
414
  `get_client_ip(c)` returns `'unknown'`.
414
415
 
415
416
  Must run **before** auth and rate-limiting middleware. See the root
416
- `../../CLAUDE.md` §Middleware Ordering.
417
-
418
- - `normalize_ip(ip)` — idempotent: lowercase + strip `::ffff:` prefix on
419
- IPv4-mapped IPv6 addresses; safe on non-IP strings (`'unknown'` → `'unknown'`).
420
- Subtle: only strips `::ffff:` when the suffix contains `.`, so pure
421
- IPv6 like `::ffff:1` is preserved
417
+ ../../CLAUDE.md §Middleware Ordering.
418
+
419
+ - `normalize_ip(ip)` — delegates to `canonicalize_ip` from `ip_canonical.ts`:
420
+ RFC 5952 IPv6 canonicalization (lowercase hex, longest-zero-run
421
+ compression), IPv4-mapped IPv6 emitted in dotted form and stripped of
422
+ the `::ffff:` prefix so the bucket collapses to plain IPv4. Idempotent;
423
+ safe on non-IP strings (`'unknown'` → `'unknown'`); strict char-set
424
+ filter (`IP_LITERAL_CHARS`) preserves malformed forms unchanged so
425
+ downstream `validate_ip_strict` can still reject them. Pure IPv6 like
426
+ `::ffff:1` (group[5]=0, not 0xffff — NOT IPv4-mapped) stays preserved.
427
+ Mirrors `zzz_server::proxy::normalize_ip`
422
428
  - `ProxyOptions` — `{trusted_proxies, get_connection_ip, log?}`
423
429
  - `ParsedProxy` — `{type: 'ip'; address}` or `{type: 'cidr'; network; prefix; address_type}`
424
430
  - `parse_proxy_entry(entry)` — accepts `'127.0.0.1'`, `'::1'`,
@@ -461,19 +467,29 @@ distinction in rate limiting and collapse to the proxy's connection
461
467
  IP (one bucket for everyone behind that proxy). nginx + cloud LBs
462
468
  don't include ports — bounded by operator configuration in practice.
463
469
 
464
- ### Origin/Referer allowlist — `origin.ts`
470
+ ### Origin allowlist — `origin.ts`
465
471
 
466
472
  Origin allowlisting for locally-running services — **not** the CSRF
467
473
  layer. CSRF is handled by `SameSite: strict` on session cookies (see
468
- `../auth/session_middleware.ts`).
474
+ `auth/session_middleware.ts`).
469
475
 
470
476
  - `parse_allowed_origins(env_value)` — comma-separated patterns → `Array<RegExp>`
471
477
  - `should_allow_origin(origin, patterns)` — case-insensitive match
472
478
  - `verify_request_source(allowed_patterns)` — Hono handler:
473
479
  1. `Origin` header present → must match allowlist or 403 `ERROR_FORBIDDEN_ORIGIN`
474
- 2. No `Origin` + `Referer` present → extract origin, check, 403
475
- `ERROR_FORBIDDEN_REFERER` on mismatch
476
- 3. Neither header allow through (curl, CLI, token auth is primary control)
480
+ 2. No `Origin` allow through (curl, CLI, token auth is primary control)
481
+
482
+ **Origin-only by design.** Fetch spec mandates `Origin` on every unsafe
483
+ method, so a real browser request on any state-changing surface always
484
+ carries it. Non-browser clients (curl, server-to-server, CLI) don't
485
+ ship auto-attached session cookies, so CSRF isn't the relevant threat
486
+ there — auth (bearer / daemon token) is the actual control. A `Referer`
487
+ fallback would only widen the accepted-shape envelope without closing
488
+ a real CSRF hole. Mirrors `zzz_server::auth::is_request_origin_allowed`.
489
+
490
+ `ERROR_FORBIDDEN_REFERER` stays exported from `error_schemas.ts` for
491
+ consumers whose error-schema unions or test assertions still reference
492
+ it — the emit site is gone, the constant is not.
477
493
 
478
494
  Pattern syntax:
479
495
 
@@ -664,7 +680,7 @@ emit_after_commit(ctx, () => notification_sender.send_to_account(account_id, msg
664
680
  ```
665
681
 
666
682
  Used for WS sends (`NotificationSender.send_to_account` for
667
- role-grant-offer notifications — see `../auth/CLAUDE.md` §WS notifications)
683
+ role-grant-offer notifications — see `auth/CLAUDE.md` §WS notifications)
668
684
  and any side effect that must run only after the transaction commits.
669
685
 
670
686
  ### Key properties
@@ -712,7 +728,7 @@ auth-domain dependencies:
712
728
  surface data reveals API structure (schemas, auth, routes)
713
729
 
714
730
  Auth-aware variants (account status, bootstrap status) live in
715
- `../auth/` — `common_routes.ts` stays generic.
731
+ `auth/` — `common_routes.ts` stays generic.
716
732
 
717
733
  ## DB Routes (Generic Browser)
718
734
 
@@ -743,7 +759,7 @@ Interfaces exported for consumer use: `TableInfo`, `TableWithCount`,
743
759
  ## Cross-Module Notes
744
760
 
745
761
  - **Middleware ordering** is assembled by `create_app_server` — see the
746
- root `../../CLAUDE.md` §Middleware Ordering. The invariants `http/`
762
+ root ../../CLAUDE.md §Middleware Ordering. The invariants `http/`
747
763
  needs consumers to uphold: trusted-proxy runs before auth/rate-limit;
748
764
  origin verification runs before session parsing; `client_ip` must be
749
765
  set before any handler or rate limiter reads it
@@ -753,7 +769,7 @@ Interfaces exported for consumer use: `TableInfo`, `TableWithCount`,
753
769
  - **Input/output schemas align with SAES.** When wiring RPC via
754
770
  `actions/action_rpc.ts` or bridging to `RouteSpec` via
755
771
  `actions/action_bridge.ts`, the same Zod types flow through unchanged
756
- (see `../actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint and §HTTP bridge)
772
+ (see `actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint and §HTTP bridge)
757
773
  - **Error modules are complementary, not redundant.** `error_schemas.ts`
758
774
  is Zod-first (for routes and surface); `jsonrpc_errors.ts` is
759
775
  throw-first (for handlers and the catch layer). A single `ERROR_*`
@@ -0,0 +1,99 @@
1
+ /**
2
+ * IP address canonicalization — collapse equivalent string forms into a
3
+ * single key per RFC 5952 (IPv6) plus the dotted form for IPv4-mapped
4
+ * IPv6 addresses.
5
+ *
6
+ * **Why this exists.** Without canonicalization, the four representations
7
+ * `::1`, `::01`, `::0001`, and `0:0:0:0:0:0:0:1` are the same IPv6 address
8
+ * but produce four distinct strings — so an attacker rotating
9
+ * equivalent forms behind a trusted-passthrough proxy could defeat
10
+ * per-IP rate limiting (each form gets a fresh bucket) and pollute
11
+ * `audit_log.ip` forensics. The collision can extend to IPv4-mapped
12
+ * IPv6 forms (`::ffff:127.0.0.1` vs `0:0:0:0:0:ffff:7f00:1` vs the
13
+ * bare `127.0.0.1`) — three keys for one address.
14
+ *
15
+ * Canonicalization runs through {@link canonicalize_ip} which:
16
+ *
17
+ * 1. Lowercases and char-set filters (`IP_LITERAL_CHARS`) — non-IP
18
+ * strings (`'unknown'`, `'attacker:controlled'`, `'::1\n'`) pass
19
+ * through unchanged so downstream strict validators can still
20
+ * reject them.
21
+ * 2. Parses via Hono's `convertIPv*ToBinary` family.
22
+ * 3. Re-emits the canonical RFC 5952 string (lowercase hex,
23
+ * longest-zero-run compressed, IPv4-mapped emitted in the dotted
24
+ * form mandated by RFC 5952 §5).
25
+ * 4. Strips the `::ffff:` prefix from dotted IPv4-mapped forms so the
26
+ * bucket collapses to plain IPv4 — the strip moves AFTER
27
+ * canonicalization because the dotted form is the only form the
28
+ * strip can recognize symmetrically.
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. See
33
+ * `~/dev/grimoire/lore/fuz_app/TODO_PROXY.md` §IPv6 String
34
+ * Canonicalization for the cross-backend parity record.
35
+ *
36
+ * @module
37
+ */
38
+ /**
39
+ * Allowed character set for a bare IP literal.
40
+ *
41
+ * Covers the union of IPv4 (digits + `.`), IPv6 (hex digits + `:`), and
42
+ * IPv4-mapped IPv6 forms (`::ffff:127.0.0.1`). Anything outside this
43
+ * set — brackets, whitespace, control bytes, letters g–z — disqualifies
44
+ * the input from parsing.
45
+ *
46
+ * Same regex `proxy.ts`'s `validate_ip_strict` uses; exported here so
47
+ * both modules can share one source of truth.
48
+ */
49
+ export declare const IP_LITERAL_CHARS: RegExp;
50
+ /**
51
+ * Canonicalize an IP address string.
52
+ *
53
+ * Returns the RFC 5952 canonical form for parseable IPv4 or IPv6
54
+ * input. Returns the input unchanged (only lowercased) when the input
55
+ * is non-IP (`'unknown'`), malformed (`'attacker:controlled'`,
56
+ * `'::1\n'`), or any string the strict char-set filter rejects.
57
+ *
58
+ * **Idempotent.** `canonicalize_ip(canonicalize_ip(x)) === canonicalize_ip(x)`
59
+ * for every input.
60
+ *
61
+ * **Order-safe for IPv4-mapped IPv6.** The `::ffff:` prefix strip
62
+ * runs AFTER the canonical emit because the canonical form of an
63
+ * IPv4-mapped IPv6 address is the dotted form (`::ffff:127.0.0.1`,
64
+ * not `::ffff:7f00:1`). Stripping before canonicalize would miss the
65
+ * full-hex form. Closes the
66
+ * `normalize_ipv4_mapped_collapse_is_order_safe` test from the Rust
67
+ * port.
68
+ *
69
+ * @example
70
+ * canonicalize_ip('::0001') // → '::1'
71
+ * canonicalize_ip('0:0:0:0:0:0:0:1') // → '::1'
72
+ * canonicalize_ip('2001:0DB8::0001') // → '2001:db8::1'
73
+ * canonicalize_ip('::ffff:127.0.0.1') // → '127.0.0.1'
74
+ * canonicalize_ip('0:0:0:0:0:ffff:7f00:1') // → '127.0.0.1'
75
+ * canonicalize_ip('::ffff:1') // → '::ffff:1' (NOT IPv4-mapped — group[5] is 0, not ffff)
76
+ * canonicalize_ip('127.0.0.1') // → '127.0.0.1'
77
+ * canonicalize_ip('not-an-ip') // → 'not-an-ip' (passes through)
78
+ * canonicalize_ip('::1\n') // → '::1\n' (fails char-set; passes through)
79
+ * canonicalize_ip('203.0.113.1:8080') // → '203.0.113.1:8080' (passes through; validate_ip_strict rejects)
80
+ */
81
+ export declare const canonicalize_ip: (ip: string) => string;
82
+ /**
83
+ * Convert a 128-bit IPv6 binary value into its RFC 5952 canonical string form.
84
+ *
85
+ * - IPv4-mapped (groups[0..5] = 0, groups[5] = 0xffff) emits the
86
+ * `::ffff:a.b.c.d` dotted form per RFC 5952 §5.
87
+ * - Otherwise: lowercase hex with no leading zeros per group (§4.1),
88
+ * the longest run of consecutive zero groups (≥ 2 groups) is
89
+ * replaced with `::` (§4.2.1, §4.2.3), and on equal-length runs the
90
+ * first one wins (§4.2.3). Single-zero groups stay as `0` (§4.2.2).
91
+ *
92
+ * Pure helper exported for the test suite to exercise the
93
+ * canonicalization invariants directly without a full
94
+ * `convertIPv6ToBinary` round-trip.
95
+ *
96
+ * @param bits - the 128-bit IPv6 value as `bigint` (only the low 128 bits are read)
97
+ */
98
+ export declare const ipv6_bigint_to_canonical: (bits: bigint) => string;
99
+ //# sourceMappingURL=ip_canonical.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip_canonical.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/ip_canonical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAIH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,QAAqB,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAmC5C,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,MAAM,KAAG,MA6DvD,CAAC"}
@@ -0,0 +1,191 @@
1
+ /**
2
+ * IP address canonicalization — collapse equivalent string forms into a
3
+ * single key per RFC 5952 (IPv6) plus the dotted form for IPv4-mapped
4
+ * IPv6 addresses.
5
+ *
6
+ * **Why this exists.** Without canonicalization, the four representations
7
+ * `::1`, `::01`, `::0001`, and `0:0:0:0:0:0:0:1` are the same IPv6 address
8
+ * but produce four distinct strings — so an attacker rotating
9
+ * equivalent forms behind a trusted-passthrough proxy could defeat
10
+ * per-IP rate limiting (each form gets a fresh bucket) and pollute
11
+ * `audit_log.ip` forensics. The collision can extend to IPv4-mapped
12
+ * IPv6 forms (`::ffff:127.0.0.1` vs `0:0:0:0:0:ffff:7f00:1` vs the
13
+ * bare `127.0.0.1`) — three keys for one address.
14
+ *
15
+ * Canonicalization runs through {@link canonicalize_ip} which:
16
+ *
17
+ * 1. Lowercases and char-set filters (`IP_LITERAL_CHARS`) — non-IP
18
+ * strings (`'unknown'`, `'attacker:controlled'`, `'::1\n'`) pass
19
+ * through unchanged so downstream strict validators can still
20
+ * reject them.
21
+ * 2. Parses via Hono's `convertIPv*ToBinary` family.
22
+ * 3. Re-emits the canonical RFC 5952 string (lowercase hex,
23
+ * longest-zero-run compressed, IPv4-mapped emitted in the dotted
24
+ * form mandated by RFC 5952 §5).
25
+ * 4. Strips the `::ffff:` prefix from dotted IPv4-mapped forms so the
26
+ * bucket collapses to plain IPv4 — the strip moves AFTER
27
+ * canonicalization because the dotted form is the only form the
28
+ * strip can recognize symmetrically.
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. See
33
+ * `~/dev/grimoire/lore/fuz_app/TODO_PROXY.md` §IPv6 String
34
+ * Canonicalization for the cross-backend parity record.
35
+ *
36
+ * @module
37
+ */
38
+ import { convertIPv6ToBinary, distinctRemoteAddr } from 'hono/utils/ipaddr';
39
+ /**
40
+ * Allowed character set for a bare IP literal.
41
+ *
42
+ * Covers the union of IPv4 (digits + `.`), IPv6 (hex digits + `:`), and
43
+ * IPv4-mapped IPv6 forms (`::ffff:127.0.0.1`). Anything outside this
44
+ * set — brackets, whitespace, control bytes, letters g–z — disqualifies
45
+ * the input from parsing.
46
+ *
47
+ * Same regex `proxy.ts`'s `validate_ip_strict` uses; exported here so
48
+ * both modules can share one source of truth.
49
+ */
50
+ export const IP_LITERAL_CHARS = /^[0-9a-fA-F.:]+$/;
51
+ /**
52
+ * Canonicalize an IP address string.
53
+ *
54
+ * Returns the RFC 5952 canonical form for parseable IPv4 or IPv6
55
+ * input. Returns the input unchanged (only lowercased) when the input
56
+ * is non-IP (`'unknown'`), malformed (`'attacker:controlled'`,
57
+ * `'::1\n'`), or any string the strict char-set filter rejects.
58
+ *
59
+ * **Idempotent.** `canonicalize_ip(canonicalize_ip(x)) === canonicalize_ip(x)`
60
+ * for every input.
61
+ *
62
+ * **Order-safe for IPv4-mapped IPv6.** The `::ffff:` prefix strip
63
+ * runs AFTER the canonical emit because the canonical form of an
64
+ * IPv4-mapped IPv6 address is the dotted form (`::ffff:127.0.0.1`,
65
+ * not `::ffff:7f00:1`). Stripping before canonicalize would miss the
66
+ * full-hex form. Closes the
67
+ * `normalize_ipv4_mapped_collapse_is_order_safe` test from the Rust
68
+ * port.
69
+ *
70
+ * @example
71
+ * canonicalize_ip('::0001') // → '::1'
72
+ * canonicalize_ip('0:0:0:0:0:0:0:1') // → '::1'
73
+ * canonicalize_ip('2001:0DB8::0001') // → '2001:db8::1'
74
+ * canonicalize_ip('::ffff:127.0.0.1') // → '127.0.0.1'
75
+ * canonicalize_ip('0:0:0:0:0:ffff:7f00:1') // → '127.0.0.1'
76
+ * canonicalize_ip('::ffff:1') // → '::ffff:1' (NOT IPv4-mapped — group[5] is 0, not ffff)
77
+ * canonicalize_ip('127.0.0.1') // → '127.0.0.1'
78
+ * canonicalize_ip('not-an-ip') // → 'not-an-ip' (passes through)
79
+ * canonicalize_ip('::1\n') // → '::1\n' (fails char-set; passes through)
80
+ * canonicalize_ip('203.0.113.1:8080') // → '203.0.113.1:8080' (passes through; validate_ip_strict rejects)
81
+ */
82
+ export const canonicalize_ip = (ip) => {
83
+ const lowered = ip.toLowerCase();
84
+ // Strict char-set filter — reject brackets, whitespace, control bytes,
85
+ // letters g-z before invoking the parser. Hono's `convertIPv6ToBinary`
86
+ // silently accepts `'::1\n'` and similar; canonicalizing those would
87
+ // erase the malformed form so downstream `validate_ip_strict` could no
88
+ // longer reject it. Pass-through preserves the original string.
89
+ if (!IP_LITERAL_CHARS.test(lowered))
90
+ return lowered;
91
+ const family = distinctRemoteAddr(lowered);
92
+ if (family === 'IPv4') {
93
+ // IPv4's dotted-decimal form is already canonical — no transform
94
+ // needed. Malformed forms (`999.999.999.999`) still pass through
95
+ // here; downstream `validate_ip_strict` rejects them via its own
96
+ // round-trip parse.
97
+ return lowered;
98
+ }
99
+ if (family === 'IPv6') {
100
+ try {
101
+ const bits = convertIPv6ToBinary(lowered);
102
+ const canonical = ipv6_bigint_to_canonical(bits);
103
+ // Strip `::ffff:` only when the canonical form is dotted
104
+ // IPv4-mapped (`::ffff:X.X.X.X`). Pure IPv6 values that happen
105
+ // to start with `::ffff:` (e.g. `::ffff:1` → `0:0:0:0:0:0:ffff:1`,
106
+ // where group[5] is 0 not 0xffff) emit without the dot and
107
+ // are preserved.
108
+ if (canonical.startsWith('::ffff:') && canonical.substring(7).includes('.')) {
109
+ return canonical.substring(7);
110
+ }
111
+ return canonical;
112
+ }
113
+ catch {
114
+ return lowered;
115
+ }
116
+ }
117
+ return lowered;
118
+ };
119
+ /**
120
+ * Convert a 128-bit IPv6 binary value into its RFC 5952 canonical string form.
121
+ *
122
+ * - IPv4-mapped (groups[0..5] = 0, groups[5] = 0xffff) emits the
123
+ * `::ffff:a.b.c.d` dotted form per RFC 5952 §5.
124
+ * - Otherwise: lowercase hex with no leading zeros per group (§4.1),
125
+ * the longest run of consecutive zero groups (≥ 2 groups) is
126
+ * replaced with `::` (§4.2.1, §4.2.3), and on equal-length runs the
127
+ * first one wins (§4.2.3). Single-zero groups stay as `0` (§4.2.2).
128
+ *
129
+ * Pure helper exported for the test suite to exercise the
130
+ * canonicalization invariants directly without a full
131
+ * `convertIPv6ToBinary` round-trip.
132
+ *
133
+ * @param bits - the 128-bit IPv6 value as `bigint` (only the low 128 bits are read)
134
+ */
135
+ export const ipv6_bigint_to_canonical = (bits) => {
136
+ // Split into 8 16-bit groups, big-endian (group[0] is the high-order group).
137
+ const groups = new Array(8);
138
+ let remaining = bits;
139
+ for (let i = 7; i >= 0; i--) {
140
+ groups[i] = Number(remaining & 0xffffn);
141
+ remaining >>= 16n;
142
+ }
143
+ // IPv4-mapped detection: leading 80 bits zero, next 16 bits 0xffff.
144
+ if (groups[0] === 0 &&
145
+ groups[1] === 0 &&
146
+ groups[2] === 0 &&
147
+ groups[3] === 0 &&
148
+ groups[4] === 0 &&
149
+ groups[5] === 0xffff) {
150
+ const high = groups[6];
151
+ const low = groups[7];
152
+ const a = (high >> 8) & 0xff;
153
+ const b = high & 0xff;
154
+ const c = (low >> 8) & 0xff;
155
+ const d = low & 0xff;
156
+ return `::ffff:${a}.${b}.${c}.${d}`;
157
+ }
158
+ // Find longest run of consecutive zero groups for `::` compression.
159
+ // RFC 5952 §4.2.1: only compress runs of two or more.
160
+ // RFC 5952 §4.2.3: on ties, compress the first run.
161
+ let best_start = -1;
162
+ let best_len = 0;
163
+ let cur_start = -1;
164
+ let cur_len = 0;
165
+ for (let i = 0; i < 8; i++) {
166
+ if (groups[i] === 0) {
167
+ if (cur_start === -1)
168
+ cur_start = i;
169
+ cur_len++;
170
+ if (cur_len > best_len) {
171
+ best_start = cur_start;
172
+ best_len = cur_len;
173
+ }
174
+ }
175
+ else {
176
+ cur_start = -1;
177
+ cur_len = 0;
178
+ }
179
+ }
180
+ const to_hex = (g) => g.toString(16);
181
+ // RFC 5952 §4.2.2 — never compress a single zero group.
182
+ if (best_len < 2) {
183
+ return groups.map(to_hex).join(':');
184
+ }
185
+ const before = groups.slice(0, best_start).map(to_hex).join(':');
186
+ const after = groups
187
+ .slice(best_start + best_len)
188
+ .map(to_hex)
189
+ .join(':');
190
+ return before + '::' + after;
191
+ };
@@ -35,16 +35,24 @@ export declare const parse_allowed_origins: (env_value: string | undefined) => A
35
35
  * Tests if a request source (origin or referer) matches any of the allowed patterns.
36
36
  * Pattern matching is case-insensitive for domains (as per web standards).
37
37
  */
38
- export declare const should_allow_origin: (origin: string, allowed_patterns: Array<RegExp>) => boolean;
38
+ export declare const should_allow_origin: (origin: string, allowed_patterns: ReadonlyArray<RegExp>) => boolean;
39
39
  /**
40
40
  * Middleware that verifies the request source against an allowlist.
41
41
  *
42
42
  * Origin allowlisting (not the CSRF layer — that's `SameSite: strict` cookies):
43
- * - Checks the `Origin` header first (if present)
44
- * - Falls back to `Referer` header (if no `Origin`)
45
- * - Allows requests without `Origin`/`Referer` headers (direct access, curl, etc.)
43
+ * - Checks the `Origin` header (if present) against the allowlist
44
+ * - Allows requests without an `Origin` header (direct access, curl, etc.)
45
+ *
46
+ * Origin-only by design — Fetch spec mandates `Origin` on every unsafe
47
+ * method (POST / PUT / DELETE / PATCH) regardless of `Referrer-Policy`,
48
+ * so every real browser request on the state-changing surface carries it.
49
+ * Non-browser clients (curl, server-to-server, CLI) don't ship auto-
50
+ * attached session cookies, so CSRF isn't the relevant threat there —
51
+ * auth (bearer / daemon token) is the actual control. A Referer fallback
52
+ * would only widen the accepted-shape envelope without closing a real
53
+ * CSRF hole; mirrors `zzz_server::auth::is_request_origin_allowed`.
46
54
  *
47
55
  * @param allowed_patterns - compiled regex patterns from `parse_allowed_origins`
48
56
  */
49
- export declare const verify_request_source: (allowed_patterns: Array<RegExp>) => Handler;
57
+ export declare const verify_request_source: (allowed_patterns: ReadonlyArray<RegExp>) => Handler;
50
58
  //# sourceMappingURL=origin.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"origin.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/origin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,MAAM,CAAC;AAIlC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,SAAS,KAAG,KAAK,CAAC,MAAM,CAO5E,CAAC;AAEP;;;GAGG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,MAAM,EAAE,kBAAkB,KAAK,CAAC,MAAM,CAAC,KAAG,OACzC,CAAC;AAE9C;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,GAChC,kBAAkB,KAAK,CAAC,MAAM,CAAC,KAAG,OA2BlC,CAAC"}
1
+ {"version":3,"file":"origin.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/origin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,MAAM,CAAC;AAIlC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,SAAS,KAAG,KAAK,CAAC,MAAM,CAO5E,CAAC;AAEP;;;GAGG;AACH,eAAO,MAAM,mBAAmB,GAC/B,QAAQ,MAAM,EACd,kBAAkB,aAAa,CAAC,MAAM,CAAC,KACrC,OAAuD,CAAC;AAE3D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,GAChC,kBAAkB,aAAa,CAAC,MAAM,CAAC,KAAG,OAgB1C,CAAC"}
@@ -10,7 +10,7 @@
10
10
  * @module
11
11
  */
12
12
  import { escape_regexp } from '@fuzdev/fuz_util/regexp.js';
13
- import { ERROR_FORBIDDEN_ORIGIN, ERROR_FORBIDDEN_REFERER } from './error_schemas.js';
13
+ import { ERROR_FORBIDDEN_ORIGIN } from './error_schemas.js';
14
14
  /**
15
15
  * Parses ALLOWED_ORIGINS env var into regex matchers for request source verification.
16
16
  * Origin allowlisting for locally-running services — not the CSRF layer
@@ -47,9 +47,17 @@ export const should_allow_origin = (origin, allowed_patterns) => allowed_pattern
47
47
  * Middleware that verifies the request source against an allowlist.
48
48
  *
49
49
  * Origin allowlisting (not the CSRF layer — that's `SameSite: strict` cookies):
50
- * - Checks the `Origin` header first (if present)
51
- * - Falls back to `Referer` header (if no `Origin`)
52
- * - Allows requests without `Origin`/`Referer` headers (direct access, curl, etc.)
50
+ * - Checks the `Origin` header (if present) against the allowlist
51
+ * - Allows requests without an `Origin` header (direct access, curl, etc.)
52
+ *
53
+ * Origin-only by design — Fetch spec mandates `Origin` on every unsafe
54
+ * method (POST / PUT / DELETE / PATCH) regardless of `Referrer-Policy`,
55
+ * so every real browser request on the state-changing surface carries it.
56
+ * Non-browser clients (curl, server-to-server, CLI) don't ship auto-
57
+ * attached session cookies, so CSRF isn't the relevant threat there —
58
+ * auth (bearer / daemon token) is the actual control. A Referer fallback
59
+ * would only widen the accepted-shape envelope without closing a real
60
+ * CSRF hole; mirrors `zzz_server::auth::is_request_origin_allowed`.
53
61
  *
54
62
  * @param allowed_patterns - compiled regex patterns from `parse_allowed_origins`
55
63
  */
@@ -64,17 +72,7 @@ export const verify_request_source = (allowed_patterns) => (c, next) => {
64
72
  }
65
73
  return next();
66
74
  }
67
- // Check referer header (fallback for some requests like gets and navigation).
68
- // Same !== undefined check as origin.
69
- const referer = c.req.header('referer');
70
- if (referer !== undefined) {
71
- const referer_origin = extract_origin_from_referer(referer);
72
- if (!should_allow_origin(referer_origin, allowed_patterns)) {
73
- return c.json({ error: ERROR_FORBIDDEN_REFERER }, 403);
74
- }
75
- return next();
76
- }
77
- // No origin or referer - direct access (curl, CLI, etc.)
75
+ // No origin header - direct access (curl, CLI, etc.)
78
76
  // Allow through since token auth is the primary security control.
79
77
  return next();
80
78
  };
@@ -182,19 +180,3 @@ const origin_pattern_to_regexp = (pattern) => {
182
180
  // Case-insensitive matching (web standards specify domains are case-insensitive)
183
181
  return new RegExp(regex_pattern, 'i');
184
182
  };
185
- /**
186
- * Extracts the origin from a referer URL, removing the path, query string, and fragment.
187
- *
188
- * @param referer - the referer URL (e.g., `https://fuz.dev/path?query#hash`)
189
- * @returns the origin part (e.g., `https://fuz.dev`)
190
- */
191
- const extract_origin_from_referer = (referer) => {
192
- try {
193
- return new URL(referer).origin;
194
- }
195
- catch {
196
- // If URL parsing fails, return the original string
197
- // (it will likely fail pattern matching anyway)
198
- return referer;
199
- }
200
- };
@@ -47,7 +47,7 @@ export interface EmitAfterCommitContext {
47
47
  * middleware (in `server/app_server.ts` and the per-message WS dispatcher)
48
48
  * is the only site that ever invokes `fn`. This is load-bearing: a
49
49
  * previous implementation queued `Promise.resolve().then(fn)`, which
50
- * JavaScript's microtask scheduler drains before the wrapping
50
+ * JS's microtask scheduler drains before the wrapping
51
51
  * `await db.query('COMMIT')` resumes — `fn` fired mid-transaction and a
52
52
  * rollback would leak a notification for state that never landed.
53
53
  *
@@ -37,7 +37,7 @@
37
37
  * middleware (in `server/app_server.ts` and the per-message WS dispatcher)
38
38
  * is the only site that ever invokes `fn`. This is load-bearing: a
39
39
  * previous implementation queued `Promise.resolve().then(fn)`, which
40
- * JavaScript's microtask scheduler drains before the wrapping
40
+ * JS's microtask scheduler drains before the wrapping
41
41
  * `await db.query('COMMIT')` resumes — `fn` fired mid-transaction and a
42
42
  * rollback would leak a notification for state that never landed.
43
43
  *
@@ -13,11 +13,19 @@ import type { MiddlewareSpec } from './middleware_spec.js';
13
13
  /**
14
14
  * Normalize an IP address for consistent matching and storage.
15
15
  *
16
- * - Strips `::ffff:` prefix from IPv4-mapped IPv6 addresses
17
- * (e.g. `::ffff:127.0.0.1` `127.0.0.1`)
18
- * - Lowercases for case-insensitive IPv6 comparison
19
- * - Idempotent: calling twice produces the same result
20
- * - Safe on non-IP strings: `normalize_ip('unknown')` returns `'unknown'`
16
+ * Delegates to `canonicalize_ip` from `ip_canonical.ts` collapses
17
+ * RFC 5952-equivalent IPv6 forms (`::1`, `::0001`, `0:0:0:0:0:0:0:1`)
18
+ * into a single key, emits IPv4-mapped IPv6 in dotted form, and
19
+ * strips the `::ffff:` prefix from dotted IPv4-mapped values so the
20
+ * bucket collapses to plain IPv4.
21
+ *
22
+ * - Lowercases for case-insensitive IPv6 comparison.
23
+ * - Idempotent: calling twice produces the same result.
24
+ * - Safe on non-IP strings: `normalize_ip('unknown')` returns `'unknown'`.
25
+ * Malformed inputs (`'attacker:controlled'`, `'::1\n'`,
26
+ * `'203.0.113.1:8080'`) pass through unchanged so downstream
27
+ * `validate_ip_strict` can still reject them — canonicalization
28
+ * never erases the malformed-form signal.
21
29
  */
22
30
  export declare const normalize_ip: (ip: string) => string;
23
31
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAEzD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GAAI,IAAI,MAAM,KAAG,MAQzC,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,+DAA+D;IAC/D,iBAAiB,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GACpB;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAC,CAAC;AAElF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,WA6CjD,CAAC;AA2BF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,KAAG,MAAM,GAAG,MAAM,GAAG,SAWjE,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,EAAE,SAAS,KAAK,CAAC,WAAW,CAAC,KAAG,OAqBvE,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,iBAAiB,GAC7B,eAAe,MAAM,EACrB,SAAS,KAAK,CAAC,WAAW,CAAC,KACzB,MAAM,GAAG,SA0BX,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,YAAY,KAAG,iBAyC/D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,YAAY,KAAG,cAInE,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,GAAG,OAAO,KAAG,MAAyC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAGzD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,IAAI,MAAM,KAAG,MAA6B,CAAC;AAExE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,+DAA+D;IAC/D,iBAAiB,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GACpB;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAC,CAAC;AAElF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,WA6CjD,CAAC;AAiBF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,KAAG,MAAM,GAAG,MAAM,GAAG,SAWjE,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,EAAE,SAAS,KAAK,CAAC,WAAW,CAAC,KAAG,OAqBvE,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,iBAAiB,GAC7B,eAAe,MAAM,EACrB,SAAS,KAAK,CAAC,WAAW,CAAC,KACzB,MAAM,GAAG,SA0BX,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,YAAY,KAAG,iBAyC/D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,YAAY,KAAG,cAInE,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,GAAG,OAAO,KAAG,MAAyC,CAAC"}