@fuzdev/fuz_app 0.63.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.
- package/dist/actions/CLAUDE.md +124 -11
- package/dist/actions/connection_closer.d.ts +68 -0
- package/dist/actions/connection_closer.d.ts.map +1 -0
- package/dist/actions/connection_closer.js +41 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +23 -2
- package/dist/actions/register_ws_endpoint.d.ts +11 -9
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -5
- package/dist/actions/transports_ws_auth_guard.d.ts +24 -8
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +23 -7
- package/dist/actions/ws_endpoint_spec.d.ts +119 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -0
- package/dist/actions/ws_endpoint_spec.js +13 -0
- package/dist/auth/CLAUDE.md +79 -15
- package/dist/auth/account_action_specs.d.ts +1 -1
- package/dist/auth/account_actions.d.ts +13 -0
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +31 -1
- package/dist/auth/account_routes.d.ts +12 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +55 -8
- package/dist/auth/account_schema.d.ts +3 -3
- package/dist/auth/admin_action_specs.d.ts +8 -8
- package/dist/auth/admin_actions.d.ts +11 -0
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +25 -0
- package/dist/auth/audit_emitter.d.ts +56 -12
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +38 -12
- package/dist/auth/audit_log_schema.d.ts +5 -3
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +5 -3
- package/dist/auth/bootstrap_routes.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts +2 -2
- package/dist/auth/signup_routes.d.ts +1 -1
- package/dist/auth/standard_rpc_actions.d.ts +1 -0
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +1 -0
- package/dist/http/CLAUDE.md +26 -10
- package/dist/http/ip_canonical.d.ts +99 -0
- package/dist/http/ip_canonical.d.ts.map +1 -0
- package/dist/http/ip_canonical.js +191 -0
- package/dist/http/origin.d.ts +13 -5
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +13 -31
- package/dist/http/pending_effects.d.ts +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/http/proxy.d.ts +13 -5
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +15 -23
- package/dist/http/surface.d.ts +50 -0
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +27 -1
- package/dist/primitive_schemas.d.ts +20 -4
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +25 -4
- package/dist/realtime/sse_auth_guard.d.ts +16 -4
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +15 -3
- package/dist/server/app_backend.d.ts +66 -19
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +57 -34
- package/dist/server/app_server.d.ts +60 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +95 -2
- package/dist/server/startup.d.ts.map +1 -1
- package/dist/server/startup.js +12 -0
- package/dist/testing/CLAUDE.md +64 -28
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +4 -5
- package/dist/testing/adversarial_headers.d.ts +6 -0
- package/dist/testing/adversarial_headers.d.ts.map +1 -1
- package/dist/testing/adversarial_headers.js +13 -5
- package/dist/testing/app_server.d.ts +33 -32
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +4 -13
- package/dist/testing/attack_surface.d.ts +8 -7
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +12 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +3 -5
- package/dist/testing/audit_drift_guard.d.ts +116 -0
- package/dist/testing/audit_drift_guard.d.ts.map +1 -0
- package/dist/testing/audit_drift_guard.js +134 -0
- package/dist/testing/connection_closer_helpers.d.ts +44 -0
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -0
- package/dist/testing/connection_closer_helpers.js +48 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +7 -9
- package/dist/testing/rate_limiting.js +4 -4
- package/dist/testing/rpc_helpers.d.ts +2 -1
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +6 -8
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +12 -6
- package/dist/testing/stubs.d.ts +11 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +4 -0
- package/dist/testing/surface_invariants.d.ts +66 -1
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/surface_invariants.js +103 -1
- package/dist/ui/SurfaceExplorer.svelte +161 -2
- package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/http/origin.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
44
|
-
* -
|
|
45
|
-
*
|
|
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:
|
|
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,
|
|
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"}
|
package/dist/http/origin.js
CHANGED
|
@@ -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
|
|
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
|
|
51
|
-
* -
|
|
52
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
*
|
package/dist/http/proxy.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
/**
|
package/dist/http/proxy.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/http/proxy.js
CHANGED
|
@@ -8,24 +8,25 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import { convertIPv4ToBinary, convertIPv6ToBinary, distinctRemoteAddr } from 'hono/utils/ipaddr';
|
|
11
|
+
import { canonicalize_ip, IP_LITERAL_CHARS } from './ip_canonical.js';
|
|
11
12
|
/**
|
|
12
13
|
* Normalize an IP address for consistent matching and storage.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Delegates to `canonicalize_ip` from `ip_canonical.ts` — collapses
|
|
16
|
+
* RFC 5952-equivalent IPv6 forms (`::1`, `::0001`, `0:0:0:0:0:0:0:1`)
|
|
17
|
+
* into a single key, emits IPv4-mapped IPv6 in dotted form, and
|
|
18
|
+
* strips the `::ffff:` prefix from dotted IPv4-mapped values so the
|
|
19
|
+
* bucket collapses to plain IPv4.
|
|
20
|
+
*
|
|
21
|
+
* - Lowercases for case-insensitive IPv6 comparison.
|
|
22
|
+
* - Idempotent: calling twice produces the same result.
|
|
23
|
+
* - Safe on non-IP strings: `normalize_ip('unknown')` returns `'unknown'`.
|
|
24
|
+
* Malformed inputs (`'attacker:controlled'`, `'::1\n'`,
|
|
25
|
+
* `'203.0.113.1:8080'`) pass through unchanged so downstream
|
|
26
|
+
* `validate_ip_strict` can still reject them — canonicalization
|
|
27
|
+
* never erases the malformed-form signal.
|
|
19
28
|
*/
|
|
20
|
-
export const normalize_ip = (ip) =>
|
|
21
|
-
const lowered = ip.toLowerCase();
|
|
22
|
-
// Strip ::ffff: prefix only when remainder contains a dot (IPv4-mapped IPv6).
|
|
23
|
-
// This distinguishes ::ffff:127.0.0.1 (IPv4-mapped) from ::ffff:1 (pure IPv6).
|
|
24
|
-
if (lowered.startsWith('::ffff:') && lowered.substring(7).includes('.')) {
|
|
25
|
-
return lowered.substring(7);
|
|
26
|
-
}
|
|
27
|
-
return lowered;
|
|
28
|
-
};
|
|
29
|
+
export const normalize_ip = (ip) => canonicalize_ip(ip);
|
|
29
30
|
/**
|
|
30
31
|
* Parse a trusted proxy entry string into a structured form.
|
|
31
32
|
*
|
|
@@ -91,15 +92,6 @@ const cidr_contains = (ip_binary, network, prefix, total_bits) => {
|
|
|
91
92
|
const shift = BigInt(total_bits - prefix);
|
|
92
93
|
return ip_binary >> shift === network >> shift;
|
|
93
94
|
};
|
|
94
|
-
/**
|
|
95
|
-
* Allowed character set for a bare IP literal.
|
|
96
|
-
*
|
|
97
|
-
* Covers the union of IPv4 (digits + `.`), IPv6 (hex digits + `:`), and
|
|
98
|
-
* IPv4-mapped IPv6 forms (`::ffff:127.0.0.1`). Anything outside this
|
|
99
|
-
* set — brackets, whitespace, control bytes, letters g-z — disqualifies
|
|
100
|
-
* the input regardless of what Hono's parser does with it.
|
|
101
|
-
*/
|
|
102
|
-
const IP_LITERAL_CHARS = /^[0-9a-fA-F.:]+$/;
|
|
103
95
|
/**
|
|
104
96
|
* Strict IP validity check.
|
|
105
97
|
*
|
package/dist/http/surface.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { RouteSpec } from './route_spec.js';
|
|
|
13
13
|
import type { RouteAuth } from './auth_shape.js';
|
|
14
14
|
import type { RateLimitKey, RouteErrorSchemas } from './error_schemas.js';
|
|
15
15
|
import type { RpcAction } from '../actions/action_rpc.js';
|
|
16
|
+
import type { ActionKind } from '../actions/action_spec.js';
|
|
17
|
+
import type { WsEndpointSpec } from '../actions/ws_endpoint_spec.js';
|
|
16
18
|
import type { Sensitivity } from '../sensitivity.js';
|
|
17
19
|
/** A route in the generated attack surface (JSON-serializable). */
|
|
18
20
|
export interface AppSurfaceRoute {
|
|
@@ -79,6 +81,45 @@ export interface AppSurfaceRpcEndpoint {
|
|
|
79
81
|
path: string;
|
|
80
82
|
methods: Array<AppSurfaceRpcMethod>;
|
|
81
83
|
}
|
|
84
|
+
/** A method within a WebSocket endpoint in the generated attack surface (JSON-serializable). */
|
|
85
|
+
export interface AppSurfaceWsMethod {
|
|
86
|
+
name: string;
|
|
87
|
+
/** `request_response` (inbound dispatch) or `remote_notification` (server → client). */
|
|
88
|
+
kind: ActionKind;
|
|
89
|
+
/**
|
|
90
|
+
* Per-action auth shape. `null` for `remote_notification` (server →
|
|
91
|
+
* client) — notifications have no inbound dispatch and therefore no
|
|
92
|
+
* auth axis. `request_response` always carries a `RouteAuth`.
|
|
93
|
+
*/
|
|
94
|
+
auth: RouteAuth | null;
|
|
95
|
+
/** JSON Schema of the input schema. `null` for nullary inputs. */
|
|
96
|
+
input_schema: unknown;
|
|
97
|
+
/** JSON Schema of the output schema. */
|
|
98
|
+
output_schema: unknown;
|
|
99
|
+
description: string;
|
|
100
|
+
side_effects: boolean;
|
|
101
|
+
/** Rate limit key declared on the action spec. `null` when not rate-limited. */
|
|
102
|
+
rate_limit_key: RateLimitKey | null;
|
|
103
|
+
}
|
|
104
|
+
/** A WebSocket endpoint in the generated attack surface (JSON-serializable). */
|
|
105
|
+
export interface AppSurfaceWsEndpoint {
|
|
106
|
+
path: string;
|
|
107
|
+
/**
|
|
108
|
+
* Upgrade-time origin allowlist, one entry per `WsEndpointSpec.allowed_origins`
|
|
109
|
+
* regex stringified via `RegExp.prototype.toString()` (`'/<source>/<flags>'`).
|
|
110
|
+
* Empty array when no origins were declared (any-origin); reviewers read this
|
|
111
|
+
* as the exact pattern matched at the upgrade gate, not a wildcard
|
|
112
|
+
* approximation. Reconstruct via `new RegExp(source, flags)` if needed.
|
|
113
|
+
*/
|
|
114
|
+
allowed_origins: ReadonlyArray<string>;
|
|
115
|
+
/**
|
|
116
|
+
* Upgrade-time role gate — empty array when no `required_roles` was
|
|
117
|
+
* declared (any-authenticated). Documents the coarse gate; per-action
|
|
118
|
+
* `auth` on each method covers per-message authorization.
|
|
119
|
+
*/
|
|
120
|
+
required_roles: ReadonlyArray<string>;
|
|
121
|
+
methods: Array<AppSurfaceWsMethod>;
|
|
122
|
+
}
|
|
82
123
|
/** Assembly-time diagnostic collected during surface generation or server assembly. */
|
|
83
124
|
export interface AppSurfaceDiagnostic {
|
|
84
125
|
level: 'warning' | 'info';
|
|
@@ -91,6 +132,7 @@ export interface AppSurface {
|
|
|
91
132
|
middleware: Array<AppSurfaceMiddleware>;
|
|
92
133
|
routes: Array<AppSurfaceRoute>;
|
|
93
134
|
rpc_endpoints: Array<AppSurfaceRpcEndpoint>;
|
|
135
|
+
ws_endpoints: Array<AppSurfaceWsEndpoint>;
|
|
94
136
|
env: Array<AppSurfaceEnv>;
|
|
95
137
|
events: Array<AppSurfaceEvent>;
|
|
96
138
|
diagnostics: Array<AppSurfaceDiagnostic>;
|
|
@@ -106,6 +148,7 @@ export interface AppSurfaceSpec {
|
|
|
106
148
|
route_specs: Array<RouteSpec>;
|
|
107
149
|
middleware_specs: Array<MiddlewareSpec>;
|
|
108
150
|
rpc_endpoints: Array<RpcEndpointSpec>;
|
|
151
|
+
ws_endpoints: Array<WsEndpointSpec>;
|
|
109
152
|
}
|
|
110
153
|
/** An RPC endpoint definition for surface generation. */
|
|
111
154
|
export interface RpcEndpointSpec {
|
|
@@ -119,6 +162,13 @@ export interface GenerateAppSurfaceOptions {
|
|
|
119
162
|
env_schema?: z.ZodObject;
|
|
120
163
|
event_specs?: Array<EventSpec>;
|
|
121
164
|
rpc_endpoints?: Array<RpcEndpointSpec>;
|
|
165
|
+
/**
|
|
166
|
+
* Mounted WS endpoints (the same array `create_app_server.ws_endpoints`
|
|
167
|
+
* auto-mounts). Each entry's actions surface into
|
|
168
|
+
* `AppSurface.ws_endpoints[i].methods` for attack-surface tests +
|
|
169
|
+
* startup logging.
|
|
170
|
+
*/
|
|
171
|
+
ws_endpoints?: ReadonlyArray<WsEndpointSpec>;
|
|
122
172
|
}
|
|
123
173
|
/**
|
|
124
174
|
* Collect error schemas from all middleware that applies to a route path.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/surface.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAC,YAAY,EAAE,iBAAiB,EAAC,MAAM,oBAAoB,CAAC;AACxE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/surface.ts"],"names":[],"mappings":"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"}
|
package/dist/http/surface.js
CHANGED
|
@@ -60,7 +60,7 @@ export const events_to_surface = (event_specs) => {
|
|
|
60
60
|
* and optional env/event metadata.
|
|
61
61
|
*/
|
|
62
62
|
export const generate_app_surface = (options) => {
|
|
63
|
-
const { route_specs, middleware_specs, env_schema, event_specs, rpc_endpoints } = options;
|
|
63
|
+
const { route_specs, middleware_specs, env_schema, event_specs, rpc_endpoints, ws_endpoints } = options;
|
|
64
64
|
const diagnostics = [];
|
|
65
65
|
// Spec-level diagnostics: check for non-strict input schemas
|
|
66
66
|
for (const r of route_specs) {
|
|
@@ -141,6 +141,31 @@ export const generate_app_surface = (options) => {
|
|
|
141
141
|
})),
|
|
142
142
|
}))
|
|
143
143
|
: [],
|
|
144
|
+
ws_endpoints: ws_endpoints?.length
|
|
145
|
+
? ws_endpoints.map((ep) => ({
|
|
146
|
+
path: ep.path,
|
|
147
|
+
allowed_origins: ep.allowed_origins.map((re) => re.toString()),
|
|
148
|
+
required_roles: ep.required_roles ?? [],
|
|
149
|
+
// `local_call` specs are frontend-side helpers — registry-only
|
|
150
|
+
// on the backend, never dispatched over WS. Drop them from the
|
|
151
|
+
// surface so attack-surface tests reflect dispatchable methods
|
|
152
|
+
// only. Notifications are kept (server → client emit).
|
|
153
|
+
methods: ep.actions
|
|
154
|
+
.filter((a) => a.spec.kind !== 'local_call')
|
|
155
|
+
.map((a) => ({
|
|
156
|
+
name: a.spec.method,
|
|
157
|
+
kind: a.spec.kind,
|
|
158
|
+
// `request_response` carries a `RouteAuth`; notifications
|
|
159
|
+
// have `auth: null` (server-pushed, no inbound dispatch).
|
|
160
|
+
auth: a.spec.auth,
|
|
161
|
+
input_schema: schema_to_surface(a.spec.input),
|
|
162
|
+
output_schema: schema_to_surface(a.spec.output),
|
|
163
|
+
description: a.spec.description,
|
|
164
|
+
side_effects: a.spec.side_effects,
|
|
165
|
+
rate_limit_key: a.spec.kind === 'request_response' ? (a.spec.rate_limit ?? null) : null,
|
|
166
|
+
})),
|
|
167
|
+
}))
|
|
168
|
+
: [],
|
|
144
169
|
env: env_schema ? env_schema_to_surface(env_schema) : [],
|
|
145
170
|
events: event_specs?.length ? events_to_surface(event_specs) : [],
|
|
146
171
|
};
|
|
@@ -155,5 +180,6 @@ export const create_app_surface_spec = (options) => {
|
|
|
155
180
|
route_specs: options.route_specs,
|
|
156
181
|
middleware_specs: options.middleware_specs,
|
|
157
182
|
rpc_endpoints: options.rpc_endpoints ?? [],
|
|
183
|
+
ws_endpoints: options.ws_endpoints ? [...options.ws_endpoints] : [],
|
|
158
184
|
};
|
|
159
185
|
};
|
|
@@ -21,11 +21,27 @@ export declare const USERNAME_LENGTH_MIN = 3;
|
|
|
21
21
|
export declare const USERNAME_LENGTH_MAX = 39;
|
|
22
22
|
/** Maximum length for username input on login/lookup — more permissive than `USERNAME_LENGTH_MAX` for forward-compatibility if the creation limit is raised. */
|
|
23
23
|
export declare const USERNAME_PROVIDED_LENGTH_MAX = 255;
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Username for account creation — starts with letter, alphanumeric/dash/underscore middle, ends with alphanumeric. No @ or . allowed.
|
|
26
|
+
*
|
|
27
|
+
* Canonicalized to lowercase at parse time. The regex rejects whitespace
|
|
28
|
+
* outright, so `.trim()` is unnecessary here. Storage is canonical across
|
|
29
|
+
* every creation site (bootstrap, signup, admin-create, invite acceptance)
|
|
30
|
+
* because the schema is the single source of truth — eliminates the
|
|
31
|
+
* per-caller `trim().toLowerCase()` ritual and keeps the
|
|
32
|
+
* `LOWER(username) = LOWER($1)` lookup contract simple.
|
|
33
|
+
*/
|
|
34
|
+
export declare const Username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
26
35
|
export type Username = z.infer<typeof Username>;
|
|
27
|
-
/**
|
|
28
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Username submitted for login or lookup — minimal validation for forward-compatibility if format rules change.
|
|
38
|
+
*
|
|
39
|
+
* Canonicalized via `.trim().toLowerCase()` at parse time so login's
|
|
40
|
+
* per-account rate-limit key and DB lookup see a uniform value
|
|
41
|
+
* regardless of casing or surrounding whitespace. Mirrors the storage
|
|
42
|
+
* canonicalization on `Username` so submission and storage agree.
|
|
43
|
+
*/
|
|
44
|
+
export declare const UsernameProvided: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
29
45
|
export type UsernameProvided = z.infer<typeof UsernameProvided>;
|
|
30
46
|
/**
|
|
31
47
|
* Email validation. Lives here rather than `@fuzdev/fuz_util` because every
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD
|
|
1
|
+
{"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAKc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,wDAIa,CAAC;AAC3C,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,YAAY,CAAC;AAC/B,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC"}
|
|
@@ -22,14 +22,35 @@ export const USERNAME_LENGTH_MIN = 3;
|
|
|
22
22
|
export const USERNAME_LENGTH_MAX = 39;
|
|
23
23
|
/** Maximum length for username input on login/lookup — more permissive than `USERNAME_LENGTH_MAX` for forward-compatibility if the creation limit is raised. */
|
|
24
24
|
export const USERNAME_PROVIDED_LENGTH_MAX = 255;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Username for account creation — starts with letter, alphanumeric/dash/underscore middle, ends with alphanumeric. No @ or . allowed.
|
|
27
|
+
*
|
|
28
|
+
* Canonicalized to lowercase at parse time. The regex rejects whitespace
|
|
29
|
+
* outright, so `.trim()` is unnecessary here. Storage is canonical across
|
|
30
|
+
* every creation site (bootstrap, signup, admin-create, invite acceptance)
|
|
31
|
+
* because the schema is the single source of truth — eliminates the
|
|
32
|
+
* per-caller `trim().toLowerCase()` ritual and keeps the
|
|
33
|
+
* `LOWER(username) = LOWER($1)` lookup contract simple.
|
|
34
|
+
*/
|
|
26
35
|
export const Username = z
|
|
27
36
|
.string()
|
|
28
37
|
.min(USERNAME_LENGTH_MIN)
|
|
29
38
|
.max(USERNAME_LENGTH_MAX)
|
|
30
|
-
.regex(/^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$/)
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
.regex(/^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$/)
|
|
40
|
+
.transform((s) => s.toLowerCase());
|
|
41
|
+
/**
|
|
42
|
+
* Username submitted for login or lookup — minimal validation for forward-compatibility if format rules change.
|
|
43
|
+
*
|
|
44
|
+
* Canonicalized via `.trim().toLowerCase()` at parse time so login's
|
|
45
|
+
* per-account rate-limit key and DB lookup see a uniform value
|
|
46
|
+
* regardless of casing or surrounding whitespace. Mirrors the storage
|
|
47
|
+
* canonicalization on `Username` so submission and storage agree.
|
|
48
|
+
*/
|
|
49
|
+
export const UsernameProvided = z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(USERNAME_PROVIDED_LENGTH_MAX)
|
|
53
|
+
.transform((s) => s.trim().toLowerCase());
|
|
33
54
|
/**
|
|
34
55
|
* Email validation. Lives here rather than `@fuzdev/fuz_util` because every
|
|
35
56
|
* current consumer pairs it with `Username` (signup, invites, audit log) —
|