@fuzdev/fuz_app 0.67.1 → 0.69.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 (235) hide show
  1. package/dist/actions/perform_action.d.ts.map +1 -1
  2. package/dist/actions/perform_action.js +10 -3
  3. package/dist/auth/CLAUDE.md +99 -5
  4. package/dist/auth/account_queries.d.ts +87 -4
  5. package/dist/auth/account_queries.d.ts.map +1 -1
  6. package/dist/auth/account_queries.js +107 -17
  7. package/dist/auth/account_schema.d.ts +19 -0
  8. package/dist/auth/account_schema.d.ts.map +1 -1
  9. package/dist/auth/account_schema.js +8 -0
  10. package/dist/auth/admin_action_specs.d.ts +170 -3
  11. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  12. package/dist/auth/admin_action_specs.js +148 -4
  13. package/dist/auth/admin_actions.d.ts +4 -14
  14. package/dist/auth/admin_actions.d.ts.map +1 -1
  15. package/dist/auth/admin_actions.js +246 -40
  16. package/dist/auth/audit_log_ddl.d.ts +10 -1
  17. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  18. package/dist/auth/audit_log_ddl.js +13 -4
  19. package/dist/auth/audit_log_schema.d.ts +34 -1
  20. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  21. package/dist/auth/audit_log_schema.js +73 -0
  22. package/dist/auth/auth_ddl.d.ts +2 -2
  23. package/dist/auth/auth_ddl.d.ts.map +1 -1
  24. package/dist/auth/auth_ddl.js +10 -2
  25. package/dist/auth/cell_action_specs.d.ts +1295 -0
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -0
  27. package/dist/auth/cell_action_specs.js +397 -0
  28. package/dist/auth/cell_actions.d.ts +63 -0
  29. package/dist/auth/cell_actions.d.ts.map +1 -0
  30. package/dist/auth/cell_actions.js +546 -0
  31. package/dist/auth/cell_audit_action_specs.d.ts +131 -0
  32. package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
  33. package/dist/auth/cell_audit_action_specs.js +70 -0
  34. package/dist/auth/cell_audit_actions.d.ts +18 -0
  35. package/dist/auth/cell_audit_actions.d.ts.map +1 -0
  36. package/dist/auth/cell_audit_actions.js +59 -0
  37. package/dist/auth/cell_audit_events.d.ts +28 -0
  38. package/dist/auth/cell_audit_events.d.ts.map +1 -0
  39. package/dist/auth/cell_audit_events.js +42 -0
  40. package/dist/auth/cell_audit_metadata.d.ts +48 -0
  41. package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
  42. package/dist/auth/cell_audit_metadata.js +46 -0
  43. package/dist/auth/cell_authorize.d.ts +88 -0
  44. package/dist/auth/cell_authorize.d.ts.map +1 -0
  45. package/dist/auth/cell_authorize.js +172 -0
  46. package/dist/auth/cell_data_schema.d.ts +44 -0
  47. package/dist/auth/cell_data_schema.d.ts.map +1 -0
  48. package/dist/auth/cell_data_schema.js +42 -0
  49. package/dist/auth/cell_field_action_specs.d.ts +244 -0
  50. package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
  51. package/dist/auth/cell_field_action_specs.js +136 -0
  52. package/dist/auth/cell_field_actions.d.ts +34 -0
  53. package/dist/auth/cell_field_actions.d.ts.map +1 -0
  54. package/dist/auth/cell_field_actions.js +153 -0
  55. package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
  56. package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
  57. package/dist/auth/cell_field_audit_metadata.js +28 -0
  58. package/dist/auth/cell_grant_action_specs.d.ts +333 -0
  59. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
  60. package/dist/auth/cell_grant_action_specs.js +148 -0
  61. package/dist/auth/cell_grant_actions.d.ts +50 -0
  62. package/dist/auth/cell_grant_actions.d.ts.map +1 -0
  63. package/dist/auth/cell_grant_actions.js +208 -0
  64. package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
  65. package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
  66. package/dist/auth/cell_grant_audit_metadata.js +54 -0
  67. package/dist/auth/cell_item_action_specs.d.ts +331 -0
  68. package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
  69. package/dist/auth/cell_item_action_specs.js +182 -0
  70. package/dist/auth/cell_item_actions.d.ts +37 -0
  71. package/dist/auth/cell_item_actions.d.ts.map +1 -0
  72. package/dist/auth/cell_item_actions.js +204 -0
  73. package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
  74. package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
  75. package/dist/auth/cell_item_audit_metadata.js +32 -0
  76. package/dist/auth/cell_relation_visibility.d.ts +32 -0
  77. package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
  78. package/dist/auth/cell_relation_visibility.js +57 -0
  79. package/dist/auth/deps.d.ts +9 -0
  80. package/dist/auth/deps.d.ts.map +1 -1
  81. package/dist/auth/role_grant_queries.d.ts +30 -0
  82. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  83. package/dist/auth/role_grant_queries.js +54 -0
  84. package/dist/auth/signup_routes.d.ts +0 -3
  85. package/dist/auth/signup_routes.d.ts.map +1 -1
  86. package/dist/auth/signup_routes.js +9 -3
  87. package/dist/auth/standard_rpc_actions.d.ts +5 -5
  88. package/dist/auth/standard_rpc_actions.js +4 -4
  89. package/dist/db/CLAUDE.md +118 -0
  90. package/dist/db/cell_audit_queries.d.ts +26 -0
  91. package/dist/db/cell_audit_queries.d.ts.map +1 -0
  92. package/dist/db/cell_audit_queries.js +53 -0
  93. package/dist/db/cell_ddl.d.ts +151 -0
  94. package/dist/db/cell_ddl.d.ts.map +1 -0
  95. package/dist/db/cell_ddl.js +247 -0
  96. package/dist/db/cell_field_queries.d.ts +105 -0
  97. package/dist/db/cell_field_queries.d.ts.map +1 -0
  98. package/dist/db/cell_field_queries.js +113 -0
  99. package/dist/db/cell_grant_queries.d.ts +132 -0
  100. package/dist/db/cell_grant_queries.d.ts.map +1 -0
  101. package/dist/db/cell_grant_queries.js +145 -0
  102. package/dist/db/cell_history_ddl.d.ts +38 -0
  103. package/dist/db/cell_history_ddl.d.ts.map +1 -0
  104. package/dist/db/cell_history_ddl.js +61 -0
  105. package/dist/db/cell_item_queries.d.ts +107 -0
  106. package/dist/db/cell_item_queries.d.ts.map +1 -0
  107. package/dist/db/cell_item_queries.js +119 -0
  108. package/dist/db/cell_queries.d.ts +327 -0
  109. package/dist/db/cell_queries.d.ts.map +1 -0
  110. package/dist/db/cell_queries.js +431 -0
  111. package/dist/db/fact_ddl.d.ts +38 -0
  112. package/dist/db/fact_ddl.d.ts.map +1 -0
  113. package/dist/db/fact_ddl.js +71 -0
  114. package/dist/db/fact_queries.d.ts +140 -0
  115. package/dist/db/fact_queries.d.ts.map +1 -0
  116. package/dist/db/fact_queries.js +161 -0
  117. package/dist/db/fact_store.d.ts +112 -0
  118. package/dist/db/fact_store.d.ts.map +1 -0
  119. package/dist/db/fact_store.js +225 -0
  120. package/dist/server/app_server.d.ts +1 -7
  121. package/dist/server/app_server.d.ts.map +1 -1
  122. package/dist/server/app_server.js +1 -5
  123. package/dist/server/env.d.ts +2 -0
  124. package/dist/server/env.d.ts.map +1 -1
  125. package/dist/server/env.js +6 -0
  126. package/dist/server/fact_write.d.ts +32 -0
  127. package/dist/server/fact_write.d.ts.map +1 -0
  128. package/dist/server/fact_write.js +56 -0
  129. package/dist/server/file_fact_fetcher.d.ts +42 -0
  130. package/dist/server/file_fact_fetcher.d.ts.map +1 -0
  131. package/dist/server/file_fact_fetcher.js +60 -0
  132. package/dist/server/file_fact_url.d.ts +53 -0
  133. package/dist/server/file_fact_url.d.ts.map +1 -0
  134. package/dist/server/file_fact_url.js +52 -0
  135. package/dist/server/serve_fact_route.d.ts +78 -0
  136. package/dist/server/serve_fact_route.d.ts.map +1 -0
  137. package/dist/server/serve_fact_route.js +205 -0
  138. package/dist/testing/CLAUDE.md +142 -6
  139. package/dist/testing/app_server.d.ts +46 -0
  140. package/dist/testing/app_server.d.ts.map +1 -1
  141. package/dist/testing/app_server.js +67 -8
  142. package/dist/testing/audit_completeness.d.ts.map +1 -1
  143. package/dist/testing/audit_completeness.js +67 -1
  144. package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
  145. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/account_lifecycle.js +144 -0
  147. package/dist/testing/cross_backend/actor_lookup.d.ts +10 -0
  148. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -0
  149. package/dist/testing/cross_backend/actor_lookup.js +83 -0
  150. package/dist/testing/cross_backend/actor_search.d.ts +6 -0
  151. package/dist/testing/cross_backend/actor_search.d.ts.map +1 -0
  152. package/dist/testing/cross_backend/actor_search.js +92 -0
  153. package/dist/testing/cross_backend/app_settings.d.ts +6 -0
  154. package/dist/testing/cross_backend/app_settings.d.ts.map +1 -0
  155. package/dist/testing/cross_backend/app_settings.js +95 -0
  156. package/dist/testing/cross_backend/backend_config.d.ts +1 -1
  157. package/dist/testing/cross_backend/capabilities.d.ts +29 -7
  158. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/capabilities.js +3 -1
  160. package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
  161. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
  162. package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
  163. package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
  164. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
  165. package/dist/testing/cross_backend/cell_crud.js +168 -0
  166. package/dist/testing/cross_backend/cell_grant_role.d.ts +8 -0
  167. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -0
  168. package/dist/testing/cross_backend/cell_grant_role.js +102 -0
  169. package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
  170. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
  171. package/dist/testing/cross_backend/cell_relations.js +229 -0
  172. package/dist/testing/cross_backend/conformance_case.d.ts +144 -0
  173. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -0
  174. package/dist/testing/cross_backend/conformance_case.js +132 -0
  175. package/dist/testing/cross_backend/conformance_table.d.ts +46 -0
  176. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -0
  177. package/dist/testing/cross_backend/conformance_table.js +199 -0
  178. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  179. package/dist/testing/cross_backend/default_backend_configs.js +6 -2
  180. package/dist/testing/cross_backend/default_spine_surface.d.ts +17 -9
  181. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  182. package/dist/testing/cross_backend/default_spine_surface.js +20 -12
  183. package/dist/testing/cross_backend/origin.d.ts +10 -0
  184. package/dist/testing/cross_backend/origin.d.ts.map +1 -0
  185. package/dist/testing/cross_backend/origin.js +73 -0
  186. package/dist/testing/cross_backend/setup.d.ts +22 -40
  187. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  188. package/dist/testing/cross_backend/setup.js +39 -5
  189. package/dist/testing/cross_backend/testing_reset_actions.d.ts +90 -2
  190. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  191. package/dist/testing/cross_backend/testing_reset_actions.js +91 -3
  192. package/dist/testing/cross_backend/xfail.d.ts +15 -0
  193. package/dist/testing/cross_backend/xfail.d.ts.map +1 -0
  194. package/dist/testing/cross_backend/xfail.js +37 -0
  195. package/dist/testing/entities.d.ts.map +1 -1
  196. package/dist/testing/entities.js +4 -0
  197. package/dist/testing/integration.d.ts +2 -3
  198. package/dist/testing/integration.d.ts.map +1 -1
  199. package/dist/testing/integration.js +20 -85
  200. package/dist/testing/rate_limiting.d.ts +1 -1
  201. package/dist/testing/rpc_helpers.d.ts +3 -3
  202. package/dist/testing/sse_round_trip.d.ts +1 -1
  203. package/dist/testing/stubs.d.ts.map +1 -1
  204. package/dist/testing/stubs.js +0 -1
  205. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  206. package/dist/testing/ws_round_trip.js +4 -0
  207. package/dist/ui/AdminAccounts.svelte +84 -35
  208. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  209. package/dist/ui/AdminSessions.svelte +21 -23
  210. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  211. package/dist/ui/CLAUDE.md +17 -26
  212. package/dist/ui/OpenSignupToggle.svelte +2 -5
  213. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  214. package/dist/ui/account_sessions_state.svelte.d.ts +9 -10
  215. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  216. package/dist/ui/account_sessions_state.svelte.js +7 -17
  217. package/dist/ui/admin_accounts_state.svelte.d.ts +41 -20
  218. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  219. package/dist/ui/admin_accounts_state.svelte.js +52 -22
  220. package/dist/ui/admin_invites_state.svelte.d.ts +8 -11
  221. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  222. package/dist/ui/admin_invites_state.svelte.js +7 -16
  223. package/dist/ui/admin_rpc_adapters.d.ts +6 -2
  224. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
  225. package/dist/ui/admin_rpc_adapters.js +5 -1
  226. package/dist/ui/admin_sessions_state.svelte.d.ts +6 -10
  227. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  228. package/dist/ui/admin_sessions_state.svelte.js +4 -14
  229. package/dist/ui/app_settings_state.svelte.d.ts +8 -12
  230. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  231. package/dist/ui/app_settings_state.svelte.js +6 -16
  232. package/dist/ui/audit_log_state.svelte.d.ts +9 -8
  233. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  234. package/dist/ui/audit_log_state.svelte.js +8 -20
  235. package/package.json +2 -2
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared helper for routing fact bytes between embedded (PG `bytes`
3
+ * column) and external (filesystem + `put_ref`) storage tiers based
4
+ * on size.
5
+ *
6
+ * External writes go through atomic temp+rename so the `facts` row
7
+ * never references a partial file; idempotence comes from POSIX
8
+ * `rename` overwrite + `INSERT ... ON CONFLICT DO NOTHING` in the
9
+ * fact-store queries layer.
10
+ *
11
+ * @module
12
+ */
13
+ import { type FactHash } from '@fuzdev/fuz_util/fact_hash.js';
14
+ import type { FactStore } from '@fuzdev/fuz_util/fact_store.js';
15
+ export interface WriteFactOptions {
16
+ content_type: string;
17
+ }
18
+ /**
19
+ * Write `bytes` as a fact, choosing embedded (PG) vs external (disk +
20
+ * `put_ref`) based on `embedded_threshold`. Returns the canonical
21
+ * `blake3:` hash either way.
22
+ *
23
+ * @param fact_store - the `FactStore` (typically `PgFactStore`)
24
+ * @param embedded_threshold - bytes ≤ threshold → embedded; > threshold → disk
25
+ * @param facts_dir - root of the sharded facts directory tree on disk
26
+ * @param bytes - the raw fact bytes
27
+ * @param options - content type for the fact metadata
28
+ * @returns the fact's `blake3:<hex64>` hash
29
+ * @mutates `fact_store`, `facts_dir` filesystem
30
+ */
31
+ export declare const write_fact: (fact_store: FactStore, embedded_threshold: number, facts_dir: string, bytes: Uint8Array, options: WriteFactOptions) => Promise<FactHash>;
32
+ //# sourceMappingURL=fact_write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_write.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/fact_write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,EAAoC,KAAK,QAAQ,EAAC,MAAM,+BAA+B,CAAC;AAC/F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gCAAgC,CAAC;AAG9D,MAAM,WAAW,gBAAgB;IAChC,YAAY,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU,GACtB,YAAY,SAAS,EACrB,oBAAoB,MAAM,EAC1B,WAAW,MAAM,EACjB,OAAO,UAAU,EACjB,SAAS,gBAAgB,KACvB,OAAO,CAAC,QAAQ,CA4BlB,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared helper for routing fact bytes between embedded (PG `bytes`
3
+ * column) and external (filesystem + `put_ref`) storage tiers based
4
+ * on size.
5
+ *
6
+ * External writes go through atomic temp+rename so the `facts` row
7
+ * never references a partial file; idempotence comes from POSIX
8
+ * `rename` overwrite + `INSERT ... ON CONFLICT DO NOTHING` in the
9
+ * fact-store queries layer.
10
+ *
11
+ * @module
12
+ */
13
+ import { randomBytes } from 'node:crypto';
14
+ import { writeFile, rename, mkdir, unlink } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { FACT_HASH_PREFIX, fact_hash_bytes } from '@fuzdev/fuz_util/fact_hash.js';
17
+ import { mint_file_fact_url } from './file_fact_url.js';
18
+ /**
19
+ * Write `bytes` as a fact, choosing embedded (PG) vs external (disk +
20
+ * `put_ref`) based on `embedded_threshold`. Returns the canonical
21
+ * `blake3:` hash either way.
22
+ *
23
+ * @param fact_store - the `FactStore` (typically `PgFactStore`)
24
+ * @param embedded_threshold - bytes ≤ threshold → embedded; > threshold → disk
25
+ * @param facts_dir - root of the sharded facts directory tree on disk
26
+ * @param bytes - the raw fact bytes
27
+ * @param options - content type for the fact metadata
28
+ * @returns the fact's `blake3:<hex64>` hash
29
+ * @mutates `fact_store`, `facts_dir` filesystem
30
+ */
31
+ export const write_fact = async (fact_store, embedded_threshold, facts_dir, bytes, options) => {
32
+ if (bytes.length <= embedded_threshold) {
33
+ return fact_store.put(bytes, options);
34
+ }
35
+ const hash = fact_hash_bytes(bytes);
36
+ const hex = hash.slice(FACT_HASH_PREFIX.length);
37
+ const shard = hex.slice(0, 2);
38
+ const rest = hex.slice(2);
39
+ const shard_dir = join(facts_dir, shard);
40
+ const tmp_dir = join(facts_dir, '.tmp');
41
+ const tmp_path = join(tmp_dir, `${randomBytes(16).toString('hex')}.tmp`);
42
+ const final_path = join(shard_dir, rest);
43
+ await Promise.all([mkdir(shard_dir, { recursive: true }), mkdir(tmp_dir, { recursive: true })]);
44
+ let renamed = false;
45
+ try {
46
+ await writeFile(tmp_path, bytes);
47
+ await rename(tmp_path, final_path);
48
+ renamed = true;
49
+ }
50
+ finally {
51
+ if (!renamed) {
52
+ await unlink(tmp_path).catch(() => undefined);
53
+ }
54
+ }
55
+ return fact_store.put_ref(mint_file_fact_url(shard, rest), bytes.length, options);
56
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Filesystem-backed `FactExternalFetcher`.
3
+ *
4
+ * Resolves `file:<shard>/<rest>` external URLs against a configured
5
+ * facts directory. The URL shape is the canonical relative-path scheme:
6
+ * 2 hex chars for the shard subdir + 62 hex chars for the rest of the
7
+ * blake3 hash (64 hex chars total). Files live at
8
+ * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
9
+ * them in.
10
+ *
11
+ * **Pre-filter regex**: `^file:[0-9a-f]{2}/[0-9a-f]{62}$`. A `..`
12
+ * segment can't match (`.` isn't in `[0-9a-f]`); neither can absolute
13
+ * paths, query strings, or any non-hex character. Defense-in-depth in
14
+ * front of `path.join` — the fetcher never trusts the URL came from a
15
+ * fact row, even though it always does in practice.
16
+ *
17
+ * The fetcher does NOT verify hash content — `PgFactStore.get` calls
18
+ * `fact_hash_verify(hash, bytes)` after the fetch and returns null on
19
+ * mismatch.
20
+ *
21
+ * Runtime: uses `node:fs/promises` + `node:fs` `createReadStream` so
22
+ * the same code works under Deno (via node compat) and vitest.
23
+ *
24
+ * @module
25
+ */
26
+ import type { FactExternalFetcher } from '../db/fact_store.js';
27
+ /** Construction options. */
28
+ export interface FileFactFetcherOptions {
29
+ /**
30
+ * Absolute path to the facts directory. Files resolve to
31
+ * `<facts_dir>/<shard>/<rest>`.
32
+ */
33
+ facts_dir: string;
34
+ }
35
+ /**
36
+ * Build a `FactExternalFetcher` that resolves `file:` URLs against the
37
+ * filesystem. Throws on a malformed URL before touching the disk so
38
+ * `PgFactStore.get` logs the warning + returns null without an I/O
39
+ * round-trip on bad data.
40
+ */
41
+ export declare const create_file_fact_fetcher: (options: FileFactFetcherOptions) => FactExternalFetcher;
42
+ //# sourceMappingURL=file_fact_fetcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file_fact_fetcher.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/file_fact_fetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAOH,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,qBAAqB,CAAC;AAG7D,4BAA4B;AAC5B,MAAM,WAAW,sBAAsB;IACtC;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,sBAAsB,KAAG,mBA0B1E,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Filesystem-backed `FactExternalFetcher`.
3
+ *
4
+ * Resolves `file:<shard>/<rest>` external URLs against a configured
5
+ * facts directory. The URL shape is the canonical relative-path scheme:
6
+ * 2 hex chars for the shard subdir + 62 hex chars for the rest of the
7
+ * blake3 hash (64 hex chars total). Files live at
8
+ * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
9
+ * them in.
10
+ *
11
+ * **Pre-filter regex**: `^file:[0-9a-f]{2}/[0-9a-f]{62}$`. A `..`
12
+ * segment can't match (`.` isn't in `[0-9a-f]`); neither can absolute
13
+ * paths, query strings, or any non-hex character. Defense-in-depth in
14
+ * front of `path.join` — the fetcher never trusts the URL came from a
15
+ * fact row, even though it always does in practice.
16
+ *
17
+ * The fetcher does NOT verify hash content — `PgFactStore.get` calls
18
+ * `fact_hash_verify(hash, bytes)` after the fetch and returns null on
19
+ * mismatch.
20
+ *
21
+ * Runtime: uses `node:fs/promises` + `node:fs` `createReadStream` so
22
+ * the same code works under Deno (via node compat) and vitest.
23
+ *
24
+ * @module
25
+ */
26
+ import { readFile } from 'node:fs/promises';
27
+ import { createReadStream } from 'node:fs';
28
+ import { Readable } from 'node:stream';
29
+ import { join } from 'node:path';
30
+ import { parse_file_fact_url } from './file_fact_url.js';
31
+ /**
32
+ * Build a `FactExternalFetcher` that resolves `file:` URLs against the
33
+ * filesystem. Throws on a malformed URL before touching the disk so
34
+ * `PgFactStore.get` logs the warning + returns null without an I/O
35
+ * round-trip on bad data.
36
+ */
37
+ export const create_file_fact_fetcher = (options) => {
38
+ const { facts_dir } = options;
39
+ const resolve_path = (url) => {
40
+ const parsed = parse_file_fact_url(url);
41
+ if (!parsed) {
42
+ throw new Error(`invalid file fact url: ${url}`);
43
+ }
44
+ return join(facts_dir, parsed.shard, parsed.rest);
45
+ };
46
+ return {
47
+ fetch_bytes: async (url) => {
48
+ const path = resolve_path(url);
49
+ const buf = await readFile(path);
50
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
51
+ },
52
+ // `Promise.resolve().then(...)` (rather than `async`) funnels any
53
+ // `resolve_path` throw into a rejection without an unused `await`.
54
+ fetch_stream: (url) => Promise.resolve().then(() => {
55
+ const path = resolve_path(url);
56
+ const node_stream = createReadStream(path);
57
+ return Readable.toWeb(node_stream);
58
+ }),
59
+ };
60
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Canonical filesystem-fact URL shape.
3
+ *
4
+ * `external_url` on the generic `facts` row is `string | null` because the
5
+ * `FactStore` interface stays federation-friendly (future
6
+ * `https://...` / `s3://...` shapes). Filesystem-minted URLs are exactly
7
+ * `file:<shard>/<rest>` where `<shard>` is the first 2 hex chars of the
8
+ * blake3 digest and `<rest>` the remaining 62 — files land at
9
+ * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
10
+ * them in.
11
+ *
12
+ * Centralizing the regex keeps the shape in one place: the `serve_fact_route`
13
+ * defense-in-depth check and the `file_fact_fetcher` resolver both validate
14
+ * against it, so federation work that loosens or extends the shape (e.g.,
15
+ * admitting `https://`) updates one place, not several.
16
+ *
17
+ * Defense-in-depth: a `..` segment can't match (`.` isn't in `[0-9a-f]`),
18
+ * neither can absolute paths, query strings, or any non-hex character.
19
+ * Used in front of `path.join` so the resolver never trusts the URL came
20
+ * from a fact row, even though it always does in practice.
21
+ *
22
+ * @module
23
+ */
24
+ import { z } from 'zod';
25
+ /** Anchored, capture-group form: `^file:(<shard>)/(<rest>)$`. */
26
+ export declare const FILE_FACT_URL_PATTERN: RegExp;
27
+ /**
28
+ * Branded URL form. Construct only via `parse_file_fact_url` or
29
+ * `mint_file_fact_url` — those are the validated boundaries. Direct
30
+ * string literals don't satisfy the brand.
31
+ */
32
+ export declare const FileFactUrl: z.core.$ZodBranded<z.ZodString, "FileFactUrl", "out">;
33
+ export type FileFactUrl = z.infer<typeof FileFactUrl>;
34
+ /** Type guard. Useful when discriminating a `string | null` column. */
35
+ export declare const is_file_fact_url: (s: string) => s is FileFactUrl;
36
+ /**
37
+ * Validate a string against the canonical shape. Returns the branded URL
38
+ * plus its parsed parts, or `null` on shape mismatch — callers decide
39
+ * whether that's a 404 (read), a skip (GC), or a hard reject (write).
40
+ */
41
+ export declare const parse_file_fact_url: (url: string) => {
42
+ url: FileFactUrl;
43
+ shard: string;
44
+ rest: string;
45
+ } | null;
46
+ /**
47
+ * Construct a canonical `file:<shard>/<rest>` URL. The writer side
48
+ * (`server/fact_write.ts`) assembles the shape from a freshly-computed
49
+ * hash; this helper centralizes the literal so a future shape change is
50
+ * a single edit.
51
+ */
52
+ export declare const mint_file_fact_url: (shard: string, rest: string) => FileFactUrl;
53
+ //# sourceMappingURL=file_fact_url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file_fact_url.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/file_fact_url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,iEAAiE;AACjE,eAAO,MAAM,qBAAqB,QAAyC,CAAC;AAE5E;;;;GAIG;AACH,eAAO,MAAM,WAAW,uDAA+D,CAAC;AACxF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,uEAAuE;AACvE,eAAO,MAAM,gBAAgB,GAAI,GAAG,MAAM,KAAG,CAAC,IAAI,WAA4C,CAAC;AAE/F;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,MAAM,KACT;IAAC,GAAG,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,GAAG,IAIpD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,WAC1B,CAAC"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Canonical filesystem-fact URL shape.
3
+ *
4
+ * `external_url` on the generic `facts` row is `string | null` because the
5
+ * `FactStore` interface stays federation-friendly (future
6
+ * `https://...` / `s3://...` shapes). Filesystem-minted URLs are exactly
7
+ * `file:<shard>/<rest>` where `<shard>` is the first 2 hex chars of the
8
+ * blake3 digest and `<rest>` the remaining 62 — files land at
9
+ * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
10
+ * them in.
11
+ *
12
+ * Centralizing the regex keeps the shape in one place: the `serve_fact_route`
13
+ * defense-in-depth check and the `file_fact_fetcher` resolver both validate
14
+ * against it, so federation work that loosens or extends the shape (e.g.,
15
+ * admitting `https://`) updates one place, not several.
16
+ *
17
+ * Defense-in-depth: a `..` segment can't match (`.` isn't in `[0-9a-f]`),
18
+ * neither can absolute paths, query strings, or any non-hex character.
19
+ * Used in front of `path.join` so the resolver never trusts the URL came
20
+ * from a fact row, even though it always does in practice.
21
+ *
22
+ * @module
23
+ */
24
+ import { z } from 'zod';
25
+ /** Anchored, capture-group form: `^file:(<shard>)/(<rest>)$`. */
26
+ export const FILE_FACT_URL_PATTERN = /^file:([0-9a-f]{2})\/([0-9a-f]{62})$/;
27
+ /**
28
+ * Branded URL form. Construct only via `parse_file_fact_url` or
29
+ * `mint_file_fact_url` — those are the validated boundaries. Direct
30
+ * string literals don't satisfy the brand.
31
+ */
32
+ export const FileFactUrl = z.string().regex(FILE_FACT_URL_PATTERN).brand('FileFactUrl');
33
+ /** Type guard. Useful when discriminating a `string | null` column. */
34
+ export const is_file_fact_url = (s) => FILE_FACT_URL_PATTERN.test(s);
35
+ /**
36
+ * Validate a string against the canonical shape. Returns the branded URL
37
+ * plus its parsed parts, or `null` on shape mismatch — callers decide
38
+ * whether that's a 404 (read), a skip (GC), or a hard reject (write).
39
+ */
40
+ export const parse_file_fact_url = (url) => {
41
+ const m = FILE_FACT_URL_PATTERN.exec(url);
42
+ if (!m)
43
+ return null;
44
+ return { url: url, shard: m[1], rest: m[2] };
45
+ };
46
+ /**
47
+ * Construct a canonical `file:<shard>/<rest>` URL. The writer side
48
+ * (`server/fact_write.ts`) assembles the shape from a freshly-computed
49
+ * hash; this helper centralizes the literal so a future shape change is
50
+ * a single edit.
51
+ */
52
+ export const mint_file_fact_url = (shard, rest) => `file:${shard}/${rest}`;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `GET /api/facts/:hash` — content-addressed fact serving.
3
+ *
4
+ * Resolves a fact hash to the bytes referenced by at least one viewable
5
+ * cell. Embedded facts stream from the `facts.bytes` PG column;
6
+ * external facts (filesystem-backed `file:<shard>/<rest>` URLs) either
7
+ * return an `X-Accel-Redirect` header pointing into nginx's internal
8
+ * facts location (production) or stream from disk via the filesystem
9
+ * `FactExternalFetcher` (dev / tests). The runtime mode is selected by
10
+ * the optional `x_accel_redirect_prefix` factory option — set in prod,
11
+ * unset in dev.
12
+ *
13
+ * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
14
+ *
15
+ * ## Authorization
16
+ *
17
+ * Auth is `{account: 'none', actor: 'none'}` — the dispatcher's
18
+ * authorization phase is skipped for pure-public routes, so this handler
19
+ * builds the `RequestContext` itself from `c.var.account_id` (populated
20
+ * by the `/api/*` session middleware) by resolving the caller's single
21
+ * actor and loading their role_grants. Unauthed callers pass through
22
+ * with `req_ctx: null`. Viewers are admitted via `can_view_cell` over
23
+ * **every** active cell that references the hash. Multi-actor accounts
24
+ * fall through with `req_ctx: null` — there's no `acting?` slot on a
25
+ * pure-public route, so multi-actor callers are treated as anonymous
26
+ * (admitted only by the public-visibility branch). A fact is viewable iff
27
+ * at least one referencing cell admits the caller; unauthenticated callers
28
+ * are admitted only via a referencing cell with `cell.visibility ===
29
+ * 'public'`. Facts with no referencing active cell are unreachable here —
30
+ * orphan-fact GC reaps them separately.
31
+ *
32
+ * 404 is the universal "not viewable" response: missing fact, missing
33
+ * referencing cell, all referencing cells private to other actors. We
34
+ * deliberately don't distinguish 403 from 404 — the existence of a
35
+ * private hash should not leak through the public surface.
36
+ *
37
+ * ## Defense-in-depth
38
+ *
39
+ * The `external_url` regex is re-validated before issuing
40
+ * `X-Accel-Redirect` even though `PgFactStore.put_ref` only writes
41
+ * `file:<shard>/<rest>`-shaped URLs. A future row-injection bug
42
+ * upstream would otherwise hand nginx an attacker-controlled path.
43
+ *
44
+ * @module
45
+ */
46
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
47
+ import { type RouteSpec } from '../http/route_spec.js';
48
+ import type { AppDeps } from '../auth/deps.js';
49
+ export interface CreateServeFactRouteSpecOptions {
50
+ /**
51
+ * App deps reference. Currently unused at handler time (cell + fact
52
+ * tables are read via `RouteContext.db`); kept on the factory
53
+ * signature for symmetry with sibling route factories and to give
54
+ * future role_grant-scoped viewer extensions somewhere to read other
55
+ * deps without changing the public shape.
56
+ */
57
+ deps: AppDeps;
58
+ /** Absolute path of the facts directory. Used for the dev/test streaming path. */
59
+ facts_dir: string;
60
+ /**
61
+ * When set, external facts return an `X-Accel-Redirect` pointing at
62
+ * `${prefix}<shard>/<rest>` — nginx's internal facts location serves
63
+ * the bytes. When unset, external facts stream from
64
+ * `<facts_dir>/<shard>/<rest>` directly. Production sets this (e.g.
65
+ * `/_facts/`); tests + dev leave it unset.
66
+ */
67
+ x_accel_redirect_prefix?: string;
68
+ log: Logger;
69
+ }
70
+ /**
71
+ * Build the `GET /api/facts/:hash` `RouteSpec`.
72
+ *
73
+ * Pure-public auth — the handler builds the per-request `RequestContext`
74
+ * from `c.var.account_id` and enforces visibility per-fact via the
75
+ * cell-walk above.
76
+ */
77
+ export declare const create_serve_fact_route_spec: (options: CreateServeFactRouteSpecOptions) => RouteSpec;
78
+ //# sourceMappingURL=serve_fact_route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAKH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAOpD,OAAO,EAAmB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEvE,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,iBAAiB,CAAC;AAoB7C,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SAgIF,CAAC"}
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `GET /api/facts/:hash` — content-addressed fact serving.
3
+ *
4
+ * Resolves a fact hash to the bytes referenced by at least one viewable
5
+ * cell. Embedded facts stream from the `facts.bytes` PG column;
6
+ * external facts (filesystem-backed `file:<shard>/<rest>` URLs) either
7
+ * return an `X-Accel-Redirect` header pointing into nginx's internal
8
+ * facts location (production) or stream from disk via the filesystem
9
+ * `FactExternalFetcher` (dev / tests). The runtime mode is selected by
10
+ * the optional `x_accel_redirect_prefix` factory option — set in prod,
11
+ * unset in dev.
12
+ *
13
+ * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
14
+ *
15
+ * ## Authorization
16
+ *
17
+ * Auth is `{account: 'none', actor: 'none'}` — the dispatcher's
18
+ * authorization phase is skipped for pure-public routes, so this handler
19
+ * builds the `RequestContext` itself from `c.var.account_id` (populated
20
+ * by the `/api/*` session middleware) by resolving the caller's single
21
+ * actor and loading their role_grants. Unauthed callers pass through
22
+ * with `req_ctx: null`. Viewers are admitted via `can_view_cell` over
23
+ * **every** active cell that references the hash. Multi-actor accounts
24
+ * fall through with `req_ctx: null` — there's no `acting?` slot on a
25
+ * pure-public route, so multi-actor callers are treated as anonymous
26
+ * (admitted only by the public-visibility branch). A fact is viewable iff
27
+ * at least one referencing cell admits the caller; unauthenticated callers
28
+ * are admitted only via a referencing cell with `cell.visibility ===
29
+ * 'public'`. Facts with no referencing active cell are unreachable here —
30
+ * orphan-fact GC reaps them separately.
31
+ *
32
+ * 404 is the universal "not viewable" response: missing fact, missing
33
+ * referencing cell, all referencing cells private to other actors. We
34
+ * deliberately don't distinguish 403 from 404 — the existence of a
35
+ * private hash should not leak through the public surface.
36
+ *
37
+ * ## Defense-in-depth
38
+ *
39
+ * The `external_url` regex is re-validated before issuing
40
+ * `X-Accel-Redirect` even though `PgFactStore.put_ref` only writes
41
+ * `file:<shard>/<rest>`-shaped URLs. A future row-injection bug
42
+ * upstream would otherwise hand nginx an attacker-controlled path.
43
+ *
44
+ * @module
45
+ */
46
+ import { createReadStream } from 'node:fs';
47
+ import { Readable } from 'node:stream';
48
+ import { join } from 'node:path';
49
+ import { FactHashSchema } from '@fuzdev/fuz_util/fact_hash.js';
50
+ import { z } from 'zod';
51
+ import { build_request_context } from '../auth/request_context.js';
52
+ import { ACCOUNT_ID_KEY } from '../hono_context.js';
53
+ import { query_actors_by_account } from '../auth/account_queries.js';
54
+ import { get_route_params } from '../http/route_spec.js';
55
+ import { ERROR_INVALID_ROUTE_PARAMS } from '../http/error_schemas.js';
56
+ import { query_get_fact, query_get_fact_meta } from '../db/fact_queries.js';
57
+ import { query_cell_list_by_ref } from '../db/cell_queries.js';
58
+ import { query_cell_grant_list_for_cell } from '../db/cell_grant_queries.js';
59
+ import { can_view_cell } from '../auth/cell_authorize.js';
60
+ import { parse_file_fact_url } from './file_fact_url.js';
61
+ /** `Cache-Control` for fact responses — 5 min revocation window. */
62
+ const CACHE_CONTROL = 'private, max-age=300';
63
+ /**
64
+ * Path-param schema. Matching the canonical fact-hash form here pushes
65
+ * malformed-hash 400s through the framework's standard params-validation
66
+ * error shape (`ERROR_INVALID_ROUTE_PARAMS`), which the round-trip
67
+ * validator expects.
68
+ */
69
+ const params_schema = z.strictObject({
70
+ hash: FactHashSchema,
71
+ });
72
+ /**
73
+ * Build the `GET /api/facts/:hash` `RouteSpec`.
74
+ *
75
+ * Pure-public auth — the handler builds the per-request `RequestContext`
76
+ * from `c.var.account_id` and enforces visibility per-fact via the
77
+ * cell-walk above.
78
+ */
79
+ export const create_serve_fact_route_spec = (options) => {
80
+ const { facts_dir, x_accel_redirect_prefix, log } = options;
81
+ return {
82
+ method: 'GET',
83
+ path: '/api/facts/:hash',
84
+ auth: { account: 'none', actor: 'none' },
85
+ description: 'Serve content-addressed fact bytes. 404 unless at least one referencing cell admits the caller via can_view_cell.',
86
+ params: params_schema,
87
+ input: z.null(),
88
+ // The body is a binary stream; no JSON output schema applies.
89
+ output: z.null(),
90
+ errors: {
91
+ // Tighten the auto-derived 400 from `ApiError` (`error: string`) to
92
+ // the actual literal emitted by `create_params_validation`, so
93
+ // `assert_error_schema_tightness` reads the surface as specific
94
+ // rather than generic.
95
+ 400: z.looseObject({
96
+ error: z.literal(ERROR_INVALID_ROUTE_PARAMS),
97
+ issues: z.array(z.unknown()),
98
+ }),
99
+ },
100
+ handler: async (c, route) => {
101
+ const { hash } = get_route_params(c);
102
+ const meta = await query_get_fact_meta({ db: route.db }, hash);
103
+ if (!meta) {
104
+ return c.body(null, 404);
105
+ }
106
+ // Pure-public route — dispatcher skips the authorization phase, so
107
+ // build the `RequestContext` here from the session-middleware-set
108
+ // account id. Multi-actor accounts fall through with `null` (no
109
+ // `acting?` slot on a public route to disambiguate); single-actor
110
+ // accounts resolve their actor and role_grants for owner / grant /
111
+ // admin admission paths.
112
+ const account_id = c.get(ACCOUNT_ID_KEY);
113
+ let req_ctx = null;
114
+ if (account_id) {
115
+ const actors = await query_actors_by_account({ db: route.db }, account_id);
116
+ if (actors.length === 1) {
117
+ req_ctx = await build_request_context({ db: route.db }, account_id, actors[0].id);
118
+ }
119
+ }
120
+ // `include_grant_count: false` — the authz walk only reads
121
+ // `can_view_cell`-relevant fields, so skip the per-row grant
122
+ // COUNT subquery. Cheap to begin with; even cheaper now.
123
+ const referencing_cells = await query_cell_list_by_ref({ db: route.db }, hash, {
124
+ include_grant_count: false,
125
+ });
126
+ // Sequential walk with early break on first admit — preserves
127
+ // the original `.some()` short-circuit. Unauthenticated callers
128
+ // skip the grant fetch entirely since no grant can admit a null
129
+ // req_ctx (the only admit path is the public-visibility branch
130
+ // in `can_view_cell`, which doesn't need grants). Authenticated
131
+ // callers eat one `cell_grant` lookup per referencing cell up
132
+ // to the first admit; acceptable at MVP fact-serve scale.
133
+ // TODO: if profiling shows this hot, batch grants in one query
134
+ // across all referencing cells, or push the predicate into SQL.
135
+ let viewable = false;
136
+ for (const cell of referencing_cells) {
137
+ const grants = req_ctx
138
+ ? await query_cell_grant_list_for_cell({ db: route.db }, cell.id)
139
+ : null;
140
+ if (can_view_cell(req_ctx, cell, grants)) {
141
+ viewable = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!viewable) {
146
+ // 404 (not 403) so existence of private hashes doesn't leak
147
+ // through the public surface. Same response shape as a
148
+ // genuinely missing fact.
149
+ return c.body(null, 404);
150
+ }
151
+ const content_type = meta.content_type ?? 'application/octet-stream';
152
+ const size = String(meta.size);
153
+ if (meta.external_url === null) {
154
+ // Embedded — bytes live in the PG row.
155
+ const row = await query_get_fact({ db: route.db }, hash);
156
+ if (!row || row.bytes === null) {
157
+ // Race: meta said embedded but bytes vanished. Treat as not-found.
158
+ log.warn(`serve_fact: embedded bytes missing for ${hash} (meta said embedded, row=${row ? 'present' : 'null'})`);
159
+ return c.body(null, 404);
160
+ }
161
+ const bytes = to_uint8(row.bytes);
162
+ return c.body(bytes, 200, {
163
+ 'Content-Type': content_type,
164
+ 'Content-Length': size,
165
+ 'Cache-Control': CACHE_CONTROL,
166
+ });
167
+ }
168
+ // External — defense-in-depth re-validate the URL before trusting it
169
+ // to address the filesystem.
170
+ const parsed = parse_file_fact_url(meta.external_url);
171
+ if (!parsed) {
172
+ log.error(`serve_fact: rejecting malformed external_url for ${hash}: ${meta.external_url}`);
173
+ return c.body(null, 404);
174
+ }
175
+ const { shard, rest } = parsed;
176
+ if (x_accel_redirect_prefix !== undefined) {
177
+ // Production: hand off to nginx via the internal facts location.
178
+ return c.body(null, 200, {
179
+ 'Content-Type': content_type,
180
+ 'Content-Length': size,
181
+ 'Cache-Control': CACHE_CONTROL,
182
+ 'X-Accel-Redirect': `${x_accel_redirect_prefix}${shard}/${rest}`,
183
+ });
184
+ }
185
+ // Dev / tests: stream from disk. `createReadStream` errors land on the
186
+ // returned ReadableStream, which Hono surfaces as a 500 to the client.
187
+ const file_path = join(facts_dir, shard, rest);
188
+ const node_stream = createReadStream(file_path);
189
+ const web_stream = Readable.toWeb(node_stream);
190
+ return c.body(web_stream, 200, {
191
+ 'Content-Type': content_type,
192
+ 'Content-Length': size,
193
+ 'Cache-Control': CACHE_CONTROL,
194
+ });
195
+ },
196
+ };
197
+ };
198
+ /**
199
+ * Coerce whatever the driver returns for BYTEA into a `Uint8Array`. Same
200
+ * shape as the helper in `PgFactStore.get` — `pg` returns a `Buffer`
201
+ * (`Uint8Array` subclass), `pglite` already returns `Uint8Array`.
202
+ */
203
+ const to_uint8 = (value) => value instanceof Uint8Array && value.constructor === Uint8Array
204
+ ? value
205
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);