@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.
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +10 -3
- package/dist/auth/CLAUDE.md +99 -5
- package/dist/auth/account_queries.d.ts +87 -4
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +107 -17
- package/dist/auth/account_schema.d.ts +19 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +8 -0
- package/dist/auth/admin_action_specs.d.ts +170 -3
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +148 -4
- package/dist/auth/admin_actions.d.ts +4 -14
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +246 -40
- package/dist/auth/audit_log_ddl.d.ts +10 -1
- package/dist/auth/audit_log_ddl.d.ts.map +1 -1
- package/dist/auth/audit_log_ddl.js +13 -4
- package/dist/auth/audit_log_schema.d.ts +34 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +73 -0
- package/dist/auth/auth_ddl.d.ts +2 -2
- package/dist/auth/auth_ddl.d.ts.map +1 -1
- package/dist/auth/auth_ddl.js +10 -2
- package/dist/auth/cell_action_specs.d.ts +1295 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_action_specs.js +397 -0
- package/dist/auth/cell_actions.d.ts +63 -0
- package/dist/auth/cell_actions.d.ts.map +1 -0
- package/dist/auth/cell_actions.js +546 -0
- package/dist/auth/cell_audit_action_specs.d.ts +131 -0
- package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_audit_action_specs.js +70 -0
- package/dist/auth/cell_audit_actions.d.ts +18 -0
- package/dist/auth/cell_audit_actions.d.ts.map +1 -0
- package/dist/auth/cell_audit_actions.js +59 -0
- package/dist/auth/cell_audit_events.d.ts +28 -0
- package/dist/auth/cell_audit_events.d.ts.map +1 -0
- package/dist/auth/cell_audit_events.js +42 -0
- package/dist/auth/cell_audit_metadata.d.ts +48 -0
- package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_audit_metadata.js +46 -0
- package/dist/auth/cell_authorize.d.ts +88 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -0
- package/dist/auth/cell_authorize.js +172 -0
- package/dist/auth/cell_data_schema.d.ts +44 -0
- package/dist/auth/cell_data_schema.d.ts.map +1 -0
- package/dist/auth/cell_data_schema.js +42 -0
- package/dist/auth/cell_field_action_specs.d.ts +244 -0
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_field_action_specs.js +136 -0
- package/dist/auth/cell_field_actions.d.ts +34 -0
- package/dist/auth/cell_field_actions.d.ts.map +1 -0
- package/dist/auth/cell_field_actions.js +153 -0
- package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
- package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_field_audit_metadata.js +28 -0
- package/dist/auth/cell_grant_action_specs.d.ts +333 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_grant_action_specs.js +148 -0
- package/dist/auth/cell_grant_actions.d.ts +50 -0
- package/dist/auth/cell_grant_actions.d.ts.map +1 -0
- package/dist/auth/cell_grant_actions.js +208 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_grant_audit_metadata.js +54 -0
- package/dist/auth/cell_item_action_specs.d.ts +331 -0
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_item_action_specs.js +182 -0
- package/dist/auth/cell_item_actions.d.ts +37 -0
- package/dist/auth/cell_item_actions.d.ts.map +1 -0
- package/dist/auth/cell_item_actions.js +204 -0
- package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
- package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_item_audit_metadata.js +32 -0
- package/dist/auth/cell_relation_visibility.d.ts +32 -0
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
- package/dist/auth/cell_relation_visibility.js +57 -0
- package/dist/auth/deps.d.ts +9 -0
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.d.ts +30 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.js +54 -0
- package/dist/auth/signup_routes.d.ts +0 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +9 -3
- package/dist/auth/standard_rpc_actions.d.ts +5 -5
- package/dist/auth/standard_rpc_actions.js +4 -4
- package/dist/db/CLAUDE.md +118 -0
- package/dist/db/cell_audit_queries.d.ts +26 -0
- package/dist/db/cell_audit_queries.d.ts.map +1 -0
- package/dist/db/cell_audit_queries.js +53 -0
- package/dist/db/cell_ddl.d.ts +151 -0
- package/dist/db/cell_ddl.d.ts.map +1 -0
- package/dist/db/cell_ddl.js +247 -0
- package/dist/db/cell_field_queries.d.ts +105 -0
- package/dist/db/cell_field_queries.d.ts.map +1 -0
- package/dist/db/cell_field_queries.js +113 -0
- package/dist/db/cell_grant_queries.d.ts +132 -0
- package/dist/db/cell_grant_queries.d.ts.map +1 -0
- package/dist/db/cell_grant_queries.js +145 -0
- package/dist/db/cell_history_ddl.d.ts +38 -0
- package/dist/db/cell_history_ddl.d.ts.map +1 -0
- package/dist/db/cell_history_ddl.js +61 -0
- package/dist/db/cell_item_queries.d.ts +107 -0
- package/dist/db/cell_item_queries.d.ts.map +1 -0
- package/dist/db/cell_item_queries.js +119 -0
- package/dist/db/cell_queries.d.ts +327 -0
- package/dist/db/cell_queries.d.ts.map +1 -0
- package/dist/db/cell_queries.js +431 -0
- package/dist/db/fact_ddl.d.ts +38 -0
- package/dist/db/fact_ddl.d.ts.map +1 -0
- package/dist/db/fact_ddl.js +71 -0
- package/dist/db/fact_queries.d.ts +140 -0
- package/dist/db/fact_queries.d.ts.map +1 -0
- package/dist/db/fact_queries.js +161 -0
- package/dist/db/fact_store.d.ts +112 -0
- package/dist/db/fact_store.d.ts.map +1 -0
- package/dist/db/fact_store.js +225 -0
- package/dist/server/app_server.d.ts +1 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +1 -5
- package/dist/server/env.d.ts +2 -0
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +6 -0
- package/dist/server/fact_write.d.ts +32 -0
- package/dist/server/fact_write.d.ts.map +1 -0
- package/dist/server/fact_write.js +56 -0
- package/dist/server/file_fact_fetcher.d.ts +42 -0
- package/dist/server/file_fact_fetcher.d.ts.map +1 -0
- package/dist/server/file_fact_fetcher.js +60 -0
- package/dist/server/file_fact_url.d.ts +53 -0
- package/dist/server/file_fact_url.d.ts.map +1 -0
- package/dist/server/file_fact_url.js +52 -0
- package/dist/server/serve_fact_route.d.ts +78 -0
- package/dist/server/serve_fact_route.d.ts.map +1 -0
- package/dist/server/serve_fact_route.js +205 -0
- package/dist/testing/CLAUDE.md +142 -6
- package/dist/testing/app_server.d.ts +46 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +67 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +67 -1
- package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
- package/dist/testing/cross_backend/account_lifecycle.js +144 -0
- package/dist/testing/cross_backend/actor_lookup.d.ts +10 -0
- package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -0
- package/dist/testing/cross_backend/actor_lookup.js +83 -0
- package/dist/testing/cross_backend/actor_search.d.ts +6 -0
- package/dist/testing/cross_backend/actor_search.d.ts.map +1 -0
- package/dist/testing/cross_backend/actor_search.js +92 -0
- package/dist/testing/cross_backend/app_settings.d.ts +6 -0
- package/dist/testing/cross_backend/app_settings.d.ts.map +1 -0
- package/dist/testing/cross_backend/app_settings.js +95 -0
- package/dist/testing/cross_backend/backend_config.d.ts +1 -1
- package/dist/testing/cross_backend/capabilities.d.ts +29 -7
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +3 -1
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
- package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_crud.js +168 -0
- package/dist/testing/cross_backend/cell_grant_role.d.ts +8 -0
- package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_grant_role.js +102 -0
- package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_relations.js +229 -0
- package/dist/testing/cross_backend/conformance_case.d.ts +144 -0
- package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -0
- package/dist/testing/cross_backend/conformance_case.js +132 -0
- package/dist/testing/cross_backend/conformance_table.d.ts +46 -0
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -0
- package/dist/testing/cross_backend/conformance_table.js +199 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -2
- package/dist/testing/cross_backend/default_spine_surface.d.ts +17 -9
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +20 -12
- package/dist/testing/cross_backend/origin.d.ts +10 -0
- package/dist/testing/cross_backend/origin.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin.js +73 -0
- package/dist/testing/cross_backend/setup.d.ts +22 -40
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +39 -5
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +90 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +91 -3
- package/dist/testing/cross_backend/xfail.d.ts +15 -0
- package/dist/testing/cross_backend/xfail.d.ts.map +1 -0
- package/dist/testing/cross_backend/xfail.js +37 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -0
- package/dist/testing/integration.d.ts +2 -3
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +20 -85
- package/dist/testing/rate_limiting.d.ts +1 -1
- package/dist/testing/rpc_helpers.d.ts +3 -3
- package/dist/testing/sse_round_trip.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +0 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +4 -0
- package/dist/ui/AdminAccounts.svelte +84 -35
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +21 -23
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +17 -26
- package/dist/ui/OpenSignupToggle.svelte +2 -5
- package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.d.ts +9 -10
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +7 -17
- package/dist/ui/admin_accounts_state.svelte.d.ts +41 -20
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +52 -22
- package/dist/ui/admin_invites_state.svelte.d.ts +8 -11
- package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_invites_state.svelte.js +7 -16
- package/dist/ui/admin_rpc_adapters.d.ts +6 -2
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +5 -1
- package/dist/ui/admin_sessions_state.svelte.d.ts +6 -10
- package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_sessions_state.svelte.js +4 -14
- package/dist/ui/app_settings_state.svelte.d.ts +8 -12
- package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
- package/dist/ui/app_settings_state.svelte.js +6 -16
- package/dist/ui/audit_log_state.svelte.d.ts +9 -8
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +8 -20
- 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);
|