@fuzdev/fuz_app 0.144.0 → 0.146.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 (69) hide show
  1. package/dist/actions/CLAUDE.md +7 -6
  2. package/dist/auth/CLAUDE.md +5 -2
  3. package/dist/auth/account_action_specs.d.ts.map +1 -1
  4. package/dist/auth/account_action_specs.js +2 -1
  5. package/dist/auth/account_route_schema.d.ts.map +1 -1
  6. package/dist/auth/account_route_schema.js +9 -2
  7. package/dist/auth/account_routes.d.ts +8 -0
  8. package/dist/auth/account_routes.d.ts.map +1 -1
  9. package/dist/auth/account_routes.js +12 -3
  10. package/dist/auth/request_context.d.ts +23 -0
  11. package/dist/auth/request_context.d.ts.map +1 -1
  12. package/dist/auth/request_context.js +32 -1
  13. package/dist/auth/token_scope.d.ts +22 -14
  14. package/dist/auth/token_scope.d.ts.map +1 -1
  15. package/dist/auth/token_scope.js +23 -14
  16. package/dist/db/CLAUDE.md +7 -3
  17. package/dist/db/fact_ddl.d.ts +1 -0
  18. package/dist/db/fact_ddl.d.ts.map +1 -1
  19. package/dist/db/fact_ddl.js +1 -0
  20. package/dist/db/fact_holder_queries.d.ts +8 -4
  21. package/dist/db/fact_holder_queries.d.ts.map +1 -1
  22. package/dist/db/fact_holder_queries.js +9 -5
  23. package/dist/db/fact_queries.d.ts +10 -7
  24. package/dist/db/fact_queries.d.ts.map +1 -1
  25. package/dist/db/fact_queries.js +31 -10
  26. package/dist/http/CLAUDE.md +2 -2
  27. package/dist/http/auth_shape.d.ts.map +1 -1
  28. package/dist/http/auth_shape.js +11 -6
  29. package/dist/http/proxy.d.ts +22 -21
  30. package/dist/http/proxy.d.ts.map +1 -1
  31. package/dist/http/proxy.js +28 -33
  32. package/dist/http/route_spec.d.ts +8 -0
  33. package/dist/http/route_spec.d.ts.map +1 -1
  34. package/dist/server/env.d.ts.map +1 -1
  35. package/dist/server/env.js +1 -1
  36. package/dist/server/fact_security_headers.d.ts +59 -0
  37. package/dist/server/fact_security_headers.d.ts.map +1 -0
  38. package/dist/server/fact_security_headers.js +58 -0
  39. package/dist/server/serve_fact_route.d.ts +21 -10
  40. package/dist/server/serve_fact_route.d.ts.map +1 -1
  41. package/dist/server/serve_fact_route.js +41 -50
  42. package/dist/server/x_accel.d.ts +135 -31
  43. package/dist/server/x_accel.d.ts.map +1 -1
  44. package/dist/server/x_accel.js +652 -88
  45. package/dist/testing/CLAUDE.md +73 -22
  46. package/dist/testing/cross_backend/capabilities.d.ts +0 -18
  47. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  48. package/dist/testing/cross_backend/capabilities.js +1 -4
  49. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  50. package/dist/testing/cross_backend/default_backend_configs.js +0 -7
  51. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/fact_serving.js +85 -12
  53. package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
  54. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/identity_parity.js +124 -9
  56. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  57. package/dist/testing/cross_backend/login_security.js +28 -0
  58. package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
  59. package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
  60. package/dist/testing/cross_backend/revocation_drivers.js +104 -0
  61. package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
  62. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  63. package/dist/testing/cross_backend/sse_round_trip.js +52 -16
  64. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/token_scope_surface.js +33 -0
  66. package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
  67. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/ws_round_trip.js +155 -39
  69. package/package.json +3 -3
@@ -113,6 +113,21 @@ export const query_delete_fact = async (deps, hash) => {
113
113
  return null;
114
114
  return { size: Number(row.size), external_url: row.external_url };
115
115
  };
116
+ /**
117
+ * The grace-window half of the orphan cutoff: no `fact_holder` row was
118
+ * recorded (or refreshed) at or after `$1`. A dedup put adds no `fact` row,
119
+ * so `fact.created_at` stays at the first insert — without this, an old
120
+ * orphan an actor re-uploads looks stale, and a sweep between the upload and
121
+ * the cell write that references it deletes the fact (and, by cascade, the
122
+ * possession the write needs). A fact counts as recent when its own
123
+ * `created_at` or any holder's is within the window. Served by
124
+ * `idx_fact_holder_hash`. Twin of the Rust `ORPHAN_FACT_NOT_RECENTLY_HELD`.
125
+ */
126
+ const ORPHAN_FACT_NOT_RECENTLY_HELD = `NOT EXISTS (
127
+ SELECT 1 FROM fact_holder h
128
+ WHERE h.hash = f.hash
129
+ AND h.created_at >= $1::timestamptz
130
+ )`;
116
131
  /**
117
132
  * Compute the "orphan facts" set: rows in `fact` where no active
118
133
  * (non-tombstone) `cell.refs` array contains the hash.
@@ -124,14 +139,15 @@ export const query_delete_fact = async (deps, hash) => {
124
139
  * outputs? external fact mirrors?) the predicate moves to a generic
125
140
  * `fact_consumers` registry; today the cell layer is the only consumer.
126
141
  *
127
- * The `older_than` filter applies to `fact.created_at`. Pass `null`
128
- * to skip the filter (used by the list-summary preview); the delete
129
- * handler always passes a non-null cutoff (default 0, meaning "any
130
- * orphan").
142
+ * The `older_than` filter is the grace cutoff: a fact passes it when both
143
+ * its `created_at` and every `fact_holder` row's are older (a re-put
144
+ * refreshes the holder row). Pass `null` to skip the filter (used by the
145
+ * list-summary preview); the delete handler always passes a non-null
146
+ * cutoff (default 0, meaning "any orphan").
131
147
  *
132
148
  * @param deps - query deps
133
- * @param older_than - filter to facts created before this Date (or null
134
- * to skip)
149
+ * @param older_than - filter to facts created, and last put by any holder,
150
+ * before this Date (or null to skip)
135
151
  * @param sample_limit - row cap for the returned `sample`
136
152
  */
137
153
  export const query_orphan_facts_list = async (deps, older_than, sample_limit) => {
@@ -142,7 +158,8 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
142
158
  WHERE c.refs @> ARRAY[f.hash]::text[]
143
159
  AND c.deleted_at IS NULL
144
160
  )
145
- AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)`, [older_than]);
161
+ AND ($1::timestamptz IS NULL
162
+ OR (f.created_at < $1::timestamptz AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}))`, [older_than]);
146
163
  const sample_rows = await deps.db.query(`SELECT hash, size, ${iso8601_timestamp_column('f', 'created_at')} AS created_at, external_url
147
164
  FROM fact f
148
165
  WHERE NOT EXISTS (
@@ -150,7 +167,8 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
150
167
  WHERE c.refs @> ARRAY[f.hash]::text[]
151
168
  AND c.deleted_at IS NULL
152
169
  )
153
- AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)
170
+ AND ($1::timestamptz IS NULL
171
+ OR (f.created_at < $1::timestamptz AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}))
154
172
  ORDER BY f.created_at ASC
155
173
  LIMIT $2`, [older_than, sample_limit]);
156
174
  return {
@@ -169,7 +187,9 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
169
187
  * (no row-count limit) — callers iterate to unlink disk files. The
170
188
  * `older_than` cutoff is required (non-null) here: bulk delete should
171
189
  * always be operator-scoped to a time window. A "delete all" sweep
172
- * passes a far-future cutoff, not `null`.
190
+ * passes a far-future cutoff, not `null`. It is the same grace cutoff as
191
+ * `query_orphan_facts_list`: a fact put — or re-put, deduplicated — by an
192
+ * actor within the window is kept, whatever its own `created_at`.
173
193
  */
174
194
  export const query_orphan_facts_select_for_delete = async (deps, older_than) => {
175
195
  const rows = await deps.db.query(`SELECT hash, size, external_url
@@ -179,6 +199,7 @@ export const query_orphan_facts_select_for_delete = async (deps, older_than) =>
179
199
  WHERE c.refs @> ARRAY[f.hash]::text[]
180
200
  AND c.deleted_at IS NULL
181
201
  )
182
- AND f.created_at < $1::timestamptz`, [older_than]);
202
+ AND f.created_at < $1::timestamptz
203
+ AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}`, [older_than]);
183
204
  return rows.map((r) => ({ hash: r.hash, size: Number(r.size), external_url: r.external_url }));
184
205
  };
@@ -52,7 +52,7 @@ consumers.
52
52
  - `input: z.ZodType` — request body schema; `z.null()` for no-body (GET/DELETE)
53
53
  - `output: z.ZodType` — success response schema
54
54
  - `mints_credential?: boolean` — the route issues a session cookie while declaring no auth (login, signup, bootstrap); assembly refuses it outside `AUTH_MIDDLEWARE_PATH`, as it does any route whose `auth.account` is not `'none'` (`assert_credential_routes_in_auth_scope` in `auth/middleware.ts`)
55
- - `reads_credential?: boolean` — the handler reads the resolved account while the route declares no auth gate, answering an anonymous caller itself (account status, the cell-scoped fact read, `create_rpc_endpoint`'s pair, whose dispatcher gates per action); held inside `AUTH_MIDDLEWARE_PATH` by the same assertion
55
+ - `reads_credential?: boolean` — the handler reads the resolved account while the route declares no auth gate, answering an anonymous caller itself (account status, the cell-scoped fact read, `create_rpc_endpoint`'s pair, whose dispatcher gates per action); held inside `AUTH_MIDDLEWARE_PATH` by the same assertion. Such a handler reads the account through `get_public_account_id` (`auth/request_context.ts`), which answers a scope-narrowed token as anonymous — the RPC pair excepted, its dispatcher gating each method
56
56
  - `rate_limit?: RateLimitKey` — metadata only (`'ip' | 'account' | 'both'`); auto-derives 429
57
57
  - `errors?: RouteErrorSchemas` — handler-specific error schemas keyed by HTTP status
58
58
  - `transaction?: boolean` — declarative transaction wrapping (see below)
@@ -315,7 +315,7 @@ rate-limiting middleware (see root ../../../CLAUDE.md §Middleware Ordering).
315
315
 
316
316
  Per-symbol semantics on TSDoc; the cross-cutting properties:
317
317
 
318
- - **Rightmost-first XFF walk**, skipping trusted entries AND entries that fail strict validation. Closes a rate-limit-key-poisoning surface where an attacker who controls XFF and transits through a trusted proxy could rotate garbage strings to evade per-IP limits.
318
+ - **Rightmost-first XFF walk, skipping trusted entries; the first entry that fails strict validation stops the walk and the request keys on the connection IP.** Stopping, not skipping, keeps a client-written entry left of a non-bare-IP hop (`ip:port`, `unknown`) from picking the bucket.
319
319
  - **`validate_ip_strict(ip)`** is defensive against Hono's lax `distinctRemoteAddr` (which classifies anything-with-colons as IPv6 and accepts `'[::1]:8080'`, `'::1\n'` as binary-valid). Two-layer check: character-set pre-filter + round-trip through `convertIPv*ToBinary`.
320
320
  - **`normalize_ip(ip)`** delegates to `canonicalize_ip` from `http/ip_canonical.ts` — RFC 5952 lowercase + longest-zero-run compression, IPv4-mapped IPv6 stripped to plain IPv4 so buckets collapse. Idempotent, safe on non-IP strings, strict char-set filter preserves malformed forms unchanged.
321
321
  - **Three-branch middleware logic**: no XFF → use connection IP; XFF + connection untrusted → ignore XFF, use connection IP (spoof-proof, debug log); XFF + connection trusted → resolve from header, warn if all entries turn out trusted.
@@ -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;;;;;;;;;;;;;;;;;;;;;;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"}
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;;;;;;;;;;;;;;kBA8EnB,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"}
@@ -138,9 +138,9 @@ export const RouteAuth = z
138
138
  *
139
139
  * - `surface:<name>` — a non-RPC surface, refused to every narrowed token
140
140
  * whatever its method list says (token scoping's rule that a narrowed
141
- * token is RPC-only). `surface:audit_stream`, `surface:fact_bare`, and
142
- * `surface:app_surface` are the spine's route-spec ones; a consumer names
143
- * its own without registering it.
141
+ * token is RPC-only). `surface:audit_stream`, `surface:fact_bare`,
142
+ * `surface:account_verify`, and `surface:app_surface` are the spine's
143
+ * route-spec ones; a consumer names its own without registering it.
144
144
  * - `rpc:<method>` — one action method, refused unless the token lists it.
145
145
  * What a route bridged from an `ActionSpec` declares: it carries the
146
146
  * method's identity but never reaches the dispatcher's per-method gate.
@@ -151,9 +151,14 @@ export const RouteAuth = z
151
151
  * account lacks the role would be told about the role rather than about the
152
152
  * scope).
153
153
  *
154
- * Opt-in rather than blanket: the account REST routes cross no boundary, and
155
- * the RPC endpoint runs its own per-method check inside the dispatcher — and
156
- * both are route specs too.
154
+ * Opt-in rather than blanket: no narrowed token reaches login (which reads
155
+ * no credential) or logout and the password change (session-only), the
156
+ * RPC endpoint runs its own per-method check inside the dispatcher, the
157
+ * `reads_credential` routes that answer anonymous callers (account status,
158
+ * the cell-scoped fact read) can't take the field and answer a narrowed
159
+ * token as anonymous in their handler (`get_public_account_id`), and the
160
+ * server-status route admits a narrowed token by design — and all are
161
+ * route specs too.
157
162
  *
158
163
  * Typed as a plain string here to keep `http/` from importing `auth/`; the
159
164
  * resolver parses it at registration and throws on a malformed one.
@@ -102,30 +102,30 @@ export declare const is_trusted_ip: (ip: string, proxies: Array<ParsedProxy>) =>
102
102
  /**
103
103
  * Resolve the real client IP from an `X-Forwarded-For` header value.
104
104
  *
105
- * Walks right-to-left, skipping trusted proxy entries AND any entry
106
- * that fails strict IP validation (`validate_ip_strict`). The first
107
- * untrusted, strictly-valid entry is the client IP. If every walked
108
- * entry is trusted or malformed, returns the leftmost strictly-valid
109
- * (trusted) entry (likely-misconfigured all-trusted case) or
110
- * `undefined` (everything was malformed — middleware falls back to
111
- * the connection IP). All entries are normalized before matching and
112
- * in the returned value.
113
- *
114
- * Skipping malformed entries is the rate-limit-key fix for the
115
- * "attacker controls XFF and the proxy passes it through" surface —
116
- * without the skip, an attacker could rotate arbitrary strings (incl.
105
+ * Walks right-to-left, skipping trusted proxy entries. The first untrusted
106
+ * entry is the client IP. The walk **stops** at the first entry that fails
107
+ * strict IP validation (`validate_ip_strict`) and returns `undefined`, so the
108
+ * middleware falls back to the connection IP: an entry is only as
109
+ * trustworthy as the hop that wrote it, and everything left of a malformed
110
+ * one was written before any trusted hop vouched for it. If every entry is
111
+ * trusted, returns the leftmost one (likely-misconfigured all-trusted case).
112
+ * Empty entries are dropped before the walk. All entries are normalized
113
+ * before matching and in the returned value.
114
+ *
115
+ * Stopping, not skipping, is the rate-limit-key rule. Returning a malformed
116
+ * entry would let an attacker rotate arbitrary strings (incl.
117
117
  * `'attacker:controlled'`, which Hono's lax `distinctRemoteAddr`
118
- * misclassifies as IPv6) as XFF values to get fresh per-IP rate-limit
119
- * buckets. Tradeoff: legitimate non-standard proxies that include
120
- * ports in XFF entries (e.g. `203.0.113.1:8080`) also fail strict
121
- * validation, so those entries get skipped and the rate-limit bucket
122
- * collapses to the proxy's connection IP (one bucket for everyone
123
- * behind that proxy). Standard proxies (nginx, cloud LBs) don't
124
- * include ports.
118
+ * misclassifies as IPv6) into fresh per-IP rate-limit buckets; skipping it
119
+ * and walking on would hand the key to the entry the client wrote whenever a
120
+ * trusted hop appends a non-bare-IP token (`ip:port`, `[v6]:port`,
121
+ * `unknown`, `unix:`). Tradeoff: behind such a proxy every client collapses
122
+ * to the proxy's connection IP in rate limiting (one bucket for everyone
123
+ * behind it). Standard proxies (nginx's `$remote_addr`, cloud LBs) append
124
+ * bare IPs.
125
125
  *
126
126
  * @param forwarded_for - the `X-Forwarded-For` header value
127
127
  * @param proxies - parsed trusted proxy entries
128
- * @returns the normalized client IP, or `undefined` if the header is empty / all entries malformed
128
+ * @returns the normalized client IP, or `undefined` if the header is empty or the walk met a malformed entry
129
129
  */
130
130
  export declare const resolve_client_ip: (forwarded_for: string, proxies: Array<ParsedProxy>) => string | undefined;
131
131
  /**
@@ -139,7 +139,8 @@ export declare const resolve_client_ip: (forwarded_for: string, proxies: Array<P
139
139
  * 2. `X-Forwarded-For` present but connection is untrusted → ignore header
140
140
  * (spoofed by a direct attacker), use connection IP.
141
141
  * 3. `X-Forwarded-For` present and connection is trusted → walk header
142
- * right-to-left, strip trusted entries, use first untrusted entry.
142
+ * right-to-left, strip trusted entries, use first untrusted entry; a
143
+ * malformed entry stops the walk and the connection IP is used.
143
144
  *
144
145
  * @param options - trusted proxy configuration
145
146
  * @mutates `c.var.client_ip` - set to the resolved (or `'unknown'`) client IP per request
@@ -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,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG3D;;;;;;;;;;;;;;;;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;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,CAAC;AAEpF;;;;;;;;;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"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG3D;;;;;;;;;;;;;;;;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;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,CAAC;AAEpF;;;;;;;;;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,SAqBX,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,YAAY,KAAG,iBAyC/D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,YAAY,KAAG,cAInE,CAAC"}
@@ -172,30 +172,30 @@ export const is_trusted_ip = (ip, proxies) => {
172
172
  /**
173
173
  * Resolve the real client IP from an `X-Forwarded-For` header value.
174
174
  *
175
- * Walks right-to-left, skipping trusted proxy entries AND any entry
176
- * that fails strict IP validation (`validate_ip_strict`). The first
177
- * untrusted, strictly-valid entry is the client IP. If every walked
178
- * entry is trusted or malformed, returns the leftmost strictly-valid
179
- * (trusted) entry (likely-misconfigured all-trusted case) or
180
- * `undefined` (everything was malformed — middleware falls back to
181
- * the connection IP). All entries are normalized before matching and
182
- * in the returned value.
175
+ * Walks right-to-left, skipping trusted proxy entries. The first untrusted
176
+ * entry is the client IP. The walk **stops** at the first entry that fails
177
+ * strict IP validation (`validate_ip_strict`) and returns `undefined`, so the
178
+ * middleware falls back to the connection IP: an entry is only as
179
+ * trustworthy as the hop that wrote it, and everything left of a malformed
180
+ * one was written before any trusted hop vouched for it. If every entry is
181
+ * trusted, returns the leftmost one (likely-misconfigured all-trusted case).
182
+ * Empty entries are dropped before the walk. All entries are normalized
183
+ * before matching and in the returned value.
183
184
  *
184
- * Skipping malformed entries is the rate-limit-key fix for the
185
- * "attacker controls XFF and the proxy passes it through" surface —
186
- * without the skip, an attacker could rotate arbitrary strings (incl.
185
+ * Stopping, not skipping, is the rate-limit-key rule. Returning a malformed
186
+ * entry would let an attacker rotate arbitrary strings (incl.
187
187
  * `'attacker:controlled'`, which Hono's lax `distinctRemoteAddr`
188
- * misclassifies as IPv6) as XFF values to get fresh per-IP rate-limit
189
- * buckets. Tradeoff: legitimate non-standard proxies that include
190
- * ports in XFF entries (e.g. `203.0.113.1:8080`) also fail strict
191
- * validation, so those entries get skipped and the rate-limit bucket
192
- * collapses to the proxy's connection IP (one bucket for everyone
193
- * behind that proxy). Standard proxies (nginx, cloud LBs) don't
194
- * include ports.
188
+ * misclassifies as IPv6) into fresh per-IP rate-limit buckets; skipping it
189
+ * and walking on would hand the key to the entry the client wrote whenever a
190
+ * trusted hop appends a non-bare-IP token (`ip:port`, `[v6]:port`,
191
+ * `unknown`, `unix:`). Tradeoff: behind such a proxy every client collapses
192
+ * to the proxy's connection IP in rate limiting (one bucket for everyone
193
+ * behind it). Standard proxies (nginx's `$remote_addr`, cloud LBs) append
194
+ * bare IPs.
195
195
  *
196
196
  * @param forwarded_for - the `X-Forwarded-For` header value
197
197
  * @param proxies - parsed trusted proxy entries
198
- * @returns the normalized client IP, or `undefined` if the header is empty / all entries malformed
198
+ * @returns the normalized client IP, or `undefined` if the header is empty or the walk met a malformed entry
199
199
  */
200
200
  export const resolve_client_ip = (forwarded_for, proxies) => {
201
201
  const entries = [];
@@ -206,26 +206,20 @@ export const resolve_client_ip = (forwarded_for, proxies) => {
206
206
  }
207
207
  if (entries.length === 0)
208
208
  return undefined;
209
- // Walk from right to left, skip trusted proxies and malformed entries.
210
- // Returning a malformed entry as the client IP would let an attacker
211
- // who controls XFF poison the per-IP rate-limit key.
209
+ // Walk from right to left, skipping trusted proxies; stop at a malformed
210
+ // entry. Everything left of it is client-written, so neither it nor any
211
+ // entry past it may pick the per-IP rate-limit key.
212
212
  for (let i = entries.length - 1; i >= 0; i--) {
213
213
  const entry = entries[i];
214
214
  if (!validate_ip_strict(entry))
215
- continue;
215
+ return undefined;
216
216
  if (!is_trusted_ip(entry, proxies)) {
217
217
  return entry;
218
218
  }
219
219
  }
220
- // Every entry was trusted or malformed. Prefer the leftmost
221
- // strictly-valid (trusted) entry — the misconfiguration warn in
222
- // the middleware fires on it. If none, fall through to undefined
223
- // and let the middleware fall back to the connection IP.
224
- for (const entry of entries) {
225
- if (validate_ip_strict(entry))
226
- return entry;
227
- }
228
- return undefined;
220
+ // Every entry was valid and trusted — the leftmost one; the
221
+ // misconfiguration warn in the middleware fires on it.
222
+ return entries[0];
229
223
  };
230
224
  /**
231
225
  * Create a Hono middleware that resolves the client IP from trusted proxies.
@@ -238,7 +232,8 @@ export const resolve_client_ip = (forwarded_for, proxies) => {
238
232
  * 2. `X-Forwarded-For` present but connection is untrusted → ignore header
239
233
  * (spoofed by a direct attacker), use connection IP.
240
234
  * 3. `X-Forwarded-For` present and connection is trusted → walk header
241
- * right-to-left, strip trusted entries, use first untrusted entry.
235
+ * right-to-left, strip trusted entries, use first untrusted entry; a
236
+ * malformed entry stops the walk and the connection IP is used.
242
237
  *
243
238
  * @param options - trusted proxy configuration
244
239
  * @mutates `c.var.client_ip` - set to the resolved (or `'unknown'`) client IP per request
@@ -169,6 +169,14 @@ export interface RouteSpec {
169
169
  * and it would see every caller as anonymous, so assembly refuses it
170
170
  * there (`assert_credential_routes_in_auth_scope`), as it does a route
171
171
  * whose `auth.account` is not `'none'`.
172
+ *
173
+ * The handler owns the token-scope rule a gate would otherwise apply: a
174
+ * scope-narrowed API token reaches no non-RPC surface that reads account
175
+ * data, and `auth.required_scope` is refused on a route that admits
176
+ * anonymous callers. Read the credential through `get_public_account_id`
177
+ * (`auth/request_context.ts`), not `ACCOUNT_ID_KEY`, so a narrowed token is
178
+ * answered as the anonymous caller. The RPC endpoint is the exception — its
179
+ * dispatcher gates each method against the scope.
172
180
  */
173
181
  reads_credential?: boolean;
174
182
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"route_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/route_spec.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"route_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/route_spec.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAA4B,KAAK,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAG5E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAoDxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBAmBxD,CAAC"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAA4B,KAAK,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAG5E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAqDxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBAmBxD,CAAC"}
@@ -69,7 +69,7 @@ export const BaseServerEnv = z.strictObject({
69
69
  description: 'Directory for referenced (large) fact bytes, sharded <shard>/<rest>'
70
70
  }),
71
71
  FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX: z.string().optional().meta({
72
- description: 'Internal nginx prefix for X-Accel-Redirect fact delivery (production only)'
72
+ description: 'Internal nginx prefix for X-Accel-Redirect fact delivery, ending in `/` (production only)'
73
73
  })
74
74
  });
75
75
  /**
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The untrusted-content hardening headers every served fact carries.
3
+ *
4
+ * Fact bytes are served with a producer-supplied `content_type` emitted
5
+ * verbatim, inline. To stop a fact stored as (or sniffable to) `text/html` from
6
+ * executing as stored XSS for any reader of a referencing cell, every fact
7
+ * response carries `FACT_SECURITY_HEADERS`: `X-Content-Type-Options: nosniff` +
8
+ * `Content-Security-Policy: default-src 'none'; sandbox`.
9
+ *
10
+ * The embedded and disk-stream branches of `server/serve_fact_route.ts` set them
11
+ * on the response directly. On the `X-Accel-Redirect` branch nginx serves the
12
+ * bytes from its internal facts location, and a location with its own
13
+ * `add_header` stops the server-level headers from applying — so that location
14
+ * must carry the set itself, as the lines `fact_security_header_directives`
15
+ * renders. The X-Accel validator (`server/x_accel.ts`) refuses a facts location
16
+ * without them.
17
+ *
18
+ * The Rust twin is `fuz_fact_serving::fact_security_headers`; both pin the same
19
+ * literals.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * `X-Content-Type-Options` for fact responses — block MIME sniffing so an
25
+ * `application/octet-stream` fact can't be sniffed into executable HTML.
26
+ */
27
+ export declare const FACT_X_CONTENT_TYPE_OPTIONS = "nosniff";
28
+ /**
29
+ * `Content-Security-Policy` for fact responses. A fact stored as (or sniffable
30
+ * to) `text/html` would otherwise execute as stored XSS for any reader of a
31
+ * referencing cell. `default-src 'none'; sandbox` neutralizes script execution
32
+ * and sub-resource loads even when a fact is rendered inline; harmless for
33
+ * directly-served images (they load no sub-resources).
34
+ */
35
+ export declare const FACT_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
36
+ /**
37
+ * `Cache-Control` for fact responses — private, five minutes: the window a
38
+ * cached read outlives a revoked view.
39
+ */
40
+ export declare const FACT_CACHE_CONTROL = "private, max-age=300";
41
+ /**
42
+ * The fact hardening headers, in render order. `Content-Disposition:
43
+ * attachment` is deliberately absent — it would force-download and break
44
+ * legitimate inline image rendering.
45
+ */
46
+ export declare const FACT_SECURITY_HEADERS: {
47
+ readonly 'X-Content-Type-Options': "nosniff";
48
+ readonly 'Content-Security-Policy': "default-src 'none'; sandbox";
49
+ };
50
+ /**
51
+ * The exact nginx `add_header … always;` lines the internal facts location must
52
+ * carry, rendered from `FACT_SECURITY_HEADERS`. `always` makes nginx add the
53
+ * header on every status, not just 2xx/3xx. The X-Accel validator refuses a
54
+ * facts location missing any of them.
55
+ *
56
+ * @returns one `add_header` directive per header, each ending in `always;`
57
+ */
58
+ export declare const fact_security_header_directives: () => Array<string>;
59
+ //# sourceMappingURL=fact_security_headers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_security_headers.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/fact_security_headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;GAGG;AACH,eAAO,MAAM,2BAA2B,YAAY,CAAC;AAErD;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,gCAAgC,CAAC;AAE1E;;;GAGG;AACH,eAAO,MAAM,kBAAkB,yBAAyB,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB;;;CAGxB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,QAAO,KAAK,CAAC,MAAM,CAG7D,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The untrusted-content hardening headers every served fact carries.
3
+ *
4
+ * Fact bytes are served with a producer-supplied `content_type` emitted
5
+ * verbatim, inline. To stop a fact stored as (or sniffable to) `text/html` from
6
+ * executing as stored XSS for any reader of a referencing cell, every fact
7
+ * response carries `FACT_SECURITY_HEADERS`: `X-Content-Type-Options: nosniff` +
8
+ * `Content-Security-Policy: default-src 'none'; sandbox`.
9
+ *
10
+ * The embedded and disk-stream branches of `server/serve_fact_route.ts` set them
11
+ * on the response directly. On the `X-Accel-Redirect` branch nginx serves the
12
+ * bytes from its internal facts location, and a location with its own
13
+ * `add_header` stops the server-level headers from applying — so that location
14
+ * must carry the set itself, as the lines `fact_security_header_directives`
15
+ * renders. The X-Accel validator (`server/x_accel.ts`) refuses a facts location
16
+ * without them.
17
+ *
18
+ * The Rust twin is `fuz_fact_serving::fact_security_headers`; both pin the same
19
+ * literals.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * `X-Content-Type-Options` for fact responses — block MIME sniffing so an
25
+ * `application/octet-stream` fact can't be sniffed into executable HTML.
26
+ */
27
+ export const FACT_X_CONTENT_TYPE_OPTIONS = 'nosniff';
28
+ /**
29
+ * `Content-Security-Policy` for fact responses. A fact stored as (or sniffable
30
+ * to) `text/html` would otherwise execute as stored XSS for any reader of a
31
+ * referencing cell. `default-src 'none'; sandbox` neutralizes script execution
32
+ * and sub-resource loads even when a fact is rendered inline; harmless for
33
+ * directly-served images (they load no sub-resources).
34
+ */
35
+ export const FACT_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
36
+ /**
37
+ * `Cache-Control` for fact responses — private, five minutes: the window a
38
+ * cached read outlives a revoked view.
39
+ */
40
+ export const FACT_CACHE_CONTROL = 'private, max-age=300';
41
+ /**
42
+ * The fact hardening headers, in render order. `Content-Disposition:
43
+ * attachment` is deliberately absent — it would force-download and break
44
+ * legitimate inline image rendering.
45
+ */
46
+ export const FACT_SECURITY_HEADERS = {
47
+ 'X-Content-Type-Options': FACT_X_CONTENT_TYPE_OPTIONS,
48
+ 'Content-Security-Policy': FACT_CONTENT_SECURITY_POLICY
49
+ };
50
+ /**
51
+ * The exact nginx `add_header … always;` lines the internal facts location must
52
+ * carry, rendered from `FACT_SECURITY_HEADERS`. `always` makes nginx add the
53
+ * header on every status, not just 2xx/3xx. The X-Accel validator refuses a
54
+ * facts location missing any of them.
55
+ *
56
+ * @returns one `add_header` directive per header, each ending in `always;`
57
+ */
58
+ export const fact_security_header_directives = () => Object.entries(FACT_SECURITY_HEADERS).map(([name, value]) => `add_header ${name} "${value}" always;`);
@@ -20,7 +20,8 @@
20
20
  * `XAccelConfig` (built via
21
21
  * `create_x_accel_config` in `server/x_accel.ts`) set in prod, unset in dev.
22
22
  * The handle can only be obtained by proving the facts nginx location is
23
- * `internal;`, so X-Accel serving can't be enabled against a public location.
23
+ * `internal;` and carries the fact hardening headers, so X-Accel serving can't
24
+ * be enabled against a public or header-less location.
24
25
  *
25
26
  * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
26
27
  *
@@ -69,14 +70,18 @@
69
70
  *
70
71
  * Auth shape on the cell-scoped route is `{account: 'none', actor: 'none'}`
71
72
  * — the dispatcher's authorization phase is skipped for pure-public routes,
72
- * so the handler builds the `RequestContext` itself from `c.var.account_id`
73
- * (populated by the `/api/*` session middleware) by resolving the caller's
74
- * single actor and loading their role_grants. Unauthed callers pass through
75
- * with `req_ctx: null` and are admitted only by a `cell.visibility ===
76
- * 'public'` cell whose `moderation` admits an audience (unmoderated or
73
+ * so the handler builds the `RequestContext` itself from the resolved account
74
+ * id (`get_public_account_id`, over what the `/api/*` auth middleware set) by
75
+ * resolving the caller's single actor and loading their role_grants.
76
+ * Unauthed callers pass through with `req_ctx: null` and are admitted only by
77
+ * a `cell.visibility === 'public'` cell whose `moderation` admits an audience (unmoderated or
77
78
  * `'approved'`). Multi-actor accounts fall through with `req_ctx: null`
78
79
  * — there's no `acting?` slot on a pure-public route, so multi-actor
79
- * callers are treated as anonymous.
80
+ * callers are treated as anonymous. A scope-narrowed API token is treated as
81
+ * anonymous too, not refused (`get_public_account_id`): a narrowed token
82
+ * reaches no non-RPC surface, but on a route that admits anonymous callers
83
+ * a 403 would enforce nothing — the holder drops the `Authorization` header and
84
+ * re-reads every public cell anyway.
80
85
  *
81
86
  * 404 is the universal "not viewable" response: missing fact, missing or
82
87
  * unviewable cell, or the cell doesn't reference the hash. We deliberately
@@ -96,8 +101,13 @@
96
101
  * from executing as stored XSS for any reader of a referencing cell, **every**
97
102
  * served-fact response carries `X-Content-Type-Options: nosniff` +
98
103
  * `Content-Security-Policy: default-src 'none'; sandbox` (the
99
- * `FACT_SECURITY_HEADERS` set, applied in all three serve branches). The same
100
- * pair, byte-identical, is set by the Rust twin (`fuz_fact_serving::serve`).
104
+ * `FACT_SECURITY_HEADERS` set from `server/fact_security_headers.ts`,
105
+ * byte-identical with the Rust twin `fuz_fact_serving`). The embedded and
106
+ * disk-stream branches set them on the response. On the `X-Accel-Redirect`
107
+ * branch nginx serves the bytes, and the headers the client sees come from the
108
+ * internal facts location, not this response — that location must carry the
109
+ * lines `fact_security_header_directives` renders, which `create_x_accel_config`
110
+ * enforces.
101
111
  * `Content-Disposition: attachment` is deliberately **not** added — it would
102
112
  * force-download and break legitimate inline image rendering.
103
113
  *
@@ -150,7 +160,8 @@ export interface CreateServeFactRouteSpecOptions {
150
160
  * `(cell, hash)` edge — never unioned across the fact's other referrers.
151
161
  *
152
162
  * Pure-public auth — the handler builds the per-request `RequestContext`
153
- * from `c.var.account_id` and enforces visibility per-reference.
163
+ * from the resolved account id (`get_public_account_id`) and enforces
164
+ * visibility per-reference.
154
165
  */
155
166
  export declare const create_serve_cell_fact_route_spec: (options: CreateServeFactRouteSpecOptions) => RouteSpec;
156
167
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAkHD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAuDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
1
+ {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4HG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAoCjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAyHD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAuDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}